Skip to content

feat(sdk): Phase 11.7 SDK propagation of Busy / BusySnapshot (SQLR-22)#128

Merged
joaoh82 merged 2 commits into
mainfrom
worktree-phase-11-7-sdk-busy
May 11, 2026
Merged

feat(sdk): Phase 11.7 SDK propagation of Busy / BusySnapshot (SQLR-22)#128
joaoh82 merged 2 commits into
mainfrom
worktree-phase-11-7-sdk-busy

Conversation

@joaoh82
Copy link
Copy Markdown
Owner

@joaoh82 joaoh82 commented May 11, 2026

Summary

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.

What ships

C FFI (sqlrite-ffi/src/lib.rs)

  • SqlriteStatus::Busy = 5 + SqlriteStatus::BusySnapshot = 6 codes
  • SqlriteStatus::is_retryable() covers both
  • New internal status_of_sqlrite mapper inspects the engine's SQLRiteError variant and routes to the dedicated codes; the generic status_of keeps mapping every error to Error for non-engine results
  • sqlrite_execute switched to the engine-aware mapper
  • Header regenerated via build.rs

Python SDK (sdk/python/src/lib.rs)

  • sqlrite.BusyError and sqlrite.BusySnapshotError pyo3 exception classes, both inheriting from sqlrite.SQLRiteError
  • New map_engine_err helper raises the matching exception based on the engine variant
  • All engine-typed call sites (open / execute / prepare / query / rows.next) routed through it
  • Existing except sqlrite.SQLRiteError blocks still catch both
while True:
    try:
        conn.execute("BEGIN CONCURRENT")
        conn.execute("UPDATE accounts SET balance = balance - 50 WHERE id = 1")
        conn.execute("COMMIT")
        break
    except sqlrite.BusyError:
        continue

Node.js SDK (sdk/nodejs/src/lib.rs)

  • Exported ErrorKind string enum ('Busy' | 'BusySnapshot' | 'Other')
  • Exported errorKind(message: string) classifier — regex-matches the engine's thiserror prefix ('Busy: ' / 'BusySnapshot: ')
  • Longest-prefix-first ordering so BusySnapshot doesn't mis-classify as Busy
const { Database, errorKind, ErrorKind } = require('@joaoh82/sqlrite');
while (true) {
  try {
    db.exec('BEGIN CONCURRENT');
    db.exec("UPDATE t SET v = v + 1 WHERE id = 1");
    db.exec('COMMIT');
    break;
  } catch (err) {
    const kind = errorKind(err.message);
    if (kind === ErrorKind.Busy || kind === ErrorKind.BusySnapshot) continue;
    throw err;
  }
}

Go SDK (sdk/go/sqlrite.go)

  • sqlrite.ErrBusy and sqlrite.ErrBusySnapshot sentinel errors
  • sqlrite.IsRetryable(err error) bool helper covers both
  • 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 cgo + database/sql driver chain
for {
    tx, err := db.Begin()
    if err != nil { return err }
    // ... do work, then:
    err = tx.Commit()
    if err == nil { break }
    if sqlrite.IsRetryable(err) { continue }
    return err
}

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

What this slice doesn't do

  • Each SDK's connect() still builds an independent backing DB. End-to-end testing of cross-handle Busy requires the multi-handle SDK shape (planned as 11.10). This PR ships the plumbing — once that wiring lands, callers already have the retry idioms they need.
  • The WAL log-record durability work (plan-doc 11.5 / roadmap 11.8) stays deferred.
  • SDK READMEs aren't yet updated with retry examples; lands alongside the 11.11 doc sweep.

Test plan

  • cargo build --workspace --exclude sqlrite-desktop --exclude sqlrite-python --exclude sqlrite-nodejs --exclude sqlrite-benchmarks --all-targets — clean
  • cargo test --workspace --exclude sqlrite-desktop --exclude sqlrite-python --exclude sqlrite-nodejs --exclude sqlrite-benchmarks652/652 (2 new FFI tests including a real cross-handle conflict via the FFI's Connection::connect)
  • cargo test -p sqlrite-nodejs — 3 new classifier tests pass
  • cargo build -p sqlrite-ffi -p sqlrite-python -p sqlrite-nodejs — all clean
  • cargo clippy — no new warnings on changed files
  • cargo fmt --all -- --check — clean
  • cargo doc — no new warnings on changed files
  • Python + Go SDK CI jobs will run the new SDK-side tests on the next CI cycle (sdk/python/tests/test_sqlrite.py + sdk/go/sqlrite_test.go)

New tests

  • FFI (2): is_retryable_covers_busy_variants, begin_concurrent_busy_status_round_trip (exercises a real conflict over a shared backing DB via Connection::connect through the FFI handle).
  • Node Rust-side (3): classify_recognises_busy_prefix, classify_recognises_busy_snapshot_prefix, classify_returns_other_for_generic_errors.
  • Python (2): test_busy_error_class_exists_and_inherits_from_sqlrite_error, test_journal_mode_pragma_round_trips_through_python.
  • Go (3): TestBusySentinelsAreDistinctErrors, TestIsRetryableCoversBothSentinels, TestJournalModeMvccRoundTripsThroughGoDriver.

Subtle issues hit + fixed

  • napi-rs's #[napi(string_enum)] auto-derives Clone + Copy. My manual #[derive(Debug, Clone, Copy, …)] clashed with E0119 (conflicting implementations). 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 to disambiguate.

Roadmap renumbering

  • plan-doc 11.5 (checkpoint) → roadmap 11.8
  • plan-doc 11.7 (indexes) → roadmap 11.9
  • multi-handle SDK + REPL .spawn (was plan-doc 11.8's other half) → roadmap 11.10
  • plan-doc 11.9 (docs) → roadmap 11.11

Called out in the roadmap entries so plan-doc references remain readable.

🤖 Generated with Claude Code

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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rust-sqlite Ready Ready Preview, Comment May 11, 2026 6:42am

Request Review

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>
@joaoh82 joaoh82 merged commit 358d995 into main May 11, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant