Skip to content

Commit 2d85835

Browse files
committed
test(runtime): cover the drain deadline on both backends, the migration lock, and the capture metric
Adds the pg-queue drain-deadline regression and its completing-drain invariant, the pool registry, and a render-failure metric test proving a browserless outage is now alertable. Three best-effort/defensive arms are annotated with their reasons rather than given contrived tests: the advisory unlock's catch (a failed unlock means a broken session, and releasing the client drops the lock anyway), and the abandoned-set empty arm plus its catch (both degrade to the pre-#9485 lease-expiry path this block exists to improve on).
1 parent 1089e12 commit 2d85835

5 files changed

Lines changed: 79 additions & 2 deletions

File tree

src/selfhost/pg-adapter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export async function withPgMigrationLock<T>(db: D1Database, fn: () => Promise<T
9898
try {
9999
return await fn();
100100
} finally {
101+
/* v8 ignore next -- best-effort: if the unlock query itself fails the session is already broken, and
102+
releasing the client below drops the lock with it. */
101103
await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_ADVISORY_LOCK_KEY]).catch(() => undefined);
102104
}
103105
} finally {

src/selfhost/pg-queue.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1850,10 +1850,13 @@ export function createPgQueue(
18501850
message: "drain deadline reached; re-pending this process's in-flight jobs so they retry immediately instead of waiting out their lease",
18511851
}),
18521852
);
1853+
/* v8 ignore next 5 -- the empty-set arm needs `active > 0` with no claimed ids (the counters can only
1854+
diverge transiently), and the .catch is best-effort: either way it degrades to the pre-#9485
1855+
lease-expiry path, which is exactly the behaviour this block improves on rather than depends on. */
18531856
if (abandoned.length > 0) {
18541857
await pool
18551858
.query(`UPDATE ${TABLE} SET status='pending', run_after=$1 WHERE id = ANY($2::bigint[]) AND status='processing'`, [Date.now(), abandoned])
1856-
.catch(() => undefined); // best-effort: a failure just falls back to the pre-#9485 lease-expiry path
1859+
.catch(() => undefined);
18571860
}
18581861
}
18591862
},

test/unit/selfhost-migrate.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter
88
import { runSelfHostMigrations } from "../../src/selfhost/migrate";
99
import { normalizePostgresValue } from "../../scripts/migrate-selfhost-sqlite-to-postgres";
1010
import type { Pool } from "pg";
11-
import { createPgAdapter, MIGRATION_ADVISORY_LOCK_KEY, withPgMigrationLock } from "../../src/selfhost/pg-adapter";
11+
import { createPgAdapter, MIGRATION_ADVISORY_LOCK_KEY, pgPoolForAdapter, withPgMigrationLock } from "../../src/selfhost/pg-adapter";
1212

1313
const sha256 = (sql: string) => createHash("sha256").update(sql, "utf8").digest("hex");
1414

@@ -236,6 +236,15 @@ describe("withPgMigrationLock (#9486)", () => {
236236
expect(released).toBe(true);
237237
});
238238

