Prevent Race Conditions with Winter Boot Distributed Locking
Annotate a public bean method with #[Lockable] and only one caller can run it at a time: on the local node by default, or across a whole cluster with a distributed LockManager (database, Doctrine or Redis, all built in). Placeholders such as name: "order-#{id}" narrow the lock to one resource, so calls for unrelated resources still run in parallel.
#[Lockable] Options
Section titled “#[Lockable] Options”-
namestring(required) Unique name for the lock. Supports#{param}interpolation to bind method parameters into the lock name (e.g."order-#{id}"). -
lockManagerstring(default: the framework’s local manager) Bean name of yourLockManagerbean (e.g.'dbLockManager'), or the class name of aLockManagerbean. Omit to use local node locking. -
waitMilliSecsint(default:0) How long (in milliseconds) to wait for the lock before throwingLockException.0means fail immediately if the lock is not available. Inside a coroutine, only the waiting coroutine pauses; the worker keeps serving other requests. -
ttlSecondsint(default:0) For distributed locks, how long (in seconds) the lock stays held before it expires on its own, for example when the holding pod crashed.0means no expiry. Local locks ignore it: the OS releases them when the holder dies.
Local Node Locking
Section titled “Local Node Locking”Local node locking works out of the box — no configuration or bean needed. Only one caller on the node can run the method at a time, whether the others are different processes or concurrent requests in the same worker. It uses flock on a file in the system temp directory, which the OS releases automatically if the holder dies. The #{id} syntax binds the method parameter $id into the lock name, so two concurrent calls with different order IDs proceed in parallel while two calls for the same ID are serialized.
use dev\winterframework\stereotype\concurrent\Lockable;
#[Lockable(name: 'order-#{id}')]public function updateOrderStatus(int $id): void{ // Only one process per $id can run this at a time on this node.}use dev\winterframework\stereotype\concurrent\Lockable;
// Wait up to 3 seconds for the lock; TTL of 30 seconds prevents deadlocks.#[Lockable(name: 'order-#{id}', waitMilliSecs: 3000, ttlSeconds: 30)]public function updateOrderStatus(int $id): void{ // Waits up to 3 s to acquire the lock, then throws LockException.}Distributed Locking
Section titled “Distributed Locking”When several pods or servers run the same application, a local lock only protects one of them. A distributed LockManager keeps locks in a store all of them share. Register one as a bean, name it in lockManager, and #[Lockable] works exactly as before.
Winter Boot ships three:
| Manager | Store | Where |
|---|---|---|
PdoLockManager |
a table in your database (PostgreSQL, MySQL/MariaDB, SQLite) | Winter Boot core |
DbalLockManager |
a table, through Doctrine DBAL (any database DBAL supports) | winter-doctrine |
RedisLockManager |
one Redis key per lock | winter-data-redis (Winter Modules) |
All three need Winter Boot 2.1.6 or later.
use dev\winterframework\pdbc\DataSource;use dev\winterframework\pdbc\lock\PdoLockManager;use dev\winterframework\stereotype\Bean;use dev\winterframework\stereotype\Configuration;use dev\winterframework\util\concurrent\LockManager;
#[Configuration]class LockConfig{ #[Bean('dbLockManager')] public function dbLockManager(DataSource $dataSource): LockManager { return new PdoLockManager($dataSource); // table "winter_locks", created on first use }}DataSource is the primary data source. For another one, inject it by name: #[Autowired('admindb')] private DataSource $adminDs;.
use dev\winterframework\doctrine\lock\DbalLockManager;use dev\winterframework\stereotype\Autowired;use dev\winterframework\stereotype\Bean;use dev\winterframework\stereotype\Configuration;use dev\winterframework\util\concurrent\LockManager;use Doctrine\DBAL\Connection;
#[Configuration]class LockConfig{ #[Autowired('admindb-doctrine-dbal')] private Connection $adminDbal;
#[Bean('dbLockManager')] public function dbLockManager(): LockManager { return new DbalLockManager($this->adminDbal); }}use dev\winterframework\data\redis\lock\RedisLockManager;use dev\winterframework\data\redis\phpredis\PhpRedisTemplate;use dev\winterframework\stereotype\Bean;use dev\winterframework\stereotype\Configuration;use dev\winterframework\util\concurrent\LockManager;
#[Configuration]class LockConfig{ #[Bean('redisLockManager')] public function redisLockManager(PhpRedisTemplate $redis): LockManager { return new RedisLockManager($redis); // keys "winter:lock:<name>" }}Then name the bean on the methods to protect:
#[Lockable(name: 'order-#{id}', ttlSeconds: 30, waitMilliSecs: 2000, lockManager: 'dbLockManager')]public function settle(int $id): void{ // Runs on one pod at a time for each order id.}How the built-in managers behave
Section titled “How the built-in managers behave”- Every caller gets its own lock handle with a random owner token, so two coroutines, workers or pods never both hold a name, and only the holder can release or extend it.
- Database locks are rows in
winter_locks(lock_nameprimary key,owner,expires_atin epoch milliseconds,0= no expiry). Names longer than 191 bytes are stored as a hash. The PDO and DBAL managers use the same layout, so they can share the table. - Database locks commit at once, outside your transaction. Each lock statement runs on its own pooled connection, so a lock taken inside a
#[Transactional]method is visible to other pods immediately, and rolling back your transaction doesn’t undo it. A plain DoctrineConnectionthat is inside a transaction is refused instead, because the lock would stay invisible until that transaction commits. - Redis locks are one key per lock (
SET NXplusPXfor the TTL). Each operation is a single Lua script, so it is atomic. Use a single, Sentinel or Cluster template, notPhpRedisArrayTemplate. - Expiry: Redis expires locks with its own clock. The database managers compare times from the application servers, so keep their clocks in sync (NTP).
- Unreachable store: taking a lock throws, and the method doesn’t run unprotected.
- Waiting: while
waitMilliSecslasts, the managers retry every 50 ms (constructor argumentpollMs).
Constructor arguments:
new PdoLockManager(DataSource $dataSource, string $table = 'winter_locks', bool $createTable = true, int $pollMs = 50);new DbalLockManager(Connection $connection, string $table = 'winter_locks', bool $createTable = true, int $pollMs = 50);new RedisLockManager(object $redis, string $prefix = 'winter:lock:', int $pollMs = 50);PdoLockManager creates its table with CREATE TABLE IF NOT EXISTS, which PostgreSQL, MySQL/MariaDB and SQLite support. For another database, create the table yourself (the SQL is returned by getCreateTableSql() on PdoLockStore) and pass createTable: false. DbalLockManager creates it with your platform’s own types, on any database.
Table DDL for winter_locks (SQLite, PostgreSQL, MySQL/MariaDB, SQL Server, Oracle)
Create the table yourself when the application’s database user may not create tables, then pass createTable: false. One table can serve the PDO and the DBAL manager. If you pass another $table name, use it here too.
SQLite
CREATE TABLE IF NOT EXISTS winter_locks ( lock_name VARCHAR(191) NOT NULL PRIMARY KEY, owner VARCHAR(64) NOT NULL, expires_at BIGINT NOT NULL);PostgreSQL
CREATE TABLE IF NOT EXISTS winter_locks ( lock_name VARCHAR(191) NOT NULL PRIMARY KEY, owner VARCHAR(64) NOT NULL, expires_at BIGINT NOT NULL);MySQL / MariaDB
CREATE TABLE IF NOT EXISTS winter_locks ( lock_name VARCHAR(191) NOT NULL PRIMARY KEY, owner VARCHAR(64) NOT NULL, expires_at BIGINT NOT NULL) ENGINE = InnoDB;VARCHAR(191) keeps the primary key within InnoDB’s index limit with utf8mb4. Longer lock names are stored as a SHA-256 hash, so they still fit.
SQL Server (DbalLockManager only)
CREATE TABLE winter_locks ( lock_name NVARCHAR(191) NOT NULL, owner NVARCHAR(64) NOT NULL, expires_at BIGINT NOT NULL, PRIMARY KEY (lock_name));Oracle (DbalLockManager only)
CREATE TABLE winter_locks ( lock_name VARCHAR2(191) NOT NULL, owner VARCHAR2(64) NOT NULL, expires_at NUMBER(20) NOT NULL, PRIMARY KEY (lock_name));Columns: lock_name is the lock’s name, owner the random token of the handle holding it, and expires_at the expiry time in epoch milliseconds (0 = no expiry). A row exists only while its lock is held or until it expires, so the table stays small; rows of expired locks are reused when the name is locked again.
Extending a lock during long work
Section titled “Extending a lock during long work”ttlSeconds should cover the method’s normal run time. For longer jobs, extend the lease while you work: $lockManager->updateLock($name, 30) restarts the 30-second TTL. If the lease was already lost (it expired and another pod took the lock), update() on the lock throws LockException, so the job can stop instead of continuing unprotected.
Your own store
Section titled “Your own store”To lock in another store, implement dev\winterframework\util\concurrent\LockStore (three atomic operations) and wrap it in a StoreLockManager:
use dev\winterframework\util\concurrent\LockStore;
class EtcdLockStore implements LockStore{ // Take the lease if it is free or expired. $ttlMs = 0 means no expiry. public function acquire(string $name, string $owner, int $ttlMs): bool { /* ... */ }
// End the lease, only if $owner holds it. public function release(string $name, string $owner): bool { /* ... */ }
// Restart the TTL, only if $owner holds it. public function refresh(string $name, string $owner, int $ttlMs): bool { /* ... */ }}
// In a #[Configuration] class:#[Bean('etcdLockManager')]public function etcdLockManager(): LockManager{ return new StoreLockManager(new EtcdLockStore(/* ... */));}Handling LockException
Section titled “Handling LockException”If the lock cannot be acquired within waitMilliSecs milliseconds, Winter Boot throws a LockException. Catch it in your controller or service layer and return an appropriate response to the caller.
use dev\winterframework\util\concurrent\LockException;use dev\winterframework\web\http\HttpStatus;use dev\winterframework\web\http\ResponseEntity;
try { $this->orderService->updateOrderStatus($id);} catch (LockException $e) { // Lock could not be acquired — tell the client to retry return ResponseEntity::status(HttpStatus::$CONFLICT) ->withJson(['message' => 'Order is currently being updated. Please retry.']);}Common LockException scenarios
| Scenario | Recommended response |
|---|---|
| High-throughput writes to the same resource | Return HTTP 409 Conflict and ask the client to retry with exponential back-off. |
| Background job overlap prevention | Log and skip the current execution; the next scheduled run will proceed normally. |
Deadlock prevention with ttlSeconds |
The lock auto-releases after the TTL; the next caller acquires it cleanly. |