Late Data: Quantify the Silent Loss, Then Heal It
Measure exactly how many rows a strict watermark load dropped without raising an error, then repair the pipeline with a rolling reprocessing window that self-heals.
This lesson is not about the fix
You already know the fix. sql-l4-idempotent-merge told you a strict high-water mark drops late rows and that a lookback catches them, and the previous lesson restated it. What no lesson has made you do is the part an on-call engineer actually gets paged for: finding the damage and putting a number on it. An unquantified fix is a guess, and a guess is not something you write in an incident report.
Two clocks, and only one of them is yours
Every event row carries two timestamps and they are not the same thing.
- Event time is when the thing happened. A payment at 02:00 has an event time of 02:00 forever.
- Processing time is when your loader could first see the row. That is a property of the network, the queue, the mobile client that was offline, and the source system's commit order.
Event time versus processing time is the single most transferable idea in streaming, and it bites batch first. Here is the whole failure in one line: a row events at 22:15 on the 12th and arrives at 07:40 on the 14th, by which point the 06:00 load has already advanced the watermark past it. The next run filters on event_ts > watermark, the row is below the boundary, and it is skipped. Forever. No error, no retry, no alert. The run is green.
| event_id | event_ts (happened) | ingested_at (arrived) | vs watermark 06:00 on the 14th | in fct_events? |
|---|---|---|---|---|
| 9004 | 12th 13:50 | 12th 13:52 | arrived long before | yes |
| 9005 | 12th 22:15 | 14th 07:40 | arrived AFTER, event time below | no, silently dropped |
| 9007 | 13th 11:05 | 13th 14:05 | 3h late, still arrived before | yes |
| 9009 | 13th 23:45 | 14th 09:15 | arrived AFTER, event time below | no, silently dropped |
| 9012 | 14th 05:20 | 14th 11:50 | arrived AFTER, event time below | no, silently dropped |
| 9013 | 14th 09:35 | 14th 09:38 | event time above, not yet due | no, correctly |
Read that last column carefully. Row 9013 is also missing from the fact, and that is correct: its event time is above the watermark, so the next run will pick it up. Only rows whose event time is at or below the watermark and that never landed are losses. Your audit has to make that distinction or it will report a number that is not real.
The audit is an anti-join
The query is small and you should be able to write it from memory:
SELECT e.event_id, e.event_ts, e.ingested_at
FROM source_events e
WHERE e.event_ts <= (SELECT last_value FROM etl_watermarks WHERE pipeline = 'events_incremental')
AND NOT EXISTS (SELECT 1 FROM fct_events f WHERE f.event_id = e.event_id)
Below the boundary, absent from the target. That count, and the revenue attached to it, is the number that goes in the write-up. Add the arrival delay per row and you also get the input for the next decision: how wide the healing window has to be.
Then heal it with a rolling window
The production fix is a bounded rolling reprocessing window: every run rebuilds the last few days (three is a common default) from source instead of only the slice above the watermark. Late arrivals then self-heal on the next run, because the day they belong to is rebuilt anyway.
That only works because the write is idempotent. Reprocessing the same three days every single run would triple your data with an append; with partition replacement (delete the day, reinsert the day) it converges to the same rows no matter how many times it runs. Module 8.2 is the prerequisite for this lesson, and this is where you cash it in.
Two things a strong answer adds. First, the window is bounded: three days, not the whole table, because cost is a design constraint and an unbounded recompute is how a nightly job becomes a six hour job. Second, the downstream aggregate has to be rebuilt in the same script, because a repaired fact under a stale total is still a wrong dashboard.
Common mistake: counting every row missing from the target as a loss. Rows whose event time is above the watermark are not lost, they are simply not due yet, and including them inflates the incident number and destroys your credibility in the review.
Interview nuance: "how do you handle late-arriving data" is a stock question, and almost every candidate jumps straight to "I would add a lookback window." The stronger answer is two steps: first quantify what was already lost with an anti-join below the watermark, then size the window from the observed arrival delays rather than picking three days because it sounds right. Naming the measurement before the remedy is the difference between an engineer and someone reciting a pattern.
On a real platform this differs. Streaming engines make this tradeoff explicit rather than accidental, but not to the same degree. Flink states it fully with a watermark plus an allowed lateness, and events past the allowance can be routed to a side output instead of vanishing. Spark Structured Streaming gives you only the watermark delay threshold in
withWatermark, and anything later than that threshold is dropped with no error and no side channel, which is much closer to the batch failure you just audited. Batch has the same tradeoff with none of the instrumentation, which is why you have to build the audit yourself.
CREATE TABLE etl_watermarks (
pipeline TEXT PRIMARY KEY,
last_value TEXT
);
INSERT INTO etl_watermarks (pipeline, last_value) VALUES
('events_incremental', '2026-03-14 06:00:00');
CREATE TABLE source_events (
event_id INTEGER,
event_ts TEXT, -- event time: when the thing actually happened
ingested_at TEXT, -- processing time: when the loader could first see the row
user_id INTEGER,
amount_usd REAL
);
INSERT INTO source_events (event_id, event_ts, ingested_at, user_id, amount_usd) VALUES
(9001, '2026-03-11 08:10:00', '2026-03-11 08:12:00', 501, 42.00),
(9002, '2026-03-11 19:40:00', '2026-03-11 19:43:00', 502, 88.50),
(9003, '2026-03-12 07:25:00', '2026-03-12 07:28:00', 503, 120.00),
(9004, '2026-03-12 13:50:00', '2026-03-12 13:52:00', 504, 65.25),
-- late AND lost: happened on the 12th, arrived after the watermark had moved past it
(9005, '2026-03-12 22:15:00', '2026-03-14 07:40:00', 505, 149.00),
(9006, '2026-03-13 06:30:00', '2026-03-13 06:33:00', 506, 210.75),
-- late but NOT lost: three hours behind, still inside the window the loader read
(9007, '2026-03-13 11:05:00', '2026-03-13 14:05:00', 507, 33.40),
(9008, '2026-03-13 18:20:00', '2026-03-13 18:24:00', 508, 97.60),
(9009, '2026-03-13 23:45:00', '2026-03-14 09:15:00', 509, 175.20),
(9010, '2026-03-14 01:10:00', '2026-03-14 01:14:00', 510, 58.00),
(9011, '2026-03-14 03:55:00', '2026-03-14 03:58:00', 511, 143.30),
(9012, '2026-03-14 05:20:00', '2026-03-14 11:50:00', 512, 82.90),
-- not late, simply not yet due: event time is above the watermark
(9013, '2026-03-14 09:35:00', '2026-03-14 09:38:00', 513, 66.45),
(9014, '2026-03-14 14:00:00', '2026-03-14 14:05:00', 514, 191.80);
CREATE TABLE fct_events (
event_id INTEGER PRIMARY KEY,
dt TEXT, -- the event-time date this row is partitioned under
event_ts TEXT,
user_id INTEGER,
amount_usd REAL
);
INSERT INTO fct_events (event_id, dt, event_ts, user_id, amount_usd) VALUES
-- older than the source's retention window: this partition exists only here now
(9000, '2026-03-10', '2026-03-10 12:00:00', 500, 55.00),
(9001, '2026-03-11', '2026-03-11 08:10:00', 501, 42.00),
(9002, '2026-03-11', '2026-03-11 19:40:00', 502, 88.50),
(9003, '2026-03-12', '2026-03-12 07:25:00', 503, 120.00),
(9004, '2026-03-12', '2026-03-12 13:50:00', 504, 65.25),
(9006, '2026-03-13', '2026-03-13 06:30:00', 506, 210.75),
(9007, '2026-03-13', '2026-03-13 11:05:00', 507, 33.40),
(9008, '2026-03-13', '2026-03-13 18:20:00', 508, 97.60),
(9010, '2026-03-14', '2026-03-14 01:10:00', 510, 58.00),
(9011, '2026-03-14', '2026-03-14 03:55:00', 511, 143.30);
CREATE TABLE fct_daily_totals (
dt TEXT PRIMARY KEY,
events INTEGER,
revenue_usd REAL
);
INSERT INTO fct_daily_totals (dt, events, revenue_usd) VALUES
('2026-03-10', 1, 55.00),
('2026-03-11', 2, 130.50),
('2026-03-12', 2, 185.25),
('2026-03-13', 3, 341.75),
('2026-03-14', 2, 201.30);-- The two clocks side by side, and what the strict watermark did with each row.
SELECT e.event_id, e.event_ts, e.ingested_at,
CASE
WHEN e.event_ts > (SELECT last_value FROM etl_watermarks WHERE pipeline = 'events_incremental')
THEN 'not due yet (event time above the watermark)'
WHEN EXISTS (SELECT 1 FROM fct_events f WHERE f.event_id = e.event_id)
THEN 'loaded'
ELSE 'SILENTLY DROPPED (arrived after the watermark moved)'
END AS load_outcome
FROM source_events e
ORDER BY e.event_ts;Apply
Your turn
The task this lesson builds to.
Write a query that returns the rows the strict watermark load silently dropped, as (event_id, event_ts, ingested_at, minutes_late), oldest event first, over source_events, etl_watermarks, and fct_events.
A row was dropped when its event_ts is at or below the events_incremental last_value and no row with that event_id exists in fct_events. Rows whose event_ts is above the watermark are not losses, they are simply not due yet, so leave them out. Report minutes_late as the whole minutes between event_ts and ingested_at, and alias the columns exactly.
3 hints and 1 automated check are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, plus 2 bonus drills.
Write a script that repairs fct_events and fct_daily_totals by reprocessing the last 3 days from source_events with partition replacement, healing the late rows, and that is safe to rerun.
The window is the watermark's date and the two days before it, so dt >= date(last_value, '-2 days') for the events_incremental row. Replace those partitions of fct_events(event_id, dt, event_ts, user_id, amount_usd) from source_events, partitioning on the event-time date, then rebuild fct_daily_totals(dt, events, revenue_usd) for the same window from the repaired fact. Days outside the window must be left exactly as they are, and that is not a formality: source_events has a retention window and the fact does not, so an older partition the source can no longer reproduce is sitting in fct_events and a rebuild that is not bounded will delete it. The grader runs your script twice and compares the contents of both tables.
1 hint and 7 automated checks are waiting in the workspace.