all_lessons / data_intensive_systems / 19 · stream processing lesson 20 / 35 · ~17 min

Part 8 · Derived history

Stream Processing: Logs, CDC, Event Time, Windows, and Effectively-Once

Lesson 18 derived one dataset from another by scanning a bounded input — a snapshot with a beginning and an end-of-file — and re-running deterministically from that fixed history. But the real world never reaches end-of-file: orders, clicks, sensor readings, and model-input events keep arriving forever. A stream is exactly batch processing without the end-of-file. That one change breaks every assumption batch leaned on. You can no longer wait for all the input before producing output, so you must decide when a result is "done enough" to emit; the input arrives in a different order than it happened, so you must separate when an event occurred from when you saw it; and a crash mid-stream can replay or duplicate work, so "ran exactly once" stops being free. This lesson builds the machinery — the durable log, change data capture, event time and windows, watermarks, and effectively-once delivery — that lets unbounded data stay as correct and rebuildable as a batch job.

DDIA source
Arc drawn from Martin Kleppmann, Designing Data-Intensive Applications, Chapter 11 (Stream Processing) — message brokers vs log-based brokers, change data capture and event sourcing, event-time vs processing-time windowing with watermarks, and fault tolerance via idempotence and checkpointed offsets. Original synthesis; ML-infra framings are ours.
Linear position
Prerequisite: Lesson 18 — batch processing, the shuffle, materialization, and "a derived view can be deleted and rebuilt from the source of truth." We extend that idea to inputs that never end. We also lean on the ordered durable log / write-ahead log introduced as a storage primitive in lesson 03, and on partitioning by key from lesson 11.
New capability: Design a system that continuously derives state from an unbounded event log — keep a search index, cache, or feature store in sync via change data capture; reason about event time vs processing time and size a watermark; and get effectively-once results despite crashes and duplicates.
The plan
Five moves. (1) The log as the core abstraction — an append-only partitioned log, and why a log-based broker (retain + replay) differs from a message queue (delete on ack). (2) Change data capture — turn a database's replication log into an event stream so derived systems stay in sync, plus event sourcing in brief. (3) Time — event time vs processing time, the four window types, and a worked watermark number that decides when a window is complete enough to emit. (4) The watermark widget: drag the lateness allowance and watch completeness trade against emit latency. (5) Fault tolerance — why exactly-once is a fiction, how idempotence + checkpointed offsets + atomic snapshots buy effectively-once, and the stream-table duality that foreshadows the next lesson on derived views.

1 · The log is the abstraction

Start with the unit. An event is a small, immutable, self-contained record that something happened at a point in time — "user 42 clicked product 91 at 10:00:03". A stream is an unbounded sequence of such events. The question is what data structure carries them, and the answer that makes everything else work is a log: an append-only, totally-ordered sequence of records. This is the same primitive you met in lesson 03 as the write-ahead log — writes only ever go on the end, and the order they land in is the order of record. Here we hand that primitive to producers and consumers across the network.

A producer appends events to the end of the log. A consumer reads forward from a position it controls, called its offset — an integer index into the log saying "I have processed everything up to here." Crucially the broker does not remember consumers; each consumer owns its own offset and advances it at its own pace. Because the log is retained (not deleted as it is read), a consumer can rewind its offset to zero and replay the entire history, or a brand-new consumer can join and catch up from the beginning. Replay is the whole point: it is what lets a derived view be rebuilt from the source of truth, exactly as batch could re-scan a file (lesson 18).

an append-only partitioned log (Kafka-style) partition 0: [e0][e1][e2][e3][e4][e5] -> (producers append here) ^ ^ consumer B offset=2 consumer A offset=5 (lagging, replaying) (caught up, tailing the head) partition 1: [e0][e1][e2][e3] partition 2: [e0][e1][e2][e3][e4] - order is guaranteed WITHIN a partition, not across partitions. - the partition key (e.g. user_id) decides which partition an event lands in, so all events for one user stay ordered (partitioning = lesson 11). - each consumer tracks its own offset -> many independent readers, replay.

