Skip to content

Commit 0f08031

Browse files
xHeavenbrendtinnocenzi
authored
fix(cache): ensure unique lock acquisition (#1757)
Co-authored-by: Brent Roose <[email protected]> Co-authored-by: Enzo Innocenzi <[email protected]>
1 parent 666d68c commit 0f08031

File tree

9 files changed

+68
-32
lines changed

9 files changed

+68
-32
lines changed

packages/cache/src/Cache.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ interface Cache
2424
* Returns a lock for the specified key. The lock is not acquired until `acquire()` is called.
2525
*
2626
* @param Stringable|string $key The identifier of the lock.
27-
* @param null|Duration|DateTimeInterface $expiration The expiration time for the lock. If not specified, the lock will not expire.
27+
* @param null|Duration|DateTimeInterface $duration The duration for the lock, or an expiration date from which the duration will be calculated. If not specified, the lock will not expire.
2828
* @param null|Stringable|string $owner The owner of the lock, which will be used to identify the process releasing it. If not specified, a random string will be used.
2929
*/
30-
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock;
30+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $duration = null, null|Stringable|string $owner = null): Lock;
3131

3232
/**
3333
* Sets the specified key to the specified value in the cache. Optionally, specify an expiration.

packages/cache/src/GenericCache.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ public function __construct(
2323
public null|UnitEnum|string $tag = null,
2424
) {}
2525

26-
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock
26+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $duration = null, null|Stringable|string $owner = null): Lock
2727
{
28-
if ($expiration instanceof Duration) {
29-
$expiration = DateTime::now()->plus($expiration);
28+
if ($duration instanceof DateTimeInterface) {
29+
$duration = $duration->since(DateTime::now());
3030
}
3131

3232
return new GenericLock(
3333
key: (string) $key,
3434
owner: $owner ? (string) $owner : Random\secure_string(length: 10),
3535
cache: $this,
36-
expiration: $expiration,
36+
duration: $duration,
3737
);
3838
}
3939

packages/cache/src/GenericLock.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public function __construct(
1414
private(set) string $key,
1515
private(set) string $owner,
1616
private readonly Cache $cache,
17-
private(set) ?DateTimeInterface $expiration = null,
17+
private(set) ?Duration $duration = null,
1818
) {}
1919

2020
public function locked(null|Stringable|string $by = null): bool
@@ -32,13 +32,17 @@ public function acquire(): bool
3232
return false;
3333
}
3434

35+
$expiration = $this->duration !== null
36+
? DateTime::now()->plus($this->duration)
37+
: null;
38+
3539
$this->cache->put(
3640
key: $this->key,
3741
value: $this->owner,
38-
expiration: $this->expiration,
42+
expiration: $expiration,
3943
);
4044

41-
return true;
45+
return $this->cache->get($this->key) === $this->owner;
4246
}
4347

4448
public function execute(Closure $callback, null|DateTimeInterface|Duration $wait = null): mixed

packages/cache/src/Lock.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ interface Lock
1717
}
1818

1919
/**
20-
* The expiration date of the lock. If null, the lock will not expire.
20+
* The duration of the lock. If null, the lock will not expire.
2121
*/
22-
public ?DateTimeInterface $expiration {
22+
public ?Duration $duration {
2323
get;
2424
}
2525

packages/cache/src/Testing/RestrictedCache.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ private function resolveTag(): ?string
2525
return $this->tag instanceof UnitEnum ? $this->tag->name : $this->tag;
2626
}
2727

28-
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock
28+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $duration = null, null|Stringable|string $owner = null): Lock
2929
{
3030
throw new CacheUsageWasForbidden($this->resolveTag());
3131
}

packages/cache/src/Testing/TestingCache.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Tempest\Cache\Cache;
1212
use Tempest\Cache\GenericCache;
1313
use Tempest\Cache\GenericLock;
14+
use Tempest\DateTime\DateTime;
1415
use Tempest\DateTime\DateTimeInterface;
1516
use Tempest\DateTime\Duration;
1617
use Tempest\Support\Random;
@@ -33,13 +34,17 @@ public function __construct(
3334
$this->cache = new GenericCache($this->adapter);
3435
}
3536

36-
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): TestingLock
37+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $duration = null, null|Stringable|string $owner = null): TestingLock
3738
{
39+
if ($duration instanceof DateTimeInterface) {
40+
$duration = $duration->since(DateTime::now());
41+
}
42+
3843
return new TestingLock(new GenericLock(
3944
key: (string) $key,
4045
owner: $owner ? (string) $owner : Random\secure_string(length: 10),
4146
cache: $this->cache,
42-
expiration: $expiration,
47+
duration: $duration,
4348
));
4449
}
4550

@@ -186,9 +191,9 @@ public function assertNotEmpty(): self
186191
/**
187192
* Asserts that the specified lock is being held.
188193
*/
189-
public function assertLocked(string|Stringable $key, null|Stringable|string $by = null, null|DateTimeInterface|Duration $until = null): self
194+
public function assertLocked(string|Stringable $key, null|Stringable|string $by = null, null|DateTimeInterface|Duration $for = null): self
190195
{
191-
$this->lock($key)->assertLocked($by, $until);
196+
$this->lock($key)->assertLocked($by, $for);
192197

193198
return $this;
194199
}

packages/cache/src/Testing/TestingLock.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ final class TestingLock implements Lock
1717
get => $this->lock->key;
1818
}
1919

