The pointer swap: two writers, one winner
Break it on purposeNeeds Python
Problem
A table format commits a new version by moving one pointer from version N to version N + 1. Two writers that both read N and both write N + 1 would each believe they committed, and one change would vanish. The commit is safe only if moving the pointer is conditional: it succeeds only if the pointer still says N, so the loser learns it lost and retries on top of the new version.
Simulate a table commit with a pointer on the local file system. Each table version is a file v1.json, v2.json, and so on; the "pointer" is "the highest version number". Creating v(N+1).json with the exclusive-create flag works as a compare-and-swap, because only one creator can succeed. Two writer threads commit 50 changes each. Then replace the exclusive create with a plain write.
The question. With an exclusive create and with a plain write, how many of the 100 commits survive, and how many retries does it take?
Predict
Expect 100 versions with no lost or duplicated commit, and some retries. With a plain write you should see lost updates. That shows why the conditional update is the whole mechanism.
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
the-pointer-swap-two-writers-one.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """The pointer swap: two writers, one table. A table commit on the local file system: each version is a file vN.json and the "pointer" is the highest version number. Creating v(N+1).json with the exclusive-create flag acts as a compare-and-swap. Two writer threads commit 50 changes each and retry when they lose. Then the same with a plain write (create-or-overwrite) in place of the exclusive create. """ import argparse, json, os, tempfile, threading p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--commits", type=int, default=50, help="Commits per writer.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def commit(table_dir, base_version, change, exclusive): new_version = base_version + 1 path = os.path.join(table_dir, f"v{new_version}.json") flags = os.O_CREAT | os.O_WRONLY | (os.O_EXCL if exclusive else os.O_TRUNC) try: fd = os.open(path, flags) # with O_EXCL: fails if the file exists except FileExistsError: return False # lost the race with os.fdopen(fd, "w") as f: json.dump({"version": new_version, "change": change}, f) return True def latest(table_dir): return max(int(n[1:-5]) for n in os.listdir(table_dir) if n.startswith("v") and n.endswith(".json")) def writer(table_dir, name, results, retries, exclusive): for i in range(args.commits): while True: base = latest(table_dir) if commit(table_dir, base, f"{name}-{i}", exclusive): results[name] += 1 break # else: retry on top of the new latest retries[name] += 1 rows = [] for exclusive, label in ((True, "exclusive create (compare-and-swap)"), (False, "plain write (create or overwrite)")): d = tempfile.mkdtemp(); open(os.path.join(d, "v0.json"), "w").write("{}") results, retries = {"A": 0, "B": 0}, {"A": 0, "B": 0} ts = [threading.Thread(target=writer, args=(d, n, results, retries, exclusive)) for n in "AB"] [t.start() for t in ts]; [t.join() for t in ts] stored = [] for n in os.listdir(d): if n != "v0.json": try: stored.append(json.load(open(os.path.join(d, n)))["change"]) except (json.JSONDecodeError, KeyError): stored.append("<torn>") claimed = results["A"] + results["B"] rows.append({"commit": label, "claimed": claimed, "latest": latest(d), "stored": len(set(stored) - {"<torn>"}), "lost": claimed - len(set(stored) - {"<torn>"}), "retries": retries["A"] + retries["B"]}) for r in rows: print(f"{r['commit']:<38} writers think they committed {r['claimed']}; latest = v{r['latest']}; changes stored {r['stored']}; lost {r['lost']}; retries {r['retries']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"commits": rows}}, 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 the-pointer-swap-two-writers-one.py uv run the-pointer-swap-two-writers-one.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --commits50 | Commits per writer. |
Our run
Two writers, 50 commits each
| Commit | Writers think they committed | Latest version | Changes stored | Lost | Retries |
|---|---|---|---|---|---|
| exclusive create (compare-and-swap) | 100 | 100 | 100 | 0 | 51 |
| plain write (create or overwrite) | 100 | 66 | 66 | 34 | 0 |
exclusive create (compare-and-swap) writers think they committed 100; latest = v100; changes stored 100; lost 0; retries 51
plain write (create or overwrite) writers think they committed 100; latest = v66; changes stored 66; lost 34; retries 0What we found
With the exclusive create, all 100 changes were stored, after 51 retries. With a plain write the writers still believed they had committed 100 changes, but only 66 were stored: 34 were overwritten without any error.
Read it
Everything else in a table format (manifests, snapshots, statistics) rests on this one conditional write. Object stores that lacked it forced table formats to add an external lock or catalog; a store that offers it (conditional PUT) lets the format commit with no other coordinator.