Skip to content

Commit 88fbdb6

Browse files
committed
feat(lock): add atomic lock service
Signed-off-by: memleakd <[email protected]>
1 parent 2db0ed7 commit 88fbdb6

8 files changed

Lines changed: 283 additions & 0 deletions

File tree

deptrac.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ deptrac:
9999
collectors:
100100
- type: classNameRegex
101101
value: '/^CodeIgniter\\Language\\.*$/'
102+
- name: Lock
103+
collectors:
104+
- type: classNameRegex
105+
value: '/^CodeIgniter\\Lock\\.*$/'
102106
- name: Log
103107
collectors:
104108
- type: classNameRegex
@@ -170,6 +174,7 @@ deptrac:
170174
- URI
171175
Cache:
172176
- I18n
177+
- Lock
173178
Controller:
174179
- HTTP
175180
- Validation
@@ -207,6 +212,8 @@ deptrac:
207212
Images:
208213
- Files
209214
- I18n
215+
Lock:
216+
- Cache
210217
Model:
211218
- Database
212219
- DataCaster

system/Config/BaseService.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
use CodeIgniter\HTTP\URI;
4747
use CodeIgniter\Images\Handlers\BaseHandler;
4848
use CodeIgniter\Language\Language;
49+
use CodeIgniter\Lock\LockManager;
4950
use CodeIgniter\Log\Logger;
5051
use CodeIgniter\Pager\Pager;
5152
use CodeIgniter\Router\RouteCollection;
@@ -119,6 +120,7 @@
119120
* @method static IncomingRequest incomingrequest(?App $config = null, bool $getShared = true)
120121
* @method static Iterator iterator($getShared = true)
121122
* @method static Language language($locale = null, $getShared = true)
123+
* @method static LockManager locks(?CacheInterface $cache = null, bool $getShared = true)
122124
* @method static Logger logger($getShared = true)
123125
* @method static MigrationRunner migrations(Migrations $config = null, ConnectionInterface $db = null, $getShared = true)
124126
* @method static Negotiate negotiator(RequestInterface $request = null, $getShared = true)

system/Config/Services.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
use CodeIgniter\HTTP\UserAgent;
4747
use CodeIgniter\Images\Handlers\BaseHandler;
4848
use CodeIgniter\Language\Language;
49+
use CodeIgniter\Lock\LockManager;
4950
use CodeIgniter\Log\Logger;
5051
use CodeIgniter\Pager\Pager;
5152
use CodeIgniter\Router\RouteCollection;
@@ -130,6 +131,24 @@ public static function cache(?Cache $config = null, bool $getShared = true)
130131
return CacheFactory::getHandler($config);
131132
}
132133

