|
| 1 | +# ADR-0034: Robust multi-write transactions (ambient transaction context) |
| 2 | + |
| 3 | +**Status**: Proposed |
| 4 | +**Author**: surfaced while implementing the cross-object atomic batch (issue #1604) + autonumber sequence (issue #1603) |
| 5 | +**Affects**: `@objectstack/objectql` (engine), `@objectstack/driver-sql` (and every driver), `@objectstack/rest` |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## TL;DR |
| 10 | + |
| 11 | +The runtime cannot reliably perform **multiple writes inside one transaction**. |
| 12 | +A `engine.transaction(async () => { await insert(A); await insert(B); })` |
| 13 | +**deadlocks** on the SQLite single-connection pool and leaves the connection |
| 14 | +wedged. This blocks every feature that needs atomic multi-object writes — |
| 15 | +master-detail save (#1604), approvals, automation chains, bulk actions. It must |
| 16 | +be fixed at the foundation **before** those features can be correct. |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## Context |
| 21 | + |
| 22 | +### Reproduction (confirmed) |
| 23 | + |
| 24 | +A cross-object batch endpoint (`POST /api/v1/batch`) that wraps N operations in |
| 25 | +`ql.transaction(...)` was implemented and joint-tested against app-showcase: |
| 26 | + |
| 27 | +- ✅ single write commits; |
| 28 | +- ✅ a batch whose later op throws **rolls back** (true atomicity); |
| 29 | +- ❌ **two successful writes + commit hangs**, and the hung transaction holds |
| 30 | + the one SQLite connection, so every subsequent write (even single-op) also |
| 31 | + hangs until the server is restarted. |
| 32 | + |
| 33 | +The endpoint was reverted — shipping a route that can wedge the backend is |
| 34 | +unacceptable — and the finding documented in issue #1604. |
| 35 | + |
| 36 | +### Root cause |
| 37 | + |
| 38 | +`app-showcase` (and the default standalone runtime) use **knex + |
| 39 | +better-sqlite3** with a **single-connection pool** (SQLite is single-writer). |
| 40 | + |
| 41 | +`engine.transaction()` threads the open transaction into the driver options of |
| 42 | +the *top-level* write via `buildDriverOptions` (`engine.ts`, which even warns |
| 43 | +about this deadlock around L1811). But internal reads/writes performed **during** |
| 44 | +a write — FK / reference checks, hook `api` calls, any helper query — do **not** |
| 45 | +all reuse the transaction's connection. Such a query asks the pool for a |
| 46 | +connection, the pool is exhausted (the transaction holds the only one), and it |
| 47 | +waits forever → deadlock. |
| 48 | + |
| 49 | +The single top-level threading is necessary but **not sufficient**: correctness |
| 50 | +requires that *every* data operation issued while a transaction is open runs on |
| 51 | +that transaction's connection. |
| 52 | + |
| 53 | +### Relevant existing facts |
| 54 | + |
| 55 | +- `driver-sql` already has the right *local* pattern for nested work: |
| 56 | + `getNextSequenceValue` uses `runner = parentTrx ?? this.knex` and opens a |
| 57 | + savepoint on the parent transaction (`sql-driver.ts` L561-563). The gap is |
| 58 | + that this discipline isn't applied *globally* to every engine→driver call. |
| 59 | +- `driver-sql` already implements a **persistent, atomic sequence** |
| 60 | + (`SEQUENCES_TABLE` + `getNextSequenceValue` with `forUpdate` + seed-from-max, |
| 61 | + L548-600). See "Autonumber (#1603)" below — this is mostly already solved at |
| 62 | + the driver level. |
| 63 | + |
| 64 | +--- |
| 65 | + |
| 66 | +## Decision |
| 67 | + |
| 68 | +### 1. Ambient transaction context (the foundation) |
| 69 | + |
| 70 | +Make the active transaction **ambient** rather than passed by hand. Use Node's |
| 71 | +`AsyncLocalStorage` to store the current transaction handle for the duration of |
| 72 | +`engine.transaction(callback)`. Every driver call reads the ambient transaction |
| 73 | +and binds its query to it (`.transacting(trx)`) automatically — no caller, hook, |
| 74 | +validation predicate, or internal helper can forget to thread it. |
| 75 | + |
| 76 | +```ts |
| 77 | +// engine |
| 78 | +private readonly txStore = new AsyncLocalStorage<{ transaction: unknown }>(); |
| 79 | + |
| 80 | +async transaction(cb, baseContext) { |
| 81 | + const trx = await driver.beginTransaction(); |
| 82 | + return this.txStore.run({ transaction: trx }, async () => { |
| 83 | + try { const r = await cb({ ...baseContext, transaction: trx }); await driver.commit(trx); return r; } |
| 84 | + catch (e) { await driver.rollback(trx); throw e; } |
| 85 | + }); |
| 86 | +} |
| 87 | + |
| 88 | +// buildDriverOptions / driver: prefer explicit opts.transaction, else fall back |
| 89 | +// to the ambient one. |
| 90 | +const trx = opts.transaction ?? engine.txStore.getStore()?.transaction; |
| 91 | +``` |
| 92 | + |
| 93 | +This keeps the explicit `options.transaction` path working and makes the |
| 94 | +*implicit* internal queries correct. |
| 95 | + |
| 96 | +### 2. Per-transaction connection |
| 97 | + |
| 98 | +For pooled drivers, a transaction must own a connection for its lifetime |
| 99 | +(knex transactions already do). The fix above guarantees that no *other* query |
| 100 | +competes for it. For SQLite specifically, keep `pool.max = 1` and rely on (1) |
| 101 | +so nothing else asks for a connection mid-transaction; for Postgres/MySQL the |
| 102 | +pool naturally hands each transaction its own connection. |
| 103 | + |
| 104 | +### 3. Autonumber (#1603) — consolidate, don't rebuild |
| 105 | + |
| 106 | +`driver-sql` already provides a persistent, atomic, gap-tolerant sequence. |
| 107 | +The engine-level in-memory counter added for the validation-order fix is |
| 108 | +redundant and itself issues a non-transactional seed query. Plan: have the |
| 109 | +engine **defer autonumber generation to the driver's sequence** (keeping the |
| 110 | +"generate before required-validation" ordering), and delete the in-memory |
| 111 | +counter. Gaps on rollback are accepted (industry-standard for sequences). |
| 112 | + |
| 113 | +### 4. Then unblock the features |
| 114 | + |
| 115 | +Once (1)+(2) land and a multi-write transaction test suite is green: |
| 116 | +- Re-add `POST /api/v1/data/batch` (cross-object, `$ref` for intra-batch parent |
| 117 | + references) — the implementation from #1604 is ready. |
| 118 | +- Point ObjectUI `masterDetailTx` at it and delete the client-side best-effort |
| 119 | + cleanup (a smell that exists only because server atomicity was missing). |
| 120 | + |
| 121 | +--- |
| 122 | + |
| 123 | +## Consequences |
| 124 | + |
| 125 | +- **Positive**: atomic multi-write becomes a platform guarantee; master-detail, |
| 126 | + approvals, automations, bulk actions become correct by construction; the SDUI |
| 127 | + client gets thinner. |
| 128 | +- **Risk**: (1) touches the engine's shared write path — must land with a |
| 129 | + cross-driver multi-write commit/rollback test suite (the absence of which let |
| 130 | + this bug exist) and careful review. This is a dedicated, reviewed PR, not a |
| 131 | + drive-by change. |
| 132 | + |
| 133 | +## Test plan (the missing coverage) |
| 134 | + |
| 135 | +Add an integration suite (per driver: memory, sql/sqlite, and at least one |
| 136 | +networked DB in CI) asserting: |
| 137 | +1. `transaction(() => { insert A; insert B; })` commits both; |
| 138 | +2. a throwing op rolls back **all** prior writes; |
| 139 | +3. a write whose validation/hook performs an internal read does not deadlock; |
| 140 | +4. master-detail create (parent + children referencing it) is atomic. |
0 commit comments