A single log on one machine cannot absorb an unbounded write rate, so the log is partitioned — split into independent sub-logs by a partition key, exactly the mechanism from lesson 11. Order is guaranteed only within a partition; events in different partitions have no relative order. The standard trick is to key by an entity id (user, account, device) so that all events for one entity land in one partition and stay strictly ordered, while the system scales out by adding partitions. This is the same trade as a shard key: throughput scales with partitions, but a hot key (one celebrity user) overloads its partition (lesson 11).

Message queue vs log-based broker

Two delivery models look superficially similar and are deeply different. A message queue (the classic JMS/AMQP/SQS model) treats a message as a task: the broker hands it to one consumer, waits for an acknowledgement, and then deletes it. The message is gone; there is no history; load is spread across consumers because each message goes to exactly one of them. That is ideal for distributing work — a job that should be done once by some worker. A log-based broker (Kafka-style) instead retains every message in the ordered log and lets each consumer track its own offset. Nothing is deleted on read; messages age out only by a time- or size-based retention policy.

PropertyMessage queue (delete on ack)Log-based broker (retain + replay)
After consumptionMessage is deletedMessage stays; offset advances
Multiple consumersLoad-balanced — each msg to oneEach independent consumer sees all
Replay / new consumerImpossible — history is goneRewind offset to re-read history
OrderingWeak once redeliveredTotal within a partition
Best forTask distribution, work dispatchCDC, derived views, event sourcing, replay

Stream processing needs the log-based model precisely because consumers fail, code changes, and derived state must be rebuilt. When you fix a bug in your feature-extraction code, you do not want the events to be gone — you want to reset the offset to zero, replay the full history through the corrected code, and rebuild the derived state from scratch. A queue that deleted on ack threw that ability away. The log-based broker is, as people say, the filesystem of the streaming world: append, retain, replay.

2 · Change data capture: the database is a stream

Here is the problem that makes streams unavoidable in practice. Your system of record is a database. But you also keep derived copies of that data for fast reads: a search index, a cache, a feature store, a vector index for retrieval. Every one of those must reflect what the database says. The naive approach is the dual write: in application code, write to the database and write to the search index. This is broken — the two writes are not atomic, so a crash between them, or two concurrent updates applied in different orders to the two systems, leaves them permanently disagreeing (this is the cross-system version of the race conditions from lesson 13). You cannot make a dual write correct by trying harder.

Change data capture (CDC) fixes this with one observation: the database already produces a perfect ordered stream of every change it makes — its replication log, the same write-ahead/replication log from lessons 03 and 08 that followers tail to stay in sync. CDC taps that log and publishes each committed row change (insert/update/delete) as an event onto a log-based broker. Now there is exactly one write — the database commit — and it is the single source of truth. Every derived system becomes a consumer that tails the change stream and applies the changes in commit order. The search index, cache, and feature store can never disagree with the database for long, because they are all replaying the same ordered log of facts.

change data capture keeps derived systems in sync from ONE source of truth app --write--> [ database ] (system of record; commit = the truth) | replication / WAL (lesson 03, 08) | CDC connector tails the log v [ change-event log ] <-- log-based broker, partitioned by key / | \ v v v [search index] [cache] [feature store / vector index] (consumers replay the SAME ordered changes -> eventually agree) fix a bug in the index builder? reset its offset to 0, replay, rebuild.

