Skip to content

Commit 1c5bb76

Browse files
authored
Merge branch 'main' into health-checks-redo
2 parents ca8bbf2 + d74dc55 commit 1c5bb76

14 files changed

Lines changed: 569 additions & 117 deletions

apps/cloud/scripts/dev-db.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,41 @@ const db = await PGlite.create(DB_PATH);
8282
console.log(`[dev-db] Running migrations from ${MIGRATIONS_FOLDER}`);
8383
await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });
8484

85+
// `PGLiteSocketServer` defaults to `maxConnections: 1` and answers every extra
86+
// concurrent connection with "Too many connections" + an immediate socket
87+
// close. (pglite-socket 0.1.4's published index.d.ts documents "default: 100",
88+
// but the shipped runtime JS is `maxConnections ?? 1`, verified in the shipped
89+
// chunk, so the runtime default really is 1.) The cloud worker opens a fresh
90+
// postgres pool per request (the MCP auth seam rebuilds one on EVERY `/mcp`
91+
// request, see apps/cloud/src/mcp/auth.ts), so under concurrent load, exactly
92+
// what the e2e suite generates against one shared dev stack, the
93+
// second-and-later connects were rejected, and postgres.js reconnected in a
94+
// tight loop against the closed socket. That reconnect storm piled up
95+
// thousands of half-closed sockets, starved real queries, drove request
96+
// latency into the tens of seconds, and eventually hung the stack: the CI e2e
97+
// "cloud dev stack degrades after minutes of sustained load" cascade flake.
98+
// PGlite runs queries serially (its internal QueryQueueManager executes each
99+
// under `runExclusive`), so allowing many connections means they queue instead
100+
// of being rejected. One caveat makes that safe: stock pglite-socket 0.1.4
101+
// enqueues each wire FRAME separately, so two connections' extended-protocol
102+
// pipelines (Parse/Bind/Execute/Sync) would interleave inside the one shared
103+
// PGlite session and corrupt each other ("bind message supplies N parameters,
104+
// but prepared statement requires M" -> random 500s on whichever request lost
105+
// the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch
106+
// batches each socket data event into one queue entry and holds handler
107+
// affinity while a pipeline is open;
108+
// src/db/dev-db-socket-concurrency.node.test.ts is the regression test.
85109
const server = new PGLiteSocketServer({
86110
db,
87111
port: PORT,
88112
host: "127.0.0.1",
113+
maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000),
114+
// Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse
115+
// sent, no Sync) with its socket still OPEN would hold the queue's handler
116+
// affinity forever and starve every other connection, since affinity only
117+
// releases on detach and detach needs close/error/idle-timeout. In ms; the
118+
// timer resets on every data event, so only a genuinely dead client trips it.
119+
idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000),
89120
});
90121

91122
await server.start();

