Skip to content

feat: add atomic locks for cache-backed concurrency control#10145

Open
memleakd wants to merge 8 commits intocodeigniter4:4.8from
memleakd:feat/cache-locks
Open

feat: add atomic locks for cache-backed concurrency control#10145
memleakd wants to merge 8 commits intocodeigniter4:4.8from
memleakd:feat/cache-locks

Conversation

@memleakd
Copy link
Copy Markdown
Contributor

Description

This PR proposes an atomic lock primitive for CodeIgniter 4.

The goal is to give applications a framework-native way to coordinate work across concurrent requests, CLI commands, queue workers, and other long-running processes.

The new lock component includes:

  • service('locks')
  • CodeIgniter\Lock\LockManager
  • CodeIgniter\Lock\LockInterface
  • CodeIgniter\Lock\LockStoreInterface
  • File, Redis, and Predis lock-store support
  • User guide documentation, examples, changelog entry, and Deptrac mapping

Supported lock operations include:

  • acquire() for immediate acquisition
  • block() for waiting up to a limited number of seconds
  • run() for acquire/callback/release usage
  • release() for owner-checked release
  • forceRelease() for administrative release
  • refresh() for extending a lock TTL
  • isAcquired() for checking current ownership
  • owner() for retrieving the owner token
  • restore() for restoring a lock from a known owner token

Background

Some application work should only happen once at a time, even when multiple PHP processes are active.

Common examples include:

  • Preventing overlapping invoice, payout, settlement, or report generation.
  • Ensuring only one worker rebuilds a large cache, search index, or export file.
  • Coordinating scheduled jobs so the same tenant/date/window is not processed twice.
  • Protecting short critical sections such as third-party API token refreshes.

Today, applications can build these patterns manually with cache keys, database flags, or custom tables, but ownership and expiry details are easy to get subtly wrong. A small framework primitive gives users a safer and more consistent baseline without requiring every project to invent its own locking convention.

Comparison

This is a common framework-level primitive in other ecosystems:

  • Laravel provides atomic locks through its cache system.
  • Symfony provides a dedicated Lock component with multiple stores.
  • Rails applications commonly use advisory locks or lock libraries for the same class of coordination problems.

This PR follows the same general idea, while keeping the implementation aligned with CodeIgniter’s existing services and cache-handler architecture.

Although the built-in stores are backed by cache handlers, locks are not cache values. They are a concurrency primitive with ownership, release, refresh, and blocking semantics. Keeping the public API under CodeIgniter\Lock avoids expanding CacheInterface with lock-specific methods and keeps lock support opt-in through LockStoreInterface.

This also leaves room for future non-cache stores, such as database or advisory-lock stores, without changing the user-facing API.

Proposal Scope

This is intended as a conservative proposal for review. I'll be happy to adjust if the team does not want this in core, prefers a different API, or wants it split differently.

This PR intentionally keeps the feature low-level. It currently does not add:

  • Queue integration
  • Scheduler integration
  • Database lock storage
  • Automatic lock renewal
  • Fencing tokens
  • Metrics or events
  • New configuration options

The implementation focuses only on the primitive itself: acquire a named lock, verify ownership, release it safely, and allow the lock to expire if the process disappears.

Higher-level features can build on this later if the team wants them.

Behavior

Locks are advisory. Code that needs protection must explicitly acquire the lock before entering the critical section.

Each acquired lock has an owner token. release() and refresh() only succeed for the current owner, which helps avoid one process accidentally releasing another process’s lock after expiry and reacquisition.

Each lock has a TTL. This prevents abandoned locks from being held forever, but it also means long-running work must choose a suitable TTL, call refresh(), or check isAcquired() before irreversible side effects.

Logical lock names are hashed before reaching the cache handler, so applications can use descriptive names without worrying about reserved cache-key characters.

Supported Cache Handlers

This PR adds lock-store support for:

  • File
  • Redis
  • Predis

Memcached is intentionally not included in this first version.

Memcached can acquire a lock with atomic add(), but safe owner-aware release and refresh require compare-and-delete / compare-and-touch semantics. A naive get owner -> delete flow can race if the lock expires and another owner acquires it between those operations.

I think Memcached support would be better handled in a separate PR with a CAS-based implementation and dedicated live tests, instead of adding a weaker implementation here.

Testing

This PR adds tests for:

  • Basic acquire/release behavior
  • Competing owners
  • Re-acquiring after release
  • Expired locks
  • Owner-checked release
  • Owner-checked refresh
  • Force release
  • Restoring a lock from an owner token
  • run() callback behavior
  • Logical names containing cache-reserved characters
  • Invalid lock names and TTL values
  • Unsupported cache handlers
  • service('locks') behavior
  • Redis and Predis live lock operations

Checklist:

  • Securely signed commits
  • Component(s) with PHPDoc blocks, only if necessary or adds value (without duplication)
  • Unit testing, with >80% coverage
  • User guide updated
  • Conforms to style guide

@github-actions github-actions Bot added the 4.8 PRs that target the `4.8` branch. label Apr 25, 2026
Copy link
Copy Markdown
Member

@paulbalandan paulbalandan left a comment

Choose a reason for hiding this comment

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

I think this can go to core. Locks are something you expect to deal with caches and having it baked in the framework core is a win.

