From e9f94a2bd77181ac4e3b321af36963dd30bc3e12 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 2 Jun 2026 20:11:45 +0000 Subject: [PATCH 1/2] fix(seed): GRO-2100 run uat-groomer linkage AFTER services seed (regression in #151) (#153) fix(seed): GRO-2100 run uat-groomer linkage after services seed (#153) Co-authored-by: Flea Flicker Co-committed-by: Flea Flicker --- packages/db/src/seed.ts | 48 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 13cf103..8e9d376 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -401,7 +401,9 @@ const servicesDef = [ * * In seedKnownUsers() this replaces the inline UAT-staff block. */ -async function seedUatStaffAccounts(db: ReturnType) { +async function seedUatStaffAccounts( + db: ReturnType, +): Promise { // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; if (uatSuperOidcSub) { @@ -677,7 +679,12 @@ async function seedUatStaffAccounts(db: ReturnType) { // We deterministically link the UAT groomer to the UAT customer's first pet // ("UAT Pup Alpha") and leave the second pet ("UAT Pup Beta") UNLINKED so // TC-UAT-2 (200) and TC-UAT-3 (403) can both hardcode the stable petIds. - await seedUatGroomerLinkage(db, uatCustomerClientId); + // + // The linkage call itself is performed by the caller AFTER the `services` + // catalogue has been seeded (this helper runs before services exist, + // which previously caused the linkage to be silently skipped on every + // reset). GRO-2100 follow-up. + return uatCustomerClientId; } /** @@ -692,12 +699,18 @@ async function seedUatStaffAccounts(db: ReturnType) { */ async function seedUatGroomerLinkage( db: ReturnType, - customerClientId: string, + customerClientId: string | null, ): Promise { const uatGroomerEmail = "uat-groomer@groombook.dev"; const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha const APPT_ID = "a0000001-0000-0000-0000-000000000001"; + // Skip silently if the UAT Customer client wasn't created (non-UAT seed + // profile, e.g. seedKnownUsers() in an env without the UAT personas). + if (!customerClientId) { + return; + } + // Only run if the UAT groomer staff record actually exists — dev/test seeds // that don't set SEED_UAT_STAFF_OIDC_SUB should not crash. const [uatGroomerStaff] = await db @@ -720,6 +733,19 @@ async function seedUatGroomerLinkage( return; } + // Skip if the linked pet hasn't been seeded yet (defensive: caller should + // ensure pets exist; if the helper is re-ordered later we don't want to + // crash here). + const [linkedPet] = await db + .select({ id: schema.pets.id }) + .from(schema.pets) + .where(eq(schema.pets.id, LINKED_PET_ID)) + .limit(1); + if (!linkedPet) { + console.warn(`⚠ GRO-2100: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping uat-groomer linkage`); + return; + } + // The "Bath & Brush" service id is stable across the reset; falls back to // any active service if it has not been seeded yet (e.g. seedKnownUsers // runs in isolation). @@ -847,7 +873,7 @@ async function seedKnownUsers() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers() // and the full seed() UAT branch. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services: idempotent upsert keyed on `id` ───────────────────────────── // GRO-2064: previously keyed on `services.name` while writing a @@ -875,6 +901,12 @@ async function seedKnownUsers() { } console.log(`✓ Seeded ${demoSvcs.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Client: Demo Client ── const [existingClient] = await db .select() @@ -1031,7 +1063,7 @@ async function seed() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials. // Must run AFTER random staff are created so upserts land correctly. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services ── // GRO-2064: key the upsert on `services.id` (not `name`) so deterministic @@ -1058,6 +1090,12 @@ async function seed() { } console.log(`✓ Created ${servicesDef.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Clients & Pets ── const now = new Date(); const appointmentsBackDate = new Date(now); From d1a68d93de2f4708491e56fedcbbee674e1451c4 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 4 Jun 2026 11:12:17 +0000 Subject: [PATCH 2/2] fix(GRO-2123): serialize seed.ts with Postgres advisory lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset-demo-data CronJob in groombook-uat intermittently failed with FK 23503 on invoice_tip_splits because two pods could run the seed concurrently: the new pod's TRUNCATE deleted rows the old pod was still inserting. Acquire a session-level advisory lock for the full duration of the seed. CRITICAL: with postgres-js connection pooling, a pg_advisory_lock acquired on one pooled connection and released on a different one is a no-op (the lock is bound to the pg-backend that took it). We therefore reserve a dedicated connection for the lock, take pg_advisory_lock(KEY) on it, run the seed on the pooled connections, and release the lock + reserved connection in a try/finally so a thrown seed error cannot leak the lock or the connection. Defence-in-depth with the infra PR that switches concurrencyPolicy: Replace → Forbid on the reset-demo-data CronJob. - Adds withSeedAdvisoryLock helper and runSeedBody extracted function - Wraps seed() body in the helper; client.end() runs after the lock releases so a reserved connection is not returned to a closed pool - SEED_ADVISORY_LOCK_KEY = 0x47524f4f ("GROO" in ASCII) — arbitrary stable 32-bit key, referenced in runbooks - UAT_PLAYBOOK.md §3.29 documents the regression check cc @cpfarhood Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 1 + packages/db/src/seed.ts | 75 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index b8949de..42d4cf9 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -166,6 +166,7 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) | | TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` | | TC-API-3.28 | Verify pet_size_category enum has all seed values | After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; pet_size_category includes all 4 values used by seed.ts `petSizeCategoryPool` (regression for GRO-1999, mirrors TC-API-3.27) | +| TC-API-3.29 | Verify `reset-demo-data` CronJob does not fail with FK 23503 on `invoice_tip_splits` (GRO-2123) | Trigger the CronJob manually: `kubectl create job --from=cronjob/reset-demo-data verify-gro2123 -n groombook-uat`. Wait for pod to terminate. Inspect logs: `kubectl logs -n groombook-uat -l job-name=verify-gro2123` | Pod reaches `Completed` state; logs show `✓ Acquired seed advisory lock` and `✓ Released seed advisory lock` from `seed.ts`; no `PostgresError: … violates foreign key constraint "invoice_tip_splits_invoice_id_invoices_id_fk"` (code 23503); final counts unchanged (500 clients, ~4000 invoices) | ### 4.4 Appointment Scheduling diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 8e9d376..0959be0 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -976,6 +976,63 @@ async function seedKnownUsers() { // ── Main seed ──────────────────────────────────────────────────────────────── +// ── GRO-2123: serialize reset+seed with a Postgres advisory lock ──────── +// The reset-demo-data CronJob runs on an hourly schedule. With +// concurrencyPolicy=Replace, a new pod can start while the previous one +// is still mid-seed; the new pod's TRUNCATE then deletes rows the old pod +// is still inserting, producing FK 23503 errors non-deterministically +// (see GRO-2123: invoice_tip_splits → invoices). +// +// We hold a session-level advisory lock for the full duration of the +// seed so that overlapping invocations block then proceed in order — +// not skip. The key is a stable 32-bit constant so it can be referenced +// from runbooks without ambiguity and binds to the single-argument +// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain +// number (no bigint type plumbing required). +const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable + +/** + * Reserve a dedicated connection from `pool`, take the seed advisory lock + * on it, run `fn`, and release the lock + connection in a try/finally. + * + * CRITICAL: with postgres-js connection pooling, a session-level + * `pg_advisory_lock(KEY)` acquired on one pooled connection and released + * on a *different* one is a no-op (the lock is bound to the session / + * pg-backend that took it). We therefore reserve a dedicated connection + * for the lock and release it from the same reserved connection. The + * seed work itself still runs on the pooled connections. + */ +async function withSeedAdvisoryLock( + pool: ReturnType, + fn: () => Promise, +): Promise { + const lockConnection = await pool.reserve(); + let lockHeld = false; + try { + await lockConnection`SELECT pg_advisory_lock(${SEED_ADVISORY_LOCK_KEY})`; + lockHeld = true; + console.log(`✓ Acquired seed advisory lock (key=${SEED_ADVISORY_LOCK_KEY})`); + const result = await fn(); + await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`; + lockHeld = false; + console.log(`✓ Released seed advisory lock`); + return result; + } finally { + if (lockHeld) { + try { + await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`; + } catch (err) { + console.error("Failed to release seed advisory lock during cleanup:", err); + } + } + try { + lockConnection.release(); + } catch (err) { + console.error("Failed to release reserved lock connection:", err); + } + } +} + async function seed() { const url = process.env.DATABASE_URL; if (!url) { @@ -993,6 +1050,22 @@ async function seed() { const client = postgres(url, { max: 5 }); const db = drizzle(client, { schema }); + // GRO-2123: hold the seed advisory lock for the full body of runSeedBody. + // See the withSeedAdvisoryLock comment for why a reserved connection is + // required (postgres-js pooling would silently drop the lock otherwise). + await withSeedAdvisoryLock(client, async () => { + return await runSeedBody(client, db, profile, cfg); + }); + + await client.end(); +} + +async function runSeedBody( + client: ReturnType, + db: ReturnType, + profile: SeedProfile, + cfg: ProfileConfig, +): Promise { console.log(`Seeding Groom Book database (profile: ${profile})...\n`); // ── Staff ── @@ -1614,8 +1687,6 @@ async function seed() { } console.log(`✓ Created ${visitLogCount} grooming visit logs`); console.log("\nSeed complete!"); - - await client.end(); } seed().catch((err) => {