Skip to content

Commit 78f7b83

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/execution-limit-gate
2 parents 14a9af1 + 9bbf5d4 commit 78f7b83

53 files changed

Lines changed: 2236 additions & 263 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ on:
77
- main
88

99
concurrency:
10-
group: ci-${{ github.ref }}
11-
cancel-in-progress: true
10+
group: ci-${{ github.event_name == 'push' && format('{0}-{1}', github.ref, github.sha) || github.ref }}
11+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
1212

1313
jobs:
1414
format:
@@ -21,6 +21,14 @@ jobs:
2121
with:
2222
bun-version: 1.3.11
2323

24+
- name: Cache Bun package cache
25+
uses: actions/cache@v4
26+
with:
27+
path: ~/.bun/install/cache
28+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
29+
restore-keys: |
30+
${{ runner.os }}-bun-1.3.11-
31+
2432
- run: bun install --frozen-lockfile
2533

2634
- run: bun run format:check
@@ -35,6 +43,14 @@ jobs:
3543
with:
3644
bun-version: 1.3.11
3745

46+
- name: Cache Bun package cache
47+
uses: actions/cache@v4
48+
with:
49+
path: ~/.bun/install/cache
50+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
51+
restore-keys: |
52+
${{ runner.os }}-bun-1.3.11-
53+
3854
- run: bun install --frozen-lockfile
3955

4056
- run: bun run lint
@@ -49,20 +65,38 @@ jobs:
4965
with:
5066
bun-version: 1.3.11
5167

68+
- name: Cache Bun package cache
69+
uses: actions/cache@v4
70+
with:
71+
path: ~/.bun/install/cache
72+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
73+
restore-keys: |
74+
${{ runner.os }}-bun-1.3.11-
75+
5276
- run: bun install --frozen-lockfile
5377

5478
- run: bun run typecheck
5579

5680
test:
5781
name: Test
5882
runs-on: ubuntu-latest
83+
env:
84+
TURBO_TEST_CONCURRENCY: 3
5985
steps:
6086
- uses: actions/checkout@v4
6187

6288
- uses: oven-sh/setup-bun@v2
6389
with:
6490
bun-version: 1.3.11
6591

92+
- name: Cache Bun package cache
93+
uses: actions/cache@v4
94+
with:
95+
path: ~/.bun/install/cache
96+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
97+
restore-keys: |
98+
${{ runner.os }}-bun-1.3.11-
99+
66100
# apps/cloud's test script invokes `node` directly; undici 8.x (pulled
67101
# in by @cloudflare/vitest-pool-workers) calls webidl.markAsUncloneable
68102
# which only exists in Node 22.10+. Pin a known-good runtime.
@@ -105,6 +139,14 @@ jobs:
105139
with:
106140
bun-version: 1.3.11
107141

142+
- name: Cache Bun package cache
143+
uses: actions/cache@v4
144+
with:
145+
path: ~/.bun/install/cache
146+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
147+
restore-keys: |
148+
${{ runner.os }}-bun-1.3.11-
149+
108150
# The dev stacks spawn Node sidecars (vite/workerd tooling); pin the
109151
# same known-good runtime the unit-test job uses.
110152
- uses: actions/setup-node@v4
@@ -113,6 +155,12 @@ jobs:
113155

114156
- run: bun install --frozen-lockfile
115157

158+
- name: Cache Playwright browsers
159+
uses: actions/cache@v4
160+
with:
161+
path: ~/.cache/ms-playwright
162+
key: ${{ runner.os }}-playwright-1.60.0
163+
116164
# Install from e2e so bunx resolves ITS pinned playwright (the version
117165
# the tests run against) rather than floating to the latest.
118166
- name: Install Playwright Chromium
@@ -152,6 +200,14 @@ jobs:
152200
with:
153201
bun-version: 1.3.11
154202

203+
- name: Cache Bun package cache
204+
uses: actions/cache@v4
205+
with:
206+
path: ~/.bun/install/cache
207+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
208+
restore-keys: |
209+
${{ runner.os }}-bun-1.3.11-
210+
155211
# The local scenarios boot a real `executor web` (which spawns a Node
156212
# sidecar) and some drive a browser, so pin Node 22 and install Chromium.
157213
- uses: actions/setup-node@v4
@@ -160,6 +216,12 @@ jobs:
160216

161217
- run: bun install --frozen-lockfile
162218

219+
- name: Cache Playwright browsers
220+
uses: actions/cache@v4
221+
with:
222+
path: ~/.cache/ms-playwright
223+
key: ${{ runner.os }}-playwright-1.60.0
224+
163225
# `chromium` and the new `chromium-headless-shell` ship as separate
164226
# downloads; the browser-driven scenarios launch the headless shell.
165227
# Install from e2e so bunx resolves ITS pinned playwright (the version the
@@ -190,6 +252,14 @@ jobs:
190252
with:
191253
bun-version: 1.3.11
192254

255+
- name: Cache Bun package cache
256+
uses: actions/cache@v4
257+
with:
258+
path: ~/.bun/install/cache
259+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
260+
restore-keys: |
261+
${{ runner.os }}-bun-1.3.11-
262+
193263
- run: bun install --frozen-lockfile
194264

195265
- name: Build web app
@@ -221,3 +291,5 @@ jobs:
221291
file: apps/host-selfhost/Dockerfile
222292
push: false
223293
tags: executor-selfhost:ci
294+
cache-from: type=gha
295+
cache-to: type=gha,mode=max

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 & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,22 +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.sync(() => {
77-
void Effect.runFork(
78-
Effect.ignore(
79-
Effect.tryPromise({
80-
try: () => sql.end({ timeout: 0 }),
81-
catch: (cause) => cause,
82-
}),
83-
),
84-
);
85-
}),
116+
close: () => closePostgres(sql),
86117
};
87118
};
88119

0 commit comments

Comments
 (0)