Skip to content
About
Main content

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 that skipped a . 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 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

With a bug silently swallowing 1% of writes for 12 hours, what does the error count show?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    reconciliation-count-and-duplicate-rate-metric.py48 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Source-to-sink gap per hour (bug from hour 12)Apple M2 · 8 cores · 8 GB
Data table
Source-to-sink gap per hour (bug from hour 12)
HourRecords missing at the sink
00%
10%
20%
30%
40%
50%
60%
70%
80%
90%
100%
110%
120.97%
131%
140.985%
150.985%
161.03%
170.99%
180.965%
190.945%
200.895%
211.06%
220.805%
230.98%
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Output · uv run reconciliation-count-and-duplicate-rate-metric.py
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 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.