0%
Chapters / Stream Processing
CHAPTER 11

Stream Processing

A stream is a batch that never ends, which breaks every assumption batch processing was allowed to make about time.

source: DDIA, Chapter 11

11.1Chapter Thesis

Batch processing assumes the input dataset has an end - a file you can finish reading. A stream has no end, only a continuously growing history, and that single difference invalidates almost every assumption batch processing was allowed to make: there is no final answer to compute, only a continuously updated one, and "done" has to be redefined as "caught up to right now," a moving target.

11.2First Principles

An event log is an append-only sequence with durable position

A stream is materialized as an ordered, append-only sequence of events, each identified by a monotonically increasing offset. Unlike a message queue that deletes a message once consumed, a log retains history, letting multiple independent consumers each track their own position and replay from any earlier offset - this is what allows a new consumer to be added later without disrupting existing ones.

Event time and processing time are different clocks

Event time is when something actually happened; processing time is when the system observed it. Network delay, retries, and consumer backlog mean these two clocks diverge, sometimes by a lot - any windowed computation ("events per minute") has to explicitly choose which clock it's windowing by, because the two produce different, both-valid answers.

Delivery semantics are a spectrum, not a binary

At-most-once delivery can silently drop messages on failure. At-least-once can redeliver the same message after a failure, requiring the consumer to handle duplicates. Exactly-once processing semantics (not delivery - delivery exactly-once is not achievable over an unreliable network) require the consumer's side effects to be idempotent, so that a redelivered message produces the same end state as processing it once.

11.3Mechanism

A partitioned log splits the event stream across multiple partitions (usually by a key, so all events for the same entity land in the same partition and preserve order relative to each other). Each consumer tracks a committed offset per partition - the position up to which it has durably finished processing. Whether that offset is committed before or after processing determines which delivery guarantee the consumer gets.

11.4Build It Yourself

partitioned-log.ts
interface Event { key: string; payload: string; }

class PartitionedLog {
  private partitions: Event[][];

  constructor(private numPartitions: number) {
    this.partitions = Array.from({ length: numPartitions }, () => []);
  }

  private partitionFor(key: string): number {
    let h = 0;
    for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
    return h % this.numPartitions;
  }

  produce(event: Event): void {
    this.partitions[this.partitionFor(event.key)].push(event);
  }

  read(partition: number, fromOffset: number): Event[] {
    return this.partitions[partition].slice(fromOffset);
  }

  latestOffset(partition: number): number {
    return this.partitions[partition].length;
  }
}

class Consumer {
  private committedOffset = 0;

  constructor(private log: PartitionedLog, private partition: number) {}

  // committing AFTER processing gives at-least-once:
  // a crash between processing and commit replays events.
  poll(processFn: (e: Event) => void): void {
    const events = this.log.read(this.partition, this.committedOffset);
    for (const e of events) {
      processFn(e);
      this.committedOffset++;
    }
  }

  lag(): number {
    return this.log.latestOffset(this.partition) - this.committedOffset;
  }
}

11.5Failure Modes

ConditionResulting state
Consumer crashes after processing but before incrementing committedOffsetAt-least-once semantics: on restart, the same events are read and processed again - safe only if processFn is idempotent.
Offset is committed before processing (inverse order)At-most-once semantics: a crash after commit but before processing permanently skips those events with no error raised.
Producer bursts events faster than the consumer can pollConsumer lag (latestOffset − committedOffset) grows unbounded; downstream effects of those events fall further and further behind real time with no automatic backpressure in this minimal design.

11.6Tradeoffs

SemanticsBenefitCostWhen it makes sense
At-most-onceSimplest implementation, no duplicate handling neededSilent data loss on any failureMetrics or logs where an occasional dropped data point is acceptable
At-least-onceNo data lossConsumer must handle duplicate processing correctlyMost production pipelines, paired with idempotent processing
Exactly-once processingNo loss, no duplicate side effectsRequires transactional or idempotent sinks; higher implementation complexityFinancial or otherwise duplicate-intolerant processing

11.7Production Perspective

Kafka implements this exact partitioned-log-plus-consumer-offset model at scale, and layers exactly-once processing on top by making offset commits part of the same transaction as the produced output, rather than by trying to prevent redelivery at the network level - accepting at-least-once delivery and canceling out duplicates at the processing layer instead.