Concurrency changes what “the value” means.
Inventory starts with one available unit at version 1. T1 begins a transaction and reads available = 1. Before T1 reads again, T2 reserves that unit and commits version 2.
Now T1’s next observation depends on its isolation choice. The row has a new committed value, but T1 may still be entitled to the value from its starting view. If T1 acts on that view, the database also needs a rule for the conflict.
The schedule is deliberately small so visibility and conflict are not hidden inside a whole application.
- Initial state
available = 1, version 1- T2’s change
- Reserve one unit and commit version 2
- T1 report
- Read the same value twice and notice whether it changes
- T1 reservation
- Reserve with a conditional update and observe its rejection or a conflict abort
There are two questions: what can T1 read, and can T1 safely commit?
Use the same schedule for both questions. A report asks for a consistent view. A reservation asks whether a decision based on the view may change the state. The answer to the first does not automatically answer the second.
T1 may read 1, then 0. The level alone would let a write from the first read overwrite
T2’s reservation; the conditional update (WHERE available > 0) is what
rejects it.
T1 may read 1, then 1. A stale reservation needs conflict detection or it could act on an old fact.
T1 keeps a coherent view, and the engine also tracks what T1 read. A write skew across two rows is aborted so the result matches some serial order.
In the inventory schedule, snapshot and serializable agree: T1 and T2 write the same row, and snapshot catches that. They part ways when two transactions write different rows. Alice and Bob are both on call, and at least one must stay. Each transaction counts two doctors on call, then takes only its own doctor off. Snapshot sees no row written twice and commits both, leaving nobody on call. That is a write skew. Serializable notices that T2 read Alice’s row after T1 changed it, and aborts T2.
Why “latest” can still be inconsistentPer-statement freshness is not one snapshot
Imagine a report reads a count from one table, another transaction changes related rows, and the report reads a second table. Read committed may give each statement a current answer while the pair never existed together at one point in time. Choose a snapshot-style view when the report’s meaning is “as of one observed state.”
Run the same interleaving through different isolation choices.
The lab runs the displayed TypeScript model. First run the report question under read committed, then switch to snapshot. Then ask the reservation question: a repeatable read is not permission to overwrite a newer version. Finally run the on-call question under snapshot and serializable to see the one schedule where they differ.
What can T1 see after T2 commits?
Keep the schedule fixed. Change the isolation choice or the question T1 is asking.
Each statement reads the latest committed value. T1 reads available inventory twice while T2 reserves the unit between reads.
Start with read committed and a report read twice. Then compare the same schedule with a snapshot.
This is one deterministic teaching schedule, not a database engine. It does not model every lock, predicate conflict, MVCC detail, retry policy, or isolation-level spelling. Check the database documentation before relying on a label.
Read the conflict as evidenceAbort, retry, or show a fresh value
When a snapshot or serializable transaction rejects T1’s stale write, that is not a mysterious failure. It is the isolation boundary refusing to let two incompatible histories commit silently. The caller must decide whether to retry from a fresh view, merge, or ask the user.
Hold the schedule steady. Change the language.
The schedule gives T1 a first read, lets T2 commit, then gives T1 its next observation.
function read(view: InventoryState, snapshot: InventoryState | null): number {
return (snapshot ?? view).available;
}
function writerReservesOne(state: InventoryState): void {
if (state.available < 1) throw new Error('T2 found no available unit');
state.available -= 1;
state.version += 1;
}
export function runSchedule(isolation: Isolation, question: Question = 'report'): IsolationRun {
const initial = copyState(initialInventory);
const live = copyState(initial);
const snapshot = isolation === 'read-committed' ? null : copyState(live);
const view = isolation === 'read-committed' ? 'latest statement' : 'transaction snapshot';
const firstRead = read(live, snapshot);
writerReservesOne(live);
const afterWriter = copyState(live);
const secondRead = read(live, snapshot);
if (question === 'report') {
return {
isolation,
question,
view,
initial,
afterWriter,
firstRead,
secondRead,
writerCommitted: true,
readerCommitted: true,
outcome: firstRead === secondRead ? 'repeatable read' : 'non-repeatable read',
explanation:
firstRead === secondRead
? 'T1 read from one transaction view even though T2 committed a change in the live state.'
: 'Each T1 statement saw the latest committed value, so the same read changed during the transaction.'
};
}
// Read committed: T1 writes with UPDATE … WHERE available > 0, which reads the latest row.
const readerCommitted =
isolation === 'read-committed'
? secondRead >= 1
: live.version === (snapshot?.version ?? live.version);
return {
isolation,
question,
view,
initial,
afterWriter,
firstRead,
secondRead,
writerCommitted: true,
readerCommitted,
outcome: readerCommitted
? 'reservation committed'
: isolation === 'read-committed'
? 'conditional update rejected reservation'
: 'conflicting write aborted',
explanation: readerCommitted
? 'The conditional update found a unit and reserved it.'
: isolation === 'read-committed'
? 'Read committed alone would let T1 write from its stale first read. T1 writes with a conditional update (WHERE available > 0); that statement saw the committed 0 and changed no row.'
: 'T1 tried to commit from an older view. Version/conflict detection rejected the stale decision.'
};
} func read(view InventoryState, snapshot *InventoryState) int {
if snapshot != nil {
return snapshot.Available
}
return view.Available
}
func writerReservesOne(state *InventoryState) {
state.Available--
state.Version++
}
func RunSchedule(isolation Isolation, question Question) IsolationRun {
initial := copyState(initialInventory)
live := copyState(initial)
var snapshot *InventoryState
view := "latest statement"
if isolation != ReadCommitted {
value := copyState(live)
snapshot = &value
view = "transaction snapshot"
}
firstRead := read(live, snapshot)
writerReservesOne(&live)
afterWriter := copyState(live)
secondRead := read(live, snapshot)
if question == Report {
repeatable := firstRead == secondRead
explanation := "Each T1 statement saw the latest committed value, so the same read changed during the transaction."
outcome := "non-repeatable read"
if repeatable {
explanation = "T1 read from one transaction view even though T2 committed a change in the live state."
outcome = "repeatable read"
}
return IsolationRun{Isolation: isolation, Question: question, View: view, Initial: initial, AfterWriter: afterWriter, FirstRead: firstRead, SecondRead: secondRead, WriterCommitted: true, ReaderCommitted: true, Outcome: outcome, Explanation: explanation}
}
readerCommitted := false
outcome := "conflicting write aborted"
explanation := "T1 tried to commit from an older view. Version/conflict detection rejected the stale decision."
if isolation == ReadCommitted {
// Read committed: T1 writes with UPDATE … WHERE available > 0, which reads the latest row.
readerCommitted = secondRead >= 1
outcome = "conditional update rejected reservation"
explanation = "Read committed alone would let T1 write from its stale first read. T1 writes with a conditional update (WHERE available > 0); that statement saw the committed 0 and changed no row."
}
if readerCommitted {
outcome = "reservation committed"
explanation = "The conditional update found a unit and reserved it."
}
return IsolationRun{Isolation: isolation, Question: question, View: view, Initial: initial, AfterWriter: afterWriter, FirstRead: firstRead, SecondRead: secondRead, WriterCommitted: true, ReaderCommitted: readerCommitted, Outcome: outcome, Explanation: explanation}
} Both examples keep the same initial version, T1/T2 interleaving, and outcomes. The language changes how the state and view are represented; it does not change the isolation contract.
Copy the complete examplesDeterministic standard-library model
These files model the observable schedule without claiming to be a database driver. Apply the same questions to the isolation levels your engine actually supports.
TypeScriptnode --experimental-strip-types inventory.ts
Gogo run inventory.go
Isolation is engine-specific and workload-dependent.
This lesson establishes the difference between a latest-per-statement view, a transaction snapshot, and a conflict-rejecting serializable schedule in one bounded example. It does not define every database’s exact behavior, guarantee a particular lock or MVCC implementation, or tell you whether a retry is safe.
Check the engine documentation for supported levels, phantom reads, predicate locks, write conflicts, deadlocks, long-running snapshots, and retry errors. Atomicity still matters: once the isolation choice produces a committed transaction, its grouped writes should publish together.
Build UIs?See where this shows up in your components.
Choose from the read and the write.
A read-only report may need a stable snapshot without needing serializable writes. A seat reservation or account transfer may need conflict detection, a lock, or a serializable transaction. State the invariant and the recovery policy before turning up the isolation level.
Match the isolation to the observation you need.
A dashboard joins several tables and must describe one coherent state while other transactions write. Which first choice matches that read requirement?
A dashboard must be internally consistent as of one point in time.
Other transactions continue writing while the report runs. Which first choice matches that read requirement?
Make the next overlap explainable.
Record what T1 needs to observe, the schedule that can challenge it, the isolation level chosen, the conflict or anomaly you expect, and the recovery after an abort. A label without an example is difficult to verify.
- Why
- A dashboard must describe one coherent state, and a reservation must not act on a stale read.
- What
- A snapshot-style read for the report; a conditional update or serializable transaction for the reservation.
- Constraint
- Read committed can change between statements, snapshot can stay stable but old, and only serializable aborts a write skew across two rows.
- Fallback
- When a snapshot or serializable transaction aborts, retry from a fresh view, merge, or ask the user.
- Reconsider when
- The engine, its supported levels, the invariant, or the write rate changes, or aborts and deadlocks start to cost more than they protect.
A choice note to adapt to your own concurrent reads. Nothing here is saved to an account.
Explore more concepts & practices →