Skip to content

Commit bf38aba

Browse files
authored
Merge pull request #335 from binaryfire/feature/lock-refresh
feat: Add `RefreshableLock` interface for long-running task support
2 parents e964070 + d14895f commit bf38aba

File tree

10 files changed

+822
-7
lines changed

10 files changed

+822
-7
lines changed

src/cache/src/ArrayLock.php

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
namespace Hypervel\Cache;
66

77
use Carbon\Carbon;
8+
use Hypervel\Cache\Contracts\RefreshableLock;
9+
use InvalidArgumentException;
810

9-
class ArrayLock extends Lock
11+
class ArrayLock extends Lock implements RefreshableLock
1012
{
1113
/**
1214
* The parent array cache store.
@@ -83,4 +85,59 @@ protected function getCurrentOwner(): string
8385
{
8486
return $this->store->locks[$this->name]['owner'];
8587
}
88+
89+
/**
90+
* Refresh the lock's TTL if still owned by this process.
91+
*
92+
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
93+
*/
94+
public function refresh(?int $seconds = null): bool
95+
{
96+
// Permanent lock with no explicit TTL requested - nothing to refresh
97+
if ($seconds === null && $this->seconds <= 0) {
98+
return true;
99+
}
100+
101+
$seconds ??= $this->seconds;
102+
103+
if ($seconds <= 0) {
104+
throw new InvalidArgumentException(
105+
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
106+
);
107+
}
108+
109+
if (! $this->exists()) {
110+
return false;
111+
}
112+
113+
if (! $this->isOwnedByCurrentProcess()) {
114+
return false;
115+
}
116+
117+
$this->store->locks[$this->name]['expiresAt'] = Carbon::now()->addSeconds($seconds);
118+
119+
return true;
120+
}
121+
122+
/**
123+
* Get the number of seconds until the lock expires.
124+
*/
125+
public function getRemainingLifetime(): ?float
126+
{
127+
if (! $this->exists()) {
128+
return null;
129+
}
130+
131+
$expiresAt = $this->store->locks[$this->name]['expiresAt'];
132+
133+
if ($expiresAt === null) {
134+
return null;
135+
}
136+
137+
if ($expiresAt->isPast()) {
138+
return null;
139+
}
140+
141+
return (float) Carbon::now()->diffInSeconds($expiresAt);
142+
}
86143
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Cache\Contracts;
6+
7+
use InvalidArgumentException;
8+
9+
/**
10+
* A lock that supports refreshing its TTL and inspecting remaining lifetime.
11+
*
12+
* Not all lock drivers can implement this interface atomically. Drivers that
13+
* cannot guarantee atomic refresh operations (like CacheLock) should not
14+
* implement this interface.
15+
*/
16+
interface RefreshableLock extends Lock
17+
{
18+
/**
19+
* Refresh the lock's TTL if still owned by this process.
20+
*
21+
* This operation is atomic - if the lock has been released or acquired
22+
* by another process, this will return false without modifying anything.
23+
*
24+
* When called without arguments on a permanent lock (one acquired with
25+
* a TTL of 0), this is a no-op that returns true since there's no TTL
26+
* to refresh.
27+
*
28+
* @param null|int $seconds Seconds to set the TTL to (null = use original TTL from construction)
29+
* @return bool True if the lock was refreshed (or is permanent), false if not owned or expired
30+
*
31+
* @throws InvalidArgumentException If $seconds is explicitly provided and is not positive
32+
*/
33+
public function refresh(?int $seconds = null): bool;
34+
35+
/**
36+
* Get the number of seconds until the lock expires.
37+
*
38+
* @return null|float Seconds remaining, or null if lock doesn't exist or has no expiry
39+
*/
40+
public function getRemainingLifetime(): ?float;
41+
}

src/cache/src/DatabaseLock.php

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
use Hyperf\Database\ConnectionInterface;
88
use Hyperf\Database\ConnectionResolverInterface;
99
use Hyperf\Database\Exception\QueryException;
10+
use Hypervel\Cache\Contracts\RefreshableLock;
11+
use InvalidArgumentException;
1012

1113
use function Hyperf\Support\optional;
1214

13-
class DatabaseLock extends Lock
15+
class DatabaseLock extends Lock implements RefreshableLock
1416
{
1517
/**
1618
* The database connection resolver.
@@ -146,4 +148,56 @@ protected function expiresAt(): int
146148

147149
return $this->currentTime() + $lockTimeout;
148150
}
151+
152+
/**
153+
* Refresh the lock's TTL if still owned by this process.
154+
*
155+
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
156+
*/
157+
public function refresh(?int $seconds = null): bool
158+
{
159+
// Permanent lock with no explicit TTL requested - nothing to refresh
160+
if ($seconds === null && $this->seconds <= 0) {
161+
return true;
162+
}
163+
164+
$seconds ??= $this->seconds;
165+
166+
if ($seconds <= 0) {
167+
throw new InvalidArgumentException(
168+
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
169+
);
170+
}
171+
172+
$updated = $this->connection()->table($this->table)
173+
->where('key', $this->name)
174+
->where('owner', $this->owner)
175+
->update([
176+
'expiration' => $this->currentTime() + $seconds,
177+
]);
178+
179+
return $updated >= 1;
180+
}
181+
182+
/**
183+
* Get the number of seconds until the lock expires.
184+
*/
185+
public function getRemainingLifetime(): ?float
186+
{
187+
$lock = $this->connection()->table($this->table)
188+
->where('key', $this->name)
189+
->first();
190+
191+
if ($lock === null) {
192+
return null;
193+
}
194+
195+
$remaining = $lock->expiration - $this->currentTime();
196+
197+
if ($remaining <= 0) {
198+
return null;
199+
}
200+
201+
return (float) $remaining;
202+
}
149203
}

src/cache/src/LuaScripts.php

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,33 @@ class LuaScripts
1111
*
1212
* KEYS[1] - The name of the lock
1313
* ARGV[1] - The owner key of the lock instance trying to release it
14-
*
15-
* @return string
1614
*/
17-
public static function releaseLock()
15+
public static function releaseLock(): string
1816
{
1917
return <<<'LUA'
2018
if redis.call("get",KEYS[1]) == ARGV[1] then
2119
return redis.call("del",KEYS[1])
2220
else
2321
return 0
2422
end
23+
LUA;
24+
}
25+
26+
/**
27+
* Get the Lua script to atomically refresh a lock's TTL.
28+
*
29+
* KEYS[1] - The name of the lock
30+
* ARGV[1] - The owner key of the lock instance trying to refresh it
31+
* ARGV[2] - The new TTL in seconds
32+
*/
33+
public static function refreshLock(): string
34+
{
35+
return <<<'LUA'
36+
if redis.call("get",KEYS[1]) == ARGV[1] then
37+
return redis.call("expire",KEYS[1],ARGV[2])
38+
else
39+
return 0
40+
end
2541
LUA;
2642
}
2743
}

src/cache/src/NoLock.php

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
namespace Hypervel\Cache;
66

7-
class NoLock extends Lock
7+
use Hypervel\Cache\Contracts\RefreshableLock;
8+
use InvalidArgumentException;
9+
10+
class NoLock extends Lock implements RefreshableLock
811
{
912
/**
1013
* Attempt to acquire the lock.
@@ -36,4 +39,35 @@ protected function getCurrentOwner(): string
3639
{
3740
return $this->owner;
3841
}
42+
43+
/**
44+
* Refresh the lock's TTL if still owned by this process.
45+
*
46+
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
47+
*/
48+
public function refresh(?int $seconds = null): bool
49+
{
50+
// Permanent lock with no explicit TTL requested - nothing to refresh
51+
if ($seconds === null && $this->seconds <= 0) {
52+
return true;
53+
}
54+
55+
$seconds ??= $this->seconds;
56+
57+
if ($seconds <= 0) {
58+
throw new InvalidArgumentException(
59+
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
60+
);
61+
}
62+
63+
return true;
64+
}
65+
66+
/**
67+
* Get the number of seconds until the lock expires.
68+
*/
69+
public function getRemainingLifetime(): ?float
70+
{
71+
return null;
72+
}
3973
}

src/cache/src/RedisLock.php

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
namespace Hypervel\Cache;
66

77
use Hyperf\Redis\Redis;
8+
use Hypervel\Cache\Contracts\RefreshableLock;
9+
use InvalidArgumentException;
810

9-
class RedisLock extends Lock
11+
class RedisLock extends Lock implements RefreshableLock
1012
{
1113
/**
1214
* The Redis factory implementation.
@@ -31,6 +33,7 @@ public function acquire(): bool
3133
if ($this->seconds > 0) {
3234
return $this->redis->set($this->name, $this->owner, ['EX' => $this->seconds, 'NX']) == true;
3335
}
36+
3437
return $this->redis->setnx($this->name, $this->owner) == true;
3538
}
3639

@@ -57,4 +60,42 @@ protected function getCurrentOwner(): string
5760
{
5861
return $this->redis->get($this->name);
5962
}
63+
64+
/**
65+
* Refresh the lock's TTL if still owned by this process.
66+
*
67+
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
68+
*/
69+
public function refresh(?int $seconds = null): bool
70+
{
71+
// Permanent lock with no explicit TTL requested - nothing to refresh
72+
if ($seconds === null && $this->seconds <= 0) {
73+
return true;
74+
}
75+
76+
$seconds ??= $this->seconds;
77+
78+
if ($seconds <= 0) {
79+
throw new InvalidArgumentException(
80+
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
81+
);
82+
}
83+
84+
return (bool) $this->redis->eval(LuaScripts::refreshLock(), [$this->name, $this->owner, $seconds], 1);
85+
}
86+
87+
/**
88+
* Get the number of seconds until the lock expires.
89+
*/
90+
public function getRemainingLifetime(): ?float
91+
{
92+
$ttl = $this->redis->ttl($this->name);
93+
94+
// -2 = key doesn't exist, -1 = key has no expiry
95+
if ($ttl < 0) {
96+
return null;
97+
}
98+
99+
return (float) $ttl;
100+
}
60101
}

0 commit comments

Comments
 (0)