super-productivity/packages/super-sync-server/tests/migrate-deploy-script.spec.ts
Johannes Millan 5ed1f2c5d0
fix(supersync): prevent migrator advisory-lock leak on deploy timeout (#9126)
A timed-out `docker compose run` in deploy.sh SIGTERMs the compose CLI but
can leave the migrator container (and its DB connection) running detached,
which keeps Prisma's session-level migration advisory lock held and wedges
the next deploy with P1002.

- deploy.sh: run_migrator() names each one-off migrator container from a
  single MIGRATOR_NAME_PREFIX and force-removes it (inline + $$-scoped EXIT
  sweep) so a timed-out run can't orphan it and leak the lock; add timeout -k.
- migrate-deploy.sh: recognize P1002 advisory-lock timeouts and print
  copy-paste diagnosis + cleanup guidance instead of a generic exit 1; never
  auto-terminate a backend (an active CONCURRENTLY build legitimately holds it).
- tests: P1002 fake-npx case + non-vacuous recovery test; assert the
  name/sweep-filter prefix single-source-of-truth invariant.
- env.example: document the recommended higher MIGRATION_TIMEOUT and the new
  force-remove-on-timeout behavior.
2026-07-17 20:18:54 +02:00

419 lines
16 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
// Drives scripts/migrate-deploy.sh end-to-end with a fake `npx prisma` so the
// generic CONCURRENTLY recovery logic is exercised without a real database.
const currentDir = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(currentDir, '../scripts/migrate-deploy.sh');
const ENCRYPTED_OPS = '20260514000000_add_encrypted_ops_partial_index';
const ENCRYPTED_OPS_SQL = `DROP INDEX CONCURRENTLY IF EXISTS "operations_user_id_server_seq_encrypted_idx";
CREATE INDEX CONCURRENTLY "operations_user_id_server_seq_encrypted_idx"
ON "operations"("user_id", "server_seq")
WHERE "is_payload_encrypted" = true;`;
const PLAIN_MIGRATION = '20260601000000_add_plain_column';
const PLAIN_SQL = `ALTER TABLE "operations" ADD COLUMN "foo" TEXT;`;
interface RunResult {
status: number;
stdout: string;
executedSql: string;
resolveApplied: string[];
resolveRolledBack: string[];
}
let projectDir: string;
let stateDir: string;
let binDir: string;
const writeMigration = (name: string, sql: string): void => {
const dir = join(projectDir, 'prisma', 'migrations', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'migration.sql'), sql);
};
// Fake `npx`: first arg is `prisma`. Behavior is driven by FAKE_* env vars and
// marker files under $FAKE_STATE so the deploy→recover→retry cycle is modelled.
const FAKE_NPX = `#!/bin/sh
set -eu
shift # drop "prisma"
sub="$1"; shift
state="$FAKE_STATE"
case "$sub" in
migrate)
action="$1"; shift
case "$action" in
deploy)
for m in $FAKE_FAIL; do
if [ ! -f "$state/applied_$m" ]; then
case "$FAKE_CODE" in
P3018)
echo "Applying migration \\\`$m\\\`"
echo "Error: P3018"
echo "A migration failed to apply. New migrations cannot be applied before the error is recovered from."
echo "Migration name: $m"
echo "Database error code: 25001"
echo "Database error:"
echo "ERROR: DROP INDEX CONCURRENTLY cannot run inside a transaction block"
;;
P3009)
echo "Error: P3009"
echo "migrate found failed migrations in the target database, new migrations will not be applied."
echo "The \\\`$m\\\` migration started at 2026-05-15 failed"
[ -n "\${FAKE_DECOY:-}" ] && echo "Applying migration \\\`\$FAKE_DECOY\\\`"
;;
INTERRUPT)
# A CONCURRENTLY build killed mid-apply (external SIGTERM/OOM):
# the migration name is visible in the "Applying" line, but no
# P3018/P3009 gate marker is emitted and the process exits with
# a non-gate code (137 stands in for any external kill; a raw
# 143 would be normalized to the timeout branch instead).
echo "Applying migration \\\`$m\\\`"
echo "Terminated"
exit 137
;;
P1002)
# Another session holds Prisma's migration advisory lock, so
# deploy times out before applying anything. No migration name
# or gate marker is emitted — the script must recognize this
# from the P1002 + advisory-lock text, not a failing migration.
echo "Error: P1002"
echo "The database server was reached but timed out."
echo "Context: Timed out trying to acquire a postgres advisory lock (SELECT pg_advisory_lock(72707369)). Elapsed: 10000ms."
;;
*)
echo "Error: P1001"
echo "Can't reach database server"
;;
esac
exit 1
fi
done
echo "No pending migrations to apply."
exit 0
;;
resolve)
mode="$1"; name="$2"
if [ "$mode" = "--rolled-back" ]; then
echo "$name" >> "$state/rolledback"
exit "\${FAKE_ROLLEDBACK_EXIT:-0}"
fi
echo "$name" >> "$state/applied_list"
[ "\${FAKE_MARK:-1}" = "1" ] && : > "$state/applied_$name"
exit 0
;;
esac
;;
db)
# db execute --schema X --stdin (SQL on stdin)
cat >> "$state/executed_sql"
echo "" >> "$state/executed_sql"
echo "---STMT---" >> "$state/executed_sql"
exit "\${FAKE_EXEC_EXIT:-0}"
;;
esac
echo "fake npx: unhandled args" >&2
exit 99
`;
const run = (env: Record<string, string>): RunResult => {
let status = 0;
let stdout = '';
try {
stdout = execFileSync('sh', [SCRIPT], {
cwd: projectDir,
encoding: 'utf8',
env: {
PATH: `${binDir}:${process.env.PATH ?? ''}`,
TMPDIR: stateDir,
FAKE_STATE: stateDir,
...env,
},
});
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string };
status = err.status ?? 1;
stdout = `${err.stdout ?? ''}${err.stderr ?? ''}`;
}
const read = (f: string): string => {
try {
return readFileSync(join(stateDir, f), 'utf8');
} catch {
return '';
}
};
return {
status,
stdout,
executedSql: read('executed_sql'),
resolveApplied: read('applied_list').split('\n').filter(Boolean),
resolveRolledBack: read('rolledback').split('\n').filter(Boolean),
};
};
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), 'sup-migproj-'));
stateDir = mkdtempSync(join(tmpdir(), 'sup-migstate-'));
binDir = mkdtempSync(join(tmpdir(), 'sup-migbin-'));
mkdirSync(join(projectDir, 'prisma'), { recursive: true });
writeFileSync(join(projectDir, 'prisma', 'schema.prisma'), '// dummy');
const npxPath = join(binDir, 'npx');
writeFileSync(npxPath, FAKE_NPX);
chmodSync(npxPath, 0o755);
});
afterEach(() => {
for (const d of [projectDir, stateDir, binDir]) {
rmSync(d, { recursive: true, force: true });
}
});
describe('migrate-deploy.sh generic CONCURRENTLY recovery', () => {
it('clean deploy with no failures exits 0 and runs no recovery', () => {
const r = run({ FAKE_FAIL: '', FAKE_CODE: 'P3018' });
expect(r.status).toBe(0);
expect(r.resolveApplied).toEqual([]);
expect(r.executedSql).toBe('');
});
it('recovers a P3018 transaction-block failure out-of-band, then succeeds', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'P3018' });
expect(r.status).toBe(0);
expect(r.resolveRolledBack).toContain(ENCRYPTED_OPS);
expect(r.resolveApplied).toContain(ENCRYPTED_OPS);
// Both statements ran out-of-band; multi-line CREATE collapsed to one line.
expect(r.executedSql).toContain(
'DROP INDEX CONCURRENTLY IF EXISTS "operations_user_id_server_seq_encrypted_idx";',
);
expect(r.executedSql).toContain(
'CREATE INDEX CONCURRENTLY "operations_user_id_server_seq_encrypted_idx" ON "operations"("user_id", "server_seq") WHERE "is_payload_encrypted" = true;',
);
const statements = r.executedSql
.split('---STMT---')
.map((s) => s.trim())
.filter(Boolean);
expect(statements).toHaveLength(2);
});
it('recovers a stuck P3009 failed migration', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'P3009' });
expect(r.status).toBe(0);
expect(r.resolveApplied).toContain(ENCRYPTED_OPS);
});
it('refuses to auto-resolve a non-CONCURRENTLY migration', () => {
writeMigration(PLAIN_MIGRATION, PLAIN_SQL);
const r = run({ FAKE_FAIL: PLAIN_MIGRATION, FAKE_CODE: 'P3018' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('refusing to auto-resolve');
expect(r.resolveApplied).toEqual([]);
});
it('refuses a bare CREATE INDEX CONCURRENTLY (intentionally fail-loud, no DROP)', () => {
const bare = '20260701000000_add_bare_concurrent_index';
// Same shape as the committed 20260511000000: deliberately no DROP, so a
// half-built INVALID index fails loudly instead of being marked applied.
const bareSql = `CREATE INDEX CONCURRENTLY "operations_bare_idx"
ON "operations"("user_id", "server_seq");`;
writeMigration(bare, bareSql);
const r = run({ FAKE_FAIL: bare, FAKE_CODE: 'P3018' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('not a recoverable drop-then-create');
expect(r.resolveApplied).toEqual([]);
});
it('prints copy-paste recovery for a stuck bare CREATE INDEX CONCURRENTLY', () => {
// An interrupted bare CREATE (e.g. a step timeout) leaves the migration
// failed (P3009) and an INVALID index of the same name; the loud failure
// must tell the operator to drop it and roll the record back — never
// auto-resolve it.
const bare = '20260701000000_add_bare_concurrent_index';
const bareSql = `CREATE INDEX CONCURRENTLY "operations_bare_idx"
ON "operations"("user_id", "server_seq");`;
writeMigration(bare, bareSql);
const r = run({ FAKE_FAIL: bare, FAKE_CODE: 'P3009' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('not a recoverable drop-then-create');
expect(r.stdout).toContain(
'DROP INDEX CONCURRENTLY IF EXISTS "operations_bare_idx";',
);
expect(r.stdout).toContain(`migrate resolve --rolled-back '${bare}'`);
expect(r.resolveApplied).toEqual([]);
expect(r.resolveRolledBack).toEqual([]);
});
it('reports a BusyBox timeout (exit 143) as a timeout, not a generic failure', () => {
// node:*-alpine ships BusyBox `timeout`, which returns 128+SIGTERM=143 on
// expiry (GNU coreutils returns 124). A fake `timeout` on PATH reproduces
// that: run the wrapped command, then exit 143 as if the step was TERMed.
// migrate-deploy.sh must normalize this to its timeout branch.
const fakeTimeout = join(binDir, 'timeout');
writeFileSync(fakeTimeout, '#!/bin/sh\nshift\n"$@"\nexit 143\n');
chmodSync(fakeTimeout, 0o755);
const r = run({ FAKE_FAIL: '', FAKE_CODE: 'P3018' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('timed out after');
expect(r.resolveApplied).toEqual([]);
});
it('prints bare-create recovery when a normalized-143 timeout aborts a bare CONCURRENTLY build (the incident)', () => {
// The reported incident: a bare CREATE INDEX CONCURRENTLY killed by the
// in-image step timeout (BusyBox SIGTERM -> 143 -> normalized to the 124
// timeout branch). That branch must ALSO print the drop-INVALID-index
// recovery, because raising the timeout alone cannot rebuild an INVALID
// index left by the aborted build.
const fakeTimeout = join(binDir, 'timeout');
writeFileSync(fakeTimeout, '#!/bin/sh\nshift\n"$@"\nexit 143\n');
chmodSync(fakeTimeout, 0o755);
const bare = '20260701000000_add_bare_concurrent_index';
const bareSql = `CREATE INDEX CONCURRENTLY "operations_bare_idx"
ON "operations"("user_id", "server_seq");`;
writeMigration(bare, bareSql);
const r = run({ FAKE_FAIL: bare, FAKE_CODE: 'INTERRUPT' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('timed out after');
expect(r.stdout).toContain(
'DROP INDEX CONCURRENTLY IF EXISTS "operations_bare_idx";',
);
expect(r.stdout).toContain(`migrate resolve --rolled-back '${bare}'`);
expect(r.resolveApplied).toEqual([]);
expect(r.resolveRolledBack).toEqual([]);
});
it('surfaces bare-create recovery when a CONCURRENTLY build is interrupted (non-gate exit)', () => {
// The user's incident: a bare CREATE INDEX CONCURRENTLY killed mid-build
// exits with a non-P3018/P3009 code before Prisma records the failure. The
// first failure must still print copy-paste recovery for the INVALID index
// (drop it, roll the record back), never a bare exit code.
const bare = '20260701000000_add_bare_concurrent_index';
const bareSql = `CREATE INDEX CONCURRENTLY "operations_bare_idx"
ON "operations"("user_id", "server_seq");`;
writeMigration(bare, bareSql);
const r = run({ FAKE_FAIL: bare, FAKE_CODE: 'INTERRUPT' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain(
'DROP INDEX CONCURRENTLY IF EXISTS "operations_bare_idx";',
);
expect(r.stdout).toContain(`migrate resolve --rolled-back '${bare}'`);
// Guidance only — the interrupted migration must never be auto-resolved.
expect(r.resolveApplied).toEqual([]);
expect(r.resolveRolledBack).toEqual([]);
});
it('hints a re-run when an auto-recoverable CONCURRENTLY migration is interrupted', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'INTERRUPT' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('re-run the deploy to finish it');
expect(r.resolveApplied).toEqual([]);
expect(r.resolveRolledBack).toEqual([]);
});
it('does NOT mark applied when an out-of-band statement fails', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({
FAKE_FAIL: ENCRYPTED_OPS,
FAKE_CODE: 'P3018',
FAKE_EXEC_EXIT: '1',
});
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('was NOT marked applied');
expect(r.resolveApplied).toEqual([]);
});
it('aborts instead of looping when recovery does not clear the failure', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({
FAKE_FAIL: ENCRYPTED_OPS,
FAKE_CODE: 'P3018',
FAKE_MARK: '0', // resolve --applied never clears the marker
});
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('failed again after out-of-band recovery');
});
it('passes through a genuine non-gate failure without auto-resolving', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'OTHER' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('Not auto-recovered');
expect(r.resolveApplied).toEqual([]);
});
it('prints advisory-lock recovery on a P1002 lock timeout, without resolving anything', () => {
// Another session holds Prisma's migration advisory lock (e.g. a migrator
// container orphaned by a prior interrupted deploy), so migrate deploy
// times out before applying anything. This is NOT a migration failure:
// print cleanup guidance, never mark a migration applied or rolled-back.
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'P1002' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('advisory lock');
expect(r.stdout).toContain('supersync-migrator');
expect(r.stdout).toContain('pg_terminate_backend');
// The lock timeout is not a failing migration — nothing gets resolved.
expect(r.resolveApplied).toEqual([]);
expect(r.resolveRolledBack).toEqual([]);
});
it('targets the failed migration in a P3009 log that also backticks others', () => {
// A decoy backticked migration name appears AFTER the failed-migration
// sentence; the sentence-anchored parse must still pick ENCRYPTED_OPS,
// not the last backticked token.
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({
FAKE_FAIL: ENCRYPTED_OPS,
FAKE_CODE: 'P3009',
FAKE_DECOY: '20991231000000_decoy_later_token',
});
expect(r.status).toBe(0);
expect(r.resolveApplied).toContain(ENCRYPTED_OPS);
});
it('recovers several sequential CONCURRENTLY migrations in one deploy', () => {
const second = '20260514000002_add_payload_bytes_unbackfilled_index';
const secondSql = `DROP INDEX CONCURRENTLY IF EXISTS "operations_payload_bytes_unbackfilled_idx";
CREATE INDEX CONCURRENTLY "operations_payload_bytes_unbackfilled_idx"
ON "operations"("user_id", "id")
WHERE "payload_bytes" = 0;`;
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
writeMigration(second, secondSql);
const r = run({
FAKE_FAIL: `${ENCRYPTED_OPS} ${second}`,
FAKE_CODE: 'P3018',
});
expect(r.status).toBe(0);
expect(r.resolveApplied).toEqual(expect.arrayContaining([ENCRYPTED_OPS, second]));
});
});