From 7248347188596608bb2a33f76d5295957e91a42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 07:18:56 +0200 Subject: [PATCH 1/2] test(integration): release the held lock when the migration has met it The 0412 lock-retry case let go of the lock on a 2.5 s timer. On a loaded runner the migrate CLI took longer than that to start, so the first attempt found the lock already free, applied cleanly and never produced the 55P03 the case is about. The lock is now released once the attempt is seen queued behind it in pg_stat_activity and gone again, which is the moment its own timeout fired, and the case fails outright if no attempt ever queued. It holds with the CLI start delayed by four seconds. --- .../migrate-deploy-lock-retry.test.ts | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) 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( From b6e9eb98f74241c1e8b77a2f001b6c3e26549583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 07:18:57 +0200 Subject: [PATCH 2/2] test(integration): size the all-time count case to its measured cost The rollup parity case writes over half a million readings and folds them into DAY buckets: about 35 s on a CI runner alone. Run beside two other heavy files on the same Postgres it took 1.7 times as long and crossed the 60 s default. Nothing in the case waits on a lock: its advisory locks are scoped to the worker's own database, and the measurement with the database capped at two CPUs (14.6 s alone, 25.3 s beside the sharing matrix and the restore memory test) shows plain contention. The case gets a 150 s budget, twice the contended estimate. HEALTHLOG_INTEGRATION_PG_CPUS caps the database container so a CI runner's contention can be reproduced locally. --- docs/contributing/testing.md | 4 +++- .../dashboard-summary-reads.test.ts | 9 ++++++- tests/integration/global-setup.ts | 24 ++++++++++++------- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fddd0c974..d006b8b48 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -119,7 +119,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; } }; }