Skip to content

Commit 95ad08a

Browse files
committed
feat(cache): add Memcached lock store
- add CAS-backed Memcached lock store support - expose Memcached locks through MemcachedHandler - normalize unsupported runtime clients to LockException - cover owner-aware release, refresh, expiry, force release, and reconnect - document Memcached lock requirements and caveats Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 2cc937a commit 95ad08a

9 files changed

Lines changed: 228 additions & 10 deletions

File tree

system/Cache/Exceptions/CacheException.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,14 @@ public static function forHandlerNotFound()
5959
{
6060
return new static(lang('Cache.handlerNotFound'));
6161
}
62+
63+
/**
64+
* Thrown when the handler cannot provide a lock store.
65+
*
66+
* @return static
67+
*/
68+
public static function forUnsupportedLockStore()
69+
{
70+
return new static(lang('Cache.unsupportedLockStore'));
71+
}
6272
}

system/Cache/Handlers/MemcachedHandler.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313

1414
namespace CodeIgniter\Cache\Handlers;
1515

16+
use CodeIgniter\Cache\Exceptions\CacheException;
17+
use CodeIgniter\Cache\LockStoreInterface;
18+
use CodeIgniter\Cache\LockStoreProviderInterface;
19+
use CodeIgniter\Cache\LockStores\MemcachedLockStore;
1620
use CodeIgniter\Exceptions\BadMethodCallException;
1721
use CodeIgniter\Exceptions\CriticalError;
1822
use CodeIgniter\I18n\Time;
@@ -26,7 +30,7 @@
2630
*
2731
* @see \CodeIgniter\Cache\Handlers\MemcachedHandlerTest
2832
*/
29-
class MemcachedHandler extends BaseHandler
33+
class MemcachedHandler extends BaseHandler implements LockStoreProviderInterface
3034
{
3135
/**
3236
* The memcached object
@@ -35,6 +39,8 @@ class MemcachedHandler extends BaseHandler
3539
*/
3640
protected $memcached;
3741

42+
private ?LockStoreInterface $lockStore = null;
43+
3844
/**
3945
* Memcached Configuration
4046
*
@@ -62,6 +68,7 @@ public function initialize(): void
6268
try {
6369
if (class_exists(Memcached::class)) {
6470
$this->memcached = new Memcached();
71+
$this->lockStore = null;
6572

6673
if ($this->config['raw']) {
6774
$this->memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true);
@@ -82,6 +89,7 @@ public function initialize(): void
8289
}
8390
} elseif (class_exists(Memcache::class)) {
8491
$this->memcached = new Memcache();
92+
$this->lockStore = null;
8593

8694
if (! $this->memcached->connect($this->config['host'], $this->config['port'])) {
8795
throw new CriticalError('Cache: Memcache connection failed.');
@@ -219,6 +227,15 @@ public function isSupported(): bool
219227
return extension_loaded('memcached') || extension_loaded('memcache');
220228
}
221229

230+
public function lockStore(): LockStoreInterface
231+
{
232+
if (! $this->memcached instanceof Memcached) {
233+
throw CacheException::forUnsupportedLockStore();
234+
}
235+
236+
return $this->lockStore ??= new MemcachedLockStore($this->memcached, $this->prefix);
237+
}
238+
222239
public function ping(): bool
223240
{
224241
$version = $this->memcached->getVersion();
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
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\Cache\LockStores;
15+
16+
use CodeIgniter\Cache\Handlers\MemcachedHandler;
17+
use CodeIgniter\Cache\LockStoreInterface;
18+
use Memcached;
19+
20+
class MemcachedLockStore implements LockStoreInterface
21+
{
22+
private const RELEASE_TTL = 2;
23+
24+
public function __construct(
25+
private readonly Memcached $memcached,
26+
private readonly string $prefix = '',
27+
) {
28+
}
29+
30+
public function acquireLock(string $key, string $owner, int $ttl): bool
31+
{
32+
$key = MemcachedHandler::validateKey($key, $this->prefix);
33+
34+
return $this->memcached->add($key, $owner, $ttl);
35+
}
36+
37+
public function releaseLock(string $key, string $owner): bool
38+
{
39+
$key = MemcachedHandler::validateKey($key, $this->prefix);
40+
41+
[$value, $cas] = $this->getValueAndCas($key);
42+
43+
if ($value !== $owner || $cas === null) {
44+
return false;
45+
}
46+
47+
// Memcached has no atomic compare-and-delete command. CAS narrows the
48+
// release race by first shortening only the current owner's value.
49+
if (! $this->memcached->cas($cas, $key, $owner, self::RELEASE_TTL)) {
50+
return false;
51+
}
52+
53+
return $this->memcached->delete($key);
54+
}
55+
56+
public function forceReleaseLock(string $key): bool
57+
{
58+
$key = MemcachedHandler::validateKey($key, $this->prefix);
59+
60+
if ($this->memcached->delete($key)) {
61+
return true;
62+
}
63+
64+
return $this->memcached->getResultCode() === Memcached::RES_NOTFOUND;
65+
}
66+
67+
public function refreshLock(string $key, string $owner, int $ttl): bool
68+
{
69+
$key = MemcachedHandler::validateKey($key, $this->prefix);
70+
71+
[$value, $cas] = $this->getValueAndCas($key);
72+
73+
if ($value !== $owner || $cas === null) {
74+
return false;
75+
}
76+
77+
return $this->memcached->cas($cas, $key, $owner, $ttl);
78+
}
79+
80+
public function getLockOwner(string $key): ?string
81+
{
82+
$key = MemcachedHandler::validateKey($key, $this->prefix);
83+
$owner = $this->memcached->get($key);
84+
85+
if ($this->memcached->getResultCode() !== Memcached::RES_SUCCESS) {
86+
return null;
87+
}
88+
89+
return is_string($owner) ? $owner : null;
90+
}
91+
92+
/**
93+
* @return array{0: mixed, 1: float|int|null}
94+
*/
95+
private function getValueAndCas(string $key): array
96+
{
97+
$extended = $this->memcached->get($key, null, Memcached::GET_EXTENDED);
98+
99+
if (! is_array($extended) || ! array_key_exists('value', $extended) || ! array_key_exists('cas', $extended)) {
100+
return [null, null];
101+
}
102+
103+
return [$extended['value'], $extended['cas']];
104+
}
105+
}

