Skip to content

Distributed Lock

Nene\Kit\DistributedLock provides a DB-backed mutual exclusion lock for coordinating work across multiple processes or instances.

When to use

  • Preventing duplicate execution of scheduled jobs (cron, batch)
  • Deduplication windows for idempotent operations
  • Single-writer enforcement across multiple app instances

For high-contention hot paths (many requests per second competing for the same lock), prefer Redis SETNX; the DB-backed approach is well-suited to low-to-medium contention.

Schema

CREATE TABLE distributed_locks (
    resource    VARCHAR(255) PRIMARY KEY,
    owner       VARCHAR(255) NOT NULL,
    expires_at  DATETIME     NOT NULL,
    acquired_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
);

API

Method Returns Description
acquire(resource, owner, ttlSeconds) bool Try to acquire the lock. Returns true on success, false if held by another owner.
release(resource, owner) bool Release the lock. Returns false if not owned by $owner.
renew(resource, owner, ttlSeconds) bool Extend the TTL. Returns false if expired, not found, or wrong owner.
isHeld(resource) bool Check whether any unexpired lock exists on $resource.

Basic usage

use Nene\Kit\DistributedLock;

$lock  = new DistributedLock();
$owner = bin2hex(random_bytes(8)); // unique per process/request

if ($lock->acquire('report:monthly', $owner, ttlSeconds: 30)) {
    try {
        // ... do the exclusive work ...
    } finally {
        $lock->release('report:monthly', $owner);
    }
} else {
    // Another process holds the lock — skip or retry later
}

Stale lock reclaim

If a process crashes while holding a lock, the TTL ensures the lock expires and the next caller can claim it automatically — no manual cleanup needed.

// Stale lock left by crashed process:
// owner='worker-A', expires_at='2024-01-01 00:00:00' (past)

$lock->acquire('job:import', 'worker-B', 30);
// → true (expired lock claimed by worker-B)

Renewing long-running jobs

For jobs that run longer than the initial TTL, renew periodically:

$lock->acquire('job:heavy', $owner, 60);

foreach ($items as $item) {
    process($item);
    $lock->renew('job:heavy', $owner, 60); // reset TTL each iteration
}

$lock->release('job:heavy', $owner);

Owner identity

Use a per-process or per-request unique string — never a fixed hostname:

// Bad: same value after restart
$owner = gethostname();

// Good: unique per execution
$owner = bin2hex(random_bytes(8));   // 16-char hex
$owner = \Ramsey\Uuid\Uuid::uuid4()->toString(); // UUIDv4

A fixed hostname like worker-1 becomes the same after restart, allowing a new process to accidentally reclaim a lock held by a crashed sibling with the same name.

Error semantics

Scenario release() renew()
Lock found, correct owner true true
Lock found, wrong owner false false
Lock expired false (DELETE matched 0 rows) false (UPDATE matched 0 rows)
Lock not found false false

Testing without sleep()

Inject a PDO instance backed by SQLite in-memory and insert rows with crafted expires_at values to simulate expiry without sleeping:

$db = new PDO('sqlite::memory:');
// ... create table ...
$db->exec("INSERT INTO distributed_locks (resource, owner, expires_at, acquired_at)
           VALUES ('job:x', 'owner-A', '2000-01-01 00:00:00', '2000-01-01 00:00:00')");

$lock = new DistributedLock($db);
$result = $lock->acquire('job:x', 'owner-B', 30); // stale reclaim → true