A few design questions before we dive deeper:

Comment thread deptrac.yaml
Comment thread system/Config/Services.php Outdated
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Copy link
Copy Markdown
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

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

I'm fine with the overall approach, but I would change the internal structure: instead of implementing all lock behavior directly inside FileHandler, RedisHandler, and PredisHandler, I would delegate to specialized classes like FileLockStore, RedisLockStore, and PredisLockStore.

This is all pseudo-code, but it will help explain what I have in mind.

We can add a small contract:

namespace CodeIgniter\Lock\Stores;
                                                                  
interface LockStoreProvider
{
    public function lockStore(): LockStoreInterface;
}

Then, cache handlers can implement that instead of what we have now, and all additional methods collapse into one:

class RedisHandler extends BaseHandler implements LockStoreProvider
{
    private ?LockStoreInterface $lockStore = null;
    
    public function lockStore(): LockStoreInterface
    {
        return $this->lockStore ??= new RedisLockStore($this->redis, $this->prefix);
    }
}

LockManager can check instanceof LockStoreProvider once at construction and fails fast:

public function __construct(CacheInterface $cache)
{
    if (! $cache instanceof LockStoreProvider) {
        throw LockException::forUnsupportedStore($cache::class);
    }
    
    $this->store = $cache->lockStore();                   
}

It wouldn't be much more complicated, and it would make the *LockStore classes the only place where lock logic lives. Additionally, cache handlers would go back to being cache handlers.

final class RedisLockStore implements LockStoreInterface
{
    public function __construct(
        private readonly Redis $redis, private readonly string $prefix = ''
    )
    {}
    
    // all other methods...
}

Thoughts?

@memleakd
Copy link
Copy Markdown
Contributor Author

Thanks for the review, I like this direction.

I agree that moving the actual lock behavior into dedicated store classes would keep the cache handlers cleaner. The handlers would only expose a lock store, and the lock-specific logic would live in FileLockStore, RedisLockStore, and PredisLockStore, which feels easier to maintain and test.

The only thing I would want to align on is the namespace/dependency direction, because the previous review pointed out the circular dependency between Cache and Lock. If LockStoreProvider lives under CodeIgniter\Lock\Stores and cache handlers implement it, then Cache depends on Lock again.

Would you prefer that structure anyway, or should the provider/store contract stay under CodeIgniter\Cache while the user-facing API remains under CodeIgniter\Lock?

For example:

  • CodeIgniter\Lock\LockManager remains the public-facing manager.
  • Cache handlers implement a small cache-layer provider contract.
  • Dedicated store classes hold the lock logic.
  • LockManager receives the store from the provider and no longer checks lock methods directly on the cache handler.

I’m happy to refactor in that direction if this is the structure the team prefers.

@michalsn
Copy link
Copy Markdown
Member

michalsn commented Apr 27, 2026

Good question. I forgot about that... I guess we can move everything related to LockStore into the Cache namespace. So it will become what you mentioned:

  • CodeIgniter\Lock\LockManager - what users get from service('locks')
  • FileHandler, RedisHandler, PredisHandler implement LockStoreProvider - single method lockStore(): LockStoreInterface
  • CodeIgniter\Cache\LockStores\{File,Redis,Predis}LockStore - the acquireLock/releaseLock/refreshLock/forceReleaseLock/getLockOwner implementations live only there
  • LockManager only references LockStoreProvider and LockStoreInterface from Cache - it never reaches into a cache handler directly.

Now Lock depends on Cache, but Cache no longer imports anything from Lock. Cycle gone. I'm open to other ideas if you see a better split.

@memleakd
Copy link
Copy Markdown
Contributor Author

Thanks, this structure makes sense to me.

I think this is probably the best split for the PR. The tradeoff is a few more internal classes, but I think that is worth it because the lock behavior is backend-specific and easier to review/test in dedicated *LockStore classes.

Unless the team prefers different names or namespaces, I’m happy to refactor the PR in this direction.

- introduce cache lock store provider and dedicated File, Redis, and Predis lock stores
- keep cache handlers responsible only for exposing their lock store
- update LockManager to resolve lock support through cache lock store providers
- refresh memoized Redis and Predis lock stores after reconnect
- update Redis and Predis lock tests for provider-based stores and reconnect behavior
- document custom lock store extension expectations

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
@memleakd
Copy link
Copy Markdown
Contributor Author

I refactored the PR in this direction for review.

The current structure is now:

  • CodeIgniter\Lock\LockManager remains the user-facing manager from service('locks').
  • Cache handlers implement CodeIgniter\Cache\LockStoreProvider.
  • Backend-specific lock behavior now lives in dedicated CodeIgniter\Cache\LockStores\*LockStore classes.
  • LockManager depends on the cache lock-store provider/interface, so Lock depends on Cache, but Cache no longer depends on Lock.

I also added reconnect coverage for Redis/Predis lock stores and updated the docs for the custom lock-store extension point.

Happy to make furthe changes if the team prefers a different final shape.

- add an in-memory mock lock store for test environments
- make MockCache provide the lock store contract used by LockManager
- keep service('locks') usable under CIUnitTestCase's mocked cache

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.8 PRs that target the `4.8` branch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants