Skip to content

Commit 4495a68

Browse files
joaoh82claude
andauthored
feat(repl): Phase 11.11a REPL .spawn for interactive BEGIN CONCURRENT demos (SQLR-22) (#131)
Lifts the REPL from a single Database to Vec<Connection> so users can mint sibling handles in-session and step through cross-handle MVCC scenarios. The prompt now shows the active handle ('sqlrite[A]> ' / 'sqlrite[B]> ') so it's obvious which connection will execute the next line. New meta-commands: - .spawn Mint a sibling Connection sharing the same Arc<Mutex<Database>> via Connection::connect(), then switch to it. Each handle gets a stable letter name (A, B, ..., Z, then AA, AB). - .use NAME Switch the active handle (case-insensitive); errors with the list of valid names on miss. - .conns List every handle, mark active with *, tag handles holding an open BEGIN CONCURRENT with (BEGIN CONCURRENT). Plumbing: - New ReplState in src/repl/mod.rs owns Vec<Connection>, parallel Vec<String> of names, and the active index. Exposes lock_active() for meta-commands that mutate the underlying Database directly, and active_conn_mut() for SQL dispatch through Connection. - src/main.rs migrates the REPL loop from &mut Database to &mut ReplState. SQL dispatch routes through a new Connection::execute_with_render which returns CommandOutput (status + optional rendered prettytable) instead of a bare String — so BEGIN CONCURRENT / COMMIT / ROLLBACK still hit the per-connection MVCC state, while SELECTs come back with the prettytable for the REPL to print above the status line. - Connection::concurrent_tx_is_open() promoted from private to public so .conns can render per-handle tx state. - .open collapses every sibling back to a single handle named A — siblings pointing at the previous Database would otherwise be stranded with stale MVCC state. Tests: - 8 new cases in src/meta_command/mod.rs cover .spawn / .use / .conns parse + dispatch behaviour, case-insensitive .use, the error message on unknown names, multi-handle shared-database visibility, .open-collapse, and the A → Z → AA naming wrap. Docs: - roadmap.md: Phase 11.11a promoted to shipped; the heavier benchmark workload split out as 11.11b. - usage.md: new "Multi-handle mode" section with a worked BEGIN-CONCURRENT-vs-BEGIN-CONCURRENT demo. - smoke-test.md: prompt example refreshed (sqlrite[A]>), command count bumped from 5 → 9. - design-decisions.md: new §12h covering the migration rationale (why Vec<Connection> over HashMap, why .open collapses siblings, why execute_with_render instead of pre-parsing). Workspace: 615/615 Rust tests pass (was 607, +8 new). fmt + clippy + doc all clean. Smoke-tested the BEGIN CONCURRENT demo end-to-end across A/B handles — A's commit hits Busy as expected. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b969f6 commit 4495a68

8 files changed

Lines changed: 625 additions & 94 deletions

File tree

docs/design-decisions.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,61 @@ the existing per-commit GC bounds in-memory chain growth.
309309

310310
---
311311

312+
### 12h. REPL holds `Vec<Connection>` rather than a single `Database` (Phase 11.11a)
313+
314+
**Decision.** The `sqlrite` REPL binary keeps its state in a small
315+
`ReplState` struct holding a `Vec<Connection>`, a parallel
316+
`Vec<String>` of stable display names (`A`, `B`, …), and a
317+
`usize` index pointing at the active handle. SQL dispatch goes
318+
through [`Connection::execute_with_render`](../src/connection.rs)
319+
(new in this slice); meta-commands either operate on the
320+
underlying `Database` via `ReplState::lock_active()` or mutate
321+
the connection list itself (`.spawn`, `.use`, `.conns`).
322+
323+
**Why migrate from `&mut Database`.** `.spawn` only makes sense
324+
across multiple `Connection`s sharing the same
325+
`Arc<Mutex<Database>>` — that's the entire point of
326+
`Connection::connect()`. The previous REPL owned a single
327+
`Database` by value, which made siblings unrepresentable. The
328+
migration is mostly mechanical: the SQL dispatch routes through
329+
the active connection, and every existing meta-command keeps
330+
operating on the database directly by grabbing the mutex guard.
331+
332+
**Why `Vec<Connection>` and not a `HashMap<String, Connection>`.**
333+
Handle creation order is the only ordering that matters for
334+
demos. A `Vec` keeps `.conns` output deterministic without
335+
sorting; the parallel `names` vector keeps the name → index
336+
lookup O(handles), which is fine for the realistic upper bound
337+
(a handful of siblings in a demo session). Skipping the map also
338+
avoids a `String` key per access on the hot SQL path.
339+
340+
**Why `.open` collapses every sibling back to a single handle.**
341+
Replacing the underlying `Database` via the mutex guard works in
342+
place — every sibling sees the new content. But sibling handles
343+
typically hold per-connection MVCC transaction state
344+
(`Connection::concurrent_tx`) keyed by the *previous* clock /
345+
`MvStore`; after a `.open` swap, that state is meaningless and
346+
attempting to commit would walk the wrong active-tx registry.
347+
Dropping siblings on `.open` is cleaner than retroactively
348+
invalidating their tx state, and matches the user's mental model
349+
("`.open` is a fresh start").
350+
351+
**Why `execute_with_render` instead of pre-parsing in the REPL.**
352+
The REPL needs both the rendered SELECT table and the BEGIN
353+
CONCURRENT routing. The old `process_command_with_render` gives
354+
the former but bypasses the per-connection MVCC dispatch
355+
(`BEGIN CONCURRENT` / `COMMIT` / `ROLLBACK` interception, the
356+
snapshot-read swap). The new method mirrors `Connection::execute`
357+
but threads the `CommandOutput` struct through every branch —
358+
the BEGIN/COMMIT/ROLLBACK arms produce
359+
`CommandOutput { status, rendered: None }`; the
360+
process-command path produces the full output. One method, one
361+
dispatch tree, every REPL line goes through it.
362+
363+
**Plan-doc reference.** [`concurrent-writes-plan.md`](concurrent-writes-plan.md) §10.8 (REPL `.spawn` meta-command and demos).
364+
365+
---
366+
312367
## Query execution
313368

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

docs/roadmap.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,9 +685,21 @@ MVCC commits now leave a typed log-record frame in the WAL on top of the existin
685685

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

688-
### Phase 11.11 — REPL `.spawn` + bench workload *(planned)*
688+
### Phase 11.11a — REPL `.spawn` for interactive `BEGIN CONCURRENT` demos
689689

690-
REPL `.spawn` meta-command for interactive `BEGIN CONCURRENT` demos. New "N concurrent writers" benchmark workload pitting SQLRite-MVCC against SQLite + DuckDB on disjoint-row write throughput. Plus Go SDK multi-handle work (cross-pool sibling shape).
690+
Lift the REPL from a single `Database` to a `Vec<Connection>` so users can mint sibling handles in-session and step through cross-handle MVCC scenarios. The prompt now shows the active handle (`sqlrite[A]> ` / `sqlrite[B]> `) so it's always obvious which connection is about to execute the next line.
691+
692+
- **`.spawn`** mints a sibling off the active handle (via `Connection::connect`) and switches to it. Each handle gets a stable letter name (`A`, `B`, `C`, …, `Z`, then `AA`, `AB`).
693+
- **`.use <NAME>`** switches the active handle (case-insensitive); errors with the list of valid names if the target is unknown.
694+
- **`.conns`** lists every handle, marks the active one with `*`, and tags any handle that holds an open `BEGIN CONCURRENT` so demos can show the conflict-detection state at a glance.
695+
- **`.open`** collapses every sibling back to a single handle named `A` so the new database doesn't strand siblings pointing at the old one.
696+
- New [`Connection::execute_with_render`](../src/connection.rs) returns a `CommandOutput` instead of a bare status string, so the REPL's SQL dispatch routes through `Connection` (catching `BEGIN CONCURRENT` / `COMMIT` / `ROLLBACK` and the in-flight tx swap) while still printing the prettytable for `SELECT`. The old non-render `execute` stays for callers that don't need it.
697+
698+
The downstream "N concurrent writers" benchmark workload (originally bundled into 11.11) is its own follow-up: it touches the `benchmarks/` harness, links SQLite + DuckDB drivers, and is much heavier than this slice.
699+
700+
### Phase 11.11b — `N concurrent writers` benchmark workload *(planned)*
701+
702+
New benchmark in [`benchmarks/`](../benchmarks/) that pits SQLRite-MVCC against SQLite + DuckDB on a disjoint-row "N writers, mostly disjoint rows" scenario. Slots into the existing SQLR-16 harness as a Group D differentiator workload. Also includes Go SDK multi-handle work (cross-pool sibling shape) — see the 11.8 note for why that's a separate slice.
691703

692704
### Phase 11.12 — Docs *(planned, plan-doc "Phase 10.9")*
693705

docs/smoke-test.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ Enter .exit to quit.
3939
Enter .help for usage hints.
4040
Connected to a transient in-memory database.
4141
Use '.open FILENAME' to reopen on a persistent database.
42-
sqlrite>
42+
sqlrite[A]>
4343
```
4444

45+
(The `[A]` suffix is the active connection handle — Phase 11.11a added `.spawn` / `.use` / `.conns` for multi-handle demos.)
46+
4547
(The version line in the banner tracks the current build — `cargo run` always shows the live value, so don't be surprised if it's later than what's printed here.)
4648

4749
Also verify the help text is detailed:
@@ -58,7 +60,7 @@ Should print the project description, the meta-command table, and a summary of s
5860
sqlrite> .help
5961
```
6062

61-
Expect the 5-command list (`.help`, `.open`, `.save`, `.tables`, `.exit`) and a note that `.read` / `.ast` aren't implemented.
63+
Expect the 9-command list (`.help`, `.open`, `.save`, `.tables`, `.ask`, `.spawn`, `.use`, `.conns`, `.exit`) and a note that `.read` / `.ast` aren't implemented.
6264

6365
### 1.3 Create a table
6466

@@ -585,7 +587,7 @@ When you want a fast before/after comparison for a change, run this condensed ch
585587
- [ ] `cargo run --bin sqlrite-mcp -- --help` prints the MCP server CLI without crashing — quick check that the stdio_redirect dance still works
586588
- [ ] `cargo run -- --help` prints the full description + meta-command table + SQL surface (not just `-h` / `-V`)
587589
- [ ] `cargo run -- somefile.sqlrite` on a non-existent path creates the file and enters the REPL with auto-save on
588-
- [ ] REPL launches, `.help` shows 5 commands
590+
- [ ] REPL launches, `.help` shows 9 commands
589591
- [ ] `.tables` in a populated DB prints one name per line
590592
- [ ] CREATE TABLE + INSERT + SELECT `*` work in memory
591593
- [ ] `SELECT ... WHERE col = literal` on a UNIQUE column returns the right row (index probe path)

docs/usage.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ Meta commands start with a dot and don't need a trailing semicolon.
2323
| `.open FILENAME` | Open (or create) a `.sqlrite` file. From this point on, every committing SQL statement auto-saves. |
2424
| `.save FILENAME` | Force-flush the current DB to `FILENAME`. Rarely needed — auto-save makes this redundant when it's the active file. Useful for "save as" to a different path. |
2525
| `.tables` | List tables in the current database, sorted alphabetically |
26+
| `.ask QUESTION` | Natural-language → SQL via the configured LLM. Requires `SQLRITE_LLM_API_KEY`. |
27+
| `.spawn` | Mint a sibling connection sharing the same backing database. Switches to it. (Phase 11.11a) |
28+
| `.use NAME` | Switch the active handle (case-insensitive). (Phase 11.11a) |
29+
| `.conns` | List every active handle; marks the current one with `*` and flags handles in an open `BEGIN CONCURRENT`. (Phase 11.11a) |
2630
| `.read` / `.ast` | Not yet implemented |
2731

2832
### `.open` semantics
@@ -31,7 +35,33 @@ Meta commands start with a dot and don't need a trailing semicolon.
3135
- If `FILENAME` doesn't exist: create an empty database at that path (auto-save enabled immediately).
3236
- If `FILENAME` exists but is not a valid SQLRite database: reject with a `bad magic bytes` error — the REPL stays in its previous state.
3337

34-
Only one database is active at a time. A subsequent `.open` replaces the in-memory state.
38+
Only one database is active at a time. A subsequent `.open` replaces the in-memory state and **collapses every sibling handle minted via `.spawn` back to a single one** (named `A`) — siblings pointing at the previous database would be stranded otherwise.
39+
40+
### Multi-handle mode (Phase 11.11a)
41+
42+
The REPL holds a vector of `Connection`s; the prompt always shows which one is active: `sqlrite[A]> `, `sqlrite[B]> `, etc.
43+
44+
- `.spawn` mints a new sibling off the active handle and switches to it. Each new handle gets the next letter in sequence (`A`, `B`, `C`, …, `Z`, `AA`, `AB`).
45+
- `.use NAME` switches the active handle. The next SQL line runs on that connection.
46+
- `.conns` shows the current roster, with `*` next to the active handle and `(BEGIN CONCURRENT)` next to any handle holding an open concurrent transaction.
47+
48+
A worked demo (assumes `PRAGMA journal_mode = mvcc;`):
49+
50+
```text
51+
sqlrite[A]> CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);
52+
sqlrite[A]> INSERT INTO t (id, v) VALUES (1, 0);
53+
sqlrite[A]> .spawn
54+
Spawned sibling handle 'B' and switched to it. 2 handles open.
55+
sqlrite[B]> .use A
56+
sqlrite[A]> BEGIN CONCURRENT;
57+
sqlrite[A]> UPDATE t SET v = 100 WHERE id = 1;
58+
sqlrite[A]> .use B
59+
sqlrite[B]> BEGIN CONCURRENT;
60+
sqlrite[B]> UPDATE t SET v = 200 WHERE id = 1;
61+
sqlrite[B]> COMMIT;
62+
sqlrite[B]> .use A
63+
sqlrite[A]> COMMIT; -- Busy: write-write conflict on t/1
64+
```
3565

3666
## Supported SQL
3767

src/connection.rs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,13 +297,42 @@ impl Connection {
297297
}
298298
}
299299

