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.
🎯 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.
- 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
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."
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
- Event time = when the event happened at the source. The only useful notion of time for correctness.
- Watermark = monotonic assertion of ‘all events \< 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
- 2010Apache Storm at TwitterFirst open-source stream processor. At-least-once, tuples, no event-time semantics.
- 2013Apache Flink at TU BerlinFirst engine with proper event-time watermarks and stateful exactly-once.
- 2014Spark Streaming (DStreams)Micro-batch on RDDs. Popular, but weak event-time story.
- 2015Google Dataflow paper (Akidau et al.)‘The Dataflow Model’ unifies batch + streaming with windows + triggers + watermarks. Becomes Apache Beam.
- 2016Spark Structured StreamingRewrite on top of DataFrames + Catalyst. Finally competitive on event-time semantics.
- 2020Flink 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
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
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
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
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
enable.idempotence=true + transactional.id. Kafka assigns a producer ID and sequence number; duplicates are silently dropped.
COMMIT_TXN marker written to every topic-partition. Either all committed, or none.
Only reads committed transactions; uncommitted messages hidden. Standard for streaming apps.
Flink/Spark checkpoints operator state + input offsets atomically. On restart, restore state + offsets together.
Postgres upsert (idempotent) OR 2PC sink (transactional). Without this, exactly-once falls back to at-least-once at the boundary.
"My stream processor gives exactly-once semantics, so every event is delivered and processed exactly once end to end."
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.
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.
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.Why does a streaming join between two streams need state, a window and a retention policy, when a batch join needs none of these?
- 1A 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
- 2In 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
- 3In 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
- 4So 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
- 5But 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
- 6Therefore 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 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.
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.
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.
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?
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
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.
(d) Production reality · 15 min
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.
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.
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.
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.
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.
(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.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- Event time vs processing time — one bug example.
- What a watermark is — one sentence + one policy knob.
- 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.