Event sourcing (briefly) pushes this idea up into the application's own design. Instead of storing only current state and emitting changes as an afterthought, you make the log of events the primary record — "cart_item_added", "order_placed", "order_cancelled" — and derive current state by replaying those events. The difference from CDC is the level: CDC extracts a change stream from a database's low-level row mutations, whereas event sourcing records high-level, domain-meaningful events from the start. Both rest on the same foundation: an ordered, immutable history of facts from which any number of read-optimized views can be built and rebuilt. The cost event sourcing adds is that you now own event schema design, versioning (lesson 05's schema evolution applies directly), and periodic snapshotting so you need not replay from the dawn of time on every restart.

3 · Time, windows, and watermarks

Batch jobs had it easy: the input was a fixed snapshot, and one timestamp column was as good as another. Streams force a distinction. Event time is when the event actually occurred, stamped by the source ("the click happened at 10:00:03"). Processing time is when your stream processor observed it. In a perfect network they would be nearly equal — but they diverge, sometimes wildly. A mobile client goes into a tunnel, queues its events for an hour offline, and uploads them at 11:05; the events happened at 10:00 but you see them at 11:05. A backfill replays last week's log today. A consumer falls behind and catches up in a burst. If you bucket by processing time you will attribute that 10:00 click to the 11:05 minute — your hourly metrics, your real-time features, your monitoring all become wrong in a way that correlates exactly with network trouble. You almost always want to group by event time.

Grouping unbounded data requires cutting it into finite windows. The four standard shapes:

Tumbling
Fixed-size, non-overlapping, back-to-back. "Count per 1-minute bucket." Every event belongs to exactly one window. The simplest and most common.
Hopping
Fixed-size but advancing by a step smaller than the size, so windows overlap. "5-minute count, emitted every 1 minute." Each event lands in several windows.
Sliding
A window of fixed duration around each event/point — "all events within the last 5 minutes of now," continuously. Like hopping with an infinitesimal hop.
Session
Variable-length, defined by activity: group events for a key until a gap of inactivity (say 30 min) ends the session. Length depends on the data, not the clock.

Now the core difficulty. A 1-minute tumbling window for event time 10:00:00–10:00:59 should contain every event that happened in that minute. But because event time and processing time diverge, events for that window can keep dribbling in long after 10:01 wall-clock. When do you declare the window finished and emit its result? Wait forever and you are always perfectly correct but never produce output. Emit the instant 10:01 wall-clock arrives and you will miss every straggler. You need an explicit policy, and that policy is a watermark.

A watermark is the processor's assertion: "I believe I have now seen all events with event time ≤ T." It is a moving low-water mark on event time, trailing behind the latest event time seen by some delay you choose. When the watermark passes the end of a window, the processor decides the window is complete enough and emits its result. The watermark delay is the knob: a larger delay waits longer, so it captures more late events (more correct), but holds the result back longer (higher latency) and keeps window state in memory longer (more cost). A smaller delay emits sooner but drops or mis-handles more stragglers. This is the freshness-versus-correctness trade, made into a single number.

event time vs processing time, with a watermark trailing the latest event (delay = 10s) processing time (wall clock) ---------------------------------------> 10:00:55 10:01:08 10:01:20 | | | events arrive (dot = event, e@10:00:12 e@10:00:58 e@10:00:30 <- arrives LATE label = its EVENT time): e@10:00:55 e@10:01:10 watermark = (latest event time seen) - 10s delay: ...==================== W = 10:00:48 ...then... W = 10:01:00 window [10:00:00 .. 10:00:59] CLOSES at event time 10:01:00, so it emits when W reaches 10:01:00 (latest event time seen = 10:01:10). the e@10:00:30 that ARRIVES after that is 30s behind the frontier (10:01:00 - 10:00:30): past the 10s allowance, its window already emitted -> dropped or late-data path.
Worked watermark number
Take a 1-minute tumbling window on event time with a 10-second watermark delay. Consider the window covering event times 10:00:00 to 10:00:59 — it closes at event time 10:01:00. The watermark is "latest event time seen − 10s", so it reaches the window's close (10:01:00) once the latest event time the processor has seen is 10:01:10. At that moment the processor decides the window is complete enough and emits its count. Now an event with event time 10:00:30 finally arrives after that — when the watermark frontier has advanced to 10:01:00. Its lateness is 10:01:00 − 10:00:30 = 30s: it is 30 seconds behind the event-time frontier, three times the 10-second allowance, so the window it belongs to has already emitted. Two honest options: drop it (the window result is now slightly undercounted but already published), or send it to a late-data path (a side output / dead-letter stream, or an emitted correction that downstream consumers apply). What you must not do is pretend it never existed without a metric — track a "late/dropped events" counter so you can tell whether your 10-second delay is too tight. Widen the delay to 30s or more and that event would have been captured — at the price of every window result arriving that much later.

Stream joins: three kinds, all subtle

Once you can window a stream by event time, you can join two streams — combine records that belong together, the streaming cousin of the SQL join. But a join assumes both sides are present at the moment you match them, and on an unbounded input nothing is ever fully present. That single fact splits stream joins into three kinds, and the time semantics from §3 are what make each one work or quietly break.

Stream-stream (windowed join)
Both sides are unbounded streams, so you cannot wait for one side to "finish." You buffer each side's recent events inside a time window and match within it — e.g. join a click event to the impression event for the same item seen in the last 30 minutes. The window is the join condition, and join state grows with the window width.
Stream-table (enrichment)
Join a stream to a table that is itself kept current by a changelog / CDC stream (§2). For each event you look up the matching row to enrich it — attach a user's current tier to a click. The subtlety: to be deterministic on replay you must read the table value as of the stream event's event time, not "whatever the table says right now."
Table-table (materialized-view maintenance)
Two changelog streams joined to keep a derived view fresh — the streaming form of maintaining a materialized join (lesson 18). A change on either side updates the output. This is the stream-table duality (§5) applied to both inputs at once.

The trickiest of the three is the stream-table join, precisely because of event time versus processing time. Say a click stream is enriched with a "user tier" table that the user can change. A click happened at 10:00 when the user was tier gold; the user upgrades to platinum at 10:05; you finally process that 10:00 click at 10:07. If you read "whatever the table says now" you enrich the old click with platinum — and worse, a replay months later would read a different table state and produce a different answer, so the job is no longer deterministic. The correct join reads the table value as of event time 10:00 (tier gold), which means the table side must retain its history as a changelog you can index by time — exactly the CDC change-event log from §2. This is why "join a stream to a table" is really "join two streams, one of which you fold into current state."

stream-table enrichment must use the table value AS OF the event's event time user-tier changelog: ...[gold@09:00].........[platinum@10:05].... click stream: .........[click@10:00 — processed late at 10:07]... WRONG (read table "now" at 10:07): click@10:00 -> platinum (not what was true) RIGHT (read table as-of 10:00): click@10:00 -> gold (deterministic on replay) stream-stream windowed join (clicks ⋈ impressions, 30-min window): impressions: [imp:item9 @10:00] ............ (buffered 30 min) clicks: [clk:item9 @10:12] -> match (12 min < 30) -> joined after 30 min the buffered impression is evicted -> state stays bounded

Two consequences fall out. First, event-time vs processing-time choice governs join correctness, not just window counts: a stream-stream join keyed on processing time would pair a click with whatever impression happened to arrive near it on the wire, which (as in §3) skews exactly when the network is bad. Second, join state is bounded by the window, not by the stream — the 30-minute window means you only buffer 30 minutes of one side, and watermarks (§3) tell you when it is safe to evict an entry because no further match can arrive within its window. Pick the window too wide and join state (and cost) balloons; too narrow and you silently miss valid late matches — the same completeness-versus-cost knob the watermark widget below lets you feel.

4 · Feel the knob: the watermark widget

Each dot below is an event, plotted by its event time. Most arrive close to on-time, but a tail arrives late (a slice of clients were briefly offline). The watermark delay sets how long after a window's end you keep waiting before you emit. Drag it and watch two numbers move in opposite directions: the fraction of late events captured (completeness) climbs, while the emit latency you add to every window grows with it.

Watermark delay — completeness vs emit latency
Green dots = on-time events (arrive before the watermark passes their window). Red dots = late events whose arrival lag exceeds the current delay — they are dropped (or sent to a late-data path). The vertical purple line is the watermark delay: events whose lateness falls to its left are captured; to its right, missed. Raise the delay to catch more reds — and add that delay to every emitted result.
Late events captured
Late events dropped
Completeness
Added emit latency
Show the core JS
// each event has an arrival lag = (processing time - event time), in seconds.
// the watermark waits `delay` seconds past a window's end before emitting.
// an event is captured iff it arrives before the watermark passes -> lag <= delay.
for (const e of events) {
  if (e.lag <= delay) captured++;   // made it in before the window emitted
  else                 dropped++;   // late: drop or route to a side output
}
const completeness = captured / events.length;  // climbs with delay
const addedLatency = delay;                      // every window result waits this long

5 · Fault tolerance: there is no exactly-once, only effectively-once

A stream processor runs forever, so it will crash mid-flight, and when it restarts it must not lose events nor double-count them. The marketing phrase is "exactly-once," and taken literally it is impossible: a processor can read an event, update state, and write output, then crash before recording that it finished — on restart it cannot tell whether the output was written, so it must redo the work, and the work happens twice at the level of physical execution. You cannot make the physical execution happen exactly once across crashes. What you can guarantee is that the observable effect is as if each event were processed once. That is effectively-once (sometimes "exactly-once semantics"), and it is built from three ingredients.

1Idempotence. An operation is idempotent if applying it twice has the same effect as applying it once. Writing feature[user_42] = 0.91 is idempotent (a replay just re-sets the same value); counter += 1 is not. Where you can shape the write to be idempotent — keyed upserts, "set to this value", dedupe by a unique event id — a duplicated delivery is harmless because the second application changes nothing.
2Checkpointed offsets. Recall offsets (§1): the consumer's "I have processed up to here." The trick is to advance the committed offset only together with the state and output it produced, so that on restart you resume from a known-consistent point. If you commit the offset before the output lands, a crash loses the output (at-most-once); if after, a crash replays it (at-least-once) — and at-least-once is fine precisely because step 1 made the replay idempotent.
3Atomic state snapshots. The processor periodically takes a consistent snapshot (a checkpoint) of its in-flight window state together with the input offsets that produced it — either via a microbatch boundary (process a small chunk, then atomically commit state + offsets, repeat) or a distributed snapshotting algorithm. On failure it rolls state back to the last checkpoint and replays the log from the offsets in that checkpoint.

Put together: checkpoint state and offset atomically, replay from the checkpoint on crash, and make the writes idempotent so the replay is invisible. The output looks as though every event was processed once — effectively-once — even though, physically, the replayed slice ran twice. Note the dependency on earlier lessons: the atomic "advance offset with output" step is exactly a transaction (lesson 13), and where output spans systems it needs the cross-system coordination that consensus underpins (lesson 17). Effectively-once is not magic; it is idempotence plus a transactional checkpoint plus replay.

Backpressure: what happens when the consumer can't keep up

Crashes are the dramatic failure; the quiet one is simply that the producer is faster than the consumer, for longer than a burst. Events arrive at 80,000/s; your processor drains 50,000/s. Where do the extra 30,000/s go? There are only three answers, and every streaming system is some combination of them.

Drop
Discard what you can't process. Bounded memory, never blocks — but you lose data. Acceptable only where approximate is fine (sampled metrics, best-effort telemetry), and only ever with a counter so the loss is visible, never silent.
Buffer
Queue the overflow. Absorbs a burst perfectly — but a sustained overload makes an unbounded buffer grow until it OOMs the process, and even a bounded one only delays the question: what do you do when it's full? Latency also grows with queue depth (Little's Law, lesson 06).
Backpressure
Push the "I'm full" signal upstream: the slow consumer makes its input queue fill, which makes its producer slow down, which propagates up the chain until the original source throttles (or blocks). No data lost, bounded memory — at the cost of slowing the whole pipeline to the speed of its slowest stage.

The deep reason a log-based broker gives backpressure almost for free is the pull model from §1. The broker never pushes; each consumer pulls at its own pace and the unread suffix of the log simply sits on the broker's disk. A slow consumer's offset falls further behind the head — its lag grows — but nothing upstream has to block and nothing is dropped, because the durable log is the buffer, sized in hours of disk rather than seconds of RAM. Lag is the pressure gauge: you watch it, alert on it, and add consumer parallelism when it trends up. A push-based pipeline (a synchronous RPC chain, or in-memory reactive streams) has no such free buffer and must implement backpressure as an explicit protocol — credit/permit windows, or TCP-style flow control — or it falls back to dropping or OOMing.

Worked number — lag is a buffer with a deadline

Consumer drains 50,000 events/s; an 80,000/s spike lasts 10 minutes. Backlog builds at (80,000 − 50,000) = 30,000/s for 600 s = 18,000,000 events sitting unread in the log. That is fine as long as the log still retains them. If the topic keeps a 6-hour / multi-TB window, the 18M backlog is a rounding error and you're safe. When the spike ends and input falls back to, say, 40,000/s, the consumer drains the backlog at (50,000 − 40,000) = 10,000/s, so recovery takes 18,000,000 / 10,000 = 1,800 s = 30 min of elevated lag before you're caught up. The failure mode is sharp: if lag ever grows so large that the oldest unread offset ages out of retention before the consumer reaches it, those events are deleted unread — silent data loss that looks exactly like a bug downstream. The pull-based log converts "overload" into "bounded, observable lag with a retention deadline," which is the most benign of the three answers — but only while lag < retention.

Stream-table duality and the batch/stream convergence

One more idea pays off in the next lesson. A table is a snapshot of current state; a stream is the log of changes to that state. They are two views of the same thing — the stream-table duality. Replay the change stream from the beginning and fold it up and you reconstruct the table (this is exactly what CDC consumers and event sourcing do, §2); conversely, watch a table and emit every change and you get a stream. Once you see that a table is just a materialized fold over a log, the wall between batch and stream starts to dissolve: a batch job is a stream processor over a bounded prefix of the log, and a stream processor is a batch job that never finishes. That convergence — and the question of whether to run one unified pipeline or a fast-but-approximate stream layer next to a slow-but-correct batch layer — is the lambda/kappa debate the next lesson (20, derived-data correctness) takes up.

Failure modes

  • Dual-write divergence. Writing to DB and to the index in app code; a crash between them, or reorderings, leave them permanently inconsistent. Symptom: search shows products the DB deleted. Fix: CDC from one source of truth (§2).
  • Bucketing by processing time. Attributing an event to the minute you saw it, not when it happened; metrics and features skew exactly when the network is bad. Symptom: traffic "spikes" the instant a backlog drains (§3).
  • Watermark too tight. A short delay drops a long tail of late events with no counter; results are silently undercounted. Symptom: counts that disagree with a batch recompute (§3).
  • Watermark too loose. A huge delay holds every window open, ballooning state memory and emit latency. Symptom: growing memory and stale real-time dashboards.
  • Non-idempotent writes under replay. counter += 1 on at-least-once delivery double-counts on every crash-replay. Symptom: totals drift upward after each restart (§5).
  • Offset committed before output. Advancing the offset, then crashing before the write lands — events vanish (at-most-once) (§5).
  • Hot partition. Keying by a low-cardinality field sends a celebrity user to one partition, serializing its throughput (lesson 11) (§1).
  • Lag outruns retention. A consumer falls so far behind that unread offsets age out of the log before it reads them — silent data loss disguised as a downstream bug. Symptom: gaps that correlate with a past traffic spike. Fix: size retention above worst-case lag and add consumer parallelism / backpressure (§5).
  • Unbounded buffering under overload. Absorbing a sustained (not bursty) overload in an in-memory queue grows it until the process OOMs. Symptom: rising heap that tracks input rate. Fix: backpressure or drop-with-counter, not a bigger buffer (§5).
  • Poison message stalls the partition. One malformed event that always throws is retried forever at the head of an ordered partition, so the offset never advances and every event behind it waits — a single bad record halts the whole stream. Symptom: lag climbs on one partition while CPU spins on retries. Fix: after K failed attempts, route the offender to a dead-letter queue (a side topic for later inspection/replay) and advance past it, rather than blocking head-of-line (§1, §5).

