Skip to content

Conversation

@binaryfire
Copy link
Contributor

Problem

Laravel's cache lock implementation provides basic locking primitives (acquire, release, forceRelease), but lacks support for long-running tasks that may exceed the lock's TTL. This is a known gap that developers have requested - the ability to refresh a lock's TTL without releasing and reacquiring it. The current workaround of release-then-reacquire is non-atomic and creates a race condition window where another process can steal the lock.

Real-world scenarios where this matters:

  • Workflows with activities that may run longer than expected
  • Long-running queue jobs that need exclusive access to a resource
  • Batch processing where completion time is unpredictable
  • Distributed cron implementations

Solution

Introduce a RefreshableLock interface that extends the base Lock contract with two methods:

interface RefreshableLock extends Lock
{
    /**
     * Atomically refresh the lock's TTL if still owned by this process.
     *
     * @throws \InvalidArgumentException If $seconds is explicitly <= 0
     */
    public function refresh(?int $seconds = null): bool;

    /**
     * Get the number of seconds until the lock expires.
     */
    public function getRemainingLifetime(): ?float;
}

refresh() Behavior

Call Behavior
refresh() Extends TTL using original value from construction
refresh(30) Extends TTL to 30 seconds
refresh() on permanent lock No-op, returns true (nothing to refresh)
refresh(0) or refresh(-1) Throws InvalidArgumentException

Why throw for refresh(0)?

Passing zero is not "renewing a lease" - it's changing the lock into something fundamentally different (permanent). This is a semantic cliff that can cause operational issues: accidentally converting a TTL lock into a permanent lock can wedge work until someone manually force-releases it.

If you need a permanent lock, acquire it that way: Cache::lock('key', 0). The refresh() method is strictly for extending existing TTLs.

Why an interface instead of adding to the base class?

Not all lock drivers can implement atomic refresh. CacheLock and FileLock use the generic Store interface which lacks atomic check-and-update operations. Adding methods that throw "not supported" would violate Liskov Substitution Principle.

The interface approach:

  • Provides type safety - you can typehint RefreshableLock when you need refresh capability
  • Is honest - drivers that can't implement it atomically simply don't implement the interface
  • Follows Interface Segregation Principle - not every lock consumer needs refresh

Implementation

Driver Implements Atomic? How
RedisLock Lua script: if GET == owner then EXPIRE
DatabaseLock UPDATE ... WHERE key = ? AND owner = ?
ArrayLock In-memory check and update
NoLock N/A No-op (always succeeds)
CacheLock - Cannot guarantee atomicity
FileLock - Inherits CacheLock limitation

Redis Implementation

Uses an inline Lua script for atomicity:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("expire", KEYS[1], ARGV[2])
else
    return 0
end

TTL Semantics

getRemainingLifetime() returns:

  • float - seconds remaining
  • null - lock doesn't exist, has expired, or has no expiry (infinite lock)

For Redis, this correctly handles the TTL command's special return values (-1 for no expiry, -2 for missing key).

Usage

use Hypervel\Cache\Contracts\RefreshableLock;

$lock = Cache::lock('long-running-task', 60);

if ($lock->acquire()) {
    try {
        while (!$finished) {
            // Do a chunk of work...

            // Refresh the lock to prevent expiry
            if ($lock instanceof RefreshableLock) {
                $lock->refresh();      // Extends by original TTL (60s)
                $lock->refresh(120);   // Or extend to 120 seconds

                // Check remaining time if needed
                $remaining = $lock->getRemainingLifetime();
            }
        }
    } finally {
        $lock->release();
    }
}

For code that requires refreshable locks:

function processWithHeartbeat(RefreshableLock $lock, callable $work): mixed
{
    // Type system guarantees refresh() is available
    // Works with any lock - permanent locks just return true (no-op)
}

Tests

Added comprehensive tests for all implementing drivers:

  • CacheRedisLockTest - New file with 15 tests
  • CacheDatabaseLockTest - 10 new tests added to existing file
  • CacheArrayStoreTest - 12 new tests added to existing file
  • CacheNoLockTest - New file with 10 tests

Tests cover: TTL refresh, custom TTL, ownership checks, permanent lock no-op behavior, and InvalidArgumentException for invalid TTL values.

Cleanup

  • Deleted LuaScripts.php from the cache package. It contained only one method (releaseLock()) used in one place. The Lua script is now inlined in RedisLock::release() for better locality.

References

Introduces a RefreshableLock interface that extends the base Lock contract
with two new methods for managing lock lifetimes in long-running tasks:

- refresh(?int $seconds = null): Atomically extends the lock TTL if still
  owned by the current process. Returns false if the lock was lost.

- getRemainingLifetime(): Returns the seconds remaining until the lock
  expires, or null if the lock doesn't exist or has no expiry.

Implemented by: RedisLock, DatabaseLock, ArrayLock, NoLock

Not implemented by CacheLock/FileLock as they cannot provide atomic
refresh guarantees through the generic Store interface.

This enables patterns like heartbeat refreshes in workflow engines and
other long-running processes without the risk of lock expiry mid-task.
- Fix RedisLock::refresh() to check ownership when seconds <= 0
  Previously returned true without verifying lock ownership, breaking
  the contract. Now uses PERSIST with ownership check via Lua script.

- Make refresh(0) behavior consistent across drivers:
  - RedisLock: Uses PERSIST to remove TTL (make permanent)
  - ArrayLock: Sets expiresAt = null (make permanent)
  - DatabaseLock: Uses default timeout (existing behavior)

- Move release Lua script inline into RedisLock::release()
  The LuaScripts class only had one method used in one place.
  Keeping scripts close to usage improves readability.

- Delete src/cache/src/LuaScripts.php (no longer needed)

- Add tests for refresh(0) edge cases in RedisLock
- refresh(0) and refresh(-N) now throw InvalidArgumentException
- refresh() on permanent locks (seconds=0) returns true as no-op
- Consistent behavior across all RefreshableLock implementations

This prevents accidental conversion of TTL locks to permanent locks,
which could wedge work until manual intervention. If a permanent lock
is needed, acquire it that way from the start.

Also improves interface docblock wording for clarity.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant