R1.03.1 · Write-behind — dive 1
The async buffer.
The write lands in Valkey and returns; a separate worker carries it to the database later, on its own clock. The write path fills the buffer; the flush path empties it.
Grounding: echo_cache/journal.ex (intend_and_enqueue/4) · coherence.ex · keyspace.ex — the outbox in one verb. Engine: Valkey.
LPUSH fills the buffer on the write path; RPOP drains it on the flush
path. First in, first out.One write, two paths
write-behind.md.txt · How It WorksA read-through cache fronts reads; a write-behind cache fronts writes. The application stops
writing the database on the request and writes Valkey instead. Two operations happen together on the write
path: the value is stored (a SET on a String, or an HSET on a Hash), and the
changed key is pushed onto a write-buffer List with LPUSH. The caller is acknowledged the
moment both succeed — no database round trip is on the path.
The flush path is a separate background worker on a timer. Each tick it drains the buffer from the tail
with RPOP, reading keys in arrival order, looks up the current value of each, and writes them
to the database in one batch. Two paths, one store: the write path fills the buffer, the flush path empties
it.
# write path — runs on the request, returns at once SET ecc:{limits}:LIM0NgWEfAEJfs {…} # store the new value in Valkey LPUSH writebuf LIM0NgWEfAEJfs # mark the key dirty on the buffer List # -> ack the caller. No database write here. # flush path — a background worker, on a timer RPOP writebuf # -> "LIM0NgWEfAEJfs" (arrival order) GET ecc:{limits}:LIM0NgWEfAEJfs # read the current value # -> write the batch to the database in one round trip
- write path
- On the request: store the value,
LPUSHthe key onto the buffer, acknowledge. No database round trip. - write buffer
- A Valkey List of changed keys, filled with
LPUSHat the head and drained withRPOPat the tail — first in, first out. - flush path
- A background worker that drains the buffer on a timer and writes the batch to the database, off the request path.
- flush interval
- How long the worker waits between drains. Longer means bigger batches and fewer database writes; it also means more pending writes between flushes.
A List buffer keeps a key once per change, so the same key can appear several times. The coalescing dive (R1.03.3) swaps the List for a dirty-set that keeps each key once.
Drain the buffer, tick by tick
write-behind.md.txt · The Sync ProcessStep the flush worker through a fixed buffer to watch the two paths interleave. The buffer starts with
five queued writes. Each arrive pushes another key on with LPUSH; each flush
drains everything pending with RPOP and writes one database batch. The readout reports the
buffer depth, the total database batches, and how many writes a single batch carried.
The write path never waits for the flush path. A burst piles into the buffer and leaves the database in a handful of batches, not one round trip per write.
The async buffer on EchoCache — the outbox in one verb
journal.ex:66 · coherence.ex:89 · keyspace.ex:20EchoCache’s write-behind path is the transactional outbox in EchoCache.Journal. The
writer’s verb, intend_and_enqueue/4, is the two paths made real: it mints a JOB
id, records the intent in a local SQLite file, calls Lanes.enqueue — the async
job lane over EchoMQ — with the coherence payload, and mark_enqueueds the intent. The write
returns after the local record; the bus carries the apply off the write path, exactly the
split above.
The value lands under ecc:{<table>}:<id> — the table name hash-tagged so one
cache lands in one cluster slot. The job lane is Coherence.enqueue/5 over the queue
ecc.coh.<table>: “the job lane: at-least-once over EchoMQ’s fair lanes.”
That fair-lanes queue is the EchoMQ protocol itself.
# the outbox in one verb — the write returns after the local record {:ok, job_id} = Journal.intend_and_enqueue(:limits_journal, conn, name_id, version) # mint a JOB id -> record the intent (SQLite) -> Lanes.enqueue (EchoMQ) -> mark enqueued
The job lane is the EchoMQ protocol
The apply rides EchoMQ’s fair lanes at-least-once. The lane mechanics — the braced
emq:{q}: keyspace, the claim and complete verbs, the fairness across groups — are taught
rung by rung in the EchoMQ course.
Store the change in the cache, record the key on a buffer, and acknowledge. A background worker drains the buffer and writes the batch to the source — the caller never waits for it.
Journal.intend_and_enqueue/4 records the intent in local SQLite and enqueues the apply on
EchoMQ’s fair lanes; the write returns after the local record, the bus carries the apply.
Notes on ValkeyRPOP removes and returns the tail element
of a List in O(1), so the flush drains in arrival order without scanning —
valkey.io/commands/rpop.
The buffer turns one write into two paths: a fast acknowledged store, and a slow batched flush the caller never waits on.
Recap — the write returns before the flush
the buffer → the windowThe write path stores the value and marks the key dirty with LPUSH, then acknowledges. The
flush path drains the buffer with RPOP on a timer and writes the database in batches. The
request never waits for the database, and a burst leaves the source in a handful of round trips. The next
dive measures what that deferral costs: the window where the cache holds the only copy.
Next in R1.03: R1.03.2 · The durability trade-off — the unflushed window, what a crash can lose, and how the flush interval and durable storage bound it.
References
sources · relatedSources
- Valkey — LPUSH — pushes the changed key onto the head of the write-buffer List.
- Valkey — RPOP — the flush worker drains the buffer from the tail, keys in arrival order.
- Valkey — SET — stores the value on the write path before the buffer mark.
- Redis — Documentation — Lists as a queue, and the structures a write buffer is built from.
Related in this course
- R1.03 · Write-behindthe module hub
- R1.03.2 · The durability trade-offthe next dive
- R1 · Cachingthe chapter
- /echomqthe fair-lanes job queue the apply rides