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);