From 7abae635c98e918b45d99181ca502e7cf1c28abf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 28 Jul 2026 10:17:53 +0600 Subject: [PATCH] updated locking mechanism --- README.md | 23 ++++ docs/adapters/memcached.rst | 2 + docs/adapters/pdo.rst | 9 ++ docs/adapters/redis.rst | 2 + docs/adapters/sqlite.rst | 5 + docs/adapters/valkey.rst | 2 + docs/cache.rst | 8 +- docs/metrics-and-locking.rst | 56 +++++++- docs/node/_content.inc | 2 +- src/Cache/Cache.php | 2 + src/Cache/CacheReadRememberTrait.php | 6 +- src/Cache/Lock/FileLockProvider.php | 19 ++- src/Cache/Lock/LockHandle.php | 1 + src/Cache/Lock/LockProviderInterface.php | 4 +- src/Cache/Lock/MemcachedLockProvider.php | 41 +++++- src/Cache/Lock/PdoLockProvider.php | 130 ++++++++++++++---- src/Cache/Lock/PollingLockProviderHelpers.php | 26 +++- src/Cache/Lock/RedisLockProvider.php | 24 +++- tests/Cache/CacheFeaturesTest.php | 30 ++-- tests/Cache/LockProviderTest.php | 78 +++++++++++ tests/Node/NodeCacheTest.php | 15 +- 21 files changed, 423 insertions(+), 62 deletions(-) create mode 100644 tests/Cache/LockProviderTest.php diff --git a/README.md b/README.md index de56e89..e080cc7 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,29 @@ $cache->invalidateTag('users'); $metrics = $cache->exportMetrics(); ``` +## Lock leases + +`remember()` uses the configured lock provider to prevent concurrent cache +fills. Lock providers expose an explicit lease and renewal contract: + +```php +$handle = $locks->acquire('reports:daily', waitSeconds: 2, leaseSeconds: 30); + +if ($handle !== null) { + try { + // Renew before the lease expires when the protected work is long-lived. + $locks->refresh($handle, leaseSeconds: 30); + } finally { + $locks->release($handle); + } +} +``` + +Redis and Valkey use token-checked Lua operations. Memcached uses CAS ownership +checks. MySQL and PostgreSQL use connection-scoped advisory locks. File locks +retain an open `flock`; SQLite and other PDO drivers safely fall back to that +file-lock implementation. Release is always ownership guarded and best effort. + ## Tiered Flow (L1 -> L2 -> DB) ```php diff --git a/docs/adapters/memcached.rst b/docs/adapters/memcached.rst index bb12cc4..d2f495a 100644 --- a/docs/adapters/memcached.rst +++ b/docs/adapters/memcached.rst @@ -18,6 +18,8 @@ Highlights: * distributed in-memory cache * ``getMulti`` based batch reads * factory auto-configures ``MemcachedLockProvider`` for ``remember()`` when using this adapter +* lock leases use ``add`` acquisition and CAS-guarded renewal/release so an + expired owner's cleanup cannot delete a replacement owner's lock You may pass your own preconfigured ``Memcached`` client. diff --git a/docs/adapters/pdo.rst b/docs/adapters/pdo.rst index 90cc3fa..89c0ab2 100644 --- a/docs/adapters/pdo.rst +++ b/docs/adapters/pdo.rst @@ -24,6 +24,15 @@ Highlights: - MySQL/MariaDB: native ``ON DUPLICATE KEY UPDATE`` - fallback path for other PDO drivers * batched ``multiFetch()`` via single ``IN (...)`` query +* MySQL/MariaDB locking uses bounded connection-scoped named locks +* PostgreSQL locking uses the two-key advisory-lock form +* SQLite and other PDO drivers without advisory locks use an injected + ``FileLockProvider`` fallback + +PDO advisory locks remain owned by their creating connection until explicit +release or connection loss. ``refresh()`` verifies local token ownership and +connection health. The provider rejects re-entrant acquisition of the same +lock through one provider instance. Examples: diff --git a/docs/adapters/redis.rst b/docs/adapters/redis.rst index b82bcfc..ab63093 100644 --- a/docs/adapters/redis.rst +++ b/docs/adapters/redis.rst @@ -19,6 +19,8 @@ Highlights: * ``MGET`` batch retrieval * TTL via ``SETEX`` when expiration is set * factory auto-configures ``RedisLockProvider`` for ``remember()`` when using this adapter +* lock ownership uses random tokens with ``SET NX PX`` acquisition and atomic + Lua renewal/release DSN notes: diff --git a/docs/adapters/sqlite.rst b/docs/adapters/sqlite.rst index 6a06201..c467047 100644 --- a/docs/adapters/sqlite.rst +++ b/docs/adapters/sqlite.rst @@ -16,6 +16,11 @@ Equivalent behavior: Use ``Cache::pdo(...)`` directly if you want to switch to MySQL/MariaDB/PostgreSQL without changing the rest of your cache usage pattern. +SQLite does not provide the cross-process advisory-lock contract required by +``LockProviderInterface``. Its ``PdoLockProvider`` therefore delegates locking +to ``FileLockProvider``. All coordinating processes must use the same writable +lock directory and filesystem. + Example ------- diff --git a/docs/adapters/valkey.rst b/docs/adapters/valkey.rst index 9236252..a108596 100644 --- a/docs/adapters/valkey.rst +++ b/docs/adapters/valkey.rst @@ -19,6 +19,8 @@ Highlights: * namespace-prefixed keys * ``MGET`` batch retrieval * factory auto-configures ``RedisLockProvider`` for ``remember()`` +* lock ownership uses random tokens with atomic Redis-compatible lease renewal + and release DSN notes: diff --git a/docs/cache.rst b/docs/cache.rst index a0cc14d..683be9a 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -197,7 +197,7 @@ Stampede-Safe ``remember()`` Behavior: 1. Read existing value. -2. On miss, acquire lock (``FileLockProvider`` by default). +2. On miss, acquire a bounded lock lease (``FileLockProvider`` by default). 3. Re-check value under lock. 4. Compute and save value. 5. Apply jitter to TTL to reduce herd effects. @@ -218,6 +218,12 @@ Factory defaults: * ``Cache::pdo(...)`` / ``Cache::sqlite(...)`` auto-configure ``PdoLockProvider`` * other adapters default to ``FileLockProvider`` +The built-in ``remember()`` miss path requests a 30-second lease and waits up +to five seconds for ownership. Cache hits do not acquire or inspect a lock. +Keep resolvers within the lease duration. For longer operations, coordinate +explicitly through ``LockProviderInterface`` and renew with ``refresh()`` as +described in :doc:`metrics-and-locking`. + Metrics and Export Hooks ------------------------ diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst index 8fbc6ce..b18be76 100644 --- a/docs/metrics-and-locking.rst +++ b/docs/metrics-and-locking.rst @@ -67,16 +67,66 @@ Custom lock providers can implement ``LockProviderInterface``: interface LockProviderInterface { - public function acquire(string $key, float $waitSeconds): ?LockHandle; + public function acquire( + string $key, + float $waitSeconds, + float $leaseSeconds = 30.0, + ): ?LockHandle; + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool; + public function release(?LockHandle $handle): void; } -``LockHandle`` carries key/token/resource metadata used by providers to release locks safely. +``acquire()`` returns ``null`` when ownership cannot be obtained within the +bounded wait. A positive lease is mandatory. Long-running operations should +call ``refresh()`` before an expiring distributed lease elapses and stop safely +when renewal returns ``false``. Always call ``release()`` from ``finally``. + +.. code-block:: php + + $handle = $locks->acquire('reports:daily', waitSeconds: 2, leaseSeconds: 30); + + if ($handle !== null) { + try { + runReportBatch(); + + if (!$locks->refresh($handle, leaseSeconds: 30)) { + throw new RuntimeException('Report lock ownership was lost.'); + } + } finally { + $locks->release($handle); + } + } + +``LockHandle`` carries the key, random ownership token, provider resource, and +original lease duration. Do not construct or modify handles in application +code. + +Provider semantics: + +* Redis/Valkey acquire with ``SET NX PX`` and renew/release with token-checked + Lua scripts. +* Memcached uses ``add`` for acquisition and CAS for ownership-safe renewal and + release. +* MySQL/MariaDB and PostgreSQL use connection-scoped advisory locks. Renewal + verifies ownership and connection health; the lock remains held until + release or connection loss. +* File locks retain an open ``flock`` until release. Renewal verifies that the + owned file resource is still open. +* SQLite and PDO drivers without native advisory locks use the file provider + fallback. Use the same writable lock directory in every process that must + coordinate. + +Release is best effort and ownership guarded. Distributed leases may disappear +after expiry, eviction, backend restart, or connection loss; callers must not +assume a successful initial acquisition guarantees permanent ownership. Adapter defaults: * Redis adapter factory sets ``RedisLockProvider`` * Valkey adapter factory sets ``RedisLockProvider`` * Memcached adapter factory sets ``MemcachedLockProvider`` -* PDO/SQLite adapter factories set ``PdoLockProvider`` +* PDO/SQLite adapter factories set ``PdoLockProvider``; SQLite uses its + file-lock fallback * all other adapters use ``FileLockProvider`` by default diff --git a/docs/node/_content.inc b/docs/node/_content.inc index 65e0f1d..4593242 100644 --- a/docs/node/_content.inc +++ b/docs/node/_content.inc @@ -167,7 +167,7 @@ Configuration reference ``lockProvider`` Optional ``LockProviderInterface`` used by ``remember()``. When omitted, Node Cache uses its local file-lock default. Supply a shared - ``RedisLockProvider`` or ``ValkeyLockProvider`` when multiple application + ``RedisLockProvider`` backed by Redis or Valkey when multiple application nodes must coordinate one ``remember()`` resolver. .. code-block:: php diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 500cf99..dc78e02 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -38,6 +38,8 @@ final class Cache implements CacheInterface private const int STAMPEDE_JITTER_PERCENT = 8; + private const float STAMPEDE_LOCK_LEASE_SECONDS = 30.0; + private const float STAMPEDE_LOCK_WAIT_SECONDS = 5.0; private const string TAG_META_PREFIX = '__im_tagm_'; diff --git a/src/Cache/CacheReadRememberTrait.php b/src/Cache/CacheReadRememberTrait.php index 7205607..4575142 100644 --- a/src/Cache/CacheReadRememberTrait.php +++ b/src/Cache/CacheReadRememberTrait.php @@ -159,7 +159,11 @@ public function remember( return $item->get(); } - $lockHandle = $this->lockProvider->acquire($this->stampedeLockKey($key), self::STAMPEDE_LOCK_WAIT_SECONDS); + $lockHandle = $this->lockProvider->acquire( + $this->stampedeLockKey($key), + self::STAMPEDE_LOCK_WAIT_SECONDS, + self::STAMPEDE_LOCK_LEASE_SECONDS, + ); try { $lockedItem = $this->getItem($key); diff --git a/src/Cache/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index 23c7c57..e5a5090 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -23,8 +23,12 @@ public function __construct( $this->retrySleepMicros = max(1_000, $retrySleepMicros); } - public function acquire(string $key, float $waitSeconds): ?LockHandle + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { + if ($leaseSeconds <= 0) { + throw new \InvalidArgumentException('Lock lease duration must be positive.'); + } + $activeLocks = &self::activeRegistry(); if (isset($activeLocks[$key])) { return null; @@ -60,7 +64,16 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle } $activeLocks[$key] = true; - return new LockHandle($key, $token, $handle); + return new LockHandle($key, $token, $handle, $leaseSeconds); + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + if ($leaseSeconds <= 0) { + throw new \InvalidArgumentException('Lock lease duration must be positive.'); + } + + return $handle instanceof LockHandle && is_resource($handle->resource); } public function release(?LockHandle $handle): void @@ -91,8 +104,8 @@ private static function &activeRegistry(): array } /** + * @param string $path The path argument. * @phpstan-return resource|false - * @param string $path The path argument. */ private function openLockFile(string $path): mixed { diff --git a/src/Cache/Lock/LockHandle.php b/src/Cache/Lock/LockHandle.php index 1805e5b..13af86f 100644 --- a/src/Cache/Lock/LockHandle.php +++ b/src/Cache/Lock/LockHandle.php @@ -10,5 +10,6 @@ public function __construct( public string $key, public string $token, public mixed $resource = null, + public float $leaseSeconds = 0.0, ) {} } diff --git a/src/Cache/Lock/LockProviderInterface.php b/src/Cache/Lock/LockProviderInterface.php index 464fc69..05f1f8d 100644 --- a/src/Cache/Lock/LockProviderInterface.php +++ b/src/Cache/Lock/LockProviderInterface.php @@ -6,7 +6,9 @@ interface LockProviderInterface { - public function acquire(string $key, float $waitSeconds): ?LockHandle; + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle; + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool; public function release(?LockHandle $handle): void; } diff --git a/src/Cache/Lock/MemcachedLockProvider.php b/src/Cache/Lock/MemcachedLockProvider.php index 2144204..cbca9a7 100644 --- a/src/Cache/Lock/MemcachedLockProvider.php +++ b/src/Cache/Lock/MemcachedLockProvider.php @@ -24,23 +24,56 @@ public function __construct( $this->retrySleepMicros = self::normalizeRetrySleepMicros($retrySleepMicros); } - public function acquire(string $key, float $waitSeconds): ?LockHandle + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { - $ttlSeconds = max(1, (int) ceil($waitSeconds + 1.0)); + $ttlSeconds = self::leaseSeconds($leaseSeconds); return $this->acquireWithRetry( $this->prefix, $key, $waitSeconds, + $leaseSeconds, fn(string $lockKey, string $token): bool => $this->memcached->add($lockKey, $token, $ttlSeconds), ); } + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + if (!$handle instanceof LockHandle) { + return false; + } + + $ttlSeconds = self::leaseSeconds($leaseSeconds); + $values = $this->memcached->getMulti([$handle->key], \Memcached::GET_EXTENDED); + if (!is_array($values)) { + return false; + } + + $entry = $values[$handle->key] ?? null; + if (!is_array($entry) || ($entry['value'] ?? null) !== $handle->token) { + return false; + } + + $casToken = $entry['cas'] ?? null; + if (!is_float($casToken) && !is_int($casToken)) { + return false; + } + + return $this->memcached->cas((float) $casToken, $handle->key, $handle->token, $ttlSeconds); + } + public function release(?LockHandle $handle): void { $this->releaseWithGuard($handle, function (LockHandle $lock): void { - $current = $this->memcached->get($lock->key); - if ($this->memcached->getResultCode() === \Memcached::RES_SUCCESS && $current === $lock->token) { + $values = $this->memcached->getMulti([$lock->key], \Memcached::GET_EXTENDED); + $entry = is_array($values) ? ($values[$lock->key] ?? null) : null; + $casToken = is_array($entry) ? ($entry['cas'] ?? null) : null; + if ( + is_array($entry) + && ($entry['value'] ?? null) === $lock->token + && (is_float($casToken) || is_int($casToken)) + && $this->memcached->cas((float) $casToken, $lock->key, $lock->token, 1) + ) { $this->memcached->delete($lock->key); } }); diff --git a/src/Cache/Lock/PdoLockProvider.php b/src/Cache/Lock/PdoLockProvider.php index 0240403..f219532 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -6,10 +6,13 @@ use Throwable; -final readonly class PdoLockProvider implements LockProviderInterface +final class PdoLockProvider implements LockProviderInterface { use GeneratesLockTokens; + /** @var array */ + private array $activeTokens = []; + private string $driver; private int $retrySleepMicros; @@ -25,12 +28,31 @@ public function __construct( $this->driver = is_string($driver) ? $driver : ''; } - public function acquire(string $key, float $waitSeconds): ?LockHandle + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { + if ($leaseSeconds <= 0) { + throw new \InvalidArgumentException('Lock lease duration must be positive.'); + } + return match ($this->driver) { - 'mysql', 'mariadb' => $this->acquireMysql($key, $waitSeconds), - 'pgsql' => $this->acquirePgsql($key, $waitSeconds), - default => $this->fallback->acquire($key, $waitSeconds), + 'mysql', 'mariadb' => $this->acquireMysql($key, $waitSeconds, $leaseSeconds), + 'pgsql' => $this->acquirePgsql($key, $waitSeconds, $leaseSeconds), + default => $this->fallback->acquire($key, $waitSeconds, $leaseSeconds), + }; + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + if ($leaseSeconds <= 0) { + throw new \InvalidArgumentException('Lock lease duration must be positive.'); + } + if (!$handle instanceof LockHandle) { + return false; + } + + return match ($this->driver) { + 'mysql', 'mariadb', 'pgsql' => $this->owns($handle) && $this->connectionAlive(), + default => $this->fallback->refresh($handle, $leaseSeconds), }; } @@ -40,24 +62,49 @@ public function release(?LockHandle $handle): void return; } - match ($this->driver) { + if (!in_array($this->driver, ['mysql', 'mariadb', 'pgsql'], true)) { + $this->fallback->release($handle); + + return; + } + if (!$this->owns($handle)) { + return; + } + + $released = match ($this->driver) { 'mysql', 'mariadb' => $this->releaseMysql($handle), 'pgsql' => $this->releasePgsql($handle), - default => $this->fallback->release($handle), }; + if ($released) { + unset($this->activeTokens[$handle->key]); + } } - private static function signedCrc32(string $value): int + /** @return array{int, int} */ + private static function advisoryKeys(string $value): array { - $u = crc32($value); + $digest = hash('sha256', $value); - return $u > 0x7FFFFFFF ? $u - 0x100000000 : $u; + return [ + self::signedHex32(substr($digest, 0, 8)), + self::signedHex32(substr($digest, 8, 8)), + ]; } - private function acquireMysql(string $key, float $waitSeconds): ?LockHandle + private static function signedHex32(string $value): int + { + $unsigned = (int) hexdec($value); + + return $unsigned > 0x7FFFFFFF ? $unsigned - 0x100000000 : $unsigned; + } + + private function acquireMysql(string $key, float $waitSeconds, float $leaseSeconds): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); - $lockKey = $this->prefix . self::digestLockKey($key); + $lockKey = self::digestLockKey($this->prefix . $key); + if (isset($this->activeTokens[$lockKey])) { + return null; + } $token = self::generateToken(); if ($token === null) { return null; @@ -69,7 +116,9 @@ private function acquireMysql(string $key, float $waitSeconds): ?LockHandle $stmt->execute([':k' => $lockKey]); $result = $stmt->fetchColumn(); if ((string) $result === '1') { - return new LockHandle($lockKey, $token); + $this->activeTokens[$lockKey] = $token; + + return new LockHandle($lockKey, $token, leaseSeconds: $leaseSeconds); } } catch (Throwable) { return null; @@ -83,11 +132,14 @@ private function acquireMysql(string $key, float $waitSeconds): ?LockHandle } while (true); } - private function acquirePgsql(string $key, float $waitSeconds): ?LockHandle + private function acquirePgsql(string $key, float $waitSeconds, float $leaseSeconds): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); - $lockKey = $this->prefix . self::digestLockKey($key); - $advisoryKey = self::signedCrc32($lockKey); + $lockKey = self::digestLockKey($this->prefix . $key); + if (isset($this->activeTokens[$lockKey])) { + return null; + } + $advisoryKeys = self::advisoryKeys($lockKey); $token = self::generateToken(); if ($token === null) { return null; @@ -95,11 +147,13 @@ private function acquirePgsql(string $key, float $waitSeconds): ?LockHandle do { try { - $stmt = $this->pdo->prepare('SELECT pg_try_advisory_lock(:k)'); - $stmt->execute([':k' => $advisoryKey]); + $stmt = $this->pdo->prepare('SELECT pg_try_advisory_lock(:k1, :k2)'); + $stmt->execute([':k1' => $advisoryKeys[0], ':k2' => $advisoryKeys[1]]); $result = $stmt->fetchColumn(); if ($result === 1 || $result === 't' || $result === '1') { - return new LockHandle($lockKey, $token, $advisoryKey); + $this->activeTokens[$lockKey] = $token; + + return new LockHandle($lockKey, $token, $advisoryKeys, $leaseSeconds); } } catch (Throwable) { return null; @@ -113,27 +167,49 @@ private function acquirePgsql(string $key, float $waitSeconds): ?LockHandle } while (true); } - private function releaseMysql(LockHandle $handle): void + private function connectionAlive(): bool + { + try { + return $this->pdo->query('SELECT 1') !== false; + } catch (Throwable) { + return false; + } + } + + private function owns(LockHandle $handle): bool + { + $token = $this->activeTokens[$handle->key] ?? null; + + return is_string($token) && hash_equals($token, $handle->token); + } + + private function releaseMysql(LockHandle $handle): bool { try { $stmt = $this->pdo->prepare('SELECT RELEASE_LOCK(:k)'); $stmt->execute([':k' => $handle->key]); + $result = $stmt->fetchColumn(); + + return $result === 1 || $result === '1'; } catch (Throwable) { - // Best effort unlock. + return false; } } - private function releasePgsql(LockHandle $handle): void + private function releasePgsql(LockHandle $handle): bool { - $advisoryKey = is_int($handle->resource) + $advisoryKeys = is_array($handle->resource) ? $handle->resource - : self::signedCrc32($handle->key); + : self::advisoryKeys($handle->key); try { - $stmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:k)'); - $stmt->execute([':k' => $advisoryKey]); + $stmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:k1, :k2)'); + $stmt->execute([':k1' => $advisoryKeys[0], ':k2' => $advisoryKeys[1]]); + $result = $stmt->fetchColumn(); + + return $result === 1 || $result === 't' || $result === '1'; } catch (Throwable) { - // Best effort unlock. + return false; } } } diff --git a/src/Cache/Lock/PollingLockProviderHelpers.php b/src/Cache/Lock/PollingLockProviderHelpers.php index 01bae93..ba98d59 100644 --- a/src/Cache/Lock/PollingLockProviderHelpers.php +++ b/src/Cache/Lock/PollingLockProviderHelpers.php @@ -8,6 +8,24 @@ trait PollingLockProviderHelpers { + protected static function leaseMilliseconds(float $leaseSeconds): int + { + if ($leaseSeconds <= 0) { + throw new \InvalidArgumentException('Lock lease duration must be positive.'); + } + + return max(1, (int) ceil($leaseSeconds * 1000)); + } + + protected static function leaseSeconds(float $leaseSeconds): int + { + if ($leaseSeconds <= 0) { + throw new \InvalidArgumentException('Lock lease duration must be positive.'); + } + + return max(1, (int) ceil($leaseSeconds)); + } + protected static function normalizeRetrySleepMicros(int $retrySleepMicros): int { return max(1_000, $retrySleepMicros); @@ -17,6 +35,7 @@ protected static function normalizeRetrySleepMicros(int $retrySleepMicros): int * @param string $prefix The prefix argument. * @param string $key The key argument. * @param float $waitSeconds The wait seconds argument. + * @param float $leaseSeconds The lease seconds argument. * @param callable $attemptAcquire The attempt acquire argument. * @phpstan-param callable(string,string):bool $attemptAcquire */ @@ -24,8 +43,13 @@ protected function acquireWithRetry( string $prefix, string $key, float $waitSeconds, + float $leaseSeconds, callable $attemptAcquire, ): ?LockHandle { + if ($leaseSeconds <= 0) { + throw new \InvalidArgumentException('Lock lease duration must be positive.'); + } + $deadline = microtime(true) + max(0.0, $waitSeconds); $lockKey = $prefix . self::digestLockKey($key); $token = self::generateToken(); @@ -35,7 +59,7 @@ protected function acquireWithRetry( do { if ($attemptAcquire($lockKey, $token)) { - return new LockHandle($lockKey, $token); + return new LockHandle($lockKey, $token, leaseSeconds: $leaseSeconds); } if (microtime(true) >= $deadline) { diff --git a/src/Cache/Lock/RedisLockProvider.php b/src/Cache/Lock/RedisLockProvider.php index 27c4be5..15bf85b 100644 --- a/src/Cache/Lock/RedisLockProvider.php +++ b/src/Cache/Lock/RedisLockProvider.php @@ -22,18 +22,38 @@ public function __construct( $this->retrySleepMicros = self::normalizeRetrySleepMicros($retrySleepMicros); } - public function acquire(string $key, float $waitSeconds): ?LockHandle + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { - $ttlMs = max(1_000, (int) ceil(($waitSeconds + 1.0) * 1000)); + $ttlMs = self::leaseMilliseconds($leaseSeconds); return $this->acquireWithRetry( $this->prefix, $key, $waitSeconds, + $leaseSeconds, fn(string $lockKey, string $token): bool => (bool) $this->redis->set($lockKey, $token, ['nx', 'px' => $ttlMs]), ); } + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + if (!$handle instanceof LockHandle) { + return false; + } + + $ttlMs = self::leaseMilliseconds($leaseSeconds); + $script = <<<'LUA' +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("PEXPIRE", KEYS[1], ARGV[2]) +end +return 0 +LUA; + + $result = $this->redis->eval($script, [$handle->key, $handle->token, $ttlMs], 1); + + return $result === 1 || $result === '1'; + } + public function release(?LockHandle $handle): void { $script = <<<'LUA' diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index fe5239c..294e0a5 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -9,12 +9,12 @@ use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; beforeEach(function () { - $this->cacheDir = sys_get_temp_dir().'/pest_cache_features_'.uniqid(); + $this->cacheDir = sys_get_temp_dir() . '/pest_cache_features_' . uniqid(); $this->cache = Cache::file('features', $this->cacheDir); }); afterEach(function () { - if (! is_dir($this->cacheDir)) { + if (!is_dir($this->cacheDir)) { return; } @@ -22,7 +22,7 @@ $rim = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); foreach ($rim as $file) { $path = $file->getRealPath(); - if ($path === false || ! file_exists($path)) { + if ($path === false || !file_exists($path)) { continue; } $file->isDir() ? rmdir($path) : unlink($path); @@ -102,10 +102,10 @@ function () use (&$count) { }); test('rejects empty tags in tag operations', function () { - expect(fn () => $this->cache->invalidateTag(' ')) + expect(fn() => $this->cache->invalidateTag(' ')) ->toThrow(CacheInvalidArgumentException::class); - expect(fn () => $this->cache->setTagged('x', 'y', ['ok', ' '])) + expect(fn() => $this->cache->setTagged('x', 'y', ['ok', ' '])) ->toThrow(CacheInvalidArgumentException::class); }); @@ -148,23 +148,27 @@ function () use (&$count) { test('remember uses configured lock provider', function () { $calls = ['acquire' => 0, 'release' => 0]; - $provider = new class($calls) implements LockProviderInterface - { + $provider = new class ($calls) implements LockProviderInterface { public function __construct(private array &$calls) {} - public function acquire(string $key, float $waitSeconds): ?LockHandle + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { - if ($waitSeconds < 0) { + if ($waitSeconds < 0 || $leaseSeconds <= 0) { return null; } $this->calls['acquire']++; - return new LockHandle($key, 'tkn'); + return new LockHandle($key, 'tkn', leaseSeconds: $leaseSeconds); + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + return $handle instanceof LockHandle && $leaseSeconds > 0; } public function release(?LockHandle $handle): void { - if (! $handle instanceof LockHandle) { + if (!$handle instanceof LockHandle) { return; } $this->calls['release']++; @@ -172,14 +176,14 @@ public function release(?LockHandle $handle): void }; $this->cache->setLockProvider($provider); - $this->cache->remember('guarded', fn () => 123, 10); + $this->cache->remember('guarded', fn() => 123, 10); expect($calls['acquire'])->toBe(1) ->and($calls['release'])->toBe(1); }); test('metrics collector exports hit and miss counters', function () { - $collector = new InMemoryCacheMetricsCollector; + $collector = new InMemoryCacheMetricsCollector(); $this->cache->setMetricsCollector($collector); $this->cache->get('x'); diff --git a/tests/Cache/LockProviderTest.php b/tests/Cache/LockProviderTest.php new file mode 100644 index 0000000..bd428c6 --- /dev/null +++ b/tests/Cache/LockProviderTest.php @@ -0,0 +1,78 @@ +acquire('worker:reports', 0.0, 0.05); + + expect($handle)->not->toBeNull() + ->and($handle?->leaseSeconds)->toBe(0.05) + ->and($first->refresh($handle, 0.05))->toBeTrue(); + + usleep(75_000); + + expect($second->acquire('worker:reports', 0.0, 0.05))->toBeNull(); + + $first->release($handle); + + $replacement = $second->acquire('worker:reports', 0.0, 0.05); + expect($replacement)->not->toBeNull(); + $second->release($replacement); + } finally { + foreach (glob($directory . '/*.lock') ?: [] as $file) { + unlink($file); + } + if (is_dir($directory)) { + rmdir($directory); + } + } +}); + +test('lock providers reject non-positive lease durations', function (): void { + $provider = new FileLockProvider(); + + expect(fn() => $provider->acquire('invalid', 0.0, 0.0)) + ->toThrow(InvalidArgumentException::class) + ->and(fn() => $provider->refresh(null, 0.0)) + ->toThrow(InvalidArgumentException::class); +}); + +test('sqlite PDO locks use the shared file-lock fallback', function (): void { + if (!extension_loaded('pdo_sqlite')) { + test()->markTestSkipped('pdo_sqlite is not available.'); + } + + $directory = sys_get_temp_dir() . '/cachelayer-pdo-lock-' . bin2hex(random_bytes(5)); + $pdo = new PDO('sqlite::memory:'); + $first = new PdoLockProvider($pdo, fallback: new FileLockProvider($directory)); + $second = new PdoLockProvider($pdo, fallback: new FileLockProvider($directory)); + + try { + $handle = $first->acquire('worker:imports', 0.0, 10.0); + + expect($handle)->not->toBeNull() + ->and($first->refresh($handle, 10.0))->toBeTrue() + ->and($second->acquire('worker:imports', 0.0, 10.0))->toBeNull(); + + $first->release($handle); + + $replacement = $second->acquire('worker:imports', 0.0, 10.0); + expect($replacement)->not->toBeNull(); + $second->release($replacement); + } finally { + foreach (glob($directory . '/*.lock') ?: [] as $file) { + unlink($file); + } + if (is_dir($directory)) { + rmdir($directory); + } + } +}); diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index 5b4b202..fe8a47e 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -64,15 +64,20 @@ public int $released = 0; - public function acquire(string $key, float $waitSeconds): ?LockHandle + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { - if ($waitSeconds < 0) { + if ($waitSeconds < 0 || $leaseSeconds <= 0) { return null; } ++$this->acquired; - return new LockHandle($key, 'test-lock'); + return new LockHandle($key, 'test-lock', leaseSeconds: $leaseSeconds); + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + return $handle instanceof LockHandle && $leaseSeconds > 0; } public function release(?LockHandle $handle): void @@ -134,7 +139,7 @@ public function release(?LockHandle $handle): void }); test('node cache configuration rejects invalid paths and timeouts', function () { - expect(fn () => new NodeCacheConfig('', 'app'))->toThrow(NodeCacheConfigurationException::class) - ->and(fn () => new NodeCacheConfig('/tmp/cache.sqlite', 'app', busyTimeoutMs: -1)) + expect(fn() => new NodeCacheConfig('', 'app'))->toThrow(NodeCacheConfigurationException::class) + ->and(fn() => new NodeCacheConfig('/tmp/cache.sqlite', 'app', busyTimeoutMs: -1)) ->toThrow(NodeCacheConfigurationException::class); });