isolab 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
isolab-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Kuboi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
isolab-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,646 @@
1
+ Metadata-Version: 2.4
2
+ Name: isolab
3
+ Version: 0.1.0
4
+ Summary: Deterministic PostgreSQL transaction-isolation anomaly lab (inspired by Designing Data-Intensive Applications, ch. 7)
5
+ Author: Daniel Kuboi
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Dan9el8/isolab
8
+ Project-URL: Repository, https://github.com/Dan9el8/isolab
9
+ Project-URL: Issues, https://github.com/Dan9el8/isolab/issues
10
+ Project-URL: Changelog, https://github.com/Dan9el8/isolab/releases
11
+ Keywords: postgresql,transactions,isolation-levels,concurrency,write-skew,serializable,testing,ddia
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Database
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: psycopg[binary]>=3.1
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # isolab — a deterministic PostgreSQL isolation-anomaly lab
32
+
33
+ > Most engineers can recite "ACID". Far fewer can say which concurrency bugs **their** database
34
+ > still allows at **their** isolation level. `isolab` answers that with evidence: it forces the exact
35
+ > unlucky interleaving against a real PostgreSQL server, checks a business invariant afterwards, and
36
+ > prints a matrix of *scenario × fix × isolation level*.
37
+
38
+ Inspired by Chapter 7 (Transactions) of Martin Kleppmann's *Designing Data-Intensive Applications*,
39
+ which argues that "ACID" is vague in practice and that weak isolation causes silent data corruption.
40
+ This repo turns that argument into something you can run, diff across Postgres versions, and extend
41
+ with bugs from your own systems.
42
+
43
+ ```
44
+ READ COMMITTED REPEATABLE READ SERIALIZABLE
45
+
46
+ Write skew (on-call doctors) [DDIA ch. 7, 'Write Skew and Phantoms']
47
+ bug: naive ✘ ANOMALY ✘ ANOMALY ⛔ aborted
48
+ fix: for_update ✔ safe ⛔ aborted ⛔ aborted
49
+ ```
50
+
51
+ *(Real output, PostgreSQL 16.15. Full matrix below.)*
52
+
53
+ ---
54
+
55
+ ## Contents
56
+
57
+ 1. [Why this exists](#1-why-this-exists)
58
+ 2. [Results](#2-results-postgresql-16)
59
+ 3. [Quick start](#3-quick-start)
60
+ 4. [CLI reference](#4-cli-reference)
61
+ 5. [How it works](#5-how-it-works)
62
+ 6. [Scenario catalogue](#6-scenario-catalogue)
63
+ 7. [Writing your own scenario](#7-writing-your-own-scenario)
64
+ 8. [Testing and CI](#8-testing-and-ci)
65
+ 9. [Limitations (read this)](#9-limitations-read-this)
66
+ 10. [Roadmap](#10-roadmap)
67
+ 11. [Project layout](#11-project-layout)
68
+ 12. [References](#12-references)
69
+
70
+ ---
71
+
72
+ ## 1. Why this exists
73
+
74
+ Concurrency bugs caused by weak isolation are **silent**. Nothing crashes. Two requests succeed, each
75
+ doing something reasonable on its own, and the combination breaks a rule nobody wrote a test for:
76
+
77
+ - two doctors both go off call, leaving none on duty;
78
+ - two payouts both pass a balance check and the wallet goes negative;
79
+ - the same payment callback is delivered twice and credits a wallet twice;
80
+ - a meeting room is booked by two people for overlapping hours.
81
+
82
+ They rarely show up in development because a single developer never produces the overlap. They show
83
+ up in production under load, intermittently, which makes them very expensive to diagnose.
84
+
85
+ The usual testing approach, "spawn N threads and hope", passes by luck. This tool takes the opposite
86
+ approach: **control the interleaving exactly**, so a bug is either provably reachable at a given
87
+ isolation level or provably not.
88
+
89
+ What you get:
90
+
91
+ - A reproducible answer to "does my fix actually work **at the isolation level I run in
92
+ production**?" (spoiler: several well-known fixes only work at some levels, see
93
+ [§6](#6-scenario-catalogue)).
94
+ - A regression test for database upgrades or config changes (`isolab verify` exits non-zero if any
95
+ behaviour differs from the documented expectation).
96
+ - A teaching tool: `--trace` prints the exact SQL timeline of any cell.
97
+
98
+ ---
99
+
100
+ ## 2. Results (PostgreSQL 16)
101
+
102
+ Generated by `isolab run --no-retry` against PostgreSQL 16.15. Every cell was also run repeatedly
103
+ (`isolab verify --repeat 10`) with identical results each time; the interleavings are deterministic.
104
+ The full report with observed violation messages is in [`docs/RESULTS.md`](docs/RESULTS.md).
105
+
106
+ | Scenario | Variant | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
107
+ |---|---|---|---|---|
108
+ | **Lost update** | `naive` (bug) | ❌ ANOMALY | ⛔ aborted | ⛔ aborted |
109
+ | | `atomic_update` | ✅ safe | ⛔ aborted | ⛔ aborted |
110
+ | | `for_update` | ✅ safe | ⛔ aborted | ⛔ aborted |
111
+ | | `optimistic_version` | ⛔ aborted | ⛔ aborted | ⛔ aborted |
112
+ | **Write skew** | `naive` (bug) | ❌ ANOMALY | ❌ ANOMALY | ⛔ aborted |
113
+ | | `for_update` | ✅ safe | ⛔ aborted | ⛔ aborted |
114
+ | **Phantom (double booking)** | `naive` (bug) | ❌ ANOMALY | ❌ ANOMALY | ⛔ aborted |
115
+ | | `exclusion_constraint` | ⛔ aborted | ⛔ aborted | ⛔ aborted |
116
+ | | `lock_room` | ✅ safe | ❌ **ANOMALY** | ⛔ aborted |
117
+ | **Read skew** | `naive` (bug) | ❌ ANOMALY | ✅ safe | ✅ safe |
118
+ | **Wallet overdraft** | `naive` (bug) | ❌ ANOMALY | ⛔ aborted | ⛔ aborted |
119
+ | | `guarded_update` | ✅ safe | ⛔ aborted | ⛔ aborted |
120
+ | | `check_constraint` | ⛔ aborted | ⛔ aborted | ⛔ aborted |
121
+ | **Duplicate M-Pesa callback** | `naive` (bug) | ❌ ANOMALY | ❌ ANOMALY | ⛔ aborted |
122
+ | | `unique_constraint` | ⛔ aborted | ⛔ aborted | ⛔ aborted |
123
+ | | `idempotent_insert` | ✅ safe | ⛔ aborted | ⛔ aborted |
124
+
125
+ **How to read a cell**
126
+
127
+ | Symbol | Meaning |
128
+ |---|---|
129
+ | ✅ **safe** | No transaction was rejected and the invariant holds. |
130
+ | ⛔ **aborted** | The database (or an app-level check) rejected a transaction. The invariant holds, **but your application received an error and must retry.** |
131
+ | 🔁 **retried** | Default mode: the aborted transaction was re-run from scratch, the way a well-behaved app would, and succeeded. Same as ⛔ plus proof that retrying works. |
132
+ | ❌ **ANOMALY** | The invariant was violated. Real data corruption. |
133
+
134
+ ### What the matrix teaches
135
+
136
+ 1. **PostgreSQL's `REPEATABLE READ` is snapshot isolation.** It stops read skew and lost updates (by
137
+ aborting the loser), but write skew and phantoms sail straight through. This is the single most
138
+ common surprise.
139
+ 2. **Only `SERIALIZABLE` protects the naive code**, and it does so by aborting. Using it means writing
140
+ retry logic. A ⛔ is not "free": it is an error path you must handle.
141
+ 3. **A fix is a (technique × isolation level) pair, not a technique.** `FOR UPDATE` is safe at READ
142
+ COMMITTED but turns into aborts at higher levels. `lock_room` is safe at READ COMMITTED yet
143
+ **fails at REPEATABLE READ** (see [§6.3](#63-phantom-double-booked-room)).
144
+ 4. **Constraints beat clever application logic.** `EXCLUDE`, `UNIQUE` and `CHECK` give the same
145
+ ⛔ result at all three levels, because they are enforced at write time regardless of what
146
+ your snapshot saw.
147
+ 5. **Pushing the logic into one statement** (`SET value = value + 1`,
148
+ `UPDATE … WHERE balance >= 80`, `INSERT … ON CONFLICT DO NOTHING`) is the cheapest correct fix
149
+ at READ COMMITTED, the default level.
150
+
151
+ ---
152
+
153
+ ## 3. Quick start
154
+
155
+ **Requirements:** Python 3.10+, and a PostgreSQL 14+ server you can freely use (see the warning
156
+ below). The only Python dependency is `psycopg[binary]`.
157
+
158
+ ```bash
159
+ # 1. a throwaway Postgres on localhost:5433 (needs Docker)
160
+ make up
161
+
162
+ # 2. install
163
+ python -m venv .venv && source .venv/bin/activate
164
+ make install # pip install -e ".[dev]"
165
+
166
+ # 3. run everything
167
+ isolab run
168
+
169
+ # 4. see exactly what happened in one cell
170
+ isolab run write_skew --variant naive --level rr --trace
171
+ ```
172
+
173
+ No Docker? Point at any Postgres:
174
+
175
+ ```bash
176
+ export ISOLAB_DSN="postgresql://user:pass@localhost:5432/scratch_db"
177
+ isolab run
178
+ ```
179
+
180
+ > ⚠️ **Use a scratch database.** For every cell `isolab` runs `DROP SCHEMA IF EXISTS isolab CASCADE`
181
+ > and recreates it. It never touches other schemas, but don't point it at a database where a schema
182
+ > named `isolab` matters. It also runs `CREATE EXTENSION IF NOT EXISTS btree_gist` (a trusted
183
+ > extension on PostgreSQL 13+; if it can't be created, the one variant that needs it is skipped and
184
+ > reported as such). Do not run two `isolab` processes against the same database at once.
185
+
186
+ ### Example: tracing a bug
187
+
188
+ ```text
189
+ $ isolab run write_skew --variant naive --level rr --trace
190
+
191
+ --- write_skew / naive @ REPEATABLE READ ---
192
+ 1 A read BEGIN ISOLATION LEVEL REPEATABLE READ
193
+ SELECT count(*) FROM doctors WHERE on_call -> [(2,)]
194
+ 2 B read BEGIN ISOLATION LEVEL REPEATABLE READ
195
+ SELECT count(*) FROM doctors WHERE on_call -> [(2,)]
196
+ 3 A write UPDATE doctors SET on_call = false WHERE name = 'alice' -> 1 row(s)
197
+ 4 B write UPDATE doctors SET on_call = false WHERE name = 'bob' -> 1 row(s)
198
+ 5 A commit COMMIT
199
+ 6 B commit COMMIT
200
+ result: ANOMALY - nobody is on call (write skew)
201
+ ```
202
+
203
+ Same schedule at `SERIALIZABLE`: both writes succeed, then the second `COMMIT` fails.
204
+
205
+ ```text
206
+ 5 A commit COMMIT
207
+ 6 B commit COMMIT
208
+ ✘ serialization_failure: could not serialize access due to read/write dependencies among transactions
209
+ result: ABORTED
210
+ ```
211
+
212
+ And a fix that blocks, from `lost_update / for_update @ READ COMMITTED`:
213
+
214
+ ```text
215
+ 1 A read BEGIN ISOLATION LEVEL READ COMMITTED
216
+ SELECT value FROM counters WHERE id = 1 FOR UPDATE -> [(0,)]
217
+ 2 B read ⏸ txn B is waiting on a lock; schedule continues
218
+ 3 A write UPDATE counters SET value = 1 WHERE id = 1 -> 1 row(s)
219
+ 4 A commit COMMIT
220
+ 5 B read BEGIN ISOLATION LEVEL READ COMMITTED
221
+ SELECT value FROM counters WHERE id = 1 FOR UPDATE -> [(1,)]
222
+ 6 B write UPDATE counters SET value = 2 WHERE id = 1 -> 1 row(s)
223
+ 7 B commit COMMIT
224
+ result: SAFE
225
+ ```
226
+
227
+ Notice B's `SELECT` returns `1`, not `0`. Under READ COMMITTED a blocked `FOR UPDATE` re-reads the
228
+ row after the lock is released, which is exactly why this fix works at that level.
229
+
230
+ ---
231
+
232
+ ## 4. CLI reference
233
+
234
+ ```
235
+ isolab [--dsn DSN] <command> [options]
236
+ ```
237
+
238
+ `--dsn` defaults to `$ISOLAB_DSN`, then `postgresql://postgres:postgres@localhost:5433/isolab`
239
+ (the `docker-compose.yml` database).
240
+
241
+ | Command | What it does |
242
+ |---|---|
243
+ | `isolab list` | List every scenario and its variants (bug vs fix). |
244
+ | `isolab explain <scenario>` | Print the story, each variant's exact schedule, and the documented result per level. No database needed. |
245
+ | `isolab run [scenario ...]` | Run cells and print the matrix. |
246
+ | `isolab verify [scenario ...]` | Run cells and exit **1** if any outcome differs from the documented expectation, or is non-deterministic. |
247
+
248
+ Options for `run` and `verify`:
249
+
250
+ | Option | Meaning |
251
+ |---|---|
252
+ | `--variant KEY` | Only this variant (`naive`, `for_update`, …). |
253
+ | `--level L` | Only one isolation level: `rc`, `rr`, `ser` (or `"read committed"`, etc.). |
254
+ | `--repeat N` | Run every cell N times. Output shows `×N/N` and flags any non-determinism. |
255
+ | `--no-retry` | Don't retry aborted transactions; report them as ⛔ aborted instead of 🔁 retried. |
256
+
257
+ Options for `run` only:
258
+
259
+ | Option | Meaning |
260
+ |---|---|
261
+ | `--format table\|markdown\|json` | Output format (default `table`). |
262
+ | `-o, --output FILE` | Write the report to a file. |
263
+ | `--trace` | Print the step-by-step SQL timeline of every selected cell. |
264
+
265
+ ```bash
266
+ isolab run # everything, with retries
267
+ isolab run --no-retry # show raw aborts
268
+ isolab run lost_update mpesa_callback # two scenarios
269
+ isolab run phantom_booking --variant lock_room --level rr --trace
270
+ isolab run --format json -o results.json # for dashboards / diffing
271
+ isolab verify --repeat 20 # CI gate
272
+ ```
273
+
274
+ ---
275
+
276
+ ## 5. How it works
277
+
278
+ ### 5.1 The problem with "spawn threads and hope"
279
+
280
+ A concurrency bug needs a specific interleaving, for instance *A reads, B reads, A writes, B writes*.
281
+ Real threads hit it only occasionally, so a passing test proves nothing. `isolab` doesn't generate
282
+ interleavings, it **dictates** them.
283
+
284
+ ### 5.2 Architecture
285
+
286
+ ```
287
+ ┌─────────────────────────────────────────────┐
288
+ Variant │ schedule: A.read B.read A.write B.write │
289
+ (scenario file) │ A.commit B.commit │
290
+ └──────────────────────┬──────────────────────┘
291
+ │ one step at a time
292
+ ┌────────▼────────┐ ┌──────────────────┐
293
+ │ Scheduler │◄───────│ observer conn │
294
+ │ (main thread) │ polls │ pg_stat_activity │
295
+ └───┬─────────┬───┘ └──────────────────┘
296
+ submit step │ │ submit step
297
+ ┌───────▼──┐ ┌──▼───────┐
298
+ │ Worker A │ │ Worker B │ one thread + one
299
+ │ conn #1 │ │ conn #2 │ PostgreSQL connection
300
+ └───────┬──┘ └──┬───────┘ per transaction
301
+ └────┬────┘
302
+ ┌────▼─────┐
303
+ │PostgreSQL│
304
+ └────┬─────┘
305
+ after all steps: ┌──▼───────────────────────┐
306
+ │ retry aborted txns │
307
+ │ check invariant → outcome│
308
+ └──────────────────────────┘
309
+ ```
310
+
311
+ Each **cell** (scenario × variant × isolation level) runs in a freshly recreated schema:
312
+
313
+ 1. **Setup**: create tables and seed rows.
314
+ 2. **Start workers**: one thread and one real connection per transaction.
315
+ 3. **Run the schedule** one step at a time (see 5.3).
316
+ 4. **Drain**: wait for all workers to finish.
317
+ 5. **Retry** any transaction that was aborted by a retryable error (see 5.5).
318
+ 6. **Check the invariant** on a separate connection and classify the outcome.
319
+
320
+ ### 5.3 Deterministic scheduling with lock detection
321
+
322
+ Releasing steps strictly in order has one catch: some steps *block*. In the "locking" fixes,
323
+ B's `SELECT … FOR UPDATE` must wait for A. A naive scheduler would wait forever.
324
+
325
+ The scheduler therefore asks PostgreSQL itself. While waiting for a step to finish it polls
326
+ `pg_stat_activity.wait_event_type` for that backend. If the backend is waiting on a `Lock`, the
327
+ step is recorded as ⏸ blocked and the schedule moves on. The blocked step completes later, when the
328
+ lock holder commits or rolls back.
329
+
330
+ There are no sleeps or timing guesses. After **every** step the scheduler also *settles*: it waits
331
+ until every worker is either idle or lock-blocked. This guarantees that anything a commit unblocked
332
+ has fully run before the next scheduled step starts. That is what makes 10 repeats give 10 identical
333
+ results.
334
+
335
+ ### 5.4 Outcomes
336
+
337
+ After the schedule finishes, the scenario's **invariant** function inspects the database (and the
338
+ values each transaction observed) and returns a list of violations.
339
+
340
+ | Condition | Outcome |
341
+ |---|---|
342
+ | any violation | `ANOMALY` |
343
+ | a transaction was rejected, none retried or retry failed | `ABORTED` |
344
+ | a transaction was rejected, retry succeeded, invariant holds | `RETRIED` |
345
+ | nothing rejected, invariant holds | `SAFE` |
346
+ | a transaction died with an unexpected error | `ERROR` (harness bug or unsupported situation) |
347
+
348
+ Invariants are written against what **actually committed**. For example, the lost-update invariant
349
+ is *"counter equals the number of transactions that committed an increment"*, not *"counter equals
350
+ 2"*. Otherwise a correctly aborted transaction would be misreported as an anomaly.
351
+
352
+ ### 5.5 Retry model
353
+
354
+ A rejected transaction counts as *prevented*, but a real application must then re-run it. To prove
355
+ that the fix is complete, `isolab` retries rejected transactions after the schedule has finished
356
+ (up to 3 attempts, one after another, each in a fresh transaction at the same isolation level) and
357
+ re-evaluates the invariant.
358
+
359
+ Retryable errors: `40001 serialization_failure`, `40P01 deadlock_detected`,
360
+ `23505 unique_violation`, `23P01 exclusion_violation`, `23514 check_violation`, and the
361
+ library's own `AppConflict` (used for optimistic-lock version mismatches).
362
+
363
+ Because each step re-reads state, the retried transaction takes the correct branch. For example, the
364
+ second doctor now sees only one doctor on call and declines to go off call. Pass `--no-retry` to skip
365
+ this and see the raw ⛔.
366
+
367
+ ### 5.6 A subtlety that affects results: when the snapshot is taken
368
+
369
+ In `REPEATABLE READ` and `SERIALIZABLE`, PostgreSQL takes the transaction's snapshot at the **first
370
+ statement**, not at `BEGIN`. `isolab` issues `BEGIN ISOLATION LEVEL …` lazily as part of each
371
+ transaction's first step, so the first step of the schedule is where the snapshot is fixed. This
372
+ matters for the `lock_room` result in [§6.3](#63-phantom-double-booked-room).
373
+
374
+ ---
375
+
376
+ ## 6. Scenario catalogue
377
+
378
+ Every scenario defines: the **story**, the **invariant** it protects, the **interleaving** that
379
+ breaks it, and a set of **variants** (naive code plus candidate fixes). Run
380
+ `isolab explain <scenario>` for the exact schedules.
381
+
382
+ ### 6.1 Lost update
383
+
384
+ *Two transactions read a counter, add one in application code, write it back.
385
+ Invariant: counter equals the number of committed increments.*
386
+
387
+ Schedule: `A.read B.read A.write A.commit B.write B.commit`
388
+
389
+ | Variant | RC | RR | SER | Why |
390
+ |---|---|---|---|---|
391
+ | `naive` | ❌ | ⛔ | ⛔ | At READ COMMITTED B overwrites A's value with a stale `0 + 1`. At RR/SER, PostgreSQL detects that B's snapshot is stale and aborts it ("could not serialize access due to concurrent update"). |
392
+ | `atomic_update` | ✅ | ⛔ | ⛔ | `SET value = value + 1` makes the read-modify-write a single statement. At RC, B waits for A's row lock, then re-evaluates against the new value. |
393
+ | `for_update` | ✅ | ⛔ | ⛔ | B's locking `SELECT` waits, then (at RC) re-reads the committed value. At RR/SER it can't proceed on a row changed since its snapshot, so it aborts. |
394
+ | `optimistic_version` | ⛔ | ⛔ | ⛔ | `WHERE version = <read>` matches zero rows, the app raises `AppConflict`. At RR/SER the database aborts first. Always correct, always needs a retry loop. |
395
+
396
+ ### 6.2 Write skew (on-call doctors)
397
+
398
+ *The book's canonical example. Alice and Bob are on call; each may go off call if at least two
399
+ doctors are on call. Invariant: at least one doctor stays on call.*
400
+
401
+ Schedule: `A.read B.read A.write B.write A.commit B.commit`
402
+
403
+ | Variant | RC | RR | SER | Why |
404
+ |---|---|---|---|---|
405
+ | `naive` | ❌ | ❌ | ⛔ | Each transaction updates a **different row**, so there is no write-write conflict for snapshot isolation to detect. Only SERIALIZABLE's read/write dependency tracking catches it. |
406
+ | `for_update` | ✅ | ⛔ | ⛔ | Locking every on-call row while counting forces B to wait. At RC, B then re-evaluates and sees one doctor, so it declines. At RR it aborts. |
407
+
408
+ ### 6.3 Phantom (double-booked room)
409
+
410
+ *Alice books room 1 for 09–11, Bob for 10–12. Each first checks that no overlapping booking exists.
411
+ Invariant: bookings of the same room never overlap.*
412
+
413
+ Schedule: `A.check B.check A.book B.book A.commit B.commit`
414
+ (`lock_room` adds a leading `lock` step per transaction.)
415
+
416
+ | Variant | RC | RR | SER | Why |
417
+ |---|---|---|---|---|
418
+ | `naive` | ❌ | ❌ | ⛔ | The conflicting row doesn't exist yet when either transaction checks, so there is nothing to lock or conflict with. This is a *phantom*. |
419
+ | `exclusion_constraint` | ⛔ | ⛔ | ⛔ | `EXCLUDE USING gist (room_id WITH =, int4range(start_hour, end_hour) WITH &&)` makes the database enforce non-overlap. B's insert blocks on A's uncommitted row, then fails with `exclusion_violation`. Works at every level. |
420
+ | `lock_room` | ✅ | ❌ | ⛔ | **The interesting one.** Materializing the conflict by locking the parent `rooms` row works at READ COMMITTED. At REPEATABLE READ it **fails**: B's snapshot was fixed at its *first* statement (the blocked lock request), before A committed, so after B finally gets the lock its overlap check still can't see A's booking. |
421
+
422
+ The `lock_room` row is why [§5.6](#56-a-subtlety-that-affects-results-when-the-snapshot-is-taken) exists:
423
+ *"take a lock before reading"* is only a fix if the snapshot is taken **after** the lock is acquired.
424
+
425
+ ### 6.4 Read skew (non-repeatable read)
426
+
427
+ *An auditor reads two accounts one after the other while a transfer moves 100 between them.
428
+ Invariant: what the auditor sees must add up to 1000.*
429
+
430
+ Schedule: `R.read_checking T.transfer T.commit R.read_savings R.commit`
431
+
432
+ | Variant | RC | RR | SER | Why |
433
+ |---|---|---|---|---|
434
+ | `naive` | ❌ | ✅ | ✅ | At READ COMMITTED each statement gets a fresh snapshot, so the auditor sees `500 + 600 = 1100`. REPEATABLE READ gives the whole transaction one snapshot, so the totals are consistent without any abort. |
435
+
436
+ This is the one scenario where the fix **is** the isolation level. The only other fix is reading both
437
+ balances in a single statement.
438
+
439
+ ### 6.5 Wallet overdraft (double spend)
440
+
441
+ *A wallet holds 100. Two payouts of 80 arrive together; each checks the balance first.
442
+ Invariant: balance never negative, and equals opening balance + ledger.*
443
+
444
+ Schedule: `A.check B.check A.debit B.debit A.commit B.commit`
445
+
446
+ | Variant | RC | RR | SER | Why |
447
+ |---|---|---|---|---|
448
+ | `naive` | ❌ | ⛔ | ⛔ | Check and debit are separate statements. At RC both pass the check and the balance ends at **−60**. |
449
+ | `guarded_update` | ✅ | ⛔ | ⛔ | `UPDATE … WHERE balance >= 80` plus a row-count check. The second payout is cleanly *declined*, with no error. |
450
+ | `check_constraint` | ⛔ | ⛔ | ⛔ | Naive code plus `CHECK (balance >= 0)`. The database refuses the second debit; a retry then sees 20 and declines. |
451
+
452
+ ### 6.6 Duplicate payment callback (M-Pesa style)
453
+
454
+ *Payment providers retry callbacks that aren't acknowledged quickly, so the same receipt can arrive
455
+ at two workers concurrently. Each checks "have I seen this receipt?" before crediting.
456
+ Invariant: a receipt credits the wallet at most once.*
457
+
458
+ The wallet is modelled as an append-only ledger (balance = sum of rows), the event-sourced shape
459
+ discussed in the book, which makes the race purely a check-then-insert problem.
460
+
461
+ Schedule: `A.check B.check A.credit B.credit A.commit B.commit`
462
+
463
+ | Variant | RC | RR | SER | Why |
464
+ |---|---|---|---|---|
465
+ | `naive` | ❌ | ❌ | ⛔ | Both see "not seen yet" and both insert: `receipt QGH7XYZ123 credited 2 times = 2000 for a single payment of 1000`. |
466
+ | `unique_constraint` | ⛔ | ⛔ | ⛔ | A unique index on the receipt turns the duplicate into a `unique_violation`. Cheap and robust. |
467
+ | `idempotent_insert` | ✅ | ⛔ | ⛔ | `INSERT … ON CONFLICT (mpesa_receipt) DO NOTHING` with the receipt as an idempotency key. At RC the loser silently becomes a no-op. |
468
+
469
+ ---
470
+
471
+ ## 7. Writing your own scenario
472
+
473
+ The most valuable use of this tool is encoding a bug **from your own system**. A scenario is a plain
474
+ Python module. Here is the shape (condensed from `isolab/scenarios/lost_update.py`):
475
+
476
+ ```python
477
+ # isolab/scenarios/my_bug.py
478
+ from ..model import ABORTED, ANOMALY, SAFE, Scenario, Step, Variant
479
+
480
+ SETUP = [
481
+ "CREATE TABLE counters (id int PRIMARY KEY, value int NOT NULL)",
482
+ "INSERT INTO counters VALUES (1, 0)",
483
+ ]
484
+
485
+ # A step is a function that receives a session `tx`.
486
+ # tx.scalar(sql, *params) -> first column of first row
487
+ # tx.rows(sql, *params) -> list of tuples
488
+ # tx.run(sql, *params) -> affected row count (DML)
489
+ # tx.ctx -> dict private to this transaction (carry values between steps)
490
+ def read(tx):
491
+ tx.ctx["v"] = tx.scalar("SELECT value FROM counters WHERE id = 1")
492
+
493
+ def write(tx):
494
+ tx.run("UPDATE counters SET value = %s WHERE id = 1", tx.ctx["v"] + 1)
495
+
496
+ # The invariant is called after the schedule with a session on the final state
497
+ # and a dict {txn_label: TxnResult(committed, ctx, ...)}. Return a list of violations.
498
+ def invariant(db, txns):
499
+ committed = sum(t.committed for t in txns.values())
500
+ value = db.scalar("SELECT value FROM counters WHERE id = 1")
501
+ return [] if value == committed else [f"counter is {value}, {committed} committed"]
502
+
503
+ SCENARIO = Scenario(
504
+ key="my_bug", title="My bug", summary="...", book_ref="...",
505
+ setup=SETUP, invariant=invariant,
506
+ variants=[
507
+ Variant(
508
+ key="naive", title="Read, add, write", description="...",
509
+ txns={"A": [Step("read", read), Step("write", write)],
510
+ "B": [Step("read", read), Step("write", write)]},
511
+ # The interleaving that breaks it. Each txn's steps appear once, in order,
512
+ # followed by "commit".
513
+ schedule=[("A", "read"), ("B", "read"), ("A", "write"), ("A", "commit"),
514
+ ("B", "write"), ("B", "commit")],
515
+ # What you expect at each level. Run once, then record what you *understand*.
516
+ expected={"READ COMMITTED": ANOMALY, "REPEATABLE READ": ABORTED,
517
+ "SERIALIZABLE": ABORTED},
518
+ is_fix=False,
519
+ ),
520
+ ],
521
+ )
522
+ ```
523
+
524
+ Then register it in `isolab/scenarios/__init__.py`:
525
+
526
+ ```python
527
+ from . import my_bug
528
+ ALL.append(my_bug.SCENARIO) # or add it to the list literal
529
+ ```
530
+
531
+ Rules, enforced when the module is imported:
532
+
533
+ - the schedule must mention every step of every transaction **exactly once, in order**, followed by
534
+ `commit`, so a retry can replay a transaction from the top;
535
+ - `expected` must cover all three isolation levels with `ANOMALY`, `ABORTED` or `SAFE`;
536
+ - use `%s` for parameters;
537
+ - extra DDL for one variant (a constraint, an index) goes in `Variant(setup=[...])`;
538
+ - raise `AppConflict` from a step to model an application-level optimistic-lock failure;
539
+ - a variant that needs a contrib extension declares `requires=("btree_gist",)`.
540
+
541
+ **Good scenarios to add from real systems:** an inventory oversell (`stock >= 0`), a "claim this job"
542
+ queue (two workers dequeuing the same row; try `FOR UPDATE SKIP LOCKED` as the fix), a
543
+ two-step-transfer deadlock, a username-uniqueness check-then-insert, or a double-spend on your wallet
544
+ service.
545
+
546
+ ---
547
+
548
+ ## 8. Testing and CI
549
+
550
+ ```bash
551
+ make test # pytest: 59 tests (static validation + every cell of the matrix)
552
+ make verify # isolab verify, exit code 1 on any mismatch
553
+ ```
554
+
555
+ - `tests/test_model.py` checks that scenarios and schedules are well formed. No database needed.
556
+ - `tests/test_matrix.py` runs **every** (scenario, variant, level) cell and asserts it matches the
557
+ documented expectation, and also asserts determinism (15 repeats of one cell) and the retry
558
+ accounting. It skips itself if no server is reachable.
559
+ - `.github/workflows/ci.yml` runs the suite and `isolab verify --repeat 3` against PostgreSQL
560
+ 14, 15, 16 and 17. Because expectations are documented per cell, **a behavioural change in a new
561
+ Postgres release shows up as a failing test**.
562
+
563
+ Status of verification: the full suite (59 tests) passes against PostgreSQL 16.15, and the 48-cell
564
+ matrix verified identical across 10 consecutive repeats. The 14/15/17 CI matrix and `docker-compose.yml`
565
+ are provided but have not been executed by the author yet. If a cell differs on your version, that is
566
+ a finding worth a close look rather than something to paper over.
567
+
568
+ ---
569
+
570
+ ## 9. Limitations (read this)
571
+
572
+ Being clear about what this tool is **not** keeps its results trustworthy:
573
+
574
+ - **It proves reachability, not probability.** A forced interleaving shows that an anomaly *can*
575
+ happen at a level. It says nothing about how often it happens under your real traffic.
576
+ - **PostgreSQL only.** Other engines implement the same level names very differently (for example,
577
+ MySQL/InnoDB's REPEATABLE READ is not PostgreSQL's), so these results do not transfer.
578
+ - **Two transactions per scenario.** Real anomalies sometimes need three or more participants.
579
+ - **It is not Jepsen.** There is no fault injection, no network partitions, no multi-node cluster,
580
+ and no history checker (Elle-style). It examines single-node transaction semantics only.
581
+ - **The schedules are hand-written.** The tool checks the interleavings you encode, not the
582
+ interleavings that exist. A "safe" cell means *this* schedule was safe.
583
+ - **SERIALIZABLE aborts can be false positives.** PostgreSQL's SSI is conservative and may abort a
584
+ transaction that would have been fine. Treat ⛔ at SERIALIZABLE as "must retry", never as proof
585
+ the transactions truly conflicted.
586
+ - **Invariants are yours.** If an invariant is wrong or incomplete, a "safe" result is meaningless.
587
+ Reviewing the `invariant()` function is part of reviewing a scenario.
588
+ - **Retry is simplified.** Retries run sequentially after the schedule, with up to three attempts, not
589
+ under contention. It demonstrates that a retry converges to a correct state, not that your retry
590
+ loop handles backoff or load.
591
+
592
+ ---
593
+
594
+ ## 10. Roadmap
595
+
596
+ - **Stress mode**: N workers with randomized interleavings, to measure how *often* an anomaly appears
597
+ under load (complements the deterministic mode).
598
+ - **More engines**: MySQL/InnoDB and MongoDB transactions, to show the same anomaly behaving
599
+ differently per engine.
600
+ - **History checking**: record transaction histories and check them with an Elle-style analysis
601
+ instead of hand-written invariants.
602
+ - **Three-transaction schedules** and **deadlock scenarios**.
603
+ - **Scenario DSL** (YAML) for non-Python users.
604
+
605
+ ---
606
+
607
+ ## 11. Project layout
608
+
609
+ ```
610
+ .
611
+ ├── README.md
612
+ ├── pyproject.toml # installs the `isolab` command
613
+ ├── docker-compose.yml # throwaway Postgres 16 on :5433
614
+ ├── Makefile # up / install / run / verify / test / report
615
+ ├── docs/RESULTS.md # generated report (make report)
616
+ ├── .github/workflows/ci.yml # verify against Postgres 14-17
617
+ ├── isolab/
618
+ │ ├── model.py # Scenario / Variant / Step + schedule validation
619
+ │ ├── engine.py # sessions, workers, deterministic scheduler, retry, outcomes
620
+ │ ├── report.py # table / markdown / json / trace renderers
621
+ │ ├── cli.py # list | explain | run | verify
622
+ │ └── scenarios/
623
+ │ ├── lost_update.py
624
+ │ ├── write_skew.py
625
+ │ ├── phantom_booking.py
626
+ │ ├── read_skew.py
627
+ │ ├── wallet_overdraft.py
628
+ │ └── mpesa_callback.py
629
+ └── tests/
630
+ ├── conftest.py # skips integration tests if no database
631
+ ├── test_model.py
632
+ └── test_matrix.py
633
+ ```
634
+
635
+ ---
636
+
637
+ ## 12. References
638
+
639
+ - Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly, 2017), ch. 7 *Transactions*,
640
+ and ch. 11 on idempotence.
641
+ - PostgreSQL documentation, *Concurrency Control → Transaction Isolation* and *Explicit Locking*.
642
+ - Hal Berenson et al., *A Critique of ANSI SQL Isolation Levels* (SIGMOD 1995): the paper that
643
+ defined snapshot isolation and write skew.
644
+ - Dan R. K. Ports and Kevin Grittner, *Serializable Snapshot Isolation in PostgreSQL* (VLDB 2012):
645
+ how PostgreSQL's `SERIALIZABLE` works.
646
+ - Kyle Kingsbury, *Jepsen* (jepsen.io): the inspiration for treating isolation claims as testable.