diff --git a/src/cache/src/ArrayLock.php b/src/cache/src/ArrayLock.php index 46491eab4..e6958aa26 100644 --- a/src/cache/src/ArrayLock.php +++ b/src/cache/src/ArrayLock.php @@ -5,8 +5,10 @@ namespace Hypervel\Cache; use Carbon\Carbon; +use Hypervel\Cache\Contracts\RefreshableLock; +use InvalidArgumentException; -class ArrayLock extends Lock +class ArrayLock extends Lock implements RefreshableLock { /** * The parent array cache store. @@ -83,4 +85,59 @@ protected function getCurrentOwner(): string { return $this->store->locks[$this->name]['owner']; } + + /** + * Refresh the lock's TTL if still owned by this process. + * + * @throws InvalidArgumentException If an explicit non-positive TTL is provided + */ + public function refresh(?int $seconds = null): bool + { + // Permanent lock with no explicit TTL requested - nothing to refresh + if ($seconds === null && $this->seconds <= 0) { + return true; + } + + $seconds ??= $this->seconds; + + if ($seconds <= 0) { + throw new InvalidArgumentException( + 'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.' + ); + } + + if (! $this->exists()) { + return false; + } + + if (! $this->isOwnedByCurrentProcess()) { + return false; + } + + $this->store->locks[$this->name]['expiresAt'] = Carbon::now()->addSeconds($seconds); + + return true; + } + + /** + * Get the number of seconds until the lock expires. + */ + public function getRemainingLifetime(): ?float + { + if (! $this->exists()) { + return null; + } + + $expiresAt = $this->store->locks[$this->name]['expiresAt']; + + if ($expiresAt === null) { + return null; + } + + if ($expiresAt->isPast()) { + return null; + } + + return (float) Carbon::now()->diffInSeconds($expiresAt); + } } diff --git a/src/cache/src/Contracts/RefreshableLock.php b/src/cache/src/Contracts/RefreshableLock.php new file mode 100644 index 000000000..fe3ae3c98 --- /dev/null +++ b/src/cache/src/Contracts/RefreshableLock.php @@ -0,0 +1,41 @@ +currentTime() + $lockTimeout; } + + /** + * Refresh the lock's TTL if still owned by this process. + * + * @throws InvalidArgumentException If an explicit non-positive TTL is provided + */ + public function refresh(?int $seconds = null): bool + { + // Permanent lock with no explicit TTL requested - nothing to refresh + if ($seconds === null && $this->seconds <= 0) { + return true; + } + + $seconds ??= $this->seconds; + + if ($seconds <= 0) { + throw new InvalidArgumentException( + 'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.' + ); + } + + $updated = $this->connection()->table($this->table) + ->where('key', $this->name) + ->where('owner', $this->owner) + ->update([ + 'expiration' => $this->currentTime() + $seconds, + ]); + + return $updated >= 1; + } + + /** + * Get the number of seconds until the lock expires. + */ + public function getRemainingLifetime(): ?float + { + $lock = $this->connection()->table($this->table) + ->where('key', $this->name) + ->first(); + + if ($lock === null) { + return null; + } + + $remaining = $lock->expiration - $this->currentTime(); + + if ($remaining <= 0) { + return null; + } + + return (float) $remaining; + } } diff --git a/src/cache/src/LuaScripts.php b/src/cache/src/LuaScripts.php index fd936af75..05c9f66bf 100644 --- a/src/cache/src/LuaScripts.php +++ b/src/cache/src/LuaScripts.php @@ -11,10 +11,8 @@ class LuaScripts * * KEYS[1] - The name of the lock * ARGV[1] - The owner key of the lock instance trying to release it - * - * @return string */ - public static function releaseLock() + public static function releaseLock(): string { return <<<'LUA' if redis.call("get",KEYS[1]) == ARGV[1] then @@ -22,6 +20,24 @@ public static function releaseLock() else return 0 end +LUA; + } + + /** + * Get the Lua script to atomically refresh a lock's TTL. + * + * KEYS[1] - The name of the lock + * ARGV[1] - The owner key of the lock instance trying to refresh it + * ARGV[2] - The new TTL in seconds + */ + public static function refreshLock(): string + { + return <<<'LUA' +if redis.call("get",KEYS[1]) == ARGV[1] then + return redis.call("expire",KEYS[1],ARGV[2]) +else + return 0 +end LUA; } } diff --git a/src/cache/src/NoLock.php b/src/cache/src/NoLock.php index da6c2d9a0..769d25cfd 100644 --- a/src/cache/src/NoLock.php +++ b/src/cache/src/NoLock.php @@ -4,7 +4,10 @@ namespace Hypervel\Cache; -class NoLock extends Lock +use Hypervel\Cache\Contracts\RefreshableLock; +use InvalidArgumentException; + +class NoLock extends Lock implements RefreshableLock { /** * Attempt to acquire the lock. @@ -36,4 +39,35 @@ protected function getCurrentOwner(): string { return $this->owner; } + + /** + * Refresh the lock's TTL if still owned by this process. + * + * @throws InvalidArgumentException If an explicit non-positive TTL is provided + */ + public function refresh(?int $seconds = null): bool + { + // Permanent lock with no explicit TTL requested - nothing to refresh + if ($seconds === null && $this->seconds <= 0) { + return true; + } + + $seconds ??= $this->seconds; + + if ($seconds <= 0) { + throw new InvalidArgumentException( + 'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.' + ); + } + + return true; + } + + /** + * Get the number of seconds until the lock expires. + */ + public function getRemainingLifetime(): ?float + { + return null; + } } diff --git a/src/cache/src/RedisLock.php b/src/cache/src/RedisLock.php index ba286fd29..d304b3093 100644 --- a/src/cache/src/RedisLock.php +++ b/src/cache/src/RedisLock.php @@ -5,8 +5,10 @@ namespace Hypervel\Cache; use Hyperf\Redis\Redis; +use Hypervel\Cache\Contracts\RefreshableLock; +use InvalidArgumentException; -class RedisLock extends Lock +class RedisLock extends Lock implements RefreshableLock { /** * The Redis factory implementation. @@ -31,6 +33,7 @@ public function acquire(): bool if ($this->seconds > 0) { return $this->redis->set($this->name, $this->owner, ['EX' => $this->seconds, 'NX']) == true; } + return $this->redis->setnx($this->name, $this->owner) == true; } @@ -57,4 +60,42 @@ protected function getCurrentOwner(): string { return $this->redis->get($this->name); } + + /** + * Refresh the lock's TTL if still owned by this process. + * + * @throws InvalidArgumentException If an explicit non-positive TTL is provided + */ + public function refresh(?int $seconds = null): bool + { + // Permanent lock with no explicit TTL requested - nothing to refresh + if ($seconds === null && $this->seconds <= 0) { + return true; + } + + $seconds ??= $this->seconds; + + if ($seconds <= 0) { + throw new InvalidArgumentException( + 'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.' + ); + } + + return (bool) $this->redis->eval(LuaScripts::refreshLock(), [$this->name, $this->owner, $seconds], 1); + } + + /** + * Get the number of seconds until the lock expires. + */ + public function getRemainingLifetime(): ?float + { + $ttl = $this->redis->ttl($this->name); + + // -2 = key doesn't exist, -1 = key has no expiry + if ($ttl < 0) { + return null; + } + + return (float) $ttl; + } } diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php index 352088111..e170a5249 100644 --- a/tests/Cache/CacheArrayStoreTest.php +++ b/tests/Cache/CacheArrayStoreTest.php @@ -6,7 +6,9 @@ use Carbon\Carbon; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\Contracts\RefreshableLock; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use stdClass; /** @@ -292,4 +294,159 @@ public function testReleasingLockAfterAlreadyForceReleasedByAnotherOwnerFails() $this->assertFalse($wannabeOwner->release()); } + + public function testLockImplementsRefreshableLock() + { + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + + $this->assertInstanceOf(RefreshableLock::class, $lock); + } + + public function testRefreshExtendsLockExpiration() + { + Carbon::setTestNow(Carbon::now()); + + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + $lock->acquire(); + + Carbon::setTestNow(Carbon::now()->addSeconds(5)); + + $this->assertTrue($lock->refresh()); + + // Lock should now expire 10 seconds from now, not 5 + Carbon::setTestNow(Carbon::now()->addSeconds(9)); + $this->assertFalse($store->lock('foo', 10)->acquire()); + + Carbon::setTestNow(Carbon::now()->addSeconds(2)); + $this->assertTrue($store->lock('foo', 10)->acquire()); + } + + public function testRefreshWithCustomTtl() + { + Carbon::setTestNow(Carbon::now()); + + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + $lock->acquire(); + + $this->assertTrue($lock->refresh(30)); + + // Lock should now expire 30 seconds from now + Carbon::setTestNow(Carbon::now()->addSeconds(29)); + $this->assertFalse($store->lock('foo', 10)->acquire()); + + Carbon::setTestNow(Carbon::now()->addSeconds(2)); + $this->assertTrue($store->lock('foo', 10)->acquire()); + } + + public function testRefreshReturnsFalseWhenLockDoesNotExist() + { + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + + $this->assertFalse($lock->refresh()); + } + + public function testRefreshReturnsFalseWhenNotOwned() + { + $store = new ArrayStore(); + $owner = $store->lock('foo', 10); + $wannabeOwner = $store->lock('foo', 10); + $owner->acquire(); + + $this->assertFalse($wannabeOwner->refresh()); + } + + public function testRefreshOnPermanentLockReturnsTrue() + { + $store = new ArrayStore(); + $lock = $store->lock('foo', 0); + $lock->acquire(); + + // No-op for permanent locks + $this->assertTrue($lock->refresh()); + } + + public function testRefreshWithExplicitZeroThrowsException() + { + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + $lock->acquire(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(0); + } + + public function testRefreshWithNegativeSecondsThrowsException() + { + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + $lock->acquire(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(-5); + } + + public function testRefreshWithInvalidTtlThrowsEvenWhenNotOwned() + { + $store = new ArrayStore(); + $owner = $store->lock('foo', 10); + $wannabeOwner = $store->lock('foo', 10); + $owner->acquire(); + + // Invalid parameters should throw regardless of ownership + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $wannabeOwner->refresh(0); + } + + public function testGetRemainingLifetimeReturnsSeconds() + { + Carbon::setTestNow(Carbon::now()); + + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + $lock->acquire(); + + $this->assertSame(10.0, $lock->getRemainingLifetime()); + + Carbon::setTestNow(Carbon::now()->addSeconds(3)); + $this->assertSame(7.0, $lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsNullWhenLockDoesNotExist() + { + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + + $this->assertNull($lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsNullForInfiniteLock() + { + $store = new ArrayStore(); + $lock = $store->lock('foo'); + $lock->acquire(); + + $this->assertNull($lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsNullWhenExpired() + { + Carbon::setTestNow(Carbon::now()); + + $store = new ArrayStore(); + $lock = $store->lock('foo', 10); + $lock->acquire(); + + Carbon::setTestNow(Carbon::now()->addSeconds(15)); + $this->assertNull($lock->getRemainingLifetime()); + } } diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index ba526c2ed..481db45a5 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -10,8 +10,10 @@ use Hyperf\Database\ConnectionResolverInterface; use Hyperf\Database\Exception\QueryException; use Hyperf\Database\Query\Builder; +use Hypervel\Cache\Contracts\RefreshableLock; use Hypervel\Cache\DatabaseLock; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; /** @@ -164,6 +166,125 @@ public function testLockWithDefaultTimeout() $this->assertTrue($lock->acquire()); } + public function testLockImplementsRefreshableLock() + { + [$lock] = $this->getLock(); + + $this->assertInstanceOf(RefreshableLock::class, $lock); + } + + public function testRefreshExtendsLockExpiration() + { + Carbon::setTestNow($now = Carbon::now()); + + [$lock, $table] = $this->getLock(); + $owner = $lock->owner(); + + $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); + $table->shouldReceive('where')->once()->with('owner', $owner)->andReturn($table); + $table->shouldReceive('update')->once()->with(m::on(function ($arg) use ($now) { + return is_array($arg) + && $arg['expiration'] === $now->getTimestamp() + 10; + }))->andReturn(1); + + $this->assertTrue($lock->refresh()); + } + + public function testRefreshWithCustomTtl() + { + Carbon::setTestNow($now = Carbon::now()); + + [$lock, $table] = $this->getLock(); + $owner = $lock->owner(); + + $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); + $table->shouldReceive('where')->once()->with('owner', $owner)->andReturn($table); + $table->shouldReceive('update')->once()->with(m::on(function ($arg) use ($now) { + return is_array($arg) + && $arg['expiration'] === $now->getTimestamp() + 30; + }))->andReturn(1); + + $this->assertTrue($lock->refresh(30)); + } + + public function testRefreshReturnsFalseWhenNotOwned() + { + [$lock, $table] = $this->getLock(); + $owner = $lock->owner(); + + $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); + $table->shouldReceive('where')->once()->with('owner', $owner)->andReturn($table); + $table->shouldReceive('update')->once()->andReturn(0); + + $this->assertFalse($lock->refresh()); + } + + public function testRefreshOnPermanentLockReturnsTrue() + { + [$lock] = $this->getLock(seconds: 0); + + // No database call should be made - it's a no-op for permanent locks + $this->assertTrue($lock->refresh()); + } + + public function testRefreshWithExplicitZeroThrowsException() + { + [$lock] = $this->getLock(seconds: 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(0); + } + + public function testRefreshWithNegativeSecondsThrowsException() + { + [$lock] = $this->getLock(seconds: 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(-5); + } + + public function testGetRemainingLifetimeReturnsSeconds() + { + Carbon::setTestNow($now = Carbon::now()); + + [$lock, $table] = $this->getLock(); + + $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); + $table->shouldReceive('first')->once()->andReturn((object) [ + 'expiration' => $now->getTimestamp() + 5, + ]); + + $this->assertSame(5.0, $lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsNullWhenLockDoesNotExist() + { + [$lock, $table] = $this->getLock(); + + $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); + $table->shouldReceive('first')->once()->andReturn(null); + + $this->assertNull($lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsNullWhenExpired() + { + Carbon::setTestNow($now = Carbon::now()); + + [$lock, $table] = $this->getLock(); + + $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); + $table->shouldReceive('first')->once()->andReturn((object) [ + 'expiration' => $now->getTimestamp() - 1, // Already expired + ]); + + $this->assertNull($lock->getRemainingLifetime()); + } + /** * Get a DatabaseLock instance with mocked dependencies. */ diff --git a/tests/Cache/CacheNoLockTest.php b/tests/Cache/CacheNoLockTest.php new file mode 100644 index 000000000..c205e72eb --- /dev/null +++ b/tests/Cache/CacheNoLockTest.php @@ -0,0 +1,96 @@ +assertInstanceOf(RefreshableLock::class, $lock); + } + + public function testAcquireAlwaysReturnsTrue() + { + $lock = new NoLock('foo', 10); + + $this->assertTrue($lock->acquire()); + $this->assertTrue($lock->acquire()); + } + + public function testReleaseAlwaysReturnsTrue() + { + $lock = new NoLock('foo', 10); + + $this->assertTrue($lock->release()); + } + + public function testRefreshReturnsTrue() + { + $lock = new NoLock('foo', 10); + + $this->assertTrue($lock->refresh()); + $this->assertTrue($lock->refresh(30)); + } + + public function testRefreshOnPermanentLockReturnsTrue() + { + $lock = new NoLock('foo', 0); + + $this->assertTrue($lock->refresh()); + } + + public function testRefreshWithExplicitZeroThrowsException() + { + $lock = new NoLock('foo', 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(0); + } + + public function testRefreshWithNegativeSecondsThrowsException() + { + $lock = new NoLock('foo', 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(-5); + } + + public function testGetRemainingLifetimeAlwaysReturnsNull() + { + $lock = new NoLock('foo', 10); + + $this->assertNull($lock->getRemainingLifetime()); + } + + public function testOwnerReturnsOwner() + { + $lock = new NoLock('foo', 10, 'custom-owner'); + + $this->assertSame('custom-owner', $lock->owner()); + } + + public function testForceReleaseDoesNothing() + { + $lock = new NoLock('foo', 10); + + $lock->forceRelease(); + $this->assertTrue(true); // Just verify no exceptions + } +} diff --git a/tests/Cache/CacheRedisLockTest.php b/tests/Cache/CacheRedisLockTest.php new file mode 100644 index 000000000..b8035f048 --- /dev/null +++ b/tests/Cache/CacheRedisLockTest.php @@ -0,0 +1,198 @@ +getLock(); + + $this->assertInstanceOf(RefreshableLock::class, $lock); + } + + public function testLockCanBeAcquired() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('set') + ->once() + ->with('foo', m::type('string'), ['EX' => 10, 'NX']) + ->andReturn(true); + + $this->assertTrue($lock->acquire()); + } + + public function testLockCanBeAcquiredWithoutExpiration() + { + [$lock, $redis] = $this->getLock(seconds: 0); + + $redis->shouldReceive('setnx') + ->once() + ->with('foo', m::type('string')) + ->andReturn(true); + + $this->assertTrue($lock->acquire()); + } + + public function testLockCanBeReleased() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('eval') + ->once() + ->with(m::type('string'), ['foo', $lock->owner()], 1) + ->andReturn(1); + + $this->assertTrue($lock->release()); + } + + public function testLockCanBeForceReleased() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('del') + ->once() + ->with('foo'); + + $lock->forceRelease(); + $this->assertTrue(true); + } + + public function testRefreshExtendsLockTtl() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('eval') + ->once() + ->with(m::type('string'), ['foo', $lock->owner(), 10], 1) + ->andReturn(1); + + $this->assertTrue($lock->refresh()); + } + + public function testRefreshWithCustomTtl() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('eval') + ->once() + ->with(m::type('string'), ['foo', $lock->owner(), 30], 1) + ->andReturn(1); + + $this->assertTrue($lock->refresh(30)); + } + + public function testRefreshReturnsFalseWhenNotOwned() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('eval') + ->once() + ->with(m::type('string'), ['foo', $lock->owner(), 10], 1) + ->andReturn(0); + + $this->assertFalse($lock->refresh()); + } + + public function testRefreshOnPermanentLockReturnsTrue() + { + [$lock] = $this->getLock(seconds: 0); + + // No Redis call should be made - it's a no-op for permanent locks + $this->assertTrue($lock->refresh()); + } + + public function testRefreshWithExplicitZeroThrowsException() + { + [$lock] = $this->getLock(seconds: 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(0); + } + + public function testRefreshWithNegativeSecondsThrowsException() + { + [$lock] = $this->getLock(seconds: 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL'); + + $lock->refresh(-5); + } + + public function testGetRemainingLifetimeReturnsSeconds() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('ttl') + ->once() + ->with('foo') + ->andReturn(5); + + $this->assertSame(5.0, $lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsNullWhenKeyDoesNotExist() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('ttl') + ->once() + ->with('foo') + ->andReturn(-2); + + $this->assertNull($lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsNullWhenNoExpiry() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('ttl') + ->once() + ->with('foo') + ->andReturn(-1); + + $this->assertNull($lock->getRemainingLifetime()); + } + + public function testGetRemainingLifetimeReturnsZeroWhenExpired() + { + [$lock, $redis] = $this->getLock(); + + $redis->shouldReceive('ttl') + ->once() + ->with('foo') + ->andReturn(0); + + $this->assertSame(0.0, $lock->getRemainingLifetime()); + } + + /** + * Get a RedisLock instance with mocked dependencies. + */ + protected function getLock(int $seconds = 10): array + { + $redis = m::mock(Redis::class); + + $lock = new RedisLock($redis, 'foo', $seconds); + + return [$lock, $redis]; + } +}