From 21edb778e11d2bd51851688dd7ed8198bb9b320a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 5 Aug 2026 20:13:22 +1200 Subject: [PATCH] feat(distributed): expose the lease token so a write can be conditional on it refresh() proves ownership at the instant it returns, which is the wrong instant for a caller whose write commits later. Handing back the value on the key lets that caller store the token with whatever it recorded under the lease and refuse a stale write whose token no longer matches, and lets an operator compare a record against the live holder directly. Co-Authored-By: Claude Fable 5 --- src/Distributed.php | 20 +++++++++++++++ tests/DistributedTest.php | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/Distributed.php b/src/Distributed.php index 74de2e7..bc0a791 100644 --- a/src/Distributed.php +++ b/src/Distributed.php @@ -139,6 +139,26 @@ public function isHeld(): bool return $this->redis->get($this->key) === $this->token; } + /** + * The value this lock wrote to the key, or null when it holds no lease. + * + * A caller that records what it did while holding the lease can store this + * alongside the record and refuse a later write whose token no longer matches, + * which is the only way to make the write itself conditional on the lease: + * {@see refresh()} proves ownership at the instant it returns, not at the + * instant the write commits. + * + * It is also the value an operator reads back from the key, so a record and + * the live holder can be compared directly. + * + * A new value is generated on every acquisition, so a lease that lapsed and + * was retaken by this same instance does not compare equal to the one before. + */ + public function token(): ?string + { + return $this->token; + } + /** * @param array $arguments */ diff --git a/tests/DistributedTest.php b/tests/DistributedTest.php index af8e3d4..36f9f58 100644 --- a/tests/DistributedTest.php +++ b/tests/DistributedTest.php @@ -121,6 +121,57 @@ public function testRefreshExtendsTtl(): void $lock->release(); } + public function testTokenIsTheValueOnTheKeyAndIsClearedOnRelease(): void + { + $lock = new Distributed($this->redis, $this->key, 30); + + $this->assertTrue($lock->tryAcquire()); + $token = $lock->token(); + + $this->assertSame( + $this->redis->get($this->key), + $token, + 'the token must be the value on the key, so a record naming it can be compared against the live holder' + ); + + $lock->release(); + $this->assertNull($lock->token()); + } + + public function testEachAcquisitionMintsItsOwnToken(): void + { + $lock = new Distributed($this->redis, $this->key, 30); + + $this->assertTrue($lock->tryAcquire()); + $first = $lock->token(); + $lock->release(); + + $this->assertTrue($lock->tryAcquire()); + $second = $lock->token(); + $lock->release(); + + $this->assertNotSame( + $first, + $second, + 'a token identifies one acquisition, so work recorded under a lapsed lease cannot pass for work under its successor' + ); + } + + public function testTokenIsNotIssuedForAFailedAcquire(): void + { + $holder = new Distributed($this->redis, $this->key, 30); + $waiter = new Distributed($this->redis, $this->key, 30); + + $this->assertNull($waiter->token(), 'a lock holding no lease has no token to hand out'); + $this->assertTrue($holder->tryAcquire()); + $this->assertFalse($waiter->tryAcquire()); + + $this->assertNull($waiter->token(), 'a lock that never took the key must not name itself the holder'); + $this->assertSame($holder->token(), $this->redis->get($this->key)); + + $holder->release(); + } + public function testLoggerReceivesMessages(): void { $holder = new Distributed($this->redis, $this->key, 30);