Skip to content

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.

  • name string (required) Unique name for the lock. Supports #{param} interpolation to bind method parameters into the lock name (e.g. "order-#{id}").

  • lockManager string (default: the framework’s local manager) Bean name of your LockManager bean (e.g. 'dbLockManager'), or the class name of a LockManager bean. Omit to use local node locking.

  • waitMilliSecs int (default: 0) How long (in milliseconds) to wait for the lock before throwing LockException. 0 means fail immediately if the lock is not available. Inside a coroutine, only the waiting coroutine pauses; the worker keeps serving other requests.

  • ttlSeconds int (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. 0 means no expiry. Local locks ignore it: the OS releases them when the holder dies.

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.

OrderService.php
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.
}

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.

LockConfig.php
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;.

Then name the bean on the methods to protect:

OrderService.php
#[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.
}
  • 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_name primary key, owner, expires_at in 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 Doctrine Connection that 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 NX plus PX for the TTL). Each operation is a single Lua script, so it is atomic. Use a single, Sentinel or Cluster template, not PhpRedisArrayTemplate.
  • 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 waitMilliSecs lasts, the managers retry every 50 ms (constructor argument pollMs).

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.

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.

To lock in another store, implement dev\winterframework\util\concurrent\LockStore (three atomic operations) and wrap it in a StoreLockManager:

EtcdLockStore.php
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(/* ... */));
}

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.

OrderController.php
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.