Skip to content

Commit 0b969f6

Browse files
joaoh82claude
andauthored
feat(engine): Phase 11.9 WAL log-record durability + crash recovery (SQLR-22) (#130)
Pre-11.9, BEGIN CONCURRENT commits persisted *table state* through the legacy save_database mirror, but MvStore's version index was reborn empty on every reopen. That's correct for single-session workloads but breaks down once cross-process MVCC matters — a second process could hand out a begin_ts below an already-committed version's end and the visibility rule would miscategorise one side. 11.9 closes that gap by adding a typed MVCC log-record frame on top of the existing per-page WAL frames. The MVCC frame is appended before the legacy save's commit-frame fsync, so a single fsync covers both: a crash either keeps both writes or loses both — torn-write atomicity for the whole transaction. On reopen, the WAL replay decodes every MVCC frame and re-pushes the row versions into MvStore, then seeds MvccClock past max(header.clock_high_water, max(commit_ts among replayed batches)) so post-restart transactions can never regress. Engine changes: - src/mvcc/log.rs: MvccCommitBatch + MvccLogRecord types and codec ("MVCC0001" magic + commit_ts + record stream, fits one frame body). - src/sql/pager/wal.rs: WAL_FORMAT_VERSION 2 → 3; MVCC_FRAME_MARKER = u32::MAX as the page-number discriminator; replay branches the frame stream into pending_mvcc that promotes onto recovered_mvcc on each commit barrier; Wal::append_mvcc_batch + Wal::recovered_mvcc_commits accessors. - src/sql/pager/pager.rs: Pager proxies (append_mvcc_batch, recovered_mvcc_commits, clock_high_water, observe_clock_high_water). - src/sql/pager/mod.rs: replay_mvcc_into_db drains recovered batches into Database::mv_store and seeds MvccClock at open time. - src/connection.rs: commit_concurrent encodes the resolved write-set into an MvccCommitBatch, appends it pre-save_database, and bumps the in-memory WAL header's clock_high_water; six new tests cover round-trip persistence, multi-row batches, ROLLBACK-no-frame, legacy-commit-no-frame, multi-commit replay after unclean close, and clock seeding past the last commit_ts. Docs: - roadmap.md: Phase 11.9 promoted to shipped; remaining checkpoint- drain half scoped as a follow-up. - file-format.md: WAL header v3 row + MVCC log-record body diagram. - concurrent-writes-plan.md: plan-doc §10.5 annotated with what shipped vs. what's parked. - design-decisions.md: new §12g — MVCC commits piggyback the legacy fsync; sentinel choice; clock-seed correctness argument. - _index.md: phase-summary bullet refreshed. Workspace: 599/599 Rust tests pass (was 587, +12 = 6 codec + 6 durability). fmt + clippy + doc all clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e84801e commit 0b969f6

11 files changed

Lines changed: 1244 additions & 24 deletions

File tree

docs/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ As of May 2026, SQLRite has:
5454
- Full-text search + hybrid retrieval (Phase 8 complete): FTS5-style inverted index with BM25 ranking + `fts_match` / `bm25_score` scalar functions + `try_fts_probe` optimizer hook + on-disk persistence with on-demand v4 → v5 file-format bump (8a-8c), a worked hybrid-retrieval example combining BM25 with vector cosine via raw arithmetic (8d), and a `bm25_search` MCP tool symmetric with `vector_search` (8e). See [`docs/fts.md`](fts.md).
5555
- SQL surface + DX follow-ups (Phase 9 complete, v0.2.0 → v0.9.1): DDL completeness — `DEFAULT`, `DROP TABLE` / `DROP INDEX`, `ALTER TABLE` (9a); free-list + manual `VACUUM` (9b) + auto-VACUUM (9c); `IS NULL` / `IS NOT NULL` (9d); `GROUP BY` + aggregates + `DISTINCT` + `LIKE` + `IN` (9e); four flavors of `JOIN` — INNER, LEFT, RIGHT, FULL OUTER (9f); prepared statements + `?` parameter binding with a per-connection LRU plan cache (9g); HNSW probe widened to cosine + dot via `WITH (metric = …)` (9h); `PRAGMA` dispatcher with the `auto_vacuum` knob (9i)
5656
- Benchmarks against SQLite + DuckDB (Phase 10 complete, SQLR-4 / SQLR-16): twelve-workload bench harness with a pluggable `Driver` trait, criterion-driven, pinned-host runs published. See [`docs/benchmarks.md`](benchmarks.md).
57-
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is in flight. **11.1 → 11.7: shipped.** Engine + SDK error propagation: `Connection` is `Send + Sync`; `Connection::connect()` mints sibling handles. `sqlrite::mvcc` exposes `MvccClock`, `ActiveTxRegistry`, `MvStore`, `ConcurrentTx`. WAL header v1 → v2 persists the clock high-water mark. `PRAGMA journal_mode = mvcc;` opts a database into MVCC. `BEGIN CONCURRENT` writes commit-validate against `MvStore` and abort with `SQLRiteError::Busy`. Reads via `Statement::query` see the BEGIN-time snapshot. Per-commit GC + `vacuum_mvcc()` bound the version chain growth. C FFI / Python / Node / Go all propagate `Busy` / `BusySnapshot` as typed retryable errors. **11.8 multi-handle SDK shape: shipped on this branch.** The FFI's `sqlrite_connect_sibling`, Python's `Connection.connect()`, and Node's `db.connect()` mint sibling handles that share backing state — closes the end-to-end gap from 11.7 where `BusyError` was reachable but not exerciseable through any SDK. Plan: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
57+
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is in flight. **11.1 → 11.9: shipped.** Engine + SDK error propagation: `Connection` is `Send + Sync`; `Connection::connect()` mints sibling handles. `sqlrite::mvcc` exposes `MvccClock`, `ActiveTxRegistry`, `MvStore`, `ConcurrentTx`, and the `MvccCommitBatch` / `MvccLogRecord` WAL codec. WAL header v1 → v2 persisted the clock high-water mark; **v2 → v3 (11.9)** adds typed MVCC log-record frames. `PRAGMA journal_mode = mvcc;` opts a database into MVCC. `BEGIN CONCURRENT` writes commit-validate against `MvStore`, abort with `SQLRiteError::Busy`, and now also append an MVCC log-record frame to the WAL — covered by the same fsync as the legacy page commit. Reopen replays those frames into `MvStore` and seeds `MvccClock` past the highest committed `commit_ts`, so the MVCC conflict-detection window survives a process restart. Reads via `Statement::query` see the BEGIN-time snapshot. Per-commit GC + `vacuum_mvcc()` bound version-chain growth. C FFI / Python / Node / Go all propagate `Busy` / `BusySnapshot` as typed retryable errors; the FFI's `sqlrite_connect_sibling`, Python's `Connection.connect()`, and Node's `db.connect()` mint sibling handles that share backing state. Plan: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
5858
- A fully-automated release pipeline that ships every product to its registry on every release with one human action — Rust engine + `sqlrite-ask` + `sqlrite-mcp` to crates.io, Python wheels to PyPI (`sqlrite`), Node.js + WASM to npm (`@joaoh82/sqlrite` + `@joaoh82/sqlrite-wasm`), Go module via `sdk/go/v*` git tag, plus C FFI tarballs, MCP binary tarballs, and unsigned desktop installers as GitHub Release assets (Phase 6 complete)
5959

6060
See the [Roadmap](roadmap.md) for the full phase plan.

docs/concurrent-writes-plan.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,11 @@ Goal: more than one `Connection` can target the same `Database` within a process
270270

271271
### Phase 10.5 — Checkpoint + crash recovery
272272

273-
- Extend the checkpointer to drain MVCC log records into pager-level updates before folding the WAL into the main file.
274-
- Crash recovery: on open, replay WAL log records into `MvStore`, then replay pager-level commit frames as today.
275-
- Tests: kill the process mid-MVCC-commit (between log-record append and version-chain push), reopen, verify the committed transaction is visible and the half-written one is not.
273+
> **Status (roadmap 11.9 — May 2026):** The crash-recovery half landed in roadmap Phase 11.9. WAL format is bumped to v3; commits append a typed `MvccCommitBatch` frame before the legacy save's fsync; reopen replays those frames into `MvStore` and seeds `MvccClock` past the highest `commit_ts`. The checkpoint-drain half — folding MVCC log records into pager-level updates and re-enabling the `Mvcc → Wal` journal-mode downgrade — is the remaining slice and stays parked for a follow-up.
274+
275+
- ~~Extend the checkpointer to drain MVCC log records into pager-level updates before folding the WAL into the main file.~~ *Deferred — see status note above.*
276+
- Crash recovery: on open, replay WAL log records into `MvStore`, then replay pager-level commit frames as today. **(Shipped — 11.9.)**
277+
- Tests: kill the process mid-MVCC-commit (between log-record append and version-chain push), reopen, verify the committed transaction is visible and the half-written one is not. **(Shipped — 11.9 covers the clean-drop case which exercises the same recovery codepath; a real OS-kill test is parked with the checkpoint-drain follow-up.)**
276278

277279
### Phase 10.6 — Garbage collection
278280

docs/design-decisions.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,63 @@ Each statement inside the transaction runs against the working `tables` clone vi
252252

253253
---
254254

255+
### 12g. MVCC commits piggyback on the legacy save's fsync (Phase 11.9)
256+
257+
**Decision.** `BEGIN CONCURRENT` commits now leave a typed
258+
[`MvccCommitBatch`](../src/mvcc/log.rs) frame in the WAL *before*
259+
the legacy `save_database` runs. The MVCC frame uses
260+
`page_num = MVCC_FRAME_MARKER (u32::MAX)` and `commit_page_count =
261+
None`, so it is **not** fsync'd on its own. The legacy save then
262+
appends its page commits and ends with the existing page-0 commit
263+
frame (which *is* fsync'd). That single fsync flushes everything
264+
buffered behind it, covering the MVCC frame and the page commits
265+
in one durability boundary.
266+
267+
**Why piggyback rather than fsync per MVCC frame.** Each fsync is
268+
the dominant cost of a small commit (often >90% of wall time on
269+
SSDs). Dual fsyncs would double the cost of a `BEGIN CONCURRENT`
270+
commit relative to a legacy commit for no correctness gain — the
271+
two writes already need to be atomic with each other (a crash that
272+
keeps one but loses the other would either resurrect uncommitted
273+
state in `MvStore` or hide a durable legacy update from the
274+
in-memory MVCC index). Sharing the boundary makes the atomicity
275+
free: torn-write recovery already drops dirty frames past the last
276+
commit barrier, and that recovery treats the MVCC frame as just
277+
another dirty frame waiting for its commit-barrier.
278+
279+
**Why the marker is `u32::MAX`.** Page numbers are bounded by the
280+
file's `page_count`, which sits well below `u32::MAX` for any
281+
realistic database (the maximum page-addressable file size at
282+
4 KiB pages is 16 TiB). Choosing the sentinel from outside the
283+
legal range keeps the discriminator a single integer comparison
284+
on the existing frame-header layout — no new flag field, no
285+
binary-incompatible header.
286+
287+
**Why the clock is seeded from `max(header.clock_high_water,
288+
max(commit_ts in WAL))`.** The WAL header's `clock_high_water`
289+
field is only persisted on checkpoint (which fsync's the
290+
truncated WAL). Between checkpoints, the in-memory header is
291+
ahead of the on-disk header — and an unclean process exit drops
292+
that in-memory lead. The MVCC frames themselves are durable, and
293+
each carries its `commit_ts`, so the replay walks the recovered
294+
batches and takes the higher of the two seeds. Without this
295+
maxing step a crash between commits and checkpoint could let a
296+
post-reopen transaction hand out a `begin_ts` *below* an
297+
already-committed version's `end` — an immediate snapshot-isolation
298+
violation.
299+
300+
**What 11.9 deferred.** The checkpoint half of plan-doc Phase
301+
10.5: draining `MvStore` versions back into the pager so a
302+
WAL truncate doesn't lose them, and re-enabling the `Mvcc → Wal`
303+
journal-mode downgrade. The legacy save mirror still covers
304+
durability of the visible row state on the read path, so this
305+
gap is foundation work — not a correctness regression — and
306+
the existing per-commit GC bounds in-memory chain growth.
307+
308+
**Plan-doc reference.** [`concurrent-writes-plan.md`](concurrent-writes-plan.md) §3.3 (durability model), §4.6 (WAL log records), §10.5 (checkpoint integration — partially shipped, see note in plan doc).
309+
310+
---
311+
255312
## Query execution
256313

257314
### 13. `NULL`-as-false in `WHERE` clauses

docs/file-format.md

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ A second file alongside the `.sqlrite`, named `<stem>.sqlrite-wal`, records page
329329
│ 8 │ 4 │ format version (u32 LE) │
330330
│ │ │ 1 = pre-Phase-11 │
331331
│ │ │ 2 = Phase 11.2 — adds clock_high_water │
332+
│ │ │ 3 = Phase 11.9 — adds MVCC log-record frames │
332333
│ 12 │ 4 │ page size (u32 LE) = 4096 │
333334
│ 16 │ 4 │ salt (u32 LE) — rolled each checkpoint │
334335
│ 20 │ 4 │ checkpoint seq (u32 LE) — increments per ckpt │
@@ -343,9 +344,21 @@ A second file alongside the `.sqlrite`, named `<stem>.sqlrite-wal`, records page
343344
were reserved-zero in v1, so a pre-Phase-11 WAL opens cleanly: the
344345
parser interprets the zeros as `clock_high_water = 0`, which is
345346
indistinguishable from "fresh checkpoint, clock has never advanced."
346-
The next checkpoint rewrites the header at v2 — there's no offline
347-
upgrade step. Forward versions we don't recognize (e.g. v3) error
348-
out with a clean diagnostic rather than misinterpreting the bytes.
347+
The next checkpoint rewrites the header at the current version —
348+
there's no offline upgrade step. Forward versions we don't recognise
349+
error out with a clean diagnostic rather than misinterpreting the
350+
bytes.
351+
352+
**v2 → v3 compatibility.** v3 doesn't change the header layout at
353+
all — only the set of frame kinds the body stream can carry. A v2
354+
reader on a v3 file would still parse every frame correctly *except*
355+
that it would not recognise the MVCC-marker frames and would skip
356+
them silently as if they were unknown page numbers (the page-number
357+
field reads `u32::MAX`). We bump the header anyway so v2 readers
358+
emit the usual "unsupported WAL format version" diagnostic on a v3
359+
WAL, surfacing the mismatch instead of silently losing MVCC
360+
durability. The current build accepts v1..=v3 on open and writes v3
361+
on every new WAL.
349362

350363
### Frames
351364

@@ -367,6 +380,67 @@ Each frame is `FRAME_HEADER_SIZE + PAGE_SIZE` = **4112 bytes**:
367380
└────────┴────────┴─────────────────────────────────────────────────┘
368381
```
369382

383+
### MVCC log-record frames (Phase 11.9)
384+
385+
When the database is in `journal_mode = mvcc`, a successful `BEGIN
386+
CONCURRENT` commit appends a second frame on top of the legacy page
387+
frames: an MVCC log record that captures the resolved write-set.
388+
The frame uses the same 4112-byte envelope but is distinguished by
389+
the page-number field carrying the sentinel `u32::MAX`
390+
(`MVCC_FRAME_MARKER`). Real page numbers are bounded by file size,
391+
so the sentinel can never collide with a legitimate page frame.
392+
393+
The body carries:
394+
395+
```
396+
┌────────┬────────┬─────────────────────────────────────────────────┐
397+
│ offset │ length │ content │
398+
├────────┼────────┼─────────────────────────────────────────────────┤
399+
│ 0 │ 8 │ magic: "MVCC0001" (ASCII, no NUL) │
400+
│ 8 │ 8 │ commit_ts (u64 LE) │
401+
│ 16 │ 2 │ record count (u16 LE) │
402+
│ 18 │ var. │ records — for each: │
403+
│ │ │ 1 byte op tag (0 = Tombstone, 1 = Present) │
404+
│ │ │ 2 + N table name (length-prefixed) │
405+
│ │ │ 8 rowid (i64 LE) │
406+
│ │ │ if op = 1: column count (u16 LE) + per-column │
407+
│ │ │ (name, type tag, value) tuples │
408+
│ ... │ ... │ zero-padded to PAGE_SIZE │
409+
└────────┴────────┴─────────────────────────────────────────────────┘
410+
```
411+
412+
Value type tags inside a record:
413+
414+
```
415+
0 Null
416+
1 Int — 8 bytes i64 LE
417+
2 Real — 8 bytes f64 LE
418+
3 Text — 4 + N bytes (u32 LE length, then UTF-8 bytes)
419+
4 Bool — 1 byte (0 / 1)
420+
5 Vector — 4 + 4*N bytes (u32 LE length, then f32 LE elements)
421+
```
422+
423+
A batch must fit in the 4096-byte body; encoder rejects oversize
424+
batches with a typed error. Multi-frame batches (for very large
425+
transactions) are a deferred follow-up.
426+
427+
The MVCC frame is appended with `commit_page_count = None` (dirty)
428+
so its own `fsync` is skipped. The very next legacy commit frame
429+
that the same `save_database` writes will `fsync` the whole buffer
430+
— covering both the MVCC frame and the legacy page updates in one
431+
boundary. A crash between the two append calls drops both, which
432+
is the right rollback semantics.
433+
434+
On reopen, the replay loop branches on `page_num`:
435+
`MVCC_FRAME_MARKER` frames are decoded into `MvccCommitBatch` and
436+
held in a pending list that promotes onto the recovered list each
437+
time the next legacy commit frame seals the transaction. The
438+
`Pager` exposes the recovered batches as
439+
`Pager::recovered_mvcc_commits()`; `pager::open_database` drains
440+
them into `Database::mv_store` via `MvStore::push_committed` and
441+
seeds `Database::mvcc_clock` past the highest replayed
442+
`commit_ts`.
443+
370444
### Torn-write recovery
371445

372446
On open the reader walks every frame from `WAL_HEADER_SIZE`, validating salt and checksum. The first invalid or incomplete frame marks the end of the usable log — its bytes and anything after stay on disk but are treated as nonexistent. Callers get a clean in-memory index of `(page → latest-committed-frame-offset)` and a `last_commit_offset` boundary.

docs/roadmap.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ Bounds in-memory growth of the [`MvStore`](../src/mvcc/store.rs) version chains.
657657
- **Go SDK**: two new sentinel error values `sqlrite.ErrBusy` / `sqlrite.ErrBusySnapshot`, plus an `IsRetryable(err error) bool` helper. `wrapErr` recognises the new FFI status codes and wraps the engine message with `fmt.Errorf("…: %w", ErrBusy)`.
658658
- **WASM SDK** — deliberately untouched (browser is single-threaded; multi-handle shape not yet exposed).
659659

660-
### 🚧 Phase 11.8 — Multi-handle SDK shape *(in progress, was plan-doc 11.8's other half; promoted ahead of plan-doc 11.5 again because the 11.7 retry-error machinery can't be exercised end-to-end through any SDK until siblings are reachable)*
660+
### Phase 11.8 — Multi-handle SDK shape *(in progress, was plan-doc 11.8's other half; promoted ahead of plan-doc 11.5 again because the 11.7 retry-error machinery can't be exercised end-to-end through any SDK until siblings are reachable)*
661661

662662
Each pre-11.8 SDK `connect()` / `new Database()` built an *isolated* backing DB; the 11.7 `BusyError` / `errorKind` / `ErrBusy` plumbing was reachable but not actually triggerable from user code. This slice exposes the engine's `Connection::connect()` through every reachable language so apps can mint sibling handles that share state, and finally exercise the 11.7 retry idioms with real cross-handle conflicts.
663663

@@ -669,9 +669,17 @@ Each pre-11.8 SDK `connect()` / `new Database()` built an *isolated* backing DB;
669669

670670
Each SDK gets end-to-end tests that exercise `BEGIN CONCURRENT` cross-handle conflicts: two sibling handles, two concurrent transactions on the same row, the second commit hits the SDK's typed retryable error, retry succeeds.
671671

672-
### Phase 11.9 — Checkpoint integration + crash recovery *(planned, plan-doc "Phase 10.5"; renumbered to follow SDK propagation because durability via the legacy `save_database` mirror already works in v0; this slice is foundation work for cross-process MVCC and column-level WAL deltas)*
672+
### Phase 11.9 — WAL log-record durability + crash recovery *(plan-doc "Phase 10.5"; renumbered to follow SDK propagation because durability via the legacy `save_database` mirror already worked in v0)*
673673

674-
MVCC log-record WAL frame format (the deferred 11.4 piece). Commit appends log records pre-`save_database`. Reopen replays log records into `MvStore`. Checkpoint drains `MvStore` versions back into the pager (so `Mvcc → Wal` becomes legal once the store is empty). Crash-recovery test: kill mid-commit between log-record append and version-chain push; reopen; verify the committed transaction is visible and the half-written one is not.
674+
MVCC commits now leave a typed log-record frame in the WAL on top of the existing page-level commit. The MVCC frame is appended before the legacy save's commit-frame fsync, so a single fsync covers both: a crash either keeps both or loses both. On reopen, the WAL replay decodes every MVCC frame and re-pushes the row versions into `MvStore`; the in-memory MVCC clock is seeded past the highest replayed `commit_ts` so post-restart transactions can never hand out a regressed `begin_ts`.
675+
676+
- **WAL format version bumped to v3.** v1 / v2 are still readable (replay just sees zero MVCC frames); v3 adds the MVCC frame marker (`page_num = u32::MAX`) and the body codec.
677+
- **Frame body codec** ([`src/mvcc/log.rs`](../src/mvcc/log.rs)): `MvccCommitBatch { commit_ts, records }` encoded with magic `MVCC0001`, then `commit_ts` (u64 LE), record count (u16 LE), then per-record `(op tag, table name, rowid, optional column-value pairs)`. Everything fits in the 4 KiB frame body; the encoder surfaces a typed error if a single commit overflows (multi-frame batches are a deferred slice).
678+
- **Append path** ([`src/connection.rs`](../src/connection.rs) `commit_concurrent`): after validation passes, the resolved write-set is encoded into a batch, appended to the WAL (no fsync), and then `save_database` runs and seals the transaction with its own fsync. The clock high-water in the WAL header is also bumped so a future checkpoint persists it.
679+
- **Replay path** ([`src/sql/pager/mod.rs`](../src/sql/pager/mod.rs) `replay_mvcc_into_db`): drains `Pager::recovered_mvcc_commits` into `MvStore` and observes the clock past `max(header.clock_high_water, max(commit_ts))`. Replay is unconditional — `JournalMode::Wal`-mode databases simply see zero frames.
680+
- **Tests** ([`src/connection.rs`](../src/connection.rs)): six new cases cover round-trip persistence, multi-row batches, ROLLBACK-leaves-no-frame, legacy-commit-leaves-no-frame, multi-commit replay after an unclean close, and clock-seeding past the last `commit_ts`.
681+
682+
**Out of scope for 11.9** (parked for a follow-up): checkpoint draining the `MvStore` versions back into the pager (which would let `set_journal_mode(Mvcc → Wal)` succeed); a real OS-level kill-mid-commit test (the existing test uses a clean drop, which exercises the same crash-recovery codepath because the WAL is the durable record).
675683

676684
### Phase 11.10 — Indexes under MVCC *(deferred-by-design, plan-doc "Phase 10.7")*
677685

0 commit comments

Comments
 (0)