Skip to content

Commit 7eca38b

Browse files
docs(selfhost): verify + document the shared backend's concurrency model
Verifies the SQLite and Postgres SelfHostD1Database adapters' actual concurrency guarantees against the real seam post-#7175, instead of assuming the old two-local-process design still silently holds, and pins each claim to a deterministic in-process test (SQLite) or a PG_TEST_URL-gated live-Postgres test. Closes #4942
1 parent 5467233 commit 7eca38b

3 files changed

Lines changed: 242 additions & 0 deletions

File tree

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

test/integration/selfhost-pg.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,46 @@ suite("Postgres backend (#977) — real Postgres", () => {
220220
expect(await getGateBlockOutcome(env, "owner/repo", 43)).toMatchObject({ overridden: true });
221221
});
222222

223+
// #4942: real cross-connection Postgres concurrency guarantees, verified against the actual pooled backend
224+
// (mirrors the SQLite side's own in-process concurrency guarantees in test/unit/selfhost-d1-concurrency.test.ts
225+
// -- see src/selfhost/backend-concurrency-model.md for the combined write-up). A scripted mock pool cannot
226+
// exhibit real multi-connection race behavior, so this needs the live server this whole suite is gated on.
227+
it("GUARANTEE (#4942): batch() rolls back the whole transaction on a failing statement, on its own pooled connection", async () => {
228+
const db = createPgAdapter(pool);
229+
await pool.query("CREATE TABLE IF NOT EXISTS concurrency_counters (id TEXT PRIMARY KEY, value INTEGER NOT NULL)");
230+
await pool.query("DELETE FROM concurrency_counters");
231+
await db.prepare("INSERT INTO concurrency_counters (id, value) VALUES ('c', 0)").run();
232+
233+
// Second statement violates the PRIMARY KEY, so the whole batch -- on its own dedicated connection -- must
234+
// ROLLBACK, leaving the first statement's UPDATE un-applied too.
235+
await expect(
236+
db.batch([
237+
db.prepare("UPDATE concurrency_counters SET value = 99 WHERE id = 'c'"),
238+
db.prepare("INSERT INTO concurrency_counters (id, value) VALUES ('c', 1)"), // duplicate PK -> throws
239+
]),
240+
).rejects.toThrow();
241+
242+
const row = await db.prepare("SELECT value FROM concurrency_counters WHERE id = 'c'").first<{ value: number }>();
243+
expect(row?.value).toBe(0);
244+
});
245+
246+
it("GUARANTEE (#4942): N concurrent atomic increments across distinct pooled connections lose no updates", async () => {
247+
const db = createPgAdapter(pool);
248+
await pool.query("CREATE TABLE IF NOT EXISTS concurrency_counters (id TEXT PRIMARY KEY, value INTEGER NOT NULL)");
249+
await pool.query("DELETE FROM concurrency_counters");
250+
await db.prepare("INSERT INTO concurrency_counters (id, value) VALUES ('n', 0)").run();
251+
252+
const N = 25;
253+
// Each single self-contained UPDATE is atomic on whichever pooled connection runs it -- real parallelism
254+
// across connections, unlike the SQLite backend's single-connection topology, still loses no updates.
255+
await Promise.all(
256+
Array.from({ length: N }, () => db.prepare("UPDATE concurrency_counters SET value = value + 1 WHERE id = 'n'").run()),
257+
);
258+
259+
const row = await db.prepare("SELECT value FROM concurrency_counters WHERE id = 'n'").first<{ value: number }>();
260+
expect(row?.value).toBe(N);
261+
});
262+
223263
it("tunes github_rate_limit_observations autovacuum below Postgres's default, idempotently (#2543)", async () => {
224264
const db = createPgAdapter(pool);
225265

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { DatabaseSync } from "node:sqlite";
2+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
3+
import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
4+
5+
// Concurrency-model verification for the shared SQLite backend (#4942). The AMS local-store guarantees were
6+
// originally designed for two local processes sharing one SQLite file; #7175 migrated that layer onto the
7+
// shared SelfHostD1Database seam (src/selfhost/backend-contracts.ts, #4010), so the guarantees the hosted
8+
// service now actually relies on need to be verified against the real seam and documented, not assumed to
9+
// still hold implicitly. This file pins down the SQLite side's guarantees under concurrent access from the
10+
// async D1 surface -- the model the SQLite backend actually has: a single process, a synchronous driver,
11+
// operations serialized on the event loop, never real OS-level multi-connection contention (see
12+
// src/selfhost/backend-concurrency-model.md). The Postgres side's real cross-connection concurrency is
13+
// exercised by the PG_TEST_URL-gated test/integration/selfhost-pg.test.ts, since it needs a live server.
14+
15+
function makeDb(): { d1: D1Database; raw: DatabaseSync } {
16+
// The production open path (src/server.ts:266) sets these exact PRAGMAs; matching them here keeps the seam
17+
// under test aligned with the deployed configuration. An in-memory db is a single connection -- the SQLite
18+
// backend's real topology (single process, one file/connection) -- so "concurrency" here is event-loop
19+
// interleaving of the async D1 surface, not OS-level multi-connection contention.
20+
const raw = new DatabaseSync(":memory:");
21+
raw.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
22+
return { d1: createD1Adapter(nodeSqliteDriver(raw as never)), raw };
23+
}
24+
25+
async function readCounter(d1: D1Database): Promise<number> {
26+
return (await d1.prepare("SELECT value FROM counters WHERE id = 'c'").first<number>("value")) ?? -1;
27+
}
28+
29+
let d1: D1Database;
30+
let raw: DatabaseSync;
31+
32+
beforeEach(async () => {
33+
({ d1, raw } = makeDb());
34+
await d1.exec("CREATE TABLE counters (id TEXT PRIMARY KEY, value INTEGER NOT NULL);");
35+
await d1.prepare("INSERT INTO counters (id, value) VALUES ('c', 0)").run();
36+
});
37+
38+
afterEach(() => {
39+
raw.close(); // release the SQLite handle so nothing is left open between tests
40+
});
41+
42+
describe("shared SQLite backend concurrency guarantees (#4942)", () => {
43+
it("GUARANTEE: N concurrent atomic increments lose no updates (final value == N)", async () => {
44+
const N = 50;
45+
// A single self-contained UPDATE is a single statement on the synchronous driver -- it runs to completion
46+
// before the next call resumes, so every increment is applied; none can interleave mid-statement.
47+
await Promise.all(
48+
Array.from({ length: N }, () => d1.prepare("UPDATE counters SET value = value + 1 WHERE id = 'c'").run()),
49+
);
50+
expect(await readCounter(d1)).toBe(N);
51+
});
52+
53+
it("BOUNDARY: concurrent non-atomic read-modify-write loses updates -- the documented hazard, not a bug", async () => {
54+
const N = 50;
55+
// Splitting the increment into an awaited read then an awaited write lets every one of the N sequences
56+
// observe the same pre-write value before any write lands, so all but one update is lost. This is
57+
// deterministic here (every read resolves before the first write, since the read is issued synchronously
58+
// at the top of each async callback) -- the exact reason callers must use a single atomic statement or a
59+
// batch(), never a bare read-then-write pair, on ANY backend.
60+
await Promise.all(
61+
Array.from({ length: N }, async () => {
62+
const current = await readCounter(d1);
63+
await d1.prepare("UPDATE counters SET value = ? WHERE id = 'c'").bind(current + 1).run();
64+
}),
65+
);
66+
const final = await readCounter(d1);
67+
expect(final).toBeLessThan(N);
68+
expect(final).toBe(1);
69+
});
70+
71+
it("GUARANTEE: a failing statement rolls back the whole batch (no partial write)", async () => {
72+
// The second statement violates the PRIMARY KEY, so the whole batch must ROLLBACK, leaving the first
73+
// statement's UPDATE un-applied too.
74+
await expect(
75+
d1.batch([
76+
d1.prepare("UPDATE counters SET value = 99 WHERE id = 'c'"),
77+
d1.prepare("INSERT INTO counters (id, value) VALUES ('c', 1)"), // duplicate PK -> throws
78+
]),
79+
).rejects.toThrow();
80+
expect(await readCounter(d1)).toBe(0);
81+
});
82+
83+
it("GUARANTEE: a committed batch applies every statement, in order", async () => {
84+
await d1.batch([
85+
d1.prepare("UPDATE counters SET value = value + 10 WHERE id = 'c'"),
86+
d1.prepare("UPDATE counters SET value = value * 2 WHERE id = 'c'"),
87+
]);
88+
expect(await readCounter(d1)).toBe(20); // (0 + 10) * 2, in the order given
89+
});
90+
91+
it("GUARANTEE: a read concurrent with a batch never observes a rolled-back intermediate state", async () => {
92+
// The batch runs BEGIN..COMMIT/ROLLBACK synchronously with no await in between (the driver is sync), so an
93+
// interleaved read can only ever see the pre-batch or post-batch value, never a partially-applied one.
94+
const failing = d1
95+
.batch([
96+
d1.prepare("UPDATE counters SET value = 77 WHERE id = 'c'"),
97+
d1.prepare("INSERT INTO counters (id, value) VALUES ('c', 2)"), // duplicate PK -> rollback
98+
])
99+
.catch(() => "rolled-back" as const);
100+
const observedDuring = await readCounter(d1);
101+
await failing;
102+
expect(observedDuring).toBe(0); // never the uncommitted 77
103+
expect(await readCounter(d1)).toBe(0); // rolled back cleanly
104+
});
105+
});

0 commit comments

Comments
 (0)