learn-data-reliability-with-phoebe / Builder session 5 of 8
Learn Data Reliability with Phoebe · Builder session 5 of 8

Backfills without breaking downstream

Session 4's runbook ends with "the source is answering again, open the backfill runbook". Tonight is that runbook. A backfill is a rerun, and a rerun is dangerous in two ways that have nothing to do with each other: it can double the rows it was meant to replace, and it can quietly change a number somebody has already signed. Harborlight's night 15 is the case for both. Six partitions landed on time with a currency column parsed wrong, the settlement report was signed against them at 07:00, and the recompute that fixed them moved a signed total by twelve thousand.

🟠 Advanced Data and analytics engineers 45 min
0-3 · Welcome 3-20 · Idempotency, watermarks and the signed number 20-40 · Build-along: backfill night 15 safely 40-45 · Q&A
Part 0

Where tonight sits in the builder track

Eight sessions, one artifact: a reliability contract for one pipeline. Sessions 1 to 3 measured, promised and alerted; session 4 wrote the page the on-call follows. Three of Harborlight's five incidents left wrong or missing data in the table after the night was over, and the contract needs a backfill procedure that repairs it without doubling rows and without changing a signed number in silence. Session 6 writes the incident package that records what the backfill changed.

Live = presented in the 45 minutes Self-study = the full depth, after class 🔍 Every diagram zooms

★ What you walk out with tonight. A partition step that is safe to run twice, written as delete-then-insert keyed on partition_hour inside one transaction. A watermark that records how far the pipeline has correctly processed, and the move that sends it back to the first bad partition. A consumers table the backfill job reads so the notification is mechanical. And the sequence for night 15 in the order it has to happen: identify, announce, recompute, re-trigger downstream in dependency order, compare the totals, file the diff.

LiveWhy a rerun is the most dangerous thing a pipeline does3 min▶

A pipeline's normal run touches each partition once. A backfill touches partitions that have already been touched, that downstream jobs have already read, and that people have already made decisions from. Every assumption the normal run gets for free - the partition is empty, nobody has read it, the number can still change - is false during a backfill. The two failures that follow are different in kind.

The first is mechanical: a step that appends rows will append them again, and the partition now has twice the orders. The second is human: a step that replaces rows correctly will correctly replace a total that the finance director signed at 07:00, and nobody will know until the two versions of the report are side by side in a dispute. The first failure is fixed with a delete. The second is fixed with a message sent before the delete.

Real world

Harborlight's previous backfill, months before night 15, reran a week of partitions to fix a timezone bug. The step appended. Seven days of orders were doubled, the settlement totals for the week doubled with them, and the first person to notice was a merchant whose payout arrived twice. The recompute was correct; the rerun was not idempotent. Every rule tonight is downstream of that week.

Self-studyThe vocabulary tonight adds3 min▶
  • Idempotent - running the step twice leaves the table exactly as running it once would. For a partition step: the partition's output is replaced, never added to.
  • Partition overwrite - the idempotent shape: delete the partition's rows, insert the recomputed ones, both keyed on partition_hour, inside one transaction so a reader never sees the gap.
  • Watermark - the pipeline's record of how far it has correctly processed. A single timestamp per pipeline; every partition at or before it is trusted.
  • Backfill - moving the watermark back to the hour before the first bad partition and running the idempotent step forward to the last bad one.
  • Signed number - a total that a named person has approved and others have acted on. It can still be corrected; it cannot be corrected silently.
  • Consumers table - a record of which reports, exports and models read which partitions, so the backfill job can list who to tell and what to re-run.
  • Replay - re-triggering the consumers of the corrected partitions in dependency order, so a report that feeds an export is rebuilt before the export is.
  • Backfill log - one row per backfill: the partitions, the total before, the total after, when it was announced, when it ran, who ran it.
Part 1 · covers idempotency and watermarks

A backfill is a rerun that must not double anything

8 min live

Two properties make a rerun safe, and they are properties of the pipeline, not of the person running it. The step must replace its own output exactly, so running it twice equals running it once. The watermark must say how far the output is correct, so a backfill knows where to start and does not reprocess a month to fix six hours.

The same partition, run twice append: the rows double night 15 · hour 05 run 1 · 4,120 rows run 2 · 8,240 rows insert into, then select: run it twice and the settlement total doubles with the rows. overwrite keyed on partition_hour: the rows are replaced night 15 · hour 05 run 1 · 4,120 rows run 2 · 4,120 rows delete where partition_hour = 05, then insert, in one transaction. Twice equals once. Night 15 · the watermark moves back to the first bad partition 00 05 10 15 20 23 before: processed_through 23:00 after: 04:00, the hour before the first bad partition Six partitions recompute, hours 05 to 10. Nothing from hour 00 is touched, and nothing after 10 needs to be. Run the overwrite twice and the count is 4,120 both times. That is the whole test of idempotency.
🔍 Click to zoom - append against overwrite for one partition, and the watermark moving back to hour 05.
LiveIdempotency: a partition rerun replaces its own output exactly3 min▶

The test is one sentence: run the step twice and the table is identical to running it once. For a partitioned table the only shape that passes is replace the partition: delete every row whose partition_hour matches, insert the recomputed rows for that hour, and do both inside one transaction so no reader ever sees an hour with zero rows. Platforms that offer insert overwrite partition do the same thing in one statement; the guarantee is the same.

✗ The step that doubles insert into fact.orders (partition_hour, order_id, amount_ccy, amount_base) select partition_hour, order_id, amount_ccy, amount_base from staging.orders_parsed where partition_hour = timestamp '2026-08-15 05:00'; -- run once: 4,120 rows for hour 05. run twice: 8,240. The settlement total doubles with it.
★ The step that replaces begin; delete from fact.orders where partition_hour = timestamp '2026-08-15 05:00'; insert into fact.orders (partition_hour, order_id, amount_ccy, amount_base) select partition_hour, order_id, amount_ccy, amount_base from staging.orders_parsed where partition_hour = timestamp '2026-08-15 05:00'; commit; -- run once: 4,120 rows. run twice: 4,120 rows. Run it a hundred times: 4,120 rows.

Never append during a rerun, and the cleanest way to guarantee that is to never append at all: if the normal nightly step is the same delete-then-insert, a backfill is the normal step pointed at an old hour. One code path, tested every night, is the code path that runs when it matters.

LiveWatermarks: how far the pipeline has correctly processed3 min▶

A watermark is one timestamp per pipeline, processed_through, and its meaning is strict: every partition at or before it is correct. The nightly run reads it, processes the hours after it, and advances it. A backfill is the same run with the watermark first moved back to the hour before the first bad partition. For night 15 that is 04:00: hours 05 to 10 recompute, hours 00 to 04 are never touched, and the six hours after 10 were already right.

  • Back to the first bad partition, not to the start. The temptation is to "rerun the month to be safe". Idempotency makes that harmless to the table and expensive to everything else: 720 partitions of compute, 720 partitions of downstream re-reads, and a signed report for every one of the thirty days now changing under its readers. Six hours is six hours.
  • The watermark is also a signal. Downstream jobs that process "everything since the watermark" see it move back and know that range is being rewritten. Moving it is how the pipeline tells the truth about what it currently trusts.
  • Night 9 needed no reset. Fourteen partitions never landed, so the watermark never advanced past night 8 at 23:00. When the source came back the normal run started from the watermark and caught up. That is the watermark doing its job with nobody touching it.
LiveThree incidents needed a backfill, and each needed something different2 min▶
IncidentBad partitionsWhat was wrong in the tableWhat the backfill needed
Night 15 · corrupt batch6, hours 05 to 10Landed on time, currency column parsed as text, amount_base wrongRecompute the six with the fixed currency parsing, after announcing, because the report was signed against them
Night 27 · partial load4Landed on time with two of three shardsRerun the four once the missing shard was available; the watermark had advanced past them, so it moves back
Night 9 · upstream outage14Never landedRecompute the fourteen once the source was back, and tell everybody who read the report stale that the numbers are now real

Nights 4 and 21 needed none. The late load and the flapping connector delivered their rows late, and the connector's retries completed the short partitions before morning; the table ended those nights whole and only the reader's wait was counted. A backfill is for wrong or missing data, not for a bad indicator.

Part 2 · covers the signed number and the replay

The downstream has already read the wrong numbers

9 min live

By the time a backfill runs, the bad partitions have been read. Reports were built from them, exports were sent, a model was trained, and on night 15 a settlement total was signed. Correcting the table is the easy half. The hard half is that every one of those readers now holds a number that is about to be wrong, and some of them acted on it.

Night 15's backfill, in the order it has to happen 1 · identify reconciled = false 6 partitions, 05 to 10 2 · announce old total, who read it, when it will run signed-number gate no recompute before the announcement 3 · recompute delete then insert keyed on the hour 4 · re-trigger downstream, in dependency order 5 · compare signed total against the new total 6 · file the diff backfill_log: old, new, announced_at 07:00 report signed against six corrupt partitions · 09:50 finance's control total disagrees · 11:40 announced 12:15 recompute, 6 partitions · 12:40 settlement report re-run, then payout export and forecast · 13:05 totals compared filed: total_before 1,284,310.55 (the signed figure) · total_after 1,271,902.10 · diff -12,408.45 · announced 11:40 A signed number that changes without an announcement is a governance event nobody agreed to. Announce first.
🔍 Click to zoom - the backfill sequence as a strip, with the signed-number gate between announce and recompute.
LiveThe signed number: a backfill that changes it is a governance event3 min▶

At 07:00 on the morning after night 15 the settlement duty lead signed a total of 1,284,310.55. Six of the partitions behind it had a currency column that arrived as text, and the base-currency amount for those rows was wrong. The recompute produced 1,271,902.10. Both numbers are the output of the same pipeline; only one of them has a signature on it, and merchants were paid against it.

A backfill that silently replaces a signed total has not fixed a bug. It has created two versions of a document that people act on, with no record of which one anybody saw. The rule is that a backfill touching partitions a signed report read is announced before it runs: to whom the report was signed by, with the old total, the expected direction of the change, and the time it will run. Then it runs. Then the old and new totals are written down next to each other, with the announcement time, in a place that outlives the chat thread.

Real world

Finance's own spreadsheet caught night 15 at 09:50, when its control total disagreed with the signed report. The question they asked first was not "how do we fix it"; it was "what did we pay against". A backfill log with the signed figure, the corrected figure and the announcement time is the answer to that question, and it is one insert statement.

LiveConsumers are a table the job reads, not a list somebody remembers3 min▶

"Who read these partitions" is a question with a changing answer. A new export is added, a model starts reading the table, a report is retired. If the backfill's notification depends on the engineer remembering the list, the list is wrong within a quarter and the newest consumer is the one nobody tells.

The fix is a consumers table: one row per downstream job that reads the orders table, with its kind, its owner, its dependency rank and the command that re-triggers it, plus a read log that records which partitions it consumed and when. The backfill job joins its target partitions to the read log and gets the list mechanically. On night 15 that list was three rows: the settlement report, the payout export that depends on it, and the demand forecast. Each row carries the person to tell and the order to run in, and the announcement in step 2 is generated from it rather than composed.

LiveThe replay order: partitions, then downstream in dependency order, then compare3 min▶
  • Backfill the partitions first. All six, inside the transaction, so no downstream job is re-triggered against a half-corrected range. The partition log rows get a backfilled_at stamp; reconciled stays false, because the month's record is what the reader saw that morning and the 35 bad partitions stay 35.
  • Re-trigger downstream in dependency order. Rank 1 before rank 2. The settlement report rebuilds first; the payout export reads the rebuilt report and so runs after it; the forecast reads the table directly and can run alongside the export. Running the export before the report re-sends the wrong payouts with a fresh timestamp.
  • Compare the totals last. The signed figure against the figure the rebuilt report now shows, for exactly the same partitions. The diff is the size of what the readers were told wrong, and it goes in the log next to both totals.
  • File the diff. One row in the backfill log: partitions, total before, total after, announced at, ran at, who ran it. The incident package in session 6 quotes that row; nobody reconstructs it from memory.
Self-studyThe honest cost: compute, attention and where it gets budgeted2 min▶
  • A backfill spends compute. Six partitions recomputed, three downstream jobs re-run, one of them a model. Night 9's fourteen partitions were more than half a night's work redone in daylight, on the same warehouse the daytime analysts use.
  • A backfill spends attention. The announcement, the reply from finance, the merchant who asks why the payout moved. None of that is on a dashboard and all of it is real.
  • A backfill spends trust, once. A signed number that moves with an announcement is a correction. One that moves without it is the last time that report is trusted without a second spreadsheet beside it.
  • The cost belongs in the budget conversation. The error budget from session 3 counts the bad partitions; it does not yet count what repairing them cost. Putting that cost next to the budget, and deciding how much of a quarter's engineering time backfills may consume before the pipeline itself gets fixed, is a leadership decision, and the leader track's session 5 is where it is made.
Build-along

Backfill night 15 safely

★ 20 min · everyone builds

Use Harborlight's partition log, the orders fact table and the consumers table. The steps run in the order the strip shows: identify, announce, recompute, re-trigger, compare, file. The announcement comes before the recompute on purpose, and the before-total is captured when the announcement goes out, because after the delete it no longer exists anywhere except the signed report.

Identify the bad partitions from the partition log. Night 15, reconciled = false. Put the hours in a targets table so every later step reads the same list. Harborlight: six rows, hours 05 to 10, all on time, all three shards.

List the consumers and announce, capturing the before-total. Join the targets to the read log to get who consumed them, in dependency order. Insert the backfill log row now with total_before and announced_at. Send the announcement generated from those rows. Do not proceed until it has gone.

Write the idempotent recompute. Delete-then-insert keyed on partition_hour, inside one transaction, with the fixed currency parsing. Run it. Count the rows. Run it again. Count again: the same.

Reset the watermark to the hour before the first bad partition. 04:00 for night 15. The scheduler's next tick runs the same step forward from there and re-advances the watermark; tonight you have already run it by hand, and idempotency is why doing both is harmless.

Re-trigger downstream in dependency order. Rank 1, wait, rank 2. The commands come from the consumers table, not from memory.

Compute the settlement total after, compare with the signed figure, and complete the log row. total_after, finished_at, and the diff. The row is what session 6 quotes.

