Part 9 · Data Engineering · Module 63

Streaming & Big Data

Some data arrives too fast to wait for tonight's batch, and some is too big for one machine. This module covers both: how event streams work (Kafka's topics, partitions, offsets, and consumer groups), how to aggregate unbounded data with windows and watermarks, how distributed engines split work with map, shuffle, and reduce, and just as important, how to tell when you don't need any of it.

Why "big" data, and when it isn't

Data is "big" when a single machine can't store it, can't process it fast enough, or can't keep up with its arrival rate. Then you scale out: split the data into partitions and process them on many machines in parallel. Distributed systems pay real costs for that: network transfer, serialization, coordination, partial failures, and much harder debugging.

Meanwhile, single machines have grown enormous. A laptop has 8–16 cores, 16–64 GB of RAM, and an NVMe SSD reading several GB/s; a cloud VM can have hundreds of cores and terabytes of RAM. Columnar, vectorized, multi-threaded engines (DuckDB, Polars) stream data from disk and spill to disk when memory runs out, so they comfortably process hundreds of gigabytes on one node.

A year of a busy clickstream, compressed in Parquet, is around 800 GB, and a typical query touches a month of it, a few columns at a time. That is single-node territory. Reach for a cluster when the data is in the tens of terabytes and queries touch most of it, when many teams share one platform, or when the arrival rate exceeds what one machine can ingest.

Remember: estimate compressed size and the fraction a query touches before choosing a cluster.

Streaming concepts and Kafka

An event is an immutable fact about something that happened, with a timestamp: "user 42 viewed page /pricing at 10:00:03". A stream is an unbounded, ordered sequence of events. Producers append events; consumers read them. A broker such as Apache Kafka (or Redpanda, Amazon Kinesis, Google Pub/Sub, Apache Pulsar) sits between them, durably storing events so producers and consumers are decoupled in time and speed.

Topics, partitions, and offsets

Kafka organizes events into topics (for example clicks). Each topic is split into partitions, and each partition is an append-only log: events get consecutive offsets 0, 1, 2… Ordering is guaranteed within a partition, not across partitions. A producer chooses the partition by hashing the event's key, so all events with the same key (the same user) land in the same partition, in order. Events are retained for a configured time (say seven days) whether or not anyone has read them, so consumers can replay.

Producers web app mobile app Topic "clicks" (key = user_id) P0P1P2 0 | 1 | 2 | 3 | 4 | 5 → 0 | 1 | 2 → 0 | 1 | 2 | 3 → Consumer group "dashboard" consumer A: P0, P1 consumer B: P2 Each partition is an ordered log. Each partition is read by exactly one consumer in a group; the group commits the offset it has processed per partition.
Producers hash keys to partitions; a consumer group splits the partitions among its members.

Consumer groups and committed offsets

A consumer group is a set of consumers sharing the work of reading a topic. Kafka assigns each partition to exactly one consumer in the group, so the number of partitions caps the group's parallelism (four partitions, six consumers: two sit idle). Each group tracks its own committed offset per partition, the position it has processed up to, so different groups (a dashboard, a fraud detector, an archiver) read the same topic independently. When a consumer joins or dies, the group rebalances and partitions move.

Watch records move through a streaming pipeline: each stage handles a record as soon as the previous stage passes it on, so all stages work at once.

Remember: topics split into partitions; partitions are ordered logs with offsets; keys pick partitions; a consumer group shares partitions and commits offsets.

Windowing with generators

You can't "sum all events" in an unbounded stream: it never ends. Instead, aggregate over windows, finite slices of time:

Tumbling

Fixed-size, non-overlapping, back-to-back: [0, 60), [60, 120)… Each event belongs to exactly one window. "Clicks per minute."

Sliding (hopping)

Fixed size, advancing by a smaller step: size 60 s every 10 s. Windows overlap and an event belongs to several. "Clicks in the last minute, updated every 10 s."

Session

Per key, a window that stays open while events keep arriving within a gap (30 minutes of inactivity closes it). Size varies. "A user's visit."

tumblingslidingsession time → (session windows close after a gap with no events)
Three ways to cut an unbounded stream into finite windows.

