Two transactions, side by side, time running down the page. Every row in the little database is a version chain — a write never overwrites anything, it appends — and every read walks that chain looking for the newest version its snapshot is allowed to see. Change the isolation level and the only things that change are when the snapshot is taken and what a conflict does. Every anomaly on screen falls out of those two knobs.
Three schedules, chosen because the same picture gives three different answers:
- write skew — two doctors on a rota, each signing off. Both read
aliceandbob, both see two people on call, both correctly conclude there is cover, and each writes a different row. Nothing collides. Both commit. Nobody is on call. - lost update — two sales against one seat counter. Both read 10, both write 9. Same row, so this one a database can catch on its own.
- read skew — a slow audit reads
x, a transfer moves 100 fromxtoyand commits, the audit then readsy. Two reads, two different instants, 100 currency units invented out of nothing.
The overlap slider slides the second transaction later in time. Drag it down and the anomalies close one by one — not because the level changed, but because the transactions stopped being concurrent, and at full separation the application’s own guard finally sees the world the first transaction left behind and refuses to write. Every anomaly here is a timing bug wearing a correctness costume.
The 3 × 3 table in the corner recomputes at whatever overlap you have dialled in; its cells are clickable, so it doubles as the navigation. At full overlap it settles into the table worth remembering:
| read committed | snapshot | serializable | |
|---|---|---|---|
| write skew | ✕ | ✕ | ✓ (abort) |
| lost update | ✕ | ✓ (abort) | ✓ (abort) |
| read skew | ✕ | ✓ | ✓ |
Snapshot isolation catches exactly the anomalies where both transactions write
the same row — there it can hold a lock and shoot the second writer. Write
skew is the shape where they write different rows, so there is no collision to
detect, and the only defense left is watching the read-write dependencies
between them and refusing the commit that closes a cycle. That last row is why
“repeatable read” in PostgreSQL is not serializable, and why Oracle’s
SERIALIZABLE is snapshot isolation wearing the wrong name.
The two rw: arrows under the rows are that dependency graph being built live.
When both arrows exist — T1 read something T2 wrote, T2 read something T1
wrote — the cycle marker lights up. Under SI it is decoration; under SSI it is
the thing that kills the second committer.
Reuse
src/skew.js is a framework-free ES module with no timers and no rendering:
SCENARIOS— three schedules, each with its rows, a value formatter, the invariant, and averdict(final)that judges the outcome. Transactions are op lists (read/write/wait/commit); a write carries aguard, which is the application’s own check, so a run can show the app being correct and the outcome being wrong at the same time.LEVELS— the three isolation levels.runSchedule({ scenario, level, offset })— replays the schedule tick by tick and returns one frame per step. Each frame is a complete deep snapshot (rows with their version chains, transaction state, the rw-edge set), so a renderer can scrub in any order without re-deriving state. Same trace-per-tick shape assiftandrho.truthTable(offset)— every scenario against every level at one overlap.maxOffset(scenarioId)— the offset at which the two transactions no longer overlap at all.
The MVCC core is small enough to read in one sitting: visibleIndex is the
whole visibility rule, the write path holds the row lock and the
first-updater-wins abort, and findCycle is the SSI check.
Gotchas
- SSI here is Cahill’s algorithm cut down to the two-transaction case: a committing transaction aborts if it closes a cycle of rw-antidependencies with transactions that have already committed. Real PostgreSQL tracks a pivot with both an inbound and an outbound edge and may abort earlier and more eagerly — it is allowed false positives. The schedules here are small enough that the two rules pick the same victim.
- A blocked writer retries on the next tick rather than queueing properly. With two transactions that is the same thing; with ten it would not be.
read committedblocks a second writer on the row lock and then lets it write the value the application computed from its earlier read. That is the lost update, and it is an application bug that the database is under no obligation to notice.SELECT … FOR UPDATEis the fix that does not require changing levels.- The demo bundles its own copy of
skew.js(self-contained by contract); re-copy after editingsrc/.