★ Step 1 · identify the bad partitions create temp table backfill_targets as select partition_hour, row_count, reconciled, shard_count, landed_at from ops.orders_partition_log where partition_hour >= timestamp '2026-08-15 00:00' and partition_hour < timestamp '2026-08-16 00:00' and not reconciled order by partition_hour; -- night 15: 6 rows · hours 05, 06, 07, 08, 09, 10 · every one landed inside its hour with shard_count 3 · reconciled false -- on time and wrong: freshness and coverage passed, correctness is what caught them
★ Step 2 · the consumers who read them, and the announcement row -- who consumed the bad partitions, in the order they must be re-run select c.consumer, c.kind, c.owner, c.dependency_rank, c.trigger_command, count(distinct r.partition_hour) as bad_partitions_read from ops.consumers c join ops.partition_reads r on r.consumer = c.consumer and r.pipeline = 'orders' where r.partition_hour in (select partition_hour from backfill_targets) group by 1, 2, 3, 4, 5 order by c.dependency_rank, c.consumer; -- night 15: settlement_report (report, finance duty lead, rank 1, 6 read) -- payout_export (export, treasury, rank 2, 6 read) depends on settlement_report -- demand_forecast (model, planning, rank 2, 6 read) -- the log row goes in now, with the total as it stands before anything is deleted insert into ops.backfill_log (backfill_id, pipeline, partitions, total_before, announced_at, announced_to, run_by) select 'bf-2026-08-16-orders', 'orders', (select array_agg(partition_hour) from backfill_targets), (select sum(amount_base) from fact.orders where partition_hour in (select partition_hour from backfill_targets)), current_timestamp, 'finance duty lead, treasury, planning', current_user; -- total_before 1,284,310.55 · equals the signed settlement figure for night 15 · announced_at 11:40 -- the announcement carries: the six hours, the signed total, "expected to fall", and "runs at 12:15"
★ Step 3 · the idempotent recompute, inside one transaction begin; delete from fact.orders where partition_hour in (select partition_hour from backfill_targets); insert into fact.orders (partition_hour, order_id, amount_ccy, currency, amount_base) select r.partition_hour, r.order_id, try_cast(r.amount_raw as decimal(18, 2)) as amount_ccy, -- the fixed parsing: numeric, not text r.currency, try_cast(r.amount_raw as decimal(18, 2)) * fx.rate_to_base as amount_base from raw.orders r join ref.fx_rates fx on fx.currency = r.currency and fx.rate_date = date(r.partition_hour) where r.partition_hour in (select partition_hour from backfill_targets); update ops.orders_partition_log set backfilled_at = current_timestamp -- reconciled stays false: the month's record is what the reader saw where partition_hour in (select partition_hour from backfill_targets); commit; select partition_hour, count(*) as rows_now from fact.orders where partition_hour in (select partition_hour from backfill_targets) group by 1 order by 1; -- hour 05: 4,120 · 06: 3,987 · 07: 4,402 · 08: 4,655 · 09: 4,910 · 10: 5,033 -- run the transaction again and this query returns the same six counts. That is the test.
★ Step 4 · reset the watermark update ops.watermarks set processed_through = (select min(partition_hour) from backfill_targets) - interval '1 hour', moved_by = current_user, moved_at = current_timestamp, reason = 'bf-2026-08-16-orders' where pipeline = 'orders'; -- night 15: processed_through moves from 2026-08-15 23:00 back to 2026-08-15 04:00 -- the scheduler's next tick runs the step for 05 through 10 and advances the watermark again -- night 9 needed none of this: nothing landed, so the watermark never moved past night 8 at 23:00
★ Step 5 · re-trigger downstream in dependency order select dependency_rank, consumer, trigger_command from ops.consumers where consumer in ('settlement_report', 'payout_export', 'demand_forecast') order by dependency_rank, consumer; -- 1 settlement_report harbor run settlement --report-day 2026-08-15 --reason bf-2026-08-16-orders -- 2 demand_forecast harbor run forecast --from 2026-08-15 --reason bf-2026-08-16-orders -- 2 payout_export harbor run payout-export --report-day 2026-08-15 --reason bf-2026-08-16-orders -- run rank 1, wait for it to finish, then run rank 2. The export reads the rebuilt report, never the old one.
★ Step 6 · compare the totals and complete the log row update ops.backfill_log set total_after = (select sum(amount_base) from fact.orders where partition_hour in (select partition_hour from backfill_targets)), finished_at = current_timestamp where backfill_id = 'bf-2026-08-16-orders'; select b.backfill_id, r.signed_total, r.signed_by, r.signed_at, b.total_before, b.total_after, b.total_after - b.total_before as diff, b.announced_at, b.finished_at from ops.backfill_log b join fin.settlement_signoffs r on r.report_day = date '2026-08-15' where b.backfill_id = 'bf-2026-08-16-orders'; -- signed_total 1,284,310.55 at 07:00 · total_before 1,284,310.55 · total_after 1,271,902.10 · diff -12,408.45 -- announced 11:40 · finished 13:05 · the report was wrong for six hours and the record says exactly which six

