Search by

artigo / cache-stampede

marcing

Stampede protection for symfony/cache whose scope is the whole fleet, not one machine.

Package info

github.com/artigo-dev/cache-stampede

pkg:composer/artigo/cache-stampede

Statistics

Installs: 37

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

v1.5.1 2026-09-14 18:55 UTC

This package is auto-updated.

Last update: 2026-09-14 18:56:19 UTC


README

artigo cache-stampede — one request through, however many servers

Stampede protection for symfony/cache that keeps a cold key to one origin call — on one server or on twelve.

Its other half is artigo/versioned-cache, a tag-aware Redis adapter whose invalidation costs four commands whether three items match or twenty thousand. This package keeps the herd off your origin; that one keeps the invalidation from being expensive in the first place. Neither depends on the other — see Use them together.

The problem

Symfony guards against cache stampedes with LockRegistry, which flock()s a fixed set of local files. Two consequences follow from "local" and "fixed", and both are measured below.

Local means the protection stops at the machine. On a fleet of N application servers a cold key reaches your origin N times — twelve servers, twelve identical expensive queries arriving at one database together. The user-facing wait is unchanged, which is why this is a deliberate trade-off; what scales with the fleet is the load at the origin.

Fixed means a couple of dozen locks cover an unbounded keyspace — 24 files on Symfony 8.1, 25 on 6.4 and 7.3, chosen by abs(crc32($key)) % count($files). Two unrelated items that land on the same file wait for each other, for a lock that protects nothing they share.

Installation

composer require artigo/cache-stampede

Usage

use Artigo\Cache\MemoLock;
use Symfony\Component\Cache\Adapter\RedisAdapter;

$pool = new RedisAdapter(RedisAdapter::createConnection('redis://127.0.0.1'));
$pool->setCallbackWrapper(MemoLock::fromDsn('redis://127.0.0.1'));

That is the whole integration. setCallbackWrapper() is a public method on every Symfony adapter — no subclassing, no fork, no patched core — so this works with any pool: Redis, filesystem, chained, tag-aware, your own.

examples/expensive-query.php runs it — eight processes released together on a key that has just expired:

$ php examples/expensive-query.php
eight requests on a cold key, 400 ms to answer it

  waited   after   410 ms  ->  the report
  queried  after   408 ms  ->  the report
  waited   after   410 ms  ->  the report
  ... five more

the query ran 1 time(s) for 8 requests

One query, eight answers, and the seven that waited are served 2 ms behind the one that ran it.

Which of the two

MemoLock FleetLock
backend Redis only any symfony/lock store
waiters are woken by a Pub/Sub message a 100 ms poll
overhead over a 300 ms origin call 16 ms 134 ms
needs ext-redis or Relay symfony/lock

Take MemoLock if you have Redis. Take FleetLock if you do not — it is still far better than what you have today, as the second table below shows.

use Artigo\Cache\FleetLock;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;

$pool->setCallbackWrapper(new FleetLock(new LockFactory(new RedisStore($redis))));

Both take the timings you would expect — how long a winner may hold the lock, how long a loser waits before looking at the pool again — and both default to something sensible:

new MemoLock($redis, lockTtlMs: 30_000, waitTimeoutMs: 5_000);
new FleetLock($locks, ttl: 30.0);

Without a cache pool at all

The same lock, for the thing a cache is not: a thumbnail, a generated file, a provisioned resource. One caller produces it; the rest wait for the thing rather than for the lock, and carry on the moment it exists.

$thumbnail = $lock->once(
    'thumbnail:'.$id,
    exists: static fn (): ?string => is_file($out) ? $out : null,
    make: function () use ($source, $out): string {
        $this->render($source, $out);

        return $out;
    },
);

exists is asked before any lock is taken, so a caller arriving after the work is done never touches Redis for one. Returning null means "not there yet".

examples/thumbnail.php runs that for real — four processes released on a barrier, all asking for the same thumbnail:

$ php examples/thumbnail.php
four requests for one thumbnail, 400 ms to render

  waited   after   416 ms  ->  artigo-thumbnail-example.txt
  rendered after   414 ms  ->  artigo-thumbnail-example.txt
  waited   after   416 ms  ->  artigo-thumbnail-example.txt
  waited   after   416 ms  ->  artigo-thumbnail-example.txt

One render, four answers, and the three waiters finish 2 ms behind the one that did the work — that is the message arriving, not a poll coming round. A mutex would have handed the lock to each of them in turn and let each decide, again, whether to render.

A plain critical section

When the waiters want the lock rather than a result:

$lock->exclusively('import:companies', function (): void {
    $this->importCompanies();
});

The wake-up pays here too, which is the reason this is worth having beside symfony/lock. Eight workers holding a 200 ms section in turn, fully serialised either way:

total handover cost over eight
symfony/lock 1 719 ms 119 ms
exclusively() 1 609 ms 9 ms

Unlike everything else here, this throws. A cache that cannot take a lock computes anyway, because a duplicated computation beats a failed request; a caller who asked that two workers never run something at once would rather hear that it did not run, so an unreachable lock or one that never frees is a LockUnavailable.

Work that outlasts the lock

The closure holding the lock is handed a KeepAlive. Call it as the work goes and the expiry moves out by a full TTL each time, so a long import does not have its lock run out underneath it:

$lock->exclusively('import:companies', function (KeepAlive $keepAlive) use ($rows): void {
    foreach ($rows as $row) {
        $this->import($row);
        $keepAlive();
    }
});

once() hands one to its make for the same reason. Closures that do not want it simply declare no parameter.

It throws if the lock has gone — expired, and taken by somebody else while the work ran. That is the one thing a long section needs to hear, and a return value is too easy not to read; symfony/lock's refresh() throws for the same reason.

