Skip to main content

Late Data: Quantify the Silent Loss, Then Heal It

Level 8: Batch Pipelines & Orchestrationhard28 minlate-arriving dataevent time vs processing timesilent data lossanti-join auditinglateness distributionrolling window recomputeNOT EXISTS

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.

Table
Late is not the same as lost. 9007 was three hours late and still made it. 9005, 9009 and 9012 all crossed the watermark while in flight, and nothing raised an error.
event_idevent_ts (happened)ingested_at (arrived)vs watermark 06:00 on the 14thin fct_events?
900412th 13:5012th 13:52arrived long beforeyes
900512th 22:1514th 07:40arrived AFTER, event time belowno, silently dropped
900713th 11:0513th 14:053h late, still arrived beforeyes
900913th 23:4514th 09:15arrived AFTER, event time belowno, silently dropped
901214th 05:2014th 11:50arrived AFTER, event time belowno, silently dropped
901314th 09:3514th 09:38event time above, not yet dueno, correctly
Late is not the same as lost. 9007 was three hours late and still made it. 9005, 9009 and 9012 all crossed the watermark while in flight, and nothing raised an error.

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.

Sample data for this example
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);
Worked example (SQL)
-- 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.