diff --git a/deptrac.yaml b/deptrac.yaml index a5bb70811e4c..955fda2394ee 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 @@ -207,6 +211,8 @@ deptrac: Images: - Files - I18n + Lock: + - Cache Model: - Database - DataCaster diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index b9b1075d115a..9b32f29696e0 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -14,6 +14,9 @@ namespace CodeIgniter\Cache\Handlers; 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; @@ -23,7 +26,7 @@ * * @see \CodeIgniter\Cache\Handlers\FileHandlerTest */ -class FileHandler extends BaseHandler +class FileHandler extends BaseHandler implements LockStoreProvider { /** * Maximum key length. @@ -47,6 +50,8 @@ class FileHandler extends BaseHandler */ protected $mode; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. * @@ -185,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. diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index c868f34550e9..263f89e38b34 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -13,6 +13,9 @@ 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; @@ -26,7 +29,7 @@ * * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest */ -class PredisHandler extends BaseHandler +class PredisHandler extends BaseHandler implements LockStoreProvider { /** * Default config @@ -58,6 +61,8 @@ class PredisHandler extends BaseHandler */ protected $redis; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. */ @@ -71,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); @@ -202,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 05cae32da440..6ebca907511b 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -13,6 +13,9 @@ 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; @@ -24,7 +27,7 @@ * * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest */ -class RedisHandler extends BaseHandler +class RedisHandler extends BaseHandler implements LockStoreProvider { /** * Default config @@ -54,6 +57,8 @@ class RedisHandler extends BaseHandler */ protected $redis; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. */ @@ -68,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'; @@ -219,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/LockStoreInterface.php b/system/Cache/LockStoreInterface.php new file mode 100644 index 000000000000..a66b74f46359 --- /dev/null +++ b/system/Cache/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\Cache; + +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; +} 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/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..fb7633cf761c 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,22 @@ 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 ($getShared) { + return static::getSharedInstance('locks', $cache); + } + + $cache ??= AppServices::get('cache'); + + return new LockManager($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..7587c03f738c --- /dev/null +++ b/system/Lock/Lock.php @@ -0,0 +1,109 @@ + + * + * 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\Cache\LockStoreInterface; +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..88202b6b7627 --- /dev/null +++ b/system/Lock/LockManager.php @@ -0,0 +1,55 @@ + + * + * 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\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Lock\Exceptions\LockException; + +class LockManager +{ + private const KEY_PREFIX = 'lock_'; + + 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 + { + if ($name === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + + 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 + { + return $this->create($name, $ttl, $owner); + } + + private function key(string $name): string + { + return self::KEY_PREFIX . hash('sha256', $name); + } +} 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 = []; + } +} 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..4e26c9890e39 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -14,6 +14,8 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -45,10 +47,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 +114,25 @@ public function testSave(): void $this->assertTrue($this->handler->save(self::$key1, 'value')); } + public function testLockOperations(): void + { + $handler = $this->handler; + + $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 { $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); @@ -199,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 d42123c6dd82..a0583a6347a8 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -14,6 +14,8 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProvider; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -54,6 +56,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 +115,25 @@ public function testSave(): void $this->assertTrue($this->handler->save(self::$key1, 'value')); } + public function testLockOperations(): void + { + $handler = $this->handler; + + $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 { $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); @@ -241,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 0917ba79a8ca..9790a217501f 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,44 @@ public function testNewFileLocator(): void $this->assertInstanceOf(FileLocator::class, $actual); } + public function testNewLocks(): void + { + $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 + { + $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, false)); + $this->assertNotSame(Services::locks($custom, false), Services::locks($custom, false)); + } 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..9829387434d2 --- /dev/null +++ b/tests/system/Lock/LockTest.php @@ -0,0 +1,204 @@ + + * + * 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 + { + $this->expectException(LockException::class); + $this->expectExceptionMessage('does not support locks'); + + new LockManager(CacheFactory::getHandler($this->config, 'dummy')); + } + + private function lockFile(string $name): string + { + return rtrim($this->config->file['storePath'], '\\/') . '/lock_' . hash('sha256', $name); + } +} 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..ec8896144cd1 --- /dev/null +++ b/user_guide_src/source/libraries/locks.rst @@ -0,0 +1,175 @@ +############ +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. + +.. 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 +************* + +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, 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\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 +*************** + +.. 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();