Search Tech Journey

Find topics, journeys and posts

6-month learning plan49 / 130
back to blog
data engineeringintermediate 55m read

S049 · Stream Processing — Watermarks, Windows, Exactly-Once

The three ideas that separate ‘I read from Kafka in a loop’ from ‘I run a real stream processor’: watermarks for time, windows for scope, exactly-once for correctness. Flink and Spark Structured Streaming demystified.

🗄️Data EngineeringM05 · Data Engineering· Session 049 of 130 90 min

🎯 Design a windowed streaming aggregation with correct event-time semantics, choose an allowed-lateness policy, and reason about exactly-once end-to-end.

Why this session exists

Stream processing is where every "we need real-time" project either succeeds beautifully or dies quietly in the third quarter. The difference is whether the team understood event time, watermarks, and windowing before they wrote code. This session teaches you the three concepts that Flink and Spark Structured Streaming both use — the mental model doesn't change even when the engine does. Add the exactly-once guarantee (or its usually-superior cousin, "effectively-once") and you have the entire competitive advantage of a serious streaming stack.

You will be able to
  • Distinguish event time from processing time and give one bug that comes from confusing them.
  • Define watermark, allowed lateness, and window emission policy in one sentence each.
  • Pick the right window type (tumbling, sliding, session, custom) for a given problem.
  • Explain the exactly-once contract: transactional producer + read-committed consumer + idempotent sink.
  • Choose between Flink and Spark Structured Streaming for a given workload with 3 concrete criteria.

Prerequisites

  • S046 — Batch vs streaming (mental model).
  • S048 — Kafka fundamentals (the source is almost always Kafka).


(a) Intuition · 5 min

Postmarks vs delivery times
🌍 Real world

Imagine sorting mail by "when the sender wrote it" (postmark) instead of "when the postman delivered it." The postmark is what actually matters for chronology — the delivery time is an artefact of the postal service. But letters arrive out of order: a June 1 letter might land on your doorstep on June 5, after the June 3 one.

You need a rule: "I'll finish processing all of May's mail on June 5 at noon, and treat anything postmarked in May that arrives after as ‘late mail’ — file it separately or discard it." That rule is a watermark plus an allowed lateness policy. Windowing is: "group all May mail together, all June mail together, and report a count when I close each window."

💻 Code world

Event time = timestamp inside the record (when the click actually happened). Processing time = wall-clock time when your operator sees it. The gap between them is event-time lag, and it can be seconds (healthy) or hours (broken producer somewhere).

Watermark = the engine's assertion "I believe all events with event-time \\< W have arrived (probably)". Windows close when the watermark passes window_end + allowed_lateness. Late events either update the window (if within lateness) or go to a side output for reprocessing.

The four ideas that unlock everything

Stream processing vocabulary
  • Event time = when the event happened at the source. The only useful notion of time for correctness.
  • Watermark = monotonic assertion of ‘all events \&lt; W have arrived’. Wrong watermarks → dropped data.
  • Window = bounded slice of the stream (5-min tumbling, 10-min sliding, 30-min session). Emits results when it closes.
  • Exactly-once = end-to-end guarantee that requires transactional producer + read_committed consumer + idempotent OR transactional sink. All three.

A quick history so you know why the world looks like this

  1. 2010
    Apache Storm at Twitter
    First open-source stream processor. At-least-once, tuples, no event-time semantics.
  2. 2013
    Apache Flink at TU Berlin
    First engine with proper event-time watermarks and stateful exactly-once.
  3. 2014
    Spark Streaming (DStreams)
    Micro-batch on RDDs. Popular, but weak event-time story.
  4. 2015
    Google Dataflow paper (Akidau et al.)
    ‘The Dataflow Model’ unifies batch + streaming with windows + triggers + watermarks. Becomes Apache Beam.
  5. 2016
    Spark Structured Streaming
    Rewrite on top of DataFrames + Catalyst. Finally competitive on event-time semantics.
  6. 2020
    Flink 1.11 · unified batch + stream API
    ‘Any batch is a bounded stream.’ Flink SQL becomes production-ready.

(b) Visual walkthrough · 15 min

