Skip to content

RFC: 5.next - Add Lock component for distributed locking#19370

Open
dereuromark wants to merge 9 commits intocakephp:5.nextfrom
dereuromark:feature/lock-component
Open

RFC: 5.next - Add Lock component for distributed locking#19370
dereuromark wants to merge 9 commits intocakephp:5.nextfrom
dereuromark:feature/lock-component

Conversation

@dereuromark
Copy link
Copy Markdown
Member

@dereuromark dereuromark commented Mar 28, 2026

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

  • LockInterface - defines the contract for lock engines
  • AcquiredLock - lock handle for acquired locks with release/refresh helpers and best-effort auto-release on destruction
  • Lock - static facade for convenient access (similar to Cache)
  • LockRegistry - registry for managing engine instances

Lock Engines

Engine Use Case
RedisLockEngine Production, distributed systems. Uses Redis SET NX with Lua scripts for atomicity
MemcachedLockEngine Production. Uses Memcached add() for atomic acquisition
FileLockEngine Single-server deployments. Uses flock() for local filesystem locking
NullLockEngine Testing and development. No-op engine

Key Capabilities

  • Non-blocking acquire() and blocking acquireBlocking() with timeout
  • Lock refresh to extend TTL for long-running operations
  • Owner verification on release (prevents releasing others' locks)
  • Force release for administrative purposes
  • synchronized() helper for automatic lock/unlock around callbacks

Example Usage

// Configure
Lock::setConfig('default', [
    'className' => RedisLockEngine::class,
    'host' => '127.0.0.1',
]);

// Basic usage
$lock = Lock::acquire('my-resource');
if ($lock !== null) {
    try {
        // Critical section
    } finally {
        $lock->release();
    }
}

// Blocking with timeout
$lock = Lock::acquireBlocking('payment-' . $orderId, ttl: 60, timeout: 10);

// Synchronized helper (auto-release)
$result = Lock::synchronized('my-resource', function () {
    return doExpensiveWork();
});

Namespace: Lock

Namespaces WITHOUT split packages (monolith-only):

  • Command
  • Controller
  • Error
  • Lock (the new one)
  • Mailer
  • Network
  • Routing
  • TestSuite
  • View

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.

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.
@dereuromark dereuromark added this to the 5.4.0 milestone Mar 28, 2026
@dereuromark dereuromark changed the title Add Lock component for distributed locking RFC: 5.next - Add Lock component for distributed locking Mar 28, 2026
@dereuromark dereuromark requested review from ADmad and markstory April 2, 2026 13:13
Copy link
Copy Markdown
Member

@markstory markstory left a comment

Choose a reason for hiding this comment

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

I didn't have time to read all of the engines, but did read through the API classes.

Comment thread src/Lock/Lock.php
Comment on lines +45 to +52
* $lock = Lock::acquire('my-resource');
* if ($lock !== null) {
* try {
* // Critical section
* } finally {
* Lock::release($lock);
* }
* }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What happens when a developer forgets to release()?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread src/Lock/Lock.php Outdated
Comment thread src/Lock/Lock.php Outdated
Comment thread src/Lock/LockEngine.php Outdated
Comment thread src/Lock/LockInstance.php Outdated
Comment thread src/Lock/AcquiredLock.php
Comment on lines +36 to +42
public function __construct(
protected readonly string $resource,
protected readonly string $token,
protected readonly int $ttl,
protected readonly float $acquiredAt,
) {
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

@dereuromark dereuromark force-pushed the feature/lock-component branch from 68d7ebd to 22e6e0f Compare April 8, 2026 16:53
@dereuromark dereuromark marked this pull request as ready for review April 9, 2026 20:32
@LordSimal
Copy link
Copy Markdown
Contributor

LordSimal commented Apr 10, 2026

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 👍🏻

@dereuromark dereuromark requested a review from markstory April 11, 2026 10:02
Comment thread src/Lock/Engine/FileLockEngine.php
Comment thread src/Lock/Engine/FileLockEngine.php Outdated
Comment thread src/Lock/Engine/FileLockEngine.php Outdated
Comment thread src/Lock/Engine/FileLockEngine.php Outdated
Comment thread src/Lock/Engine/MemcachedLockEngine.php Outdated
*/
protected function _connect(): bool
{
$this->_redis = new Redis();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What about redis-cluster ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread src/Lock/AcquiredLock.php
public function release(): bool
{
if ($this->released || $this->engine === null) {
return false;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this condition raise an error? It feels like a logic error to double free a lock.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants