Definition
Handling late data and reprocessing involves several cooperating mechanisms.
- Event-time vs processing-time – Event-time is when the event happened (carried in the record); processing-time is when the engine sees it. Correct streaming uses event-time.
- Watermarks – Moving markers that estimate how far event-time has progressed, used to decide when a window can be finalized.
- Allowed lateness – A grace period after the watermark during which stragglers can still update an already-fired window.
- Windowing – Tumbling, sliding, and session windows bucket an infinite stream into finite computations.
- Reprocessing – Replaying events from the durable log or object store into backfill jobs with idempotent or exactly-once writes.
The goal is correct results despite disorder and safe, reproducible recovery after failures.
Imagine the National Bank of India tallies card spend minute by minute, but some slips arrive late from a branch with poor network. Processing-time tallying would wrongly count a 10:00 slip in the 10:05 minute just because it arrived late. Event-time tallying reads the real time stamped on the slip and files it in the correct minute. A watermark is the supervisor saying "I am now fairly sure all slips up to 10:03 have arrived." Allowed lateness is a short grace period to still accept a straggler from 10:02. And if the bank finds a mistake, it re-feeds the stored slips through the counter again with idempotent filing so nothing is double-counted.
Understanding the Concepts
What is the difference between event-time and processing-time?
Event-time is when the event actually happened, carried inside the record. Processing-time is when the engine happens to see it. In the real world, events are delayed and arrive out of order because of network hiccups, retries, and devices going offline.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, window, sum as _sum
spark = SparkSession.builder.appName("BankEventTime").getOrCreate()
# Each transaction carries its event-time (txn_timestamp)
txns = spark.readStream.format("kafka") \
.option("subscribe", "bank-transactions") \
.load()
parsed = txns.selectExpr(
"CAST(value AS STRING)",
"CAST(timestamp AS TIMESTAMP) AS kafka_time" # processing-time
).select(
col("value").alias("raw"),
col("kafka_time"),
# Event-time from the record itself
col("value").getField("txn_timestamp").cast("timestamp").alias("event_time")
)
# Using event-time ensures a transaction stamped 10:00
# is counted in the 10:00 window even if it arrives at 10:04
How do watermarks work?
A watermark is a moving marker that flows with the stream and asserts: "I believe I have now seen all events with event-time up to T." It is usually derived as the maximum observed event-time minus a bounded-lateness allowance.
# Spark Structured Streaming: 5-second watermark
watermarked = parsed.withWatermark("event_time", "5 seconds")
# Flink equivalent:
# stream.assignTimestampsAndWatermarks(
# WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(5))
# )
Stream of events (event-time → arrival): 10:00:01 → arrived at 10:00:03 10:00:02 → arrived at 10:00:05 (out of order) 10:00:04 → arrived at 10:00:04 Watermark (5s lateness): After seeing 10:00:04 → watermark = 10:00:04 - 5s = 09:59:59 After seeing 10:00:10 → watermark = 10:00:05 Window [10:00:00, 10:01:00) fires when watermark passes 10:01:00
What is allowed lateness?
Allowed lateness is a grace period after the watermark during which a straggler can still update an already-fired window. After the grace period, very late events are dropped or routed to a side output.
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
// Flink: 1-minute tumbling windows with 30-second allowed lateness
DataStream<TransactionTotal> totals = txnStream
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Transaction>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((txn, ts) -> txn.getTimestamp()))
.keyBy(Transaction::getAccountId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.allowedLateness(Time.seconds(30))
.sideOutputLateData(lateOutputTag)
.sum("amount");
// Late events arriving within 30s after window fires: update result
// Late events arriving after 30s: routed to side output for investigation
What are the window types?
Windowing bounds an infinite stream into finite computations. All window types key off event-time so late data lands in the correct window.
from pyspark.sql.functions import window, sum as _sum
# Tumbling window: fixed, non-overlapping 1-minute buckets
tumbling = watermarked.groupBy(
window("event_time", "1 minute"),
"branch_code"
).agg(_sum("amount").alias("total_amount"))
# Sliding window: 1-minute window every 10 seconds (overlapping)
sliding = watermarked.groupBy(
window("event_time", "1 minute", "10 seconds"),
"branch_code"
).agg(_sum("amount").alias("rolling_total"))
# Session window: group bursts separated by 5-minute gaps
# (Flink natively supports session windows)
Tumbling (1 min): |--10:00--|--10:01--|--10:02--|
Sliding (1 min/10s): |--10:00--|
|--10:00:10--|
|--10:00:20--|
Session (5 min gap): |--burst1--| gap |--burst2--|
How do you handle very late data?
Events that arrive after the allowed lateness are dropped by the engine. You can route them to a side output for separate handling or reconciliation.
// Flink: capture very late data in a side output
OutputTag<Transaction> lateOutputTag = new OutputTag<>("late-transactions") {};
SingleOutputStreamOperator<TransactionTotal> result = txnStream
.keyBy(Transaction::getAccountId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.allowedLateness(Time.seconds(30))
.sideOutputLateData(lateOutputTag)
.sum("amount");
// Process late data separately
DataStream<Transaction> lateData = result.getSideOutput(lateOutputTag);
lateData.addSink(new LateDataSink()); // Write to DLQ or reconciliation table
How do you reprocess historical data?
Because the durable log (Kafka) and the lake (object store) retain events, you reprocess by replaying from a chosen offset or re-reading stored Parquet, then running a backfill job to recompute results for a past period.
# Replay from Kafka at a specific offset for backfill
backfill = spark.read.format("kafka") \
.option("subscribe", "bank-transactions") \
.option("startingOffsets", '{"bank-transactions":{"0":1000,"1":2000}}') \
.option("endingOffsets", '{"bank-transactions":{"0":5000,"1":6000}}') \
.load()
# Or replay from the lake
backfill_lake = spark.read.parquet("/data/bank/raw/transactions/date=2025-06-*")
# Recompute and write with idempotent upsert
backfill_lake.groupBy("account_id", "txn_date").agg(_sum("amount").alias("total")) \
.write.format("delta").mode("overwrite") \
.option("replaceWhere", "txn_date >= '2025-06-01' AND txn_date < '2025-07-01'") \
.save("/data/bank/gold/account_daily_totals")
Why must reprocessing be idempotent?
Idempotency means re-running the same input produces the same result with no side effects. This is achieved with upserts keyed by a stable event ID, so replaying the same events overwrites rather than duplicates.
-- Idempotent upsert in Delta Lake
MERGE INTO gold.account_daily_totals AS target
USING backfill_results AS source
ON target.account_id = source.account_id AND target.txn_date = source.txn_date
WHEN MATCHED THEN UPDATE SET total = source.total
WHEN NOT MATCHED THEN INSERT (account_id, txn_date, total) VALUES (source.account_id, source.txn_date, source.total);
-- Replay produces the same result: no double-counting
What should a senior engineer know?
- Keep windowing logic deterministic and version your transformations so a backfill months later reproduces consistent results. At a bank, corrected figures may feed regulatory resubmissions and must be reproducible.
- The watermark strategy is a tuning decision: too tight drops late data, too loose delays results.
- Session windows are useful for user interaction patterns but complex to implement correctly.
- Spark Structured Streaming and Flink handle watermarks differently: Spark uses a global watermark across all partitions, while Flink uses per-partition watermarks.
# Version your transformation logic for reproducible backfills
TRANSFORM_VERSION = "v2.1"
def compute_daily_totals(df):
"""Deterministic transformation for bank daily totals."""
return df.groupBy("account_id", "txn_date") \
.agg(_sum("amount").alias("total")) \
.withColumn("transform_version", lit(TRANSFORM_VERSION))
Visual Overview
Event-Time vs Processing-Time:
Real world: 10:00 10:01 10:02 10:03 10:04
Event occurs: TXN-A TXN-B TXN-C
Arrives at: 10:01 10:04 10:02
│ │ │
Processing-time: │ │ │
Window [10:01]: TXN-A │ │
Window [10:02]: │ TXN-C
Window [10:04]: TXN-B ← WRONG bucket!
│ │ │
Event-time: │ │ │
Window [10:00]: TXN-A │ │
Window [10:01]: TXN-B │ ← CORRECT bucket
Window [10:02]: TXN-C
Watermark Flow:
Time ──────────────────────────────────►
Events: [10:00] [10:02] [10:01] [10:04]
Max seen: 10:00 10:02 10:02 10:04
Watermark: 9:55 9:57 9:57 9:59 (5s lateness)
│ │ │
│ │ └─ Window [9:59,10:00) can fire
│ └─ Window [9:57,9:58) can fire
└─ Too early to fire any window
Key Takeaway
Handling late and out-of-order data starts with computing on event-time instead of processing-time. Event-time is when the event actually happened, and processing-time is just when the engine saw it. Because real-world events are delayed and arrive out of order, aggregating on processing-time puts them in the wrong buckets. Watermarks are the mechanism that decides when a time period is complete: they are moving markers asserting the stream has probably seen everything up to a given time.
When the watermark passes the end of a window, the engine finalizes it, and allowed lateness provides a grace period during which stragglers can still update an already-fired window. Windows themselves bound the infinite stream, whether tumbling, sliding, or session, and they all key off event-time so late data lands correctly.
Reprocessing is the other half. Because the durable log and the lake retain events, you replay from a chosen offset or re-read stored Parquet and run a backfill job. The non-negotiable requirement is idempotency: upserts keyed by a stable event ID ensure re-running neither loses nor double-applies events. At the National Bank of India, this mattered for per-minute card-spend aggregates where swipes arrived out of order from branches with poor connectivity.
Keep windowing logic deterministic and version your transformations so backfills months later reproduce consistent results. In a bank, corrected figures feed regulatory resubmissions and must be auditable.
Remember one sentence: use event-time with watermarks and allowed lateness for correct windows, and replay with idempotent writes for safe reprocessing.
---