Event time vs processing time

The four window types

Tumbling

Fixed non-overlapping intervals.

  • ‘Count clicks per 5-minute bucket’
  • Each event in exactly one window
  • Easy to reason about; most common
  • Emits when watermark passes window_end
Sliding

Fixed size, sliding by a smaller step.

  • ‘Rolling 1-hour average, computed every 5 minutes’
  • Each event in multiple windows
  • More state, more emissions
  • Great for dashboards
Session

Windows defined by gaps of inactivity.

  • ‘User activity session ends after 30 min idle’
  • Variable size, per-key
  • Complex state; Flink handles natively
  • Perfect for user behaviour analytics
Custom / Global

You define triggers and eviction.

  • ‘Emit every N events regardless of time’
  • Advanced use only
  • Full flexibility, full responsibility
  • Rare outside big engineering teams

Watermark + allowed lateness in one diagram

Exactly-once end-to-end

1
Producer · idempotent + transactional

enable.idempotence=true + transactional.id. Kafka assigns a producer ID and sequence number; duplicates are silently dropped.

2
Broker · atomic multi-topic commit

COMMIT_TXN marker written to every topic-partition. Either all committed, or none.

3
Consumer · read_committed

Only reads committed transactions; uncommitted messages hidden. Standard for streaming apps.

4
Processing · checkpointed state

Flink/Spark checkpoints operator state + input offsets atomically. On restart, restore state + offsets together.

5
Sink · idempotent OR transactional

Postgres upsert (idempotent) OR 2PC sink (transactional). Without this, exactly-once falls back to at-least-once at the boundary.


Common misconception
✗ What most people think

"My stream processor gives exactly-once semantics, so every event is delivered and processed exactly once end to end."

✓ What is actually true

Exactly-once delivery is impossible over an unreliable network — that is a proven result, not an engineering gap. What these systems provide is exactly-once effect: at-least-once delivery combined with transactional or idempotent state updates, so that reprocessing a duplicate produces the same final state. The guarantee holds only inside the boundary the framework controls, and it stops at the sink.

Why the myth is so sticky

Because the marketing phrase is "exactly-once" and the config flag is literally named that, so it reads as an end-to-end promise. The gap appears at the edges: if your job calls an external API, sends an email, or writes to a store that cannot participate in the transaction, the framework cannot roll that back. It will happily replay from the last checkpoint and do it again. The guarantee is about the framework's own state, not about the world.

Prove it to yourself

The boundary is the sink, and idempotency is what you actually control:

# NOT exactly-once - a replay after failure sends the email twice
def process(event):
    send_email(event.user)          # external side effect, cannot be rolled back
    state.update(event)

# Exactly-once EFFECT via idempotent write:
#   the same event_id upserts to the same row, so a replay is a no-op
MERGE INTO results t USING staged s ON t.event_id = s.event_id
WHEN NOT MATCHED THEN INSERT ...;

# Or make the side effect idempotent with a dedup key the receiver honours:
send_email(user, idempotency_key=event.event_id)

# Ask of every sink: if this runs twice with the same input,
# is the end state identical? If no, you do not have exactly-once,
# whatever the framework config says.
From first principles
Start with the question

Why does a streaming join between two streams need state, a window and a retention policy, when a batch join needs none of these?

  1. 1
    A join matches rows from two inputs on a key. To emit a match, both sides' rows for that key must be available simultaneously.
    forced by · a join output is a function of a pair; you cannot emit half of it
  2. 2
    In batch, both inputs are finite and complete, so the engine can read one side fully into a hash table and probe with the other. Availability is guaranteed.
    forced by · completeness is known before the job starts
  3. 3
    In a stream, the matching row on the other side may not have arrived yet — and may arrive seconds or hours later, or never.
    forced by · streams are unbounded and the two sources are independent, with independent delays
  4. 4
    So the processor must buffer unmatched rows from both sides in state, waiting for their counterpart.
    forced by · discarding a row means permanently missing any match that arrives afterwards
  5. 5
    But an unbounded stream produces unbounded unmatched rows, so buffering forever is unbounded memory — which is not implementable.
    forced by · state grows monotonically with every key ever seen and never matched
  6. 6
    Therefore the join must be bounded by a window: "match only rows whose event times are within N minutes", after which state for that window is dropped.
    forced by · bounded state requires a rule for when a row can never match again, and only a time bound can supply one
