Skip to content

Commit 358d995

Browse files
joaoh82claude
andauthored
feat(sdk): Phase 11.7 SDK propagation of Busy / BusySnapshot (SQLR-22) (#128)
* feat(sdk): Phase 11.7 SDK propagation of Busy / BusySnapshot (SQLR-22) Surfaces retryable engine errors through the C FFI and every language SDK so Python / Node / Go callers can actually write BEGIN CONCURRENT retry loops with idiomatic per-language patterns. Picked ahead of plan-doc 11.5 (checkpoint integration) for the same reason 11.5 / 11.6 jumped the queue — durability already works through the legacy save_database mirror, but SDK users hitting BEGIN CONCURRENT had no way to distinguish retryable errors from real failures. Plan-doc 11.8 ("SDK + REPL propagation") split into two: - FFI/SDK error propagation ships here as roadmap 11.7 - Multi-handle SDK shape + REPL .spawn → roadmap 11.10 C FFI: - new SqlriteStatus::Busy = 5 + BusySnapshot = 6 codes - SqlriteStatus::is_retryable() covers both - new status_of_sqlrite() mapper routes engine-typed errors to the dedicated codes; generic status_of() keeps mapping every error to Error for non-engine result types - sqlrite_execute switched to the engine-aware mapper - header regenerated via build.rs Python SDK: - new sqlrite.BusyError + sqlrite.BusySnapshotError pyo3 exception classes, both inheriting from sqlrite.SQLRiteError - new map_engine_err() helper inspects the engine variant and raises the matching exception class - all engine-typed call sites (open / execute / prepare / query / rows.next) routed through it - existing `except sqlrite.SQLRiteError` blocks still catch both; retry helpers branch with `except sqlrite.BusyError` Node.js SDK: - new exported ErrorKind string enum ('Busy' | 'BusySnapshot' | 'Other') and errorKind(message) classifier function - engine's thiserror Display already prefixes the error message with 'Busy: ' / 'BusySnapshot: '; classifier matches the prefix (longest-first to avoid mis-classifying snapshot errors) - JS pattern: try { ... } catch (err) { if (errorKind(err.message) === ErrorKind.Busy) continue; throw err; } Go SDK: - new ErrBusy + ErrBusySnapshot sentinel errors - new IsRetryable(err error) bool helper covers both - wrapErr recognises new FFI status codes and wraps the engine message with fmt.Errorf("…: %w", ErrBusy) so errors.Is(err, sqlrite.ErrBusy) works through the cgo + database/sql driver chain WASM SDK: deliberately untouched. Browser is single-threaded; multi-handle Connection::connect concurrency isn't exposed through wasm-bindgen yet. Same 'Busy: ' message prefix will be the classifier hook when the multi-handle WASM work lands (11.10). Subtle issues hit: - napi-rs derive #[napi(string_enum)] auto-adds Clone+Copy. My manual `#[derive(Debug, Clone, Copy, ...)]` produced E0119 (conflicting impls). Dropped Clone+Copy from the manual derive list. - Python's `SQLRiteError` short name clashes between the engine enum and the pyo3 exception class (both called SQLRiteError). Used fully-qualified `sqlrite::SQLRiteError::Busy(_)` in match arms. Tests: - 2 new FFI tests: is_retryable_covers_busy_variants + begin_concurrent_busy_status_round_trip (exercises a real conflict over a shared backing DB via Connection::connect through FFI). - 3 Node-side Rust tests for classify_error_message. - 2 Python tests (class existence + inheritance, journal_mode round-trip). - 3 Go tests (sentinel distinctness, IsRetryable coverage, journal_mode round-trip). What this slice doesn't do: - Each SDK's connect() still builds an independent backing DB. End-to-end testing of cross-handle Busy needs the multi-handle SDK shape (planned as 11.10). This PR ships the *plumbing* so once that wiring lands, callers already have the retry idioms. - WAL log-record durability still deferred to 11.8. - SDK READMEs not yet updated with retry examples — that's a doc sweep, lands alongside 11.11. 652/652 Rust workspace tests pass. 3/3 Node-side classifier tests pass. fmt + clippy + doc clean on changed files. Python + Go SDK CI jobs will exercise the SDK-side additions on the next run. Roadmap renumbered: plan-doc 11.5 (checkpoint) → roadmap 11.8; plan-doc 11.7 (indexes) → 11.9; multi-handle SDK + REPL = 11.10; plan-doc 11.9 (docs) → 11.11. Called out in the roadmap entries so plan-doc references remain readable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sdk): Python + Go test fixes for CI Go: my Phase 11.7 tests referenced the new sentinels and helper as bare names (`ErrBusy`, `IsRetryable`), but `sdk/go/sqlrite_test.go` is `package sqlrite_test` (external) and needs the `sqlrite.` prefix. CI's `go test ./...` caught it; my workspace cargo build didn't (Go SDK isn't a Rust workspace member). Python: my `test_journal_mode_pragma_round_trips_through_python` asserted `cur.fetchone() == ('mvcc',)` after `PRAGMA journal_mode`, but PRAGMA goes through the cursor's non-query path — the rendered single-row result lives in `CommandOutput.rendered`, never surfaced through the cursor's row iterator. Fetchone() returns None. Reshaped both tests to verify the *gate* opens rather than the read-form renders: `PRAGMA journal_mode = mvcc` followed by a `BEGIN CONCURRENT` that would otherwise reject. Same property, cleanly testable through each SDK's public API today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b64d13 commit 358d995

9 files changed

Lines changed: 563 additions & 23 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.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).
57+
- 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).
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/roadmap.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,7 @@ The headline slice. Multiple sibling `Connection`s can each hold their own open
622622
**Known limitations carried forward (most resolved in 11.5):**
623623

