// Migration B (DISTRIBUTE) of the Workflow Runtime V2 Citus colocation work. // Runs AFTER Deploy 1 (code writes `tenant` only). Distributes the v2 tables into // colocation group 41 (the uuid group of the v1 workflow tables) on `tenant`. // // Per-table sequence: drop FKs/uniques/PK -> recreate PK as (tenant, ) -> // create_distributed_table(..., 'tenant', colocate_with => 'workflow_tasks') // -> truncate_local_data_after_distributing_table (Citus leaves the original rows // as LOCAL coordinator data after distributing a non-empty table, and that // leftover data blocks the FK/constraint re-adds) -> re-add tenant-scoped uniques // and FKs. `tenant_id` columns remain (vestigial) until the cleanup migration C. // // MUST be validated on a Citus staging clone before production: the exact set of // pre-existing constraints/indexes varies, and this migration drops/recreates // them defensively. See .ai/workflow-v2-citus-colocation-plan.md. exports.config = { transaction: false }; const UUID_REGEX = "'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'"; // Colocate the v2 tables with an existing distributed v1 workflow table in the // uuid group 41. workflow_tasks is present and distributed on tenant in group 41. const COLOCATE_WITH = 'workflow_tasks'; // Single natural-id PK column per table (tenant is prepended). const PK_ID = { workflow_definitions: 'workflow_id', workflow_runs: 'run_id', workflow_definition_versions: 'version_id', workflow_run_steps: 'step_id', workflow_run_waits: 'wait_id', workflow_run_snapshots: 'snapshot_id', workflow_action_invocations: 'invocation_id', workflow_run_logs: 'log_id', workflow_runtime_events: 'event_id', tenant_workflow_schedule: 'id', }; // Distribute parents before children so FK targets exist + are colocated first. const DISTRIBUTE_ORDER = [ 'workflow_definitions', 'workflow_runs', 'workflow_definition_versions', 'workflow_run_steps', 'workflow_run_waits', 'workflow_run_snapshots', 'workflow_action_invocations', 'workflow_run_logs', 'workflow_runtime_events', 'tenant_workflow_schedule', ]; const RUN_CHILDREN = [ 'workflow_run_steps', 'workflow_run_waits', 'workflow_run_snapshots', 'workflow_action_invocations', 'workflow_run_logs', ]; // Tenant-scoped uniques to re-add after distribution (tenant-prefixed). const UNIQUES = [ { table: 'workflow_definition_versions', name: 'workflow_definition_versions_tenant_workflow_version_unique', cols: ['tenant', 'workflow_id', 'version'] }, { table: 'workflow_action_invocations', name: 'workflow_action_invocations_tenant_idempotency_unique', cols: ['tenant', 'action_id', 'action_version', 'idempotency_key'] }, { table: 'tenant_workflow_schedule', name: 'tenant_workflow_schedule_tenant_workflow_unique', cols: ['tenant', 'workflow_id'] }, ]; // Tenant-scoped FKs to re-add (all ON DELETE CASCADE, matching the originals). const FKS = [ { name: 'workflow_runs_tenant_workflow_fk', table: 'workflow_runs', cols: ['tenant', 'workflow_id'], ref: 'workflow_definitions', refCols: ['tenant', 'workflow_id'] }, { name: 'workflow_definition_versions_tenant_workflow_fk', table: 'workflow_definition_versions', cols: ['tenant', 'workflow_id'], ref: 'workflow_definitions', refCols: ['tenant', 'workflow_id'] }, { name: 'workflow_run_steps_tenant_run_fk', table: 'workflow_run_steps', cols: ['tenant', 'run_id'], ref: 'workflow_runs', refCols: ['tenant', 'run_id'] }, { name: 'workflow_run_waits_tenant_run_fk', table: 'workflow_run_waits', cols: ['tenant', 'run_id'], ref: 'workflow_runs', refCols: ['tenant', 'run_id'] }, { name: 'workflow_run_snapshots_tenant_run_fk', table: 'workflow_run_snapshots', cols: ['tenant', 'run_id'], ref: 'workflow_runs', refCols: ['tenant', 'run_id'] }, { name: 'workflow_action_invocations_tenant_run_fk', table: 'workflow_action_invocations', cols: ['tenant', 'run_id'], ref: 'workflow_runs', refCols: ['tenant', 'run_id'] }, { name: 'workflow_run_logs_tenant_run_fk', table: 'workflow_run_logs', cols: ['tenant', 'run_id'], ref: 'workflow_runs', refCols: ['tenant', 'run_id'] }, ]; const isCitusEnabled = async (knex) => { const r = await knex.raw("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'citus') AS enabled"); return Boolean(r.rows?.[0]?.enabled); }; const ensureSequentialMode = async (knex) => { await knex.raw("SET citus.multi_shard_modify_mode TO 'sequential'"); }; const isDistributed = async (knex, table) => { const r = await knex.raw( `SELECT EXISTS (SELECT 1 FROM pg_dist_partition WHERE logicalrelid = ?::regclass) AS d`, [table] ); return Boolean(r.rows?.[0]?.d); }; const dropForeignKeys = async (knex, table) => { const r = await knex.raw( `SELECT conname FROM pg_constraint WHERE conrelid = ?::regclass AND contype = 'f'`, [table] ); for (const row of r.rows) { await knex.raw('ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ?? CASCADE', [table, row.conname]); } }; const dropUniqueConstraints = async (knex, table) => { const r = await knex.raw( `SELECT conname FROM pg_constraint WHERE conrelid = ?::regclass AND contype = 'u'`, [table] ); for (const row of r.rows) { await knex.raw('ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ?? CASCADE', [table, row.conname]); } }; // Drop UNIQUE indexes that are not backed by a constraint (e.g. partial unique // indexes). Non-unique indexes are left alone — Citus only requires the // distribution column in UNIQUE/PK/exclusion constraints. const dropUniqueIndexes = async (knex, table) => { const r = await knex.raw( `SELECT i.relname AS idxname FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid JOIN pg_class t ON t.oid = x.indrelid WHERE t.relname = ? AND x.indisunique AND NOT x.indisprimary AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = x.indexrelid)`, [table] ); for (const row of r.rows) { await knex.raw('DROP INDEX IF EXISTS ??', [row.idxname]); } }; // Drop non-internal triggers — Citus rejects create_distributed_table on a table // with triggers unless citus.enable_unsafe_triggers is set. The v2 tables have // none (updated_at is set in app code), so this is a defensive no-op. const dropTriggers = async (knex, table) => { const r = await knex.raw( `SELECT tgname FROM pg_trigger WHERE tgrelid = ?::regclass AND NOT tgisinternal`, [table] ); for (const row of r.rows) { await knex.raw('DROP TRIGGER IF EXISTS ?? ON ??', [row.tgname, table]); } }; const getPrimaryKey = async (knex, table) => { const r = await knex.raw( `SELECT c.conname AS constraint_name FROM pg_constraint c WHERE c.conrelid = ?::regclass AND c.contype = 'p'`, [table] ); return r.rows?.[0]?.constraint_name ?? null; }; const recreateTenantPrimaryKey = async (knex, table) => { const existing = await getPrimaryKey(knex, table); if (existing) { await knex.raw('ALTER TABLE ?? DROP CONSTRAINT ?? CASCADE', [table, existing]); } await knex.raw('ALTER TABLE ?? ADD PRIMARY KEY (tenant, ??)', [table, PK_ID[table]]); }; // Fresh Citus chains never distributed the v1 workflow tables this migration // colocates with. The system catalogs (tenant-less) become reference tables, // but converting them would drag workflow_tasks — which FKs distributed // users — into the conversion, so its FKs are dropped and re-added around // it. isReference (not just pg_dist_partition presence) matters: the first // conversion auto-adds connected locals as citus-local tables, which also // appear in pg_dist_partition. No-op on prod, where workflow_tasks is // already distributed. const ensureColocationTargetDistributed = async (knex) => { if (await isDistributed(knex, COLOCATE_WITH)) return; const isReference = async (table) => { const r = await knex.raw( `SELECT 1 FROM pg_dist_partition WHERE logicalrelid = ?::regclass AND partmethod = 'n' AND repmodel = 't'`, [table] ); return r.rows.length > 0; }; const { rows: wtFks } = await knex.raw( `SELECT conname, pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conrelid = 'workflow_tasks'::regclass AND contype = 'f' AND confrelid IN ('system_workflow_task_definitions'::regclass, 'workflow_task_definitions'::regclass)` ); for (const fk of wtFks) { await knex.raw('ALTER TABLE workflow_tasks DROP CONSTRAINT IF EXISTS ??', [fk.conname]); } for (const ref of ['system_workflow_form_definitions', 'system_workflow_task_definitions']) { if (!(await isReference(ref))) { await knex.raw('SELECT create_reference_table(?::regclass)', [ref]); } } if (!(await isDistributed(knex, 'workflow_task_definitions'))) { await knex.raw(`SELECT create_distributed_table('workflow_task_definitions', 'tenant')`); } await knex.raw(`SELECT create_distributed_table('workflow_tasks', 'tenant')`); for (const fk of wtFks) { await knex.raw(`ALTER TABLE workflow_tasks ADD CONSTRAINT "${fk.conname}" ${fk.def}`); } }; exports.up = async function up(knex) { if (!(await isCitusEnabled(knex))) { console.log('Citus not enabled, skipping workflow v2 distribution'); return; } await ensureSequentialMode(knex); await ensureColocationTargetDistributed(knex); const present = []; for (const table of DISTRIBUTE_ORDER) { if (await knex.schema.hasTable(table)) present.push(table); } // Only NOT-yet-distributed tables need prep + distribution. Already-distributed // tables (e.g. from a prior partial run that failed at the constraint step) skip // straight to the idempotent constraint re-add: re-running their backfill would // fail with "modifying the partition value of rows is not allowed", because // `tenant` is now the distribution column. const toPrep = []; for (const table of present) { if (!(await isDistributed(knex, table))) toPrep.push(table); } // 1. Backfill any NULL tenant (rollover rows written by old code), then enforce // NOT NULL (required: the distribution column must be NOT NULL). for (const table of toPrep) { if (await knex.schema.hasColumn(table, 'tenant_id')) { await knex.raw( `UPDATE ?? SET tenant = tenant_id::uuid WHERE tenant IS NULL AND tenant_id ~ ${UUID_REGEX}`, [table] ); } } for (const table of RUN_CHILDREN) { if (!toPrep.includes(table)) continue; await knex.raw( `UPDATE ?? AS c SET tenant = r.tenant FROM workflow_runs r WHERE c.run_id = r.run_id AND c.tenant IS NULL AND r.tenant IS NOT NULL`, [table] ); } if (toPrep.includes('workflow_definition_versions')) { await knex.raw( `UPDATE workflow_definition_versions AS v SET tenant = d.tenant FROM workflow_definitions d WHERE v.workflow_id = d.workflow_id AND v.tenant IS NULL AND d.tenant IS NOT NULL` ); } for (const table of toPrep) { const nulls = await knex(table).whereNull('tenant').count({ c: '*' }).first(); if (Number(nulls?.c ?? 0) > 0) { throw new Error(`Cannot distribute ${table}: ${nulls.c} rows still have NULL tenant`); } await knex.raw('ALTER TABLE ?? ALTER COLUMN tenant SET NOT NULL', [table]); } // 2. Drop all FKs (so PKs they reference can be rebuilt). for (const table of toPrep) { await dropForeignKeys(knex, table); } // 3. Per table: drop triggers + uniques + unique indexes, recreate PK as (tenant, ). for (const table of toPrep) { await dropTriggers(knex, table); await dropUniqueConstraints(knex, table); await dropUniqueIndexes(knex, table); await recreateTenantPrimaryKey(knex, table); } // 4. Distribute, then immediately truncate the leftover LOCAL coordinator data — // it otherwise blocks the constraint re-adds below. Cast to ::regclass so the // Citus functions resolve unambiguously. if (toPrep.length) { console.log(`Colocating workflow v2 tables with ${COLOCATE_WITH}`); } for (const table of toPrep) { await knex.raw(`SELECT create_distributed_table(?::regclass, 'tenant', colocate_with => ?)`, [table, COLOCATE_WITH]); await knex.raw('SELECT truncate_local_data_after_distributing_table(?::regclass)', [table]); } // 5. Re-add tenant-scoped uniques and FKs (targets are now distributed+colocated). // DROP IF EXISTS first so a re-run after a partial failure is idempotent. for (const u of UNIQUES) { if (!present.includes(u.table)) continue; const cols = u.cols.map(() => '??').join(', '); await knex.raw('ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??', [u.table, u.name]); await knex.raw(`ALTER TABLE ?? ADD CONSTRAINT ?? UNIQUE (${cols})`, [u.table, u.name, ...u.cols]); } // Restore workflow_definitions' per-tenant unique key (partial → unique index; // dropped above because the old one was on tenant_id, not the dist column). if (present.includes('workflow_definitions')) { await knex.raw('DROP INDEX IF EXISTS workflow_definitions_tenant_key_unique'); await knex.raw( 'CREATE UNIQUE INDEX workflow_definitions_tenant_key_unique ON workflow_definitions (tenant, key) WHERE key IS NOT NULL' ); } // Add FKs as NOT VALID: there are legacy rows (e.g. ~388 pre-split cross-tenant // workflow_runs) whose (tenant, workflow_id) has no same-tenant definition. The // constraint is enforced for all NEW writes (the app always scopes a run to its // definition's tenant); the legacy rows are grandfathered. Clean them up and run // `ALTER TABLE .. VALIDATE CONSTRAINT ..` later as a separate task. for (const fk of FKS) { if (!present.includes(fk.table) || !present.includes(fk.ref)) continue; const cols = fk.cols.map(() => '??').join(', '); const refCols = fk.refCols.map(() => '??').join(', '); await knex.raw('ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??', [fk.table, fk.name]); await knex.raw( `ALTER TABLE ?? ADD CONSTRAINT ?? FOREIGN KEY (${cols}) REFERENCES ?? (${refCols}) ON DELETE CASCADE NOT VALID`, [fk.table, fk.name, ...fk.cols, fk.ref, ...fk.refCols] ); } // 6. Verify: every targeted table is distributed on tenant and shares one // colocationid. (Fetch all rows and filter in JS — array bindings in raw // SQL are unreliable in knex.) const check = await knex.raw( `SELECT logicalrelid::text AS tbl, column_to_column_name(logicalrelid, partkey) AS dist_col, colocationid FROM pg_dist_partition` ); const rows = (check.rows || []).filter((r) => present.includes(r.tbl)); const badCol = rows.filter((r) => r.dist_col !== 'tenant'); if (badCol.length) { throw new Error(`Tables not distributed on tenant: ${badCol.map((r) => r.tbl).join(', ')}`); } const missing = present.filter((t) => !rows.some((r) => r.tbl === t)); if (missing.length) { throw new Error(`Tables not distributed at all: ${missing.join(', ')}`); } const groups = new Set(rows.map((r) => r.colocationid)); if (groups.size !== 1) { throw new Error(`Workflow v2 tables landed in multiple colocation groups: ${[...groups].join(', ')}`); } console.log(`Workflow v2 tables distributed into colocation group ${[...groups][0]}`); }; exports.down = async function down() { // Deliberately no-op: create_distributed_table cannot be safely reversed once // writes resume. Roll forward instead. };