system/Language/en/Cache.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414
// Cache language settings
1515
return [
16-
'unableToWrite' => 'Cache unable to write to "{0}".',
17-
'invalidHandler' => 'Cache driver "{0}" is not a valid cache handler.',
18-
'invalidHandlers' => 'Cache config must have an array of $validHandlers.',
19-
'noBackup' => 'Cache config must have a handler and backupHandler set.',
20-
'handlerNotFound' => 'Cache config has an invalid handler or backup handler specified.',
16+
'unableToWrite' => 'Cache unable to write to "{0}".',
17+
'invalidHandler' => 'Cache driver "{0}" is not a valid cache handler.',
18+
'invalidHandlers' => 'Cache config must have an array of $validHandlers.',
19+
'noBackup' => 'Cache config must have a handler and backupHandler set.',
20+
'handlerNotFound' => 'Cache config has an invalid handler or backup handler specified.',
21+
'unsupportedLockStore' => 'The cache handler cannot provide a lock store with the current runtime client.',
2122
];

system/Lock/LockManager.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CodeIgniter\Lock;
1515

1616
use CodeIgniter\Cache\CacheInterface;
17+
use CodeIgniter\Cache\Exceptions\CacheException;
1718
use CodeIgniter\Cache\LockStoreInterface;
1819
use CodeIgniter\Cache\LockStoreProviderInterface;
1920
use CodeIgniter\Exceptions\InvalidArgumentException;
@@ -36,7 +37,11 @@ public function __construct(CacheInterface $cache)
3637
throw LockException::forUnsupportedStore($cache::class);
3738
}
3839