624624
- ~~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`.
625-
- 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.)
625+
- 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.)
626626
- `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.
627627
- 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.
628628

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

635635
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).
636636

637-
### 🚧 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)*
637+
### 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)*
638638

639639
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.
640640

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

649649
- 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.
650-
- 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.
650+
- 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.
651651

652-
### 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)*
652+
### 🚧 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)*
653+
654+
- **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`.
655+
- **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.
656+
- **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; }`.
657+
- **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.
658+
- **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.
659+
660+
**What this slice doesn't do:**
661+
662+
- 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.
663+
- The WAL log-record durability work (plan-doc 11.5 / our 11.8) stays deferred.
664+
665+
### 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)*
653666

654667
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.
655668

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

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

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

662-
Surface `Busy` / `BusySnapshot` through the FFI shim and each language SDK. New REPL `.spawn` meta-command + new "N concurrent writers" benchmark workload.
675+
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.
663676

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

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

sdk/go/sqlrite.go

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,65 @@ const (
127127
statusOk Status = 0
128128
statusError Status = 1
129129
statusInvalidArgument Status = 2
130-
statusDone Status = 101
131-
statusRow Status = 102
130+
// Phase 11.7 — retryable-error codes the C FFI surfaces from
131+
// `BEGIN CONCURRENT` commit conflicts. See `ErrBusy` /
132+
// `ErrBusySnapshot` below for the Go-side sentinels callers
133+
// match against.
134+
statusBusy Status = 5
135+
statusBusySnapshot Status = 6
136+
statusDone Status = 101
137+
statusRow Status = 102
132138
)
133139