Decision checklist

  • Do you need replay and multiple consumers (log-based broker) or one-time work dispatch (queue)? (§1)
  • What is the partition key, and does it both preserve the ordering you need and avoid hot keys? (§1, lesson 11)
  • Are derived views fed by CDC from a single source of truth, never a dual write? (§2)
  • Do you group by event time or processing time — and is that the right one for freshness? (§3)
  • Which window type fits — tumbling, hopping, sliding, or session? (§3)
  • What is the watermark delay, and where do late events go — dropped (with a counter) or a late-data path? (§3)
  • Are writes idempotent, are offsets checkpointed atomically with state, and is the recovery path tested by killing the job? (§5)
  • When the consumer can't keep up, do you drop (with a counter), buffer, or backpressure — and is retention sized comfortably above worst-case consumer lag? (§5)

Checkpoint exercise

Try it
You run an online recommender. A profile-update event must refresh three derived systems — a search index, a serving cache, and a feature store / vector index — and you must never let them disagree with the database. (a) Sketch the pipeline using a single source of truth: which broker type, fed by what mechanism, with each derived system as what? (b) You compute a per-user "events in the last 1 minute" feature. State whether you group by event time or processing time and why, name the window type, and pick a watermark delay — then say what happens to a feature event that arrives 45 s late under that delay. (c) On a crash mid-window, your "events count" feature must not double after restart. Name the three ingredients that give you effectively-once here, and rewrite a non-idempotent count += 1 update into an idempotent one.