⇒ Therefore

Therefore a stream-stream join is fundamentally a windowed join, and the window size is a direct trade of match completeness against memory. There is no unwindowed stream-stream join, and a system offering one is either buffering without limit or silently dropping.

And note what this predicts: a stream-table join needs no window at all, because the table side is a bounded, queryable state rather than an unbounded stream. That is why enriching a stream with a dimension is cheap and joining two high-volume streams is expensive — and it is why so many designs materialise one stream into a compacted changelog table first, converting the hard join into the easy one.

Mental modelA standing query over a moving window

In batch, data sits still and the query moves over it. In streaming, invert it: the query stands still and the data moves through it. The processor holds a fixed computation, and events flow past, updating its state and occasionally causing it to emit.

Everything hard follows from that inversion. The processor must remember things between events (state). It must decide when it has seen enough of a window to speak (watermarks). It must survive being restarted mid-flow without losing or double-counting what it remembered (checkpoints). Those three — state, time and recovery — are the entire subject.

  • Every stateful operator is a database you now operate: sized, checkpointed, restored, and eventually cleaned up. Unbounded state is the most common cause of a streaming job dying in production.
  • Tumbling windows do not overlap; sliding windows do and therefore multiply state and output; session windows are defined by gaps and have unbounded length by nature.
  • Checkpoint interval trades recovery time against steady-state overhead. Recovery replays from the last checkpoint, so a long interval means a long catch-up.
  • Streams and tables are dual: a table is the current state of a changelog stream, a stream is the sequence of changes to a table. Most designs get simpler once you can move freely between the two views.
🔔 Fires when you see

Fire this model the moment you see: enrichment of events with reference data · sessionisation from event gaps · deduplication over a time window · alerting on a rolling threshold · a job whose memory grows steadily until it dies · "why is the count different after a restart?" · a stream-stream join proposed without a window · any requirement to act on an event within seconds.

The tradeoff

You must enrich a high-volume event stream with slowly-changing reference data. Look it up per event, broadcast the reference data into state, or pre-join upstream?

External lookup per event
+ you gain always current, no state to manage in the job, and the reference data can be arbitrarily large since it stays where it lives
− you pay a network round trip per event caps throughput hard and couples your pipeline's availability to that service; caching helps but reintroduces staleness with none of the guarantees
pick when event volume is low, or the reference data is too large to hold in state and must be strictly current — and you have a fallback for when the service is down
Broadcast the reference data into operator state
+ you gain lookups become local memory access, so throughput is limited only by CPU; updates arrive as a changelog stream so the state stays fresh without per-event calls
− you pay the reference data must fit in every task's memory, initial load takes time on restart, and consistency between the two streams is only eventual — an event may be enriched with slightly stale reference data
pick when reference data is bounded and modest (dimension-sized) and small staleness is acceptable — the standard and usually correct pattern
Pre-join upstream, at the source
+ you gain the stream arrives already enriched, so the processor is stateless and trivially scalable and restartable; and the enrichment is captured at event time, which is often more correct for historical accuracy
− you pay requires the producing system to cooperate and to have the reference data available, widens the event payload, and a change to what you need enriched means changing the producer
pick when the producer already holds the context, and you want the values as they were at event time rather than as they are now
What a senior engineer actually does

Broadcast state is the default for dimension-sized reference data, because it removes the per-event network call — which is almost always the actual throughput ceiling — while keeping the data reasonably fresh. Reserve per-event lookups for cases where staleness is genuinely unacceptable, and be honest that you have just made an external service part of your pipeline's availability budget.

The subtler question is which version of the reference data is correct. Enriching with the current value gives you a result that changes if you replay later, which quietly destroys reproducibility — the streaming analogue of the SCD Type 1 problem. If the enriched value feeds anything historical or auditable, capture it at event time, either at the producer or by joining against a versioned changelog, so a replay produces the same answer it did the first time.