apps/cloud/src/db/db.close.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Contract: the postgres pool finalizer must AWAIT the connection teardown.
2+
//
3+
// The cloud MCP auth seam (`makeMcpOrganizationAuthServices`) builds a fresh
4+
// postgres pool on EVERY `/mcp` request and closes it in its `acquireRelease`
5+
// finalizer. That finalizer used to be fire-and-forget (`Effect.runFork(
6+
// sql.end({ timeout: 0 }))`): it returned before the socket was torn down, so
7+
// under sustained MCP load closed-but-unreaped sockets piled up against the
8+
// dev PGlite server (effectively single-connection) faster than it reaped
9+
// them. New connects queued behind the backlog, request latency climbed into
10+
// the tens of seconds, and the e2e cloud dev stack hung after a few minutes:
11+
// the CI cascade flake.
12+
//
13+
// These tests characterize the contract the old fire-and-forget close violated
14+
// (`closePostgres` itself is new alongside them): it must (a) call `sql.end`
15+
// with a NON-zero drain window (a clean Terminate, not an abandon) and (b)
16+
// return an Effect that does not complete until `sql.end` has resolved, even
17+
// when the teardown takes real wall-clock time. All asserted with a fake `sql`
18+
// whose `end()` completion is observable, so the tests are fast and need no
19+
// live database.
20+
21+
import { describe, expect, it } from "@effect/vitest";
22+
import { Effect, Exit } from "effect";
23+
24+
import { POSTGRES_END_TIMEOUT_SECONDS, closePostgres } from "./db";
25+
26+
describe("closePostgres", () => {
27+
it.effect("passes a non-zero drain window to sql.end (clean Terminate, not abandon)", () =>
28+
Effect.gen(function* () {
29+
let received: { timeout?: number } | undefined;
30+
const fakeSql = {
31+
end: (options?: { timeout?: number }) => {
32+
received = options;
33+
return Promise.resolve();
34+
},
35+
};
36+
37+
yield* closePostgres(fakeSql);
38+
39+
expect(received?.timeout).toBe(POSTGRES_END_TIMEOUT_SECONDS);
40+
expect(POSTGRES_END_TIMEOUT_SECONDS).toBeGreaterThan(0);
41+
}),
42+
);
43+
44+
it.effect("does not complete until sql.end resolves (awaits the teardown)", () =>
45+
Effect.gen(function* () {
46+
// `end` records an ordering marker only after an async tick. If
47+
// `closePostgres` awaits it, the "close completed" marker lands AFTER the
48+
// "end resolved" marker. The old fire-and-forget close returned before
49+
// `end` ran, so "close completed" would land FIRST.
50+
const order: string[] = [];
51+
const fakeSql = {
52+
end: () =>
53+
// Defer resolution across a microtask so a non-awaiting close would
54+
// observably finish before this runs.
55+
Promise.resolve()
56+
.then(() => Promise.resolve())
57+
.then(() => {
58+
order.push("end-resolved");
59+
}),
60+
};
61+
62+
yield* closePostgres(fakeSql);
63+
order.push("close-completed");
64+
65+
// Awaiting the teardown means end resolved strictly before close returned.
66+
expect(order).toEqual(["end-resolved", "close-completed"]);
67+
}),
68+
);
69+
70+
it.effect("awaits a teardown that takes real wall-clock time (bounded by the ceiling)", () =>
71+
Effect.gen(function* () {
72+
// `end` resolves only after a real timer delay, not just a microtask.
73+
// This pins the "we await to completion, the timeout is only a ceiling"
74+
// contract: closePostgres must stay suspended across the delay rather
75+
// than resolving early.
76+
const order: string[] = [];
77+
const fakeSql = {
78+
end: () =>
79+
new Promise<void>((resolve) => {
80+
setTimeout(() => {
81+
order.push("end-resolved");
82+
resolve();
83+
}, 75);
84+
}),
85+
};
86+
87+
const startedAt = Date.now();
88+
yield* closePostgres(fakeSql);
89+
order.push("close-completed");
90+
91+
expect(order).toEqual(["end-resolved", "close-completed"]);
92+
// Slack of a few ms: platform timers may fire marginally early.
93+
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(70);
94+
}),
95+
);
96+
97+
it.effect("swallows sql.end failures (a teardown error must not fail the request scope)", () =>
98+
Effect.gen(function* () {
99+
const fakeSql = {
100+
// A rejected teardown (connection already gone) must not surface as a
101+
// scope failure.
102+
// oxlint-disable-next-line executor/no-promise-reject -- test fake: model `sql.end` (a raw postgres.js promise) rejecting
103+
end: () => new Promise<void>((_resolve, reject) => reject("connection already gone")),
104+
};
105+
const exit = yield* Effect.exit(closePostgres(fakeSql));
106+
expect(Exit.isSuccess(exit)).toBe(true);
107+
}),
108+
);
109+
});

apps/cloud/src/db/db.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,53 @@ const makeSql = (): Sql =>
6767
onnotice: () => undefined,
6868
});
6969

