Commit ordering and the offset in the sink
Break it on purposeNeeds Python
Problem
A consumer that keeps its position separately from its output has to choose an order. Saving the position first and then processing loses the record if it crashes in between (at-most-once). Processing first and then saving the position repeats the record after a crash (at-least-once). Only when the output and the position are written in one transaction does a crash leave both or neither: exactly-once, as far as that store is concerned. With at-least-once, the duplicates after a crash are everything processed since the last saved position, so saving less often means more of them.
Use SQLite. Write a consumer that reads numbers 1..10,000 with a stored position, and adds each number to a total table. Kill the process at random points (os._exit after a random number of steps) and restart it. Run three variants: (a) save the position to a file before processing (at-most-once), (b) save it after processing (at-least-once), (c) update total and the position row in one database transaction. Compare the final total with 50,005,000 after 20 random crashes. Then vary how often (b) saves its position.
The question. After 20 crashes, how far off is the total in each variant, and how do (b)'s duplicates grow with the commit interval?
Predict
(a) the total is too small, (b) the total is too large, (c) the total is exactly right on every run. Duplicates are a quantity equal to interval times rate.
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
commit-ordering-and-the-offset-in.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Commit ordering, and the offset in the same transaction. A consumer adds the numbers 1..10,000 to a total in SQLite and keeps a position; it crashes (os._exit) after a random number of steps (any write, so also between saving the position and processing the record) and restarts, 20 times. (a) position saved to a file before processing, (b) after processing, (c) total and position in one transaction. The right answer is 50,005,000. Then (b) with different commit intervals. """ import argparse, json, os, random, sqlite3, subprocess, sys, tempfile p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--records", type=int, default=10_000, help="Numbers 1..N.") p.add_argument("--crashes", type=int, default=20, help="Crashes per variant.") p.add_argument("--intervals", type=lambda s: [int(v) for v in s.split(",")], default=[1, 10, 100], help="Commit intervals for variant (b).") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--worker", help=argparse.SUPPRESS) p.add_argument("--dir", help=argparse.SUPPRESS) p.add_argument("--crash-after", type=int, help=argparse.SUPPRESS) p.add_argument("--every", type=int, default=1, help=argparse.SUPPRESS) p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() if args.worker: db = sqlite3.connect(os.path.join(args.dir, "db.sqlite"), isolation_level=None) posf = os.path.join(args.dir, "pos.txt") if args.worker == "c": pos = db.execute("SELECT pos FROM progress").fetchone()[0] else: pos = int(open(posf).read()) if os.path.exists(posf) else 0 steps = [0] def step(): # the process can die after any single step steps[0] += 1 if args.crash_after and steps[0] >= args.crash_after: os._exit(1) for n in range(pos + 1, args.records + 1): if args.worker == "a": open(posf, "w").write(str(n)); step(); db.execute("UPDATE total SET v = v + ?", (n,)); step() elif args.worker == "b": db.execute("UPDATE total SET v = v + ?", (n,)); step() if n % args.every == 0 or n == args.records: open(posf, "w").write(str(n)); step() else: db.execute("BEGIN"); db.execute("UPDATE total SET v = v + ?", (n,)); step() db.execute("UPDATE progress SET pos = ?", (n,)); step(); db.execute("COMMIT"); step() sys.exit(0) def variant(v, every=1): random.seed(args.seed) d = tempfile.mkdtemp(); db = sqlite3.connect(os.path.join(d, "db.sqlite")) db.execute("CREATE TABLE total (v INTEGER)"); db.execute("INSERT INTO total VALUES (0)") db.execute("CREATE TABLE progress (pos INTEGER)"); db.execute("INSERT INTO progress VALUES (0)"); db.commit(); db.close() for _ in range(args.crashes): subprocess.run([sys.executable, __file__, "--worker", v, "--dir", d, "--records", str(args.records), "--every", str(every), "--crash-after", str(random.randint(1, 2 * args.records // args.crashes))]) subprocess.run([sys.executable, __file__, "--worker", v, "--dir", d, "--records", str(args.records), "--every", str(every)], check=True) return sqlite3.connect(os.path.join(d, "db.sqlite")).execute("SELECT v FROM total").fetchone()[0] right = args.records * (args.records + 1) // 2 rows = [] for v, label in (("a", "(a) position before processing"), ("b", "(b) position after processing"), ("c", "(c) both in one transaction")): t = variant(v); rows.append({"variant": label, "total": t, "off_by": t - right}) print(f"{label:<34} total {t:>12,} off by {t - right:+,}") intervals = [] for every in args.intervals: t = variant("b", every); intervals.append({"commit_every": every, "off_by": t - right}) print(f"(b) with the position saved every {every:>3} records: off by {t - right:+,}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"variants": rows, "intervals": intervals, "summary": [{"right": right}]}}, 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 commit-ordering-and-the-offset-in.py uv run commit-ordering-and-the-offset-in.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --records10,000 | Numbers 1..N. |
| --crashes20 | Crashes per variant. |
| --intervals1, 10, 100 | Commit intervals for variant (b). |
| --seed0 | Random seed. |
Our run
Final total after 20 crashes (right answer 50,005,000)
| Variant | Total | Off by |
|---|---|---|
| (a) position before processing | 50M | -31.8K |
| (b) position after processing | 50M | 31.8K |
| (c) both in one transaction | 50M | 0 |
Variant (b): position saved every N records
| N | Off by (sum of replayed numbers) |
|---|---|
| 1 | 31.8K |
| 10 | 421K |
| 100 | 5.18M |
(a) position before processing total 49,973,151 off by -31,849
(b) position after processing total 50,036,794 off by +31,794
(c) both in one transaction total 50,005,000 off by +0
(b) with the position saved every 1 records: off by +31,794
(b) with the position saved every 10 records: off by +421,193
(b) with the position saved every 100 records: off by +5,180,088What we found
Saving the position first lost records: the total was -31.8K off. Saving it after processing double-counted: 31.8K. The single transaction was exact (0). In (b) the surplus grew with the commit interval, from 31.8K saving every record to 421K every 10 and 5.18M every 100: each crash replays everything since the last saved position.
Read it
Delivery guarantees are not a property of the transport; they are the order of two writes. If the output store can hold the position, put it there and write both in one transaction. If it cannot, choose at-least-once and make the output idempotent, and size the commit interval knowing that it is also the size of each replay.