Generators are a natural fit: a stream is an iterator that may never end, and a windowing operator is a generator that consumes events and yields each window result as soon as the window closes, holding only the current window's state in memory.

Remember: tumbling = fixed and disjoint; sliding = fixed and overlapping; session = per key, closed by inactivity. Emit a window when it closes, and keep only open windows in memory.

Event time, late data, and watermarks

Every event has two times: event time (when it happened, stamped by the device) and processing time (when your system sees it). They differ, sometimes by hours: a phone in airplane mode uploads its clicks on landing; a network partition delays a whole data center. Windowing by processing time is easy but wrong for most analytics ("sales between 10:00 and 10:01" means event time).

Windowing by event time raises a question: when can you close the 10:00 window, if a 10:00 event might still arrive? A watermark is the system's estimate that "all events with event time ≤ W have probably arrived". A common heuristic is max event time seen minus an allowed lateness. When the watermark passes a window's end, the window is emitted. Events that arrive for an already-closed window are late: drop them, send them to a side output for correction, or update the emitted result (which downstream must handle).

Trace it

The event at time 8 arrives after 12 but is still on time: the watermark is only 12 − 5 = 7, so window [0, 10) is open. Event 14 moves the watermark to 9; event 25 moves it to 20, which closes [0, 10) (total 16) and [10, 20) (total 9). The event at time 9 (value 100) then arrives for a closed window, so it is late. Event 22 is on time: [20, 30) stays open until event 45 pushes the watermark to 40.

Remember: window by event time; the watermark decides when a window closes; late events need a policy.

Backpressure and exactly-once

Backpressure is how a slow consumer tells a fast producer to slow down. Without it, the gap between them accumulates in memory until the process crashes. The simplest mechanism is a bounded buffer: when it's full, the producer blocks (or drops, or samples). Kafka consumers get backpressure for free because they pull: a slow consumer simply falls behind, and its consumer lag (latest offset minus committed offset) grows, which is the key metric to alert on.

Exactly-once, honestly

"Exactly-once" in streaming means the effects of each event appear once, despite retries and crashes. It is achieved with one of:

Each guarantee covers only the systems inside the transaction or checkpoint. Sending an email or calling an external API from a stream processor is at-least-once unless that API is idempotent (an idempotency key).

Remember: bounded buffers and pull-based consumers give backpressure; watch consumer lag; exactly-once is end-to-end engineering, usually via idempotent writes.

Distributed processing: MapReduce

MapReduce (Google, 2004; Hadoop) is the model under every distributed engine. A job has three phases:

  1. Map: each worker reads one input split and emits (key, value) pairs, independently and in parallel.
  2. Shuffle: pairs are routed by key (hash of the key mod the number of reducers) so all values for a key meet at the same reducer. This is the expensive phase: data crosses the network and is sorted.
  3. Reduce: each reducer combines the values for each of its keys.
flowchart LR
  S1[split 1] --> M1[map]
  S2[split 2] --> M2[map]
  S3[split 3] --> M3[map]
  M1 --> SH{{shuffle by hash of key}}
  M2 --> SH
  M3 --> SH
  SH --> R1[reduce: keys a-m]
  SH --> R2[reduce: keys n-z]
  R1 --> O[(output)]
  R2 --> O
    

Remember: map is embarrassingly parallel; the shuffle moves data by key and dominates cost; combiners shrink it.

Dask and Spark

Modern engines generalize MapReduce into a DAG of operations and add lazy evaluation: transformations only build a plan; nothing runs until an action (compute(), collect(), count(), a write) asks for a result. Laziness lets the optimizer fuse steps, push filters down to the file scan, and prune unneeded columns, just like DuckDB and Polars lazy mode.

Dask

Dask scales the Python APIs you know. A Dask DataFrame is many pandas DataFrames (partitions); Dask Array is many NumPy chunks; dask.delayed turns arbitrary functions into a lazy task graph. It runs on a laptop's cores or a cluster with the same code.

Apache Spark and PySpark

Spark is the most widely used distributed engine. A driver program builds a plan; a cluster manager starts executors on many machines; the plan is split into stages at every shuffle boundary, and each stage into one task per partition.