300+
/// Phase 11.11a — same as [`Connection::execute`], but returns
301+
/// the full [`CommandOutput`] (status + optional pre-rendered
302+
/// prettytable for `SELECT`). The REPL needs this to print the
303+
/// table the engine produced *and* the status line in one
304+
/// pass, while still routing `BEGIN CONCURRENT` / `COMMIT` /
305+
/// `ROLLBACK` through the per-connection MVCC state.
306+
///
307+
/// `BEGIN` / `COMMIT` / `ROLLBACK` carry no rendered output —
308+
/// they return `CommandOutput { status, rendered: None }`.
309+
pub fn execute_with_render(&mut self, sql: &str) -> Result<crate::sql::CommandOutput> {
310+
let intent = concurrent_tx_intent(sql);
311+
let has_tx = self.concurrent_tx_is_open();
312+
let status = match intent {
313+
ConcurrentTxIntent::Begin => self.begin_concurrent()?,
314+
ConcurrentTxIntent::Commit if has_tx => self.commit_concurrent()?,
315+
ConcurrentTxIntent::Rollback if has_tx => self.rollback_concurrent()?,
316+
ConcurrentTxIntent::None
317+
| ConcurrentTxIntent::Commit
318+
| ConcurrentTxIntent::Rollback => return self.execute_dispatch_with_render(sql),
319+
};
320+
Ok(crate::sql::CommandOutput {
321+
status,
322+
rendered: None,
323+
})
324+
}
325+
300326
/// Phase 11.5 — cheap probe used by [`Connection::execute`]
301327
/// (and [`Statement::query`]) to decide whether to route
302328
/// through the concurrent-tx dispatch. Acquires the
303329
/// `concurrent_tx` mutex briefly; never blocks for a
304330
/// meaningful amount of time because the only other lockers
305331
/// are this connection's own writers.
306-
fn concurrent_tx_is_open(&self) -> bool {
332+
///
333+
/// Public so the REPL can render per-handle tx state in
334+
/// `.conns` output (Phase 11.11a).
335+
pub fn concurrent_tx_is_open(&self) -> bool {
307336
self.lock_concurrent_tx().is_some()
308337
}
309338

@@ -407,6 +436,20 @@ impl Connection {
407436
}
408437
}
409438

439+
/// Phase 11.11a — render-aware twin of
440+
/// [`Connection::execute_dispatch`]. Same branching, but the
441+
/// non-concurrent path calls `process_command_with_render` and
442+
/// the concurrent path goes through
443+
/// [`Connection::execute_in_concurrent_tx_with_render`].
444+
fn execute_dispatch_with_render(&mut self, sql: &str) -> Result<crate::sql::CommandOutput> {
445+
if self.concurrent_tx_is_open() {
446+
self.execute_in_concurrent_tx_with_render(sql)
447+
} else {
448+
let mut db = self.lock();
449+
crate::sql::process_command_with_render(sql, &mut db)
450+
}
451+
}
452+
410453
/// Phase 11.4 — opens a `BEGIN CONCURRENT` transaction on this
411454
/// connection. Allocates a new `TxHandle` (which advances the
412455
/// MVCC clock by one), deep-clones the live tables into the
@@ -630,6 +673,18 @@ impl Connection {
630673
/// new tables to the live database without a separate merge
631674
/// pass).
632675
fn execute_in_concurrent_tx(&mut self, sql: &str) -> Result<String> {
676+
self.execute_in_concurrent_tx_with_render(sql)
677+
.map(|o| o.status)
678+
}
679+
680+
/// Render-aware twin of [`Connection::execute_in_concurrent_tx`].
681+
/// Same swap-based dispatch; the only difference is the inner
682+
/// call goes through `process_command_with_render` so the
683+
/// caller gets the rendered SELECT table (Phase 11.11a).
684+
fn execute_in_concurrent_tx_with_render(
685+
&mut self,
686+
sql: &str,
687+
) -> Result<crate::sql::CommandOutput> {
633688
let intent = legacy_tx_intent(sql);
634689
if matches!(intent, LegacyTxIntent::Begin) {
635690
return Err(SQLRiteError::General(
@@ -672,7 +727,7 @@ impl Connection {
672727
tables: HashMap::new(),
673728
});
674729

675-
let result = crate::sql::process_command(sql, &mut db);
730+
let result = crate::sql::process_command_with_render(sql, &mut db);
676731

677732
// Unwind in reverse: take the dummy txn off (don't restore
678733
// anything from it), swap the tables back.

0 commit comments

Comments
 (0)