Chapter 01 Correctness
Reruns that do not corrupt
Almost every data incident I have investigated collapses into one question: what happens when this job runs twice? Get that answer right and half of your on-call disappears.
A pipeline is idempotent when running it again with the same inputs leaves the world in the same state. Run it once, run it fifty times, the table looks identical.
This sounds academic until 3am, when a task failed halfway through writing, the retry kicked in, and your revenue table now has 1.4x the real revenue because the first attempt committed 40% of its rows before dying.
Here is the deal
You do not get idempotency from a framework setting. You get it from three decisions: how you partition, how you write, and whether your logic has hidden inputs. Airflow retries, Spark speculation and Kubernetes restarts all assume you already solved it.
The only three write modes, ranked by how much they will hurt you
| Write mode | Idempotent? | When it is right | How it bites |
|---|---|---|---|
| Append | No | Immutable event logs where duplicates are handled downstream | Partial failure plus retry equals duplicates. Forever. Nothing cleans it up for you. |
| Insert overwrite partition | Yes | Batch tables partitioned on a deterministic key, usually event date | Only safe if the partition key is derived from the data, not from the clock at runtime. |
| Merge / upsert | Yes, conditionally | Slowly changing dimensions, CDC targets, late-arriving corrections | Breaks when the merge key is not truly unique, or when two source rows for the same key arrive in one batch. |
The default should be insert overwrite by partition. It is the cheapest form of correctness available: the write is a full replacement of a bounded slice, so the state after the run does not depend on the state before it.
# Without this, Spark's "overwrite" nukes the ENTIRE table, not just
# the partitions present in your dataframe. This one line has caused
# more data loss than any bug I have seen.
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
(df
.repartition("event_date") # one writer per partition, fewer small files
.write
.mode("overwrite")
.partitionBy("event_date")
.saveAsTable("analytics.fct_payment"))
Hidden inputs: the real cause of non-determinism
A pipeline is deterministic when output equals f(input partition). Anything else your code reads is a hidden input, and every hidden input is a future incident.
-
current_timestamp(),now(),current_date()in the transformation body. If a backfill for March runs in August, every "days since" column is wrong. -
rand(),uuid(),monotonically_increasing_id(). Rerun, different values, downstream joins silently lose rows. - Mutable dimension tables read as of now. Joining yesterday's facts to today's customer table gives you a different answer every day you rerun it.
- API or service calls inside the transform. The remote system moved on.
-
Ordering assumptions.
collect_listwithout an explicit sort returns whatever the shuffle gives you.
The most expensive one-liner in data engineering
Partitioning on date(current_timestamp()) instead
of date(event_time). It works perfectly for
months. Then you backfill, and every historic day lands in
today's partition. Now the fix is not a rerun, it is a manual
archaeology project across every partition you touched.
Late data: pick a lookback window, and pick it with evidence
Events arrive late. Mobile clients buffer offline, upstream services replay, a broker had a bad hour. So "compute yesterday, once" is wrong on every real system.
The pattern that works: every run reprocesses a rolling window, not just the newest day. Because the write is an overwrite by partition, reprocessing is free of side effects.
-- Runs every day. Rewrites the last 3 event-days from source truth.
-- Rerunning any date range is safe: same input, same output.
INSERT OVERWRITE TABLE analytics.fct_payment PARTITION (event_date)
SELECT
payment_id,
TO_DATE(event_time) AS event_date, -- from the DATA
amount_usd,
status
FROM raw.payment_events
WHERE TO_DATE(event_time)
BETWEEN DATE_SUB('${run_date}', 2) AND '${run_date}';
How do you choose the window size? Measure it. Log
ingest_time - event_time for a month and take the
p99. If p99 is 26 hours, a 3 day lookback covers you with
margin. If p99 is 9 days, you have an upstream problem to fix
before you widen the window to 10 days and pay for it every
single night.
Deduplication that actually holds
At-least-once delivery is the norm in every message system worth using. So dedup is not optional, it is a layer.
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY payment_id -- the true business key
ORDER BY source_updated_at DESC, -- newest wins
ingest_sequence DESC -- tiebreak, never leave ties
) AS rn
FROM raw.payment_events
WHERE event_date BETWEEN '${start}' AND '${end}'
)
SELECT * EXCEPT (rn) FROM ranked WHERE rn = 1;
Three details separate this from the version that fails in six months:
-
Always include a tiebreaker. If two rows
share the same key and same timestamp,
ROW_NUMBERpicks arbitrarily, and "arbitrarily" changes between runs. That alone destroys idempotency. - Order by the source's clock, not yours. Ingest time tells you when you saw it. Source updated time tells you which version is truer. For CDC use the log position (LSN, SCN, binlog offset), which is monotonic by construction.
- Dedup inside the reprocessing window, not globally. A global dedup scans the whole table on every run and gets slower forever.
Backfills: one code path or none
The single strongest predictor of backfill pain is having a separate backfill script. It drifts from the production job within two sprints, and then your history and your present are computed by different logic.
Three operational rules that make large backfills survivable:
- Chunk by partition and run with bounded parallelism. Fifty concurrent partition rewrites will evict your production queries from the cluster and page someone else.
- Write to a shadow table first for anything above a week. Compare row counts and key aggregates against production, then swap. This is the write, audit, publish pattern from Chapter 5.
- Keep a backfill ledger. One row per partition with status and run id. When someone asks in November whether March 14 was reprocessed after the fix, you want an answer, not a Slack search.
Exactly-once, said honestly
There is no such thing as exactly-once delivery over a network. What exists is at-least-once delivery plus an idempotent sink, which produces exactly-once effects. Every system that markets exactly-once is doing one of these:
| Mechanism | How it works | Cost |
|---|---|---|
| Idempotent sink | Write is an upsert on a stable key, so replays overwrite rather than duplicate | Cheapest and most robust. Needs a real primary key. |
| Transactional two-phase commit | Sink participates in the checkpoint, commits only when the checkpoint completes | Adds latency equal to your checkpoint interval. Breaks if the transaction timeout is shorter than a slow checkpoint. |
| Dedup on a stored key set | Keep seen event ids in state or a store, drop repeats | State grows without bound unless you TTL it, and the TTL is a correctness window. |
Interview answer worth memorizing
"I do not rely on exactly-once delivery. I make the sink idempotent with a deterministic key, so at-least-once delivery converges to the correct state. Then I only pay for transactional commits where the sink genuinely cannot be keyed, for example appending to an external file drop."
Rerun safety check
InteractiveTick everything that is true about your pipeline. The verdict tells you what happens on the second run.
The checklist
Before you merge any pipeline
- Partition key is derived from the event payload, never from the clock.
- Write mode is overwrite by partition or a keyed merge. Append only for raw immutable landing zones.
- No clock, no randomness, no network calls inside the transform.
- Dimension joins are as-of the event date, not as-of now.
- Dedup ranking has a deterministic tiebreaker.
- The job takes a start and end date. There is no second script.
- You have run it twice in staging and diffed the output. Byte for byte, or at least row count plus checksum per partition.