Skip to content

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

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

feat: add atomic locks for cache-backed concurrency control#10145
memleakd wants to merge 5 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
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.

1 participant