Cache and locks — performance and concurrency
CachePort provides caching and distributed mutual exclusion. Unlike simple counters, locks are ownership-verified and TTL-bounded, making them safe for job idempotency and preventing cache stampedes across multi-worker deployments.
CachePort methods
See Ports for the complete interface.
Basic operations
$value = $cache->get('user.42.profile'); // or null if absent/expired
$cache->set('user.42.profile', $profile, 3600); // TTL in seconds
$cache->has('user.42.profile');
$cache->delete('user.42.profile');
$cache->flush(); // clear entire cacheremember() — cache-aside pattern
Fetch or compute and cache:
$profile = $cache->remember('user.42.profile', 3600, function () {
return $db->queryOne('SELECT * FROM users WHERE id = ?', [42]);
});If the key exists, it is returned immediately. If absent or expired, the callback runs, its return value is cached for 3600 seconds, and returned to the caller.
increment() — atomic counters
Increment a counter atomically (no read/write race):
$viewCount = $cache->increment('article.42.views'); // +1
$viewCount = $cache->increment('article.42.views', 5); // +5
// NOT a substitute for lock() — it has no ownership, no blocking, no TTL releasedeletePattern() — wildcard delete
Delete keys matching a pattern (e.g., after a user modifies their profile):
$count = $cache->deletePattern('user.42.*'); // invalidate all profile cachesNot all adapters support this; check the adapter's documentation.
Lock — distributed mutual exclusion
A lock is ownership-verified and TTL-bounded, making it safe for:
- Single-flight work: only one worker processes the same task
- Job idempotency: retrying a job doesn't duplicate side effects
- Cache stampede prevention: one slow callback rebuilds the cache while others wait
- Scheduled tasks: only one node runs the daily report
Typical usage
// Try once without waiting
$lock = $cache->lock('report:nightly', 300); // 300s TTL
if (!$lock->acquire()) {
return; // another worker owns it — do nothing
}
try {
$this->generateNightly();
} finally {
$lock->release();
}block() — wait with a callback
Blocking acquisition with automatic release:
$result = $cache->lock('report:nightly', 300)
->block(10, fn() => $this->generateNightly());
// Waits up to 10s, runs the callback while holding the lock,
// ALWAYS releases afterwards (even if the callback throws)If $lock->block(10) is called without a callback, it waits up to 10 seconds and returns true once acquired — the caller then owns the release.
Ownership is the point
Every lock carries an owner token. release() only succeeds for the holder:
$lock = $cache->lock('task', 300);
$lock->acquire();
$owner = $lock->owner(); // unguessable token
// Later, in another request or process:
$lock2 = $cache->restoreLock('task', $owner);
$lock2->release(); // only works because we passed the correct ownerWithout ownership verification, a process whose lock expired cannot delete the lock a different process just acquired. Implementations must make the check-and-delete atomic (Redis uses Lua scripts).
Graceful timeout
use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\LockTimeoutException;
try {
$cache->lock('report:nightly', 300)->block(10);
} catch (LockTimeoutException $e) {
// Another worker held the lock for > 10 seconds
// Skip this run and try again next minute
}AbstractLock
Base class for Lock implementations. Subclasses implement only the store-specific operations; the block() logic (including timeout and sleep) is shared across all backends.
abstract class AbstractLock implements Lock
{
public function block(int $seconds, ?callable $callback = null): mixed { ... }
protected static function randomOwner(): string { ... }
protected function sleep(int $micros): void { ... }
}The sleep method is coroutine-aware: under OpenSwoole/Swoole it yields the coroutine; otherwise it uses usleep(). This ensures a waiting lock never stalls a worker.
LockTimeoutException
Thrown when lock->block($seconds) exceeds the deadline.
use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\LockTimeoutException;
try {
$cache->lock('task', 300)->block(5);
} catch (LockTimeoutException $e) {
// Timed out
}Patterns
Cache stampede prevention
When a hot cache key expires, every pending request rebuilds it. Lock-back a rebuild:
$data = $cache->remember('config.published', 3600, function () use ($cache) {
$lock = $cache->lock('config.rebuild', 300);
if (!$lock->acquire()) {
// Another request is rebuilding — wait for it
sleep(1);
return $cache->get('config.published') ?? [];
}
try {
return $db->query('SELECT * FROM config WHERE published = ?', [true]);
} finally {
$lock->release();
}
});Better: let the first request rebuild while others wait:
$data = $cache->lock('config.rebuild', 300)
->block(5, fn() => $cache->remember('config.published', 3600, fn() =>
$db->query('SELECT * FROM config WHERE published = ?', [true])
));Idempotent job retries
Mark a job as complete in the cache before returning. If the job is retried, it exits early:
// In the job's handle() method
$jobKey = 'job:' . $this->jobId();
if ($cache->has($jobKey)) {
return JobResult::skipped('Already processed');
}
// Do the work...
$cache->set($jobKey, true, 86400); // 1 day retention
return JobResult::success();For even stronger guarantees, use a database flag instead of cache.
Scheduled task overlap prevention
The Scheduler uses locks to prevent multiple workers from running the same task:
// In module.json, under "schedule": [ ... ]
{
"name": "reports.nightly",
"at": "0 2 * * *",
"job": "reports.generate-nightly",
"withoutOverlapping": true,
"expiresAfter": 1800
}The Scheduler takes a lock before dispatching; if another worker holds it, the task is skipped.
Rate limiting via counters
Track requests per user without a DB call:
$key = "rate:user_{$userId}:requests";
$count = $cache->increment($key);
if ($count === 1) {
// First increment this window — set TTL
$cache->set($key, 1, 60); // reset every 60s
}
if ($count > 100) {
throw new TooManyRequestsException();
}For production rate limiting, use the throttle route filter from the SecurityFilters plugin.
Testing with locks
Create an in-memory fake:
<?php declare(strict_types=1);
namespace Tests\Fixtures;
use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\{CachePort, Lock};
final class InMemoryCacheAdapter implements CachePort
{
private array $store = [];
private array $locks = [];
public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock
{
$owner ??= bin2hex(random_bytes(8));
return new InMemoryLock($this->locks, $name, $seconds, $owner);
}
// ... other methods
}
final class InMemoryLock extends AbstractLock
{
public function __construct(
private array &$locks,
string $name,
int $seconds,
string $owner,
) {
parent::__construct($name, $seconds, $owner);
}
public function acquire(): bool
{
if (!isset($this->locks[$this->name])) {
$this->locks[$this->name] = $this->owner;
return true;
}
return $this->locks[$this->name] === $this->owner;
}
public function release(): bool
{
if (!isset($this->locks[$this->name])) {
return false;
}
if ($this->locks[$this->name] !== $this->owner) {
return false;
}
unset($this->locks[$this->name]);
return true;
}
public function forceRelease(): void
{
unset($this->locks[$this->name]);
}
}Deployment notes
Cache backends: Choose one that supports atomic operations (Redis, Memcached). File-based caches do not support atomic increment or locking and should only be used for development.
TTL and lock duration: Set a lock TTL high enough to cover worst-case execution (e.g., 5 minutes for a background job), but low enough that a crashed worker doesn't strand the lock forever. 15–300 seconds is typical.
Monitoring: Track lock contention via metrics:
$lock = $cache->lock('task', 300);
if (!$lock->acquire()) {
$metrics->counter('task.lock.contention');
return;
}Common mistakes
WARNING
Using increment() for locking. increment() is atomic but has no ownership, blocking, or TTL-based release. If the process dies after reading the counter, it stays high forever. Use lock() instead.
Not setting a lock TTL. A crashed process with $seconds = 0 (no expiry) strands the lock. Always set a TTL.
Assuming cache is persistent. Cache is ephemeral — entries may be evicted, the cache may restart, or the backend may fail. Never use cache alone for authoritative state; use the database.
Sharing a lock across requests. Locks obtained with lock() are request-scoped; a static reference would leak them across requests under OpenSwoole.