From 3ca17b291c9503be7d48f05baf4be2cf53f88f48 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:09:04 +0200 Subject: [PATCH 01/13] feat(lock): add atomic lock service Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- deptrac.yaml | 7 ++ system/Config/BaseService.php | 2 + system/Config/Services.php | 19 ++++ system/Lock/Exceptions/LockException.php | 24 +++++ system/Lock/Lock.php | 108 +++++++++++++++++++++++ system/Lock/LockInterface.php | 38 ++++++++ system/Lock/LockManager.php | 58 ++++++++++++ system/Lock/LockStoreInterface.php | 27 ++++++ 8 files changed, 283 insertions(+) create mode 100644 system/Lock/Exceptions/LockException.php create mode 100644 system/Lock/Lock.php create mode 100644 system/Lock/LockInterface.php create mode 100644 system/Lock/LockManager.php create mode 100644 system/Lock/LockStoreInterface.php diff --git a/deptrac.yaml b/deptrac.yaml index a5bb70811e4c..cc05b439244b 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -99,6 +99,10 @@ deptrac: collectors: - type: classNameRegex value: '/^CodeIgniter\\Language\\.*$/' + - name: Lock + collectors: + - type: classNameRegex + value: '/^CodeIgniter\\Lock\\.*$/' - name: Log collectors: - type: classNameRegex @@ -170,6 +174,7 @@ deptrac: - URI Cache: - I18n + - Lock Controller: - HTTP - Validation @@ -207,6 +212,8 @@ deptrac: Images: - Files - I18n + Lock: + - Cache Model: - Database - DataCaster diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 30dee6fcf1d6..6564bddaf948 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -47,6 +47,7 @@ use CodeIgniter\HTTP\URI; use CodeIgniter\Images\Handlers\BaseHandler; use CodeIgniter\Language\Language; +use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; use CodeIgniter\Pager\Pager; use CodeIgniter\Router\RouteCollection; @@ -121,6 +122,7 @@ * @method static IncomingRequest incomingrequest(?App $config = null, bool $getShared = true) * @method static Iterator iterator($getShared = true) * @method static Language language($locale = null, $getShared = true) + * @method static LockManager locks(?CacheInterface $cache = null, bool $getShared = true) * @method static Logger logger($getShared = true) * @method static MigrationRunner migrations(Migrations $config = null, ConnectionInterface $db = null, $getShared = true) * @method static Negotiate negotiator(RequestInterface $request = null, $getShared = true) diff --git a/system/Config/Services.php b/system/Config/Services.php index 54f12c360f68..a039e36963dc 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -47,6 +47,7 @@ use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Images\Handlers\BaseHandler; use CodeIgniter\Language\Language; +use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; use CodeIgniter\Pager\Pager; use CodeIgniter\Router\RouteCollection; @@ -131,6 +132,24 @@ public static function cache(?Cache $config = null, bool $getShared = true) return CacheFactory::getHandler($config); } + /** + * The locks service provides atomic locks backed by supported cache handlers. + * + * @return LockManager + */ + public static function locks(?CacheInterface $cache = null, bool $getShared = true) + { + if ($cache instanceof CacheInterface) { + return new LockManager($cache); + } + + if ($getShared) { + return static::getSharedInstance('locks', null); + } + + return new LockManager(AppServices::get('cache')); + } + /** * The CLI Request class provides for ways to interact with * a command line request. diff --git a/system/Lock/Exceptions/LockException.php b/system/Lock/Exceptions/LockException.php new file mode 100644 index 000000000000..1faaebd13d65 --- /dev/null +++ b/system/Lock/Exceptions/LockException.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +class LockException extends FrameworkException +{ + public static function forUnsupportedStore(string $class): self + { + return new self(sprintf('The cache handler "%s" does not support locks.', $class)); + } +} diff --git a/system/Lock/Lock.php b/system/Lock/Lock.php new file mode 100644 index 000000000000..3534f93e16b2 --- /dev/null +++ b/system/Lock/Lock.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock; + +use Closure; +use CodeIgniter\Exceptions\InvalidArgumentException; + +class Lock implements LockInterface +{ + public function __construct( + private readonly LockStoreInterface $store, + private readonly string $key, + private readonly int $ttl, + private readonly string $owner, + ) { + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be a positive integer.'); + } + + if ($owner === '') { + throw new InvalidArgumentException('Lock owner cannot be empty.'); + } + } + + public function acquire(): bool + { + return $this->store->acquireLock($this->key, $this->owner, $this->ttl); + } + + public function block(int $seconds): bool + { + if ($seconds < 1) { + return $this->acquire(); + } + + $expiresAt = microtime(true) + $seconds; + + do { + if ($this->acquire()) { + return true; + } + + usleep(100_000); + } while (microtime(true) < $expiresAt); + + return false; + } + + /** + * @param Closure(): mixed $callback + */ + public function run(Closure $callback, int $waitSeconds = 0): mixed + { + $acquired = $waitSeconds > 0 ? $this->block($waitSeconds) : $this->acquire(); + + if (! $acquired) { + return null; + } + + try { + return $callback(); + } finally { + $this->release(); + } + } + + public function release(): bool + { + return $this->store->releaseLock($this->key, $this->owner); + } + + public function forceRelease(): bool + { + return $this->store->forceReleaseLock($this->key); + } + + public function refresh(?int $ttl = null): bool + { + $ttl ??= $this->ttl; + + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be a positive integer.'); + } + + return $this->store->refreshLock($this->key, $this->owner, $ttl); + } + + public function isAcquired(): bool + { + return $this->store->getLockOwner($this->key) === $this->owner; + } + + public function owner(): string + { + return $this->owner; + } +} diff --git a/system/Lock/LockInterface.php b/system/Lock/LockInterface.php new file mode 100644 index 000000000000..d8091f72f11d --- /dev/null +++ b/system/Lock/LockInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock; + +use Closure; + +interface LockInterface +{ + public function acquire(): bool; + + public function block(int $seconds): bool; + + /** + * @param Closure(): mixed $callback + */ + public function run(Closure $callback, int $waitSeconds = 0): mixed; + + public function release(): bool; + + public function forceRelease(): bool; + + public function refresh(?int $ttl = null): bool; + + public function isAcquired(): bool; + + public function owner(): string; +} diff --git a/system/Lock/LockManager.php b/system/Lock/LockManager.php new file mode 100644 index 000000000000..cf8637222cbe --- /dev/null +++ b/system/Lock/LockManager.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock; + +use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Lock\Exceptions\LockException; + +class LockManager +{ + private const KEY_PREFIX = 'lock_'; + + public function __construct(private readonly CacheInterface $cache) + { + } + + public function create(string $name, int $ttl = 300, ?string $owner = null): LockInterface + { + if ($name === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + + $store = $this->store(); + $key = $this->key($name); + + return new Lock($store, $key, $ttl, $owner ?? bin2hex(random_bytes(16))); + } + + public function restore(string $name, string $owner, int $ttl = 300): LockInterface + { + return $this->create($name, $ttl, $owner); + } + + private function store(): LockStoreInterface + { + if (! $this->cache instanceof LockStoreInterface) { + throw LockException::forUnsupportedStore($this->cache::class); + } + + return $this->cache; + } + + private function key(string $name): string + { + return self::KEY_PREFIX . hash('sha256', $name); + } +} diff --git a/system/Lock/LockStoreInterface.php b/system/Lock/LockStoreInterface.php new file mode 100644 index 000000000000..f045b670725c --- /dev/null +++ b/system/Lock/LockStoreInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock; + +interface LockStoreInterface +{ + public function acquireLock(string $key, string $owner, int $ttl): bool; + + public function releaseLock(string $key, string $owner): bool; + + public function forceReleaseLock(string $key): bool; + + public function refreshLock(string $key, string $owner, int $ttl): bool; + + public function getLockOwner(string $key): ?string; +} From 5855457a0f0502b57095a18cce7070c512765ab6 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:09:28 +0200 Subject: [PATCH 02/13] feat(cache): add lock store support Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Cache/Handlers/FileHandler.php | 163 +++++++++++++++++++++++- system/Cache/Handlers/PredisHandler.php | 57 ++++++++- system/Cache/Handlers/RedisHandler.php | 56 +++++++- 3 files changed, 273 insertions(+), 3 deletions(-) diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index b9b1075d115a..c557da5e8271 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\I18n\Time; +use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use Throwable; @@ -23,7 +24,7 @@ * * @see \CodeIgniter\Cache\Handlers\FileHandlerTest */ -class FileHandler extends BaseHandler +class FileHandler extends BaseHandler implements LockStoreInterface { /** * Maximum key length. @@ -155,6 +156,78 @@ public function decrement(string $key, int $offset = 1): bool|int return $this->increment($key, -$offset); } + public function acquireLock(string $key, string $owner, int $ttl): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { + $data = self::readLockData($handle); + $now = Time::now()->getTimestamp(); + + if ($data !== null && $data['expires'] > $now) { + return false; + } + + return self::writeLockData($handle, $owner, $now + $ttl); + }); + } + + public function releaseLock(string $key, string $owner): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner): bool { + $data = self::readLockData($handle); + + if ($data === null || $data['owner'] !== $owner) { + return false; + } + + return self::clearLockFile($handle); + }); + } + + public function forceReleaseLock(string $key): bool + { + return ! is_file($this->path . static::validateKey($key, $this->prefix)) + || $this->withLockFile($key, static fn ($handle): bool => self::clearLockFile($handle), false); + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { + $data = self::readLockData($handle); + $now = Time::now()->getTimestamp(); + + if ($data === null || $data['owner'] !== $owner || $data['expires'] <= $now) { + return false; + } + + return self::writeLockData($handle, $owner, $now + $ttl); + }); + } + + public function getLockOwner(string $key): ?string + { + $owner = null; + + $this->withLockFile($key, static function ($handle) use (&$owner): bool { + $data = self::readLockData($handle); + + if ($data === null) { + return true; + } + + if ($data['expires'] <= Time::now()->getTimestamp()) { + self::clearLockFile($handle); + + return true; + } + + $owner = $data['owner']; + + return true; + }, false); + + return $owner; + } + public function clean(): bool { return delete_files($this->path, false, true); @@ -229,4 +302,92 @@ protected function getItem(string $filename): array|false return $data; } + + /** + * @param callable(resource): bool $callback + */ + private function withLockFile(string $key, callable $callback, bool $create = true): bool + { + $key = static::validateKey($key, $this->prefix); + $handle = @fopen($this->path . $key, $create ? 'c+b' : 'r+b'); + + if ($handle === false) { + return false; + } + + try { + if (! flock($handle, LOCK_EX)) { + return false; + } + + return $callback($handle); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + + if (is_file($this->path . $key)) { + try { + chmod($this->path . $key, $this->mode); + } catch (Throwable $e) { + log_message('debug', 'Failed to set mode on cache lock file: ' . $e); + } + } + } + } + + /** + * @param resource $handle + * + * @return array{owner: string, expires: int}|null + */ + private static function readLockData($handle): ?array + { + rewind($handle); + + $content = stream_get_contents($handle); + + if ($content === false || $content === '') { + return null; + } + + try { + $data = unserialize($content); + } catch (Throwable) { + return null; + } + + if (! is_array($data) || ! isset($data['owner'], $data['expires']) || ! is_string($data['owner']) || ! is_int($data['expires'])) { + return null; + } + + return $data; + } + + /** + * @param resource $handle + */ + private static function writeLockData($handle, string $owner, int $expires): bool + { + rewind($handle); + + if (! ftruncate($handle, 0)) { + return false; + } + + if (fwrite($handle, serialize(['owner' => $owner, 'expires' => $expires])) === false) { + return false; + } + + return fflush($handle); + } + + /** + * @param resource $handle + */ + private static function clearLockFile($handle): bool + { + rewind($handle); + + return ftruncate($handle, 0) && fflush($handle); + } } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index c868f34550e9..a09d9c689451 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; +use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use Exception; use Predis\Client; @@ -26,7 +27,7 @@ * * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest */ -class PredisHandler extends BaseHandler +class PredisHandler extends BaseHandler implements LockStoreInterface { /** * Default config @@ -167,6 +168,60 @@ public function decrement(string $key, int $offset = 1): int return $this->redis->hincrby($key, 'data', -$offset); } + public function acquireLock(string $key, string $owner, int $ttl): bool + { + $key = static::validateKey($key); + $result = $this->redis->set($key, $owner, 'EX', $ttl, 'NX'); + + return $result instanceof Status && $result->getPayload() === 'OK'; + } + + public function releaseLock(string $key, string $owner): bool + { + $key = static::validateKey($key); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + + return 0 + LUA; + + return $this->redis->eval($script, 1, $key, $owner) === 1; + } + + public function forceReleaseLock(string $key): bool + { + $key = static::validateKey($key); + $deleted = $this->redis->del($key); + + return is_int($deleted) && $deleted >= 0; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + $key = static::validateKey($key); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + end + + return 0 + LUA; + + return $this->redis->eval($script, 1, $key, $owner, (string) $ttl) === 1; + } + + public function getLockOwner(string $key): ?string + { + $key = static::validateKey($key); + $owner = $this->redis->get($key); + + return is_string($owner) ? $owner : null; + } + public function clean(): bool { return $this->redis->flushdb()->getPayload() === 'OK'; diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 05cae32da440..e3f8f409684e 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; +use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use Redis; use RedisException; @@ -24,7 +25,7 @@ * * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest */ -class RedisHandler extends BaseHandler +class RedisHandler extends BaseHandler implements LockStoreInterface { /** * Default config @@ -185,6 +186,59 @@ public function decrement(string $key, int $offset = 1): int return $this->increment($key, -$offset); } + public function acquireLock(string $key, string $owner, int $ttl): bool + { + $key = static::validateKey($key, $this->prefix); + + return (bool) $this->redis->set($key, $owner, ['nx', 'ex' => $ttl]); + } + + public function releaseLock(string $key, string $owner): bool + { + $key = static::validateKey($key, $this->prefix); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + + return 0 + LUA; + + return (int) $this->redis->eval($script, [$key, $owner], 1) === 1; + } + + public function forceReleaseLock(string $key): bool + { + $key = static::validateKey($key, $this->prefix); + $deleted = $this->redis->del($key); + + return is_int($deleted) && $deleted >= 0; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + $key = static::validateKey($key, $this->prefix); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + end + + return 0 + LUA; + + return (int) $this->redis->eval($script, [$key, $owner, $ttl], 1) === 1; + } + + public function getLockOwner(string $key): ?string + { + $key = static::validateKey($key, $this->prefix); + $owner = $this->redis->get($key); + + return is_string($owner) ? $owner : null; + } + public function clean(): bool { return $this->redis->flushDB(); From be8b0e56cc1e746ddf438a63538d74a2fc44deb3 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:09:47 +0200 Subject: [PATCH 03/13] test(lock): cover atomic lock behavior Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- .../Cache/Handlers/MemcachedHandlerTest.php | 4 + .../Cache/Handlers/PredisHandlerTest.php | 24 ++ .../Cache/Handlers/RedisHandlerTest.php | 20 ++ tests/system/Config/ServicesTest.php | 28 +++ tests/system/Lock/LockTest.php | 206 ++++++++++++++++++ 5 files changed, 282 insertions(+) create mode 100644 tests/system/Lock/LockTest.php diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index e6bd5dd147ec..b972db6f7845 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -51,6 +51,10 @@ protected function setUp(): void protected function tearDown(): void { + if (! isset($this->handler)) { + return; + } + foreach (self::getKeyArray() as $key) { $this->handler->delete($key); } diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 135d3ff083de..c8d445b69fa7 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; +use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use PHPUnit\Framework\Attributes\Group; @@ -45,10 +46,18 @@ protected function setUp(): void $this->config = new Cache(); $this->handler = CacheFactory::getHandler($this->config, 'predis'); + + if ($this->handler::class !== PredisHandler::class) { + $this->markTestSkipped('Predis connection not available.'); + } } protected function tearDown(): void { + if (! isset($this->handler)) { + return; + } + foreach (self::getKeyArray() as $key) { $this->handler->delete($key); } @@ -104,6 +113,21 @@ public function testSave(): void $this->assertTrue($this->handler->save(self::$key1, 'value')); } + public function testLockOperations(): void + { + $handler = $this->handler; + + $this->assertInstanceOf(LockStoreInterface::class, $handler); + $this->assertTrue($handler->acquireLock(self::$key1, 'owner1', 60)); + $this->assertFalse($handler->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner1', $handler->getLockOwner(self::$key1)); + $this->assertFalse($handler->releaseLock(self::$key1, 'owner2')); + $this->assertTrue($handler->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($handler->releaseLock(self::$key1, 'owner1')); + $this->assertNull($handler->getLockOwner(self::$key1)); + $this->assertTrue($handler->forceReleaseLock(self::$key1)); + } + public function testSavePermanent(): void { $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index d42123c6dd82..5b0de2243e8b 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; +use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -54,6 +55,10 @@ protected function setUp(): void protected function tearDown(): void { + if (! isset($this->handler)) { + return; + } + foreach (self::getKeyArray() as $key) { $this->handler->delete($key); } @@ -109,6 +114,21 @@ public function testSave(): void $this->assertTrue($this->handler->save(self::$key1, 'value')); } + public function testLockOperations(): void + { + $handler = $this->handler; + + $this->assertInstanceOf(LockStoreInterface::class, $handler); + $this->assertTrue($handler->acquireLock(self::$key1, 'owner1', 60)); + $this->assertFalse($handler->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner1', $handler->getLockOwner(self::$key1)); + $this->assertFalse($handler->releaseLock(self::$key1, 'owner2')); + $this->assertTrue($handler->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($handler->releaseLock(self::$key1, 'owner1')); + $this->assertNull($handler->getLockOwner(self::$key1)); + $this->assertTrue($handler->forceReleaseLock(self::$key1)); + } + public function testSavePermanent(): void { $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 0917ba79a8ca..ba496ae3c6b3 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -32,6 +32,7 @@ use CodeIgniter\HTTP\URI; use CodeIgniter\Images\ImageHandlerInterface; use CodeIgniter\Language\Language; +use CodeIgniter\Lock\LockManager; use CodeIgniter\Pager\Pager; use CodeIgniter\Router\RouteCollection; use CodeIgniter\Router\Router; @@ -46,6 +47,7 @@ use CodeIgniter\Validation\Validation; use CodeIgniter\View\Cell; use CodeIgniter\View\Parser; +use Config\Cache; use Config\Database as DatabaseConfig; use Config\Exceptions; use Config\Security as SecurityConfig; @@ -107,6 +109,32 @@ public function testNewFileLocator(): void $this->assertInstanceOf(FileLocator::class, $actual); } + public function testNewLocks(): void + { + $actual = Services::locks(); + $this->assertInstanceOf(LockManager::class, $actual); + } + + public function testLocksWithCustomCacheIsNotShared(): void + { + $config = new Cache(); + $config->file['storePath'] = WRITEPATH . 'cache/ServicesLockTest'; + + if (! is_dir($config->file['storePath'])) { + mkdir($config->file['storePath'], 0777, true); + } + + try { + $custom = Services::cache($config, false); + + $this->assertInstanceOf(LockManager::class, Services::locks($custom)); + $this->assertNotSame(Services::locks($custom), Services::locks()); + } finally { + delete_files($config->file['storePath'], false, true); + rmdir($config->file['storePath']); + } + } + public function testNewUnsharedFileLocator(): void { $actual = Services::locator(false); diff --git a/tests/system/Lock/LockTest.php b/tests/system/Lock/LockTest.php new file mode 100644 index 000000000000..d53f75b46e28 --- /dev/null +++ b/tests/system/Lock/LockTest.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use CodeIgniter\Lock\Exceptions\LockException; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Cache; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class LockTest extends CIUnitTestCase +{ + private Cache $config; + private LockManager $locks; + + protected function setUp(): void + { + parent::setUp(); + + helper('filesystem'); + + $this->config = new Cache(); + $this->config->file['storePath'] = WRITEPATH . 'cache/LockTest'; + + if (! is_dir($this->config->file['storePath'])) { + mkdir($this->config->file['storePath'], 0777, true); + } + + $this->locks = new LockManager(CacheFactory::getHandler($this->config, 'file')); + } + + protected function tearDown(): void + { + parent::tearDown(); + + Time::setTestNow(); + + if (is_dir($this->config->file['storePath'])) { + delete_files($this->config->file['storePath'], false, true); + rmdir($this->config->file['storePath']); + } + } + + public function testLockCanBeAcquiredAndReleased(): void + { + $lock = $this->locks->create('reports.daily-export', 60); + + $this->assertTrue($lock->acquire()); + $this->assertFileExists($this->lockFile('reports.daily-export')); + $this->assertTrue($lock->isAcquired()); + $this->assertTrue($lock->release()); + $this->assertFalse($lock->isAcquired()); + $this->assertTrue($this->locks->create('reports.daily-export', 60)->acquire()); + } + + public function testCompetingLockCannotBeAcquiredUntilReleased(): void + { + $first = $this->locks->create('reports.daily-export', 60); + $second = $this->locks->create('reports.daily-export', 60); + + $this->assertTrue($first->acquire()); + $this->assertFalse($second->acquire()); + + $this->assertTrue($first->release()); + $this->assertTrue($second->acquire()); + } + + public function testSameLockCannotBeAcquiredTwice(): void + { + $lock = $this->locks->create('reports.daily-export', 60); + + $this->assertTrue($lock->acquire()); + $this->assertFalse($lock->acquire()); + } + + public function testExpiredLockCanBeAcquiredByNewOwner(): void + { + Time::setTestNow('2026-01-01 12:00:00'); + + $first = $this->locks->create('imports.customer-feed', 10); + + $this->assertTrue($first->acquire()); + + Time::setTestNow('2026-01-01 12:00:11'); + + $second = $this->locks->create('imports.customer-feed', 10); + + $this->assertTrue($second->acquire()); + $this->assertFalse($first->isAcquired()); + } + + public function testOnlyOwnerCanReleaseLock(): void + { + $first = $this->locks->create('payments.settlement', 60); + $second = $this->locks->create('payments.settlement', 60); + + $this->assertTrue($first->acquire()); + $this->assertFalse($second->release()); + $this->assertTrue($first->isAcquired()); + } + + public function testForceReleaseIgnoresOwner(): void + { + $first = $this->locks->create('payments.settlement', 60); + $second = $this->locks->create('payments.settlement', 60); + + $this->assertTrue($first->acquire()); + $this->assertTrue($second->forceRelease()); + $this->assertTrue($second->acquire()); + } + + public function testRestoreCanReleaseOwnedLock(): void + { + $lock = $this->locks->create('jobs.unique', 60); + + $this->assertTrue($lock->acquire()); + + $restored = $this->locks->restore('jobs.unique', $lock->owner(), 60); + + $this->assertTrue($restored->isAcquired()); + $this->assertTrue($restored->release()); + $this->assertFalse($lock->isAcquired()); + } + + public function testRefreshRequiresOwner(): void + { + $first = $this->locks->create('cache.rebuild', 60); + $second = $this->locks->create('cache.rebuild', 60); + + $this->assertTrue($first->acquire()); + $this->assertTrue($first->refresh(120)); + $this->assertFalse($second->refresh(120)); + } + + public function testRunReleasesLockAfterCallback(): void + { + $lock = $this->locks->create('notifications.send', 60); + + $this->assertSame('sent', $lock->run(static fn (): string => 'sent')); + $this->assertTrue($this->locks->create('notifications.send', 60)->acquire()); + } + + public function testRunReturnsNullWhenLockCannotBeAcquired(): void + { + $first = $this->locks->create('notifications.send', 60); + $second = $this->locks->create('notifications.send', 60); + + $this->assertTrue($first->acquire()); + $this->assertNull($second->run(static fn (): string => 'sent')); + } + + public function testLogicalNamesCanContainReservedCacheCharacters(): void + { + $lock = $this->locks->create('tenant:1/payments/{settlement}', 60); + + $this->assertTrue($lock->acquire()); + } + + public function testEmptyLockNameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->locks->create(''); + } + + public function testNonPositiveTtlIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be a positive integer.'); + + $this->locks->create('reports.daily-export', 0); + } + + public function testUnsupportedCacheHandlerThrows(): void + { + $locks = new LockManager(CacheFactory::getHandler($this->config, 'dummy')); + + $this->expectException(LockException::class); + $this->expectExceptionMessage('does not support locks'); + + $locks->create('reports.daily-export'); + } + + private function lockFile(string $name): string + { + return rtrim($this->config->file['storePath'], '\\/') . '/lock_' . hash('sha256', $name); + } +} From 9f91cfc4461091de9195623c6ff9a8420f23535b Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:10:00 +0200 Subject: [PATCH 04/13] docs(lock): document atomic locks Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- user_guide_src/source/changelogs/v4.8.0.rst | 1 + user_guide_src/source/libraries/index.rst | 1 + user_guide_src/source/libraries/locks.rst | 162 ++++++++++++++++++ user_guide_src/source/libraries/locks/001.php | 13 ++ user_guide_src/source/libraries/locks/002.php | 5 + user_guide_src/source/libraries/locks/003.php | 11 ++ user_guide_src/source/libraries/locks/004.php | 11 ++ 7 files changed, 204 insertions(+) create mode 100644 user_guide_src/source/libraries/locks.rst create mode 100644 user_guide_src/source/libraries/locks/001.php create mode 100644 user_guide_src/source/libraries/locks/002.php create mode 100644 user_guide_src/source/libraries/locks/003.php create mode 100644 user_guide_src/source/libraries/locks/004.php diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index af6ddecb3e94..706a7024c4bb 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -222,6 +222,7 @@ Libraries - **Context**: This new feature allows you to easily set and retrieve normal or hidden contextual data for the current request. See :ref:`Context ` for details. - **Images:**: Added support for the AVIF file format. +- **Locks:** Added :doc:`Atomic Locks ` for owner-aware, cross-process mutual exclusion backed by supported cache handlers. - **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. - **Logging:** Added :ref:`per-call context logging ` 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``. diff --git a/user_guide_src/source/libraries/index.rst b/user_guide_src/source/libraries/index.rst index a67b4d97e545..0445001977a0 100644 --- a/user_guide_src/source/libraries/index.rst +++ b/user_guide_src/source/libraries/index.rst @@ -15,6 +15,7 @@ Library Reference file_collections honeypot images + locks pagination publisher security diff --git a/user_guide_src/source/libraries/locks.rst b/user_guide_src/source/libraries/locks.rst new file mode 100644 index 000000000000..91bc9c78dc71 --- /dev/null +++ b/user_guide_src/source/libraries/locks.rst @@ -0,0 +1,162 @@ +############ +Atomic Locks +############ + +.. versionadded:: 4.8.0 + +.. contents:: + :local: + :depth: 2 + +Atomic locks provide a simple way to prevent the same task from running +concurrently across requests, CLI commands, or workers that share the same +cache storage. + +Locks are advisory. Your code must acquire the lock before entering the +critical section, and release it when the work is finished. + +************* +Configuration +************* + +The Locks library uses the Cache service. The cache handler must support atomic +lock operations. The built-in **File**, **Redis**, and **Predis** cache handlers +support locks. + +.. note:: Locks are most useful when all competing processes share the same cache + storage. The File handler is suitable for a single server. For multiple + application servers, use a shared handler such as Redis. + +************* +Example Usage +************* + +You can create a lock through the ``locks`` service. The second argument is the +lock TTL, in seconds. The TTL prevents abandoned locks from being held forever +if a process exits unexpectedly. + +.. literalinclude:: locks/001.php + +.. warning:: If the work takes longer than the lock TTL, another process may + acquire the same lock while the first process is still running. For + long-running work, choose a TTL that comfortably covers the operation, call + ``refresh()`` while the lock is held, or check ``isAcquired()`` before + performing irreversible side effects. + +Running a Callback +================== + +The ``run()`` method acquires the lock, runs the callback, and releases the lock +in a ``finally`` block. + +.. literalinclude:: locks/002.php + +If the lock cannot be acquired, ``run()`` returns ``null`` and the callback is +not called. + +Blocking +======== + +The ``block()`` method waits up to the given number of seconds for the lock to +become available: + +.. literalinclude:: locks/003.php + +Restoring a Lock by Owner +========================= + +Each acquired lock has an owner token. You may pass this token to another +process and restore the lock there, for example to release a lock from a queued +worker that continues work started by the current request. + +.. literalinclude:: locks/004.php + +************************ +Locks and Cache Handlers +************************ + +The default File cache handler supports locks, so locks work without additional +configuration in a standard application. + +If the configured cache handler does not support locks, creating a lock throws a +``CodeIgniter\Lock\Exceptions\LockException``. + +Custom cache handlers can support locks by implementing +``CodeIgniter\Lock\LockStoreInterface``. This keeps lock support opt-in and does +not require all cache handlers to implement lock operations. + +*************** +Class Reference +*************** + +.. php:namespace:: CodeIgniter\Lock + +.. php:class:: LockManager + + .. php:method:: create(string $name[, int $ttl = 300[, ?string $owner = null]]) + + :param string $name: The logical lock name. + :param int $ttl: Number of seconds before the lock expires. + :param string|null $owner: Optional owner token. + :returns: A lock instance. + :rtype: LockInterface + + Creates a lock for the given logical name. + + .. php:method:: restore(string $name, string $owner[, int $ttl = 300]) + + :param string $name: The logical lock name. + :param string $owner: The owner token. + :param int $ttl: Number of seconds before the lock expires. + :returns: A lock instance. + :rtype: LockInterface + + Restores a lock instance for an existing owner token. + +.. php:interface:: LockInterface + + .. php:method:: acquire() + + :returns: ``true`` if the lock was acquired, ``false`` otherwise. + :rtype: bool + + .. php:method:: block(int $seconds) + + :param int $seconds: Maximum number of seconds to wait. + :returns: ``true`` if the lock was acquired, ``false`` otherwise. + :rtype: bool + + .. php:method:: run(Closure $callback[, int $waitSeconds = 0]) + + :param Closure $callback: The callback to run while the lock is held. + :param int $waitSeconds: Maximum number of seconds to wait. + :returns: The callback result, or ``null`` if the lock was not acquired. + :rtype: mixed + + .. php:method:: release() + + :returns: ``true`` if the lock was released by its owner. + :rtype: bool + + .. php:method:: forceRelease() + + :returns: ``true`` if the lock was force released. + :rtype: bool + + Releases the lock without checking the owner token. + + .. php:method:: refresh([?int $ttl = null]) + + :param int|null $ttl: Number of seconds before the lock expires. + :returns: ``true`` if the owned lock was refreshed. + :rtype: bool + + .. php:method:: isAcquired() + + :returns: ``true`` if this lock instance still owns the lock. + :rtype: bool + + .. php:method:: owner() + + :returns: The owner token. + :rtype: string diff --git a/user_guide_src/source/libraries/locks/001.php b/user_guide_src/source/libraries/locks/001.php new file mode 100644 index 000000000000..f6a402d96916 --- /dev/null +++ b/user_guide_src/source/libraries/locks/001.php @@ -0,0 +1,13 @@ +create('reports.daily-export', 300); + +if (! $lock->acquire()) { + return; +} + +try { + // Run the work that must not overlap. +} finally { + $lock->release(); +} diff --git a/user_guide_src/source/libraries/locks/002.php b/user_guide_src/source/libraries/locks/002.php new file mode 100644 index 000000000000..b120e8ca711f --- /dev/null +++ b/user_guide_src/source/libraries/locks/002.php @@ -0,0 +1,5 @@ +create('reports.daily-export', 300) + ->run(static fn () => build_daily_report()); diff --git a/user_guide_src/source/libraries/locks/003.php b/user_guide_src/source/libraries/locks/003.php new file mode 100644 index 000000000000..e1e6b49a48bf --- /dev/null +++ b/user_guide_src/source/libraries/locks/003.php @@ -0,0 +1,11 @@ +create('imports.customer-feed', 300); + +if ($lock->block(10)) { + try { + import_customer_feed(); + } finally { + $lock->release(); + } +} diff --git a/user_guide_src/source/libraries/locks/004.php b/user_guide_src/source/libraries/locks/004.php new file mode 100644 index 000000000000..226a8f26e267 --- /dev/null +++ b/user_guide_src/source/libraries/locks/004.php @@ -0,0 +1,11 @@ +create('exports.monthly', 300); + +if ($lock->acquire()) { + queue_export_job($lock->owner()); +} + +// Later, in another process: +$restored = service('locks')->restore('exports.monthly', $owner); +$restored->release(); From 14be1a79ef4fc9b3aba351ea4d398dee0d26f32c Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:06:28 +0200 Subject: [PATCH 05/13] docs(lock): document cache flush behavior Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- user_guide_src/source/libraries/locks.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/user_guide_src/source/libraries/locks.rst b/user_guide_src/source/libraries/locks.rst index 91bc9c78dc71..9e5452b462be 100644 --- a/user_guide_src/source/libraries/locks.rst +++ b/user_guide_src/source/libraries/locks.rst @@ -27,6 +27,12 @@ support locks. storage. The File handler is suitable for a single server. For multiple application servers, use a shared handler such as Redis. +.. important:: Locks are stored in the configured cache handler. Clearing or + flushing that cache storage, for example with ``cache()->clean()`` or a + Redis ``FLUSHDB``, may remove active locks. Avoid clearing shared lock + storage while lock-protected work is running, or use a dedicated cache + store for locks when that separation is important. + ************* Example Usage ************* From c9a8fcec2b27fb07b9a372b59f6a2d6756585f2a Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:11:58 +0200 Subject: [PATCH 06/13] refactor(lock): align store interface and service behavior Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- deptrac.yaml | 1 - system/Cache/Handlers/FileHandler.php | 2 +- system/Cache/Handlers/PredisHandler.php | 2 +- system/Cache/Handlers/RedisHandler.php | 2 +- system/{Lock => Cache}/LockStoreInterface.php | 2 +- system/Config/Services.php | 10 ++++------ system/Lock/Lock.php | 1 + system/Lock/LockManager.php | 1 + tests/system/Cache/Handlers/PredisHandlerTest.php | 2 +- tests/system/Cache/Handlers/RedisHandlerTest.php | 2 +- tests/system/Config/ServicesTest.php | 6 +++--- user_guide_src/source/libraries/locks.rst | 2 +- 12 files changed, 16 insertions(+), 17 deletions(-) rename system/{Lock => Cache}/LockStoreInterface.php (95%) diff --git a/deptrac.yaml b/deptrac.yaml index cc05b439244b..955fda2394ee 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -174,7 +174,6 @@ deptrac: - URI Cache: - I18n - - Lock Controller: - HTTP - Validation diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index c557da5e8271..3f5761613a91 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -14,8 +14,8 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\Exceptions\CacheException; +use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\I18n\Time; -use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use Throwable; diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index a09d9c689451..0dd0742ac6b1 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -13,9 +13,9 @@ namespace CodeIgniter\Cache\Handlers; +use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; -use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use Exception; use Predis\Client; diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index e3f8f409684e..7ae3859f47e4 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -13,9 +13,9 @@ namespace CodeIgniter\Cache\Handlers; +use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; -use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use Redis; use RedisException; diff --git a/system/Lock/LockStoreInterface.php b/system/Cache/LockStoreInterface.php similarity index 95% rename from system/Lock/LockStoreInterface.php rename to system/Cache/LockStoreInterface.php index f045b670725c..a66b74f46359 100644 --- a/system/Lock/LockStoreInterface.php +++ b/system/Cache/LockStoreInterface.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Lock; +namespace CodeIgniter\Cache; interface LockStoreInterface { diff --git a/system/Config/Services.php b/system/Config/Services.php index a039e36963dc..fb7633cf761c 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -139,15 +139,13 @@ public static function cache(?Cache $config = null, bool $getShared = true) */ public static function locks(?CacheInterface $cache = null, bool $getShared = true) { - if ($cache instanceof CacheInterface) { - return new LockManager($cache); - } - if ($getShared) { - return static::getSharedInstance('locks', null); + return static::getSharedInstance('locks', $cache); } - return new LockManager(AppServices::get('cache')); + $cache ??= AppServices::get('cache'); + + return new LockManager($cache); } /** diff --git a/system/Lock/Lock.php b/system/Lock/Lock.php index 3534f93e16b2..7587c03f738c 100644 --- a/system/Lock/Lock.php +++ b/system/Lock/Lock.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Lock; use Closure; +use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Exceptions\InvalidArgumentException; class Lock implements LockInterface diff --git a/system/Lock/LockManager.php b/system/Lock/LockManager.php index cf8637222cbe..e920f4c5a60f 100644 --- a/system/Lock/LockManager.php +++ b/system/Lock/LockManager.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Lock; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Lock\Exceptions\LockException; diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index c8d445b69fa7..f4fabcb852f2 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -14,9 +14,9 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; -use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index 5b0de2243e8b..f84feb25d989 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -14,9 +14,9 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; -use CodeIgniter\Lock\LockStoreInterface; use Config\Cache; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index ba496ae3c6b3..929a3c1c62ce 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -115,7 +115,7 @@ public function testNewLocks(): void $this->assertInstanceOf(LockManager::class, $actual); } - public function testLocksWithCustomCacheIsNotShared(): void + public function testNewUnsharedLocksWithCustomCache(): void { $config = new Cache(); $config->file['storePath'] = WRITEPATH . 'cache/ServicesLockTest'; @@ -127,8 +127,8 @@ public function testLocksWithCustomCacheIsNotShared(): void try { $custom = Services::cache($config, false); - $this->assertInstanceOf(LockManager::class, Services::locks($custom)); - $this->assertNotSame(Services::locks($custom), Services::locks()); + $this->assertInstanceOf(LockManager::class, Services::locks($custom, false)); + $this->assertNotSame(Services::locks($custom, false), Services::locks($custom, false)); } finally { delete_files($config->file['storePath'], false, true); rmdir($config->file['storePath']); diff --git a/user_guide_src/source/libraries/locks.rst b/user_guide_src/source/libraries/locks.rst index 9e5452b462be..a0797282cf5d 100644 --- a/user_guide_src/source/libraries/locks.rst +++ b/user_guide_src/source/libraries/locks.rst @@ -88,7 +88,7 @@ If the configured cache handler does not support locks, creating a lock throws a ``CodeIgniter\Lock\Exceptions\LockException``. Custom cache handlers can support locks by implementing -``CodeIgniter\Lock\LockStoreInterface``. This keeps lock support opt-in and does +``CodeIgniter\Cache\LockStoreInterface``. This keeps lock support opt-in and does not require all cache handlers to implement lock operations. *************** From 678223ffda5dae7611657ce82841e2fa8e6ae733 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:43:55 +0200 Subject: [PATCH 07/13] refactor(lock): move lock stores out of cache handlers - introduce cache lock store provider and dedicated File, Redis, and Predis lock stores - keep cache handlers responsible only for exposing their lock store - update LockManager to resolve lock support through cache lock store providers - refresh memoized Redis and Predis lock stores after reconnect - update Redis and Predis lock tests for provider-based stores and reconnect behavior - document custom lock store extension expectations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Cache/Handlers/FileHandler.php | 171 +--------------- system/Cache/Handlers/PredisHandler.php | 68 ++----- system/Cache/Handlers/RedisHandler.php | 69 ++----- system/Cache/LockStoreProvider.php | 19 ++ system/Cache/LockStores/FileLockStore.php | 189 ++++++++++++++++++ system/Cache/LockStores/PredisLockStore.php | 80 ++++++++ system/Cache/LockStores/RedisLockStore.php | 80 ++++++++ system/Lock/LockManager.php | 24 +-- .../Cache/Handlers/PredisHandlerTest.php | 38 ++-- .../Cache/Handlers/RedisHandlerTest.php | 38 ++-- tests/system/Config/ServicesTest.php | 16 +- tests/system/Lock/LockTest.php | 4 +- user_guide_src/source/libraries/locks.rst | 13 +- 13 files changed, 489 insertions(+), 320 deletions(-) create mode 100644 system/Cache/LockStoreProvider.php create mode 100644 system/Cache/LockStores/FileLockStore.php create mode 100644 system/Cache/LockStores/PredisLockStore.php create mode 100644 system/Cache/LockStores/RedisLockStore.php diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index 3f5761613a91..9b32f29696e0 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -15,6 +15,8 @@ use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStores\FileLockStore; use CodeIgniter\I18n\Time; use Config\Cache; use Throwable; @@ -24,7 +26,7 @@ * * @see \CodeIgniter\Cache\Handlers\FileHandlerTest */ -class FileHandler extends BaseHandler implements LockStoreInterface +class FileHandler extends BaseHandler implements LockStoreProvider { /** * Maximum key length. @@ -48,6 +50,8 @@ class FileHandler extends BaseHandler implements LockStoreInterface */ protected $mode; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. * @@ -156,78 +160,6 @@ public function decrement(string $key, int $offset = 1): bool|int return $this->increment($key, -$offset); } - public function acquireLock(string $key, string $owner, int $ttl): bool - { - return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { - $data = self::readLockData($handle); - $now = Time::now()->getTimestamp(); - - if ($data !== null && $data['expires'] > $now) { - return false; - } - - return self::writeLockData($handle, $owner, $now + $ttl); - }); - } - - public function releaseLock(string $key, string $owner): bool - { - return $this->withLockFile($key, static function ($handle) use ($owner): bool { - $data = self::readLockData($handle); - - if ($data === null || $data['owner'] !== $owner) { - return false; - } - - return self::clearLockFile($handle); - }); - } - - public function forceReleaseLock(string $key): bool - { - return ! is_file($this->path . static::validateKey($key, $this->prefix)) - || $this->withLockFile($key, static fn ($handle): bool => self::clearLockFile($handle), false); - } - - public function refreshLock(string $key, string $owner, int $ttl): bool - { - return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { - $data = self::readLockData($handle); - $now = Time::now()->getTimestamp(); - - if ($data === null || $data['owner'] !== $owner || $data['expires'] <= $now) { - return false; - } - - return self::writeLockData($handle, $owner, $now + $ttl); - }); - } - - public function getLockOwner(string $key): ?string - { - $owner = null; - - $this->withLockFile($key, static function ($handle) use (&$owner): bool { - $data = self::readLockData($handle); - - if ($data === null) { - return true; - } - - if ($data['expires'] <= Time::now()->getTimestamp()) { - self::clearLockFile($handle); - - return true; - } - - $owner = $data['owner']; - - return true; - }, false); - - return $owner; - } - public function clean(): bool { return delete_files($this->path, false, true); @@ -258,6 +190,11 @@ public function isSupported(): bool return is_writable($this->path); } + public function lockStore(): LockStoreInterface + { + return $this->lockStore ??= new FileLockStore($this->path, $this->mode, $this->prefix); + } + /** * Does the heavy lifting of actually retrieving the file and * verifying its age. @@ -302,92 +239,4 @@ protected function getItem(string $filename): array|false return $data; } - - /** - * @param callable(resource): bool $callback - */ - private function withLockFile(string $key, callable $callback, bool $create = true): bool - { - $key = static::validateKey($key, $this->prefix); - $handle = @fopen($this->path . $key, $create ? 'c+b' : 'r+b'); - - if ($handle === false) { - return false; - } - - try { - if (! flock($handle, LOCK_EX)) { - return false; - } - - return $callback($handle); - } finally { - flock($handle, LOCK_UN); - fclose($handle); - - if (is_file($this->path . $key)) { - try { - chmod($this->path . $key, $this->mode); - } catch (Throwable $e) { - log_message('debug', 'Failed to set mode on cache lock file: ' . $e); - } - } - } - } - - /** - * @param resource $handle - * - * @return array{owner: string, expires: int}|null - */ - private static function readLockData($handle): ?array - { - rewind($handle); - - $content = stream_get_contents($handle); - - if ($content === false || $content === '') { - return null; - } - - try { - $data = unserialize($content); - } catch (Throwable) { - return null; - } - - if (! is_array($data) || ! isset($data['owner'], $data['expires']) || ! is_string($data['owner']) || ! is_int($data['expires'])) { - return null; - } - - return $data; - } - - /** - * @param resource $handle - */ - private static function writeLockData($handle, string $owner, int $expires): bool - { - rewind($handle); - - if (! ftruncate($handle, 0)) { - return false; - } - - if (fwrite($handle, serialize(['owner' => $owner, 'expires' => $expires])) === false) { - return false; - } - - return fflush($handle); - } - - /** - * @param resource $handle - */ - private static function clearLockFile($handle): bool - { - rewind($handle); - - return ftruncate($handle, 0) && fflush($handle); - } } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 0dd0742ac6b1..263f89e38b34 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -14,6 +14,8 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStores\PredisLockStore; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; use Config\Cache; @@ -27,7 +29,7 @@ * * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest */ -class PredisHandler extends BaseHandler implements LockStoreInterface +class PredisHandler extends BaseHandler implements LockStoreProvider { /** * Default config @@ -59,6 +61,8 @@ class PredisHandler extends BaseHandler implements LockStoreInterface */ protected $redis; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. */ @@ -72,7 +76,8 @@ public function __construct(Cache $config) public function initialize(): void { try { - $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + $this->lockStore = null; $this->redis->time(); } catch (Exception $e) { throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').', $e->getCode(), $e); @@ -168,60 +173,6 @@ public function decrement(string $key, int $offset = 1): int return $this->redis->hincrby($key, 'data', -$offset); } - public function acquireLock(string $key, string $owner, int $ttl): bool - { - $key = static::validateKey($key); - $result = $this->redis->set($key, $owner, 'EX', $ttl, 'NX'); - - return $result instanceof Status && $result->getPayload() === 'OK'; - } - - public function releaseLock(string $key, string $owner): bool - { - $key = static::validateKey($key); - - $script = <<<'LUA' - if redis.call("get", KEYS[1]) == ARGV[1] then - return redis.call("del", KEYS[1]) - end - - return 0 - LUA; - - return $this->redis->eval($script, 1, $key, $owner) === 1; - } - - public function forceReleaseLock(string $key): bool - { - $key = static::validateKey($key); - $deleted = $this->redis->del($key); - - return is_int($deleted) && $deleted >= 0; - } - - public function refreshLock(string $key, string $owner, int $ttl): bool - { - $key = static::validateKey($key); - - $script = <<<'LUA' - if redis.call("get", KEYS[1]) == ARGV[1] then - return redis.call("expire", KEYS[1], ARGV[2]) - end - - return 0 - LUA; - - return $this->redis->eval($script, 1, $key, $owner, (string) $ttl) === 1; - } - - public function getLockOwner(string $key): ?string - { - $key = static::validateKey($key); - $owner = $this->redis->get($key); - - return is_string($owner) ? $owner : null; - } - public function clean(): bool { return $this->redis->flushdb()->getPayload() === 'OK'; @@ -257,6 +208,11 @@ public function isSupported(): bool return class_exists(Client::class); } + public function lockStore(): LockStoreInterface + { + return $this->lockStore ??= new PredisLockStore($this->redis); + } + public function ping(): bool { try { diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 7ae3859f47e4..6ebca907511b 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -14,6 +14,8 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStores\RedisLockStore; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; use Config\Cache; @@ -25,7 +27,7 @@ * * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest */ -class RedisHandler extends BaseHandler implements LockStoreInterface +class RedisHandler extends BaseHandler implements LockStoreProvider { /** * Default config @@ -55,6 +57,8 @@ class RedisHandler extends BaseHandler implements LockStoreInterface */ protected $redis; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. */ @@ -69,7 +73,8 @@ public function initialize(): void { $config = $this->config; - $this->redis = new Redis(); + $this->redis = new Redis(); + $this->lockStore = null; try { $funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect'; @@ -186,59 +191,6 @@ public function decrement(string $key, int $offset = 1): int return $this->increment($key, -$offset); } - public function acquireLock(string $key, string $owner, int $ttl): bool - { - $key = static::validateKey($key, $this->prefix); - - return (bool) $this->redis->set($key, $owner, ['nx', 'ex' => $ttl]); - } - - public function releaseLock(string $key, string $owner): bool - { - $key = static::validateKey($key, $this->prefix); - - $script = <<<'LUA' - if redis.call("get", KEYS[1]) == ARGV[1] then - return redis.call("del", KEYS[1]) - end - - return 0 - LUA; - - return (int) $this->redis->eval($script, [$key, $owner], 1) === 1; - } - - public function forceReleaseLock(string $key): bool - { - $key = static::validateKey($key, $this->prefix); - $deleted = $this->redis->del($key); - - return is_int($deleted) && $deleted >= 0; - } - - public function refreshLock(string $key, string $owner, int $ttl): bool - { - $key = static::validateKey($key, $this->prefix); - - $script = <<<'LUA' - if redis.call("get", KEYS[1]) == ARGV[1] then - return redis.call("expire", KEYS[1], ARGV[2]) - end - - return 0 - LUA; - - return (int) $this->redis->eval($script, [$key, $owner, $ttl], 1) === 1; - } - - public function getLockOwner(string $key): ?string - { - $key = static::validateKey($key, $this->prefix); - $owner = $this->redis->get($key); - - return is_string($owner) ? $owner : null; - } - public function clean(): bool { return $this->redis->flushDB(); @@ -273,6 +225,13 @@ public function isSupported(): bool return extension_loaded('redis'); } + public function lockStore(): LockStoreInterface + { + assert($this->redis instanceof Redis); + + return $this->lockStore ??= new RedisLockStore($this->redis, $this->prefix); + } + public function ping(): bool { if (! isset($this->redis)) { diff --git a/system/Cache/LockStoreProvider.php b/system/Cache/LockStoreProvider.php new file mode 100644 index 000000000000..1c71bc70e2a1 --- /dev/null +++ b/system/Cache/LockStoreProvider.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +interface LockStoreProvider +{ + public function lockStore(): LockStoreInterface; +} diff --git a/system/Cache/LockStores/FileLockStore.php b/system/Cache/LockStores/FileLockStore.php new file mode 100644 index 000000000000..28d2d4790dc2 --- /dev/null +++ b/system/Cache/LockStores/FileLockStore.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\LockStores; + +use CodeIgniter\Cache\Handlers\FileHandler; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\I18n\Time; +use Throwable; + +class FileLockStore implements LockStoreInterface +{ + public function __construct( + private readonly string $path, + private readonly int $mode, + private readonly string $prefix = '', + ) { + } + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { + $data = self::readLockData($handle); + $now = Time::now()->getTimestamp(); + + if ($data !== null && $data['expires'] > $now) { + return false; + } + + return self::writeLockData($handle, $owner, $now + $ttl); + }); + } + + public function releaseLock(string $key, string $owner): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner): bool { + $data = self::readLockData($handle); + + if ($data === null || $data['owner'] !== $owner) { + return false; + } + + return self::clearLockFile($handle); + }); + } + + public function forceReleaseLock(string $key): bool + { + return ! is_file($this->path . FileHandler::validateKey($key, $this->prefix)) + || $this->withLockFile($key, static fn ($handle): bool => self::clearLockFile($handle), false); + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { + $data = self::readLockData($handle); + $now = Time::now()->getTimestamp(); + + if ($data === null || $data['owner'] !== $owner || $data['expires'] <= $now) { + return false; + } + + return self::writeLockData($handle, $owner, $now + $ttl); + }); + } + + public function getLockOwner(string $key): ?string + { + $owner = null; + + $this->withLockFile($key, static function ($handle) use (&$owner): bool { + $data = self::readLockData($handle); + + if ($data === null) { + return true; + } + + if ($data['expires'] <= Time::now()->getTimestamp()) { + self::clearLockFile($handle); + + return true; + } + + $owner = $data['owner']; + + return true; + }, false); + + return $owner; + } + + /** + * @param callable(resource): bool $callback + */ + private function withLockFile(string $key, callable $callback, bool $create = true): bool + { + $key = FileHandler::validateKey($key, $this->prefix); + $handle = @fopen($this->path . $key, $create ? 'c+b' : 'r+b'); + + if ($handle === false) { + return false; + } + + try { + if (! flock($handle, LOCK_EX)) { + return false; + } + + return $callback($handle); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + + if (is_file($this->path . $key)) { + try { + chmod($this->path . $key, $this->mode); + } catch (Throwable $e) { + log_message('debug', 'Failed to set mode on cache lock file: ' . $e); + } + } + } + } + + /** + * @param resource $handle + * + * @return array{owner: string, expires: int}|null + */ + private static function readLockData($handle): ?array + { + rewind($handle); + + $content = stream_get_contents($handle); + + if ($content === false || $content === '') { + return null; + } + + try { + $data = unserialize($content); + } catch (Throwable) { + return null; + } + + if (! is_array($data) || ! isset($data['owner'], $data['expires']) || ! is_string($data['owner']) || ! is_int($data['expires'])) { + return null; + } + + return $data; + } + + /** + * @param resource $handle + */ + private static function writeLockData($handle, string $owner, int $expires): bool + { + rewind($handle); + + if (! ftruncate($handle, 0)) { + return false; + } + + if (fwrite($handle, serialize(['owner' => $owner, 'expires' => $expires])) === false) { + return false; + } + + return fflush($handle); + } + + /** + * @param resource $handle + */ + private static function clearLockFile($handle): bool + { + rewind($handle); + + return ftruncate($handle, 0) && fflush($handle); + } +} diff --git a/system/Cache/LockStores/PredisLockStore.php b/system/Cache/LockStores/PredisLockStore.php new file mode 100644 index 000000000000..d15d3c6394eb --- /dev/null +++ b/system/Cache/LockStores/PredisLockStore.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\LockStores; + +use CodeIgniter\Cache\Handlers\PredisHandler; +use CodeIgniter\Cache\LockStoreInterface; +use Predis\Client; +use Predis\Response\Status; + +class PredisLockStore implements LockStoreInterface +{ + public function __construct(private readonly Client $redis) + { + } + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + $key = PredisHandler::validateKey($key); + $result = $this->redis->set($key, $owner, 'EX', $ttl, 'NX'); + + return $result instanceof Status && $result->getPayload() === 'OK'; + } + + public function releaseLock(string $key, string $owner): bool + { + $key = PredisHandler::validateKey($key); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + + return 0 + LUA; + + return $this->redis->eval($script, 1, $key, $owner) === 1; + } + + public function forceReleaseLock(string $key): bool + { + $key = PredisHandler::validateKey($key); + $deleted = $this->redis->del($key); + + return is_int($deleted) && $deleted >= 0; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + $key = PredisHandler::validateKey($key); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + end + + return 0 + LUA; + + return $this->redis->eval($script, 1, $key, $owner, (string) $ttl) === 1; + } + + public function getLockOwner(string $key): ?string + { + $key = PredisHandler::validateKey($key); + $owner = $this->redis->get($key); + + return is_string($owner) ? $owner : null; + } +} diff --git a/system/Cache/LockStores/RedisLockStore.php b/system/Cache/LockStores/RedisLockStore.php new file mode 100644 index 000000000000..76623bc82c70 --- /dev/null +++ b/system/Cache/LockStores/RedisLockStore.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\LockStores; + +use CodeIgniter\Cache\Handlers\RedisHandler; +use CodeIgniter\Cache\LockStoreInterface; +use Redis; + +class RedisLockStore implements LockStoreInterface +{ + public function __construct( + private readonly Redis $redis, + private readonly string $prefix = '', + ) { + } + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + + return (bool) $this->redis->set($key, $owner, ['nx', 'ex' => $ttl]); + } + + public function releaseLock(string $key, string $owner): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + + return 0 + LUA; + + return (int) $this->redis->eval($script, [$key, $owner], 1) === 1; + } + + public function forceReleaseLock(string $key): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + $deleted = $this->redis->del($key); + + return is_int($deleted) && $deleted >= 0; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + end + + return 0 + LUA; + + return (int) $this->redis->eval($script, [$key, $owner, $ttl], 1) === 1; + } + + public function getLockOwner(string $key): ?string + { + $key = RedisHandler::validateKey($key, $this->prefix); + $owner = $this->redis->get($key); + + return is_string($owner) ? $owner : null; + } +} diff --git a/system/Lock/LockManager.php b/system/Lock/LockManager.php index e920f4c5a60f..88202b6b7627 100644 --- a/system/Lock/LockManager.php +++ b/system/Lock/LockManager.php @@ -15,6 +15,7 @@ use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Lock\Exceptions\LockException; @@ -22,8 +23,15 @@ class LockManager { private const KEY_PREFIX = 'lock_'; - public function __construct(private readonly CacheInterface $cache) + private readonly LockStoreInterface $store; + + public function __construct(CacheInterface $cache) { + if (! $cache instanceof LockStoreProvider) { + throw LockException::forUnsupportedStore($cache::class); + } + + $this->store = $cache->lockStore(); } public function create(string $name, int $ttl = 300, ?string $owner = null): LockInterface @@ -32,10 +40,7 @@ public function create(string $name, int $ttl = 300, ?string $owner = null): Loc throw new InvalidArgumentException('Lock name cannot be empty.'); } - $store = $this->store(); - $key = $this->key($name); - - return new Lock($store, $key, $ttl, $owner ?? bin2hex(random_bytes(16))); + return new Lock($this->store, $this->key($name), $ttl, $owner ?? bin2hex(random_bytes(16))); } public function restore(string $name, string $owner, int $ttl = 300): LockInterface @@ -43,15 +48,6 @@ public function restore(string $name, string $owner, int $ttl = 300): LockInterf return $this->create($name, $ttl, $owner); } - private function store(): LockStoreInterface - { - if (! $this->cache instanceof LockStoreInterface) { - throw LockException::forUnsupportedStore($this->cache::class); - } - - return $this->cache; - } - private function key(string $name): string { return self::KEY_PREFIX . hash('sha256', $name); diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index f4fabcb852f2..4e26c9890e39 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -117,15 +118,19 @@ public function testLockOperations(): void { $handler = $this->handler; - $this->assertInstanceOf(LockStoreInterface::class, $handler); - $this->assertTrue($handler->acquireLock(self::$key1, 'owner1', 60)); - $this->assertFalse($handler->acquireLock(self::$key1, 'owner2', 60)); - $this->assertSame('owner1', $handler->getLockOwner(self::$key1)); - $this->assertFalse($handler->releaseLock(self::$key1, 'owner2')); - $this->assertTrue($handler->refreshLock(self::$key1, 'owner1', 120)); - $this->assertTrue($handler->releaseLock(self::$key1, 'owner1')); - $this->assertNull($handler->getLockOwner(self::$key1)); - $this->assertTrue($handler->forceReleaseLock(self::$key1)); + $this->assertInstanceOf(LockStoreProvider::class, $handler); + + $store = $handler->lockStore(); + + $this->assertInstanceOf(LockStoreInterface::class, $store); + $this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60)); + $this->assertFalse($store->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner1', $store->getLockOwner(self::$key1)); + $this->assertFalse($store->releaseLock(self::$key1, 'owner2')); + $this->assertTrue($store->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($store->releaseLock(self::$key1, 'owner1')); + $this->assertNull($store->getLockOwner(self::$key1)); + $this->assertTrue($store->forceReleaseLock(self::$key1)); } public function testSavePermanent(): void @@ -223,11 +228,18 @@ public function testPing(): void public function testReconnect(): void { - $this->handler->save(self::$key1, 'value'); - $this->assertSame('value', $this->handler->get(self::$key1)); + $handler = $this->handler; - $this->assertTrue($this->handler->reconnect()); + $this->assertInstanceOf(LockStoreProvider::class, $handler); - $this->assertSame('value', $this->handler->get(self::$key1)); + $lockStore = $handler->lockStore(); + + $handler->save(self::$key1, 'value'); + $this->assertSame('value', $handler->get(self::$key1)); + + $this->assertTrue($handler->reconnect()); + + $this->assertSame('value', $handler->get(self::$key1)); + $this->assertNotSame($lockStore, $handler->lockStore()); } } diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index f84feb25d989..a0583a6347a8 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -118,15 +119,19 @@ public function testLockOperations(): void { $handler = $this->handler; - $this->assertInstanceOf(LockStoreInterface::class, $handler); - $this->assertTrue($handler->acquireLock(self::$key1, 'owner1', 60)); - $this->assertFalse($handler->acquireLock(self::$key1, 'owner2', 60)); - $this->assertSame('owner1', $handler->getLockOwner(self::$key1)); - $this->assertFalse($handler->releaseLock(self::$key1, 'owner2')); - $this->assertTrue($handler->refreshLock(self::$key1, 'owner1', 120)); - $this->assertTrue($handler->releaseLock(self::$key1, 'owner1')); - $this->assertNull($handler->getLockOwner(self::$key1)); - $this->assertTrue($handler->forceReleaseLock(self::$key1)); + $this->assertInstanceOf(LockStoreProvider::class, $handler); + + $store = $handler->lockStore(); + + $this->assertInstanceOf(LockStoreInterface::class, $store); + $this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60)); + $this->assertFalse($store->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner1', $store->getLockOwner(self::$key1)); + $this->assertFalse($store->releaseLock(self::$key1, 'owner2')); + $this->assertTrue($store->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($store->releaseLock(self::$key1, 'owner1')); + $this->assertNull($store->getLockOwner(self::$key1)); + $this->assertTrue($store->forceReleaseLock(self::$key1)); } public function testSavePermanent(): void @@ -261,11 +266,18 @@ public function testPing(): void public function testReconnect(): void { - $this->handler->save(self::$key1, 'value'); - $this->assertSame('value', $this->handler->get(self::$key1)); + $handler = $this->handler; - $this->assertTrue($this->handler->reconnect()); + $this->assertInstanceOf(LockStoreProvider::class, $handler); - $this->assertSame('value', $this->handler->get(self::$key1)); + $lockStore = $handler->lockStore(); + + $handler->save(self::$key1, 'value'); + $this->assertSame('value', $handler->get(self::$key1)); + + $this->assertTrue($handler->reconnect()); + + $this->assertSame('value', $handler->get(self::$key1)); + $this->assertNotSame($lockStore, $handler->lockStore()); } } diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 929a3c1c62ce..9790a217501f 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -111,8 +111,20 @@ public function testNewFileLocator(): void public function testNewLocks(): void { - $actual = Services::locks(); - $this->assertInstanceOf(LockManager::class, $actual); + $config = new Cache(); + $config->file['storePath'] = WRITEPATH . 'cache/ServicesLockTest'; + + if (! is_dir($config->file['storePath'])) { + mkdir($config->file['storePath'], 0777, true); + } + + try { + $actual = Services::locks(Services::cache($config, false)); + $this->assertInstanceOf(LockManager::class, $actual); + } finally { + delete_files($config->file['storePath'], false, true); + rmdir($config->file['storePath']); + } } public function testNewUnsharedLocksWithCustomCache(): void diff --git a/tests/system/Lock/LockTest.php b/tests/system/Lock/LockTest.php index d53f75b46e28..9829387434d2 100644 --- a/tests/system/Lock/LockTest.php +++ b/tests/system/Lock/LockTest.php @@ -191,12 +191,10 @@ public function testNonPositiveTtlIsRejected(): void public function testUnsupportedCacheHandlerThrows(): void { - $locks = new LockManager(CacheFactory::getHandler($this->config, 'dummy')); - $this->expectException(LockException::class); $this->expectExceptionMessage('does not support locks'); - $locks->create('reports.daily-export'); + new LockManager(CacheFactory::getHandler($this->config, 'dummy')); } private function lockFile(string $name): string diff --git a/user_guide_src/source/libraries/locks.rst b/user_guide_src/source/libraries/locks.rst index a0797282cf5d..ec8896144cd1 100644 --- a/user_guide_src/source/libraries/locks.rst +++ b/user_guide_src/source/libraries/locks.rst @@ -84,12 +84,19 @@ Locks and Cache Handlers The default File cache handler supports locks, so locks work without additional configuration in a standard application. -If the configured cache handler does not support locks, creating a lock throws a +If the configured cache handler does not support locks, resolving the ``locks`` +service or constructing a lock manager throws a ``CodeIgniter\Lock\Exceptions\LockException``. Custom cache handlers can support locks by implementing -``CodeIgniter\Cache\LockStoreInterface``. This keeps lock support opt-in and does -not require all cache handlers to implement lock operations. +``CodeIgniter\Cache\LockStoreProvider`` and returning a +``CodeIgniter\Cache\LockStoreInterface`` instance. This keeps lock support +opt-in and does not require all cache handlers to implement lock operations. + +Custom lock stores must implement owner-aware acquisition, release, refresh, +force release, and owner lookup methods. The owner token is used to ensure +``release()`` and ``refresh()`` only affect the process that currently owns the +lock. *************** Class Reference From 67b45cae3f9da7b38105cf8e14545d0408cc6b33 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:56:33 +0200 Subject: [PATCH 08/13] test(lock): add lock support to mock cache - add an in-memory mock lock store for test environments - make MockCache provide the lock store contract used by LockManager - keep service('locks') usable under CIUnitTestCase's mocked cache Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Test/Mock/MockCache.php | 12 +++- system/Test/Mock/MockLockStore.php | 88 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 system/Test/Mock/MockLockStore.php diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 27af8a1ad213..a2d012726b0e 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -16,10 +16,12 @@ use Closure; use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\Handlers\BaseHandler; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; use CodeIgniter\I18n\Time; use PHPUnit\Framework\Assert; -class MockCache extends BaseHandler implements CacheInterface +class MockCache extends BaseHandler implements CacheInterface, LockStoreProvider { /** * Mock cache storage. @@ -42,6 +44,8 @@ class MockCache extends BaseHandler implements CacheInterface */ protected $bypass = false; + private ?MockLockStore $lockStore = null; + /** * Takes care of any handler-specific setup that must be done. */ @@ -180,6 +184,7 @@ public function clean(): true { $this->cache = []; $this->expirations = []; + $this->lockStore?->clean(); return true; } @@ -227,6 +232,11 @@ public function isSupported(): bool return true; } + public function lockStore(): LockStoreInterface + { + return $this->lockStore ??= new MockLockStore(); + } + // -------------------------------------------------------------------- // Test Helpers // -------------------------------------------------------------------- diff --git a/system/Test/Mock/MockLockStore.php b/system/Test/Mock/MockLockStore.php new file mode 100644 index 000000000000..a59042db806c --- /dev/null +++ b/system/Test/Mock/MockLockStore.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Mock; + +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\I18n\Time; + +class MockLockStore implements LockStoreInterface +{ + /** + * @var array + */ + private array $locks = []; + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + if ($this->getLockOwner($key) !== null) { + return false; + } + + $this->locks[$key] = [ + 'owner' => $owner, + 'expires' => Time::now()->getTimestamp() + $ttl, + ]; + + return true; + } + + public function releaseLock(string $key, string $owner): bool + { + if ($this->getLockOwner($key) !== $owner) { + return false; + } + + unset($this->locks[$key]); + + return true; + } + + public function forceReleaseLock(string $key): bool + { + unset($this->locks[$key]); + + return true; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + if ($this->getLockOwner($key) !== $owner) { + return false; + } + + $this->locks[$key]['expires'] = Time::now()->getTimestamp() + $ttl; + + return true; + } + + public function getLockOwner(string $key): ?string + { + if (! isset($this->locks[$key])) { + return null; + } + + if ($this->locks[$key]['expires'] <= Time::now()->getTimestamp()) { + unset($this->locks[$key]); + + return null; + } + + return $this->locks[$key]['owner']; + } + + public function clean(): void + { + $this->locks = []; + } +} From 2819c58efba28c69311f08139bc4b5be319a9ca6 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:45:01 +0200 Subject: [PATCH 09/13] fix(lock): return false when run cannot acquire - return false from run() when lock acquisition fails - update lock docs and tests for the new return value - list LockManager in the core classes user guide Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Lock/Lock.php | 2 +- tests/system/Lock/LockTest.php | 4 ++-- user_guide_src/source/extending/core_classes.rst | 1 + user_guide_src/source/libraries/locks.rst | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/system/Lock/Lock.php b/system/Lock/Lock.php index 7587c03f738c..6fa07929d5c0 100644 --- a/system/Lock/Lock.php +++ b/system/Lock/Lock.php @@ -66,7 +66,7 @@ public function run(Closure $callback, int $waitSeconds = 0): mixed $acquired = $waitSeconds > 0 ? $this->block($waitSeconds) : $this->acquire(); if (! $acquired) { - return null; + return false; } try { diff --git a/tests/system/Lock/LockTest.php b/tests/system/Lock/LockTest.php index 9829387434d2..8418c798c73b 100644 --- a/tests/system/Lock/LockTest.php +++ b/tests/system/Lock/LockTest.php @@ -157,13 +157,13 @@ public function testRunReleasesLockAfterCallback(): void $this->assertTrue($this->locks->create('notifications.send', 60)->acquire()); } - public function testRunReturnsNullWhenLockCannotBeAcquired(): void + public function testRunReturnsFalseWhenLockCannotBeAcquired(): void { $first = $this->locks->create('notifications.send', 60); $second = $this->locks->create('notifications.send', 60); $this->assertTrue($first->acquire()); - $this->assertNull($second->run(static fn (): string => 'sent')); + $this->assertFalse($second->run(static fn (): string => 'sent')); } public function testLogicalNamesCanContainReservedCacheCharacters(): void diff --git a/user_guide_src/source/extending/core_classes.rst b/user_guide_src/source/extending/core_classes.rst index 2e4b1ac134a3..00ed323f04d6 100644 --- a/user_guide_src/source/extending/core_classes.rst +++ b/user_guide_src/source/extending/core_classes.rst @@ -51,6 +51,7 @@ The following is a list of the core system classes that are invoked every time C * ``CodeIgniter\HTTP\SiteURIFactory`` * ``CodeIgniter\HTTP\URI`` * ``CodeIgniter\HTTP\UserAgent`` (if launched over HTTP) +* ``CodeIgniter\Lock\LockManager`` * ``CodeIgniter\Log\Logger`` * ``CodeIgniter\Log\Handlers\BaseHandler`` * ``CodeIgniter\Log\Handlers\FileHandler`` diff --git a/user_guide_src/source/libraries/locks.rst b/user_guide_src/source/libraries/locks.rst index ec8896144cd1..e61ee4c26d29 100644 --- a/user_guide_src/source/libraries/locks.rst +++ b/user_guide_src/source/libraries/locks.rst @@ -57,7 +57,7 @@ in a ``finally`` block. .. literalinclude:: locks/002.php -If the lock cannot be acquired, ``run()`` returns ``null`` and the callback is +If the lock cannot be acquired, ``run()`` returns ``false`` and the callback is not called. Blocking @@ -143,7 +143,7 @@ Class Reference :param Closure $callback: The callback to run while the lock is held. :param int $waitSeconds: Maximum number of seconds to wait. - :returns: The callback result, or ``null`` if the lock was not acquired. + :returns: The callback result, or ``false`` if the lock was not acquired. :rtype: mixed .. php:method:: release() From 9c0e47509a697f2cacb9ceccd23badc484cc5080 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:04:48 +0200 Subject: [PATCH 10/13] refactor(lock): polish lock store review feedback - Rename the lock store provider contract for interface consistency - Finalize immutable lock manager and lock implementations - Add native service return type and interface method docs - Localize lock exception messages - Clarify file lock cleanup and custom store docs - Address file force-release race pre-check and Predis prefix notes Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Cache/Handlers/FileHandler.php | 10 ++++---- system/Cache/Handlers/PredisHandler.php | 5 ++-- system/Cache/Handlers/RedisHandler.php | 4 ++-- system/Cache/LockStoreInterface.php | 15 ++++++++++++ ...der.php => LockStoreProviderInterface.php} | 5 +++- system/Cache/LockStores/FileLockStore.php | 3 +-- system/Config/Services.php | 4 +--- system/Language/en/Lock.php | 17 ++++++++++++++ system/Lock/Exceptions/LockException.php | 2 +- system/Lock/Lock.php | 14 ++++++----- system/Lock/LockInterface.php | 23 +++++++++++++++++++ system/Lock/LockManager.php | 8 +++---- system/Test/Mock/MockCache.php | 4 ++-- .../Cache/Handlers/PredisHandlerTest.php | 6 ++--- .../Cache/Handlers/RedisHandlerTest.php | 6 ++--- user_guide_src/source/libraries/locks.rst | 16 +++++++++---- 16 files changed, 105 insertions(+), 37 deletions(-) rename system/Cache/{LockStoreProvider.php => LockStoreProviderInterface.php} (76%) create mode 100644 system/Language/en/Lock.php diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index 9b32f29696e0..d53b20203c86 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -15,7 +15,7 @@ use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\Cache\LockStoreInterface; -use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\Cache\LockStores\FileLockStore; use CodeIgniter\I18n\Time; use Config\Cache; @@ -26,7 +26,7 @@ * * @see \CodeIgniter\Cache\Handlers\FileHandlerTest */ -class FileHandler extends BaseHandler implements LockStoreProvider +class FileHandler extends BaseHandler implements LockStoreProviderInterface { /** * Maximum key length. @@ -50,7 +50,7 @@ class FileHandler extends BaseHandler implements LockStoreProvider */ protected $mode; - private ?LockStoreInterface $lockStore = null; + private readonly LockStoreInterface $lockStore; /** * Note: Use `CacheFactory::getHandler()` to instantiate. @@ -74,6 +74,8 @@ public function __construct(Cache $config) $this->mode = $options['mode']; $this->prefix = $config->prefix; + $this->lockStore = new FileLockStore($this->path, $this->mode, $this->prefix); + helper('filesystem'); } @@ -192,7 +194,7 @@ public function isSupported(): bool public function lockStore(): LockStoreInterface { - return $this->lockStore ??= new FileLockStore($this->path, $this->mode, $this->prefix); + return $this->lockStore; } /** diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 263f89e38b34..02ea87313d60 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -14,7 +14,7 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\LockStoreInterface; -use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\Cache\LockStores\PredisLockStore; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; @@ -29,7 +29,7 @@ * * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest */ -class PredisHandler extends BaseHandler implements LockStoreProvider +class PredisHandler extends BaseHandler implements LockStoreProviderInterface { /** * Default config @@ -210,6 +210,7 @@ public function isSupported(): bool public function lockStore(): LockStoreInterface { + // Predis applies the configured prefix at the client level. return $this->lockStore ??= new PredisLockStore($this->redis); } diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 6ebca907511b..8a4549f2ee68 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -14,7 +14,7 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\LockStoreInterface; -use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\Cache\LockStores\RedisLockStore; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; @@ -27,7 +27,7 @@ * * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest */ -class RedisHandler extends BaseHandler implements LockStoreProvider +class RedisHandler extends BaseHandler implements LockStoreProviderInterface { /** * Default config diff --git a/system/Cache/LockStoreInterface.php b/system/Cache/LockStoreInterface.php index a66b74f46359..ba900e292b24 100644 --- a/system/Cache/LockStoreInterface.php +++ b/system/Cache/LockStoreInterface.php @@ -15,13 +15,28 @@ interface LockStoreInterface { + /** + * Attempts to acquire a lock for the given owner and TTL. + */ public function acquireLock(string $key, string $owner, int $ttl): bool; + /** + * Releases the lock only when it is currently held by the given owner. + */ public function releaseLock(string $key, string $owner): bool; + /** + * Releases the lock without checking ownership. + */ public function forceReleaseLock(string $key): bool; + /** + * Extends the lock TTL only when it is currently held by the given owner. + */ public function refreshLock(string $key, string $owner, int $ttl): bool; + /** + * Returns the current owner token, or null when the lock is not held. + */ public function getLockOwner(string $key): ?string; } diff --git a/system/Cache/LockStoreProvider.php b/system/Cache/LockStoreProviderInterface.php similarity index 76% rename from system/Cache/LockStoreProvider.php rename to system/Cache/LockStoreProviderInterface.php index 1c71bc70e2a1..7984b8038d26 100644 --- a/system/Cache/LockStoreProvider.php +++ b/system/Cache/LockStoreProviderInterface.php @@ -13,7 +13,10 @@ namespace CodeIgniter\Cache; -interface LockStoreProvider +interface LockStoreProviderInterface { + /** + * Returns the atomic lock store for this cache handler. + */ public function lockStore(): LockStoreInterface; } diff --git a/system/Cache/LockStores/FileLockStore.php b/system/Cache/LockStores/FileLockStore.php index 28d2d4790dc2..d0bc4ce61d13 100644 --- a/system/Cache/LockStores/FileLockStore.php +++ b/system/Cache/LockStores/FileLockStore.php @@ -56,8 +56,7 @@ public function releaseLock(string $key, string $owner): bool public function forceReleaseLock(string $key): bool { - return ! is_file($this->path . FileHandler::validateKey($key, $this->prefix)) - || $this->withLockFile($key, static fn ($handle): bool => self::clearLockFile($handle), false); + return $this->withLockFile($key, static fn ($handle): bool => self::clearLockFile($handle)); } public function refreshLock(string $key, string $owner, int $ttl): bool diff --git a/system/Config/Services.php b/system/Config/Services.php index fb7633cf761c..51244718e221 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -134,10 +134,8 @@ public static function cache(?Cache $config = null, bool $getShared = true) /** * The locks service provides atomic locks backed by supported cache handlers. - * - * @return LockManager */ - public static function locks(?CacheInterface $cache = null, bool $getShared = true) + public static function locks(?CacheInterface $cache = null, bool $getShared = true): LockManager { if ($getShared) { return static::getSharedInstance('locks', $cache); diff --git a/system/Language/en/Lock.php b/system/Language/en/Lock.php new file mode 100644 index 000000000000..ce87c6e9d72d --- /dev/null +++ b/system/Language/en/Lock.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// Lock language settings +return [ + 'unsupportedStore' => 'The cache handler "{0}" does not support locks.', +]; diff --git a/system/Lock/Exceptions/LockException.php b/system/Lock/Exceptions/LockException.php index 1faaebd13d65..8f6785040a66 100644 --- a/system/Lock/Exceptions/LockException.php +++ b/system/Lock/Exceptions/LockException.php @@ -19,6 +19,6 @@ class LockException extends FrameworkException { public static function forUnsupportedStore(string $class): self { - return new self(sprintf('The cache handler "%s" does not support locks.', $class)); + return new self(lang('Lock.unsupportedStore', [$class])); } } diff --git a/system/Lock/Lock.php b/system/Lock/Lock.php index 6fa07929d5c0..d6d7e764bccf 100644 --- a/system/Lock/Lock.php +++ b/system/Lock/Lock.php @@ -17,13 +17,15 @@ use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Exceptions\InvalidArgumentException; -class Lock implements LockInterface +final readonly class Lock implements LockInterface { + private const BLOCK_RETRY_MICROSECONDS = 100_000; + public function __construct( - private readonly LockStoreInterface $store, - private readonly string $key, - private readonly int $ttl, - private readonly string $owner, + private LockStoreInterface $store, + private string $key, + private int $ttl, + private string $owner, ) { if ($ttl < 1) { throw new InvalidArgumentException('Lock TTL must be a positive integer.'); @@ -52,7 +54,7 @@ public function block(int $seconds): bool return true; } - usleep(100_000); + usleep(self::BLOCK_RETRY_MICROSECONDS); } while (microtime(true) < $expiresAt); return false; diff --git a/system/Lock/LockInterface.php b/system/Lock/LockInterface.php index d8091f72f11d..9aa45f8fb472 100644 --- a/system/Lock/LockInterface.php +++ b/system/Lock/LockInterface.php @@ -17,22 +17,45 @@ interface LockInterface { + /** + * Attempts to acquire the lock immediately. + */ public function acquire(): bool; + /** + * Attempts to acquire the lock, waiting up to the given number of seconds. + */ public function block(int $seconds): bool; /** + * Runs the callback while the lock is held. + * * @param Closure(): mixed $callback */ public function run(Closure $callback, int $waitSeconds = 0): mixed; + /** + * Releases the lock only if this instance still owns it. + */ public function release(): bool; + /** + * Releases the lock without checking ownership. + */ public function forceRelease(): bool; + /** + * Extends the lock TTL only if this instance still owns it. + */ public function refresh(?int $ttl = null): bool; + /** + * Checks whether this instance still owns the lock. + */ public function isAcquired(): bool; + /** + * Returns this instance's owner token. + */ public function owner(): string; } diff --git a/system/Lock/LockManager.php b/system/Lock/LockManager.php index 88202b6b7627..df8faa762dd5 100644 --- a/system/Lock/LockManager.php +++ b/system/Lock/LockManager.php @@ -15,19 +15,19 @@ use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\LockStoreInterface; -use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Lock\Exceptions\LockException; -class LockManager +final readonly class LockManager { private const KEY_PREFIX = 'lock_'; - private readonly LockStoreInterface $store; + private LockStoreInterface $store; public function __construct(CacheInterface $cache) { - if (! $cache instanceof LockStoreProvider) { + if (! $cache instanceof LockStoreProviderInterface) { throw LockException::forUnsupportedStore($cache::class); } diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index a2d012726b0e..393246a8d0a9 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -17,11 +17,11 @@ use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\Handlers\BaseHandler; use CodeIgniter\Cache\LockStoreInterface; -use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\I18n\Time; use PHPUnit\Framework\Assert; -class MockCache extends BaseHandler implements CacheInterface, LockStoreProvider +class MockCache extends BaseHandler implements CacheInterface, LockStoreProviderInterface { /** * Mock cache storage. diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 4e26c9890e39..3b317df2299e 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -15,7 +15,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\LockStoreInterface; -use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -118,7 +118,7 @@ public function testLockOperations(): void { $handler = $this->handler; - $this->assertInstanceOf(LockStoreProvider::class, $handler); + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); $store = $handler->lockStore(); @@ -230,7 +230,7 @@ public function testReconnect(): void { $handler = $this->handler; - $this->assertInstanceOf(LockStoreProvider::class, $handler); + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); $lockStore = $handler->lockStore(); diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index a0583a6347a8..72a66f04df1b 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -15,7 +15,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\LockStoreInterface; -use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -119,7 +119,7 @@ public function testLockOperations(): void { $handler = $this->handler; - $this->assertInstanceOf(LockStoreProvider::class, $handler); + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); $store = $handler->lockStore(); @@ -268,7 +268,7 @@ public function testReconnect(): void { $handler = $this->handler; - $this->assertInstanceOf(LockStoreProvider::class, $handler); + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); $lockStore = $handler->lockStore(); diff --git a/user_guide_src/source/libraries/locks.rst b/user_guide_src/source/libraries/locks.rst index e61ee4c26d29..312e5295e54f 100644 --- a/user_guide_src/source/libraries/locks.rst +++ b/user_guide_src/source/libraries/locks.rst @@ -33,6 +33,11 @@ support locks. storage while lock-protected work is running, or use a dedicated cache store for locks when that separation is important. +.. note:: File-backed locks clear released and expired lock contents, but may + leave empty lock files in the cache directory. These files do not represent + active locks and may be removed by normal cache cleanup when no + lock-protected work is running. + ************* Example Usage ************* @@ -89,14 +94,17 @@ service or constructing a lock manager throws a ``CodeIgniter\Lock\Exceptions\LockException``. Custom cache handlers can support locks by implementing -``CodeIgniter\Cache\LockStoreProvider`` and returning a +``CodeIgniter\Cache\LockStoreProviderInterface`` and returning a ``CodeIgniter\Cache\LockStoreInterface`` instance. This keeps lock support opt-in and does not require all cache handlers to implement lock operations. Custom lock stores must implement owner-aware acquisition, release, refresh, -force release, and owner lookup methods. The owner token is used to ensure -``release()`` and ``refresh()`` only affect the process that currently owns the -lock. +force release, and owner lookup methods. ``acquireLock()`` should atomically +claim the lock for an owner token and TTL. ``releaseLock()`` and +``refreshLock()`` must only affect the lock when the supplied owner token still +matches the current owner. ``forceReleaseLock()`` intentionally ignores +ownership, and ``getLockOwner()`` should return ``null`` when the lock is absent +or expired. *************** Class Reference From 8075fcc4a7fbeb6da4194694ee5abca456adb76d Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:25:08 +0200 Subject: [PATCH 11/13] refactor(lock): use JSON for file lock metadata - Replace file lock serialize/unserialize metadata handling with JSON - Keep explicit owner and expiry validation after decoding - Use JSON_THROW_ON_ERROR for encode and decode failures" Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Cache/LockStores/FileLockStore.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/system/Cache/LockStores/FileLockStore.php b/system/Cache/LockStores/FileLockStore.php index d0bc4ce61d13..0e732098ce70 100644 --- a/system/Cache/LockStores/FileLockStore.php +++ b/system/Cache/LockStores/FileLockStore.php @@ -146,7 +146,7 @@ private static function readLockData($handle): ?array } try { - $data = unserialize($content); + $data = json_decode($content, true, flags: JSON_THROW_ON_ERROR); } catch (Throwable) { return null; } @@ -169,7 +169,13 @@ private static function writeLockData($handle, string $owner, int $expires): boo return false; } - if (fwrite($handle, serialize(['owner' => $owner, 'expires' => $expires])) === false) { + try { + $content = json_encode(['owner' => $owner, 'expires' => $expires], JSON_THROW_ON_ERROR); + } catch (Throwable) { + return false; + } + + if (fwrite($handle, $content) === false) { return false; } From 500466afcedeaf72b7efa5eeaf1ac8890504da6e Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:51:47 +0200 Subject: [PATCH 12/13] refactor(lock): document store requirement and use xxh128 - Document LockManager cache-store requirements and exception behavior - Use xxh128 for stable non-cryptographic lock keys - Update file lock test helper for the new hash algorithm Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Lock/LockManager.php | 7 ++++++- tests/system/Lock/LockTest.php | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/system/Lock/LockManager.php b/system/Lock/LockManager.php index df8faa762dd5..ca56e9a4631e 100644 --- a/system/Lock/LockManager.php +++ b/system/Lock/LockManager.php @@ -25,6 +25,11 @@ private LockStoreInterface $store; + /** + * @param CacheInterface $cache Cache handler that must also implement LockStoreProviderInterface. + * + * @throws LockException When the cache handler does not support locks. + */ public function __construct(CacheInterface $cache) { if (! $cache instanceof LockStoreProviderInterface) { @@ -50,6 +55,6 @@ public function restore(string $name, string $owner, int $ttl = 300): LockInterf private function key(string $name): string { - return self::KEY_PREFIX . hash('sha256', $name); + return self::KEY_PREFIX . hash('xxh128', $name); } } diff --git a/tests/system/Lock/LockTest.php b/tests/system/Lock/LockTest.php index 8418c798c73b..cfd11d3f1c39 100644 --- a/tests/system/Lock/LockTest.php +++ b/tests/system/Lock/LockTest.php @@ -199,6 +199,6 @@ public function testUnsupportedCacheHandlerThrows(): void private function lockFile(string $name): string { - return rtrim($this->config->file['storePath'], '\\/') . '/lock_' . hash('sha256', $name); + return rtrim($this->config->file['storePath'], '\\/') . '/lock_' . hash('xxh128', $name); } } From 25474c1ae96a9387d10d993ba72bc2160266f771 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:01:39 +0200 Subject: [PATCH 13/13] test(lock): document unsupported store path for analysis - Add intersection PHPDoc for LockManager cache-store requirements - Keep the runtime LockException for unsupported cache handlers - Add targeted PHPStan ignore for the test that intentionally exercises the exception path" Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Lock/LockManager.php | 2 +- tests/system/Lock/LockTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/system/Lock/LockManager.php b/system/Lock/LockManager.php index ca56e9a4631e..83aa4fa0a182 100644 --- a/system/Lock/LockManager.php +++ b/system/Lock/LockManager.php @@ -26,7 +26,7 @@ private LockStoreInterface $store; /** - * @param CacheInterface $cache Cache handler that must also implement LockStoreProviderInterface. + * @param CacheInterface&LockStoreProviderInterface $cache Cache handler that supports lock stores. * * @throws LockException When the cache handler does not support locks. */ diff --git a/tests/system/Lock/LockTest.php b/tests/system/Lock/LockTest.php index cfd11d3f1c39..b308915f13f1 100644 --- a/tests/system/Lock/LockTest.php +++ b/tests/system/Lock/LockTest.php @@ -194,6 +194,7 @@ public function testUnsupportedCacheHandlerThrows(): void $this->expectException(LockException::class); $this->expectExceptionMessage('does not support locks'); + // @phpstan-ignore argument.type new LockManager(CacheFactory::getHandler($this->config, 'dummy')); }