Skip to content

Commit 786f379

Browse files
joaoh82claude
andauthored
feat(sdk:go): Phase 11.11c — cross-pool sibling shape via path registry (SQLR-22) (#134)
* feat(sdk:go): Phase 11.11c — cross-pool sibling shape via path registry (SQLR-22) Closes the last open item from Phase 11's SDK arc. Pre-11.11c the Go SDK took a real engine `Connection::open` for every `sql.Open(…)` call — which deadlocked against itself on `flock(LOCK_EX)` whenever two `*sql.DB` instances pointed at the same file, or a single pool grew past one connection. The C / Python / Node SDKs already had sibling-handle shapes since Phase 11.8; Go was the holdout because `database/sql`'s pool model expects `driver.Open` to be cheap + idempotent and SQLRite's exclusive flock collided with that contract. The fix: a process-level path registry in `sdk/go/sqlrite.go` keyed by canonical absolute path (`filepath.Abs` + `filepath.Clean`). For file-backed read-write opens: 1. First opener pays for a real `sqlrite_open` → handle stored as a hidden "primary" in the registry, refcount = 0. 2. Subsequent openers mint a sibling via the FFI's `sqlrite_connect_sibling(primary)` (shipped in 11.8). Each `*conn` owns its own sibling; refcount++. 3. Close: refcount--. When 0, the registry closes the primary and removes the entry. Lock order: `c.mu` → `registryMu`, never the reverse. `newConn` holds only `registryMu` (the `*conn` doesn't exist yet); `conn.Close` takes `c.mu` first, then `registryMu`. Scope (v0): - `:memory:` opens bypass the registry — each is its own DB by design (matches SQLite). - Read-only opens (`sqlrite.OpenReadOnly`) bypass too — they take a shared `flock(LOCK_SH)` that already coexists with other readers. - Symlinks are NOT resolved; the key is lexical. Callers needing symlink-equality canonicalize via `os.EvalSymlinks`. Tests (`sdk/go/sqlrite_test.go`): - `TestTwoSqlOpenOnSameFileShareState` — two `*sql.DB`s on the same path see each other's writes immediately, bidirectional. - `TestBeginConcurrentAcrossSqlOpenInstances` — pinned `*sql.Conn`s from two different pools each hold their own `BEGIN CONCURRENT`; A's commit wins, B's hits `ErrBusy` and retries; final value matches `0 + 1 + 100 = 101`. - `TestRegistryRefcountDropsToZeroOnLastClose` — after the last sibling closes, a fresh `sql.Open` on the same path succeeds (proves the flock was released and the entry removed). Docs: - `sdk/go/README.md` — new "Multi-handle reads + writes" section with the cross-pool runnable example + BEGIN CONCURRENT retry loop demo. Caveats called out (`:memory:` isolation, read-only bypass, symlinks). - `docs/concurrent-writes.md` — SDK propagation table refreshed to reflect Go cross-pool support; the "still constructs its own backing DB" note replaced with the registry's actual behaviour. - `docs/_index.md` — Phase 11 summary blurb updated to reflect the end-to-end story (only 11.10 indexes-under-MVCC and the checkpoint-drain follow-up remain). - `docs/roadmap.md` — Phase 11.11c promoted to ✅ shipped; active- frontier blurb refreshed. - `docs/design-decisions.md` — new §12i covering the registry rationale, lock order, scope decisions, and symlink caveat. Workspace: 615/615 Rust tests pass; `go test ./...` in `sdk/go` covers all existing tests plus the 3 new ones in ~1s. fmt + clippy + doc all clean. Phase 11's SDK arc is now done end-to-end: C / Python / Node / Go all mint sibling handles that share `Arc<Mutex<Database>>`; the 11.7 retryable-error machinery is exerciseable cross-pool from every shipped SDK. Only WASM remains untouched (single-threaded runtime, sibling story doesn't apply). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sdk:go): replace t.Context() with context.Background() — Go 1.24+ method `t.Context()` was added in Go 1.24, but `sdk/go/go.mod` declares Go 1.21 and CI runs an older toolchain. The three new tests added in 11.11c (`TestBeginConcurrentAcrossSqlOpenInstances` + `TestRegistryRefcountDropsToZeroOnLastClose`) used `t.Context()` which compiled locally on Go 1.24+ but broke CI on Go 1.21. Replace all three sites with `context.Background()` (available since Go 1.7), add the `context` import. 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 b3cd155 commit 786f379

8 files changed

Lines changed: 500 additions & 21 deletions

File tree

docs/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ As of May 2026, SQLRite has:
5555
- 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).
5656
- 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)
5757
- 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).
58-
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is **shipped end-to-end through 11.11a** plus the 11.12 docs sweep — a small set of follow-ups (checkpoint-drain to enable `Mvcc → Wal` downgrade; indexes under MVCC; the "N concurrent writers" benchmark workload) remain explicitly parked. `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 added 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 append a typed 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 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. The `sqlrite` REPL ships `.spawn` / `.use` / `.conns` for interactive demos. **User-facing reference:** [`docs/concurrent-writes.md`](concurrent-writes.md); runnable example at [`examples/rust/concurrent_writers.rs`](../examples/rust/concurrent_writers.rs). Original design proposal: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
58+
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is **shipped end-to-end** — `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 added 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 append a typed 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`. 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 *and* mint sibling handles that share backing state — Go's process-level path registry (Phase 11.11c) handles cross-`*sql.DB` sharing too. The `sqlrite` REPL ships `.spawn` / `.use` / `.conns` for interactive demos; the SQLR-16 benchmark suite adds `W13` (concurrent writers, mostly disjoint rows) as the Phase-11 differentiator workload. The only remaining items are deferred-by-design or foundation work: indexes under MVCC (11.10) and the checkpoint-drain follow-up (parked half of 11.9). **User-facing reference:** [`docs/concurrent-writes.md`](concurrent-writes.md); runnable example at [`examples/rust/concurrent_writers.rs`](../examples/rust/concurrent_writers.rs). Original design proposal: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
5959
- 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)
6060

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

docs/concurrent-writes.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,10 @@ Sibling propagation across each SDK (Phase 11.7 + 11.8):
193193
| C FFI | `sqlrite_connect_sibling(existing, out)` | `SqlriteStatus::Busy` / `BusySnapshot`; `sqlrite_status_is_retryable` |
194194
| Python | `conn.connect()` | `sqlrite.BusyError` / `sqlrite.BusySnapshotError` (both subclass `SQLRiteError`) |
195195
| Node.js | `db.connect()` | `errorKind(message)` returns `'Busy'` / `'BusySnapshot'` / `'Other'` |
196-
| Go | `(via database/sql pool — see notes below)` | `errors.Is(err, sqlrite.ErrBusy)` / `ErrBusySnapshot`; `sqlrite.IsRetryable(err)` |
196+
| Go | `database/sql` pool + cross-pool path registry (Phase 11.11c) | `errors.Is(err, sqlrite.ErrBusy)` / `ErrBusySnapshot`; `sqlrite.IsRetryable(err)` |
197197
| WASM | *(deferred — single-threaded runtime)* | *(deferred)* |
198198

199-
For Go, each `sql.Open("sqlrite", path)` still constructs its own backing DB; siblings within a single `sql.DB` pool share state automatically. Cross-pool sharing is a separate follow-up (Phase 11.11b).
199+
For Go, every `sql.Open("sqlrite", path)` against a file-backed read-write DB routes through a process-level path registry (Phase 11.11c) — multiple `sql.Open` calls for the same canonical path mint sibling handles off a shared primary, so each `*sql.DB`'s pool can issue its own `BEGIN CONCURRENT` against the same backing engine. `:memory:` opens stay isolated by design; read-only opens (via `sqlrite.OpenReadOnly`) take a shared lock and bypass the registry. See [`sdk/go/README.md`](../sdk/go/README.md#multi-handle-reads--writes-phase-1111c) for the runnable cross-pool example.
200200

201201
### The retry loop
202202

docs/design-decisions.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,32 @@ dispatch tree, every REPL line goes through it.
366366

367367
---
368368

369+
### 12i. Go SDK uses a process-level path registry to mint siblings (Phase 11.11c)
370+
371+
**Decision.** The Go SDK at [`sdk/go/`](../sdk/go/) keeps a process-level `map[string]*sharedEntry` keyed by canonical absolute path. Every file-backed read-write `sql.Open("sqlrite", path)` resolves the path through `filepath.Abs` + `filepath.Clean`, then either creates a registry entry (paying for a real `sqlrite_open`) or mints a **sibling handle** off the existing entry's primary via the FFI's `sqlrite_connect_sibling`. The registry holds a refcount of outstanding siblings; the last close fires `sqlrite_close` on the primary and removes the entry.
372+
373+
**Why.** `database/sql`'s pool model expects `driver.Open` to be cheap and idempotent: a single `*sql.DB` will call it whenever it needs another pool slot, and applications routinely hold multiple `*sql.DB` instances against the same file (one for the API server, one for a background worker, …). SQLRite's engine takes `flock(LOCK_EX)` on the WAL sidecar at the first `Connection::open`, so the second `sqlrite_open` for the same path would deadlock against the first one in the *same* process — a real defect that surfaced as "the existing TestFileBackedPersistsAcrossConnections only works because each `db.Close()` releases the lock before the next `sql.Open`."
374+
375+
The registry is the smallest thing that makes the SDK match the Phase 11.7 / 11.8 contract: sibling handles share `Arc<Mutex<Database>>` and each can hold its own `BEGIN CONCURRENT`. The Python / Node / C SDKs already had this story since 11.8 because they expose sibling creation directly (`Connection.connect()` / `db.connect()` / `sqlrite_connect_sibling`). Go's quirk is `database/sql`'s pool — it asks the driver for connections on its own schedule — so the work happens transparently inside `newConn`.
376+
377+
**Why a process-level (not `*sql.DB`-level) registry.** Cross-`*sql.DB` sharing was the original 11.8 gap. Keying the registry on path rather than on a pool instance is what closes that gap; two `sql.Open` calls in the same process for the same file converge on the same backing engine, same as the FFI / Python / Node story.
378+
379+
**Why a hidden "primary" + refcount, not just the first opener's handle.** The first opener could close *before* the second opener finishes its work. If the registry held only "the first opener's handle" the close would either:
380+
- close the underlying engine (leaving the second opener with a dead handle), or
381+
- need to transfer ownership somewhere, complicating the close path
382+
383+
A hidden primary that the registry itself owns sidesteps both problems: every `*conn` gets its own sibling, closes are independent, and the registry tears the primary down only when the refcount reaches zero.
384+
385+
**Why `:memory:` and read-only opens bypass the registry.** `:memory:` databases are isolated by design — each `sql.Open(":memory:")` is its own DB, matching SQLite. Read-only opens take a shared `flock(LOCK_SH)` that already coexists with other readers; routing them through the registry would give every read-only opener a read-write sibling (since `connect_sibling` doesn't downgrade access mode), which is the wrong abstraction. Cross-pool read-only sharing is a clean follow-up if anyone surfaces the use case.
386+
387+
**Why no symlink resolution.** `filepath.Abs` + `filepath.Clean` is lexical only — two `sql.Open` calls via different symlinks pointing at the same file end up as separate registry entries and the second one fails to acquire the flock. Resolving symlinks via `os.EvalSymlinks` would close that gap but breaks if intermediate path components are themselves symlinks that change between the resolution and the file open. v0 keeps the simple key and documents the symlink caveat in [`sdk/go/README.md`](../sdk/go/README.md); callers who care can pass an `EvalSymlinks`-canonicalized path.
388+
389+
**Lock order.** Two locks are in play: each `*conn`'s `c.mu`, and the registry's `registryMu`. Acquisition order is always `c.mu → registryMu`, never the reverse. `newConn` only holds `registryMu` (the `*conn` doesn't exist yet); `conn.Close` takes `c.mu` first, then `registryMu`. Other operations only hold `c.mu`.
390+
391+
**Plan-doc reference.** [`concurrent-writes-plan.md`](concurrent-writes-plan.md) §10.8 (multi-handle SDK shape, Go follow-up).
392+
393+
---
394+
369395
## Query execution
370396

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

docs/roadmap.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The project is staged in phases. Each phase is shippable on its own, ends with a working build + full test suite + a commit on `main`, and can be paused between. The README's roadmap section is a summary of this doc.
44

5-
> **Active frontier (May 2026):** Phases 0–10 shipped end-to-end. After Phase 8 closed the v0.1.x cycle, the v0.2.0 → v0.9.1 wave (Phase 9, sub-phases 9a–9i) landed the SQL surface that had been parked under "possible extras": DDL completeness (DEFAULT, DROP TABLE/INDEX, ALTER TABLE), free-list + auto-VACUUM, IS NULL, GROUP BY + aggregates + DISTINCT + LIKE + IN, four flavors of JOIN, prepared statements with parameter binding, HNSW metric extension, and the PRAGMA dispatcher. Phase 10 published the SQLR-4 / SQLR-16 benchmarks against SQLite + DuckDB. **Current head: v0.9.1.** **Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is shipped end-to-end through 11.12** — the multi-connection foundation, logical clock, `MvStore`, `BEGIN CONCURRENT` writes + commit-time validation, snapshot-isolated reads, garbage collection, SDK propagation across C / Python / Node / Go, multi-handle SDK shape, WAL log-record durability + crash recovery, REPL `.spawn` for interactive demos, and the canonical user-facing reference all landed. A small set of follow-ups (checkpoint-drain to enable `Mvcc → Wal` downgrade, indexes under MVCC, the "N concurrent writers" benchmark workload) remain explicitly parked. See [`concurrent-writes.md`](concurrent-writes.md) for the user-facing reference; [`concurrent-writes-plan.md`](concurrent-writes-plan.md) for the design rationale.
5+
> **Active frontier (May 2026):** Phases 0–10 shipped end-to-end. After Phase 8 closed the v0.1.x cycle, the v0.2.0 → v0.9.1 wave (Phase 9, sub-phases 9a–9i) landed the SQL surface that had been parked under "possible extras": DDL completeness (DEFAULT, DROP TABLE/INDEX, ALTER TABLE), free-list + auto-VACUUM, IS NULL, GROUP BY + aggregates + DISTINCT + LIKE + IN, four flavors of JOIN, prepared statements with parameter binding, HNSW metric extension, and the PRAGMA dispatcher. Phase 10 published the SQLR-4 / SQLR-16 benchmarks against SQLite + DuckDB. **Current head: v0.9.1.** **Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is shipped end-to-end through 11.12 + 11.11b + 11.11c** — the multi-connection foundation, logical clock, `MvStore`, `BEGIN CONCURRENT` writes + commit-time validation, snapshot-isolated reads, garbage collection, SDK propagation across C / Python / Node / Go (cross-pool sibling shape on Go via the path registry), multi-handle SDK shape, WAL log-record durability + crash recovery, REPL `.spawn` for interactive demos, the `W13` concurrent-writers bench workload, and the canonical user-facing reference all landed. The only remaining items are deferred-by-design or foundation work: indexes under MVCC (11.10, Turso punted on the same problem), and the checkpoint-drain follow-up (parked half of 11.9, enables `set_journal_mode(Mvcc → Wal)` once `MvStore` is drainable). See [`concurrent-writes.md`](concurrent-writes.md) for the user-facing reference; [`concurrent-writes-plan.md`](concurrent-writes-plan.md) for the design rationale.
66

77
## ✅ Phase 0 — Modernization
88

@@ -708,9 +708,18 @@ New `W13` workload in [`benchmarks/`](../benchmarks/) pits SQLRite-MVCC against
708708

709709
Headline numbers will land with the first pinned-host re-publication; v1 ships the workload + correctness gate so any future numbers stand on a verified base.
710710

711-
### Phase 11.11c — Go SDK cross-pool sibling shape *(planned)*
711+
### Phase 11.11c — Go SDK cross-pool sibling shape
712712

713-
Each `sql.Open("sqlrite", path)` today builds an independent backing DB; sharing engine state across `sql.DB` pools needs a process-level registry keyed by path. Bundled into Phase 11.11 originally; split out because it touches the Go binding architecture (cgo + the `database/sql` driver model) rather than the bench harness or the engine. See [`sdk/go/README.md`](../sdk/go/README.md) for the current single-pool sibling story.
713+
The Go SDK ([`sdk/go/`](../sdk/go/)) used to take one engine-level `Connection::open` per `sql.Open("sqlrite", path)`. A second `sql.Open` (or a single pool that grew past one connection) collided with the first opener's `flock(LOCK_EX)` and deadlocked — `database/sql`'s pool model + SQLRite's exclusive-writer lock disagreed.
714+
715+
This slice adds a **process-level path registry** (in [`sdk/go/sqlrite.go`](../sdk/go/sqlrite.go)) keyed by canonical absolute path. File-backed read-write opens now route through it: the first opener pays for a real `sqlrite_open` and the resulting handle is stashed as a hidden "primary" in the registry; subsequent openers mint a **sibling** off that primary via the C FFI's [`sqlrite_connect_sibling`](../sqlrite-ffi/include/sqlrite.h) (shipped in 11.8), sharing the engine's `Arc<Mutex<Database>>` underneath. A refcount tracks outstanding siblings; the registry closes the primary when it hits zero.
716+
717+
- `:memory:` opens stay isolated by design (matches SQLite); each `sql.Open(":memory:")` is its own DB.
718+
- Read-only opens (`sqlrite.OpenReadOnly`) bypass the registry — they take a shared `flock(LOCK_SH)` that can coexist with other readers but conflicts with any writer in the same process.
719+
- Symlinks are **not** resolved; the registry key is `filepath.Abs` + `filepath.Clean`. Symlink-equality is the caller's job (use `os.EvalSymlinks`-ed paths).
720+
- New tests cover cross-`*sql.DB` state sharing, BEGIN CONCURRENT across separate pools with a real Busy + retry, and the refcount dropping to zero on the last close.
721+
722+
End result: every shipped SDK — C FFI / Python / Node / Go — now mints sibling handles that share backing state. The 11.7 retryable-error machinery (`sqlrite.ErrBusy`, `sqlrite.ErrBusySnapshot`, `sqlrite.IsRetryable`) is finally exerciseable cross-pool from Go.
714723

715724
### ✅ Phase 11.12 — Docs sweep *(plan-doc "Phase 10.9")*
716725

0 commit comments

Comments
 (0)