Before-total first, or it is gone. Once step 3's delete runs, the only place the old total exists is the signed PDF. Capturing total_before in the same statement as the announcement is what makes the diff in step 6 a fact rather than a reconstruction.

Homework

Before session 6

30 min
  1. Find the step in your own pipeline that writes the fact table and run it twice against a copy of one partition. Count the rows after each run. If the counts differ, rewrite the step as delete-then-insert keyed on the partition before doing anything else tonight.
  2. Find your pipeline's watermark. If it does not have one, add processed_through to a watermarks table and have the run read and advance it. Then move it back one hour by hand and watch what the next run does.
  3. Build the consumers table for your fact table: every report, export and model that reads it, with owner, dependency rank and the re-trigger command. Ask each owner one question: "if these numbers changed, would you want to know before or after". Their answer is the announcement rule for that row.
  4. Write the backfill log row for the last backfill your team ran, from memory and from whatever records exist. Note every field you could not fill in. Session 6's incident package will ask for the same fields.
Check yourself

Three questions

A partition step is run twice by mistake. Under which shape is the table identical after the second run?

A filtered insert still appends: 4,120 rows becomes 8,240. The transaction stops readers seeing the gap, but only the delete stops the doubling. Idempotency is a property of the step, not of how carefully it is run.

The settlement report for night 15 was signed at 07:00 against six corrupt partitions. The recompute is ready at 11:30. What has to happen before it runs?

Correct and silent creates two versions of a document merchants were paid against, with no record of which one anybody saw. Announce first, then run, then file both totals with the announcement time.

Why does the backfill move the watermark back to 04:00 on night 15 rather than rerunning the month "to be safe"?

Idempotency makes the full rerun harmless to the table and expensive to everything else. Six bad hours is six hours of recompute; the watermark is what lets the backfill know that.

Sources covered

What tonight maps to

✓ Idempotency, watermarks and replay as operating discipline Established practice, taught to working depth: partition overwrite as delete-then-insert keyed on partition_hour, the watermark as the record of correct-through, and the replay in dependency order.
✓ The settlement report and the signed number A backfill that changes a signed total is announced before it runs, and the old and new totals are filed with the announcement time. Night 15's six partitions are the worked case.
✓ Which of the five incidents needed a backfill Nights 15, 27 and 9, each needing something different; nights 4 and 21 needed none. The partition log keeps reconciled false so the month's 35 bad partitions stay 35.
◐ Budgeting the cost of backfills Named here as compute, attention and trust. The leader track's session 5 puts that cost next to the error budget and decides how much of a quarter it may consume.
◐ Pipeline construction, CI/CD and orchestration The DataOps and orchestration courses own how the step is built and scheduled. Here the step already exists and only has to be safe to run twice.
◐ Recording what the backfill changed Session 6. The incident package quotes the backfill log row rather than reconstructing it.
· Traces, spans and alerting for AI services Not covered by design. That is the AI observability course; this track is tables, partitions and the people on call for them.

Session 5 cheat sheet

Twice equals onceDelete the partition's rows, insert the recomputed ones, keyed on partition_hour, one transaction. Never append.
One code pathThe nightly step and the backfill are the same step pointed at different hours.
Watermark = correct throughMove it back to the hour before the first bad partition. Six hours, not a month.
Night 9 needed no resetNothing landed, so the watermark never advanced. The normal run caught up.
Announce before recomputeA signed number that changes silently is a governance event. Old total, direction, run time, to the people who signed.
Consumers are a tableKind, owner, rank, re-trigger command, read log. The notification is generated, not remembered.
Replay in rank orderPartitions, then rank 1, then rank 2. The export reads the rebuilt report, never the old one.
File the difftotal_before captured at announcement, total_after at the end, both in the backfill log. 1,284,310.55 to 1,271,902.10.