R1.02 · Caching — module hub
Write-through.
Maintain cache-database consistency by synchronously writing to both the cache and the database before returning success — so reads always hit the cache with fresh data.
Grounding: echo/apps/echo_store · EchoStore.Table.put · bcs.4.md — every figure verbatim from a committed record; the engine is Valkey.
Static verdicts — L2 SET PX → value + TTL atomically (the larger latency term)
· L1 insert → sub-microsecond, same call · return → only after both land · later
read → served fresh from L1 (762 ns).
How it works
write-through.md.txt · How It WorksWhen a write occurs, three steps run in order. The application writes the data to the cache. The application writes the same data to the database. Only after both writes succeed does the operation complete. The cache and the database are always updated together, which keeps them consistent with each other.
A write-through cache treats the two stores as one write target. When a value changes, the writer sets the
cache with SET … PX and the database with an update, and the write does not return until both
are done. Because the cache was updated on the same path as the source, a read that follows serves the cached
value and matches the database. This is the trade against cache-aside, which fills the cache lazily on a read
miss and drops it on a write: a cheaper, single-store write, but a window where the cache is stale or empty.
The cache and the database move together on the write. A read after the write serves a value that is, by construction, never behind the source.
The commands the write uses
write-through.md.txt · Redis Commands UsedA write-through operation is a single cache write followed immediately by the corresponding database write,
and the application returns success only after both complete. The cache half is one command:
SET … PX sets the value and the millisecond TTL together — atomically.
SET ecc:{cm_emojisets}:EMS0ODMggk1d5N "<version><value>" PX 300000 # cache: value + TTL, atomically
UPDATE emoji_sets ... WHERE id = ... # the corresponding database write
# return success only after both acknowledge
The key is the cache’s own form ecc:{<table>}:<id>
(EchoStore.Keyspace.key/2): a fresh prefix beside emq:, with the table name
hash-tagged in braces so every key of one cache lands in one cluster slot. The value is framed
version <> value — a 14-byte branded version prefix in front of the bytes — so a
later coherence message can compare newer-wins. The cache half of a write-through write is one command; the
dual-write dive takes that command apart.
Notes on ValkeySET key value PX <ms> sets the value
and the TTL in one atomic command. A separate EXPIRE after SET leaves an immortal key
if the process dies between the two —
valkey.io/commands/set.
Advantages and disadvantages
write-through.md.txt · Advantages / DisadvantagesAdvantages.
- Strong consistency — the cache always reflects the latest data. There is no window where the cache holds stale information, because the cache write is on the same path as the database write.
- Fast reads after writes — data written is immediately available for the next
read. The L1 ETS hit it serves is
40 times cheaperthan the L2 round trip (bcs.4.md). - Simpler cache invalidation — because writes go through the cache, there is no need to explicitly invalidate keys after a database update.
Disadvantages.
- Increased write latency — the write is not complete until both the cache and the database acknowledge, so the total latency is at least the sum. The latency dive measures this against write-behind.
- Cache pollution — data that is written but rarely read still occupies cache memory, and can evict more valuable, frequently-read data.
- Failure complexity — if the database write fails after the cache write succeeds, the two stores disagree. The application has to handle partial failures carefully.
Handling partial failures
write-through.md.txt · Handling Partial FailuresThe safest approach is to write to the database first. Write to the database. If that succeeds, write to the cache. If the cache write fails, report it rather than reporting success with a stale cache. This way the database — the source of truth — is always correct, and a later cache miss reads fresh data from the database. The latency dive walks each failure path and the state it leaves.
Write the database first, and a partial failure can never leave the cache ahead of the source of truth. The worst case is a cache miss, not a wrong answer.
When to use — and when to avoid
write-through.md.txt · When to Use / AvoidUse write-through when strong consistency between cache and database is required, write-then-immediately-read patterns are common, the write-latency overhead is acceptable, and the dataset is relatively small so cache pollution is less of a concern.
Avoid it when write latency is critical, most data is written but rarely read, or the system needs to tolerate cache failures without affecting write operations. A write-heavy, stale-tolerant workload reaches for write-behind instead — the next module.
Fixed cost model — one cache write ≈ 1 unit, one database write ≈ 8 units; a write-through write pays both on the hot path.
The three dives
the write → the guarantee → the costEach dive takes one part of write-through, in the arc the write → the guarantee → the cost: the dual write that touches both layers, the read-after-write freshness it buys, and the latency every write pays for it. Read them in order — the synchronous dual write starts.
The synchronous dual write
One write reaches both layers: SET … PX to the L2 Valkey row and an
insert into the L1 ETS table, together, before the write returns.
The consistency guarantee
Why the next read cannot be stale: both layers were set under one TTL, framed with the write’s mint-time version, so a read-after-write serves the value that write produced.
The latency cost
The synchronous GenServer.call waits for the L2 SET round-trip —
the trade against write-behind’s deferred database write.
SET PX then insert — both layers, one call
The dual write is not an aspiration — it is the handle_call({:put, …}) clause in
the as-built table: set the L2 row with the value and TTL atomically, then insert into L1, on the one call.
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
Write the cache and the source in one synchronous step, so a read after a write is always fresh — at the cost of a second write on every change.
EchoStore.Table.put runs SET … PX to the L2 Valkey row then
insert into the L1 ETS table, both inside one synchronous GenServer.call; reads
are always fresh.
Three dives, one pattern: the synchronous write that touches both layers, the read-after-write freshness it guarantees, and the latency it costs to keep that promise.
References
sources · relatedSources
- Valkey — SET —
SET key value PX <ms>sets value and TTL atomically; the L2 write-path command. - Valkey — Topics — the engine EchoStore’s L2 layer is written against; the live line is Valkey.
- Redis — SET — the string write-path command and its
EX/PXexpiry options. - Redis — Documentation — the data structures and commands the cache write is built from.
Related in this course
- R1.02.1 · The synchronous dual writethe write that touches both layers
- R1.02.2 · The consistency guaranteeread-after-write freshness
- R1.02.3 · The latency costthe price of the guarantee
- R1 · Cachingthe chapter
- /echomq/cachethe EchoStore dual-layer write kept coherent, in depth
- /bcsthe EchoStore manuscript, Part IV