artigo / versioned-cache
Tag invalidation that never gets slower: a tag-aware symfony/cache adapter for Redis 8.
Requires
- php: >=8.2
- ext-redis: *
- psr/log: ^1.1|^2|^3
- symfony/cache: ^6.4|^7.0|^8.0
- symfony/cache-contracts: ^3.1
Requires (Dev)
- artigo/cache-stampede: ^2.0
- cache/integration-tests: ^1.0.3
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5|^11.0
- psr/simple-cache: ^3.0
Suggests
- ext-apcu: To share the rule set across the workers of a server, so a request adopts it instead of loading the stream (the default where it is enabled)
- ext-relay: To use Relay instead of ext-redis, answering reads from a replica in the process
- ext-zstd: For ZstdMarshaller, which compresses values with zstd and tells a compressed one by its first four bytes
- artigo/cache-stampede: Stampede protection that spans a fleet: Symfony's own guards one machine
Provides
None
Conflicts
None
Replaces
None
README
A drop-in symfony/cache adapter that invalidates a tag in four Redis commands — whether it matches three items or twenty thousand.
Its other half is
artigo/cache-stampede. This package keeps an invalidation from being expensive; that one keeps the invalidated items from being recomputed by every server at once. Neither depends on the other — see Use them together.
What it is for
A Symfony application caches what it renders and tags every item with what it
depends on: a product page carries product-42, a category listing carries
category-7 and every product in it, the menu fragment carries menu. When
the price of product 42 changes, the application calls
invalidateTags(['product-42']) and expects every item carrying that tag to be
gone. This package is about what that call costs when the tag matches many
items, and what the Redis server does for everybody else while it runs.
Example. An editor renames category 7 at 12:00:00. The tag category-7 is
carried by 20 000 cached fragments.
- Symfony's
RedisTagAwareAdapterkeeps a SET of the 20 000 ids. The invalidation reads it and unlinks every member inside one Lua script: about 20 000 commands, 28 ms in the run below, during which the single-threaded server answers nobody. At a million entries the same ban takes 1.4 s, and aPINGsent meanwhile waits up to 61 ms for its reply. - Symfony's
TagAwareAdapterdeletes one version key per tag, so the invalidation is one command. The cost moves to the read side: the tag versions have to be fetched whenever they were not seen within 150 ms and on every miss, so a fresh process pays two round trips per read, and one version key per tag ever written stays in Redis without a TTL. - This adapter appends one rule to a Redis stream — "items tagged
category-7written before now are stale" — in four commands and about 3 ms, whether 1 000 or 1 000 000 entries match. The server keeps answering (PINGp99 0.17 ms during the ban), and a read costs the one round trip it costsRedisTagAwareAdapter. The 20 000 fragments stay in Redis until a reader finds one and drops it, aprune()collects them, or their TTL runs out.
So the invalidation's cost moves from the server, at invalidation time, to the readers: each pays one in-process comparison per tag it carries, and a stale item costs the first reader that finds it one drop. What you give up is memory coming back at once, and a tag index to inspect.
Symfony holds two corners of the design space: cheap invalidation with
expensive reads (TagAwareAdapter), and cheap reads with an invalidation that
grows with the match count (RedisTagAwareAdapter). This adapter is the third
corner — TagAwareAdapter's invalidation cost with RedisTagAwareAdapter's
read cost, and storage bounded by one trimmed stream.
Installation
composer require artigo/versioned-cache
Usage
It is a drop-in TagAwareAdapterInterface — the same constructor shape as
RedisTagAwareAdapter, the same DSNs, the same marshallers.
use Artigo\Cache\Adapter\VersionedRedisTagAwareAdapter; use Symfony\Component\Cache\Adapter\RedisAdapter; $pool = new VersionedRedisTagAwareAdapter( RedisAdapter::createConnection('redis://127.0.0.1'), ); $item = $pool->getItem('article-1'); $item->set($article)->tag(['articles', 'front-page']); $pool->save($item); $pool->invalidateTags(['articles']); // four commands, for 3 articles or a million $pool->prune(); // and the memory back, at your leisure
Invalidating deletes nothing, on purpose: a stale item is dropped by the
reader that finds it. prune() is for the items nobody reads again — see
Prune. Symfony's cache:pool:prune drives
it, so a nightly cron is all it takes. Nothing depends on it: a pool that is
never pruned is just as correct, only larger.
Six settings beyond Symfony's, each explained in its own section below:
new VersionedRedisTagAwareAdapter( $redis, namespace: 'app', defaultLifetime: 0, marshaller: null, rulesRetentionSeconds: 2_592_000, // 30 days; also the maximum item lifetime rulesCacheMs: 1_000, // 0 for exact reads, at a reload each rulesCache: null, // APCu where enabled; any PSR-6 pool; false for none rulesMaxTags: 15_000, // past it, a floor replaces the oldest rules; 0 for no bound guardWindowSeconds: 60, // how long a delete is remembered; 0 switches the guard off softGraceSeconds: 0, // how long what a soft invalidation reached is kept for StaleCache );
| setting | section |
|---|---|
rulesRetentionSeconds |
Tag invalidation that never gets slower |
rulesCacheMs |
The rule set a read is judged against |
rulesCache |
The shared rule set while it reloads |
rulesMaxTags |
The bound and its floor |
guardWindowSeconds |
The invalidation guard |
softGraceSeconds |
Soft invalidation |
Symfony framework
framework.cache.pools can use the adapter once it has an abstract service to
build pools from, the way FrameworkBundle defines cache.adapter.redis_tag_aware.
The provider may be a DSN; Symfony's own connection factory resolves it.
# config/services.yaml services: cache.adapter.versioned_redis: class: Artigo\Cache\Adapter\VersionedRedisTagAwareAdapter abstract: true arguments: - !abstract 'Redis connection service' - '' # namespace - 0 # default lifetime - '@?cache.default_marshaller' calls: - [setLogger, ['@?logger']] tags: - { name: cache.pool, provider: cache.default_redis_provider, clearer: cache.default_clearer, reset: reset } - { name: monolog.logger, channel: cache } # config/packages/cache.yaml framework: cache: pools: app.articles: adapter: cache.adapter.versioned_redis provider: 'redis://127.0.0.1'
The six settings beyond Symfony's are the fifth to tenth constructor
arguments; add them to arguments to change them. The seventh takes a pool
service for the shared rule set, false for none, or nothing for APCu.
Tag invalidation that never gets slower
The setup. A tag-aware cache has to be able to tell, later, which items a
tag named. RedisTagAwareAdapter writes the item's id into a SET per tag and
walks that SET when the tag is invalidated. TagAwareAdapter keeps a version
number per tag and compares it on every read. This adapter keeps neither. An
invalidation appends one rule to a Redis stream and returns. Every item is
written carrying the id of the stream's last entry at the time — its
watermark — and a read compares only the rules newer than that watermark
with the item's own tags. There is no index of which items a tag names, so
there is nothing to walk and nothing to repair.
The question. Who does the work of noticing that an item is stale, and when?
Example. At 12:00:00 article-7 is written with the tags articles and
front-page. The stream's last entry id is 1000-0, so that is the item's
watermark. At 12:05:00 an editor invalidates articles: rule 1300-0, "tag
articles fell", is appended — four commands, and the call returns. At
12:05:01 a request reads article-7: the rule 1300-0 is newer than the
watermark 1000-0 and names one of the item's tags, so the item is stale. The
reader drops its data and reports a miss, and the value is computed again.
article-9, written at 12:06:00, carries the watermark 1300-0; no rule is
newer than that, so it is a hit.
RedisTagAwareAdapter: the invalidation at 12:05:00 deletesarticle-7and every other member of thearticlesSET, one command per member, before it returns.TagAwareAdapter: the invalidation deletes the version key ofarticles. The read at 12:05:01 fetches the versions ofarticlesandfront-page(a second round trip, unless the process saw them within 150 ms), finds no version forarticles, and misses.- This adapter: the invalidation costs four commands. The read at 12:05:01
costs the same one round trip a plain read costs, because the rules are
already in the process.
article-7stays in Redis, as data nobody will serve again, until that read drops it.
So an invalidation costs the same four commands whether it matches three items or twenty thousand, and in exchange a stale item is removed by the reader who finds it rather than by the invalidation.
How it works. The invalidation is one script: XREVRANGE for the current
head, an XADD that trims the stream as it appends (rules older than the
longest possible item lifetime, counted back from the head, have nothing left
alive to match), and PERSIST, so the stream never carries an expiry. A
clear() of the whole pool appends a rule that covers every key, and adds one
XTRIM: every older rule is then redundant and the stream is cut back to it.
Each rule records whether it opened the stream, so a reader can tell a stream
that was lost and reborn from one that has simply been trimmed. Ordering is
causal rather than clock-based: stream ids are generated by the node that owns
the stream and are monotonic, so nothing anywhere compares two clocks.
Nothing accumulates in Redis. Every key here is bounded by its own TTL,
save one rules stream per pool that the retention trims. A tag SET is not: an
item that expires by its TTL keeps its id in every SET that names it until that
tag is next invalidated, and the SETs carry no TTL, so eviction cannot reclaim
them either - which is why RedisTagAwareAdapter refuses to run under an
allkeys-* policy at all. This adapter runs under one safely: a lost stream is
detected, and the items it covered read as a miss.
rulesRetentionSeconds (30 days) is how long invalidation rules are kept,
and therefore the longest an item may live: an item must never outlive the rule
that made it stale, or it would come back from the dead once that rule is
trimmed away. A longer TTL is capped to it, and a warning is logged once per
pool instance. An item saved with no lifetime at all gets the retention as its
TTL.
Measured. The invalidation column of the headline table:
4 commands and 0.000 s for 1 000, 2 000 and 20 000 matches, where
RedisTagAwareAdapter sends 1 006, 2 006 and 20 012; and
banning thousands of tags, where a million
entries fall in 3 ms while the server keeps answering PING in 0.17 ms.
The rule set a read is judged against
The setup. To judge an item, a worker needs the rules. It cannot ask Redis
for them on every read — that would be the round trip TagAwareAdapter pays
for its versions — so it keeps a compacted copy in memory: the newest rule per
tag, one integer each, plus the clears. The copy is refreshed from the stream
at most once per rulesCacheMs (1 000 ms), and a refresh fetches only what was
appended since the copy's last id.
The question. How old may that copy be — which is to say, how long can an invalidation made on another server, by a cron job or by a CLI process go unseen?
Example. At 12:00:00.000 a price changes on server A and product-42 is
invalidated. On server B a worker refreshed its copy at 11:59:59.400. That copy
does not hold the rule, so B serves the old price until its next refresh, which
happens on the first read after 12:00:00.400. The old price is gone within
rulesCacheMs of the invalidation, 1 s at worst while refreshes land at once,
and a second more while one is in flight.
rulesCacheMs: 0: every read that can take the reload flag refreshes first; while another worker holds it, a copy up to a second old judges the read, and an older one makes it a miss. The old price is gone within a second, and every read costs up to one round trip more (XRANGEfrom the last id held, which usually answers nothing).rulesCacheMs: 1_000, the default: the old price may be served for up to a second, two while a refresh is in flight, and the refreshes cost the server oneXRANGEper second, however many reads it serves (one per worker without a shared set).TagAwareAdapter's answer to the same question is 150 ms per tag, and a fetch of every tag the read carries whenever the window has passed.
So rulesCacheMs bounds how long an invalidation made elsewhere goes unseen,
counted from the moment the last refresh began, and buys reads that cost no
round trip for the rules. An invalidation made on the same server is never
served stale there while the stream can be read, after a reset() too; see
the shared rule set.
How it works. A refresh is an XRANGE from the last rule id the set holds,
in batches of ten thousand rules, absorbed into the set; a read is judged
against the newest rule per tag it carries, so neither the refresh nor the read
grows with the length of the log. The process that made an invalidation adds
the rule to its own set from the id Redis answered, where its set ends at the
rule the new one follows, so it sees its own invalidation at once, and shares
that set with the other workers of the server — in the call, for the first
invalidation of a request, and when the request ends for the ones after it;
otherwise its set is behind, and is not judged by until it is reloaded. While the stream
cannot be read at all, a worker keeps judging by the set it last read, however
old: reporting "no rules" would let an invalidated item read as fresh, which
is the one answer this must never give. One worker per server tries the
stream again a second after the last attempt ended (see
the shared rule set). A worker
holding no set reads every item as a miss until the rules are back, drops
nothing on a verdict it could not reach, and stores nothing it computes
meanwhile.
Measured. Ten thousand invalidations behind a fresh fill cost twenty
thousand reads three commands in all — one refresh per second, the first
carrying the ten thousand rules and the rest carrying nothing (the
read hit, 10 000 rules behind row).
The shared rule set while it reloads
The setup. Every tag invalidation writes a rule into Redis, like "items
tagged product-42 written before 12:00:00 are stale". To check an item
without asking Redis every time, each PHP worker keeps a copy of all rules in
memory, shared through APCu by all workers on a server. That copy goes out of
date whenever another server, a cron job or a CLI process invalidates
something. So about once a second (rulesCacheMs), one worker per server
takes a flag and reloads the copy, and the others pick up its result. Nobody
else fetches rules, unless the store refuses to record the flag (below).
The question. What should the other workers do while that worker is still fetching? This happens all the time, but usually the fetch takes 1–2 ms, so the answer barely matters. It matters when the fetch is slow: Redis under load, a large rule set, or the Redis node holding the rules failing over or hanging.
Example. At 12:00:00 a price changes on server A and product-42 is
invalidated. On server B, the worker holding the flag starts fetching, but
Redis is struggling and the fetch takes 3 seconds.
- "Wait for the reload": every request on server B waits for it. The price is correct, but the whole site slows down exactly when Redis is struggling.
- "Fetch on your own": every request on server B fetches the rules itself, and a Redis node that hangs makes every one of them wait for its own timeout.
- What this adapter does: nobody waits but the worker that reloads. For the first second of its fetch, a worker whose copy is at most a second past the window judges by it; one whose copy is older, behind an invalidation made on B, or missing reads every item as a miss and computes it, without the stampede lock, and stores it stamped with its copy's last rule (with no copy, it stores nothing). After that second the fetch counts as an outage for everyone else: every worker judges by the copy it holds, whatever its age, until Redis answers; one that holds none reads every item as a miss, computed without the stampede lock. Server B serves the old price until 12:00:03, and recomputes for at most a second.
What that promises, in plain words:
- While Redis is healthy and a reload lands within a second, an invalidation
made elsewhere is seen within
rulesCacheMsplus a second, plus the lag when reads go to replicas. A read that cannot promise that is a miss. - While the rules can be read, an invalidation made on this server is never served stale on this server: a copy behind it judges no read until it is reloaded. During an outage, or while a reload hangs past a second, a copy behind it judges reads like any other, and the invalidation is seen once the stream answers again.
- During an outage, or while a reload hangs past a second, invalidations made elsewhere are seen when Redis answers again, and nobody but one request per server per second fetches rules.
- No value computed after a read without rules, or without its epoch, is stored. Past the guard window that read still refuses its write, once, and is then forgotten.
A reload in flight costs at most a second of misses or of a slightly older copy, then staleness, and only the reloader waits for it — for as long as its fetch's timeouts allow, so keep them short: a second or two to connect and to read:
// in the DSN $redis = RedisAdapter::createConnection('redis://127.0.0.1?timeout=1&read_timeout=1.5'); // or on a connection of your own $redis->connect('127.0.0.1', 6379, 1.0); $redis->setOption(\Redis::OPT_READ_TIMEOUT, 1.5);
Left at their defaults, Symfony gives the connect 30 seconds and phpredis waits
default_socket_timeout, a minute, for a reply, and so would the reloader. A
fetch that fails puts the server in an outage (below).
Why the set is shared at all. Under PHP-FPM every request is a fresh
process. A process that holds no rules would have to load the whole stream —
every invalidation of the retention window — before its first hit. Shared, the
compacted set is loaded once per server and then only followed; a fresh
process adopts it instead. rulesCache says where: an ApcuAdapter by
default where APCu is enabled, any PSR-6 pool you pass otherwise, or false
to keep the set per instance. APCu is spoken to directly, because it keeps a
PHP array as it is and hands back a copy, where a pool serialises it on the
way in and parses it on the way out, and that cost is paid per request, by
the size of the set. The processes that share one medium are one server: the
reload flag, the outage and the mark of the newest invalidation live beside
the set and speak for all of them. So pass a pool per server, as APCu is; one
shared by several servers makes them one server, with one reloader and one
outage among them.
How it works.
- The flag is a lease kept beside the set: an owner token and the moment the reload began. One worker at a time takes it — APCu adds or swaps it in one step; in a pool you pass, a short file lock on the host serialises the look and the write — and only its owner releases it. It outlasts any reload: it is there for a worker that dies, not for one that hangs. A lease older than 30 seconds is free again, and a worker that dies of a fatal error lets go of it on shutdown.
- A store that refuses to record the flag — APCu while another process
expunges it, a pool on a full or read-only disk — would leave the server
with nobody to reload, and every read a miss for good. So a worker that
finds the flag free but cannot take it is a server of one for that read: it
loads the rules itself, from the last id of the set it holds, shares
nothing, keeps its outage to itself, and logs a warning, once per pool
instance. It costs one fetch of the stream per request while the store
refuses, as
rulesCache: falsedoes. A store that takes the flag but refuses the rule set itself leaves the others judging by the older set there; the worker whose set was refused logs a warning, once per pool instance. - A read takes the first of these that applies:
- a copy read less than
rulesCacheMsago and not behind an invalidation made on this server judges it, at no round trip — never withrulesCacheMs: 0; - during an outage, or while a reload has run for more than a second, the copy judges it whatever its age, a copy behind included. With none, every item is a miss, computed without the stampede lock: nothing it computes is stored, so the lock would only queue the requests for a key one behind another. One worker tries the stream again when the flag is free and a second has passed since the last attempt ended;
- with the flag free, the worker takes it and reloads:
XRANGEfrom the copy's last id, shared with the others when it lands — in place of their copy where the stream started over, as the primary confirms. A fetch that fails, an error reply (BUSY,LOADING) included, starts an outage, and only a fetch that reads the stream ends it; - while another worker's reload has run for at most a second, a copy at
most
rulesCacheMsplus a second old and not behind judges it — withrulesCacheMs: 0, a copy up to a second old; - anything else — an older copy, one behind, none — reads every item as a miss, computed without the stampede lock. With a copy, what is computed is stored, stamped with the copy's last rule, which every rule since judges; with none, nothing is stored.
- a copy read less than
- A copy's age counts from the moment the fetch that read it began. A failure reported by a reload that began before the last success is ignored.
- A write that follows no read takes its copy the same way — so it can become the reloader — and is stamped with the copy's last rule id; the stream's head is never read for a write. With no copy it is not stored, and the value it would replace is deleted (see the guard).
- An invalidation made on this server: where the invalidating process's copy
ends at the rule the new one follows, it adds the rule from the id Redis
answered. The reply proves that copy holds everything appended before the
call, so it is stamped with the moment just before it, and the others take
it for as fresh as that. A mark beside the shared set records the newest
rule a process of this server appended, and the time after the reply; a
copy read before the mark that lacks the rule is behind: it judges no read
until it is reloaded, through the flag like any reload — never by a fetch
of its own. The first invalidation of a request — a pool since its last
reset()— shares its copy at once, in theinvalidateTags()call, before it marks the server: a worker that sees the mark finds a shared copy that already holds the rule, adopts it, and judges by it — no fetch, no misses. That covers the usual save: one call. Later invalidations in the same request share nothing in the call; the copy is shared once when the request ends, byreset()(Messenger'sservices_resetterbetween messages calls it) or when the pool goes away, never at a read. Until then the other workers are behind those later rules, and reload through the flag. Where another rule landed in between, nothing is shared either. During an outage, or while a reload hangs past a second, a copy behind judges reads as any copy does, and the invalidation is seen once the stream answers again. The mark only moves forward: its time and its rule id are each raised in one step — a compare-and-swap in APCu, the host's file lock in a pool you pass — so two invalidations racing on one server leave the later one's. - A share stores the whole copy and reads the entry back first, so a newer
set is not overwritten: about 0.28 ms for a set of 2 000 rules in APCu,
1.28 ms for 10 000 and 1.67 ms for 15 000 (in a pool you pass, what a
write to that pool costs). A request pays it at most twice, however many
invalidateTags()calls it makes: once in its first call, once at its end; shared in every call, ten thousand calls of one tag would pay it ten thousand times. Each call is still a round trip, so passing the tags of one change in oneinvalidateTags()call stays the cheapest. reset()keeps the copy and the moment it was read; what a request read goes with the request.- With
rulesCache: falsea worker is a server of one: its copy, its flag and its outage are its own, and another instance sees a rule as one made elsewhere. - The entry is keyed by the rules key and the connection's identity (host, port, database, the masters of a cluster, a client-side key prefix), so two pools on one server that talk to different Redis servers, or under different prefixes, never read each other's rules. The entry says which format wrote it: in APCu, which only this package's processes write, a fresh process adopts an entry of its own format as it is, without checking each rule, and one of another format is a miss; in a pool handed in, where others may write too, every rule is checked.
Measured. Adopting the ten-thousand-tag set costs about 1.1 ms per fresh
pool and asks Redis for nothing; without sharing, a fresh pool loads the
stream: 4 000 more commands, about 19 ms per pool (the two
fresh pool every 10 reads rows). What a set of a
given size costs to adopt is in
the rule set a fresh process adopts.
The bound and its floor
The setup. The rule set holds one integer per tag invalidated within the
retention window, and every request copies it. A shop that invalidates
product-N on every price change holds as many rules as products changed in
the last 30 days; an application with a tag per entity can hold a hundred
thousand.
The question. What should the set do when it grows past what a request can afford to copy?
Example. 100 000 distinct entity tags have fallen behind a fill of 20 000 items. Unbounded, the set is 4 MB in APCu and costs every request about 10 ms to adopt. With the bound at 15 000, the set keeps its newest 13 500 rules and puts a floor in place of the rest: a clear of the whole pool at the newest id it dropped. Whatever a dropped rule invalidated, the floor invalidates too — so the 20 000 items, all older than the floor, read as a miss and are computed once, and an item written after the floor keeps its verdicts.
rulesMaxTags: 0, no bound: every hit is kept, and a 4 MB set is copied by every request (10.49 ms).rulesMaxTags: 15_000, the default: the set stays a few hundred KB however many distinct tags fall (1.00 ms to adopt), and items older than the floor are recomputed once. The 10 % of headroom keeps a set at its bound from being cut again by every rule that follows.
So the bound trades one recomputation of the items older than the floor for a set whose size no longer depends on how many distinct tags fall. The floor never serves a stale hit: an item it reaches reads as a miss.
How it works. Tags and clears count together. Past the bound the set sorts its rule ids, keeps the newest 90 %, and prepends a prefix rule for the whole pool at the newest id beyond them. A set is also trimmed by the retention — rules older than the longest possible item lifetime have nothing left alive to match — and by a clear of the whole pool, which says everything an older rule could say.
Measured. A set costs about 40 KB in APCu and 0.1 ms to adopt per
thousand rules; see the rule set a fresh process adopts
for the table, and the behind=100000 table there for the hit count behind a
hundred thousand entity tags.
Prune: giving the memory back
The setup. Invalidating deletes nothing. A stale item is dropped by the
reader that finds it, so an item that is read again costs its reader one drop
and nobody else anything. An item nobody reads again keeps its RAM until its
TTL runs out — up to rulesRetentionSeconds, 30 days.
The question. Who gives the memory back for the items nobody reads again?
Example. The 20 000 fragments of the renamed category, with nobody reading
them again. After invalidateTags(['category-7']) they are still 20 000 hashes
on the server, waiting for a reader or for their TTL.
RedisTagAwareAdapter: the eager walk deleted them as it went, which is what its 20 012 commands bought: the memory is back at once.- Doing nothing: correct, and larger. The pool is just as right without a prune; the server holds data nobody will serve until the TTL ends.
prune(): a walk of the pool, run bycache:pool:prunefrom a cron: 20 000 items back in 0.116 s and 122 round trips, because the judgement happens in the process and only the unlinks go to the server.
So the memory comes back lazily — by the reader, by the prune, or by the
TTL — and prune() is the way out of the one case where the bargain is a poor
one: a tag matching a great many items, none of them read afterwards.
How it works. prune() reads the rules once, at the start, then walks the
pool's keys with SCAN in batches of a thousand, reads each item's tags and
watermark, asks the rules the same question a read asks, and unlinks what a
read would drop. An item a soft invalidation reached
stays for its grace, as a read keeps it. Tombstones left by the
guard are left alone and expire by themselves,
except one that a delete leaves in the instant between the walk's read of an
item and its unlink: it goes with the item, and a computation that runs across
that delete is then stored, as after a delete older than the guard window. A
rule appended during the walk is not applied this time, and an item written
during it is newer than every rule in the snapshot, so it cannot be judged
stale: either way of being out of date keeps an item that could have gone.
When there is no rule set to judge by — none can be read, or another worker
is reloading one this worker may not judge by — nothing is deleted and
prune() answers false; during an outage a worker prunes with the set it
holds, which leaves an item a newer rule reached for a later prune — harmless. A pool with no namespace has no keys of its own to name, so the walk
covers the whole database and judges every hash in it, as Symfony's own prune
does: namespace a pool that shares its database. On a cluster the walk goes
master by master; the batches are pipelined where the connection allows it,
which a cluster only does where phpredis has cluster pipelines, so elsewhere a
prune costs one round trip per item rather than one per thousand. It is a job
for a cron either way.
Measured. What each adapter leaves behind.
The invalidation guard
The setup. Every cache that computes on a miss runs a race, in three steps:
- A request misses
article-42and starts computing it from the database. - Meanwhile the article changes, and whoever changed it calls
invalidateTags(['article-42'])ordeleteItem('article-42'). - The computation finishes and saves what it read before the change. The invalidation found nothing to invalidate, so the old article is served until its TTL runs out.
The question. Can a value computed from data that was deleted or invalidated while it was computed ever be stored as fresh?
Example. At 12:00:00.000 a request misses article-42 and starts a
computation that takes 4 ms. At 12:00:00.002 an editor saves the article and
invalidates article-42. At 12:00:00.004 the computation saves the old
article.
TagAwareAdapter: keeps the tag invalidation out only where the computing process knew the tag's version before its miss — a long-running worker that fetched it within 150 ms. A fresh pool, the PHP-FPM case, knows none, so the old article is stored as fresh and served for its TTL. A delete during the computation is never covered.RedisTagAwareAdapter: the old article is stored and served, for a tag invalidation and for a delete alike (922 and 927 of 1 000 rounds with one computing process, in the measurement below).- This adapter, the guard on (default): the write is stamped with the
rule its read was judged against,
1000-0, not with the stream's head. The rule from 12:00:00.002 is newer than the stamp, so the next read finds the item stale: written, read as stale. For a delete, the write is refused outright:save()answersfalse, and the next read computes afresh.
So the guard closes the race: a value computed across an invalidation is never served as fresh past it. The price is two more commands on the write that follows a miss, three commands instead of one for a delete, and a tombstone per deleted key for the window.
How it works. Every write leaves an epoch, a random token in a fourth
field. A delete leaves a tombstone: for guardWindowSeconds (60) the key
holds a fresh epoch and nothing else, which every read takes for a miss.
The write that follows a read of this pool is one script, which refuses
where the key holds an epoch other than the one the read saw, lands where it
holds that one or none, and stamps the item with the rule its read was judged
against rather than with the stream's head. So a delete during the
computation refuses the write while its tombstone lasts, and a tag
invalidation or a clear during it is newer than the stamp: the write lands,
and the next read finds it stale. A key with no epoch at all was not deleted
or written within the window, since both leave one for that long: the epoch
the read saw ran out by itself, and the write lands.
The script goes out as EVALSHA; where the server does not hold it — after a
SCRIPT FLUSH, a restart or a failover — it goes again as EVAL, once per
node. A write that follows no read stays the plain HSETEX, and an
invalidation stays the one script it always is (four commands): the guard
adds no script beyond the write that follows a read.
Which writes are guarded. The pool remembers, per key, the reads a write
may follow: a miss, a tombstone, a stale item, a value that does not
unmarshal, a hit that get() is about to recompute ($beta = INF, or an
early expiration). A read made with no rule set held is remembered too, and
the write after it is not stored: the caller gets its value. Where get()
computed it after its own miss, the key stays as it was, for the rules to
judge once they are back; any other write — a forced recomputation, a save
after that read, a plain write while no rule set is held — was to replace the
value, so it deletes it, and the value it was to replace is not served.
Which of the two a write is travels with the write, key by key; it is not
guessed from the reads remembered. A read of the item that fails — an error,
or a timeout raised from the pipeline — does not know the item's epoch: the
write after it is not stored either, by the same rule. (With
guardWindowSeconds: 0 nothing is remembered: a write after such a read is a
plain one, and with no rule set held the value it would replace is unlinked.)
Such a doubtful read refuses the write after it even past the guard window,
once, by the same rule, and is then forgotten, so the write after that is a
normal one; reset() forgets it at once. A plain hit forgets the
read before it; so does a write that lands and the pool's own
delete of the key. The pool's own invalidateTags() and clear() move the
reads that had seen the stream up to them on to the new rule: a value
computed by the pool that invalidated is not judged stale by its own
invalidation, which TagAwareTestTrait's testInvalidateCommits pins, while
a rule from elsewhere before it still counts and a delete from elsewhere
still refuses. A read that a rule from elsewhere followed before the pool's
own invalidation keeps its mark: a value it computes with the tag the pool
invalidated reads as a miss once — the safe direction. A pool remembers a
thousand reads besides its largest single fetch, the oldest forgotten first:
a batch of getItems() is guarded whole, however many keys it holds, and
past it the oldest single reads go first and their writes go out plain.
reset() and a clone forget them all.
What callers notice.
save()andcommit()answerfalsefor a refused write; the adapter logs one info line, "Refused to save …: it was deleted or written by somebody else while it was computed", and Symfony its usual warning for a failed save.get()still returns the value it computed, and on Symfony 8.2 setsItemInterface::METADATA_SAVE_FAILED. Symfony does not retry an id reported as failed, and the read stays remembered, so saving the same item again is refused too: read it again first.- A deleted key exists for the window as a hash with one field, about 280
bytes;
prune()leaves tombstones alone, and they expire by themselves. - The epoch lives for the window at least, so an item written with a shorter TTL keeps its key, holding the epoch alone, until the window has passed.
- An invalidation inside the callback, made through the same pool, is the pool's own: the value is written and read as fresh. Made through another pool instance, it is a rule from elsewhere, and the value reads stale.
guardWindowSeconds: 0switches it off: no epoch, a delete is anUNLINK, and every write is stamped when it lands, with the last rule id of the set it takes, as a write that follows no read is with the guard on.
Where it stops.
- A delete is seen by every write made within the guard window after it. If
a value can take longer than the guard window to compute, raise the window
above it: a computation that outlasts it writes after the tombstone has
gone, and a delete early in it is not seen. Tag invalidations and
clear()are always seen, whatever the computation takes: the stamp is the read's. prune()can remove a tombstone that a delete left a moment earlier, in the instant between its read of the item and its unlink. That is the same bound: a computation across that delete is stored.- A pool instance with
guardWindowSeconds: 0on the same keys deletes withUNLINK, which takes the tombstone away too, and writes without a new epoch. A guarded instance does not see those deletes and writes: give every instance of a pool the same window. - A value computed across a soft invalidation reads as stale, but within the grace it can be served aged, as any value a soft rule reached (soft invalidation).
- One pool shared by coroutines (Swoole, AMPHP): one coroutine's invalidation moves another's read on. One pool per request has no such interleaving.
ChainAdapter::get()reaches the last pool of its chain throughgetItem()andsave(), not itsget(): a miss is guarded there, a recomputed hit is not.- A write with no read of the same pool instance before it —
Psr16Cache::set(), a second pool — is a plain write, and lands over a tombstone as a write after a delete should.
The three rows of #67391. The reproduction behind
#67391 and
#67407 has three ways for a
computation to outlive an invalidation, and the guard covers all three on a
fresh pool, the PHP-FPM case
(InvalidationGuardTest::testAColdTagIsCoveredOnAFreshAdapter):
| during the computation | versioned |
|---|---|
invalidateTags(), the tag's version fetched before the miss (warm) |
written, read as stale |
invalidateTags(), the miss is the first read of the tag (cold) |
written, read as stale |
deleteItem() |
refused |
Measured with real processes (benchmarks/race.php, bastnic's lab:race
shape): one process sets a source value, makes the item a miss, waits part of
a computation, changes the source and invalidates again; other processes
compute the miss through get() all the while, with a callback that reads the
source and takes 4 ms. Counted is how often the old value is still served once
every computation that read it has written: out of 1 000 rounds, with one
computing process / with four. Each computation builds a fresh pool, as a
PHP-FPM request does, unless the row says one pool per process, as a
long-running worker keeps; the cluster is three masters with a replica each,
read from the replicas. Symfony's adapters are symfony/cache 8.1.11, #67407 its
head 7f926b3 over 8.2.0-BETA1, the versioned rows 2.0.1; one run per row:
| stack | tag invalidation | delete | tag invalidation, cluster | delete, cluster |
|---|---|---|---|---|
TagAwareAdapter |
894 / 741 | 821 / 818 | 851 / 522 | 836 / 854 |
TagAwareAdapter, one pool per process |
14 / 13 | 826 / 850 | ||
TagAwareAdapter from #67407 |
873 / 635 | 836 / 807 | 820 / 221 | 788 / 858 |
TagAwareAdapter from #67407, one pool per process |
16 / 14 | 840 / 841 | ||
RedisTagAwareAdapter |
922 / 880 | 927 / 920 | 950 / 887 | 938 / 885 |
versioned, guardWindowSeconds: 0 |
4 / 4 | 861 / 855 | 5 / 3 | 872 / 849 |
versioned, guardWindowSeconds: 0, rulesCacheMs: 0 |
759 / 812 | 811 / 835 | ||
| versioned, the guard on | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 |
versioned, the guard on, rulesCacheMs: 0 |
0 / 0 | 0 / 0 |
TagAwareAdapter keeps a tag invalidation out where the computing process
knew the tag's version before its miss — a long-running worker, which reuses a
version it fetched within 150 ms; a fresh pool knows none. #67407 (at
7f926b3) keeps more of them out where several processes compute at once,
never all, and like 8.1 it never covers a delete. Without the guard, this
adapter stamps a write with the rule set it holds while that set is younger
than rulesCacheMs, which keeps most tag invalidations out by the accident of
timing; with exact reads that set is reloaded as the write lands, so the
stamp is the stream's head, and 759 stale values survive. A delete is open either way. The rounds wait on counters, not on
timers: each computation is counted when it starts and when its write is done,
and the round is read once every computation that started before the change
has finished.
Per tag, not per pool. One epoch for the whole pool would be the simpler
guard, and the wrong one where something is invalidated all the time: under
ten unrelated invalidations a second, a hundred computations of 200 ms
(race.php precision=100):
| writes refused | |
|---|---|
| one epoch per pool: every write a rule was appended during | 100 of 100 |
| versioned: a rule for one of the item's own tags | 0 of 100 |
What it costs (bench.php guard=1, 20 000 items, 18 tags each; commands ·
round trips · time; this package 2.0.1, the middle of three runs):
| the guard on | guardWindowSeconds: 0 |
|
|---|---|---|
fill through get() misses |
100 006 · 40 006 · 5.0 s | 60 005 · 40 005 · 4.8 s |
fill through getItems() + commit(), 100 a batch |
100 001 · 1 001 · 0.65 s | 60 001 · 1 001 · 0.56 s |
| read hit | 20 002 · 20 002 · 2.2 s | 20 002 · 20 002 · 2.2 s |
| delete every item, 1 000 a call | 60 000 · 220 · 0.13 s | 20 · 40 · 0.03 s |
| left after the delete | 20 000 tombstones, 4.9 MB, for the window | nothing |
The write that follows a miss is five commands where it was three: the script
holds three (HGET, HSETEX, PEXPIRE) for an item that lives at least the
window, and one HSETEX more for one that lives shorter, whose epoch gets the
window as its own expiry; so the two more are the EVALSHA itself and the
HGET, in the same round trips. A delete is three commands per key where it
was one UNLINK per call, and holds its tombstone for the window.
Soft invalidation
The setup. HTTP caches have done this for years: Fastly's soft purge marks
content stale instead of deleting it and serves it while it revalidates,
Varnish's xkey.softpurge() keeps an object for grace mode, and
FOSHttpCacheBundle invalidates tags softly by default. Here a tag invalidation
is soft unless it says otherwise, and softGraceSeconds says how long an item
a soft invalidation reached is kept for a consumer that may serve it stale.
The question. When a tag that 300 page fragments carry falls, must every visitor wait for the fragments to be recomputed, when the old version was fine for another minute?
Example. At 12:00:00 an editor edits the menu and invalidates menu.
Twelve visitors arrive at once; each page reads 25 of the 300 fragments that
carry the tag, and a fragment takes the origin 20 ms.
- Hard invalidation (
hard: true, or any pool without a grace): the first reader of each fragment computes it. All 12 visitors wait, 551 ms at the median, 552 ms at most, and 300 origin calls run during the responses. - Soft invalidation with a grace, read through
artigo/cache-stampede'sStaleCachein soft mode: every visitor is served the old fragments at once, 18 ms for the 25 reads, 18 ms at most. The 300 refreshes run after the responses, one per fragment, elected throughMemoLock. No visitor waits.
So soft invalidation trades serving the old fragment for up to the grace against making visitors wait for its recomputation; a price, a stock level or anything that must never be served again is invalidated hard.
$pool->invalidateTags(['menu']); // soft $pool->invalidateTags(['price-42'], hard: true); // never served again
How it works. With softGraceSeconds, a read that finds an item only soft
rules reached, the newest of them less than the grace ago, does not drop it.
The read is a miss all the same — every PSR-6 and get() read stays strict —
and the item is kept for takeAgedItem() on the @experimental
AgedItemsInterface, which StaleCache reads in soft mode: it serves the old
value stale and refreshes it once. Nothing else consumes it, and without a
grace the two kinds of rule read alike. 0, the default, drops such an item
as a hard rule would.
- Who opts in. The value: a
StaleCache(..., soft: true)pool writes values that may be served after a tag invalidation; any other value is a miss. And the invalidation:hard: trueoverrules every value. - What stays hard. A hard rule, a delete, a clear, the bound's floor and a
lost stream. The newest hard rule per tag is kept beside the newest rule, so
a soft invalidation after a hard one leaves the hard one standing. What a
read kept is handed out once, and goes before that with the next read,
write or delete of the key through the pool; a clear, a hard invalidation
or a
reset()through the pool drops every kept copy. So a value this pool wrote, deleted, cleared or invalidated hard after its read is never handed out. A change made through another pool or process does not reach a copy already kept: the copy lasts until this pool next reads the key, or one of the above drops it. A pool keeps a thousand such copies at most, the oldest forgotten first. hasItem()andprune()judge as a read does: such an item is a miss, and stays on the server for the read that may keep it, until its grace has passed.- The window counts from the newest invalidation that reached the item,
on the server's clock, read once per process;
softGraceSecondsbounds how long an item is kept for it, andStaleCache's stale and error times apply within that. - No mark to lose. The invalidation time is the rule's own stream id, so nothing is written to an item when its tag falls, and a lost rule is a hard miss, never a stale hit. An implementation that marks items instead — a stamp beside a tag, a flag on a value — has to fail hard when the mark is evicted, or the mark becomes the way an invalidated value is served.
- And the guard. A computation that straddles a soft invalidation is stamped where its read was, so it reads stale, and comes back only aged — never fresh.
Measured with real processes (benchmarks/soft.php, cache-stampede's
StaleCache in soft mode over this adapter with MemoLock): 300 fragments
carry one tag, twelve visitors arrive at once after it falls, each page reads
25 of them, and a fragment takes the origin 20 ms:
| invalidation | visitors who waited for the origin | wait p50 | wait max | origin calls during the responses | after them |
|---|---|---|---|---|---|
| hard | 12 / 12 | 551 ms | 552 ms | 300 | 0 |
| soft | 0 / 12 | 18 ms | 18 ms | 0 | 300 |
The soft wait is the 25 reads; the 300 refreshes run after the responses, one
per fragment, elected through MemoLock. Redis 8.10.1, PHP 8.5.12, this
package 2.0.1 with cache-stampede 2.0.0 over symfony/cache 8.1.11, the
benchmark in a container on the Redis container's network; the middle of three
runs.
Replicas
The setup. On a cluster with failover=slaves, phpredis sends a cluster's
read-only commands to replicas and everything else to the primaries. A replica
lags behind its primary by whatever replication takes.
The question. Can a read that a lagging replica answers serve a value the rules have already invalidated?
Example. At 12:00:00.000 product-42 is invalidated on the primary. A
replica is 200 ms behind. At 12:00:00.100 a worker refreshes its rules from
that replica: the rule is not there yet, so the worker's set stays as it was,
and it fetches the rule on a later refresh — the invalidation is seen late by
the lag, on top of rulesCacheMs. A read of the item itself from that replica
sees the item as it was 200 ms ago: older, or missing, never a value the set
the worker holds has invalidated. A fetch that comes back as if the stream had
been lost — the id the worker holds is not there, or nothing is — is not
believed: the same range is asked of the primary before anything is dropped.
| command | goes to | what a lagging replica does to it |
|---|---|---|
HMGET (a read) |
a replica | an item looks older, or missing: a miss where the primary has a hit, never a value the set has invalidated |
XRANGE (a rule fetch) |
a replica | a rule arrives late by the lag, on top of rulesCacheMs |
EVAL (an invalidation, a confirmation) |
the primary | nothing |
EVALSHA (the write that follows a read) |
the primary | a read that saw an older epoch, or missed a tombstone, makes the write refuse while the primary holds the newer one: it never lands where a write after a read of the primary would not |
HSETEX, EXPIRE, HDEL, UNLINK (writes) |
the primary | nothing |
So a lagging replica can make a read stricter, or an invalidation later by the lag, and never serve a value past an invalidation the set already holds. The price is one more round trip in a rare case: the primary is asked again when a replica's answer is in doubt.
How it works. A replica that lags behind the rule id a worker holds
answers a fetch exactly as a lost stream would, and a lost stream is a reason
to drop the set. So on a connection that may read from replicas, that answer
is asked again of the primary — the same XRANGE, as a one-line EVAL — and
only the primary's answer may drop anything; so is a fetch that ends before a
rule this server just appended. On such a connection the writes go out as
typed hsetex(), expire(), hdel() and hpexpire() calls: phpredis sends
a raw command to the node the last read of its slot went to, a replica, which
refuses it. failover=slaves therefore needs phpredis 6.3 or later, the
first with hsetex(); an older one keeps the raw command, and its writes fail
once a read of the same slot has gone to a replica. Relay's cluster client is
given the same typed calls; CI runs Relay against three masters, but not
reading from replicas, so that path is untested.
Clients: standalone, cluster, RedisArray, key prefix, Relay
The setup. Every command the adapter issues touches a single key, so three
masters are no different from one. The two clients, ext-redis and Relay, route
a command by the key it names; a RedisArray routes a script by the node its
body hashes to, and a client-side key prefix (OPT_PREFIX) is put on keys by
the client for typed calls and scripts but not for raw commands.
The question. Does each command reach the node that owns its key, with the key spelt the way the server stores it?
Example. A pool with the namespace app on a RedisArray of two nodes,
the client prefixed shop:. The rules stream is shop:app:@rules; node 2
owns it. An invalidation is a script, which the array would run on whichever
node its body hashes to — so the adapter sends it to node 2 itself. A read of
app:article-7 is a typed hMGet, which the array routes to the key's owner
and the client prefixes. A raw HSETEX carries no prefix from the client, so
the adapter writes shop:app:article-7 into the command and sends it to the
node that owns the bare key.
- Standalone and cluster: every command is routed by its key by the client itself; the adapter prefixes the keys of its raw commands and leaves the address — the key a cluster hashes for the slot — bare, as a prefixed address would be prefixed twice.
RedisArray: no raw command of its own and nothing to pipeline across nodes, so every call goes one at a time to the node that owns its key, and every command naming the rules stream to the node that owns it.- Relay: the same code paths as ext-redis; the adapter should not be able to tell which one it is holding. After a guarded write, Relay's in-process cache is told to take in the server's invalidation, so another connection's cached miss is not served past it.
- A cluster with phpredis cluster pipelines
(phpredis#2798,
unreleased): the adapter sends one pipeline across the slots;
ClusterPipelineTestpins that path. Without them, a cluster gets one command per round trip.
So the adapter runs on a single node, a cluster, an array and through Relay, with or without a client-side prefix, and CI covers every one of those legs (see Requirements).
ZstdMarshaller: compress in the marshaller, never in the client
The setup. The adapter writes raw hash fields and reads them back with
HMGET, so it refuses phpredis' own serializer and compression, as
RedisTagAwareAdapter does. The marshaller is where compression belongs, and
it pays: bastnic measured 2 609 → 743 bytes per model and about 80 % less load
on the cache nodes
(his post).
The question. Deflate or zstd, and how does a read tell a compressed value from a raw one?
Example. A ~2.6 KB product model. Deflate makes it 0.482 of its size in 56 µs and inflates it in 14 µs. Zstd makes it 0.508 of its size in 24 µs and inflates it in 6.2 µs. A ~300-byte record: deflate 0.661 in 11 µs, zstd 0.723 in 7.4 µs. A 100-byte value is not worth compressing at all: a frame is smaller than the value only from about 90 bytes on.
DefaultMarshaller: 2 912 bytes on the wire per item written, nothing to decide on read.DeflateMarshaller: 1 329 bytes (−54 %); a read tries to inflate every value, 330 ns when the value was not deflated.ZstdMarshaller: 1 376 bytes (−53 %); a read tells a frame from a raw value by its first four bytes, a 4-byte compare (30 ns), which is what lets it store a value raw when a frame would not pay.
So zstd keeps about 5 % more bytes than deflate on these values and takes less
than half of its time to write them and to read them; where
ext-zstd is loaded, use this package's ZstdMarshaller, elsewhere Symfony's
DeflateMarshaller. Either works with any Symfony pool:
use Artigo\Cache\Adapter\VersionedRedisTagAwareAdapter; use Artigo\Cache\Marshaller\ZstdMarshaller; use Symfony\Component\Cache\Marshaller\DefaultMarshaller; $pool = new VersionedRedisTagAwareAdapter($redis, marshaller: new ZstdMarshaller(new DefaultMarshaller()));
# config/services.yaml: every pool that uses the default marshaller services: Artigo\Cache\Marshaller\ZstdMarshaller: decorates: cache.default_marshaller arguments: ['@.inner']
Since FrameworkBundle 8.1 a single pool can take it with its marshaller
option instead.
Measured. For a ~2.5 KB entity (bench.php stores=versioned payload=model,
without a marshaller option, then with marshaller=deflate and
marshaller=zstd):
| marshaller | bytes Redis received per item written | commands |
|---|---|---|
DefaultMarshaller |
2 912 B | 5 per item |
DeflateMarshaller |
1 329 B (−54 %) | 5 per item |
ZstdMarshaller |
1 376 B (−53 %) | 5 per item |
The bytes are the whole write — the HMGET that found the miss and the
EVALSHA that carries the value, its tags, its watermark and the epochs; the
five commands count the three the script runs — so the value itself shrank by
more than that.
On the ~2.6 KB models zstd keeps 5 % more bytes and takes less than half of
deflate's time to write them and to read them; on ~300-byte records it keeps
9 % more, takes two thirds of the time to write them and the same time to read
them; on a large fragment it is the same size at an eighth of the write time
(benchmarks/marshaller.php, PHP 8.5.12, ext-zstd 0.18.0 with libzstd 1.5.7,
the medians of three runs):
| values | deflate (level -1): ratio · compress · decompress | zstd (level 3): ratio · compress · decompress |
|---|---|---|
| product models, ~2.6 KB | 0.482 · 56 µs · 14 µs | 0.508 · 24 µs · 6.2 µs |
| records, ~300 B | 0.661 · 11 µs · 3.1 µs | 0.723 · 7.4 µs · 3.2 µs |
| a rendered fragment, ~50 KB | 0.135 · 701 µs · 28 µs | 0.136 · 93 µs · 26 µs |
How it works. A zstd frame begins with a four-byte magic number, so
ZstdMarshaller tells a compressed value from a raw one with a 4-byte compare
(30 ns), where DeflateMarshaller tries to inflate every value it reads
(330 ns when the value was not deflated). That is what lets it keep a value
raw: one shorter than the threshold, or one the frame would not shrink. A
frame is smaller than the value only from about 90 bytes on, and around 100
bytes it saves 4 %, so values under 256 bytes stay raw by default
(threshold). A raw value that happens to begin with the magic number is
compressed whatever its size, so whatever the marshaller writes and begins
with it is a frame; such a value longer than maxLength (16 MiB) cannot be
stored either way and is reported as failed, as the inner marshaller reports
a value it cannot serialize. The constructor takes the zstd level (1 to 22;
3, zstd's own default), the threshold and maxLength.
Caveats. Values written before the marshaller was added read as they are,
unless one begins with the magic number: any stored value that does is read
as a frame. A value DeflateMarshaller wrote has no header and reads as a
miss (Symfony's own adapters log the failed unserialize), so moving a pool
from one to the other is a cold cache. A value that begins with the magic
number is read as exactly one frame, the way the marshaller writes it, and
refused, so read as a miss, where it is anything else: where the header sets
the reserved bit; where it states no size, a size of 0, 4 GiB or more, or more
than maxLength (16 MiB; longer values are stored raw), checked before
inflating because the extension reserves the stated size first; where the frame's
blocks, followed to the last one, do not end exactly where the value does (a
frame cut short, a second frame, a skippable frame or any other bytes after
it), which zstd_uncompress() alone would not notice; and where the frame
does not inflate to the size it states. Frames carry no checksum: bytes
damaged inside one can inflate to a different value of the right size, as
with deflate.
The hash encoding: memory per item
The setup. An item is a four-field hash — value, tags, watermark and the
guard's epoch — and Redis keeps a hash as a compact listpack only while
every field fits hash-max-listpack-value bytes, 64 by default. Most cached
values are larger, so by default an item is a hashtable; the expiry on its
fields adds about 50 bytes either way, and the epoch 30 to 60.
The question. How much more does an item cost here than
RedisTagAwareAdapter's string, and what can be done about it?
Example. A 100-byte value is 476 bytes as a hashtable against 195 for
RedisTagAwareAdapter's string: 503 MB for a million such items where
RedisTagAwareAdapter holds 249 MB. With hash-max-listpack-value raised to
4096 the same item is 280 bytes. At 2.6 KB values — bastnic's — a million items
take 3 280 MB by default, 3 021 MB with the limit raised, where
RedisTagAwareAdapter holds 3 025 MB: the gap is small either way.
- Leave the default: small values cost about twice what a string costs.
- Raise
hash-max-listpack-valueto 4096: a standard setting (redis.conf,CONFIG SET, an ElastiCache or MemoryDB parameter group; Valkey has the same name), and with four fields per item alistpackcosts nothing to search. It applies to every hash on the server, so weigh it against hashes with many large fields elsewhere.
So the memory per item is set by Redis' hash encoding rather than by the
rules, and the limit is the knob. Per item, as each adapter writes it
(MEMORY USAGE, Redis 8.10.1, symfony/cache 8.1.11):
| value | versioned, limit 64 (default) | versioned, limit 4096 | RedisTagAwareAdapter |
TagAwareAdapter |
|---|---|---|---|---|
| 100 B | 476 B (hashtable) |
280 B (listpackex) |
195 B | 195 B |
| 2 600 B | 3 388 B (hashtable) |
2 781 B (listpackex) |
3 107 B | 3 107 B |
MEMORY USAGE leaves the allocator's overhead out, so for planning read what
the server holds: a million 2.6 KB items, one tag each (ban.php tags=1000 entries=1000 bytes=2600, used_memory):
| versioned, limit 64 | versioned, limit 4096 | RedisTagAwareAdapter |
TagAwareAdapter |
|
|---|---|---|---|---|
| before the ban | 3 280 MB | 3 021 MB | 3 025 MB | 2 997 MB |
| after it | 3 280 MB (kept) | 3 021 MB (kept) | 2.0 MB (freed) | 2 997 MB (kept) |
Deploying a new version
During a rolling restart, old and new workers miss each other's shared entry — the entry says which format wrote it — and load from the stream until the old ones are gone. Items need nothing: a reader without the guard ignores the epoch, and one with it takes an item without an epoch for one written before it.
Measured, not asserted
benchmarks/bench.php runs this adapter, Symfony's RedisTagAwareAdapter and
Symfony's generic TagAwareAdapter (over a RedisAdapter) through the same
scenarios. Wall clock is worth only as much as the link it was measured over,
so every row carries the commands Redis executed and, where it differs, the
socket reads it took to receive them — round trips, in all but name — neither
of which moves when the network does.
20 000 items, 18 tags each, Redis 8.10.1, PHP 8.5.12, this package 2.0.1 against symfony/cache 8.1.11, the middle of three runs for each cell, the benchmark in a container on the Redis container's network (see the end of this section):
| Scenario | VersionedRedisTagAwareAdapter |
RedisTagAwareAdapter |
TagAwareAdapter |
|---|---|---|---|
| fill (commands · round trips) | 100 102 · 20 783 | 71 671 · 21 231 | 61 595 · 40 996 |
| overwrite (commands · round trips) | 60 003 · 20 768 | 87 195 · 21 281 | 40 053 · 21 024 |
| read hit (commands) | 20 003 | 20 000 | 21 774 |
| invalidate 1 000 matches | 4 · 0.000 s | 1 006 · 0.002 s | 1 · 0.000 s |
| invalidate 2 000 matches | 4 · 0.000 s | 2 006 · 0.003 s | 1 · 0.000 s |
| invalidate 20 000 matches | 4 · 0.000 s | 20 012 · 0.027 s | 1 · 0.000 s |
| read after invalidating everything | 60 004 · 4.2 s | 20 000 · 1.7 s | 40 000 · 4.7 s |
| memory reclaimed by reading | 14.2 MB | 16.5 MB | 0.0 MB |
| 10 000 invalidations matching nothing, one tag per call | 40 000 · 1.3 s | 50 000 · 1.1 s | 10 000 · 0.9 s |
| read hit, 10 000 rules behind | 20 003 · 2.3 s | 20 000 · 2.0 s | 21 836 · 2.5 s |
| read hit, fresh pool every 10 reads, 10 000 rules behind | 20 004 · 4.5 s | 20 000 · 2.0 s | 40 000 · 4.7 s |
| the same, the rule set not shared | 24 000 · 41.0 s | — | — |
Four of those rows are the questions a rule log invites.
What does a loop of invalidations cost? The 10 000 invalidations matching nothing row calls invalidateTags() ten thousand times, one tag per call:
four commands a call, and the updated rule set is stored for the other workers
of the server at most twice in the request, in its first call and when it
ends (see the shared rule set). So
the loop takes 1.3 s, about what 2.0.0 took (1.2 s); stored in every call, it
would take 7.8 s. Passed in one invalidateTags() call, the same ten thousand
tags are one rule and one script: 3 ms.
What does a long log cost the readers? Ten thousand invalidations land
behind a fresh fill, so every item is older than every rule. Three commands over
twenty thousand reads — one refresh per second, the first carrying the ten
thousand rules and the rest carrying nothing — because a refresh fetches only
what was appended, and a read is judged against the newest rule per tag rather
than against the log. TagAwareAdapter pays for the same log differently: its
versions are per tag, so a read whose tags it has not seen within 150 ms
fetches them again.
What does a fresh process pay? Under PHP-FPM every request is one. The last
two rows re-create the pool every ten reads, behind those ten thousand rules.
Sharing the rule set through APCu, adopting the ten-thousand-tag set costs
about 1.1 ms per fresh pool and asks Redis for nothing; without sharing a fresh
pool loads the stream: 4 000 more commands, about 19 ms per pool. With the
round trip next to nothing, as it is here, copying the set is the cost of a
fresh pool, and it grows with the set (see
what a fresh process adopts): this row
is the one where RedisTagAwareAdapter is faster in time.
TagAwareAdapter has no set to lose, but a fresh pool has no known versions
either, so every read costs two round trips; RedisTagAwareAdapter holds
nothing between requests and pays nothing for a fresh one. All of that is
with a round trip of under a tenth of a millisecond: over a real link the
second round trip of every read weighs more than the copy (see
what a request pays in time).
docker compose up -d php benchmarks/bench.php items=20000 php benchmarks/bench.php items=20000 unique=1
Four commands, always. A thousand matches or twenty thousand, the
invalidation is one script: XREVRANGE, an XADD that trims the stream as it
appends, and PERSIST. The eager side tracks the match count
exactly — 1 006, 2 006, 20 012 — so the distance between them belongs to
whoever grows. TagAwareAdapter needs one command, and always did: the
difference to it is on the read side.
What each adapter leaves behind
A cache is mostly written to and only sometimes read back, so what the server is still holding afterwards matters as much as what an invalidation cost. The same 20 000 items, with nobody reading them again:
VersionedRedisTagAwareAdapter |
RedisTagAwareAdapter |
TagAwareAdapter |
|
|---|---|---|---|
| after every item expired | 20 000 epochs until the guard window ends, then nothing | 1 574 sets holding 360 000 members | 1 574 tag version keys |
| after invalidating everything | 20 000 hashes, 1 stream | 1 573 sets holding 340 000 members | 20 000 items, 1 573 tag version keys |
after prune() |
1 stream | — | unchanged |
| what that prune cost | 20 041 · 122 round trips · 0.116 s | — | 0 commands |
Expiry leaves nothing here. The tags live inside the item, so when Redis
drops the key it drops the last thing that named the tag. These items lived
two seconds, shorter than the guard window, so each key kept its epoch alone
until the window ended (see the invalidation guard);
an item that lives longer than the window takes it along. A tag SET outlives
every item in it: all 360 000 of those members point at keys that no longer
exist, and the SETs carry no TTL, so no eviction policy reclaims them either.
They are cleaned when the tag is next invalidated, or by the prune()
RedisTagAwareAdapter gains in Symfony 8.2 (#65353) — not in the 8.1 the
run above used, hence the dash. TagAwareAdapter writes one version key per
tag ever used, also without a TTL, and has no way to collect them at all.
Invalidation is the other way round. The eager walk deleted the items as it
went, which is what its 20 012 commands bought; here they are still there,
waiting for a reader or a prune. That is the bargain this adapter makes, and
prune() is the way out of the case where it is a poor one: 20 000 items back
in 0.116 s and 122 round trips, because the judgement happens in the process
and only the unlinks go to the server. TagAwareAdapter is Pruneable too,
but forwards to its inner pool, which a Redis pool is not, so nothing happens.
With a tag of its own on every item
The tags applications really use include one per entity — article-42 — which
no other item shares and no per-tag cache can have warmed. The same run with
one of the 18 tags being the item's own (unique=1, one run):
| Scenario | VersionedRedisTagAwareAdapter |
RedisTagAwareAdapter |
TagAwareAdapter |
|---|---|---|---|
| fill (commands · round trips) | 100 102 · 20 790 | 89 606 · 21 280 | 81 498 · 41 096 |
| overwrite (commands · round trips) | 60 003 · 20 767 | 112 416 · 21 337 | 60 021 · 41 013 |
| read hit (commands · round trips) | 20 002 · 20 002 | 20 000 · 20 000 | 40 000 · 40 000 |
| read hit, 10 000 rules behind | 20 003 · 2.3 s | 20 000 · 2.0 s | 40 000 · 4.8 s |
| read hit, fresh pool every 10 reads | 20 004 · 4.6 s | 20 000 · 2.0 s | 40 000 · 4.7 s |
| the same, the rule set not shared | 24 000 · 42.0 s | — | — |
| after every item expired | 20 000 epochs until the guard window ends, then nothing | 21 477 sets holding 360 000 members | 21 477 tag version keys |
| after invalidating everything | 20 000 hashes, 1 stream | 21 476 sets holding 340 000 members | 20 000 items, 21 476 tag version keys |
Two round trips per read, hit or miss, is the cost of keeping the versions next to the tags rather than the rules next to the pool. The rule log's read side does not move between the two runs.
Banning thousands of tags
The scenario behind #67391,
in the format of bastnic's lab:ban, so the rows can be read beside his
(his post):
entries populated through each adapter's public API, one tag per entry and
~100-byte values, then every tag invalidated in chunks of 50 while a second
process sends PING to every primary in a loop. A slow PING is a server busy
with something else.
Standalone, 1 000 tags × 1 000 entries (a million), then 5 000 × 200; this package 2.0.0 against symfony/cache 8.1.11, the middle of three runs for each cell:
| stack | ban duration | commands on the server | PING p50 / p99 / max during the 1 000 × 1 000 ban |
memory before → after the 1 000 × 1 000 ban |
|---|---|---|---|---|
VersionedRedisTagAwareAdapter |
3 ms / 15 ms | 81 / 401 (one script per chunk: XREVRANGE, XADD, PERSIST) |
0.09 / 0.17 / 0.17 ms | 503 MB → 503 MB (kept) |
RedisTagAwareAdapter |
1.4 s / 1.7 s | 1 006 000 / 1 030 000 (DEL inside Lua, SSCAN) |
14.8 / 61.2 / 61.2 ms | 249 MB → 2.3 MB (freed) |
TagAwareAdapter |
3 ms / 16 ms | 20 / 100 UNLINK (tag keys only) |
0.10 / 0.17 / 0.17 ms | 250 MB → 250 MB (kept) |
Cluster, three masters with a replica each, 1 000 tags × 200 entries, then
5 000 × 40 (a ban sends only writes, which go to the primaries whatever the
failover option):
| stack | ban duration | commands on the primaries | PING max |
|---|---|---|---|
VersionedRedisTagAwareAdapter |
4 ms / 16 ms | 81 / 401 (one script per chunk) | 0.33 ms / 0.23 ms |
RedisTagAwareAdapter |
16.8 s / 17.2 s | ~133k UNLINK sent by PHP + ~68k / ~72k DEL inside Lua |
1.45 ms / 19.8 ms |
TagAwareAdapter |
110 ms / 570 ms | 1 000 / 5 000 UNLINK |
0.33 ms / 0.50 ms |
TagAwareAdapter, every tag version in one slot |
4 ms / 17 ms | 20 / 100 UNLINK |
0.18 ms / 0.20 ms |
On the cluster every row's PING p99 is 0.15–0.33 ms; the maximum during
RedisTagAwareAdapter's walks of 17 seconds moves from run to run, 1.0 to
23 ms. The last row was measured with 2.0.3, the versioned and
TagAwareAdapter rows run again beside it (4 / 16–17 ms and 111–114 /
542–570 ms, as above).
Read against TagAwareAdapter, the tables show no gain in the ban itself.
Both invalidate by writing rather than deleting, so on one server both take
3 ms and the server keeps answering; TagAwareAdapter sends fewer commands
(one UNLINK per chunk of 50 tags, against one script of four) and, at these
100-byte values, holds half the memory (see below). Its 110 ms on a cluster
come from where its keys land: its tag versions are spread over the slots,
and phpredis splits a multi-key UNLINK by slot, so a chunk of 50 tags is 50
commands, a round trip each. Give its tags pool a connection of its own whose
key prefix is the hash tag {tags} (stores=tagaware-slot), and every
version lands in one slot: a chunk is one UNLINK again, and the ban takes
this adapter's time. The price is that every version read and write goes to
that one node. The ban is what this adapter gains on RedisTagAwareAdapter;
what it gains on TagAwareAdapter is on the read side
(what a request pays in time).
Two things to read off it. The ban costs the same handful of commands whether
it names a thousand entries or a million, and the server never stops answering
while it happens: the PINGs during the versioned ban are the ones before it.
And the memory column is this adapter's price at these values, set by Redis'
hash encoding rather than by the rules: an item is a hash, and a hash stays a
compact listpack only while every field is at most hash-max-listpack-value
bytes, 64 by default. A 100-byte value tips it into a hashtable: 476 bytes
for the item against 195 for RedisTagAwareAdapter's string (the expiry on its
fields is about 50 of them, the guard's epoch about 60). Raise the limit and
the same item is 280 bytes;
at bastnic's 2.6 KB values the gap is small either way (see
the hash encoding). An invalidated item
stays until it is read, pruned or expires; the eager walk gives it back at
once, which is what its 1.4 seconds of a blocked server bought.
php benchmarks/ban.php tags=1000 entries=1000 php benchmarks/ban.php tags=5000 entries=200 php benchmarks/ban.php dsn="redis:?host[127.0.0.1:7110]&host[127.0.0.1:7111]&host[127.0.0.1:7112]&redis_cluster=1" tags=1000 entries=200 php benchmarks/ban.php dsn="redis:?host[127.0.0.1:7110]&host[127.0.0.1:7111]&host[127.0.0.1:7112]&redis_cluster=1" tags=5000 entries=40 php benchmarks/ban.php dsn="redis:?host[127.0.0.1:7110]&host[127.0.0.1:7111]&host[127.0.0.1:7112]&redis_cluster=1" tags=1000 entries=200 stores=versioned,tagaware,tagaware-slot
Round trips per request
bastnic's lab:read: a request reads 100 keys, through a get() loop or
through getItems() and one batch of saveDeferred() + commit() for what it
missed. Round trips are counted on the server (total_reads_processed, summed
over the nodes that serve the reads), with tiny values so that one burst is one
read. Every request is a fresh pool, as under PHP-FPM; the versioned pool
adopts the rule set the request before it shared, which is the case within
rulesCacheMs of a refresh — otherwise one XRANGE more. Ten keys share a tag.
Each cell is the lower of two runs, against symfony/cache 8.1.11 and
8.2.0-BETA1, which count alike except that on a cluster a replica's
REPLCONF ACK can land in a cell as one read more.
| strategy | standalone warm | cluster warm | standalone cold | cluster cold |
|---|---|---|---|---|
get() loop, VersionedRedisTagAwareAdapter |
100 | 100 | 200 | 200 |
get() loop, RedisTagAwareAdapter |
100 | 100 | 201 | 301 |
get() loop, TagAwareAdapter |
110 | 110 | 400 | 400 |
get() loop, RedisAdapter |
100 | 100 | 200 | 200 |
getItems() + batch, VersionedRedisTagAwareAdapter |
1 | 100 | 2 (3 reads) | 200 |
getItems() + batch, RedisTagAwareAdapter |
1 | 100 | 3 | 211 |
getItems() + batch, TagAwareAdapter |
2 | 110 | 4 | 310 |
getItems() + batch, RedisAdapter |
1 | 100 | 2 | 200 |
The tag check costs no round trip. The rules are held in the process, so
a warm batch is one round trip — the items — where TagAwareAdapter needs a
second for the tag versions, and a cold one is two: the reads, then the writes.
The versioned cold batch's 100 writes, one guarded script per key, arrive in
two socket reads: three reads, two round trips. On a cluster every adapter here
pays one round trip per command, because phpredis 6.3 pipelines no cluster
command, and the write that follows a miss is one command where a plain write
is two (the versioned rows were measured with the guard on); the
adapter already sends one pipeline across the slots where phpredis has cluster
pipelines (phpredis#2798,
unreleased; ClusterPipelineTest pins that path), which would make the warm
batch one round trip per node.
php -d apc.enable_cli=1 benchmarks/bench.php reads=100
php -d apc.enable_cli=1 benchmarks/bench.php reads=100 dsn="redis:?host[127.0.0.1:7110]&host[127.0.0.1:7111]&host[127.0.0.1:7112]&redis_cluster=1"
What a request pays in time
A round trip saved is worth what the network charges for it, and a fresh
versioned pool pays for one thing the others do not: its copy of the rules, one
per invalidation made before (up to the bound). benchmarks/request.php
measures both. A request is a fresh pool reading 10 keys that are all there,
one by one through get() or in one getItems(); each item has a tag of its
own and a tag it shares with nine others. Before the requests, 2 000 or 10 000
invalidations of other tags are made through each adapter. The network is
simulated: Redis on the benchmark machine (a round trip of 0.09 ms), then
behind a toxiproxy (0.25 ms, about a network inside a data centre), then behind
that proxy with 1 ms added to every reply (1.36 ms, a slow link).
The outcome, by how a request reads:
| reading 10 keys per request | fastest | second | slowest |
|---|---|---|---|
| one by one, over a network | RedisTagAwareAdapter |
VersionedRedisTagAwareAdapter, 0.4–1.8 ms behind it |
TagAwareAdapter, 1.3 to 1.9 times this adapter's time |
| one by one, on the same machine | RedisTagAwareAdapter |
VersionedRedisTagAwareAdapter after 2 000 invalidations; TagAwareAdapter after 10 000 |
TagAwareAdapter after 2 000 invalidations; VersionedRedisTagAwareAdapter after 10 000 |
| in one batch, same machine or data centre | RedisTagAwareAdapter |
TagAwareAdapter (a tie with this adapter after 2 000 invalidations, in the data centre) |
VersionedRedisTagAwareAdapter, up to 5.8 times TagAwareAdapter's time |
| in one batch, slow link | RedisTagAwareAdapter |
VersionedRedisTagAwareAdapter after 2 000 invalidations (a tie after 10 000) |
TagAwareAdapter |
So on reads this adapter is never the fastest: it makes
RedisTagAwareAdapter's round trips and adds the copy of its rules. Against
TagAwareAdapter it wins when code reads one item at a time over a network,
and loses on batches unless the link is slow. What it gains over
RedisTagAwareAdapter is not on reads but in invalidating (the ban tables).
Why, in what each adapter pays on top of the reads themselves:
| adapter | extra per request of 10 reads | grows with |
|---|---|---|
TagAwareAdapter |
a second round trip for the tag versions: 10 more when the keys are read one by one, 1 more in one batch | the network |
VersionedRedisTagAwareAdapter |
its copy of the rules, and checking each item against them: 0.2–0.7 ms after 2 000 invalidations, 1.3–1.9 ms after 10 000 | the invalidations made before (bounded) |
RedisTagAwareAdapter |
nothing: the invalidations before left nothing for a read to fetch | — |
The measurements (this package 2.0.3, symfony/cache 8.1.11, Redis 8.10.1, PHP
8.5.12): time per request, the mean of 300 requests and the middle of three
runs; in brackets, this adapter against TagAwareAdapter. After 2 000
invalidations:
| 10 reads | TagAwareAdapter |
VersionedRedisTagAwareAdapter |
RedisTagAwareAdapter |
|---|---|---|---|
| one by one, same machine (0.09 ms a round trip) | 2.0 ms | 1.4 ms (−30 %) | 1.0 ms |
| one by one, data centre (0.25 ms) | 5.2 ms | 3.2 ms (−39 %) | 2.5 ms |
| one by one, slow link (1.36 ms) | 27.8 ms | 14.3 ms (−48 %) | 13.9 ms |
| one batch, same machine | 0.25 ms | 0.32 ms (+28 %) | 0.12 ms |
| one batch, data centre | 0.58 ms | 0.57 ms (tie) | 0.29 ms |
| one batch, slow link | 2.81 ms | 1.70 ms (−40 %) | 1.39 ms |
After 10 000 invalidations:
| 10 reads | TagAwareAdapter |
VersionedRedisTagAwareAdapter |
RedisTagAwareAdapter |
|---|---|---|---|
| one by one, same machine (0.09 ms a round trip) | 2.0 ms | 2.4 ms (+21 %) | 1.0 ms |
| one by one, data centre (0.25 ms) | 5.3 ms | 4.1 ms (−23 %) | 2.5 ms |
| one by one, slow link (1.36 ms) | 27.7 ms | 15.4 ms (−44 %) | 13.6 ms |
| one batch, same machine | 0.24 ms | 1.38 ms (×5.8) | 0.13 ms |
| one batch, data centre | 0.60 ms | 1.60 ms (×2.7) | 0.29 ms |
| one batch, slow link | 2.82 ms | 2.78 ms (tie) | 1.40 ms |
Two more runs after 2 000 invalidations: a hundred reads one by one in the data
centre take 28.9 ms with this adapter against 53.0 ms with TagAwareAdapter;
and with 18 list tags on every item, TagAwareAdapter's ten reads there take
5.6 ms while this adapter's stay at 3.2 ms, because it fetches the version of
every tag an item carries.
php -d apc.enable_cli=1 benchmarks/request.php php -d apc.enable_cli=1 benchmarks/request.php pattern=batch behind=10000 docker compose -f compose.tests.yaml --profile flaky up -d --wait php -d apc.enable_cli=1 benchmarks/request.php dsn=redis://127.0.0.1:6397 latency=0 php -d apc.enable_cli=1 benchmarks/request.php dsn=redis://127.0.0.1:6397 latency=1
The rule set a fresh process adopts
What a request pays for holding the rules: a fresh pool adopting the set the workers share, and reading one item, against the same pool with no rules at all; the bytes the set takes in APCu; and the one load from the stream that fills it. The rules are a tag each — the worst case, every rule a tag of its own.
| rule set | first load from the stream | a fresh pool adopting it, and one read | in APCu |
|---|---|---|---|
| none | — | 0.12 ms | 1 KB |
| 15 000 rules (the bound) | 32 ms | 1.20 ms | 605 KB |
| 100 000 rules, bound 15 000 | 259 ms | 1.00 ms | 555 KB |
| 100 000 rules, no bound | 203 ms | 10.49 ms | 4 092 KB |
About 40 KB and 0.1 ms per thousand rules: the set is a flat array, one integer per tag, and APCu hands it over without the package checking each rule (the entry carries its format, and one of another format is a miss). The bound keeps it at that: past 15 000 the set keeps its newest 13 500 and a floor, and stays a few hundred KB however many distinct tags fall; without it, a hundred thousand entity tags cost every request 10 ms. Copying 4 MB moves from run to run, between 8 and 12 ms in 2.0.0 and 2.0.1 alike; the table is the middle of three runs, taken back to back with 2.0.0's.
Behind 100 000 distinct entity tags (unique=1 behind=100000), the FPM rows
read differently, and the hit count says why:
| Scenario | VersionedRedisTagAwareAdapter |
RedisTagAwareAdapter |
TagAwareAdapter |
|---|---|---|---|
| 100 000 invalidations matching nothing, one tag per call | 400 000 · 13.3 s | 500 000 · 10.9 s | 100 000 · 8.3 s |
| read, fresh pool every 10 reads | 20 005 · 5.5 s | 20 000 · 1.9 s | 40 000 · 4.6 s |
| the same, the rule set not shared | 42 014 · 457 s | — | — |
| hits among the 20 000 items written before the rules | 0 | 20 000 | 20 000 |
With the default bound, the 20 000 items are older than the floor the
100 000 rules pushed up, so every one of them reads as a miss: recomputed once,
never served stale. That is the bound's trade, and rulesMaxTags: 0 makes the
other one: every hit kept, and a 4 MB set copied by every request. Without a
shared set, a request loads and bounds the whole stream itself — ten batches,
0.23 s each time — which is the case APCu, or a pool passed as rulesCache, is
there for. Behind this many distinct tags the fresh-pool row is the slowest of
the three in time (one run). The invalidation row is the loop of the table
above at ten times the length, with the set at its bound of 15 000 rules for
most of it, and that set is stored at most twice: 13.3 s, where 2.0.0 took
13.1 s; stored in every call, the loop would take 185 s.
php -d apc.enable_cli=1 -d apc.shm_size=256M benchmarks/bench.php adopt=15000,100000 php benchmarks/bench.php items=20000 unique=1 behind=100000
Every number in this section was measured inside Docker Desktop (WSL2,
Windows 11, 24 CPUs), with the benchmark in a php:8.5-cli container
(PHP 8.5.12, phpredis 6.3.0, APCu 5.1.28) on the network of the Redis it
measured (Redis 8.10.1; the cluster is three masters with a replica each, laid
out as compose.tests.yaml's cluster-replicas profile), against
symfony/cache 8.1.11: the ban tables on 2026-11-06 with this package at 2.0.0,
whose invalidation script and item format 2.0.1 keeps, and every other table
on 2026-11-07 with 2.0.1. Counted from the host
instead, through Docker Desktop's port forwarding, timings grow and a pipeline
can arrive in several reads.
What it is worse at
Every cache trades something, and two rows of that table are the price.
- Memory comes back lazily. An invalidated item holds its RAM until a reader
drops it or its TTL runs out, so reading a fully invalidated set costs
2× the round trips — 40 004 against 20 000 — and three commands per item,
the drop keeping the item's epoch for the guard window. For the items nobody reads again,
prune()is the way out: it walks the pool and unlinks what the rules have already invalidated, andcache:pool:prunedrives it. - The rules stream must not be evicted. Like Symfony's tag Sets it carries
no TTL, so that
volatile-*andnoevictionnever touch it — run one of those. Underallkeys-*it can go; the read path then fails safe, and an item stamped with a rule the stream no longer remembers reads as a miss rather than a stale hit, at the price of recomputing the pool once. - Overwriting an existing item costs half again
TagAwareAdapter's commands — 60 003 against 40 053 — in the same single round trip: the write is anHSETEXand aPEXPIREwhere Symfony's is oneSETEX, because the item's watermark has to be rewritten whether its tags changed or not.RedisTagAwareAdapter8.1.11 sends more than either, 87 195, itsSETEXfollowed by theSADDs andSREMs of the item's tag SETs. A first fill writes after a miss, which is the guarded script: five commands per item — theHMGET, theEVALSHA, and the three it runs (HGET,HSETEX,PEXPIRE) — so two more than a plain write, theEVALSHAand theHGET: 100 102 againstRedisTagAwareAdapter's 71 671, in the same round trips. - A small item takes more memory, because a hash with a value above
hash-max-listpack-value(64 bytes by default) is stored as ahashtable: 476 bytes for a 100-byte value against 195 for Symfony's string — 503 MB for a million items whereRedisTagAwareAdapterholds 249 MB. Raise the limit to 4096 and it is 280 bytes. At 2.6 KB values a million items take 3 280 MB by default and 3 021 MB with the limit raised, whereRedisTagAwareAdapterholds 3 025 MB (see the hash encoding). - A fresh process copies the rule set, about 0.1 ms per thousand rules,
which on a fast network is more than the round trips it saves; the bound
keeps it near a millisecond.
RedisTagAwareAdapterholds nothing to copy, and reads fastest everywhere (what a request pays in time). - Against
TagAwareAdapter, batches and clusters even out. A request that reads its keys in onegetItems()paysTagAwareAdaptera single extra round trip, which the copy of the rule set matches or outweighs except on a slow link; and aTagAwareAdapterwhose tag versions share one cluster slot bans as fast as this adapter (banning thousands of tags). - An invalidation made elsewhere becomes visible within
rulesCacheMsplus a second, two seconds by default, while Redis is healthy and a reload lands within a second; a read that cannot promise that is a miss, so a slow reload costs up to a second of recomputations. Past that second, or while the stream cannot be read, every worker serves the set it holds, however old, until Redis answers again, and one request per server tries it a second after the last attempt ended. Pass0for exact reads, at a reload each. - The rule set has to live somewhere between requests. Without APCu and
without a pool passed as
rulesCache, every request loads the rules of the whole retention window before its first hit — about a hundred bytes per rule on the wire, so a month of invalidations, per request. - No tag index to inspect. Symfony's tag-aware API has no enumeration
method, so nothing public goes missing. What goes missing is the ability to
ask the server which items a tag names -
SMEMBERSon a tag SET, withRedisTagAwareAdapter- which is occasionally worth having when debugging, and which anything built on that index would need. rulesRetentionSecondsis a ceiling on item lifetime, not only a default.
Conformance
Not our tests: AdapterTestCase and TagAwareTestTrait, the suite
RedisTagAwareAdapter itself has to pass, plus the PHP-FIG cache group's
SimpleCacheTest through Symfony's Psr16Cache bridge.
| suite | |
|---|---|
PSR-6 conformance, with TagAwareTestTrait |
154 tests, one skip |
| PSR-16 conformance | 212 tests |
| whole suite, on a single node | 606 tests, five skips |
The skip is testPrune, which sleeps its way through four expiry deadlines to
watch a pool collect what has expired. Redis does that by itself, which is why
Symfony's own Redis adapters skip it too - RedisTagAwareAdapter's 8.2 test
puts it in one line: "Redis expires items by itself, prune() only
garbage-collects tag Sets". This pool is Pruneable, for the other reason:
prune() unlinks the items a rule has invalidated and nobody has read
since. That is ours to prove, and
PruneTest does - including that it keeps an
item written after the rule, stays inside its own namespace, and deletes
nothing at all when the rules cannot be read. Of the other four, two of
ClientPrefixTest's need a cluster: that a write or a delete goes straight
to the master of its key, and that prune() walks every master. Two wait
for something outside the package: Symfony's BatchCache,
whose writes InvalidationGuardTest will guard once it ships, and a phpredis
built with compression, which the test of a client that compresses on its own
needs.
The rest of the suite is ours, and it breaks Redis on purpose: a rules stream that cannot be read, a pipeline that never comes back, a stream deleted under a running process, a client that serializes on its own. Each has to degrade to a miss or a refusal, never to a stale hit and never to an exception in the request. A read made with no rule set held is a miss, and the value computed after it is handed to the caller and not stored.
Use them together
The two packages answer the two halves of the same bad afternoon. Invalidate a
tag matching twenty thousand items and this adapter does it in four commands —
and then twenty thousand items are cold at once, and every server you have
starts recomputing them in parallel. That second part is what
artigo/cache-stampede is for.
composer require artigo/versioned-cache artigo/cache-stampede
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 requires the other: this adapter runs under Symfony's own
LockRegistry, and those locks improve any Symfony pool. But an O(1)
invalidation and a herd that arrives one request at a time are the same problem
seen from both ends, and most setups want both dealt with. Where they meet:
MemoLockas the callback wrapper, as above. Tags declared inside a locked callback still reach the item and still invalidate — which is not obvious, since the lock wraps the very callback that declares them, soVersionedWithMemoLockTestchecks it here.StaleCacheover this adapter: a refresh is this pool's forcedget(), which the guard treats like a miss — a delete that lands while the refresh computes refuses its write, and a tag invalidation leaves it stale.- Soft serving:
StaleCache(..., soft: true)over this adapter withsoftGraceSecondsserves a value stale after its tag falls, and refreshes it once; see soft invalidation.
A worked example is in the repository, and it runs:
docker compose up -d php examples/together.php
examples/together.php warms a handful of tagged
cards, reads them back as hits, invalidates the tag they share, and reads them
again — printing how often the origin was actually reached at each step. What
the lock adds cannot show in one process, since a single request cannot
stampede itself; benchmarks/stampede.php in
artigo/cache-stampede releases
twelve real ones at the same cold key for that.
Requirements
PHP 8.2+, 64-bit · symfony/cache 6.4, 7.x or 8.x · Redis 8.0+ ·
ext-redis (required by composer.json, also when the connection handed in
is Relay's) · ext-apcu recommended under PHP-FPM, for the shared rule set
(see rulesCache) · ext-zstd for ZstdMarshaller
Redis 8 is what makes a write a single command: HSETEX sets an item's fields
and their expiry together, so there is no way for the data to land without the
TTL that keeps it from outliving the rules, and no script on the write path
outside the write that follows a read (see
the invalidation guard). That one is sent by its
sha1 and sent again as a body where the server does not hold it, so a
SCRIPT FLUSH, restart or failover costs one round trip per node and no
FUNCTION LOAD is ever needed. A second script, on every invalidation, is the
appended rule — it needs the id XADD just generated, and the id before it —
and it travels as a body, as does the third: the one-line XRANGE asked of a
primary when a replica's answer is in doubt (see
Replicas). The key is given the TTL of its
longest-lived field, so a volatile-* eviction policy still sees it. Stream
ids hold milliseconds, which a 32-bit integer cannot: the constructor refuses a
32-bit PHP. Valkey has HSETEX since 9.0.
Clients: ext-redis or Relay. Standalone, cluster or ext-redis'
RedisArray — every command the adapter issues touches a single key, so three
masters are no different from one. An array runs a script on the node its
body hashes to, so every command that names the rules stream is sent to the
node that owns it, and a raw command to the node that owns its key. A
client-side key prefix (OPT_PREFIX) is honoured: the client puts it on the
keys of typed calls and scripts, and the adapter on those of the raw commands,
which a client sends as they are. CI runs the suite on PHP 8.2 to 8.5 against
Symfony 6.4, 7.4, 8.1 and the 8.2 development branch, on Redis 8.0, 8.4 and
the current 8.x and on Valkey 9, through Relay, against a three-master
cluster through ext-redis and
through Relay, and against three masters with a replica each, read from the
replicas (failover=slaves), every cluster leg with and without a key prefix;
the array is two names for one server.
Benchmarks
The suite ships in this repository, so every number above is something you can re-run rather than take on trust:
| script | question it answers |
|---|---|
benchmarks/bench.php |
what does invalidating a tag cost as the match count grows, what does a read cost after it, what does a fresh process pay, and what is the server left holding afterwards — against both of Symfony's tag adapters? |
benchmarks/bench.php reads=100 |
how many round trips does a request reading a hundred keys make, warm and cold, standalone and on a cluster? |
benchmarks/request.php |
what does a request pay in time for its reads, item by item or in one batch, as the round trip grows? (through toxiproxy for a slower link) |
benchmarks/bench.php adopt=… |
what does a fresh process pay to adopt the shared rule set, and what does the set take in APCu, with the bound and without? |
benchmarks/ban.php |
what does banning thousands of tags cost the server, and everybody else on it while it happens? |
benchmarks/race.php |
does a value computed before an invalidation survive it, and how many writes would one epoch per pool refuse? |
benchmarks/soft.php |
what does a visitor wait for after a tag invalidation, hard and soft? (needs cache-stampede 2.0) |
benchmarks/bench.php guard=1 |
what does the invalidation guard cost a fill, a read and a delete? |
benchmarks/marshaller.php |
what do deflate and zstd cost and save per value, from which size is a frame smaller than the value, and what does telling a raw value from a compressed one cost? (needs ext-zstd; no Redis) |
benchmarks/charts.php |
redraws the pictures above from those measurements |
unique=1 gives every item one tag of its own, the entity tag applications
really use; fresh=N re-creates the pool every N reads in the FPM rows, the
way a request is a fresh process; behind=N sets how many unrelated rules land
behind the fill (10 000); payload=model writes a ~2.5 KB entity instead of
256 repeated bytes, and marshaller=deflate or marshaller=zstd wraps the
default marshaller in Symfony's DeflateMarshaller or in ZstdMarshaller;
stores= picks from versioned, symfony
(RedisTagAwareAdapter) and tagaware (TagAwareAdapter over a RedisAdapter);
ban.php and reads= also take tagaware-slot, a TagAwareAdapter whose tag
versions all sit in one cluster slot, and request.php takes redis (a plain
RedisAdapter, the floor) and shared=N, the list tags per item.
The stampede benchmark is maintained in
artigo/cache-stampede with
the locks it measures, where it also has the swr and fleetlock-pubsub
modes; the copy still here (benchmarks/stampede.php) races nolock,
memolock and LockRegistry on one host and on a fleet.
Licence
MIT. See LICENSE.


