Cache
The Cache component provides an async-safe in-memory LRU cache with per-key atomicity. It prevents cache stampedes in concurrent fiber environments - if two fibers request the same key simultaneously, only one computes the value while the other waits.
Basic Usage
compute() is the primary API - get a cached value or compute it if absent. update() always recomputes, receiving the old value.
use Psl\Cache;
use Psl\DateTime\Duration;
use Psl\IO;
$store = new Cache\LocalStore(maxSize: 100);
// compute() returns cached value or computes it
$value = $store->compute('greeting', static fn(): string => 'Hello, World!');
IO\write_line('%s', $value);
// Second call returns cached value - computer is not invoked
$value = $store->compute('greeting', static fn(): string => 'This is never called');
IO\write_line('%s', $value); // Still "Hello, World!"
// With TTL - entry expires after 5 minutes
$store->compute(
'user:42',
/** @return array{name: string} */
static fn(): array => ['name' => 'Alice'],
Duration::minutes(5),
);
// update() always invokes the computer with the old value
$store->compute('counter', static fn(): int => 0);
$store->update('counter', static fn(null|int $old): int => ($old ?? 0) + 1);
/** @var int $counter */
$counter = $store->get('counter');
IO\write_line('Counter: %d', $counter);
Async Safety
Operations on the same key are serialized via KeyedSequence. Different keys run in parallel with no blocking.
use Psl\Async;
use Psl\Cache;
use Psl\DateTime\Duration;
use Psl\IO;
$store = new Cache\LocalStore();
// Two fibers requesting the same key concurrently.
// KeyedSequence ensures only one computes - the other waits and gets the cached result.
[$a, $b] = Async\concurrently([
static fn(): string => $store->compute('expensive', static function (): string {
Async\sleep(Duration::milliseconds(100));
return 'computed once';
}),
static fn(): string => $store->compute('expensive', static fn(): string => 'this is never called'),
]);
IO\write_line('Fiber A: %s', $a); // "computed once"
IO\write_line('Fiber B: %s', $b); // "computed once" - got cached result
// Different keys run in parallel - no blocking
Async\concurrently([
static fn(): string => $store->compute('key-1', static fn(): string => 'value-1'),
static fn(): string => $store->compute('key-2', static fn(): string => 'value-2'),
]);
LRU Eviction
LocalStore maintains a bounded cache with configurable maximum size. When full, the least recently used entry is evicted. Accessed entries are promoted to most-recently-used.
TTL Expiration
A null TTL means no expiration. A zero TTL expires immediately: compute() or update() returns its result, but later reads cannot reuse it.
An expired entry is never returned: it is removed when it is accessed, and an event loop timer also sweeps expired entries proactively so they do not linger in memory. The timer runs every 3 seconds by default (configurable with the $cleanupInterval constructor argument), activates when TTL'd entries exist and disables itself when none remain, adding zero overhead when no entries have expiration. It never keeps the event loop running on its own.
LocalStore may keep expired entries until the next access or cleanup sweep. Those entries use cache slots and may evict older entries until removed.
NullStore
NullStore implements StoreInterface but never caches - every compute() invokes the computer, every get() throws. Useful for testing or disabling caching without changing calling code.
See src/Psl/Cache/ for the full API.