39-
$this->store = $cache->lockStore();
40+
try {
41+
$this->store = $cache->lockStore();
42+
} catch (CacheException) {
43+
throw LockException::forUnsupportedStore($cache::class);
44+
}
4045
}
4146

4247
public function create(string $name, int $ttl = 300, ?string $owner = null): LockInterface

tests/system/Cache/Handlers/MemcachedHandlerTest.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
namespace CodeIgniter\Cache\Handlers;
1515

1616
use CodeIgniter\Cache\CacheFactory;
17+
use CodeIgniter\Cache\LockStoreInterface;
18+
use CodeIgniter\Cache\LockStoreProviderInterface;
1719
use CodeIgniter\CLI\CLI;
1820
use CodeIgniter\Exceptions\BadMethodCallException;
1921
use CodeIgniter\I18n\Time;
@@ -104,6 +106,53 @@ public function testSave(): void
104106
$this->assertTrue($this->handler->save(self::$key1, 'value'));
105107
}
106108

109+
public function testLockOperations(): void
110+
{
111+
$handler = $this->handler;
112+
113+
$this->assertInstanceOf(LockStoreProviderInterface::class, $handler);
114+
115+
$store = $handler->lockStore();
116+
117+
$this->assertInstanceOf(LockStoreInterface::class, $store);
118+
$this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60));
119+
$this->assertFalse($store->acquireLock(self::$key1, 'owner2', 60));
120+
$this->assertSame('owner1', $store->getLockOwner(self::$key1));
121+
$this->assertFalse($store->releaseLock(self::$key1, 'owner2'));
122+
$this->assertFalse($store->refreshLock(self::$key1, 'owner2', 120));
123+
$this->assertTrue($store->refreshLock(self::$key1, 'owner1', 120));
124+
$this->assertTrue($store->releaseLock(self::$key1, 'owner1'));
125+
$this->assertNull($store->getLockOwner(self::$key1));
126+
$this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60));
127+
$this->assertTrue($store->forceReleaseLock(self::$key1));
128+
$this->assertNull($store->getLockOwner(self::$key1));
129+
$this->assertTrue($store->forceReleaseLock(self::$key1));
130+
}
131+
132+
/**
133+
* This test waits for 2 seconds before reacquiring the lock.
134+
*
135+
* @timeLimit 2.5
136+
*/
137+
public function testExpiredLockCanBeAcquiredByNewOwner(): void
138+
{
139+
$handler = $this->handler;
140+
141+
$this->assertInstanceOf(LockStoreProviderInterface::class, $handler);
142+
143+
$store = $handler->lockStore();
144+
145+
$this->assertTrue($store->acquireLock(self::$key1, 'owner1', 1));
146+
147+
CLI::wait(2);
148+
149+
$this->assertTrue($store->acquireLock(self::$key1, 'owner2', 60));
150+
$this->assertSame('owner2', $store->getLockOwner(self::$key1));
151+
$this->assertFalse($store->releaseLock(self::$key1, 'owner1'));
152+
$this->assertFalse($store->refreshLock(self::$key1, 'owner1', 120));
153+
$this->assertTrue($store->releaseLock(self::$key1, 'owner2'));
154+
}
155+
107156
public function testSavePermanent(): void
108157
{
109158
$this->assertTrue($this->handler->save(self::$key1, 'value', 0));
@@ -200,11 +249,18 @@ public function testPing(): void
200249

201250
public function testReconnect(): void
202251
{
252+
$handler = $this->handler;
253+
254+
$this->assertInstanceOf(LockStoreProviderInterface::class, $handler);
255+
256+
$lockStore = $handler->lockStore();
257+
203258
$this->handler->save(self::$key1, 'value');
204259
$this->assertSame('value', $this->handler->get(self::$key1));
205260

206261
$this->assertTrue($this->handler->reconnect());
207262

208263
$this->assertSame('value', $this->handler->get(self::$key1));
264+
$this->assertNotSame($lockStore, $handler->lockStore());
209265
}
210266
}

