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 +21 -0
- isolab-0.1.0/PKG-INFO +646 -0
- isolab-0.1.0/README.md +616 -0
- isolab-0.1.0/isolab/__init__.py +2 -0
- isolab-0.1.0/isolab/__main__.py +3 -0
- isolab-0.1.0/isolab/cli.py +147 -0
- isolab-0.1.0/isolab/engine.py +417 -0
- isolab-0.1.0/isolab/model.py +89 -0
- isolab-0.1.0/isolab/report.py +124 -0
- isolab-0.1.0/isolab/scenarios/__init__.py +13 -0
- isolab-0.1.0/isolab/scenarios/lost_update.py +80 -0
- isolab-0.1.0/isolab/scenarios/mpesa_callback.py +72 -0
- isolab-0.1.0/isolab/scenarios/phantom_booking.py +82 -0
- isolab-0.1.0/isolab/scenarios/read_skew.py +52 -0
- isolab-0.1.0/isolab/scenarios/wallet_overdraft.py +82 -0
- isolab-0.1.0/isolab/scenarios/write_skew.py +59 -0
- isolab-0.1.0/isolab.egg-info/PKG-INFO +646 -0
- isolab-0.1.0/isolab.egg-info/SOURCES.txt +24 -0
- isolab-0.1.0/isolab.egg-info/dependency_links.txt +1 -0
- isolab-0.1.0/isolab.egg-info/entry_points.txt +2 -0
- isolab-0.1.0/isolab.egg-info/requires.txt +4 -0
- isolab-0.1.0/isolab.egg-info/top_level.txt +1 -0
- isolab-0.1.0/pyproject.toml +49 -0
- isolab-0.1.0/setup.cfg +4 -0
- isolab-0.1.0/tests/test_matrix.py +34 -0
- isolab-0.1.0/tests/test_model.py +33 -0
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.
|