70+
/**
71+
* Graceful drain window (seconds) handed to `postgres`'s `sql.end`. Small but
72+
* non-zero: `timeout: 0` closes immediately without waiting for a clean
73+
* Terminate, which leaves the socket half-closed for the server to reap on its
74+
* own schedule. A short drain lets postgres.js finish the wire teardown.
75+
*
76+
* This is a `Promise.race` CEILING, not a fixed wait: postgres.js races
77+
* `end({ timeout })` against the actual teardown, and an idle connection's
78+
* `end()` calls `terminate()` immediately and resolves as soon as the socket
79+
* closes (sub-millisecond). Awaiting it in the request scope therefore adds no
80+
* meaningful latency on the common path; the 5s only bounds how long a
81+
* connection still mid-query can hold the scope open.
82+
*/
83+
export const POSTGRES_END_TIMEOUT_SECONDS = 5;
84+
85+
/**
86+
* Close a postgres pool and AWAIT its teardown.
87+
*
88+
* The `DbService` layers ({@link DbService.Live}, {@link makeDbLayer}) run this
89+
* as their `acquireRelease` finalizer. The MCP auth seam
90+
* (`makeMcpOrganizationAuthServices`) builds a FRESH pool on EVERY `/mcp`
91+
* request, so this finalizer runs per request under sustained load.
92+
*
93+
* It used to be fire-and-forget (`Effect.runFork(sql.end({ timeout: 0 }))`),
94+
* which returned before the connection was actually torn down. The abandoned
95+
* sockets piled up against the dev PGlite server (effectively single-connection)
96+
* faster than it reaped them; new connects then queued behind the backlog, so
97+
* request latency climbed into the tens of seconds and the stack eventually
98+
* hung — the CI e2e "cloud dev stack degrades after minutes of sustained load"
99+
* cascade. Awaiting the close bounds the number of live-plus-closing sockets to
100+
* what is actually in flight. It runs inside the request's own Effect scope, so
101+
* it respects workerd's per-request I/O rule.
102+
*/
103+
export const closePostgres = (sql: Pick<Sql, "end">): Effect.Effect<void> =>
104+
Effect.ignore(
105+
Effect.tryPromise({
106+
try: () => sql.end({ timeout: POSTGRES_END_TIMEOUT_SECONDS }),
107+
catch: (cause) => cause,
108+
}),
109+
);
110+
70111
const makePostgresResource = (): DbResource => {
71112
const sql = makeSql();
72113
return {
73114
sql,
74115
db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb,
75-
close: () =>
76-
Effect.ignore(
77-
Effect.tryPromise({
78-
try: () => sql.end({ timeout: 0 }),
79-
catch: (cause) => cause,
80-
}),
81-
),
116+
close: () => closePostgres(sql),
82117
};
83118
};
84119

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Regression for the dev-db PGlite socket protocol-interleaving bug (patched
2+
// in patches/@electric-sql%2Fpglite-socket@0.1.4.patch).
3+
//
4+
// PGLiteSocketServer's QueryQueueManager used to enqueue each postgres wire
5+
// FRAME (Parse, Bind, Execute, Sync) as its own queue entry against the one
6+
// shared PGlite session. With more than one connection (the dev-db now allows
7+
// many — see scripts/dev-db.ts maxConnections), two clients' extended-protocol
8+
// pipelines interleaved: client A's Parse (5 params) ... client B's Parse
9+
// (1 param) ... A's Bind now hits B's unnamed statement:
10+
//
11+
// PostgresError: bind message supplies 5 parameters, but prepared
12+
// statement "" requires 1
13+
//
14+
// which surfaced in e2e as random 500s ("Failed to load tools", StorageError)
15+
// on whichever request lost the race — the residual per-spec CI flakes after
16+
// the connection-storm fix. The patch batches all frames of one socket data
17+
// event into a single queue entry and adds handler affinity while a pipeline
18+
// is open, so one client's Parse..Sync executes atomically.
19+
//
20+
// This test drives concurrent clients issuing unprepared parameterized queries
21+
// with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through
22+
// one PGLiteSocketServer and asserts zero protocol corruption.
23+
24+
import { describe, expect, it } from "@effect/vitest";
25+
import { PGlite } from "@electric-sql/pglite";
26+
import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
27+
import postgres from "postgres";
28+
29+
const PORT = 45998;
30+
const CLIENTS = 6;
31+
const QUERIES_PER_CLIENT = 40;
32+
33+
describe("dev-db PGlite socket under concurrent connections", () => {
34+
it(
35+
"serves interleaved multi-connection pipelines without protocol corruption",
36+
{ timeout: 60_000 },
37+
async () => {
38+
const db = await PGlite.create();
39+
const server = new PGLiteSocketServer({
40+
db,
41+
port: PORT,
42+
host: "127.0.0.1",
43+
maxConnections: 100,
44+
});
45+
await server.start();
46+
47+
let ok = 0;
48+
const errors: string[] = [];
49+
50+
const worker = async (id: number) => {
51+
const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, {
52+
max: 1,
53+
idle_timeout: 0,
54+
connect_timeout: 10,
55+
fetch_types: false,
56+
prepare: true,
57+
onnotice: () => undefined,
58+
});
59+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path
60+
try {
61+
for (let q = 0; q < QUERIES_PER_CLIENT; q++) {
62+
// Alternate 1-param and 5-param unprepared queries: maximally
63+
// collision-prone unnamed-statement shapes across connections.
64+
if ((id + q) % 2 === 0) {
65+
await sql.unsafe(`select $1::int as one`, [1]);
66+
} else {
67+
await sql.unsafe(`select $1::int, $2::text, $3::text, $4::text, $5::text`, [
68+
1,
69+
"b",
70+
"c",
71+
"d",
72+
"e",
73+
]);
74+
}
75+
ok++;
76+
}
77+
} catch (cause) {
78+
// oxlint-disable-next-line executor/no-unknown-error-message -- test boundary: the raw PostgresError message IS the assertion payload
79+
errors.push(String(cause));
80+
} finally {
81+
// oxlint-disable-next-line executor/no-promise-catch -- test boundary: postgres.js is promise-native; a failed teardown must not mask the assertion
82+
await sql.end({ timeout: 5 }).catch(() => {});
83+
}
84+
};
85+
86+
await Promise.all(Array.from({ length: CLIENTS }, (_, i) => worker(i)));
87+
await server.stop();
88+
await db.close();
89+
90+
expect(errors, `protocol corruption under concurrency:\n${errors.join("\n")}`).toEqual([]);
91+
expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT);
92+
},
93+
);
94+
});

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/cloud/billing-trial-checkout-stale.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,17 @@ scenario(
4444
await step("A fresh org is offered the Team free trial", async () => {
4545
// Billing requests are org-scoped via the URL slug header, so reach the
4646
// plans page through the org-scoped URL (a bare /billing/plans would fire
47-
// the first fetch before the slug resolves and 401). Land on "/" to
48-
// canonicalize, then open the slug-scoped plans page.
47+
// the first fetch before the slug resolves and 401). Land on "/" and WAIT
48+
// for the shell to canonicalize onto the slug before reading it:
49+
// networkidle only proves the network went quiet, not that the client-side
50+
// redirect ran, and reading too early navigates to the literal
51+
// "/undefined/billing/plans" — the billing fetches fire under the bogus
52+
// slug, fail, and are never refetched (autumn-js staleTime 60s), leaving
53+
// the plans grid empty forever.
4954
await page.goto("/", { waitUntil: "networkidle" });
55+
await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
56+
timeout: 30_000,
57+
});
5058
const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0];
5159
await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" });
5260
await page.getByRole("heading", { name: "Choose a plan" }).waitFor();

