feat: add atomic locks for cache-backed concurrency control#10145
feat: add atomic locks for cache-backed concurrency control#10145memleakd wants to merge 8 commits intocodeigniter4:4.8from
Conversation
paulbalandan
left a comment
There was a problem hiding this comment.
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:
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>
85955c6 to
6667132
Compare
There was a problem hiding this comment.
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?
|
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 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 Would you prefer that structure anyway, or should the provider/store contract stay under For example:
I’m happy to refactor in that direction if this is the structure the team prefers. |
|
Good question. I forgot about that... I guess we can move everything related to
Now |
|
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 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>
|
I refactored the PR in this direction for review. The current structure is now:
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>
486af64 to
229dcc0
Compare
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\LockManagerCodeIgniter\Lock\LockInterfaceCodeIgniter\Lock\LockStoreInterfaceSupported lock operations include:
acquire()for immediate acquisitionblock()for waiting up to a limited number of secondsrun()for acquire/callback/release usagerelease()for owner-checked releaseforceRelease()for administrative releaserefresh()for extending a lock TTLisAcquired()for checking current ownershipowner()for retrieving the owner tokenrestore()for restoring a lock from a known owner tokenBackground
Some application work should only happen once at a time, even when multiple PHP processes are active.
Common examples include:
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:
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\Lockavoids expandingCacheInterfacewith lock-specific methods and keeps lock support opt-in throughLockStoreInterface.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:
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()andrefresh()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 checkisAcquired()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:
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 naiveget owner -> deleteflow 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:
run()callback behaviorservice('locks')behaviorChecklist: