Skip to content

Commit 31b32d6

Browse files
os-zhuangclaude
andcommitted
docs(adr): ADR-0034 robust multi-write transactions (ambient transaction)
Diagnoses the multi-write transaction deadlock found while implementing the cross-object atomic batch (#1604): knex+sqlite single-connection contention because internal queries during a write don't reuse the transaction's connection. Specifies the fix (AsyncLocalStorage ambient transaction + per-transaction connection), notes driver-sql already has a persistent sequence (so #1603 consolidates rather than rebuilds), and a cross-driver multi-write test plan. Foundation for #1603/#1604. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 128e3e0 commit 31b32d6

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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

Comments
 (0)