Where this points next

We now have both halves of derived history: lesson 18's batch layer, which is slow but reprocesses the whole history deterministically, and this lesson's stream layer, which is fast but must reason about time, lateness, and replay to stay correct. The next lesson (20) opens Part 9 — derived views — by putting both halves in one frame. It asks the question every real system faces: caches, search indexes, feature stores, and RAG vector indexes are all derived data that must be kept consistent with a source of truth — so do you run a fast approximate stream layer beside a slow exact batch layer and reconcile them (the lambda architecture), or treat the durable log as the single pipeline and reprocess by replay (the kappa architecture)? The stream-table duality you just met is the hinge of that argument, and keeping derived views correct end to end is where Part 9 begins.

Where is truth?
System of record: the append-only partitioned change log — the database's WAL exposed by CDC, or the event-sourced log of facts; the commit order in the log is the truth. Copies / derived views: every consumer built by folding the log — search index, cache, feature store, vector index, windowed aggregates and join state. Freshness budget: consumer lag, expressed as offsets behind the head or as a watermark delay (e.g. a 1-min window emits ~10s after its event-time end); late events past the watermark are dropped or routed to a late-data path. Owner: each consumer owns its offset and advances it at its own pace. Deletion path: a delete (tombstone) event flows down the same log to every consumer, which applies it in order. Reconciliation / repair: reset a consumer's offset to 0 and replay the retained log through corrected code to rebuild the view from scratch. Evidence it is correct: effectively-once — idempotent writes plus offsets checkpointed atomically with state make a crash-replay invisible, and a counter of late/dropped events tells you whether the watermark delay is honest.
Takeaway
A stream is batch without an end-of-file, and the data structure that tames it is the append-only partitioned log: producers append, each consumer reads from its own offset, and because nothing is deleted on read, history is replayable — which is why a log-based broker (retain + replay) beats a message queue (delete on ack) for building derived data. Change data capture turns a database's replication log into that event stream so a search index, cache, and feature store all stay in sync from one source of truth, killing the dual-write problem; event sourcing pushes the same ordered-history idea into the application. Because event time (when it happened) and processing time (when you saw it) diverge, you group by event time into windows (tumbling / hopping / sliding / session) and use a watermark — "I've seen everything up to T" — to decide when a window is complete enough to emit, trading a longer delay (more late events captured) against higher latency and more state. You cannot physically process exactly once across crashes, but idempotence + checkpointed offsets + atomic state snapshots make the observable effect effectively-once. And since a table is just a fold over a change log — the stream-table duality — batch and stream are two ends of one spectrum, which is exactly what the next lesson on keeping derived views correct (20) unifies.

Interview prompts