PyMySQLLock
PyMySQLLock is a Python library that provides a MySQL-backed distributed locking primitive. It enables multiple application instances to coordinate and ensure that only one instance holds a specific lock at a time, performing tasks that require exclusive access. As of version 0.2.0, it aims to be a lightweight solution when MySQL is the primary dependency for application uptime and health, rather than relying on external systems like ZooKeeper or etcd. The project appears stable with a moderate release cadence.
Common errors
-
sqlalchemy.exc.InternalError: (PyMySQL.err.InternalError) (1205, 'Lock wait timeout exceeded; try restarting transaction')
cause The MySQL server's `innodb_lock_wait_timeout` has been exceeded while attempting to acquire a lock or perform a database operation that requires a lock.fixThis specific error often indicates an underlying transaction lock contention. While `PyMySQLLock` uses named locks, ensuring your application transactions are short and efficient can help. For `PyMySQLLock` itself, ensure the `timeout` parameter in `lock.acquire()` is set appropriately and consider increasing `innodb_lock_wait_timeout` on the MySQL server if application logic genuinely requires longer waits. -
RuntimeError: Could not obtain named lock my_critical_task_lock within 10 seconds
cause The `lock.acquire(timeout=X)` method returned `False` because another process or thread successfully held the named lock for the entire specified `X` duration.fixThis indicates expected contention. The application should implement a retry strategy with exponential backoff, log the failure, or inform the user that the resource is currently busy. Do not simply busy-wait, as this consumes CPU unnecessarily. -
MySQL connection not reflecting external changes (e.g., `SELECT` queries returning stale data)
cause This is a common issue with long-lived database connections, where the connection's transaction isolation level or internal caching prevents it from seeing committed changes made by other connections without explicit refresh or re-connection.fixEnsure your MySQL connection is set to an appropriate isolation level (e.g., `READ COMMITTED`). For `PyMySQLLock`, if you are reusing the underlying connection for other database operations, you might need to commit (even if no writes) or explicitly close and re-open the connection to force it to see the latest state. `Locker` manages its own connection, but application code interacting with the same DB often faces this.
Warnings
- gotcha The default `acquire()` method for a lock has a `timeout` of -1, meaning it will wait indefinitely for the lock to become available. This can lead to application processes hanging if the lock is held for a prolonged period or never released.
- gotcha PyMySQLLock uses MySQL's `GET_LOCK()` and `RELEASE_LOCK()` functions, which are tied to the specific MySQL connection that acquired them. If the application process holding a lock crashes or the underlying database connection is lost, the lock will be implicitly released by MySQL after its connection-level timeout, potentially leading to brief windows of unprotected access.
- gotcha Sharing a single MySQL database connection object across multiple threads within the same application process can lead to deadlocks, race conditions, or unexpected behavior, as most Python MySQL drivers are not thread-safe at the connection level.
Install
-
pip install PyMySQLLock
Imports
- Locker
from pymysqllock import Locker
from PyMySQLLock import Locker
Quickstart
import os
from PyMySQLLock import Locker
# --- Configuration (use environment variables for security) ---
MYSQL_HOST = os.environ.get('MYSQL_HOST', 'localhost')
MYSQL_USER = os.environ.get('MYSQL_USER', 'root')
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'password')
MYSQL_DB = os.environ.get('MYSQL_DB', 'test_db')
LOCK_NAME = 'my_critical_task_lock'
ACQUIRE_TIMEOUT = 10 # seconds
def run_task_with_lock():
locker = None
try:
# Locker uses connection parameters compatible with common MySQL drivers (e.g., PyMySQL)
locker = Locker(
host=MYSQL_HOST,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
database=MYSQL_DB
)
lock = locker.lock(LOCK_NAME)
print(f"Attempting to acquire lock '{LOCK_NAME}' with a {ACQUIRE_TIMEOUT}s timeout...")
# Try to acquire the lock. Default timeout is -1 (infinite wait).
# Setting refresh_interval_secs keeps the connection alive for long-held locks.
if lock.acquire(timeout=ACQUIRE_TIMEOUT, refresh_interval_secs=5):
print(f"Successfully acquired lock '{LOCK_NAME}'. Performing critical task...")
# Simulate work
import time
time.sleep(5)
print("Critical task completed.")
else:
print(f"Failed to acquire lock '{LOCK_NAME}' within {ACQUIRE_TIMEOUT} seconds. Another instance might hold it.")
except Exception as e:
print(f"An error occurred: {e}")
finally:
if 'lock' in locals() and lock.is_acquired():
print(f"Releasing lock '{LOCK_NAME}'.")
lock.release()
if locker:
locker.close_connection()
print("MySQL connection closed.")
if __name__ == "__main__":
print("Ensure MySQL server is running and database exists.")
print("Set MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB environment variables if not using defaults.")
run_task_with_lock()