Skip to content

Commit e143df8

Browse files
committed
feat: add cache locks
1 parent a4c0acd commit e143df8

File tree

10 files changed

+489
-2
lines changed

10 files changed

+489
-2
lines changed

packages/cache/src/Cache.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ interface Cache
2020
set;
2121
}
2222

23+
/**
24+
* Returns a lock for the specified key. The lock is not acquired until `acquire()` is called.
25+
*
26+
* @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.
28+
* @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.
29+
*/
30+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock;
31+
2332
/**
2433
* Sets the specified key to the specified value in the cache. Optionally, specify an expiration.
2534
*/
@@ -52,6 +61,11 @@ public function get(Stringable|string $key): mixed;
5261
*/
5362
public function getMany(iterable $key): array;
5463

64+
/**
65+
* Determines whether the cache contains the specified key.
66+
*/
67+
public function has(Stringable|string $key): bool;
68+
5569
/**
5670
* Increments the value associated with the specified key by the specified amount. If the key does not exist, it is created with the specified amount.
5771
*/

packages/cache/src/GenericCache.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Tempest\DateTime\DateTimeInterface;
1212
use Tempest\DateTime\Duration;
1313
use Tempest\Support\Arr;
14+
use Tempest\Support\Random;
1415

1516
final class GenericCache implements Cache
1617
{
@@ -22,6 +23,29 @@ public function __construct(
2223
$this->adapter ??= $this->cacheConfig->createAdapter();
2324
}
2425

26+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock
27+
{
28+
if ($expiration instanceof Duration) {
29+
$expiration = DateTime::now()->plus($expiration);
30+
}
31+
32+
return new GenericLock(
33+
key: (string) $key,
34+
owner: $owner ? ((string) $owner) : Random\secure_string(length: 10),
35+
cache: $this,
36+
expiration: $expiration,
37+
);
38+
}
39+
40+
public function has(Stringable|string $key): bool
41+
{
42+
if (! $this->enabled) {
43+
return false;
44+
}
45+
46+
return $this->adapter->getItem((string) $key)->isHit();
47+
}
48+
2549
public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface
2650
{
2751
$item = $this->adapter

packages/cache/src/GenericLock.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
namespace Tempest\Cache;
4+
5+
use Closure;
6+
use Stringable;
7+
use Tempest\DateTime\DateTime;
8+
use Tempest\DateTime\DateTimeInterface;
9+
use Tempest\DateTime\Duration;
10+
11+
final class GenericLock implements Lock
12+
{
13+
public function __construct(
14+
private(set) string $key,
15+
private(set) string $owner,
16+
private readonly Cache $cache,
17+
private(set) ?DateTimeInterface $expiration = null,
18+
) {}
19+
20+
public function locked(null|Stringable|string $by = null): bool
21+
{
22+
if ($by === null) {
23+
return $this->cache->has($this->key);
24+
}
25+
26+
return $this->cache->get($this->key) === ((string) $by);
27+
}
28+
29+
public function acquire(): bool
30+
{
31+
if ($this->locked()) {
32+
return false;
33+
}
34+
35+
$this->cache->put(
36+
key: $this->key,
37+
value: $this->owner,
38+
expiration: $this->expiration,
39+
);
40+
41+
return true;
42+
}
43+
44+
public function execute(Closure $callback, null|DateTimeInterface|Duration $wait = null): mixed
45+
{
46+
$wait ??= Datetime::now();
47+
$waitUntil = ($wait instanceof Duration)
48+
? DateTime::now()->plus($wait)
49+
: $wait;
50+
51+
while (! $this->acquire()) {
52+
if ($waitUntil->beforeOrAtTheSameTime(DateTime::now())) {
53+
throw new LockAcquisitionTimedOutException($this->key);
54+
}
55+
56+
usleep(250); // TODO: sleep from clock?
57+
}
58+
59+
try {
60+
return $callback();
61+
} finally {
62+
$this->release();
63+
}
64+
}
65+
66+
public function release(bool $force = false): bool
67+
{
68+
if (! $this->locked()) {
69+
return false;
70+
}
71+
72+
$lock = $this->cache->get($this->key);
73+
74+
if ($lock !== $this->owner && ! $force) {
75+
return false;
76+
}
77+
78+
$this->cache->remove($this->key);
79+
80+
return true;
81+
}
82+
}

packages/cache/src/Lock.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace Tempest\Cache;
4+
5+
use Closure;
6+
use Stringable;
7+
use Tempest\DateTime\DateTimeInterface;
8+
use Tempest\DateTime\Duration;
9+
10+
interface Lock
11+
{
12+
/**
13+
* The key used to identify the lock. This should be unique across all locks.
14+
*/
15+
public string $key {
16+
get;
17+
}
18+
19+
/**
20+
* The expiration date of the lock. If null, the lock will not expire.
21+
*/
22+
public ?DateTimeInterface $expiration {
23+
get;
24+
}
25+
26+
/**
27+
* The owner of the lock. This is used to verify that the lock is being released by the correct owner.
28+
*/
29+
public string $owner {
30+
get;
31+
}
32+
33+
/**
34+
* Attempts to acquire a lock.
35+
*/
36+
public function acquire(): bool;
37+
38+
/**
39+
* Checks if the lock is currently held.
40+
*/
41+
public function locked(null|Stringable|string $by = null): bool;
42+
43+
/**
44+
* Executes the given callback while holding the lock.
45+
*
46+
* @template TReturn
47+
*
48+
* @param Closure(): TReturn $callback The callback to execute while holding the lock.
49+
* @param null|DateTimeInterface|Duration $wait The time to wait for the lock to be acquired. If null, the lock will not wait.
50+
*
51+
* @return TReturn The result of the callback.
52+
*/
53+
public function execute(Closure $callback, null|DateTimeInterface|Duration $wait = null): mixed;
54+
55+
/**
56+
* Releases the lock.
57+
*/
58+
public function release(bool $force = false): bool;
59+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Tempest\Cache;
4+
5+
use Exception;
6+
7+
final class LockAcquisitionTimedOutException extends Exception implements CacheException
8+
{
9+
public function __construct(
10+
public readonly string $key,
11+
) {
12+
parent::__construct(
13+
message: "Lock with key `{$key}` could not be acquired on time.",
14+
);
15+
}
16+
}

packages/cache/src/Testing/RestrictedCache.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Stringable;
88
use Tempest\Cache\Cache;
99
use Tempest\Cache\ForbiddenCacheUsageException;
10+
use Tempest\Cache\Lock;
1011
use Tempest\DateTime\DateTimeInterface;
1112
use Tempest\DateTime\Duration;
1213

@@ -17,6 +18,16 @@ public function __construct(
1718
private ?string $tag = null,
1819
) {}
1920

21+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock
22+
{
23+
throw new ForbiddenCacheUsageException($this->tag);
24+
}
25+
26+
public function has(Stringable|string $key): bool
27+
{
28+
throw new ForbiddenCacheUsageException($this->tag);
29+
}
30+
2031
public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface
2132
{
2233
throw new ForbiddenCacheUsageException($this->tag);

packages/cache/src/Testing/TestingCache.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44

55
use Closure;
66
use PHPUnit\Framework\Assert;
7-
use PHPUnit\Framework\AssertionFailedError;
8-
use PHPUnit\Framework\ExpectationFailedException;
97
use Psr\Cache\CacheItemInterface;
108
use Psr\Clock\ClockInterface;
119
use Stringable;
1210
use Symfony\Component\Cache\Adapter\ArrayAdapter;
1311
use Tempest\Cache\Cache;
1412
use Tempest\Cache\Config\CustomCacheConfig;
1513
use Tempest\Cache\GenericCache;
14+
use Tempest\Cache\GenericLock;
1615
use Tempest\DateTime\DateTimeInterface;
1716
use Tempest\DateTime\Duration;
17+
use Tempest\Support\Random;
1818

1919
final class TestingCache implements Cache
2020
{
@@ -37,6 +37,21 @@ public function __construct(
3737
);
3838
}
3939

40+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): TestingLock
41+
{
42+
return new TestingLock(new GenericLock(
43+
key: (string) $key,
44+
owner: $owner ? ((string) $owner) : Random\secure_string(length: 10),
45+
cache: $this->cache,
46+
expiration: $expiration,
47+
));
48+
}
49+
50+
public function has(Stringable|string $key): bool
51+
{
52+
return $this->cache->has($key);
53+
}
54+
4055
public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface
4156
{
4257
return $this->cache->put($key, $value, $expiration);
@@ -171,4 +186,24 @@ public function assertNotEmpty(): self
171186

172187
return $this;
173188
}
189+
190+
/**
191+
* Asserts that the specified lock is being held.
192+
*/
193+
public function assertLocked(string|Stringable $key, null|Stringable|string $by = null, null|DateTimeInterface|Duration $until = null): self
194+
{
195+
$this->lock($key)->assertLocked($by, $until);
196+
197+
return $this;
198+
}
199+
200+
/**
201+
* Asserts that the specified lock is not being held.
202+
*/
203+
public function assertNotLocked(string|Stringable $key, null|Stringable|string $by = null): self
204+
{
205+
$this->lock($key)->assertNotLocked($by);
206+
207+
return $this;
208+
}
174209
}

0 commit comments

Comments
 (0)