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.
@albertcht albertcht added the feature New feature or request label Jan 5, 2026
@albertcht albertcht requested a review from Copilot January 5, 2026 16:48
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a RefreshableLock interface to support long-running tasks that need to refresh lock TTLs without releasing and reacquiring. The interface adds two methods: refresh() for atomically extending a lock's TTL, and getRemainingLifetime() for checking expiration time.

Key Changes:

  • New RefreshableLock interface extending the base Lock contract
  • Implementation in RedisLock, DatabaseLock, ArrayLock, and NoLock using atomic operations
  • Comprehensive test coverage for all implementing drivers
  • Removal of LuaScripts.php utility class with Lua script inlined for better locality

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/cache/src/Contracts/RefreshableLock.php New interface defining refresh() and getRemainingLifetime() methods with clear contract documentation
src/cache/src/RedisLock.php Implements RefreshableLock with Lua scripts for atomic ownership checking; inlines release script previously in LuaScripts
src/cache/src/DatabaseLock.php Implements RefreshableLock using UPDATE with WHERE conditions for atomic refresh
src/cache/src/ArrayLock.php Implements RefreshableLock with in-memory ownership and expiration checks
src/cache/src/NoLock.php Implements RefreshableLock as no-op for testing/disabled scenarios
src/cache/src/LuaScripts.php Deleted - single method inlined in RedisLock for better code locality
tests/Cache/CacheRedisLockTest.php New test file with 15 tests covering all RedisLock refresh functionality
tests/Cache/CacheNoLockTest.php New test file with 10 tests for NoLock refresh behavior
tests/Cache/CacheDatabaseLockTest.php Added 10 tests for DatabaseLock refresh operations
tests/Cache/CacheArrayStoreTest.php Added 12 tests for ArrayLock refresh functionality

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 101 to 115
if (! $this->exists()) {
return false;
}

if (! $this->isOwnedByCurrentProcess()) {
return false;
}

$seconds ??= $this->seconds;

if ($seconds <= 0) {
throw new InvalidArgumentException(
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
);
}
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent parameter validation order. In ArrayLock, the ownership check happens before parameter validation (lines 101-107 before 111-115), but in RedisLock, DatabaseLock, and NoLock, parameter validation happens first (line 90-94 before the ownership check in the Lua script at line 104). This means ArrayLock will return false for invalid parameters when not owning the lock, while other implementations will throw InvalidArgumentException. The validation should happen first for consistency across all implementations.

Copilot uses AI. Check for mistakes.
@albertcht
Copy link
Member

Hi @binaryfire , thanks for this pull request. I personally like this idea and I think it will be pretty useful in long-running tasks.

Restore LuaScripts usage per maintainer feedback. Add refreshLock()
method for the new lock refresh functionality.
Ensures consistent behavior across all lock implementations: invalid
TTL parameters now throw InvalidArgumentException regardless of lock
ownership state.
@albertcht albertcht merged commit bf38aba into hypervel:main Jan 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants