Reconciliation count and duplicate-rate metric
Simulate a modelNeeds Python
Problem
The failures that matter most in data movement do not raise errors: a record dropped by an over-broad exception handler, a filter that matches too much, a consumer that skipped a partition. Error dashboards stay empty because nothing failed loudly. What catches these is monitoring for absence: count records per hour at the source and at the sink, and alert when the two differ by more than a small tolerance.
For any pipeline you have (even the one from the first lab), write a query that counts records per hour at the source topic and at the sink table, and alerts if the difference is above 0.1%. Also count the rejected duplicates per hour. Introduce a bug on purpose (an except: pass around the sink write for 1% of records) and see if your reconciliation catches it.
The script simulates 24 hours of 20,000 records per hour, with the bug switched on from hour 12.
The question. When a bug silently swallows 1% of writes, what do the error count and the hourly reconciliation each show?
Predict
Error dashboards stay empty, but the reconciliation count shows a gap of about 1%.
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
reconciliation-count-and-duplicate-rate-metric.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Reconciliation: a count check and a duplicate-rate metric. Records per hour at the source and at the sink, with an alert above 0.1% difference. A bug is introduced on purpose: an `except: pass` around the sink write that swallows 1% of records. The error dashboard stays empty. """ import argparse, json, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--hours", type=int, default=24, help="Hours of traffic.") p.add_argument("--per-hour", type=int, default=20_000, help="Records per hour.") p.add_argument("--bug-from", type=int, default=12, help="Hour from which the bug swallows records.") p.add_argument("--swallow", type=float, default=0.01, help="Share of records the bug swallows.") p.add_argument("--dup-rate", type=float, default=0.002, help="Share of records redelivered (rejected as duplicates).") 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) rows, errors = [], 0 for h in range(args.hours): source = sink = rejected = 0 for _ in range(args.per_hour): source += 1 if random.random() < args.dup_rate: rejected += 1 # a redelivery the sink's key rejects try: if h >= args.bug_from and random.random() < args.swallow: raise IOError("write failed") sink += 1 except Exception: pass # the bug: no log, no metric, no error gap = (source - sink) / source rows.append({"hour": h, "source": source, "sink": sink, "gap_pct": gap * 100, "rejected_dups": rejected, "alert": "yes" if gap > 0.001 else "no"}) alerts = sum(r["alert"] == "yes" for r in rows) print(f"errors reported: {errors}") print(f"hours with a count gap above 0.1%: {alerts} of {args.hours} (the bug ran from hour {args.bug_from})") print(f"mean gap while the bug ran: {sum(r['gap_pct'] for r in rows[args.bug_from:]) / (args.hours - args.bug_from):.2f}%") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"hours": rows, "summary": [{"alerts": alerts, "errors": errors, "gap_during_pct": sum(r['gap_pct'] for r in rows[args.bug_from:]) / (args.hours - args.bug_from)}]}}, 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 reconciliation-count-and-duplicate-rate-metric.py uv run reconciliation-count-and-duplicate-rate-metric.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --hours24 | Hours of traffic. |
| --per-hour20,000 | Records per hour. |
| --bug-from12 | Hour from which the bug swallows records. |
| --swallow0.01 | Share of records the bug swallows. |
| --dup-rate0.002 | Share of records redelivered (rejected as duplicates). |
| --seed0 | Random seed. |
Our run
Data table
| Hour | Records missing at the sink |
|---|---|
| 0 | 0% |
| 1 | 0% |
| 2 | 0% |
| 3 | 0% |
| 4 | 0% |
| 5 | 0% |
| 6 | 0% |
| 7 | 0% |
| 8 | 0% |
| 9 | 0% |
| 10 | 0% |
| 11 | 0% |
| 12 | 0.97% |
| 13 | 1% |
| 14 | 0.985% |
| 15 | 0.985% |
| 16 | 1.03% |
| 17 | 0.99% |
| 18 | 0.965% |
| 19 | 0.945% |
| 20 | 0.895% |
| 21 | 1.06% |
| 22 | 0.805% |
| 23 | 0.98% |
errors reported: 0
hours with a count gap above 0.1%: 12 of 24 (the bug ran from hour 12)
mean gap while the bug ran: 0.97%What we found
The error count stayed at 0. The reconciliation flagged 12 hours, exactly the hours the bug ran, with a mean gap of 0.968%, as expected.
Read it
This is the "monitor for absence, not for errors" idea of this whole mechanism.
A try/catch around a side effect, followed by continuing, is an at-most-once decision made by whoever wrote the error handler, usually without realizing it. The duplicate-rate metric is the reconciliation's partner: a stable non-zero rate of rejected duplicates is evidence that dedup is running, and a rate of zero is ambiguous.