Skip to content

Commit 5551555

Browse files
joaoh82claude
andauthored
feat(web): v0.10.0 launch — concurrent-writes blog post + site update (SQLR-57) (#136)
Marketing-side companion to the v0.10.0 release. SQLR-22's Phase 11 (MVCC + BEGIN CONCURRENT) shipped end-to-end and got tagged + published to crates.io / PyPI / npm / Go this morning; this commit ships the public-facing announcement. ## New blog post content/blog/shipping-concurrent-writes-mvcc-v010.mdx — walkthrough of the MVCC machinery: why concurrent writes matter, the in-memory version chain, snapshot-isolation visibility, commit-time validation, the retry-loop API in every SDK, the new WAL log-record frame for crash recovery, the REPL .spawn demo, what we punted on (indexes-under-MVCC, checkpoint drain, cross-process MVCC), and how to install v0.10.0. Same tone as the prior launch posts (shipping-sqlrite-tauri-mcp-sdks, sqlrite-vs-sqlite-benchmarks): code-forward, opinionated, links out to the canonical docs rather than re-deriving them. ## Site updates - src/lib/site.ts: SITE.version 0.9.1 → 0.10.0. Cascades to every hero / nav / terminal / docs / OG-image reference. - src/components/hero.tsx: "Phases 0–10 shipped" → "Phases 0–11 shipped — concurrent writes live". - src/components/features.tsx: new tile #5 "Concurrent writes · MVCC" inserted right after #04 "Real transactions" (the conceptual neighbour). Tiles #6-#13 renumbered. Tile #11 (Six language SDKs) blurb updated to mention sibling connection handles. - src/components/roadmap.tsx: new "Phase 11 — Concurrent writes via MVCC (v0.10.0)" entry as `done`, with the sub-phase bullet list. Section subtext updated "Ten phases through v0.9.1" → "Eleven phases through v0.10.0". "What's next" bullets refreshed — concurrent writes removed (shipped); HAVING / BETWEEN / scalar functions / subqueries surfaced (the SQL gaps tracked in SQLR-52..56); MVCC checkpoint-drain + indexes-under-MVCC carve-outs called out. Verified locally: `npx tsc --noEmit`, `npm run lint`, and `npm run build` are all clean. The new post lands at /blog/shipping-concurrent-writes-mvcc-v010 with OG + Twitter images generated alongside every existing post. Closes SQLR-57. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5f3c865 commit 5551555

5 files changed

Lines changed: 435 additions & 16 deletions

File tree

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
---
2+
title: "Concurrent writes in SQLRite: shipping BEGIN CONCURRENT and MVCC in v0.10.0"
3+
description: "SQLRite v0.10.0 lifts the single-writer ceiling. The release ships an in-memory version index, BEGIN CONCURRENT with row-level conflict detection at commit, snapshot-isolated reads, durable WAL log-records, and sibling connection handles across every SDK — Rust, Python, Node, Go, C FFI, and a REPL demo."
4+
publishedAt: "2026-05-12"
5+
author: "Joao Henrique Machado Silva"
6+
tags: ["sqlrite", "mvcc", "concurrent-writes", "rust", "sqlite", "database-internals"]
7+
primaryKeyword: "Rust embedded database MVCC concurrent writes"
8+
---
9+
10+
SQLite serializes every writer through a single exclusive lock. The
11+
file-level `PENDING`/`EXCLUSIVE` mode is the design choice users hit
12+
first when they scale — two unrelated writers touching disjoint rows
13+
still wait on each other because the lock is page- or file-granularity,
14+
not row-granularity. For workloads where most writes don't actually
15+
conflict, that's throughput left on the table.
16+
17+
[SQLRite](https://github.com/joaoh82/rust_sqlite) v0.10.0 lifts that
18+
ceiling. The headline shape is straight out of the
19+
[Hekaton paper](https://www.microsoft.com/en-us/research/wp-content/uploads/2011/01/main-mem-cc-techreport.pdf)
20+
and Turso's
21+
[experimental MVCC](https://docs.turso.tech/tursodb/concurrent-writes),
22+
narrowed for SQLRite's single-process scope:
23+
24+
```sql
25+
PRAGMA journal_mode = mvcc; -- once per database
26+
BEGIN CONCURRENT;
27+
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
28+
UPDATE accounts SET balance = balance + 50 WHERE id = 2;
29+
COMMIT; -- may return Busy → caller retries
30+
```
31+
32+
Two writers on disjoint rows now commit in parallel. Two writers on
33+
the same row see the second commit fail fast with
34+
`SQLRiteError::Busy`, which the caller retries with a fresh
35+
`BEGIN CONCURRENT`. The data structure backing this is a per-row
36+
in-memory version chain (`MvStore`) sitting in front of the existing
37+
pager; the on-disk format is unchanged.
38+
39+
This post walks through the engineering — how the version chain
40+
works, what "snapshot isolation" actually means here, why durability
41+
needed a new WAL frame kind, how the SDKs got involved, and which
42+
parts we deliberately punted on. If you've been reading SQLRite's
43+
[architecture docs](https://github.com/joaoh82/rust_sqlite/blob/main/docs/architecture.md)
44+
this is the part Phase 11 added; if you haven't, this is a tour of
45+
what optimistic MVCC looks like when you build it from scratch in
46+
about 4 weeks of focused work.
47+
48+
## What "concurrent writes" actually means
49+
50+
The phrase is overloaded. There are at least four things people
51+
mean by "concurrent writes in a database":
52+
53+
1. **Multiple writers, same process.** Two threads inside one app
54+
each running a write transaction.
55+
2. **Multiple writers, same machine, different processes.** Two
56+
instances of a daemon writing to the same file.
57+
3. **Snapshot-isolated reads.** A read transaction sees a consistent
58+
point-in-time view of the database, even while writes happen.
59+
4. **Row-level conflict detection.** If two writers touch unrelated
60+
rows, neither blocks; if they touch the same row, exactly one
61+
wins.
62+
63+
v0.10.0 ships **1**, **3**, and **4** end-to-end. **2** stays
64+
out-of-scope by design — multi-process MVCC would need a shared-
65+
memory coordination file the way SQLite's WAL does for read marks,
66+
and the marginal payoff is small enough that "use sibling connection
67+
handles inside one process" is the documented escape hatch. Today's
68+
file-level `flock(LOCK_EX)` still serializes between processes; the
69+
new story is *within* a process.
70+
71+
That distinction matters for which Rust patterns make sense. If
72+
you're inside one process you can share `Arc<Mutex<Database>>`
73+
between threads. If you're across processes you need a coordination
74+
medium more complicated than a `Mutex`, and the engineering bill
75+
goes up by an order of magnitude. SQLRite picks intra-process and
76+
moves on.
77+
78+
## The version chain in 30 seconds
79+
80+
For every row that's been touched under `BEGIN CONCURRENT`, the
81+
engine holds an ordered chain of `RowVersion`s in memory:
82+
83+
```text
84+
begin=ts1 begin=ts3 begin=ts7
85+
end=Some(ts3) end=Some(ts7) end=None
86+
┌────────────┐ ┌────────────┐ ┌────────────┐
87+
rowid 42 ─→ │ balance=100│ ──next──→ │ balance=150│ ──next──→ │ Tombstone │
88+
│ │ │ │ │ (DELETE) │
89+
└────────────┘ └────────────┘ └────────────┘
90+
```
91+
92+
A version is **visible** to a transaction with begin-timestamp `T`
93+
when `begin <= T < end`. That's the textbook snapshot-isolation
94+
visibility rule. New writes push a new head onto the chain at commit
95+
time, capping the previous latest version's `end` to the new
96+
`commit_ts`.
97+
98+
Timestamps come from a process-wide logical clock (`MvccClock`), an
99+
`AtomicU64` that hands out `begin_ts` at `BEGIN CONCURRENT` and
100+
`commit_ts` at the start of validation. The clock's high-water mark
101+
is persisted in the WAL header, so a process restart doesn't reuse
102+
timestamps — important because the visibility rule (`begin <= T < end`)
103+
would mis-classify versions otherwise.
104+
105+
## Commit-time validation, not lock-time pessimism
106+
107+
The interesting choice happens at `COMMIT`, not at `BEGIN`. Two
108+
writers issue `BEGIN CONCURRENT` concurrently and neither blocks —
109+
they both proceed against their own private snapshot of the
110+
database. The conflict, if any, is decided when one of them tries
111+
to commit:
112+
113+
1. Allocate a `commit_ts` from the clock.
114+
2. Walk the write-set. For each `(table, rowid)`, check whether any
115+
committed version's `begin > tx.begin_ts`. If yes, someone else
116+
superseded us → return `SQLRiteError::Busy`. The transaction is
117+
dropped server-side; the caller retries with a fresh
118+
`BEGIN CONCURRENT`.
119+
3. Otherwise, push a new `RowVersion` onto each row's chain at
120+
`commit_ts`, capping the previous latest's `end`.
121+
4. Append an `MvccCommitBatch` frame to the WAL.
122+
5. Mirror the writes into the legacy `Database::tables` so the
123+
non-concurrent read path stays correct after commit.
124+
6. Drop the transaction's handle and sweep the write-set's chains
125+
for GC.
126+
127+
This is **optimistic concurrency control** — we don't pay for locks
128+
upfront; we pay only when conflicts actually happen. For workloads
129+
where most writes are disjoint, that's a strict throughput win over
130+
SQLite's "every writer waits for the writer lock" model. For
131+
workloads where everyone fights over the same handful of rows, the
132+
retry loop is doing the work — and a careful caller might prefer to
133+
pre-serialize at the app layer to skip the retry tax.
134+
135+
The plan was always to ship both shapes and let workloads pick.
136+
137+
## The retry loop is the whole API
138+
139+
The shape is the same in every language. Here's Rust:
140+
141+
```rust
142+
use sqlrite::{Connection, SQLRiteError};
143+
144+
let mut conn = Connection::open("orders.sqlrite")?;
145+
conn.execute("PRAGMA journal_mode = mvcc")?;
146+
147+
loop {
148+
conn.execute("BEGIN CONCURRENT")?;
149+
conn.execute("INSERT INTO orders (id, customer, total) VALUES (1, 'alice', 100)")?;
150+
conn.execute("UPDATE inventory SET stock = stock - 1 WHERE sku = 'WIDGET-A'")?;
151+
match conn.execute("COMMIT") {
152+
Ok(_) => break,
153+
Err(e) if e.is_retryable() => {
154+
conn.execute("ROLLBACK").ok();
155+
continue;
156+
}
157+
Err(e) => return Err(e.into()),
158+
}
159+
}
160+
# Ok::<(), sqlrite::SQLRiteError>(())
161+
```
162+
163+
`SQLRiteError::is_retryable()` covers both `Busy` (write-write
164+
conflict at commit) and `BusySnapshot` (the snapshot the read path
165+
expected has been GC'd). Every SDK surfaces an equivalent
166+
classifier — `errors.Is(err, sqlrite.ErrBusy)` in Go,
167+
`sqlrite.BusyError` (subclass of `SQLRiteError`) in Python,
168+
`errorKind(err) === 'Busy'` in Node, `sqlrite_status_is_retryable`
169+
in the C FFI. **None of them ship an automatic backoff**: the right
170+
policy (immediate retry vs. exponential vs. capped attempts vs.
171+
jittered) depends on the workload, and forcing one would just mean
172+
every caller has to fight the default.
173+
174+
A full runnable example lives at
175+
[`examples/rust/concurrent_writers.rs`](https://github.com/joaoh82/rust_sqlite/blob/main/examples/rust/concurrent_writers.rs).
176+
Two sibling `Connection`s, interleaved `BEGIN CONCURRENT`s, the
177+
disjoint-row happy path plus the same-row retry. Mostly under 80
178+
lines.
179+
180+
## Sibling connections — the SDK plumbing
181+
182+
A single `Connection::open` is the only call that touches the
183+
file. Additional handles come from `Connection::connect()`, which
184+
mints a sibling sharing the same `Arc<Mutex<Database>>`. Every
185+
sibling can hold its own independent `BEGIN CONCURRENT` — that's
186+
the whole point of multi-handle MVCC.
187+
188+
Every SDK now exposes this:
189+
190+
| SDK | Mint a sibling |
191+
|---|---|
192+
| Rust | `let b = primary.connect();` |
193+
| C FFI | `sqlrite_connect_sibling(existing, out)` |
194+
| Python | `conn.connect()` |
195+
| Node.js | `db.connect()` |
196+
| Go | All `sql.Open("sqlrite", path)` calls for the same canonical path automatically share state through a process-level path registry |
197+
198+
The Go case is the one that ate the most time. Go's
199+
`database/sql` pool calls `driver.Open` whenever it wants another
200+
connection slot, and a second `sqlrite_open` for the same path
201+
would deadlock against the first one's `flock(LOCK_EX)`. The fix is
202+
a tiny in-process registry keyed by `filepath.Abs(name)`: the first
203+
opener pays for a real engine connection, subsequent openers (within
204+
the same pool *or* across separate `*sql.DB` instances) mint
205+
siblings off a hidden primary. The registry refcounts; the last
206+
sibling out closes the primary. It's about 80 lines of Go and it
207+
makes the existing `errors.Is(err, sqlrite.ErrBusy)` machinery
208+
actually exercisable from real Go code.
209+
210+
## Durability needed a new WAL frame kind
211+
212+
Phase 4's WAL was per-page: every commit appended frames for
213+
modified pages plus a final commit-barrier frame with the new page
214+
count. That's perfect for the legacy single-writer path — `COMMIT`
215+
fsyncs the barrier frame and the transaction is durable.
216+
217+
The MVCC commit path mirrors writes into `Database::tables` so the
218+
legacy save still happens, so technically the visible row state is
219+
durable through the existing machinery. But the `MvStore` itself —
220+
the version chain that powers conflict detection — lives only in
221+
memory. Without persistence the conflict-detection window doesn't
222+
survive a process restart: a second process could legitimately hand
223+
out a `begin_ts` below an already-committed version's `end`, and
224+
the visibility rule would mis-classify one side.
225+
226+
Phase 11.9 closes that gap with a typed `MvccCommitBatch` frame,
227+
distinguished from page frames by the sentinel `page_num = u32::MAX`
228+
(real page numbers are bounded by file size; no collision risk).
229+
The frame body encodes the commit timestamp plus a record stream of
230+
the resolved write-set:
231+
232+
```
233+
┌────────┬────────┬─────────────────────────────────────────────────┐
234+
│ offset │ length │ content │
235+
├────────┼────────┼─────────────────────────────────────────────────┤
236+
│ 0 │ 8 │ magic "MVCC0001" │
237+
│ 8 │ 8 │ commit_ts (u64 LE) │
238+
│ 16 │ 2 │ record count (u16 LE) │
239+
│ 18 │ var. │ per-record: op tag, table name, rowid, payload │
240+
│ ... │ ... │ zero-padded to PAGE_SIZE │
241+
└────────┴────────┴─────────────────────────────────────────────────┘
242+
```
243+
244+
The frame is appended without its own fsync — the very next legacy
245+
commit frame from the same `save_database` is fsync'd, and that
246+
flushes everything in between. So a single fsync covers both the
247+
MVCC frame and the page-level updates. A crash between the two
248+
appends drops both — torn-write atomicity for the whole transaction,
249+
the same property the per-page WAL already had.
250+
251+
On reopen, the WAL replay walks every MVCC frame and re-pushes the
252+
versions into `MvStore` via the same `push_committed` the live
253+
commit path uses. The `MvccClock` is seeded past
254+
`max(WAL header clock_high_water, max(commit_ts in replayed frames))`
255+
the max is what keeps things correct between checkpoints, since the
256+
header is only fsync'd at checkpoint time and the frame timestamps
257+
are durable on every commit.
258+
259+
WAL format goes v1 → v3 (v2 added the clock high-water; v3 added
260+
the MVCC frame marker). Decoders accept all three, so v0.10.0 reads
261+
v0.9.1's files unchanged.
262+
263+
## The REPL is the demo
264+
265+
`sqlrite`, the REPL binary, used to hold a single `&mut Database`.
266+
v0.10.0 lifts it to `Vec<Connection>` so users can mint sibling
267+
handles in-session. The prompt always shows the active handle:
268+
269+
```text
270+
sqlrite[A]> PRAGMA journal_mode = mvcc;
271+
sqlrite[A]> CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);
272+
sqlrite[A]> INSERT INTO t (id, v) VALUES (1, 0);
273+
sqlrite[A]> .spawn
274+
Spawned sibling handle 'B' and switched to it. 2 handles open.
275+
sqlrite[B]> .use A
276+
sqlrite[A]> BEGIN CONCURRENT;
277+
sqlrite[A]> UPDATE t SET v = 100 WHERE id = 1;
278+
sqlrite[A]> .conns
279+
2 handle(s):
280+
* A (BEGIN CONCURRENT)
281+
B
282+
sqlrite[A]> .use B
283+
sqlrite[B]> BEGIN CONCURRENT;
284+
sqlrite[B]> UPDATE t SET v = 200 WHERE id = 1;
285+
sqlrite[B]> COMMIT;
286+
sqlrite[B]> .use A
287+
sqlrite[A]> COMMIT;
288+
An error occured: Busy: write-write conflict on t/1: another transaction
289+
committed this row at ts=3 (after our begin_ts=1); transaction rolled
290+
back, retry with a fresh BEGIN CONCURRENT
291+
sqlrite[A]> .use B
292+
sqlrite[B]> SELECT * FROM t;
293+
+----+-----+
294+
| id | v |
295+
+----+-----+
296+
| 1 | 200 |
297+
+----+-----+
298+
```
299+
300+
`.spawn` mints a sibling. `.use NAME` switches the active handle.
301+
`.conns` lists every handle, marks the active one, and flags any
302+
holding an open `BEGIN CONCURRENT`. The whole multi-handle MVCC
303+
story is reachable from a single binary, no external orchestration,
304+
no Docker compose.
305+
306+
## What we left out, on purpose
307+
308+
Three things stayed deliberately out of v0.10.0:
309+
310+
**Indexes under MVCC** — Turso explicitly punted on this in their
311+
own MVCC work, and we did too. Each secondary-index entry under
312+
MVCC would need its own `RowVersion`, keyed by `(index_id, key, rowid)`
313+
one version chain per indexed `(column, row)` pair. The memory and
314+
GC costs are non-trivial. The engine currently rejects `CREATE INDEX`
315+
while `journal_mode = mvcc;` with a typed error. We'll tackle
316+
indexes-under-MVCC as its own follow-up phase once the v0 is stable.
317+
318+
**Checkpoint drain** — The checkpointer doesn't yet fold `MvStore`
319+
versions back into pager-level updates. As a result,
320+
`set_journal_mode(Mvcc → Wal)` is rejected if the store carries any
321+
committed versions (would silently strand them). The MVCC frames in
322+
the WAL still provide durability, and the per-commit GC bounds
323+
memory growth for normal workloads; but a clean Mvcc → Wal downgrade
324+
is parked.
325+
326+
**Cross-process MVCC** — Mentioned earlier. The in-memory `MvStore`
327+
has no cross-process visibility; multi-process writers still
328+
serialize through `flock(LOCK_EX)`. SQLite's WAL coordination uses
329+
a shared-memory file for read marks; we could go there, but the
330+
intra-process story covers the workloads we actually care about.
331+
332+
All three are tracked in the repo's [roadmap](https://github.com/joaoh82/rust_sqlite/blob/main/docs/roadmap.md)
333+
and as separate work items.
334+
335+
## What it took
336+
337+
Phase 11 was ten merged sub-phases plus a docs sweep:
338+
339+
1. **11.1**`Connection` becomes a thin handle over `Arc<Mutex<Database>>`
340+
2. **11.2** — Logical clock + active-tx registry; WAL header v1 → v2
341+
3. **11.3**`MvStore` skeleton + `PRAGMA journal_mode` opt-in
342+
4. **11.4**`BEGIN CONCURRENT` writes + commit-time validation
343+
5. **11.5** — Snapshot-isolated reads via `Statement::query`
344+
6. **11.6** — Per-commit GC + `Connection::vacuum_mvcc`
345+
7. **11.7** — SDK propagation of Busy/BusySnapshot across C, Python, Node, Go
346+
8. **11.8** — Sibling connection handles in the FFI and Python/Node bindings
347+
9. **11.9** — WAL log-record durability + crash recovery; WAL format v3
348+
10. **11.11a** — REPL `.spawn` / `.use` / `.conns`
349+
11. **11.11b** — New `W13` bench workload (4 workers × 50 BEGIN/UPDATE/COMMIT)
350+
12. **11.11c** — Go SDK cross-pool sibling path registry
351+
13. **11.12** — Canonical [`docs/concurrent-writes.md`](https://github.com/joaoh82/rust_sqlite/blob/main/docs/concurrent-writes.md)
352+
+ worked example + roadmap cleanup
353+
354+
The full design rationale lives in
355+
[`docs/concurrent-writes-plan.md`](https://github.com/joaoh82/rust_sqlite/blob/main/docs/concurrent-writes-plan.md);
356+
the user-facing reference is [`docs/concurrent-writes.md`](https://github.com/joaoh82/rust_sqlite/blob/main/docs/concurrent-writes.md).
357+
Each sub-phase was one PR, one review, one merge. Phase numbering is
358+
real and the roadmap is the single source of truth — it's how you
359+
keep "MVCC" from sprawling from an estimate into an engineering
360+
sinkhole.
361+
362+
## Try it
363+
364+
```bash
365+
# Rust
366+
cargo add sqlrite-engine # v0.10.0
367+
368+
# Python
369+
pip install sqlrite # v0.10.0
370+
371+
# Node
372+
npm install @joaoh82/sqlrite # v0.10.0
373+
374+
# Go
375+
go get github.com/joaoh82/rust_sqlite/sdk/go@latest
376+
377+
# REPL
378+
cargo install sqlrite-engine
379+
sqlrite some/path/to/db.sqlrite
380+
```
381+
382+
Then `PRAGMA journal_mode = mvcc;`, `BEGIN CONCURRENT;`, and you're
383+
in. The canonical reference is at
384+
[`docs/concurrent-writes.md`](https://github.com/joaoh82/rust_sqlite/blob/main/docs/concurrent-writes.md);
385+
the worked retry-loop example is at
386+
[`examples/rust/concurrent_writers.rs`](https://github.com/joaoh82/rust_sqlite/blob/main/examples/rust/concurrent_writers.rs);
387+
the design rationale is at
388+
[`docs/concurrent-writes-plan.md`](https://github.com/joaoh82/rust_sqlite/blob/main/docs/concurrent-writes-plan.md).
389+
390+
If you build something on top of it, I want to hear about it —
391+
[open an issue](https://github.com/joaoh82/rust_sqlite/issues),
392+
[join the Discord](https://discord.gg/dHPmw89zAE), or just publish a
393+
post. SQLRite's whole premise is "implement the parts of SQLite that
394+
matter, in the open, so the codebase is the textbook." Phase 11 was
395+
the chapter on MVCC. Whatever you build with it teaches the rest.

0 commit comments

Comments
 (0)