From 88fbdb68084b8376432871778a9f6fb825ac824a 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 1/5] 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 3c3bddb26146..5bdfa5ecd30e 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -46,6 +46,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; @@ -119,6 +120,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 82025723d48c..06ea10181100 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -46,6 +46,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; @@ -130,6 +131,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 d8b403b8061270ccc2abd043f219174dd0c42e40 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 2/5] 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 5232f875020e7cbed4fc793e05f690c241e69a28 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 3/5] 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 ce8502e1df5b42b5f7010f07c27c72a2227df35a 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 4/5] 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 a9a33ee50165..7d76279d997b 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 85955c64c8b74864f32c90c0f02776e82e15cb4b 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 5/5] 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 *************