134+
/**
135+
* The locks service provides atomic locks backed by supported cache handlers.
136+
*
137+
* @return LockManager
138+
*/
139+
public static function locks(?CacheInterface $cache = null, bool $getShared = true)
140+
{
141+
if ($cache instanceof CacheInterface) {
142+
return new LockManager($cache);
143+
}
144+
145+
if ($getShared) {
146+
return static::getSharedInstance('locks', null);
147+
}
148+
149+
return new LockManager(AppServices::get('cache'));
150+
}
151+
133152
/**
134153
* The CLI Request class provides for ways to interact with
135154
* a command line request.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Lock\Exceptions;
15+
16+
use CodeIgniter\Exceptions\FrameworkException;
17+
18+
class LockException extends FrameworkException
19+
{
20+
public static function forUnsupportedStore(string $class): self
21+
{
22+
return new self(sprintf('The cache handler "%s" does not support locks.', $class));
23+
}
24+
}

system/Lock/Lock.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Lock;
15+
16+
use Closure;
17+
use CodeIgniter\Exceptions\InvalidArgumentException;
18+
19+
class Lock implements LockInterface
20+
{
21+
public function __construct(
22+
private readonly LockStoreInterface $store,
23+
private readonly string $key,
24+
private readonly int $ttl,
25+
private readonly string $owner,
26+
) {
27+
if ($ttl < 1) {
28+
throw new InvalidArgumentException('Lock TTL must be a positive integer.');
29+
}
30+
31+
if ($owner === '') {
32+
throw new InvalidArgumentException('Lock owner cannot be empty.');
33+
}
34+
}
35+
36+
public function acquire(): bool
37+
{
38+
return $this->store->acquireLock($this->key, $this->owner, $this->ttl);
39+
}
40+
41+
public function block(int $seconds): bool
42+
{
43+
if ($seconds < 1) {
44+
return $this->acquire();
45+
}
46+
47+
$expiresAt = microtime(true) + $seconds;
48+
49+
do {
50+
if ($this->acquire()) {
51+
return true;
52+
}
53+
54+
usleep(100_000);
55+
} while (microtime(true) < $expiresAt);
56+
57+
return false;
58+
}
59+
60+
/**
61+
* @param Closure(): mixed $callback
62+
*/
63+
public function run(Closure $callback, int $waitSeconds = 0): mixed
64+
{
65+
$acquired = $waitSeconds > 0 ? $this->block($waitSeconds) : $this->acquire();
66+
67+
if (! $acquired) {
68+
return null;
69+
}
70+
71+
try {
72+
return $callback();
73+
} finally {
74+
$this->release();
75+
}
76+
}
77+
78+
public function release(): bool
79+
{
80+
return $this->store->releaseLock($this->key, $this->owner);
81+
}
82+
83+
public function forceRelease(): bool
84+
{
85+
return $this->store->forceReleaseLock($this->key);
86+
}
87+
88+
public function refresh(?int $ttl = null): bool
89+
{
90+
$ttl ??= $this->ttl;
91+
92+
if ($ttl < 1) {
93+
throw new InvalidArgumentException('Lock TTL must be a positive integer.');
94+
}
95+
96+
return $this->store->refreshLock($this->key, $this->owner, $ttl);
97+
}
98+
99+
public function isAcquired(): bool
100+
{
101+
return $this->store->getLockOwner($this->key) === $this->owner;
102+
}
103+
104+
public function owner(): string
105+
{
106+
return $this->owner;
107+
}
108+
}

system/Lock/LockInterface.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Lock;
15+
16+
use Closure;
17+
18+
interface LockInterface
19+
{
20+
public function acquire(): bool;
21+
22+
public function block(int $seconds): bool;
23+
24+
/**
25+
* @param Closure(): mixed $callback
26+
*/
27+
public function run(Closure $callback, int $waitSeconds = 0): mixed;
28+
29+
public function release(): bool;
30+
31+
public function forceRelease(): bool;
32+
33+
public function refresh(?int $ttl = null): bool;
34+
35+
public function isAcquired(): bool;
36+
37+
public function owner(): string;
38+
}

system/Lock/LockManager.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Lock;
15+
16+
use CodeIgniter\Cache\CacheInterface;
17+
use CodeIgniter\Exceptions\InvalidArgumentException;
18+
use CodeIgniter\Lock\Exceptions\LockException;
19+
20+
class LockManager
21+
{
22+
private const KEY_PREFIX = 'lock_';
23+
24+
public function __construct(private readonly CacheInterface $cache)
25+
{
26+
}
27+
28+
public function create(string $name, int $ttl = 300, ?string $owner = null): LockInterface
29+
{
30+
if ($name === '') {
31+
throw new InvalidArgumentException('Lock name cannot be empty.');
32+
}
33+
34+
$store = $this->store();
35+
$key = $this->key($name);
36+
37+
return new Lock($store, $key, $ttl, $owner ?? bin2hex(random_bytes(16)));
38+
}
39+
40+
public function restore(string $name, string $owner, int $ttl = 300): LockInterface
41+
{
42+
return $this->create($name, $ttl, $owner);
43+
}
44+
45+
private function store(): LockStoreInterface
46+
{
47+
if (! $this->cache instanceof LockStoreInterface) {
48+
throw LockException::forUnsupportedStore($this->cache::class);
49+
}
50+
51+
return $this->cache;
52+
}
53+
54+
private function key(string $name): string
55+
{
56+
return self::KEY_PREFIX . hash('sha256', $name);
57+
}
58+
}

system/Lock/LockStoreInterface.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Lock;
15+
16+
interface LockStoreInterface
17+
{
18+
public function acquireLock(string $key, string $owner, int $ttl): bool;
19+
20+
public function releaseLock(string $key, string $owner): bool;
21+
22+
public function forceReleaseLock(string $key): bool;
23+
24+
public function refreshLock(string $key, string $owner, int $ttl): bool;
25+
26+
public function getLockOwner(string $key): ?string;
27+
}

0 commit comments

Comments
 (0)