20-
public ?DateTimeInterface $expiration {
21-
get => $this->lock->expiration;
20+
public ?Duration $duration {
21+
get => $this->lock->duration;
2222
}
2323

2424
public string $owner {
@@ -52,7 +52,7 @@ public function release(bool $force = false): bool
5252
/**
5353
* Asserts that the specified lock is being held.
5454
*/
55-
public function assertLocked(null|Stringable|string $by = null, null|DateTimeInterface|Duration $until = null): self
55+
public function assertLocked(null|Stringable|string $by = null, null|DateTimeInterface|Duration $for = null): self
5656
{
5757
Assert::assertTrue(
5858
condition: $this->locked($by),
@@ -61,19 +61,19 @@ public function assertLocked(null|Stringable|string $by = null, null|DateTimeInt
6161
: "Lock `{$this->key}` is not being held.",
6262
);
6363

64-
if ($until) {
65-
if ($until instanceof Duration) {
66-
$until = DateTime::now()->plus($until);
64+
if ($for) {
65+
if ($for instanceof DateTimeInterface) {
66+
$for = $for->since(DateTime::now());
6767
}
6868

6969
Assert::assertNotNull(
70-
actual: $this->expiration,
71-
message: "Expected lock `{$this->key}` to have an expiration, but it has none.",
70+
actual: $this->duration,
71+
message: "Expected lock `{$this->key}` to have a duration, but it has none.",
7272
);
7373

7474
Assert::assertTrue(
75-
condition: $this->expiration->afterOrAtTheSameTime($until),
76-
message: "Expected lock `{$this->key}` to expire at or after `{$until}`, but it expires at `{$this->expiration}`.",
75+
condition: $this->duration->getTotalSeconds() >= $for->getTotalSeconds(),
76+
message: "Expected lock `{$this->key}` to have a duration of at least `{$for->getTotalSeconds()}` seconds, but it has `{$this->duration->getTotalSeconds()}` seconds.",
7777
);
7878
}
7979

rector.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use Rector\Php70\Rector\StaticCall\StaticCallOnNonStaticToInstanceCallRector;
1111
use Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector;
1212
use Rector\Php74\Rector\Property\RestoreDefaultNullToNullableTypePropertyRector;
13-
use Rector\Php81\Rector\Array_\FirstClassCallableRector;
13+
use Rector\Php81\Rector\Array_\ArrayToFirstClassCallableRector;
1414
use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector;
1515
use Rector\Php81\Rector\Property\ReadOnlyPropertyRector;
1616
use Rector\Php82\Rector\Class_\ReadOnlyClassRector;
@@ -43,7 +43,7 @@
4343
ArgumentAdderRector::class,
4444
ClosureToArrowFunctionRector::class,
4545
EmptyOnNullableObjectToInstanceOfRector::class,
46-
FirstClassCallableRector::class,
46+
ArrayToFirstClassCallableRector::class,
4747
NullToStrictStringFuncCallArgRector::class,
4848
ReadOnlyClassRector::class,
4949
ReadOnlyPropertyRector::class,

tests/Integration/Cache/LockTest.php

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ public function test_lock_with_ttl(): void
6161
$clock = $this->clock();
6262
$cache = new GenericCache(new ArrayAdapter(clock: $clock->toPsrClock()));
6363

64-
$lock = $cache->lock('processing', expiration: Duration::hours(1));
64+
$lock = $cache->lock('processing', duration: Duration::hours(1));
6565

6666
$this->assertTrue($lock->acquire());
67-
$this->assertTrue($lock->expiration->equals($clock->now()->plusHours(1)));
67+
$this->assertTrue($lock->duration->equals(Duration::hours(1)));
6868

6969
// Still locked after 30 min
7070
$clock->plus(Duration::minutes(30));
@@ -73,7 +73,7 @@ public function test_lock_with_ttl(): void
7373
// No longer locked after another 30 min (total 1h)
7474
$clock->plus(Duration::minutes(30));
7575
$this->assertTrue($lock->acquire());
76-
$this->assertFalse($lock->release());
76+
$this->assertTrue($lock->release());
7777
}
7878

7979
public function test_lock_execution_without_timeout(): void
@@ -106,7 +106,7 @@ public function test_lock_execution_when_already_locked_by_another_owner_with_ti
106106
$cache = new GenericCache(new ArrayAdapter(clock: $clock->toPsrClock()));
107107

108108
// Lock externally for a set duration
109-
$externalLock = $cache->lock('processing', expiration: Duration::hours(1));
109+
$externalLock = $cache->lock('processing', duration: Duration::hours(1));
110110
$externalLock->acquire();
111111

112112
// Skip the set duration
@@ -116,4 +116,31 @@ public function test_lock_execution_when_already_locked_by_another_owner_with_ti
116116
/** @phpstan-ignore-next-line */
117117
$this->assertTrue($cache->lock('processing')->execute(fn () => true, wait: Duration::hours(1)));
118118
}
119+
120+
public function test_lock_can_be_reacquired_after_expiration(): void
121+
{
122+
$clock = $this->clock();
123+
$cache = new GenericCache(new ArrayAdapter(clock: $clock->toPsrClock()));
124+
125+
$lock = $cache->lock('processing', duration: Duration::hours(1));
126+
127+
// Acquire the lock
128+
$this->assertTrue($lock->acquire());
129+
130+
// Skip the lock duration
131+
$clock->plus(Duration::hours(1));
132+
133+
// Lock expired, so we can re-acquire it
134+
$this->assertTrue($lock->acquire());
135+
136+
// Verify the lock is held
137+
$this->assertTrue($lock->locked());
138+
139+
// Skip another hour
140+
$clock->plus(Duration::hours(1));
141+
142+
// Lock expired again, so we can re-acquire it again
143+
$this->assertTrue($lock->acquire());
144+
$this->assertTrue($lock->release());
145+
}
119146
}

0 commit comments

Comments
 (0)