Lesson 08 / 10
Resilience to Repetition and Reprocessing
Measuring that processing the same load a second time duplicates rows and that the served number does not show it: in an appending store, backfilling one period takes the raw rows from 2400 to 2600 and a full rerun takes them to 4800, reaching 7200 in three runs, yet the served value stays at 1212.55 in all three runs because the deduplication stage absorbs the surplus; when the resent load carries a correction, the same stage throws the correction away and leaves the value at 794.36; and when deduplication looks at the whole row instead of the key, the same reruns carry the value to 2425.09 and 3637.64.
Published Updated
In this lesson
- Runs idempotent and non-idempotent write modes on the same reprocessing scenario and counts the duplicated rows.
- Shows that the deduplication stage absorbs the surplus and that the served value therefore does not change while the raw row count triples over three runs.
- Measures that when the deduplication key is chosen wrongly, the same rerun grows the served value together with the number of runs.
Contents
The previous lesson showed that the contract catches the corruption at stage 0, but catching is not repairing: the 200 blocked rows wait somewhere, the producer fixes them, and that period is resent to the pipeline. In none of the measurements so far has the pipeline seen the same data twice.
The second run is not an exception; it is the most frequent thing a pipeline does. A producer corrects a load, a stage fails and is retried, a past period is backfilled. This lesson counts what happens to the row count of a pipeline that processes the same load twice, which stage absorbs the surplus, and whether the served value shows it. The answer is the cleanest example of the third claim of the course.
- QP14. The source, the pipeline and the store are fictional; no real store, file format or orchestrator is called. The seed is 20260218, and the measurement does not depend on the run.
- QP15. The equivalent of
idempotentin this curriculum is idempotent. The concept itself was measured in the Shell Programming course, and the retry and queue procedure in the Caching, Queues and Asynchronous Processing course, and is not repeated. The only question here is whether reprocessing duplicates the row. - QP16. Reprocessing is not an accident: a corrected load, a retried stage and a backfilled period are ordinary jobs.
- QP17. The value of a resent row stays the same, only the load stamp changes; the one exception is the load that carries a correction. This detail determines the whole of the last two measurements.
- QP18. The baseline is the single run: raw rows 2400, duplicated rows 0.
- QP19. The units counted: reprocessed rows, duplicated rows, rows dropped by deduplication and the difference at serving. No raw time is written, the unit is written as run.
- QP20. Resolution: the step of the row share is 0.0004, of a summary cell 0.0182, of the serving value 0.2000. No share is printed on the serving output.
Two Write Modes
Whether a pipeline is idempotent is decided by a single choice: how the incoming row is written to the store. An appending write puts the row at the end of the store; when the same row arrives twice, it sits in the store twice. A keyed write overwrites the row at a key; when the same key arrives twice, it sits in the store once. The key here is the pair of subscriber and period, which is already what the pipeline uses as its uniqueness criterion.
The second part is how reprocessing looks. When a corrected load arrives, the value of the row is the same; the only thing that changes is the load stamp. This detail is often overlooked, and the whole of the last measurement rests on it.
# FICTIONAL. Meter readings, the five-stage pipeline and the store are defined in the lesson; # no real store, file format or orchestrator is called. SEED, M32 = 20260218, 0xFFFFFFFF def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value REGION = ["east", "west", "north", "south", "central"] TYPE = ["residential", "commercial"] PERIODS = [f"2026-{a:02d}" for a in range(1, 13)] SUBSCRIBERS = {} for i in range(200): r = rng(SEED + 37 * i) SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)], "start": 10000 + int(r() * 60000)} def generate_source(): records = [] for i, (sub, a) in enumerate(SUBSCRIBERS.items()): e = a["start"] for p in PERIODS: r = rng(SEED + 101 * i + 7 * PERIODS.index(p)) e += 4 + int(r() * 46) records.append({"subscriber": sub, "period": p, "region": a["region"], "type": a["type"], "index": e, "unit": "m3"}) return records # ---- PIPELINE: five stages, each counts the rows it drops def s1_screen(records): REQUIRED = ("subscriber", "period", "region", "index", "unit") keep = [k for k in records if all(a in k for a in REQUIRED)] return keep, len(records) - len(keep) def s2_dedupe(records): seen, keep = set(), [] for k in records: key = (k["subscriber"], k["period"]) if key in seen: continue seen.add(key) keep.append(k) return keep, len(records) - len(keep) def s3_consumption(records): table = {(k["subscriber"], k["period"]): k for k in records} out, dropped = [], 0 for k in records: i = PERIODS.index(k["period"]) if i == 0: continue prev = table.get((k["subscriber"], PERIODS[i - 1])) if prev is None: dropped += 1 continue try: f = int(k["index"]) - int(prev["index"]) except (TypeError, ValueError): dropped += 1 continue out.append(dict(k, m3=f)) return out, dropped def s4_summary(records): totals = {} for k in records: key = (k["region"], k["period"]) totals[key] = totals.get(key, 0) + k["m3"] return totals, 0 def s5_serve(summary): region = {} for (b, d), v in summary.items(): region.setdefault(b, []).append(v) return {b: round(sum(v) / len(v), 2) for b, v in region.items()}, 0 STAGES = [("screen", s1_screen), ("dedupe", s2_dedupe), ("consumption", s3_consumption), ("summary", s4_summary), ("serve", s5_serve)] def pipeline(records, stages=None): trace, data = [], records for name, f in (stages or STAGES): data, dropped = f(data) trace.append({"stage": name, "remaining": len(data), "dropped": dropped}) return data, trace # ---- INGESTION and REPROCESSING def ingest(records, load): """Ingestion stage: each load is given a stamp.""" return [dict(k, load=load) for k in records] def resend(records, periods, load): """The corrected load is resent ; the stamp changes, the value does not.""" return [dict(k, load=load) for k in records if k["period"] in periods] def store_append(store, new): """NON-idempotent write: the incoming row is appended to the end of the store.""" return store + new def store_keyed(store, new): """IDEMPOTENT write: the (subscriber , period) key is overwritten.""" table = {(k["subscriber"], k["period"]): k for k in store} for k in new: table[(k["subscriber"], k["period"])] = k return list(table.values()) def s2_dedupe_full(records): """FLAWED DEDUPLICATION: looks at the whole row, not at the key.""" seen, keep = set(), [] for k in records: stamp = tuple(sorted((a, str(v)) for a, v in k.items())) if stamp in seen: continue seen.add(stamp) keep.append(k) return keep, len(records) - len(keep) FLAWED = [("screen", s1_screen), ("dedupe", s2_dedupe_full), ("consumption", s3_consumption), ("summary", s4_summary), ("serve", s5_serve)] def c_uniqueness(data): return len(data) != len(set((k["subscriber"], k["period"]) for k in data)) def c_row_count(data, expected=2400, tolerance=0.05): return abs(len(data) - expected) / expected > tolerance
Duplicated Rows and an Unchanged Serving
The second block applies five setups to the same source. The raw column gives the row count in the
store, dedupe the rows dropped by that stage, summary the cell count of the fourth stage, and
north one of the five served values.
SOURCE_RECORDS = generate_source() FIRST = ingest(SOURCE_RECORDS, 1) BASELINE, _ = pipeline(FIRST) def report(name, store, stages=None): serving, trace = pipeline(store, stages) print(f"{name:<30}{len(store):>6}{trace[1]['dropped']:>9}{trace[3]['remaining']:>9}" f"{serving['north']:>11.2f}{'same' if serving == BASELINE else 'changed':>9}") one_period = resend(SOURCE_RECORDS, {"2026-07"}, 2) full = resend(SOURCE_RECORDS, set(PERIODS), 2) print("baseline serving:", BASELINE) print("resent rows: one period", len(one_period), " full run", len(full)) print() print(f"{'setup':<30}{'raw':>6}{'dedupe':>9}{'summary':>9}{'north':>11}{'serving':>9}") report("single run", FIRST) report("appending store , one period", store_append(FIRST, one_period)) report("appending store , full run", store_append(FIRST, full)) report("keyed store , full run", store_keyed(FIRST, full)) report("flawed deduplication , full", store_append(FIRST, full), FLAWED) print() d = store_append(FIRST, full) print("uniqueness check:", c_uniqueness(d), " row count check:", c_row_count(d))
baseline serving: {'central': 1321.45, 'north': 1212.55, 'east': 881.36, 'west': 833.09, 'south': 1065.55}
resent rows: one period 200 full run 2400
setup raw dedupe summary north serving
single run 2400 0 55 1212.55 same
appending store , one period 2600 200 55 1212.55 same
appending store , full run 4800 2400 55 1212.55 same
keyed store , full run 2400 0 55 1212.55 same
flawed deduplication , full 4800 0 55 2425.09 changed
uniqueness check: True row count check: True
The second row is the headline of the lesson: when one period is backfilled, the appending store carries 2600 rows, which means 200 rows were duplicated. The deduplication stage drops exactly those 200 rows, and the served value stays at 1212.55. The third row shows the same thing at a larger scale: a full rerun takes the store to 4800 rows, deduplication drops 2400 rows, and the serving is again the same.
This is the cleanest example of the third claim of the course. The size of the store has doubled, the pipeline has done the same work twice, yet none of the five served values has moved. An observation that looks at the output concludes that the pipeline is idempotent. But what is idempotent is not the pipeline, it is a single stage inside the pipeline: deduplication absorbs the surplus and tells nobody while doing so.
The fourth row shows what real idempotence is. After the second run the keyed store still carries 2400 rows: duplicated rows 0, rows dropped by deduplication 0. The difference is not in the store but beyond the pipeline; while producing the right number, the appending store takes up twice the space and cannot be used without a deduplication stage.
The last two rows are what the checks say. The uniqueness check fires on the appending store, and the row count check fires too — 4800 is far outside the five percent band around the expected 2400. So the detection lag of the corruption is 0, and the check that catches it is not a check that looks at the serving.
The Absorbed Correction
So far the value of the resent row was the same as the first one’s, so which one deduplication kept did not matter. In real backfills the situation is the reverse: the load is resent because it was corrected. The deduplication stage keeps the row it sees first, that is, the old one. In the setup below, the producer read the last period short, then corrected it and resent it. The corruption is deliberately placed in the last period: a shift in the middle of the series returns with the opposite sign in the following period and the two cancel out in the average.
def short_reading(records, period="2026-12", diff=100): """The producer read the last period short ; it will then correct it and resend.""" return [dict(k, index=k["index"] - diff) if k["period"] == period else k for k in records] SHORT = ingest(short_reading(SOURCE_RECORDS), 1) CORRECTION = resend(SOURCE_RECORDS, {"2026-12"}, 2) print("corrected rows:", len(CORRECTION)) print(f"{'setup':<32}{'raw':>6}{'dedupe':>9}{'north':>10}{'baseline':>10}") for name, store in (("short load , no correction", SHORT), ("appending store , correction", store_append(SHORT, CORRECTION)), ("keyed store , correction", store_keyed(SHORT, CORRECTION))): serving, trace = pipeline(store) print(f"{name:<32}{len(store):>6}{trace[1]['dropped']:>9}{serving['north']:>10.2f}" f"{BASELINE['north']:>10.2f}")
corrected rows: 200 setup raw dedupe north baseline short load , no correction 2400 0 794.36 1212.55 appending store , correction 2600 200 794.36 1212.55 keyed store , correction 2400 0 1212.55 1212.55
The second row is the most expensive finding of the lesson. The correction arrived at the pipeline, 200 rows were reprocessed, the store went up to 2600 rows, deduplication dropped 200 rows — and the served value stayed at 794.36, that is, exactly the same as before the correction. The 200 rows that were dropped were the corrected ones. The deduplication stage absorbed the second run; here, absorbing is losing.
This is the exact opposite reading of the previous section, and both come from the same behavior. When the resent row is the same as the first, deduplication saves the pipeline; when it is corrected, the same deduplication throws the correction away. The stage cannot tell the two apart, because all it has is the key and the key of the two rows is the same. The third row shows the solution: because the keyed store overwrites the old row with the new one, the serving returns to 1212.55, that is, to the baseline.
For a pipeline to count as idempotent, it is not enough that processing the same load twice leaves the result unchanged; processing a corrected load a second time must also correct the result. The appending store meets the first condition and does not meet the second.
When the Key Is Chosen Wrongly
The last row of the table opens up the most fragile assumption of the pipeline. The deduplication stage
looks at the (subscriber, period) key. Had the same stage been written to look at the whole row,
the resent row would look different because of the load stamp and would not be eliminated. This is not a
made-up flaw: asking whether a load is entirely the same seems intuitively safer, and that is exactly
why it gets written.
appending, keyed = FIRST, FIRST print(f"{'run':<6}{'appending raw':>15}{'north':>9}{'flawed north':>15}" f"{'keyed raw':>18}{'north':>9}") for run in range(1, 4): if run > 1: new = resend(SOURCE_RECORDS, set(PERIODS), run) appending = store_append(appending, new) keyed = store_keyed(keyed, new) serve_append, _ = pipeline(appending) serve_flawed, _ = pipeline(appending, FLAWED) serve_keyed, _ = pipeline(keyed) print(f"{run:<6}{len(appending):>15}{serve_append['north']:>9.2f}" f"{serve_flawed['north']:>15.2f}{len(keyed):>18}" f"{serve_keyed['north']:>9.2f}") print() summ, _ = s4_summary(s3_consumption(s2_dedupe_full(s1_screen(appending)[0])[0])[0]) summ_base, _ = s4_summary(s3_consumption(s2_dedupe(s1_screen(FIRST)[0])[0])[0]) print("summary ('north', '2026-07') baseline:", summ_base[("north", "2026-07")], " flawed deduplication:", summ[("north", "2026-07")]) print("keys kept by deduplication:", len(set((k["subscriber"], k["period"]) for k in FIRST)))
run appending raw north flawed north keyed raw north
1 2400 1212.55 1212.55 2400 1212.55
2 4800 1212.55 2425.09 2400 1212.55
3 7200 1212.55 3637.64 2400 1212.55
summary ('north', '2026-07') baseline: 1251 flawed deduplication: 3753
keys kept by deduplication: 2400
The three-run table sets two sentences side by side. In the pipeline that works with the right key, the
raw store goes from 2400 to 4800 to 7200 and the served value is 1212.55 in all three runs. In the
pipeline that works with the flawed key, the same raw store and the same reruns make the served value
grow as 1212.55 → 2425.09 → 3637.64: in direct proportion to the number of runs. The same shows in
the summary layer: the 2026-07 cell of the north is 1251 at the baseline and 3753 after the third
run.
The only difference between the two pipelines is the key choice of one stage. No corruption class, no field name changed, no type drifted; the only thing that happened on the producer side is that the same correct load was sent a second time. This is the form that the second claim of the course takes in this lesson: a pipeline’s own stage, with a flawed key, can triple the served number without any source corruption.
The column of the keyed store says where the cost lies. Idempotence is not free: at write time a key table of 2400 entries is kept and every row is looked up before it is written. The deduplication stage carries a set of the same size. In return, the store stays at 2400 rows regardless of the number of runs, and the correctness of the pipeline no longer depends on the key of a single stage.
Summary
- Reprocessing is an ordinary job; in an appending store, backfilling one period takes the raw rows from 2400 to 2600, a full rerun to 4800, and the third run to 7200.
- None of the five served values shows this growth: the north is 1212.55 in all three runs, because the deduplication stage absorbs the surplus and does not say so while absorbing.
- What is idempotent is not the pipeline but a single stage inside it; when keyed writing is used, duplicated rows fall to 0 and the store stays independent of the number of runs.
- When the resent load carries a correction, the same deduplication throws it away: the served value stays at 794.36, whereas the keyed store returns to 1212.55 with the same correction. Idempotence is not that the second run leaves the result unchanged, but that it corrects when it should correct.
- When deduplication looks at the whole row instead of
(subscriber, period), the same reruns carry the served value from 1212.55 to 2425.09 and then to 3637.64; the summary cell goes from 1251 to 3753. - The checks that catch duplication are uniqueness and row count, and both fire at stage 0; no check that looks at the serving can see it.
Next Step
In three lessons the check always looked at the same place: the row itself. Does it have its fields, is its type right, is its key unique, is its value in line with the previous periods. These questions give no answer when a serving value is corrupted, because none of them says where the row came from. Someone looking at the figure 3753 produced by the flawed deduplication has to read the whole pipeline from the beginning to find which loads contributed to that cell and at which link of the chain the error is. The next lesson counts this search: how many stages and how many rows one has to look at to go from a corrupted serving value back to the source, with a record kept and without one.
To keep your progress and take notes, Log in
My notes
Log in to take notes.