jonnify·redis /redis-patterns/caching/client-side-caching/invalidation-push
Redis Patterns / R1 · Caching / Client-side caching / The invalidation push

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).

Change a key, see who gets the push. Select a key that changes; only the connections holding it light up and receive the message.
the server tracking push: invalidate <key> to its holders only conn 7 set:EMS9q · flag:halt conn 9 set:EMS9q conn 12 cfg:limits — — —
readout · Change a key.
§1

One message, naming the key

source · How invalidation works

The 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.
§2

What the holder does with it

source · the holder's response

The 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.

Step a holder's state. Through one invalidation.
readout · Step the states to trace one invalidation through a holder.

An invalidation says “drop it,” not “here is the new value.” The holder discards and re-reads lazily — the cheapest correct response.

§3

The broadcast lane in EchoStore

echo_store/coherence.ex · bcs.4

EchoStore 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.

the application · the lanes, priced

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:

bcs.4.md · the coherence lanes, measuredfrozen
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
the invalidation push

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.

its EchoStore application

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.

§4

References

sources · related

Sources

Related in this course