tests/system/Lock/LockTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
namespace CodeIgniter\Lock;
1515

1616
use CodeIgniter\Cache\CacheFactory;
17+
use CodeIgniter\Cache\Exceptions\CacheException;
18+
use CodeIgniter\Cache\Handlers\DummyHandler;
19+
use CodeIgniter\Cache\LockStoreInterface;
20+
use CodeIgniter\Cache\LockStoreProviderInterface;
1721
use CodeIgniter\Exceptions\InvalidArgumentException;
1822
use CodeIgniter\I18n\Time;
1923
use CodeIgniter\Lock\Exceptions\LockException;
@@ -198,6 +202,21 @@ public function testUnsupportedCacheHandlerThrows(): void
198202
new LockManager(CacheFactory::getHandler($this->config, 'dummy'));
199203
}
200204

205+
public function testUnsupportedLockStoreProviderThrows(): void
206+
{
207+
$cache = new class () extends DummyHandler implements LockStoreProviderInterface {
208+
public function lockStore(): LockStoreInterface
209+
{
210+
throw CacheException::forUnsupportedLockStore();
211+
}
212+
};
213+
214+
$this->expectException(LockException::class);
215+
$this->expectExceptionMessage('does not support locks');
216+
217+
new LockManager($cache);
218+
}
219+
201220
private function lockFile(string $name): string
202221
{
203222
return rtrim($this->config->file['storePath'], '\\/') . '/lock_' . hash('xxh128', $name);

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ Libraries
225225

226226
- **Context**: This new feature allows you to easily set and retrieve normal or hidden contextual data for the current request. See :ref:`Context <context>` for details.
227227
- **Images:**: Added support for the AVIF file format.
228-
- **Locks:** Added :doc:`Atomic Locks </libraries/locks>` for owner-aware, cross-process mutual exclusion backed by supported cache handlers.
228+
- **Locks:** Added :doc:`Atomic Locks </libraries/locks>` for owner-aware, cross-process mutual exclusion backed by supported cache handlers: **File**, **Redis**, **Predis**, and **Memcached**.
229229
- **Logging:** Log handlers now receive the full context array as a third argument to ``handle()``. When ``$logGlobalContext`` is enabled, the CI global context is available under the ``HandlerInterface::GLOBAL_CONTEXT_KEY`` key. Built-in handlers append it to the log output; custom handlers can use it for structured logging.
230230
- **Logging:** Added :ref:`per-call context logging <logging-per-call-context>` with three new ``Config\Logger`` options (``$logContext``, ``$logContextTrace``, ``$logContextUsedKeys``). Per PSR-3, a ``Throwable`` in the ``exception`` context key is automatically normalized to a meaningful array. All options default to ``false``.
231231

user_guide_src/source/libraries/locks.rst

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ Configuration
2020
*************
2121

2222
The Locks library uses the Cache service. The cache handler must support atomic
23-
lock operations. The built-in **File**, **Redis**, and **Predis** cache handlers
24-
support locks.
23+
lock operations. The built-in **File**, **Redis**, **Predis**, and
24+
**Memcached** cache handlers support locks.
2525

2626
.. note:: Locks are most useful when all competing processes share the same cache
2727
storage. The File handler is suitable for a single server. For multiple
@@ -33,6 +33,11 @@ support locks.
3333
storage while lock-protected work is running, or use a dedicated cache
3434
store for locks when that separation is important.
3535

36+
.. note:: Memcached lock support requires the ``memcached`` PHP extension.
37+
The older ``memcache`` extension does not provide the CAS operations needed
38+
for owner-aware release and refresh. Memcached locks may also be lost if the
39+
Memcached server restarts, evicts keys, or flushes its cache.
40+
3641
.. note:: File-backed locks clear released and expired lock contents, but may
3742
leave empty lock files in the cache directory. These files do not represent
3843
active locks and may be removed by normal cache cleanup when no

0 commit comments

Comments
 (0)