-
-
Notifications
You must be signed in to change notification settings - Fork 14
feat: Add RefreshableLock interface for long-running task support
#335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
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.
There was a problem hiding this 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
RefreshableLockinterface extending the baseLockcontract - Implementation in
RedisLock,DatabaseLock,ArrayLock, andNoLockusing atomic operations - Comprehensive test coverage for all implementing drivers
- Removal of
LuaScripts.phputility 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.
src/cache/src/ArrayLock.php
Outdated
| 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.' | ||
| ); | ||
| } |
Copilot
AI
Jan 5, 2026
There was a problem hiding this comment.
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.
|
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.
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:
Solution
Introduce a
RefreshableLockinterface that extends the baseLockcontract with two methods:refresh()Behaviorrefresh()refresh(30)refresh()on permanent locktrue(nothing to refresh)refresh(0)orrefresh(-1)InvalidArgumentExceptionWhy 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). Therefresh()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.
CacheLockandFileLockuse the genericStoreinterface which lacks atomic check-and-update operations. Adding methods that throw "not supported" would violate Liskov Substitution Principle.The interface approach:
RefreshableLockwhen you need refresh capabilityImplementation
RedisLockif GET == owner then EXPIREDatabaseLockUPDATE ... WHERE key = ? AND owner = ?ArrayLockNoLockCacheLockFileLockRedis Implementation
Uses an inline Lua script for atomicity:
TTL Semantics
getRemainingLifetime()returns:float- seconds remainingnull- lock doesn't exist, has expired, or has no expiry (infinite lock)For Redis, this correctly handles the TTL command's special return values (
-1for no expiry,-2for missing key).Usage
For code that requires refreshable locks:
Tests
Added comprehensive tests for all implementing drivers:
CacheRedisLockTest- New file with 15 testsCacheDatabaseLockTest- 10 new tests added to existing fileCacheArrayStoreTest- 12 new tests added to existing fileCacheNoLockTest- New file with 10 testsTests cover: TTL refresh, custom TTL, ownership checks, permanent lock no-op behavior, and
InvalidArgumentExceptionfor invalid TTL values.Cleanup
LuaScripts.phpfrom the cache package. It contained only one method (releaseLock()) used in one place. The Lua script is now inlined inRedisLock::release()for better locality.References
refresh())