RFC: 5.next - Add Lock component for distributed locking#19370
RFC: 5.next - Add Lock component for distributed locking#19370dereuromark wants to merge 9 commits intocakephp:5.nextfrom
Conversation
Introduces a new Lock component that provides distributed locking
mechanisms to prevent race conditions when multiple processes access
shared resources.
Features:
- LockInterface defines the contract for lock engines
- LockInstance value object represents an acquired lock
- Lock static facade for convenient access (similar to Cache)
- LockRegistry for managing engine instances
- Four lock engines:
- RedisLockEngine: Uses Redis SET NX with Lua scripts for atomicity
- MemcachedLockEngine: Uses Memcached add() for atomic acquisition
- FileLockEngine: Uses flock() for local filesystem locking
- NullLockEngine: No-op engine for testing and development
Key capabilities:
- Non-blocking acquire() and blocking acquireBlocking()
- Lock refresh to extend TTL
- Owner verification on release (prevents releasing others' locks)
- Force release for administrative purposes
- synchronized() helper for automatic lock/unlock around callbacks
Example usage:
Lock::setConfig('default', ['className' => RedisLockEngine::class]);
$lock = Lock::acquire('my-resource');
if ($lock !== null) {
try {
// Critical section
} finally {
Lock::release($lock);
}
}
// Or using synchronized helper:
$result = Lock::synchronized('my-resource', function () {
return doWork();
});
- Use empty array comparison instead of count() in MemcachedLockEngine - Combine nested if statements in RedisLockEngine
Use getConfig() instead of direct array access to properly handle defaults when config is not fully initialized.
Import the env() function from Cake\Core to fix "undefined function" errors.
Use getVersion() to verify the Memcached connection actually works, allowing tests to be properly skipped when connection fails.
Wrap cleanupTestLocks in try-catch to prevent test failures when Redis connection drops during cleanup.
markstory
left a comment
There was a problem hiding this comment.
I didn't have time to read all of the engines, but did read through the API classes.
| * $lock = Lock::acquire('my-resource'); | ||
| * if ($lock !== null) { | ||
| * try { | ||
| * // Critical section | ||
| * } finally { | ||
| * Lock::release($lock); | ||
| * } | ||
| * } |
There was a problem hiding this comment.
What happens when a developer forgets to release()?
There was a problem hiding this comment.
The main path is now Lock::synchronized(), which guarantees release. For manual acquisition, the returned AcquiredLock can still be released explicitly, but it also does a best-effort release from __destruct() so an accidentally dropped handle does not linger for the rest of the request/process.
| public function __construct( | ||
| protected readonly string $resource, | ||
| protected readonly string $token, | ||
| protected readonly int $ttl, | ||
| protected readonly float $acquiredAt, | ||
| ) { | ||
| } |
There was a problem hiding this comment.
Have you considered having a destructor that releases the lock? That would allow locks to be cleaned up as methods end and objects were destroyed by PHP.
There was a problem hiding this comment.
Implemented this, but by turning the old value object into an owning lock handle first. AcquiredLock now knows the engine that acquired it, so it can do a best-effort release() from __destruct() without needing the caller to pass config back in.
68d7ebd to
22e6e0f
Compare
|
Maybe this could help improve our TreeBehaviour as it currently causes "problems" when multiple requests/queue workers try to adjust the same tree. In general I like this 👍🏻 |
| */ | ||
| protected function _connect(): bool | ||
| { | ||
| $this->_redis = new Redis(); |
There was a problem hiding this comment.
That would add quite a bit of change, and there could also be some issues.
The bigger uncertainty is phpredis API differences for RedisCluster::eval() and constructor/auth behavior.
| public function release(): bool | ||
| { | ||
| if ($this->released || $this->engine === null) { | ||
| return false; |
There was a problem hiding this comment.
Should this condition raise an error? It feels like a logic error to double free a lock.
There was a problem hiding this comment.
On double release: that’s a small code change, but it needs one design adjustment because src/Lock/AcquiredLock.php auto-releases in __destruct(). If release() throws on second call, __destruct() must not leak exceptions.
So a design decision I guess?
Summary
Introduces a new Lock component that provides distributed locking mechanisms to prevent race conditions when multiple processes access shared resources.
This was identified as a gap in CakePHP compared to other frameworks (Symfony Lock component, Laravel Cache::lock()). The implementation follows CakePHP's existing patterns from the Cache component.
Features
Lock Engines
Key Capabilities
acquire()and blockingacquireBlocking()with timeoutsynchronized()helper for automatic lock/unlock around callbacksExample Usage
Namespace: Lock
Namespaces WITHOUT split packages (monolith-only):
This seems to fit, since we dont need its own package usually here.
Related Discussion
This addresses the gap identified when comparing CakePHP to other frameworks. See: Symfony Lock component, Laravel's atomic locks.
We could also make this a plugin. But it seems this could be a core feature.
PS: Symfony added 2 years after Lock also Semaphore functionality. I descoped that for now, could also be something added within the next 2 years if thats what people need/want.