jonnify·redis /redis-patterns/caching/write-through/dual-write

R1.02.1 · Write-through — dive 1

The synchronous dual write.

One write reaches both layers. The L1 ETS cache and the L2 Valkey row update together, and the call does not return until both are done.

Grounding: echo/apps/echo_cache/lib/echo_cache/table.ex (handle_call({:put, …}), verbatim) · keyspace.ex (the key form) — the engine is Valkey.

Step the dual write. Select a stage to read what it does and whether the call has returned.
Table.putone GenServer.call SET PX insert L2 · Valkey rowversion <> value, PX L1 · ETS tableinsert(id, value, ver) both acknowledge before the call returns return · :ok
readout · Select a stage. Both the L2 SET and the L1 insert run on one call; the reply is :ok only after both land.
§1

Two writes, bound into one

write-through.md.txt · How It Works

The write-through write is a single logical step made of two store writes. EchoCache.Table.put receives a changed value, sets the L2 Valkey row with SET … PX, writes the L1 ETS table with insert, and returns once both have landed. The two layers are never out of step at the moment the write returns, because the write does not return until both are done. In the as-built code the order is L2 first, then L1: the handle_call({:put, id, value, version}) clause runs Connector.command(["SET", l2, version <> value, "PX", ttl]), matches {:ok, "OK"}, then calls insert(state, id, value, version).

The order is a discipline, and a write-then-invalidate caller chooses the safe order for its own two stores. If the source write fails, the cache write is never attempted, so the cache cannot end up holding a value the source rejected. If the cache write fails after the source succeeds, the writer treats it as a failed write rather than reporting success with a stale cache. The latency dive covers those failure modes; this dive fixes the shape of the successful path.

dual write
One logical write that touches two layers — the L1 cache and the L2 store — before it returns.
synchronous
The caller waits for both writes to land; the write completes only when both are done.
SET … PX
The Valkey write-path command. SET key value PX <ms> stores a key, its value, and a millisecond TTL in one command.
acknowledge
A store confirms the write landed. {:ok, "OK"} from Valkey gates the L1 insert; both gate the return.
§2

SET — value and TTL in one command

write-through.md.txt · Redis Commands Used

The cache half of the write is one Valkey command. SET … PX sets the value and the millisecond TTL together: the L2 row carries the declared TTL on the server’s own clock, so the second layer expires itself even if every node forgets. Choose a TTL to see what the put clause sends to Valkey and what it guarantees.

Pick the cache command. Select a TTL to read the exact SET the put clause sends and what it holds.
readout · Select a TTL. The put clause always sends value and expiry together, atomically.

Static verdicts — PX 300000 → 5-minute expiry · PX 300 → the committed drill’s PTTL 300 ms of 300 · PX 3600000 → 1-hour expiry; all set value and TTL in one command.

Notes on ValkeySET key value PX <ms> sets the value and the TTL atomically; a separate EXPIRE after SET leaves an immortal key if the process dies between the two — valkey.io/commands/set.

The cache half of a write-through write is one command. SET … PX sets value and expiry together, so a drifted value cannot live forever and no second command can be lost.

§3

The dual write on EchoCache

table.ex · handle_call({:put, …}) · verbatim

Take one change: an instrument reference row is updated. EchoCache.Table.put sets the new value into the L2 Valkey row and the L1 ETS table, then returns. A later read serves the cached value and matches the source, because the cache was written on the same path:

echo/apps/echo_cache/lib/echo_cache/table.ex · handle_call({:put, …}) · verbatimas-built
l2 = Keyspace.key(state.table, id)            ← "ecc:{instruments}:AST0NgWEfAEJfs"

{:ok, "OK"} =
  Connector.command(state.conn, [
    "SET", l2, version <> value, "PX",      ← L2 Valkey: value + TTL, atomically
    Integer.to_string(state.spec.ttl_ms)
  ])

insert(state, id, value, version)             ← L1 ETS, on the same call
{:reply, :ok, state}                          ← returns only after both land

Each part is visible. Keyspace.key/2 composes the cache’s own key ecc:{<table>}:<id>, the table name hash-tagged so the key lands in one cluster slot. The L2 SET stores version <> value with the declared TTL. The L1 insert writes the same value and version into the ETS table on the same call. The reply is :ok only after both. put/3 mints the version now, of the table’s kind; put/4 carries the writer’s own.

the application · the cache’s own key, one slot

The braced key — one cache, one cluster slot

The L2 key is the cache’s own form, the table name hash-tagged in braces so every key of one cache co-locates — EchoCache.Keyspace.key/2, quoted from the as-built keyspace:

echo/apps/echo_cache/lib/echo_cache/keyspace.ex · the key formas-built
def key(table, id) when is_binary(table) and is_binary(id) do
  unless BrandedId.valid?(id) do
    raise ArgumentError, "invalid branded id in cache key position: ..."
  end
  "ecc:{" <> table <> "}:" <> id            ← ecc:{instruments}:AST0NgWEfAEJfs
end
the dual write

Bind the cache write and the source write into one synchronous step; return only when both have landed, so neither is ever behind the other at the moment the write returns.

its EchoCache application

The handle_call({:put, …}) clause runs SET … PX to the L2 Valkey row, matches {:ok, "OK"}, then inserts into L1, and replies :ok — both layers, one call.

The dual write is the heart of write-through: one synchronous call that writes both layers, with a single Valkey command on the cache side.

§4

Recap — one write, two layers

table.ex · the put clause

A write-through write touches the L1 cache and the L2 store in one synchronous call and returns only when both land. The cache side is one Valkey command, SET … PX. Because the cache was written on the same path as the source, the next read is fresh by construction. The next dive turns that property into a guarantee: why a read-after-write cannot be stale.

§5

References

sources · related

Sources

  • Valkey — SET — SET key value PX <ms> sets value and TTL atomically; the L2 command in the put clause.
  • Valkey — Topics — the engine the L2 layer is written against; the live line is Valkey.
  • Redis — SET — the string write-path command, with optional EX/PX expiry.
  • Redis — Documentation — the string and expiry commands the cache write is built from.

Related in this course