(c) Hands-on · 25 min

A Spark Structured Streaming job with event-time windows, watermarks, and idempotent output to Postgres. Save as stream_processing.py.

# stream_processing.py — event-time windowed aggregation with watermark + idempotent upsert.
# Run: spark-submit --packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.0,\
#                            org.postgresql:postgresql:42.7.0 stream_processing.py
 
from pyspark.sql import SparkSession, functions as F
from pyspark.sql.types import StructType, StringType, TimestampType
 
spark = (
    SparkSession.builder
    .appName("stream_processing")
    .config("spark.sql.streaming.checkpointLocation", "/tmp/ck/click_agg")
    .config("spark.sql.shuffle.partitions", "8")
    .getOrCreate()
)
spark.sparkContext.setLogLevel("WARN")
 
# ------------------------------------------------------------------
# Source: JSON events on a Kafka topic
# ------------------------------------------------------------------
schema = (StructType()
    .add("user_id", StringType())
    .add("event", StringType())
    .add("ts", TimestampType()))     # event time inside the payload
 
raw = (
    spark.readStream
    .format("kafka")
    .option("kafka.bootstrap.servers", "localhost:9092")
    .option("subscribe", "clicks")
    .option("startingOffsets", "latest")
    .load()
)
 
parsed = (
    raw.selectExpr("CAST(value AS STRING) AS json")
       .select(F.from_json("json", schema).alias("e"))
       .select("e.*")
)
 
# ------------------------------------------------------------------
# Watermark: ‘I promise no more events \< now - 2 minutes’
# Windowing: 1-minute tumbling by event time
# ------------------------------------------------------------------
windowed = (
    parsed
    .withWatermark("ts", "2 minutes")
    .groupBy(
        F.window("ts", "1 minute"),
        F.col("user_id")
    )
    .agg(F.count("*").alias("n"))
    .select(
        F.col("window.start").alias("window_start"),
        F.col("window.end").alias("window_end"),
        "user_id",
        "n"
    )
)
 
# ------------------------------------------------------------------
# Sink: foreachBatch → Postgres UPSERT (idempotent = effectively-once)
# ------------------------------------------------------------------
JDBC_URL   = "jdbc:postgresql://localhost:5432/streamdemo"
JDBC_PROPS = {"user": "postgres", "password": "demo", "driver": "org.postgresql.Driver"}
 
def upsert_to_pg(batch_df, batch_id: int) -> None:
    if batch_df.isEmpty(): return
    tmp = f"click_agg_stg_{batch_id}"
    (batch_df.write
        .mode("overwrite")
        .jdbc(url=JDBC_URL, table=tmp, properties=JDBC_PROPS))
 
    import psycopg2
    conn = psycopg2.connect("host=localhost dbname=streamdemo user=postgres password=demo")
    with conn, conn.cursor() as cur:
        cur.execute(f"""
            INSERT INTO click_agg (window_start, window_end, user_id, n)
            SELECT window_start, window_end, user_id, n FROM {tmp}
            ON CONFLICT (window_start, user_id)
              DO UPDATE SET n = EXCLUDED.n, window_end = EXCLUDED.window_end;
            DROP TABLE {tmp};
        """)
    conn.close()
 
query = (
    windowed.writeStream
    .outputMode("update")           # only changed rows per batch
    .foreachBatch(upsert_to_pg)
    .trigger(processingTime="30 seconds")
    .start()
)
query.awaitTermination()

What each block does

Anatomy of the script

checkpointLocation
MANDATORY for stateful streaming. Spark writes state + offsets here; on restart, resumes exactly-once at the checkpoint boundary.
checkpoint
withWatermark(‘ts’, ‘2 minutes’)
‘I promise no more events with event-time &lt; max_seen − 2min’. Older late events are silently dropped.
watermark
F.window(‘ts’, ‘1 minute’)
Tumbling 1-min window. Change to F.window(‘ts’, ‘5 min’, ‘1 min’) for sliding.
window
outputMode(‘update’)
Only rows whose aggregate changed since last batch. append is for windows that never update (past watermark); complete resends everything (huge for large state).
mode
foreachBatch + UPSERT
The effectively-once pattern. Even if Spark retries the batch after failure, the UPSERT with (window_start, user_id) primary key gives the same result.
sink
trigger(processingTime=‘30s’)
Micro-batch every 30 seconds. Lower = more frequent output + more overhead. AvailableNow for backfill runs; continuous for sub-second (experimental).
trigger
Try itSee late data drop and understand the trade-off

