R1.04.2 · Client-side caching — dive 2
The invalidation push.
A tracked key changes; the server pushes its name to every connection holding it, and only those. The near caches drop their copy.
Grounding: real RESP3 push frames · echo_store/coherence.ex (broadcast/4) · echo_store/table.ex (the push handler) · bcs.4.md (the lanes, measured).
One message, naming the key
source · How invalidation worksThe invalidation is a push, not a reply to a request. On a RESP3 connection the server can send a frame the client did not ask for, and tracking uses exactly that: when a tracked key is modified, an invalidation message arrives carrying the name of the key — or a null, meaning the whole local cache should be cleared, as on a flush. The message does not carry the new value. It says only that the held copy is no longer trustworthy.
That is the minimal contract. A bare CLIENT TRACKING invalidation has no record of what the
client cached, only which keys the client read, so the most it can report is “this key changed.” A
change is a change: a SET, a DEL, an expiry, an eviction all trigger the same push to
the key's holders.
- invalidation
- A server-initiated push naming a changed key, sent to every connection that holds it.
- RESP3 push
- An out-of-band frame the server sends without a matching request; the channel the message rides.
- null payload
- The clear-everything signal — sent on a flush, telling holders to drop the whole local cache.
- holder
- A connection the tracking table records as holding the key; the only recipient of its push.
What the holder does with it
source · the holder's responseThe client's job on receiving an invalidation is small and fixed: drop the local copy of the named key. It does not re-read eagerly. The next time the application asks for that key, the near cache misses, the client fetches once from the server, repopulates the local copy, and is tracked again. So an invalidation costs nothing until the key is next needed — and a key that is never read again costs only the one dropped entry.
Trace a holder's state through one change. It holds the value and serves reads from memory; the push arrives and it drops the copy; the next access misses and re-reads. Step the states to see each transition and what reaches the application.
An invalidation says “drop it,” not “here is the new value.” The holder discards and re-reads lazily — the cheapest correct response.
The broadcast lane in EchoStore
echo_store/coherence.ex · bcs.4EchoStore rides this push over plain pub/sub, and adds one byte-field a bare invalidation lacks: the
writer's mint-time version. A writer that changes an emoji-set row publishes the change on the table's channel
— the real Coherence.broadcast/4 — and every subscribing table receives the push and
applies it newer-wins in its owner.
# echo/apps/echo_store/lib/echo_store/coherence.ex def channel(table), do: "ecc:{" <> table <> "}:coh" def payload(<<_::binary-14>> = id, <<_::binary-14>> = version), do: id <> ":" <> version def broadcast(conn, table, id, version) do Connector.command(conn, ["PUBLISH", channel(table), payload(id, version)]) end # echo/apps/echo_store/lib/echo_store/table.ex — the holder applies the push def handle_info({:emq_push, ["message", _channel, payload]}, state) do case Coherence.parse(payload) do {:ok, id, version} -> Ring.publish(state.ring, {id, version}) :error -> :ignored end {:noreply, state} end
The message is twenty-nine bytes — a cached name, a colon, and the writer's mint-time version. That
version is the one thing a bare CLIENT TRACKING invalidation cannot send, and it is what lets a
late stale message bounce off both layers: the L2 drop runs as a Lua script that deletes the row only when the
incoming version is newer than the one framed into the stored value.
Notes on ValkeyThe broadcast lane rides plain pub/sub, which is at-most-once — a message is delivered once if at all. A lost broadcast costs one TTL of staleness, which is exactly why a surface where a stale read costs money rides the durable job lane instead — valkey.io/topics/pubsub.
Two lanes, one 29-byte message
The same twenty-nine bytes ride either lane. The broadcast lane is fast and lossy; the durable job lane over EchoMQ survives a crash. The committed record prices both:
coherence message twenty-nine bytes ← name + ":" + mint-time version median push latency 72 us ← over 100 messages, broadcast lane job lane (EchoMQ) 148 us ← at-least-once, crash-surviving the guarantee costs 2.1 times the latency
On a change, the server sends each holder a RESP3 message naming the key. The holder drops its local copy and re-reads lazily on next access.
Coherence.broadcast/4 PUBLISHes the 29-byte message on ecc:{<table>}:coh;
the holder's {:emq_push, ["message", …]} handler drops L1 newer-wins.
The push is the half that keeps the near cache honest: a value is trusted until the server says it changed, and not one read longer.
References
sources · relatedSources
- Valkey — Pub/Sub — at-most-once delivery: a message is delivered once if at all; the broadcast lane's contract.
- Valkey — Client-side caching — the invalidation message and its null clear-all form; the unversioned gap the 29-byte message closes.
- Redis — CLIENT TRACKING — the tracking that drives the push, and the
REDIRECToption.
Related in this course
- R1.04 · Client-side cachingthe module hub
- R1.04.1 · CLIENT TRACKINGthe record the push uses
- R1.04.3 · The SHA1 script-cache parallelthe next dive
- /echomq/cachethe EchoStore invalidation push that drops L1, in depth