What If Someone Changes the Job While Botie Is Reading It?
Two valid edits can overwrite each other. Make stale assumptions visible, and distinguish version checks from the database’s isolation guarantees.
14 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
Coordinating Readers and Writers Without Losing Work
BOTIE
SQLite and PostgreSQL expose different concurrency models. SQLite serialises writers, while WAL mode lets readers keep an earlier snapshot as a writer commits. PostgreSQL offers several isolation levels with their own anomaly and retry rules. Our one-connection exercise illustrates an optimistic version predicate, not either engine’s full scheduler.
Sources for this history
The Second Save Quietly Erased the First Edit
Two technicians read J1 at version 4. One assigns a replacement specialist and saves version 5. The other closes the earlier copy, unaware that the job changed. A blind save can overwrite work that happened after the read.
We will first use an expected version to reject a stale edit. Then we will examine what snapshots, write locks and isolation levels can guarantee in a real database.
Invented maintenance records. SQL programs run in an isolated SQLite database; the workshop illustrates the operation with records rather than parsing SQL.
Step 1 of 1
Two edits began from the same record
A later save must notice that its original view is old.
| Moment | Stored version | Second editor’s expected version |
|---|---|---|
| Both read J1 | 4 | 4 |
| First edit commits | 5 | 4 |
| Second editor tries to save | 5 | 4 |
Both read J1
- Stored version
- 4
- Second editor’s expected version
- 4
First edit commits
- Stored version
- 5
- Second editor’s expected version
- 4
Second editor tries to save
- Stored version
- 5
- Second editor’s expected version
- 4
Keep stale assumptions in the write predicate
A matching version is consumed by incrementing it, so replayed old edits no longer match.
Does the edit still match the version it was based on?
Compare the proposal with the stored version before applying its replacement status.
BOTIE
Resulting job record
{
"id": "J1",
"status": "done",
"version": 5
}Follow the operations
1. Stored job
{ "id": "J1", "status": "assigned", "version": 4 }2. Versioned edit
{ "id": "J1", "expected_version": 4, "status": "done" }3. Compare the expected record version
true
4. Apply the edit only to its matching version
{ "id": "J1", "status": "done", "version": 5 }5. Result
{ "id": "J1", "status": "done", "version": 5 }
The workshop is a sequential optimistic-check model. It does not simulate locks, isolation levels or simultaneous transactions.
1. A read is an observation, not a reservation
Reading a row tells the application what was visible at that moment under its transaction rules. Another transaction may commit before the application writes. A user thinking about an edit can make that gap quite long.
Carry the observed version with the proposed change. The application should explain a conflict and show the current state when the version no longer matches. Automatically reapplying the old decision can be wrong even if the SQL is retried successfully.
The conflict concerns the meaning of the edit, not merely a temporary database error.
2. Put the expected version in the UPDATE
Match both the primary key and expected version in the same UPDATE predicate. If it matches, update the fields and increment the version. A stale proposal affects zero rows. The application must inspect that outcome instead of reporting a successful save.
This protects against a lost update for cooperating writers that follow the version protocol. It does not independently enforce a condition involving several different rows. Every write path that can change the relevant state must maintain the version.
The version is a concurrency token. It need not be a timestamp, and clock ordering is unnecessary for this comparison.
Check yourself
Questions on Parts 1 to 2
Question 1 of 2
How many rows should its key-and-version predicate update?
Use the information below.
Stored J1 has version 5. The proposed update requires version 4.3. Try a successful edit and then replay the stale one
The supplied edit expects version 4. The first application of the statement can produce version 5. Replaying the same proposal must leave version 5 untouched because the expectation is still 4.
An application that wants another edit obtains the new record and creates a new deliberate proposal. It should not silently replace expected_version with whatever is current just to make the UPDATE succeed.
The exercise has one edit row. The key and version predicate generalise when the driver binds a different proposal.
A stale editor cannot close the changed job
Change the stored version first, then apply the old expected version.
| id | expected_version | status |
|---|---|---|
| J1 | 4 | done |
| id | status | version |
|---|---|---|
| J1 | assigned | 4 |
| J2 | open | 1 |
Database setup for this example
CREATE TABLE jobs(id TEXT PRIMARY KEY NOT NULL,status TEXT NOT NULL,version INTEGER NOT NULL);
INSERT INTO jobs VALUES('J1','assigned',4),('J2','open',1);
CREATE TABLE edit(id TEXT,expected_version INTEGER,status TEXT); INSERT INTO edit VALUES('J1',4,'done');UPDATE jobs SET status='review',version=5 WHERE id='J1';
UPDATE jobs SET status='done',version=version+1 WHERE id='J1' AND version=4;
SELECT id,status,version FROM jobs ORDER BY id;| id | status | version |
|---|---|---|
| J1 | review | 5 |
| J2 | open | 1 |
The old proposal matched no row. The other job also remains untouched.
4. What SQLite serialises
SQLite permits only one writer at a time. Its journal mode affects reader/writer interaction: WAL readers can keep a snapshot while a writer commits. A read transaction may be unable to upgrade to a writer when its snapshot is no longer current.
BEGIN IMMEDIATE requests the write transaction earlier and can fail when another writer holds it. It does not make multiple SQLite writers execute their writes simultaneously. Set bounded busy handling and keep transactions short. The in-browser worksheet uses one isolated connection, so it cannot demonstrate real lock contention.
Sequentially replaying a stale edit is a controlled illustration of an application precondition, not a concurrency benchmark.
5. What PostgreSQL isolation levels change
At PostgreSQL Read Committed, a statement reads a snapshot established for that statement, with documented rules for concurrent updates. Repeatable Read gives a transaction a stable snapshot but still permits some anomalies that Serializable prevents. Serializable execution may reject a transaction so that the application retries the complete unit.
SELECT ... FOR UPDATE can lock selected rows when pessimistic coordination is appropriate. It does not automatically lock every possible future row that could satisfy an arbitrary business rule. Choose the isolation and locking strategy from the invariant, and test the exact interleaving that could break it.
A generic claim that transactions prevent all races hides the isolation level that actually governs them.
6. One row version cannot protect a rule across several rows
Suppose two engineers must never both leave the on-call rota. Each reads a snapshot showing the other still on call, then updates only their own row to off call. Both individual row versions can match even though their combined result breaks the rota rule. This is a write-skew pattern: the writes affect different rows, but both decisions relied on a shared condition.
Protect the condition at the database boundary. Depending on the engine and schema, the solution can use a common row that competing decisions lock, a suitable constraint, or serializable transactions with retry handling. PostgreSQL Repeatable Read uses snapshot isolation and can permit serialization anomalies of this kind; Serializable can reject a conflicting execution instead. SQLite permits one writer at a time, but an application that reads outside its eventual write transaction can still act on stale decisions. The transaction boundary is part of the design.
Our versioned job edit solves a single-row stale overwrite. It does not claim to enforce every rule involving a set of jobs.
7. Retry the whole decision when its read assumptions changed
A serialization failure can require retrying the transaction from the beginning with fresh reads. Repeating only the final UPDATE preserves stale calculations. Keep retries bounded and make external effects replay-safe.
For an optimistic user-edit conflict, asking the user to reconcile may be preferable to automatic retry. Record the conflict and current state. Avoid a loop that continually overwrites newer work until one write finally succeeds.
The next lessons improve query cost; they should preserve these correctness requirements while changing execution plans.
Check yourself
Questions on Parts 1 to 7
Question 1 of 2
What does a correct retry generally need?
Use the information below.
A PostgreSQL Serializable transaction fails because its reads and writes conflict with concurrent work.Try it with Botie
Build with Botie
Write and run SQL