Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ As of May 2026, SQLRite has:
- 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).
- 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)
- 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).
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is in flight. **11.1 → 11.5: shipped.** `Connection` is `Send + Sync`; `Connection::connect()` mints sibling handles. `sqlrite::mvcc` exposes `MvccClock`, `ActiveTxRegistry`, `MvStore`, and `ConcurrentTx`. WAL header v1 → v2 persists the clock high-water mark. `PRAGMA journal_mode = mvcc;` opts a database into MVCC. `BEGIN CONCURRENT` writes go through commit-time validation against `MvStore` and abort with `SQLRiteError::Busy` on row-level write-write conflict. Reads via `Statement::query` see the BEGIN-time snapshot. **11.6 garbage collection: shipped on this branch.** Per-commit GC sweep on the write-set's chains + a new `Connection::vacuum_mvcc()` for explicit full-store drains; bounds the in-memory version chain growth that 11.4–11.5 left unbounded. Plan: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is in flight. **11.1 → 11.6: shipped.** `Connection` is `Send + Sync`; `Connection::connect()` mints sibling handles. `sqlrite::mvcc` exposes `MvccClock`, `ActiveTxRegistry`, `MvStore`, and `ConcurrentTx`. WAL header v1 → v2 persists the clock high-water mark. `PRAGMA journal_mode = mvcc;` opts a database into MVCC. `BEGIN CONCURRENT` writes go through commit-time validation and abort with `SQLRiteError::Busy` on row-level write-write conflict. Reads via `Statement::query` see the BEGIN-time snapshot. Per-commit GC + `Connection::vacuum_mvcc()` bound the in-memory version chain growth. **11.7 SDK propagation: shipped on this branch.** The C FFI gains `SqlriteStatus::Busy` / `BusySnapshot`. Python adds `sqlrite.BusyError` / `BusySnapshotError` subclasses. Node exports `errorKind(message)` + `ErrorKind` enum. Go adds `ErrBusy` / `ErrBusySnapshot` sentinels (matchable with `errors.Is`) + an `IsRetryable(err)` helper. Plan: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
- 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)

See the [Roadmap](roadmap.md) for the full phase plan.
Expand Down
29 changes: 21 additions & 8 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ The headline slice. Multiple sibling `Connection`s can each hold their own open
**Known limitations carried forward (most resolved in 11.5):**

- ~~Reads via `Statement::query` / `Statement::query_with_params` bypass the swap.~~ ✅ Fixed in 11.5 — `Connection.concurrent_tx` is now `Mutex<Option<…>>` and a new `with_snapshot_read` helper threads the swap through `&self`.
- The `MvStore` write-set isn't yet persisted to the WAL — Phase 11.7 introduces an MVCC log-record frame kind so commits become durable through `MvStore` itself rather than via the legacy `Database::tables` mirror. (Durability already works through the legacy mirror in v0; the WAL log-record format is foundation work for cross-process MVCC.)
- The `MvStore` write-set isn't yet persisted to the WAL — Phase 11.8 introduces an MVCC log-record frame kind so commits become durable through `MvStore` itself rather than via the legacy `Database::tables` mirror. (Durability already works through the legacy mirror in v0; the WAL log-record format is foundation work for cross-process MVCC.)
- `AUTOINCREMENT` inside `BEGIN CONCURRENT` isn't explicitly rejected; the v0 deep-clone-snapshot model handles concurrent INSERTs by isolating each tx's `last_rowid` bumps to its private snapshot, so two concurrent INSERTs on an `AUTOINCREMENT` column may collide at COMMIT and surface as `Busy`. Adopting the plan's "reject AUTOINCREMENT under MVCC" gate is a clean follow-up.
- Tables touched by `BEGIN CONCURRENT` writes can't carry FTS or HNSW indexes today — `restore_row` only maintains B-tree secondary indexes. Concurrent-tx tests don't exercise FTS / HNSW, but a runtime guard would surface this with a clear error rather than producing inconsistent indexes.

Expand All @@ -634,7 +634,7 @@ Lock order is consistently `concurrent_tx → inner` across every code path; dea

This was renumbered out of plan-doc order: the plan-doc had 11.5 as checkpoint integration, but that's a much larger slice and the prepare/query-bypass-the-swap gap was a real correctness hole for users hitting `BEGIN CONCURRENT`. Plan-doc 11.5 (checkpoint) → roadmap 11.7; plan-doc 11.6 (GC) → roadmap 11.6 (this one).

### 🚧 Phase 11.6 — Garbage collection *(in progress, plan-doc "Phase 10.6"; promoted ahead of plan-doc 11.5 because unbounded `MvStore` growth was the next concrete user-impact concern after 11.5 closed the snapshot-read gap)*
### Phase 11.6 — Garbage collection *(plan-doc "Phase 10.6"; promoted ahead of plan-doc 11.5 because unbounded `MvStore` growth was the next concrete user-impact concern after 11.5 closed the snapshot-read gap)*

Bounds in-memory growth of the [`MvStore`](../src/mvcc/store.rs) version chains. Without this, every committed version stays forever in the in-memory chain — a memory leak that grows linearly with commits.

Expand All @@ -647,21 +647,34 @@ Bounds in-memory growth of the [`MvStore`](../src/mvcc/store.rs) version chains.
**What 11.6 doesn't yet do:**

- No background GC thread or `PRAGMA mvcc_gc_interval_ms`. Per-commit sweep + explicit `vacuum_mvcc()` cover the v0 model; the periodic-sweep variant lands as a follow-up if profiles show it's needed.
- GC sweeps don't trigger `Mvcc → Wal` journal-mode downgrades. The `set_journal_mode` setter still rejects the transition while the store carries committed versions; promoting that path requires the checkpoint-integration story from 11.7.
- GC sweeps don't trigger `Mvcc → Wal` journal-mode downgrades. The `set_journal_mode` setter still rejects the transition while the store carries committed versions; promoting that path requires the checkpoint-integration story from 11.8.

### Phase 11.7 — Checkpoint integration + crash recovery *(planned, plan-doc "Phase 10.5"; renumbered to follow GC 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)*
### 🚧 Phase 11.7 — SDK propagation of `Busy` / `BusySnapshot` *(in progress, plan-doc "Phase 10.8"; promoted ahead of plan-doc 11.5 checkpoint work for the same reason 11.5 / 11.6 jumped the queue — surfacing retryable errors to SDK callers is what unblocks Python / Node / Go users from actually writing `BEGIN CONCURRENT` retry loops)*

- **C FFI** ([`sqlrite-ffi/src/lib.rs`](../sqlrite-ffi/src/lib.rs)): new `SqlriteStatus::Busy = 5` and `SqlriteStatus::BusySnapshot = 6` codes alongside the existing `Ok` / `Error` / `InvalidArgument` set. `SqlriteStatus::is_retryable()` covers both. A new internal `status_of_sqlrite` mapper inspects the engine's `SQLRiteError` variant and routes `Busy` / `BusySnapshot` to the dedicated codes (the generic `status_of` keeps mapping every error to `Error`). `sqlrite_execute` switches to the engine-aware mapper so `BEGIN CONCURRENT` commits surface the dedicated codes through every language binding. Header regenerated automatically via `build.rs`.
- **Python SDK** ([`sdk/python/src/lib.rs`](../sdk/python/src/lib.rs)): two new exception classes `sqlrite.BusyError` and `sqlrite.BusySnapshotError`, both inheriting from `sqlrite.SQLRiteError`. Existing `except sqlrite.SQLRiteError` blocks keep catching them; retry helpers can branch with `except sqlrite.BusyError`. A new `map_engine_err` helper inspects the engine error variant and raises the matching exception class. Every engine-typed call site (open / execute / prepare / query / rows.next) routes through it.
- **Node.js SDK** ([`sdk/nodejs/src/lib.rs`](../sdk/nodejs/src/lib.rs)): new exported `ErrorKind` string enum (`'Busy'`, `'BusySnapshot'`, `'Other'`) and `errorKind(message: string)` classifier function. The engine's `thiserror` Display already prefixes retryable errors with `'Busy: '` / `'BusySnapshot: '`, so the classifier just regex-tests the prefix. JS callers wrap their `BEGIN CONCURRENT` loops in `try / catch (err) { if (errorKind(err.message) === ErrorKind.Busy) continue; }`.
- **Go SDK** ([`sdk/go/sqlrite.go`](../sdk/go/sqlrite.go)): two new sentinel error values `sqlrite.ErrBusy` and `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)` so `errors.Is(err, sqlrite.ErrBusy)` works through the `database/sql` driver chain.
- **WASM SDK** — deliberately untouched. The browser WASM target is single-threaded; `BEGIN CONCURRENT` is meaningful but multi-handle concurrency through `Connection::connect` isn't yet exposed across `wasm-bindgen`'s lifetime model. When the multi-handle JS shape lands (separate slice), the same `Busy: …` message prefix will be the classifier hook for the WASM bindings too.

**What this slice doesn't do:**

- The multi-handle / sibling-`Connection` shape isn't exposed through any SDK yet. Each `sqlrite.connect(path)` / `new Database(path)` / `sql.Open(...)` builds an independent backing database. End-to-end testing of cross-handle `Busy` is therefore deferred to the multi-handle SDK slice; this PR ships the *plumbing* so once that wiring lands, callers already have the retry idioms they need.
- The WAL log-record durability work (plan-doc 11.5 / our 11.8) stays deferred.

### Phase 11.8 — Checkpoint integration + crash recovery *(planned, plan-doc "Phase 10.5"; renumbered to follow GC + 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)*

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.

### Phase 11.8 — Indexes under MVCC *(deferred-by-design, plan-doc "Phase 10.7")*
### Phase 11.9 — Indexes under MVCC *(deferred-by-design, plan-doc "Phase 10.7")*

Each secondary-index entry becomes its own `RowVersion`. Turso explicitly punted on this; SQLRite's v0 will reject `CREATE INDEX` while `journal_mode = mvcc`.

### Phase 11.9 — SDK + REPL propagation *(planned, plan-doc "Phase 10.8")*
### Phase 11.10Multi-handle SDK shape + REPL `.spawn` *(planned, was plan-doc 11.8's other half)*

Surface `Busy` / `BusySnapshot` through the FFI shim and each language SDK. New REPL `.spawn` meta-command + new "N concurrent writers" benchmark workload.
Expose `Connection::connect()` through the FFI + each SDK so Python / Node / Go callers can mint sibling handles, plus a new REPL `.spawn` meta-command. Without this, the 11.7 retry-error machinery can't actually be exercised end-to-end through an SDK (each SDK `connect()` builds an independent DB). Also adds the "N concurrent writers" benchmark workload.

### Phase 11.10 — Docs *(planned, plan-doc "Phase 10.9")*
### Phase 11.11 — Docs *(planned, plan-doc "Phase 10.9")*

Promote the plan to `docs/concurrent-writes.md` and update the cross-references.

Expand Down
66 changes: 63 additions & 3 deletions sdk/go/sqlrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,65 @@ const (
statusOk Status = 0
statusError Status = 1
statusInvalidArgument Status = 2
statusDone Status = 101
statusRow Status = 102
// Phase 11.7 — retryable-error codes the C FFI surfaces from
// `BEGIN CONCURRENT` commit conflicts. See `ErrBusy` /
// `ErrBusySnapshot` below for the Go-side sentinels callers
// match against.
statusBusy Status = 5
statusBusySnapshot Status = 6
statusDone Status = 101
statusRow Status = 102
)

// Phase 11.7 — retryable error sentinels exposed to Go callers.
// Match against them with `errors.Is(err, sqlrite.ErrBusy)` /
// `errors.Is(err, sqlrite.ErrBusySnapshot)` to drive a retry
// loop:
//
// for {
// tx, err := db.Begin()
// if err != nil { return err }
// // ... do work, then:
// err = tx.Commit()
// if err == nil { break }
// if errors.Is(err, sqlrite.ErrBusy) ||
// errors.Is(err, sqlrite.ErrBusySnapshot) {
// // tx was already rolled back by the engine
// continue
// }
// return err
// }
//
// Use [IsRetryable] for a kind-agnostic check.
var (
// ErrBusy is returned when a `BEGIN CONCURRENT` commit hits a
// row-level write-write conflict. The transaction has already
// been rolled back; the caller should retry the whole
// transaction with a fresh `BEGIN CONCURRENT`.
ErrBusy = errors.New("sqlrite: busy (write-write conflict; transaction rolled back, retry)")

// ErrBusySnapshot is returned when a `BEGIN CONCURRENT` read
// sees a row that has been superseded after the transaction's
// snapshot was taken. Same retry semantics as `ErrBusy`.
ErrBusySnapshot = errors.New("sqlrite: busy snapshot (snapshot stale; transaction rolled back, retry)")
)

// IsRetryable reports whether `err` chains an `ErrBusy` or
// `ErrBusySnapshot` and should therefore be retried by the
// caller. Use it instead of comparing against individual
// sentinels so a future retryable variant (e.g. lock-wait
// timeout) doesn't force a caller-side change.
func IsRetryable(err error) bool {
return errors.Is(err, ErrBusy) || errors.Is(err, ErrBusySnapshot)
}

