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_store/lib/echo_store/table.ex (handle_call({:put, …}), verbatim) · keyspace.ex (the key form) — the engine is Valkey.
:ok only after both land.Two writes, bound into one
write-through.md.txt · How It WorksThe write-through write is a single logical step made of two store writes. EchoStore.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.
SET — value and TTL in one command
write-through.md.txt · Redis Commands UsedThe 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.
SET the put
clause sends and what it holds.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.
The dual write on EchoStore
table.ex · handle_call({:put, …}) · verbatimTake one change: an emoji-set row is updated. EchoStore.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:
l2 = Keyspace.key(state.table, id) ← "ecc:{cm_emojisets}:EMS0ODMggk1d5N" {: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 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 — EchoStore.Keyspace.key/2, quoted from the as-built keyspace:
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:{cm_emojisets}:EMS0ODMggk1d5N
end
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.
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.
Recap — one write, two layers
table.ex · the put clauseA 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.
References
sources · relatedSources
- 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/PXexpiry. - Redis — Documentation — the string and expiry commands the cache write is built from.
Related in this course
- R1.02 · Write-throughthe module hub
- R1.02.2 · The consistency guaranteethe next dive — read-after-write freshness
- R1.02.3 · The latency costthe price of the dual write
- R1 · Cachingthe chapter
- /echomq/cachethe EchoStore two-layer write kept coherent, in depth