Skip to main content

Compare and Swap

Compare-and-swap means: read the current state, compare it against what you expect, and only write when it matches — all resolved by the storage engine in a single indivisible step.

The reason this needs its own interface is that the obvious way of writing it is wrong:

<?php
// WRONG - two separate operations
if (!$cache->has('lock')) { // <- another process can write here
$cache->set('lock', $token, 30);
}

Between the has() and the set() there is a gap. Two processes can both find the key absent and both write, and both walk away believing they own it. No amount of re-reading afterwards closes that gap. Compare-and-swap removes it by never splitting the check from the write.

The engines that support it implement CompareAndSwapInterface.

Engines that support compare and swap:

  • RedisCacheEngine
  • MemcachedEngine

FileSystemCacheEngine does not implement this interface. See the note at the bottom.

setIfAbsent​

Stores the value only if the key currently holds nothing. Exactly one of any number of concurrent callers gets true. A key whose TTL has already elapsed counts as absent.

<?php
/** @var \ByJG\Cache\CompareAndSwapInterface $cache */
$token = bin2hex(random_bytes(16));

if ($cache->setIfAbsent('my-lock', $token, 30)) {
// This process, and only this process, owns 'my-lock' for the next 30 seconds
}

The TTL is applied in the same step as the write. This matters: an engine that wrote the value first and set the expiry afterwards would leave a key with no expiry at all if the process died in between — a lock nobody can ever release.

deleteIfEquals​

Deletes the key only if it still holds exactly the value you pass.

<?php
/** @var \ByJG\Cache\CompareAndSwapInterface $cache */
$cache->deleteIfEquals('my-lock', $token);

Use this instead of delete() whenever the key might have changed hands. If your TTL quietly expired while you were still working, another process may already hold the key — a plain delete() would destroy their lock. deleteIfEquals() refuses and returns false.

expireIfEquals​

Rewrites the TTL, but only while the key still holds your value. This lets a long-running owner keep its claim alive without any risk of extending a claim that has already passed to someone else.

<?php
/** @var \ByJG\Cache\CompareAndSwapInterface $cache */
while ($stillWorking) {
// ... do a chunk of work ...
if (!$cache->expireIfEquals('my-lock', $token, 30)) {
// We lost the lock. Stop - somebody else is doing this work now.
break;
}
}

Passing null as the TTL removes the expiry, making the key permanent.

Putting it together​

<?php
/** @var \ByJG\Cache\CompareAndSwapInterface $cache */
$token = bin2hex(random_bytes(16));

if (!$cache->setIfAbsent('rebuild-report', $token, 60)) {
return; // Another worker already has it
}

try {
rebuildTheReport();
} finally {
$cache->deleteIfEquals('rebuild-report', $token);
}

The random token is what makes the release safe. Without it there is no way to tell your own lock apart from the one a later process took over after your TTL lapsed.

Detecting support​

Not every engine can offer these guarantees. Probe before relying on them, and degrade explicitly rather than silently:

<?php
if (!$cache instanceof \ByJG\Cache\CompareAndSwapInterface) {
throw new RuntimeException('This engine cannot guarantee mutual exclusion');
}

What this does not give you​

Compare-and-swap makes acquisition correct. It does not turn the cache into a complete lock manager. There is no fairness (a starved process can lose every round), no reentrancy, and the TTL is still a guess: a slow owner can have its lock expire while it is still working. deleteIfEquals will correctly refuse that owner's release, but the work has already run twice. If you need guarantees beyond "exactly one winner per acquisition", you want a real lock manager.

Why not FileSystemCacheEngine​

The file system is not a reliable substrate for mutual exclusion, and this engine does not pretend otherwise. Use Redis or Memcached.

flock() attaches to an inode, not to a path. Because this engine deletes a cache file when the entry is removed, a process can end up holding a lock on a file that has already been unlinked while another process creates and locks a fresh file at the same path — two processes holding "the lock" on two different inodes, both proceeding. On network file systems flock() is weaker still, and on some NFS configurations it is advisory in name only.

increment, decrement and add remain available here through AtomicOperationInterface, and are safe against other callers going through the same engine on the same host. That is a narrower guarantee than compare-and-swap, and it is not a lock.