Change withWatermark("ts", "2 minutes") to "30 seconds" and re-run. Feed the same event batch with event_time 90 seconds in the past.

What happens: most events get dropped as late; the aggregation is missing rows. This is the exact bug that produced Uber's "surge pricing anomaly" incidents in 2018.

The lesson: watermark = business SLA. If your business tolerates 5-minute-late data, set 5-minute watermark. Don't cargo-cult a value.

💡 Hint · Change the watermark from ‘2 minutes’ to ‘30 seconds’ and feed the same events — you'll drop 10× more as ‘late’. Wider watermark = fewer drops but more state + longer emission delay.

(d) Production reality · 15 min

War story Netflix· 2020Keystone stream processing, trillions of events/day
🔥 What broke

A Flink job aggregating video-quality metrics used a 30-second watermark. During a global CDN incident, mobile events lagged 3 minutes behind desktop events. Watermark advanced based on desktop; mobile events arrived past watermark and were dropped. The affected metric under-reported mobile quality issues during the incident — the exact opposite of what SRE wanted.

🧯 The fix

Adopted per-source watermarks (min of per-CDN watermarks) and increased allowed lateness to 5 minutes. Added an alert on `numLateRecords` as a health metric. Never trust a global watermark on multi-source streams.

🎓 Lesson to steal
Watermarks are per-partition / per-source. If one source lags, either use conservative min-watermark or split the job by source. Also: alert on ‘events dropped by watermark’ — it should be near zero in healthy operation.
Post-mortem
War story Uberreported in ‘Marmaray’ / ‘Real-Time Exactly-Once Event Processing’ blog
🔥 What broke

Uber's payment-events Flink job used at-least-once with a downstream Kafka sink. On a checkpoint failure + restart, some events were re-emitted, appeared in the audit topic twice, and confused a downstream reconciliation job by ₹ thousands per day.

🧯 The fix

Enabled Flink's `EXACTLY_ONCE` checkpoint mode with 2PC Kafka sink (transactional producer). Also added downstream dedup by event_id as belt-and-suspenders. Duplicates went to zero; latency increased by ~500 ms because of the Kafka transaction coordination.

🎓 Lesson to steal
Exactly-once is a real feature with a real cost. Use where correctness matters (payments, audit); use at-least-once + idempotent sink elsewhere.
War story Common failure modeevery ‘let's stream everything’ project
🔥 What broke

Team ships a streaming aggregation with watermark set to the DEFAULT of 0. Every out-of-order event is dropped. Analytics dashboard slowly diverges from the batch source of truth. Nobody notices for months until finance reconciles.

🧯 The fix

(a) Always set an explicit watermark based on your producer's known delivery lag. (b) Emit ‘events dropped by watermark’ as a Prometheus metric. (c) Run a nightly batch reconciliation to catch drift.

🎓 Lesson to steal
Streaming without a batch reconciliation baseline is unauditable. The Netflix/Uber pattern: streaming for dashboards + hourly batch for source-of-truth aggregates.

Where this shows up in the rest of the plan

Stream processing is where every previous session lands
S050 · Airflow
Batch jobs orchestrated on top of streaming outputs.
S051 · dbt
dbt models over streaming-populated tables (Iceberg / Delta).
S054 · Data quality
Great Expectations on streaming outputs to catch drift.
S055 · CDC (Debezium)
CDC converts a database into an event stream this session consumes.
S099 · ML feature stores
Online features computed by streaming windows; offline by batch.
S121 · System design — fraud detection
Real-time scoring engine = stream processing + ML model + KV store lookups.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. Event time vs processing time — one bug example.
  2. What a watermark is — one sentence + one policy knob.
  3. Effectively-once vs exactly-once — when to pay for the real thing.

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.