// wrapErr returns a Go error when the status code is nonzero. Use
// after any `sqlrite_*` call that can fail.
//
// Phase 11.7 — retryable statuses surface as errors that wrap the
// matching sentinel (`ErrBusy` / `ErrBusySnapshot`) so callers can
// use `errors.Is` to branch their retry loops without parsing the
// message.
func wrapErr(status Status, op string) error {
if status == statusOk {
return nil
Expand All @@ -141,7 +194,14 @@ func wrapErr(status Status, op string) error {
if msg == "" {
msg = fmt.Sprintf("SQLRite status %d", uint32(status))
}
return fmt.Errorf("sqlrite: %s: %s", op, msg)
switch status {
case statusBusy:
return fmt.Errorf("sqlrite: %s: %s: %w", op, msg, ErrBusy)
case statusBusySnapshot:
return fmt.Errorf("sqlrite: %s: %s: %w", op, msg, ErrBusySnapshot)
default:
return fmt.Errorf("sqlrite: %s: %s", op, msg)
}
}

// cString converts a Go string into a C-allocated NUL-terminated
Expand Down
70 changes: 70 additions & 0 deletions sdk/go/sqlrite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ package sqlrite_test

import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -249,6 +251,74 @@ func TestBadSQLBubblesUpAsError(t *testing.T) {
}
}

// ---------------------------------------------------------------------------
// Phase 11.7 — BEGIN CONCURRENT / Busy sentinel errors

func TestBusySentinelsAreDistinctErrors(t *testing.T) {
if sqlrite.ErrBusy == nil {
t.Fatal("sqlrite.ErrBusy is nil")
}
if sqlrite.ErrBusySnapshot == nil {
t.Fatal("sqlrite.ErrBusySnapshot is nil")
}
// Sanity: the two sentinels are independent values.
if errors.Is(sqlrite.ErrBusy, sqlrite.ErrBusySnapshot) {
t.Error("sqlrite.ErrBusy must not match sqlrite.ErrBusySnapshot via errors.Is")
}
if errors.Is(sqlrite.ErrBusySnapshot, sqlrite.ErrBusy) {
t.Error("sqlrite.ErrBusySnapshot must not match sqlrite.ErrBusy via errors.Is")
}
}

func TestIsRetryableCoversBothSentinels(t *testing.T) {
if !sqlrite.IsRetryable(sqlrite.ErrBusy) {
t.Error("sqlrite.IsRetryable(sqlrite.ErrBusy) should be true")
}
if !sqlrite.IsRetryable(sqlrite.ErrBusySnapshot) {
t.Error("sqlrite.IsRetryable(sqlrite.ErrBusySnapshot) should be true")
}
if sqlrite.IsRetryable(errors.New("not a busy error")) {
t.Error("sqlrite.IsRetryable on a generic error should be false")
}
if sqlrite.IsRetryable(nil) {
t.Error("sqlrite.IsRetryable(nil) should be false")
}
// Wrapped errors flow through errors.Is — retry loops use
// `fmt.Errorf("... %w", sqlrite.ErrBusy)` shape, so we verify the
// helper recognises wrapped variants too.
wrapped := fmt.Errorf("commit failed: %w", sqlrite.ErrBusy)
if !sqlrite.IsRetryable(wrapped) {
t.Error("sqlrite.IsRetryable should unwrap %w to find sqlrite.ErrBusy")
}
}

func TestJournalModeMvccReachesGoDriver(t *testing.T) {
// Sanity that `PRAGMA journal_mode = mvcc` reaches the engine
// through cgo. BEGIN CONCURRENT itself isn't usefully
// exercisable through `database/sql` today (the driver
// doesn't expose sibling Connection handles per the Phase
// 11.1 multi-connection contract), but PRAGMA accepts and the
// `BEGIN CONCURRENT` gate flips, which proves the cgo
// plumbing is right.
//
// Note: PRAGMA renders a single-row result in the engine's
// `CommandOutput.rendered`, but the Go driver routes non-SELECT
// statements through `sqlrite_execute` (no rows), so we don't
// try to read the value back through `db.Query`.
db := openMem(t)
mustExec(t, db, "PRAGMA journal_mode = mvcc")
// BEGIN CONCURRENT only succeeds once journal_mode is mvcc;
// the gate proves the toggle landed.
mustExec(t, db, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
mustExec(t, db, "BEGIN CONCURRENT")
mustExec(t, db, "ROLLBACK")
// Unknown values still error cleanly (regression guard for
// the PRAGMA dispatcher).
if _, err := db.Exec("PRAGMA journal_mode = nonsense"); err == nil {
t.Fatal("expected unknown journal_mode to error")
}
}

func TestNonEmptyParametersRejected(t *testing.T) {
db := openMem(t)
mustExec(t, db, "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
Expand Down
Loading
Loading