Skip to content
About
Main content

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 (). Processing first and then saving the position repeats the record after a crash (). 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 ?


Predict

Saving the position after processing, how does the double-counted surplus grow as the commit interval goes from every record to every 100?

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.

    commit-ordering-and-the-offset-in.py73 linesDownload
    View 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)
    
  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 commit-ordering-and-the-offset-in.py
    uv run commit-ordering-and-the-offset-in.py --help     # every knob
Knobs
Knob · defaultWhat 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)

Final total after 20 crashes (right answer 50,005,000)
VariantTotalOff by
(a) position before processing50M-31.8K
(b) position after processing50M31.8K
(c) both in one transaction50M0

Variant (b): position saved every N records

Variant (b): position saved every N records
NOff by (sum of replayed numbers)
131.8K
10421K
1005.18M
Reference runApple M2 · 8 cores · 8 GB
Output · uv run commit-ordering-and-the-offset-in.py
(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,088
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What 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 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 , and size the commit interval knowing that it is also the size of each replay.