e2e/cloud/oauth-callback-org-scope.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,19 +223,48 @@ scenario(
223223
});
224224

225225
await step("Start OAuth from the original organization's add-connection flow", async () => {
226-
await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, {
226+
// Land WITHOUT ?addAccount=1 and let the org scope settle first. The
227+
// session cookie still points at org B, so this navigation bounces:
228+
// first paint under the cookie's org B, OrgSlugGate canonicalizes,
229+
// then the URL-scoped /account/me settles auth back on org A. A modal
230+
// auto-opened by the deep link would fire its /api/oauth/clients fetch
231+
// DURING that transient org-B scope, get org B's empty app list, never
232+
// refetch, and leave "Connect with OAuth" disabled forever (the flake
233+
// this step used to have). Waiting for org A's shell before opening
234+
// the modal makes every modal fetch run under the settled org A scope.
235+
await page.goto(`/${orgA.slug}/integrations/${String(integration)}`, {
227236
waitUntil: "networkidle",
228237
});
238+
await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({
239+
timeout: 30_000,
240+
});
241+
await page.waitForURL((url) => url.pathname.startsWith(`/${orgA.slug}/`), {
242+
timeout: 30_000,
243+
});
244+
await page.getByRole("button", { name: "Add connection" }).click();
229245
await page.getByRole("heading", { name: /Add connection/ }).waitFor({
230246
timeout: 30_000,
231247
});
248+
// The footer button enables once the registered OAuth app has loaded
249+
// and is selected; wait for that state instead of racing the click
250+
// against the apps fetch.
251+
await page
252+
.getByRole("button", { name: "Connect with OAuth", disabled: false })
253+
.waitFor({ timeout: 30_000 });
254+
// Armed BEFORE the click so the navigation can never outrun the wait.
232255
const authorizeRequest = page.waitForRequest(
233256
(request) => {
234257
const url = new URL(request.url());
235258
return url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/authorize";
236259
},
237260
{ timeout: 30_000 },
238261
);
262+
// If the click throws first (e.g. the button never enables), the armed
263+
// wait would otherwise time out later as an UNHANDLED rejection and
264+
// pollute the run with phantom errors. Mark it handled here; the
265+
// `await authorizeRequest` below still surfaces its failure on the
266+
// success path.
267+
authorizeRequest.catch(() => {});
239268
await page.getByRole("button", { name: "Connect with OAuth" }).click();
240269
providerAuthorizeUrl = new URL((await authorizeRequest).url());
241270
await page.waitForURL(

0 commit comments

Comments
 (0)