Inject loss, then catch it
Break it on purposeNeeds Python
Problem
Incremental pipelines do not fail loudly. A pipeline that drops a few changes still runs on time and reports success; the consumer pays later, in totals that do not tie out. The defence is reconciliation: compare cheap summaries (a row count and a checksum per day) on both sides and re-copy only the ranges that differ. A second failure hides in the restart path. A consumer records how far it has read (its cursor). If it commits the cursor before the state, a crash loses changes; after the state, a crash replays them. Only committing both together is safe.
Build a toy CDC pipeline: a source table, an in-memory change list, a sink table. Randomly drop 0.1% of the changes before applying them. Show that no error appears and the pipeline finishes on time. Then add a per-day count + checksum comparison and see it detect the drift. Finally, apply the "commit cursor with state" rule and show with a simulated crash that committing the cursor first loses changes, and committing it second creates duplicates (harmless with an upsert, harmful for an append-only sink).
The script runs 30 days of orders, and crashes half-way through one batch of 500 changes.
The question. Does dropping 0.1% of changes cause any visible error, does a per-day checksum catch it, and what does a crash do under each cursor-commit order?
Predict
Expect no error from the dropped changes and the per-day checksum to flag the affected days; committing the cursor first to lose changes, committing it second to create duplicates (harmless with an upsert, harmful for an append-only sink), and committing it together with the state to do neither.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
inject-loss-then-catch-it.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Inject loss, then catch it. A toy CDC pipeline (source table, change list, sink table) that silently drops a small share of changes; a per-day count + checksum comparison that catches it; and a simulated crash that shows why the cursor must be committed with the state, not before or after it. """ import argparse, hashlib, json, random from collections import defaultdict p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--days", type=int, default=30, help="Days of orders.") p.add_argument("--orders-per-day", type=int, default=2_000, help="Orders created per day.") p.add_argument("--drop", type=float, default=0.001, help="Share of changes silently dropped before they are applied.") p.add_argument("--batch", type=int, default=500, help="Changes per batch for the crash experiment.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) # Source and its change list: inserts, then status updates and a few deletes source, changes, lsn = {}, [], 0 for day in range(args.days): for i in range(args.orders_per_day): oid = day * args.orders_per_day + i row = {"day": day, "status": "placed", "total": round(random.uniform(5, 500), 2)} source[oid] = row; lsn += 1; changes.append((lsn, "I", oid, dict(row))) for oid in random.sample(sorted(k for k in source if source[k]["day"] == day), args.orders_per_day // 4): source[oid]["status"] = "shipped"; lsn += 1; changes.append((lsn, "U", oid, dict(source[oid]))) for oid in random.sample(sorted(k for k in source if source[k]["day"] == day), args.orders_per_day // 100): del source[oid]; lsn += 1; changes.append((lsn, "D", oid, None)) def apply(sink, change): _, op, oid, row = change if op == "D": sink.pop(oid, None) else: sink[oid] = row # an upsert keyed by order id # 1. Drop a small share of changes: nothing fails, the run finishes sink, dropped = {}, 0 for c in changes: if random.random() < args.drop: dropped += 1; continue apply(sink, c) print(f"{len(changes):,} changes, {dropped} silently dropped ({dropped / len(changes):.2%}); pipeline finished with no error") # 2. Reconcile: count + checksum per day on both sides def summary(table): out = defaultdict(lambda: [0, 0]) for oid, r in table.items(): out[r["day"]][0] += 1 out[r["day"]][1] ^= int(hashlib.md5(f"{oid}|{r['status']}|{r['total']}".encode()).hexdigest()[:15], 16) return out src_s, snk_s = summary(source), summary(sink) bad_days = [d for d in range(args.days) if src_s[d] != snk_s[d]] row_diffs = sum(1 for k in set(source) | set(sink) if source.get(k) != sink.get(k)) print(f"per-day count + checksum: {len(bad_days)} of {args.days} days differ ({row_diffs} rows wrong in the sink)") # 3. A crash in the middle of a batch, under three commit orders def crash_run(order, sink_kind): """Apply the change list in batches; the process crashes once, half-way through batch 3, and restarts from the last committed cursor. order: when the cursor is committed relative to the sink state.""" sink = {} if sink_kind == "upsert" else [] def put(c): if sink_kind == "upsert": apply(sink, c) else: sink.append(c[0]) # append-only: one row per change applied cursor, crashed = 0, False while cursor < len(changes): start = cursor batch = changes[start: start + args.batch] crash_here = not crashed and start == 3 * args.batch if order == "cursor first": cursor = start + len(batch) # cursor committed, then the state staged = [] for c in batch[: len(batch) // 2 if crash_here else len(batch)]: if order == "together": staged.append(c) # state and cursor in one transaction else: put(c) if crash_here: # the process dies here crashed = True continue # restart from the committed cursor for c in staged: put(c) if order != "cursor first": cursor = start + len(batch) # cursor committed after (or with) the state if sink_kind == "upsert": wrong = sum(1 for k in source if sink.get(k) != source[k]) + sum(1 for k in sink if k not in source) return {"lost_or_wrong": wrong, "duplicates": 0} return {"lost_or_wrong": len(changes) - len(set(sink)), "duplicates": len(sink) - len(set(sink))} crash = [] for order in ("cursor first", "cursor last", "together"): for kind in ("upsert", "append"): r = crash_run(order, kind); crash.append({"cursor": order, "sink": kind, **r}) print(f"crash, {order:<13} {kind:>6} sink: {r['lost_or_wrong']:>4} lost or wrong, {r['duplicates']:>4} duplicates") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"crash": crash, "summary": [{ "changes": len(changes), "dropped": dropped, "bad_days": len(bad_days), "days": args.days, "row_diffs": row_diffs}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run inject-loss-then-catch-it.py uv run inject-loss-then-catch-it.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --days30 | Days of orders. |
| --orders-per-day2000 | Orders created per day. |
| --drop0.001 | Share of changes silently dropped before they are applied. |
| --batch500 | Changes per batch for the crash experiment. |
| --seed0 | Random seed. |
Our run
A crash half-way through a batch
| Cursor committed | Sink | Lost or wrong | Duplicates |
|---|---|---|---|
| cursor first | upsert | 178 | 0 |
| cursor first | append | 250 | 0 |
| cursor last | upsert | 0 | 0 |
| cursor last | append | 0 | 250 |
| together | upsert | 0 | 0 |
| together | append | 0 | 0 |
75,600 changes, 73 silently dropped (0.10%); pipeline finished with no error
per-day count + checksum: 26 of 30 days differ (61 rows wrong in the sink)
crash, cursor first upsert sink: 178 lost or wrong, 0 duplicates
crash, cursor first append sink: 250 lost or wrong, 0 duplicates
crash, cursor last upsert sink: 0 lost or wrong, 0 duplicates
crash, cursor last append sink: 0 lost or wrong, 250 duplicates
crash, together upsert sink: 0 lost or wrong, 0 duplicates
crash, together append sink: 0 lost or wrong, 0 duplicatesWhat we found
The pipeline dropped 73 of 75.6K changes and finished with no error. The per-day count + checksum flagged 26 of 30 days, with 61 rows wrong in the sink: a tiny loss spread over most days, which is exactly what a total-only check would miss.
The crash behaved as the rule predicts. With the cursor committed first, the rest of the interrupted batch was never applied: rows were lost on both sinks. With the cursor committed after the state, the half batch was applied twice: harmless for the upsert sink, duplicates for the append-only sink. Committing the cursor in the same transaction as the state gave neither.
Read it
Every long-lived incremental system needs a periodic full rebuild or reconciliation, not to fix known bugs but as anti-entropy against unknown ones. The reconciliation compares summaries, not data: counts and checksums per partition, and only the ranges that differ are re-copied.
Never write the same fact to two systems; write it once and derive the second, and commit your cursor with your state, not beside it. When the sink cannot share a transaction with the cursor, make the sink idempotent instead (an upsert keyed by the source position), so a replay is harmless.