Remember: transformations are lazy, actions run; shuffles (wide transformations) define stages and cost the most; prefer DataFrames to RDDs.

Partitioning and skew

Parallelism only helps if work is spread evenly. Engines assign rows to partitions by hash partitioning (hash(key) % n, used by shuffles), range partitioning (sorted key ranges, used for sorting), or the input file layout. Skew happens when a few keys hold most of the data (one celebrity account, one giant customer, a NULL key): one task gets far more rows than the others, and the whole stage waits for that straggler.

Other remedies: broadcast joins (send a small table to every worker instead of shuffling the big one; Spark's F.broadcast(df)), Spark 3's adaptive query execution (AQE), which splits skewed partitions automatically, and filtering or handling NULL keys separately. On disk, aim for partitions (files) of roughly 100 MB–1 GB; thousands of tiny files are a form of skew in the other direction ("small files problem"), fixed by compaction.

Remember: look at the task-duration distribution; one straggler means skew; salt, broadcast, or let AQE split it.

Cloud object storage

Big-data systems store their files in object storage: Amazon S3, Google Cloud Storage, Azure Blob Storage, or S3-compatible systems (MinIO, Cloudflare R2). It is cheap, effectively unlimited, durable (S3 is designed for eleven nines), and readable by every engine. But it is not a filesystem, and its differences shape data layouts:

Remember: object storage is cheap, durable, and flat; write large immutable files under partition-style prefixes; let a table format handle commits.

Practice

Write a generator tumbling(events, size). events is an iterable (possibly endless) of time-ordered (timestamp, amount) pairs with integer or float timestamps. Yield (window_start, count, total) for each window [start, start + size) that contains at least one event, as soon as the window closes (when an event for a later window arrives), and flush the last window when the input ends. window_start is int(ts // size) * size.

Hint

Keep current, count, total. When an event's window start differs from current, yield the old window and reset. After the loop, yield the last window if current is not None.

Show solution
def tumbling(events, size):
    current, count, total = None, 0, 0
    for ts, amount in events:
        start = int(ts // size) * size
        if current is not None and start != current:
            yield current, count, total
            count, total = 0, 0
        current = start
        count += 1
        total += amount
    if current is not None:
        yield current, count, total

Write sessionize(events, gap). events is a list of (timestamp, user) pairs, not necessarily sorted. For each user, consecutive events (in time order) belong to the same session while the time since that user's previous event is <= gap. Return a list of (user, start, end, n_events) sorted by (user, start).

Hint

Group timestamps by user, sort each list, then walk it: start a new session when t - previous > gap.

Show solution
from collections import defaultdict

def sessionize(events, gap):
    by_user = defaultdict(list)
    for ts, user in events:
        by_user[user].append(ts)
    out = []
    for user, times in by_user.items():
        times.sort()
        start = prev = times[0]
        n = 1
        for t in times[1:]:
            if t - prev > gap:
                out.append((user, start, prev, n))
                start, n = t, 0
            prev = t
            n += 1
        out.append((user, start, prev, n))
    return sorted(out)

Implement the three phases of MapReduce:

  • map_phase(text): return a list of (word, 1) pairs for each lowercase word matched by re.findall(r"[a-z]+", text.lower()).
  • shuffle(pairs, n): return a list of n dicts; put each pair's value into buckets[zlib.crc32(word.encode()) % n][word] (a list of values).
  • reduce_phase(bucket): return {word: sum(values)}.
  • word_count(texts, n): run them all and return one merged dict of counts.
Hint

Use collections.defaultdict(list) for each bucket. In word_count, flatten the mapped pairs, shuffle once, and update a result dict with each reduced bucket.

Show solution
import re
import zlib
from collections import defaultdict

def map_phase(text):
    return [(w, 1) for w in re.findall(r"[a-z]+", text.lower())]

def shuffle(pairs, n):
    buckets = [defaultdict(list) for _ in range(n)]
    for word, value in pairs:
        buckets[zlib.crc32(word.encode()) % n][word].append(value)
    return [dict(b) for b in buckets]

def reduce_phase(bucket):
    return {word: sum(values) for word, values in bucket.items()}

def word_count(texts, n=3):
    pairs = [p for t in texts for p in map_phase(t)]
    result = {}
    for bucket in shuffle(pairs, n):
        result.update(reduce_phase(bucket))
    return result

Write window_with_watermark(events, size, lateness). events arrive in processing order as (event_time, value). Maintain watermark = max(event_time seen) - lateness (start at negative infinity). For each event: if its window [start, start + size) has start + size <= watermark, it is late (record (event_time, value) in a late list); otherwise add value to its window. After updating the watermark, close every open window with start + size <= watermark. At the end, close all remaining windows. Return (closed, late) where closed is a list of (start, total) in the order windows closed (windows closing together in ascending start order).

Hint

Use a dict start → total for open windows. Closing: for s in sorted(w for w in open_ if w + size <= watermark): closed.append((s, open_.pop(s))).

Show solution
def window_with_watermark(events, size, lateness):
    open_, closed, late = {}, [], []
    watermark = float("-inf")
    for t, value in events:
        start = t // size * size
        if start + size <= watermark:
            late.append((t, value))
            continue
        open_[start] = open_.get(start, 0) + value
        watermark = max(watermark, t - lateness)
        for s in sorted(w for w in open_ if w + size <= watermark):
            closed.append((s, open_.pop(s)))
    for s in sorted(open_):
        closed.append((s, open_[s]))
    return closed, late

Write partition_sizes(keys, n) returning a list of n counts, where key k goes to partition zlib.crc32(k.encode()) % n. Write skew(sizes) returning max(sizes) / mean(sizes) (1.0 is perfectly even; return 0.0 for all-zero sizes). Finally write salt(keys, hot, k) returning a new list where every key in the set hot becomes f"{key}#{i % k}", with i counting that key's occurrences from 0, and other keys are unchanged.

Hint

For salt, keep a collections.Counter of how many times each hot key has been seen so far.

Show solution
import zlib
from collections import Counter

def partition_sizes(keys, n):
    sizes = [0] * n
    for k in keys:
        sizes[zlib.crc32(k.encode()) % n] += 1
    return sizes

def skew(sizes):
    mean = sum(sizes) / len(sizes)
    return 0.0 if mean == 0 else max(sizes) / mean

def salt(keys, hot, k):
    seen = Counter()
    out = []
    for key in keys:
        if key in hot:
            out.append(f"{key}#{seen[key] % k}")
            seen[key] += 1
        else:
            out.append(key)
    return out

Mini-project: a streaming clickstream aggregator

Mini-projectLive page-view dashboard over a simulated stream

Build a stream processor that consumes an endless, simulated clickstream and maintains per-page counts in one-minute tumbling windows, by event time, with late-data handling, writing results idempotently to SQLite.

pixi init clickstream && cd clickstream
pixi add python structlog pytest
pixi run python -m clickstream --minutes 10 --late-rate 0.05 --seed 1
pixi run pytest
# stretch: a real broker
pixi add python-confluent-kafka
  1. generator.py: an endless generator of events {"event_id", "user", "page", "event_time"} with a fixed seed, mostly in order but with a configurable fraction delayed by up to 90 seconds, plus a few duplicate deliveries.
  2. windows.py: a generator that consumes events and yields closed (window_start, page, count) results using a watermark with configurable allowed lateness; late events go to a side list.
  3. sink.py: upsert window results into page_views(window_start, page, views) keyed on (window_start, page), deduplicating by event_id before counting.
  4. A bounded asyncio.Queue between the generator and the windowing stage; log consumer lag every window with structlog.
  • Window counts are by event time, not arrival time (tested with a shuffled stream)
  • Duplicate deliveries don't inflate counts
  • Late events beyond the allowed lateness are reported, not silently dropped
  • Replaying the same seeded stream twice leaves identical page_views rows
  • Memory stays bounded: only open windows are held (check with a 100 000-event run)

Stretch: add 5-minute sliding windows and per-user session windows; produce events into a local Kafka topic with three partitions and consume with two consumers in one group; compare results with a batch DuckDB query over the same events written to Parquet.

Quiz

Recap

Going further