diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 741528ed9..100e9a043 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -121,7 +121,9 @@ Each Vitest worker clones it on its first file (`CREATE DATABASE … TEMPLATE`, see `tests/integration/worker-database.ts`), so files run in parallel without seeing each other's rows, advisory locks or queue schema. The worker count follows the host (half the cores, at most eight) unless -`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. +`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. To reproduce a CI runner's +contention on a larger machine, cap the database container with +`HEALTHLOG_INTEGRATION_PG_CPUS=2`. `truncateAllTables` empties every table the migrations created and puts back the rows they seed (the mood-tag and symptom catalogues), so a test starts diff --git a/tests/integration/dashboard-summary-reads.test.ts b/tests/integration/dashboard-summary-reads.test.ts index 979c6746f..5df8ac0bd 100644 --- a/tests/integration/dashboard-summary-reads.test.ts +++ b/tests/integration/dashboard-summary-reads.test.ts @@ -335,5 +335,12 @@ describe("dashboard all-time counts — parity with the live count", () => { expect(legacy.get("PULSE")).toBeGreaterThan(500_000); expect(legacy.get("WEIGHT")).toBe(5); expect(legacy.get("BLOOD_GLUCOSE")).toBe(2); - }); + // Over half a million rows written and folded into DAY buckets: about + // 35 s on a CI runner when the file ran alone. With three files sharing + // the runner's Postgres it took 1.7 times as long (measured with the + // database capped at two CPUs, HEALTHLOG_INTEGRATION_PG_CPUS=2: 14.6 s + // alone, 25.3 s beside the sharing matrix and the restore memory test), + // past the suite's 60 s default. The work is the point of the case, so + // the budget is sized to it: twice the contended estimate. + }, 150_000); }); diff --git a/tests/integration/global-setup.ts b/tests/integration/global-setup.ts index 31fdc912b..da31f7124 100644 --- a/tests/integration/global-setup.ts +++ b/tests/integration/global-setup.ts @@ -44,7 +44,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PROJECT_ROOT = resolve(__dirname, "..", ".."); -let container: StartedPostgreSqlContainer | null = null; +let started: StartedPostgreSqlContainer | null = null; declare module "vitest" { export interface ProvidedContext { @@ -56,7 +56,7 @@ declare module "vitest" { export default async function setup( project: TestProject, ): Promise<() => Promise> { - container = await new PostgreSqlContainer("postgres:16-alpine") + let container = new PostgreSqlContainer("postgres:16-alpine") .withDatabase(TEMPLATE_DATABASE) .withUsername("healthlog") .withPassword("healthlog") @@ -74,10 +74,18 @@ export default async function setup( "full_page_writes=off", "-c", "max_connections=400", - ]) - .start(); + ]); + // A CPU quota for the database container, to reproduce a CI runner's + // contention on a larger machine: HEALTHLOG_INTEGRATION_PG_CPUS=2. + const cpus = Number.parseFloat( + process.env.HEALTHLOG_INTEGRATION_PG_CPUS ?? "", + ); + if (Number.isFinite(cpus) && cpus > 0) { + container = container.withResourcesQuota({ cpu: cpus }); + } + started = await container.start(); - const url = container.getConnectionUri() + "?schema=public&pgbouncer=false"; + const url = started.getConnectionUri() + "?schema=public&pgbouncer=false"; process.env.DATABASE_URL = url; // The explicit child environment keeps Prisma CLI on the same container. @@ -91,9 +99,9 @@ export default async function setup( project.provide("integrationDatabasePlan", await readTemplate(url)); return async () => { - if (container) { - await container.stop({ remove: true }); - container = null; + if (started) { + await started.stop({ remove: true }); + started = null; } }; } diff --git a/tests/integration/migrate-deploy-lock-retry.test.ts b/tests/integration/migrate-deploy-lock-retry.test.ts index b9678feaf..d4b0c6137 100644 --- a/tests/integration/migrate-deploy-lock-retry.test.ts +++ b/tests/integration/migrate-deploy-lock-retry.test.ts @@ -145,6 +145,47 @@ async function holdLock(): Promise { return holder; } +/** Sessions of this database queued behind a lock, other than the holder. */ +async function lockWaiters(db: Client, holderPid: number): Promise { + const { rows } = await db.query<{ n: string }>( + `SELECT count(*) AS n FROM pg_stat_activity + WHERE datname = current_database() + AND wait_event_type = 'Lock' + AND pid <> $1`, + [holderPid], + ); + return Number(rows[0]?.n ?? 0); +} + +/** + * Let go of the held lock once a migration attempt has queued behind it and + * given up. Resolves false when no attempt queued within the budget, so the + * test reports that instead of passing on a run that never met the lock. + */ +async function releaseAfterFirstTimeout(holder: Client): Promise { + const { rows } = await holder.query<{ pid: number }>( + "SELECT pg_backend_pid() AS pid", + ); + const holderPid = rows[0].pid; + const watcher = await connect(); + const until = async (predicate: (n: number) => boolean) => { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (predicate(await lockWaiters(watcher, holderPid))) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return false; + }; + try { + const queued = await until((n) => n > 0); + if (queued) await until((n) => n === 0); + await holder.query("ROLLBACK"); + return queued; + } finally { + await watcher.end(); + } +} + async function state(db: Client) { const rows = await db.query( `SELECT migration_name, finished_at IS NOT NULL AS finished, @@ -181,15 +222,23 @@ describe("migration 0412 under a held lock", () => { add0412(dir, { marked: true }); const holder = await holdLock(); // The first attempt meets the lock; the holder lets go during the backoff. - const release = setTimeout(() => { - void holder.query("ROLLBACK"); - }, 2_500); + // The release follows the migration rather than a clock: it waits until + // the first attempt is seen queued behind the lock and then gone again + // (its one-second timeout fired), and lets go before the retry. A fixed + // delay raced the CLI's own start-up, which on a loaded runner outlasted + // the delay, so the first attempt found the lock already free. + const released = releaseAfterFirstTimeout(holder); const startedAt = Date.now(); const result = await migrateDeploy(dir); - clearTimeout(release); + const releasedInTime = await released; await holder.query("ROLLBACK").catch(() => {}); await holder.end(); + expect( + releasedInTime, + "the first attempt never queued behind the held lock", + ).toBe(true); + expect(result.code, result.stderr).toBe(0); expect(result.stderr).toContain("Database error code: 55P03"); expect(result.stdout).toContain(