jonnify·redis /redis-patterns/caching/write-behind/async-buffer

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.

Two operations on one store. Select an operation to read what it does and which path runs it.
a writeon request write buffer · Valkey List head tail database LPUSH · ack now RPOP · batch
readout · Select an operation. LPUSH fills the buffer on the write path; RPOP drains it on the flush path. First in, first out.
§1

One write, two paths

write-behind.md.txt · How It Works

A 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, LPUSH the key onto the buffer, acknowledge. No database round trip.
write buffer
A Valkey List of changed keys, filled with LPUSH at the head and drained with RPOP at 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.

§2

Drain the buffer, tick by tick

write-behind.md.txt · The Sync Process

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

Step the worker. Arrive and flush over a fixed buffer; the readout reports the depth and the batches.

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.

§3

The async buffer on EchoCache — the outbox in one verb

journal.ex:66 · coherence.ex:89 · keyspace.ex:20

EchoCache’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 application · the fair-lanes job queue, in depth

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.

/echomq — the protocol, in depth →

the async buffer

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.

on EchoCache

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.

§4

Recap — the write returns before the flush

the buffer → the window

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

§5

References

sources · related

Sources

  • 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