What symfony/lock still does better: it releases on destruct, where this releases when the closure returns — the same guarantee only if the work fits in a closure.

Per-call overrides

A slow resolver wants a lock that outlasts it; a cheap key is not worth locking at all. Symfony's get() has nowhere to say either — its signature is ($key, $callback, $beta, &$metadata) and $beta governs probabilistic early expiry, not locking — but setCallbackWrapper() hands back whatever it replaced, so a single call can borrow a different lock and give it back.

// a longer lock, for this call only
$value = $lock->with(lockTtlMs: 30_000)->around(
    $pool,
    static fn () => $pool->get('external-api', $resolver),
);

// no lock at all, for a key not worth protecting
$value = MemoLock::unguarded($pool, static fn () => $pool->get('cheap', $resolver));

with() answers a copy, so the lock the pool normally uses is untouched, and the restoring happens in a finally — which is the part that gets forgotten when this is written out by hand.

Choosing the lock TTL: set it to the longest the work should take. Too low and the waiters see it expire and rebuild in parallel, which is the thing being prevented; too high and a holder that dies keeps everybody waiting before one of them retakes it.

Measured, not asserted

benchmarks/stampede.php spawns real processes, releases them on a shared wall-clock barrier, and counts how many reach the origin. Redis 8, PHP 8.4, Linux.

One cold key, twelve concurrent requests

Setup Origin calls Overhead
no protection 12 / 12 9 ms
LockRegistry, 1 app server 1 / 12 107 ms
LockRegistry, 3 app servers 3 / 12 114 ms
LockRegistry, 6 app servers 6 / 12 110 ms
LockRegistry, 12 app servers 12 / 12 9 ms
FleetLock, 12 app servers 1 / 12 134 ms
MemoLock, any fleet size 1 / 12 16 ms
docker compose up -d
php benchmarks/stampede.php workers=12 resolverMs=300 hosts=12

Requests reaching the origin, by fleet size

LockRegistry gives exactly one origin call per app server — three hosts, three calls; twelve hosts, twelve. Its lock files are local, so a second machine cannot see them. Note the last row of its own block: twelve hosts looks fast only because nobody is waiting for anybody.

Different keys that happen to share a lock file

Distinct cold keys chosen to share a slot, 300 ms to compute each. All of them genuinely need computing, so the lock saves nothing here and only lines them up:

unrelated keys LockRegistry one lock per key
2 0.707 s 0.322 s
4 1.508 s 0.322 s
6 2.309 s 0.322 s
8 3.111 s 0.321 s
php benchmarks/stampede.php workers=8 resolverMs=300 keys=collide

Unrelated keys sharing a LockRegistry slot

Strictly linear: eight items at 300 ms each take 3.1 seconds between them instead of 0.3, on a single machine, with no fleet involved.

Notes worth knowing

  • Setting the wrapper explicitly also overrides Symfony's default of disabling locking under the CLI SAPI, which is what makes any of this measurable from a script. Symfony also disables LockRegistry entirely on Windows; neither lock here does.
  • Probabilistic early expiration is not what these locks do, and $beta is not read. Recomputing a little before expiry, at random, and hoping the herd never forms is the other answer to this problem — the answer these locks exist because of — and Symfony decides it upstream anyway, before the wrapper is ever called.
  • INF is honoured, the way LockRegistry honours it: a forced recompute waits for the lock like any other loser and then takes it. It arrives on the same parameter but means something explicit — recompute now — so it still gets you a value computed after you asked, and it gets it behind the lock rather than beside it.
  • Neither lock will ever be the reason a request fails: an unreachable Redis or lock store degrades to computing without protection, and says so through the PSR-3 logger.
  • Waiters take a read lock. Acquiring the exclusive one hands it over one at a time, and the last of twelve waits out eleven acquire/release cycles before it looks at the pool — measured at 486 ms against a 300 ms call, worse than no lock at all. LockRegistry avoids the same trap with flock(LOCK_SH).

Use them together

Invalidating a tag that matches twenty thousand items is one problem; twenty thousand items going cold at once, on every server you have, is the next one. artigo/versioned-cache answers the first, this package answers the second.

composer require artigo/cache-stampede artigo/versioned-cache
use Artigo\Cache\Adapter\VersionedRedisTagAwareAdapter;
use Artigo\Cache\MemoLock;
use Symfony\Component\Cache\Adapter\RedisAdapter;

$redis = RedisAdapter::createConnection('redis://127.0.0.1');

$pool = new VersionedRedisTagAwareAdapter($redis);
$pool->setCallbackWrapper(MemoLock::fromDsn('redis://127.0.0.1'));

Neither package requires the other, and the locks here improve any Symfony pool at all — filesystem, chained, tag-aware, your own.

A worked example of the two together, which runs, lives in the other repository: examples/together.php. It warms a handful of tagged cards, invalidates the tag they share and reads them back, printing how often the origin was reached at each step.

Requirements

PHP 8.4+ · symfony/cache 8.1+ · and then either ext-redis/Relay for MemoLock, or symfony/lock for FleetLock.

CI runs the suite against ext-redis on Redis 6.2, 7 and 8, and against Relay.

Benchmarks

Every number above is something you can re-run rather than take on trust:

script question it answers
benchmarks/stampede.php how many requests reach the origin when a key goes cold, and what does waiting cost?
examples/expensive-query.php eight requests, one cold cache key
examples/thumbnail.php four requests, one file that has to be generated

keys=collide switches it from one shared key to distinct keys that share a lock file, which is the second table. hosts=N models a fleet by giving each worker its own LockRegistry file set — which is what a separate machine is to flock.

Licence

MIT. See LICENSE.