|
| 1 | +# Shared backend concurrency model — verification & design doc (#4942) |
| 2 | + |
| 3 | +The AMS local-store concurrency guarantees were originally designed for **two local processes sharing one |
| 4 | +SQLite file**. #7175 migrated that layer off `node:sqlite` directly and onto the shared |
| 5 | +`SelfHostD1Database` seam (`src/selfhost/backend-contracts.ts`, #4010), which has two interchangeable |
| 6 | +adapters — a SQLite one and a Postgres one. This doc records what concurrency the two adapters actually |
| 7 | +guarantee (and what they don't) against the real shared seam, so the hosted service's assumptions are |
| 8 | +stated explicitly instead of being inherited implicitly from the old local-file design. The claims below |
| 9 | +are pinned by `test/unit/selfhost-d1-concurrency.test.ts` (SQLite, runs in every CI pass) and the |
| 10 | +`PG_TEST_URL`-gated `test/integration/selfhost-pg.test.ts` (Postgres, needs a live server). |
| 11 | + |
| 12 | +## The seam |
| 13 | + |
| 14 | +Both adapters implement one contract, `SelfHostD1Database` (`src/selfhost/backend-contracts.ts:87-89`): |
| 15 | +`prepare` / `batch` / `exec` / `dump`, where `batch(statements)` is documented as running "a batch |
| 16 | +atomically, one result per statement, in order" (`src/selfhost/d1-adapter.ts:75`). Every data-access call |
| 17 | +site in loopover — the ~171 drizzle-orm repository sites plus every raw |
| 18 | +`env.DB.prepare(sql).bind(...).all()/.first()/.run()/.batch()` call — goes through this one surface, so |
| 19 | +its atomicity is the guarantee the whole application actually leans on. |
| 20 | + |
| 21 | +- **SQLite adapter** — `createD1Adapter(driver)` (`src/selfhost/d1-adapter.ts:70`) over the synchronous |
| 22 | + `SqliteDriver` primitive (`d1-adapter.ts:20-22`); the default driver is `nodeSqliteDriver` over |
| 23 | + `node:sqlite` (`d1-adapter.ts:116`). The D1 API is async, but the driver is **synchronous** — the async |
| 24 | + methods only wrap already-resolved values, so there is no real preemption inside a single statement. |
| 25 | +- **Postgres adapter** — `createPgAdapter(pool)` (`src/selfhost/pg-adapter.ts`) over a `node-postgres` |
| 26 | + `Pool`; a real pooled, async, multi-connection client. |
| 27 | + |
| 28 | +## SQLite backend |
| 29 | + |
| 30 | +**Topology.** One process, one connection, one file. This is not incidental — it is the supported topology |
| 31 | +for the whole admission system: `installation-concurrency-admission.ts` states outright that |
| 32 | +"single-process-per-deployment is already the supported topology for the whole admission system (the |
| 33 | +SQLite backend structurally cannot share state across processes at all)". "Concurrency" against this |
| 34 | +backend therefore means **event-loop interleaving of the async D1 surface within one process**, not |
| 35 | +OS-level multi-connection contention. |
| 36 | + |
| 37 | +**Atomicity.** `batch()` wraps its statements in `BEGIN` / `COMMIT`, with `ROLLBACK` on any error |
| 38 | +(`d1-adapter.ts:75-88`). Because the driver is synchronous, a `batch()` runs its `BEGIN` through its |
| 39 | +`COMMIT`/`ROLLBACK` with no `await` in between, so no other operation can observe a partially-applied |
| 40 | +batch. |
| 41 | + |
| 42 | +**What is guaranteed** |
| 43 | + |
| 44 | +- A single self-contained write statement (e.g. `UPDATE … SET value = value + 1`) is applied in full; N |
| 45 | + such concurrent statements lose no updates (final value == N). _(test: "N concurrent atomic increments |
| 46 | + lose no updates")_ |
| 47 | +- `batch()` is all-or-nothing: a failing statement rolls back the entire batch, leaving no partial write. |
| 48 | + _(test: "a failing statement rolls back the whole batch")_ |
| 49 | +- A committed batch applies every statement, in order. _(test: "a committed batch applies every statement, |
| 50 | + in order")_ |
| 51 | +- A read interleaved with a batch never observes an uncommitted intermediate state — only the pre- or |
| 52 | + post-batch value. _(test: "a read concurrent with a batch never observes a rolled-back intermediate |
| 53 | + state")_ |
| 54 | + |
| 55 | +**What is NOT guaranteed** |
| 56 | + |
| 57 | +- **Non-atomic read-modify-write is not safe**, exactly as on any backend. Splitting an increment into an |
| 58 | + awaited read then an awaited write lets concurrent sequences all read the same pre-write value before |
| 59 | + any write lands, losing all but one update. _(test: "concurrent non-atomic read-modify-write loses |
| 60 | + updates")_ Callers must use a single atomic statement, a `batch()`, or a `UNIQUE`-constrained upsert — |
| 61 | + never a bare read-then-write pair. |
| 62 | +- **Cross-process sharing is out of scope** for this backend. `nodeSqliteDriver` itself sets no PRAGMAs; |
| 63 | + the production open path (`src/server.ts:266`) applies |
| 64 | + `PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;`, which lets a single |
| 65 | + deployment's short serialized write windows resolve without `SQLITE_BUSY`, but multi-writer |
| 66 | + cross-process durability is a Postgres concern, not a SQLite one. |
| 67 | + |
| 68 | +## Postgres backend |
| 69 | + |
| 70 | +`batch()` acquires a dedicated pooled connection, runs `BEGIN`, executes each statement on that same |
| 71 | +client, then `COMMIT` — or `ROLLBACK` and rethrow on error — before releasing the connection back to the |
| 72 | +pool (`pg-adapter.ts`, `async batch(statements)`). This is real cross-connection transactional isolation: |
| 73 | +concurrent tenants run on distinct pooled connections, and each `batch()` is its own isolated transaction. |
| 74 | + |
| 75 | +**What is guaranteed** |
| 76 | + |
| 77 | +- Each `batch()` is an isolated transaction on its own connection; a failure rolls the whole batch back |
| 78 | + without touching any other in-flight connection's work. _(test, `PG_TEST_URL`-gated: "batch() rolls back |
| 79 | + the whole transaction on a failing statement")_ |
| 80 | +- Distinct pooled connections give genuine parallelism across tenant sessions, unlike the SQLite backend's |
| 81 | + single-connection topology. |
| 82 | + |
| 83 | +**What is NOT guaranteed** |
| 84 | + |
| 85 | +- Application-level lost-update protection for a read-then-write spanning two separate statements — the |
| 86 | + same rule as SQLite. Use row locking (`SELECT … FOR UPDATE`), a `UNIQUE`/upsert constraint, or fold the |
| 87 | + read and write into a single atomic statement inside the batch. |
| 88 | + |
| 89 | +## Why the tests are split this way |
| 90 | + |
| 91 | +The SQLite guarantees are verified deterministically **in-process** (the backend's real topology), so they |
| 92 | +run in the standard `test:coverage` suite with no external dependency and no flakiness. Real |
| 93 | +multi-connection Postgres concurrency needs a live server, so it stays behind the existing |
| 94 | +`PG_TEST_URL`-gated integration suite (`test/integration/selfhost-pg.test.ts`) rather than being faked |
| 95 | +with a scripted mock pool, which cannot exhibit real multi-connection race behavior. The shared takeaway |
| 96 | +for callers is backend-independent: **atomicity is a property of the statement or `batch()` you write, not |
| 97 | +something either backend adds to a read-modify-write pair for free.** |
0 commit comments