Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/adapters/memcached.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions docs/adapters/pdo.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/adapters/redis.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 5 additions & 0 deletions docs/adapters/sqlite.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------

Expand Down
2 changes: 2 additions & 0 deletions docs/adapters/valkey.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
8 changes: 7 additions & 1 deletion docs/cache.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
------------------------

Expand Down
56 changes: 53 additions & 3 deletions docs/metrics-and-locking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/node/_content.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/Cache/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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_';
Expand Down
6 changes: 5 additions & 1 deletion src/Cache/CacheReadRememberTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 16 additions & 3 deletions src/Cache/Lock/FileLockProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand Down
1 change: 1 addition & 0 deletions src/Cache/Lock/LockHandle.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ public function __construct(
public string $key,
public string $token,
public mixed $resource = null,
public float $leaseSeconds = 0.0,
) {}
}
4 changes: 3 additions & 1 deletion src/Cache/Lock/LockProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
41 changes: 37 additions & 4 deletions src/Cache/Lock/MemcachedLockProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand Down
Loading