skip to content

Why is a task that appends its window's rows unsafe to re-run, and what write pattern fixes it?

level: middleimportance: must knowfreq 68%

answer

  1. the second run has to land somewhere
  2. an append cannot undo the previous attempt
  3. delete the slice you own, then insert it
  4. one transaction, or a merge on a stable key

basics

~20 s

An append has no way to remove what a previous attempt wrote, so every re-run adds another copy. Fix it by making the write replace the slice: delete-then-insert for that window in one transaction, a merge on a natural key, or an atomic partition swap.

solid answer

~50 s

`INSERT` is additive, so a retry or a replay lands a second copy of the window and nothing in the write path notices. The fix is to make the task **own a slice and replace it**. Three patterns: delete the window's rows and insert the recomputed set inside one transaction; `MERGE`/upsert on a stable natural key so re-running updates rows in place; or write to a new partition location and swap it in atomically, which is the object-store version of the same idea. Overwrite is cheapest and simplest but only valid if the task genuinely owns the whole slice — if two producers write into one partition, one will erase the other. Merge tolerates late and out-of-order rows and multiple producers, but needs a real business key and costs more per run. Whichever you pick, the delete and the insert must be atomic, or a crash between them leaves a hole.

code

sql · 11 lines
sql
-- replace the window the task owns, atomically
BEGIN;
DELETE FROM sales_daily
 WHERE dt >= :interval_start
   AND dt <  :interval_end;
INSERT INTO sales_daily (order_id, amount, dt)
SELECT order_id, amount, order_ts::date
  FROM raw_orders
 WHERE order_ts >= :interval_start
   AND order_ts <  :interval_end;
COMMIT;

go deeper

for a junior

Know that a plain insert duplicates rows on any re-run and that the standard fixes are deleting the window before inserting it, or upserting on a key. Be able to spot the additive write in a snippet.

for a middle

Explain the mechanics and the tradeoffs: atomicity of delete-and-insert, what predicate defines the slice the task owns, and why merge needs a stable source key and costs more per run.

for a senior

Bring the failure modes you have seen — the empty source that wiped a partition, two producers deleting each other's rows, the dedupe job that became load-bearing — and the guardrails you added, such as row-count gates and write-new-then-swap.

for a principal

Decide the write contract for the platform: which tables are partition-owned versus key-merged, who is allowed to write into a shared table, and whether the storage layer's atomic snapshot swap becomes the mandated mechanism.

## Why appending breaks `INSERT INTO target SELECT ...` is an additive operation: it knows nothing about what previous attempts wrote. So the second execution of the same window — a retry, a cleared run, a replay of a missed interval, a deliberate backfill — leaves two copies of every row. The failure is quiet. No constraint fires, no task turns red, and the pipeline reports success. It surfaces days later as a metric that is exactly 2x for one day, or 3x for the day someone backfilled twice. The deeper problem is that an append makes the target a function of *how many times the pipeline ran*, which is an operational accident, rather than a function of the input data. Everything downstream inherits that. ## Pattern 1: delete-then-insert for the window The task declares the slice it owns — usually one partition, one day, one interval — deletes exactly that slice, and inserts the recomputed rows. ```sql BEGIN; DELETE FROM sales_daily WHERE dt >= :interval_start AND dt < :interval_end; INSERT INTO sales_daily (order_id, amount, dt) SELECT order_id, amount, order_ts::date FROM raw_orders WHERE order_ts >= :interval_start AND order_ts < :interval_end; COMMIT; ``` Two details make or break this. First, **atomicity**: if the delete commits and the process dies before the insert, the window is now empty and readers see a hole. Wrap both in one transaction, or use an engine operation that replaces a partition in a single step. Second, **ownership**: the delete predicate must cover exactly the rows this task produces and no others. If a second pipeline also writes into `sales_daily` for the same days, each run silently deletes the other's output. A third hazard is worth calling out because it bites during incidents: if the source query now returns zero rows — a broken upstream, a wrong credential, a filter typo — the delete still runs and you have replaced good data with nothing. Guarding the swap on a plausibility check (row count is non-zero, or within a sane band of the previous run) turns a silent wipe into a failed task. ## Pattern 2: merge / upsert on a natural key ```sql MERGE INTO sales_daily t USING (SELECT order_id, amount, order_ts::date AS dt FROM raw_orders WHERE order_ts >= :interval_start AND order_ts < :interval_end) s ON t.order_id = s.order_id WHEN MATCHED THEN UPDATE SET amount = s.amount, dt = s.dt WHEN NOT MATCHED THEN INSERT (order_id, amount, dt) VALUES (s.order_id, s.amount, s.dt); ``` Re-running updates rows in place, so replay converges instead of accumulating. Merge is the right choice when rows can arrive late or out of order, when several producers legitimately write to the same table, or when a row's own attributes change after first load. Its requirements are stricter: you need a key that is stable and genuinely unique in the source. Deriving that key from a mutable field, or from a load-time sequence, reintroduces duplicates through the back door. Merge is also the more expensive write on most engines because it must match against existing data rather than blindly replacing a slice. Note what merge does *not* fix: rows that were in the target from a previous run but are absent from the new source result — a cancelled order, a hard delete upstream — stay behind. If the source is the authority on the whole window, delete-then-insert is more truthful than merge. ## Pattern 3: write-new-then-swap On object storage and in lakehouse table formats the idiomatic move is to write the window's output to a fresh location and then flip a pointer — register the new partition, or commit a new table snapshot — in one metadata operation. Readers see either the old data or the new data, never a half-written mix, and rollback is a pointer flip back. This is the same replace semantics as pattern 1 with a better failure story, and it is what large backfills should use. ## Choosing between them Ask who owns the slice. If this task is the sole producer of a bounded partition and the source can regenerate the whole partition, overwrite it — simplest, cheapest, easiest to reason about during an incident. If rows trickle in over time, or several jobs contribute, or the grain is a business entity rather than a time slice, merge on the key. If your storage layer offers atomic partition or snapshot swaps, prefer that mechanism over hand-rolled delete-and-insert, because it removes the half-written window entirely. ## The pattern to refuse "Append everything and deduplicate downstream" shows up constantly and should be pushed back on. It moves correctness into a separate job that runs later, so the window between load and dedupe serves wrong numbers; the dedupe needs the very key you claimed you did not have; and storage grows with the number of retries. Deduplication as a *defensive* backstop is fine. Deduplication as the primary mechanism for idempotence is a bug that has not fired yet.

  • What breaks if the delete and the insert are not in the same transaction?
    A crash between them leaves the window empty, and readers see a hole rather than stale-but-correct data. The gap is short, which makes it worse: the pipeline usually looks green after the retry, and only a dashboard screenshot taken during the gap proves it happened. Use one transaction, or an atomic partition/snapshot swap.
  • When would you choose merge on a key over overwriting the whole window?
    When rows for a window keep arriving or changing after the window closed, when more than one producer legitimately writes into the same table, or when the grain is a business entity rather than a time slice. Merge needs a stable unique key, costs more per run, and will not remove rows that disappeared from the source.
  • A re-run's source query returns zero rows and the task deletes the window first. What happens, and how do you prevent it?
    The delete commits, the insert writes nothing, and good data is replaced by an empty window — a silent wipe caused by a broken upstream rather than by bad logic. Guard the write: fail the task if the computed row count is zero or outside a sane band of the previous run, and prefer write-new-then-swap so nothing is destroyed until the replacement exists.

saying these in an interview costs you the question

  • Says append is fine because a nightly dedupe job cleans it up
  • Runs the delete and insert as two separate committed statements
  • Deletes with a predicate broader than the slice the task owns
  • Merges on a key generated at load time rather than a source key
  • Assumes overwrite is safe when a second pipeline writes the same partition

context