1153ccc556
The dev reset-demo-data CronJob intermittently produced one Error pod per run with `invoices_pkey` duplicate-key violations. The CTO analysis (traced in GRO-2136) concluded the race is between the reset image's three-step chain and a concurrent same-PRNG seeder (the dev seed-test-data Job being recreated at the top of the hour by Flux). GRO-2123 added `pg_advisory_lock(0x47524f4f)` around `runSeedBody`, but `reset.ts` (DROP TABLE … CASCADE) and `drizzle-kit migrate` ran as separate processes outside that lock — so a concurrent locked seed could still interleave with the reset's drop+recreate, leaving two same-seed writers emitting identical invoice ids (the Mulberry32(seed=42) stream is fully deterministic per process). This commit makes the whole chain a single locked unit: - `reset.ts` now takes the same advisory lock and runs DROP → migrate → runSeedBody under a single Postgres session (max: 1). The lock spans the entire chain, so any concurrent `seed.ts` invocation (via the seed-test-data Job or CI) blocks until the reset finishes. - `packages/db/package.json` `reset` script is now a single `tsx src/reset.ts` invocation — `drizzle-kit migrate` no longer runs as a separate un-locked process. - `withSeedAdvisoryLock`, `runSeedBody`, `getProfile`, `profiles`, `SEED_ADVISORY_LOCK_KEY`, and the `SeedProfile`/`ProfileConfig` types are now exported from `seed.ts` so `reset.ts` can use them while preserving the deterministic seed contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5.2 KiB
TypeScript
146 lines
5.2 KiB
TypeScript
/**
|
|
* reset.ts — Drop all application tables, re-run migrations, and re-seed.
|
|
*
|
|
* Intended for local development only. Never run against production.
|
|
*
|
|
* Usage:
|
|
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
|
*
|
|
* GRO-2139: the entire drop→migrate→seed chain runs inside a single
|
|
* Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent
|
|
* `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at
|
|
* the top of the hour) cannot interleave between `reset.ts` (DROP)
|
|
* and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`.
|
|
*
|
|
* Why this matters: `seed.ts` derives every primary key from a single
|
|
* shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and
|
|
* `uuid()` in seed.ts). Two concurrent same-profile seeders therefore
|
|
* emit *identical* ids for the same logical row, and any moment
|
|
* between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the
|
|
* window in which the second seeder's INSERT can hit a pkey already
|
|
* taken by the first. Pre-GRO-2123 this raced unconditionally;
|
|
* GRO-2123 added the advisory lock around `runSeedBody` but left
|
|
* `reset.ts` and `drizzle-kit migrate` outside the lock. This script
|
|
* now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock`
|
|
* pins the lock to one reserved session and the DROP → migrate → seed
|
|
* work runs on the rest of the pool, so the lock guarantees mutual
|
|
* exclusion against any concurrent seeder for the entire chain.
|
|
*
|
|
* See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and
|
|
* `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same
|
|
* `seed.ts` code path on the same database in `groombook-dev`.
|
|
*/
|
|
import postgres from "postgres";
|
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, resolve } from "node:path";
|
|
import * as schema from "./schema.js";
|
|
import {
|
|
SEED_ADVISORY_LOCK_KEY,
|
|
withSeedAdvisoryLock,
|
|
getProfile,
|
|
runSeedBody,
|
|
profiles,
|
|
} from "./seed.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const MIGRATIONS_FOLDER = resolve(__dirname, "../migrations");
|
|
|
|
async function reset() {
|
|
const url = process.env.DATABASE_URL;
|
|
if (!url) {
|
|
console.error("DATABASE_URL is not set");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (
|
|
process.env.NODE_ENV === "production" &&
|
|
process.env.ALLOW_RESET !== "true"
|
|
) {
|
|
console.error(
|
|
"[FATAL] db:reset must not be run in production without ALLOW_RESET=true.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Pool sizing is load-bearing here. `withSeedAdvisoryLock` does
|
|
// `pool.reserve()` to pin the advisory lock to one dedicated session
|
|
// (a session-level lock released on a *different* pooled connection is
|
|
// a no-op), and the DROP / migrate / seed work then runs on the
|
|
// *remaining* pooled connections. The lock provides mutual exclusion
|
|
// across processes regardless of how many connections the work uses —
|
|
// it does NOT require the work to share the lock's session.
|
|
//
|
|
// Therefore `max` must be ≥ 2: 1 reserved for the lock + ≥1 free for
|
|
// the work. `max: 1` would let `reserve()` consume the only connection
|
|
// and every query inside the callback would block forever waiting for
|
|
// a connection that never frees (connection-starvation deadlock). We
|
|
// use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work).
|
|
const client = postgres(url, { max: 6 });
|
|
const db = drizzle(client, { schema });
|
|
|
|
try {
|
|
await withSeedAdvisoryLock(client, async () => {
|
|
console.log("Dropping all application tables...\n");
|
|
|
|
// Drop dependencies (tables) first
|
|
await client`
|
|
DO $$ DECLARE
|
|
r RECORD;
|
|
BEGIN
|
|
FOR r IN (
|
|
SELECT tablename FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
) LOOP
|
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
|
END LOOP;
|
|
END $$;
|
|
`;
|
|
|
|
// Drop custom enums
|
|
await client`
|
|
DO $$ DECLARE
|
|
r RECORD;
|
|
BEGIN
|
|
FOR r IN (
|
|
SELECT typname FROM pg_type
|
|
WHERE typtype = 'e' AND typnamespace = (
|
|
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
|
)
|
|
) LOOP
|
|
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
|
END LOOP;
|
|
END $$;
|
|
`;
|
|
|
|
// Drop the drizzle migrations tracking table
|
|
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
|
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
|
|
|
console.log("✓ All tables and enums dropped\n");
|
|
|
|
console.log("Running migrations...");
|
|
await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
console.log("✓ Migrations applied\n");
|
|
|
|
console.log("Seeding database...");
|
|
const profile = getProfile();
|
|
const cfg = profiles[profile];
|
|
await runSeedBody(client, db, profile, cfg);
|
|
});
|
|
|
|
console.log(
|
|
`\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`,
|
|
);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
reset().catch((err) => {
|
|
console.error("Reset failed:", err);
|
|
process.exit(1);
|
|
});
|