Files
HealthLog/tests/integration/migrate-deploy-invalid-index.test.ts
T
Marc-André Bombeck 4390614463 fix(migrations): rebuild an index a failed concurrent build left invalid
A CREATE INDEX CONCURRENTLY that fails half-way leaves an INVALID index,
and the migration re-run after migrate resolve --rolled-back finds it
through IF NOT EXISTS and does nothing: recorded applied, with an index
the planner never uses. 0477 has this shape.

The boot now drops an INVALID index that a not-yet-applied migration
builds concurrently before it runs migrate deploy, with a bounded lock
wait, so the re-run builds it again. An INVALID index whose migration is
recorded applied is named on stderr with the rebuild that mends it. The
deploy runbook says both.
2026-10-25 02:42:09 +01:00

174 lines
5.4 KiB
TypeScript

/**
* A concurrent index build that failed leaves an INVALID index behind, and
* `CREATE INDEX CONCURRENTLY IF NOT EXISTS` finds that index and does
* nothing: the migration re-run after `migrate resolve --rolled-back` is
* recorded applied while the index it exists for is never used, and nothing
* says so (0477 has this shape).
*
* The boot (`scripts/migrate-deploy.mjs`) drops such an index first, when the
* migration that builds it is not applied, so the re-run builds it again. The
* probe runs the real script and the real CLI against a schema of its own.
*/
import { spawn } from "node:child_process";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client } from "pg";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
const SCHEMA = "invalid_index_probe";
const NAME = "0002_probe_value_index";
const INDEX = "probe_value_key";
const ROOT = process.cwd();
const dirs: string[] = [];
afterAll(async () => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
const db = await connect();
await db.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
await db.end();
});
function baseUrl(): string {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set for the integration run");
return url;
}
function probeUrl(): string {
const url = new URL(baseUrl());
url.searchParams.set("schema", SCHEMA);
return url.toString();
}
async function connect(): Promise<Client> {
const url = new URL(baseUrl());
url.searchParams.delete("schema");
const client = new Client({ connectionString: url.toString() });
await client.connect();
return client;
}
function write(dir: string, name: string, sql: string) {
mkdirSync(join(dir, "migrations", name), { recursive: true });
writeFileSync(join(dir, "migrations", name, "migration.sql"), sql);
}
function prisma(dir: string, args: string[]) {
return run(join(ROOT, "node_modules", "prisma", "build", "index.js"), [
...args,
"--schema",
join(dir, "schema.prisma"),
]);
}
function migrateDeploy(dir: string) {
return run(join(ROOT, "scripts", "migrate-deploy.mjs"), [
"--schema",
join(dir, "schema.prisma"),
]);
}
function run(
script: string,
args: string[],
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [script, ...args], {
cwd: ROOT,
env: {
...process.env,
DATABASE_URL: probeUrl(),
HEALTHLOG_PRISMA_CLI: join(
ROOT,
"node_modules",
"prisma",
"build",
"index.js",
),
HEALTHLOG_MIGRATIONS_DIR: currentDir,
HEALTHLOG_MIGRATE_LOCK_RETRY_DELAYS: "1",
},
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => (stdout += chunk));
child.stderr.on("data", (chunk) => (stderr += chunk));
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
let currentDir = "";
async function indexState(db: Client) {
const { rows } = await db.query<{ valid: boolean }>(
`SELECT i.indisvalid AS valid FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relname = $2`,
[SCHEMA, INDEX],
);
return rows[0]?.valid ?? null;
}
beforeEach(async () => {
const db = await connect();
await db.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
await db.end();
});
describe("a concurrent index build that failed", () => {
it("is built again when its migration is re-run, not skipped as present", async () => {
const dir = mkdtempSync(join(tmpdir(), "hl-invalid-index-"));
dirs.push(dir);
currentDir = join(dir, "migrations");
writeFileSync(
join(dir, "schema.prisma"),
'datasource db {\n provider = "postgresql"\n}\n',
);
mkdirSync(join(dir, "migrations"), { recursive: true });
cpSync(
join(ROOT, "prisma", "migrations", "migration_lock.toml"),
join(dir, "migrations", "migration_lock.toml"),
);
// Two rows that break the unique build, the way a constraint violation,
// a cancel or a killed container leaves a concurrent build half done.
write(
dir,
"0001_probe_table",
`CREATE TABLE "probe" ("id" integer PRIMARY KEY, "value" integer);
INSERT INTO "probe" VALUES (1, 7), (2, 7);`,
);
write(
dir,
NAME,
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "${INDEX}" ON "probe" ("value");`,
);
const failed = await migrateDeploy(dir);
expect(failed.code).not.toBe(0);
const db = await connect();
try {
expect(await indexState(db)).toBe(false);
// The operator mends the data and marks the attempt rolled back, as
// docs/ops/deploy.md says.
await db.query(`DELETE FROM ${SCHEMA}.probe WHERE id = 2`);
const resolved = await prisma(dir, [
"migrate",
"resolve",
"--rolled-back",
NAME,
]);
expect(resolved.code, resolved.stderr).toBe(0);
const again = await migrateDeploy(dir);
expect(again.code, again.stderr).toBe(0);
expect(again.stdout).toContain(`dropped the invalid index ${INDEX}`);
expect(await indexState(db)).toBe(true);
} finally {
await db.end();
}
});
});