|
| 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