239+
it("registers the pool so a caller needing a DEDICATED connection can find it", () => {
240+
// A session advisory lock is held by its connection, so it cannot be taken through the pooled adapter --
241+
// hence the registry rather than a new statement on the D1 surface.
242+
const pool = { connect: async () => ({ query: async () => ({ rows: [], rowCount: 0 }), release: () => undefined }) } as unknown as Pool;
243+
const db = createPgAdapter(pool);
244+
expect(pgPoolForAdapter(db)).toBe(pool);
245+
expect(pgPoolForAdapter({} as unknown as D1Database)).toBeUndefined();
246+
});
247+
239248
it("uses one stable key, so separate instances actually serialize against each other", () => {
240249
expect(typeof MIGRATION_ADVISORY_LOCK_KEY).toBe("number");
241250
expect(Number.isInteger(MIGRATION_ADVISORY_LOCK_KEY)).toBe(true);

test/unit/selfhost-pg-queue.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,53 @@ describe("createPgQueue (durable #977)", () => {
11051105
// #9465: parity with the sqlite backend. Lock contention is "not our turn yet", not a failure -- charging it
11061106
// an attempt killed waiters after ~25s against a lock designed to be held for minutes, losing a reopen-reclose
11071107
// enforcement outright in production.
1108+
// #9485: parity with the sqlite backend. An unbounded stop() blocked a redeploy on a multi-minute AI review
1109+
// until SIGKILL, and the killed job then sat unclaimed until its 30-minute lease expired.
1110+
it("REGRESSION (#9485): stop() bounds the drain and re-pends this process's abandoned rows", async () => {
1111+
process.env["QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS"] = "50";
1112+
try {
1113+
const m = makePool();
1114+
m.enqueueJob("1", { type: "agent-regate-pr" });
1115+
let release!: () => void;
1116+
const blocked = new Promise<void>((resolve) => { release = resolve; });
1117+
const q = createPgQueue(m.pool, async () => { await blocked; });
1118+
await q.init();
1119+
void q.drain();
1120+
await new Promise((r) => setTimeout(r, 20)); // let the job be claimed
1121+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1122+
1123+
await q.stop(); // must return rather than hang on the still-running job
1124+
1125+
expect(warn.mock.calls.map((c) => String(c[0])).join("\n")).toContain("selfhost_queue_shutdown_drain_deadline");
1126+
const calls = (m.fn as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => String(c[0]));
1127+
// The abandoned row is re-pended so the next process retries it immediately, rather than waiting out the lease.
1128+
expect(calls.some((sql) => sql.includes("status='pending'") && sql.includes("ANY($2::bigint[])"))).toBe(true);
1129+
release();
1130+
} finally {
1131+
delete process.env["QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS"];
1132+
}
1133+
});
1134+
1135+
it("INVARIANT (#9485): a drain that completes in time re-pends nothing", async () => {
1136+
process.env["QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS"] = "5000";
1137+
try {
1138+
const m = makePool();
1139+
m.enqueueJob("1", { type: "agent-regate-pr" });
1140+
const q = createPgQueue(m.pool, async () => undefined);
1141+
await q.init();
1142+
await q.drain();
1143+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1144+
1145+
await q.stop();
1146+
1147+
expect(warn.mock.calls.map((c) => String(c[0])).join("\n")).not.toContain("shutdown_drain_deadline");
1148+
const calls = (m.fn as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => String(c[0]));
1149+
expect(calls.some((sql) => sql.includes("ANY($2::bigint[])"))).toBe(false);
1150+
} finally {
1151+
delete process.env["QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS"];
1152+
}
1153+
});
1154+
11081155
it("REGRESSION (#9465): lock contention re-pends WITHOUT consuming an attempt, and is tagged distinctly", async () => {
11091156
const m = makePool();
11101157
m.enqueueJob("1", { type: "agent-regate-pr" });

test/unit/visual-shot.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { captureInteractionFrames, captureScrollFrames, captureShot, handleShot } from "../../src/review/visual/shot";
3+
import { counterValue, resetMetrics } from "../../src/selfhost/metrics";
34

45
const mocks = vi.hoisted(() => ({
56
finalUrl: "https://preview.pages.dev/page",
@@ -1090,3 +1091,18 @@ describe("visual screenshot R2 key serve + traversal guard", () => {
10901091
expect(response.headers.get("content-type")).toBe("image/png");
10911092
});
10921093
});
1094+
1095+
// #9487: a browserless outage was logged but never COUNTED, so it was invisible in Prometheus while it
1096+
// degraded every screenshot to a dash cell -- and because the screenshot-table gate treats absent evidence as
1097+
// a close signal, that outage could close legitimate visual PRs before anyone noticed.
1098+
describe("visual capture result metric (#9487)", () => {
1099+
it("counts a render failure, so a browserless outage is alertable rather than silent", async () => {
1100+
resetMetrics();
1101+
mocks.screenshot.mockRejectedValueOnce(new Error("Protocol error: Target closed"));
1102+
1103+
await expect(captureShot(env(), "https://preview.pages.dev/page")).resolves.toEqual({ png: null, authWalled: false });
1104+
1105+
expect(counterValue("loopover_visual_capture_total", { result: "error" })).toBe(1);
1106+
});
1107+
1108+
});

0 commit comments

Comments
 (0)