140+
// Phase 11.7 — retryable error sentinels exposed to Go callers.
141+
// Match against them with `errors.Is(err, sqlrite.ErrBusy)` /
142+
// `errors.Is(err, sqlrite.ErrBusySnapshot)` to drive a retry
143+
// loop:
144+
//
145+
// for {
146+
// tx, err := db.Begin()
147+
// if err != nil { return err }
148+
// // ... do work, then:
149+
// err = tx.Commit()
150+
// if err == nil { break }
151+
// if errors.Is(err, sqlrite.ErrBusy) ||
152+
// errors.Is(err, sqlrite.ErrBusySnapshot) {
153+
// // tx was already rolled back by the engine
154+
// continue
155+
// }
156+
// return err
157+
// }
158+
//
159+
// Use [IsRetryable] for a kind-agnostic check.
160+
var (
161+
// ErrBusy is returned when a `BEGIN CONCURRENT` commit hits a
162+
// row-level write-write conflict. The transaction has already
163+
// been rolled back; the caller should retry the whole
164+
// transaction with a fresh `BEGIN CONCURRENT`.
165+
ErrBusy = errors.New("sqlrite: busy (write-write conflict; transaction rolled back, retry)")
166+
167+
// ErrBusySnapshot is returned when a `BEGIN CONCURRENT` read
168+
// sees a row that has been superseded after the transaction's
169+
// snapshot was taken. Same retry semantics as `ErrBusy`.
170+
ErrBusySnapshot = errors.New("sqlrite: busy snapshot (snapshot stale; transaction rolled back, retry)")
171+
)
172+
173+
// IsRetryable reports whether `err` chains an `ErrBusy` or
174+
// `ErrBusySnapshot` and should therefore be retried by the
175+
// caller. Use it instead of comparing against individual
176+
// sentinels so a future retryable variant (e.g. lock-wait
177+
// timeout) doesn't force a caller-side change.
178+
func IsRetryable(err error) bool {
179+
return errors.Is(err, ErrBusy) || errors.Is(err, ErrBusySnapshot)
180+
}
181+
134182
// wrapErr returns a Go error when the status code is nonzero. Use
135183
// after any `sqlrite_*` call that can fail.
184+
//
185+
// Phase 11.7 — retryable statuses surface as errors that wrap the
186+
// matching sentinel (`ErrBusy` / `ErrBusySnapshot`) so callers can
187+
// use `errors.Is` to branch their retry loops without parsing the
188+
// message.
136189
func wrapErr(status Status, op string) error {
137190
if status == statusOk {
138191
return nil
@@ -141,7 +194,14 @@ func wrapErr(status Status, op string) error {
141194
if msg == "" {
142195
msg = fmt.Sprintf("SQLRite status %d", uint32(status))
143196
}
144-
return fmt.Errorf("sqlrite: %s: %s", op, msg)
197+
switch status {
198+
case statusBusy:
199+
return fmt.Errorf("sqlrite: %s: %s: %w", op, msg, ErrBusy)
200+
case statusBusySnapshot:
201+
return fmt.Errorf("sqlrite: %s: %s: %w", op, msg, ErrBusySnapshot)
202+
default:
203+
return fmt.Errorf("sqlrite: %s: %s", op, msg)
204+
}
145205
}
146206

147207
// cString converts a Go string into a C-allocated NUL-terminated

sdk/go/sqlrite_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ package sqlrite_test
1212

1313
import (
1414
"database/sql"
15+
"errors"
16+
"fmt"
1517
"os"
1618
"path/filepath"
1719
"strings"
@@ -249,6 +251,74 @@ func TestBadSQLBubblesUpAsError(t *testing.T) {
249251
}
250252
}
251253

254+
// ---------------------------------------------------------------------------
255+
// Phase 11.7 — BEGIN CONCURRENT / Busy sentinel errors
256+
257+
func TestBusySentinelsAreDistinctErrors(t *testing.T) {
258+
if sqlrite.ErrBusy == nil {
259+
t.Fatal("sqlrite.ErrBusy is nil")
260+
}
261+
if sqlrite.ErrBusySnapshot == nil {
262+
t.Fatal("sqlrite.ErrBusySnapshot is nil")
263+
}
264+
// Sanity: the two sentinels are independent values.
265+
if errors.Is(sqlrite.ErrBusy, sqlrite.ErrBusySnapshot) {
266+
t.Error("sqlrite.ErrBusy must not match sqlrite.ErrBusySnapshot via errors.Is")
267+
}
268+
if errors.Is(sqlrite.ErrBusySnapshot, sqlrite.ErrBusy) {
269+
t.Error("sqlrite.ErrBusySnapshot must not match sqlrite.ErrBusy via errors.Is")
270+
}
271+
}
272+
273+
func TestIsRetryableCoversBothSentinels(t *testing.T) {
274+
if !sqlrite.IsRetryable(sqlrite.ErrBusy) {
275+
t.Error("sqlrite.IsRetryable(sqlrite.ErrBusy) should be true")
276+
}
277+
if !sqlrite.IsRetryable(sqlrite.ErrBusySnapshot) {
278+
t.Error("sqlrite.IsRetryable(sqlrite.ErrBusySnapshot) should be true")
279+
}
280+
if sqlrite.IsRetryable(errors.New("not a busy error")) {
281+
t.Error("sqlrite.IsRetryable on a generic error should be false")
282+
}
283+
if sqlrite.IsRetryable(nil) {
284+
t.Error("sqlrite.IsRetryable(nil) should be false")
285+
}
286+
// Wrapped errors flow through errors.Is — retry loops use
287+
// `fmt.Errorf("... %w", sqlrite.ErrBusy)` shape, so we verify the
288+
// helper recognises wrapped variants too.
289+
wrapped := fmt.Errorf("commit failed: %w", sqlrite.ErrBusy)
290+
if !sqlrite.IsRetryable(wrapped) {
291+
t.Error("sqlrite.IsRetryable should unwrap %w to find sqlrite.ErrBusy")
292+
}
293+
}
294+
295+
func TestJournalModeMvccReachesGoDriver(t *testing.T) {
296+
// Sanity that `PRAGMA journal_mode = mvcc` reaches the engine
297+
// through cgo. BEGIN CONCURRENT itself isn't usefully
298+
// exercisable through `database/sql` today (the driver
299+
// doesn't expose sibling Connection handles per the Phase
300+
// 11.1 multi-connection contract), but PRAGMA accepts and the
301+
// `BEGIN CONCURRENT` gate flips, which proves the cgo
302+
// plumbing is right.
303+
//
304+
// Note: PRAGMA renders a single-row result in the engine's
305+
// `CommandOutput.rendered`, but the Go driver routes non-SELECT
306+
// statements through `sqlrite_execute` (no rows), so we don't
307+
// try to read the value back through `db.Query`.
308+
db := openMem(t)
309+
mustExec(t, db, "PRAGMA journal_mode = mvcc")
310+
// BEGIN CONCURRENT only succeeds once journal_mode is mvcc;
311+
// the gate proves the toggle landed.
312+
mustExec(t, db, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
313+
mustExec(t, db, "BEGIN CONCURRENT")
314+
mustExec(t, db, "ROLLBACK")
315+
// Unknown values still error cleanly (regression guard for
316+
// the PRAGMA dispatcher).
317+
if _, err := db.Exec("PRAGMA journal_mode = nonsense"); err == nil {
318+
t.Fatal("expected unknown journal_mode to error")
319+
}
320+
}
321+
252322
func TestNonEmptyParametersRejected(t *testing.T) {
253323
db := openMem(t)
254324
mustExec(t, db, "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")

0 commit comments

Comments
 (0)