Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

170 lines
6.3 KiB
TypeScript

/**
* Fail-fast Postgres migration locking (#1744).
*
* On Cloudflare Workers, EmDash's per-isolate init lock cannot coordinate
* across isolates, so multiple isolates can run `runMigrations` against the
* same Postgres database concurrently. Kysely's stock adapter serializes
* them with a *blocking* `pg_advisory_xact_lock` — when a pending migration
* keeps failing, every cold start parks a connection inside Postgres
* waiting for the lock and then retries the same failing migration.
*
* These tests run only when EMDASH_TEST_PG is set. They simulate the
* concurrent isolate by holding the advisory lock from a second connection
* of the same pool (a distinct Postgres session, like another isolate's
* connection).
*/
import { sql } from "kysely";
import { describe, expect, it } from "vitest";
import {
ConcurrentMigrationTimeoutError,
MIGRATION_COUNT,
runMigrations,
} from "../../../src/database/migrations/runner.js";
import {
createTestPostgresDatabase,
hasPgTestDatabase,
type PgTestContext,
setupTestPostgresDatabase,
teardownTestPostgresDatabase,
} from "../../utils/test-db.js";
/** Kysely's migration advisory lock id (mirrored in pg-migration-lock.ts). */
const LOCK_ID = BigInt("3853314791062309107");
/**
* Acquire the migration advisory lock on a dedicated session (a separate
* connection from the context's pool) and hold it until `release()` is
* called — simulating another isolate's in-flight migrator.
*/
async function holdMigrationLock(
ctx: PgTestContext,
): Promise<{ release: () => void; done: Promise<void> }> {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let acquired!: () => void;
let failed!: (error: unknown) => void;
const acquiredPromise = new Promise<void>((resolve, reject) => {
acquired = resolve;
failed = reject;
});
const done = ctx.db
.transaction()
.execute(async (trx) => {
await sql`select pg_advisory_xact_lock(${sql.lit(LOCK_ID)})`.execute(trx);
acquired();
// Keep the transaction (and therefore the lock) open until released.
await held;
})
.catch((error: unknown) => {
failed(error);
throw error;
});
await acquiredPromise;
return { release, done };
}
describe.runIf(hasPgTestDatabase)("Fail-fast Postgres migration lock (#1744)", () => {
it("fails fast instead of blocking when another migrator holds the lock", async () => {
// Fresh schema, no migrations applied — the state of a database whose
// pending migrations another isolate is (unsuccessfully) applying.
const ctx = await createTestPostgresDatabase();
try {
const lock = await holdMigrationLock(ctx);
try {
const start = Date.now();
// The distinct error type matters: getDatabase() exempts it from
// the failure backoff, since the lock holder may simply be slow
// rather than failing.
await expect(
runMigrations(ctx.db, { migrationTableSchema: ctx.schemaName, raceWaitMs: 300 }),
).rejects.toThrow(ConcurrentMigrationTimeoutError);
// The old behavior blocked inside pg_advisory_xact_lock until the
// holder finished — i.e. forever in this test. The fail-fast path
// must return promptly: try-lock + a bounded ~300ms poll.
expect(Date.now() - start).toBeLessThan(5000);
} finally {
lock.release();
await lock.done;
}
} finally {
await teardownTestPostgresDatabase(ctx);
}
});
it("treats the busy lock as success once the concurrent migrator finishes", async () => {
// Fully migrated schema with the last migration row removed — the
// bookkeeping state a waiter observes while another isolate applies
// the final pending migration.
const ctx = await setupTestPostgresDatabase();
try {
const lastRow = await sql<{ name: string; timestamp: string }>`
SELECT name, timestamp FROM _emdash_migrations ORDER BY name DESC LIMIT 1
`.execute(ctx.db);
const last = lastRow.rows[0]!;
await sql`DELETE FROM _emdash_migrations WHERE name = ${last.name}`.execute(ctx.db);
const lock = await holdMigrationLock(ctx);
try {
const migration = runMigrations(ctx.db, {
migrationTableSchema: ctx.schemaName,
raceWaitMs: 10_000,
});
// While the migrator polls for the "concurrent migrator", restore
// the row (as if the lock holder just applied the migration)...
await sql`
INSERT INTO _emdash_migrations (name, timestamp)
VALUES (${last.name}, ${last.timestamp})
`.execute(ctx.db);
// ...and it must settle as success without applying anything itself.
await expect(migration).resolves.toEqual({ applied: [] });
} finally {
lock.release();
await lock.done;
}
} finally {
await teardownTestPostgresDatabase(ctx);
}
});
it("surfaces a genuinely failing migration without queueing concurrent callers", async () => {
// The incident shape: a pending migration that fails every attempt
// (here: re-running 001_initial against an already-built schema).
// Concurrent callers must all fail in bounded time — none may hang on
// the advisory lock.
const ctx = await setupTestPostgresDatabase();
try {
await sql`DELETE FROM _emdash_migrations WHERE name = '001_initial'`.execute(ctx.db);
const start = Date.now();
const results = await Promise.allSettled([
runMigrations(ctx.db, { migrationTableSchema: ctx.schemaName, raceWaitMs: 500 }),
runMigrations(ctx.db, { migrationTableSchema: ctx.schemaName, raceWaitMs: 500 }),
]);
expect(Date.now() - start).toBeLessThan(15_000);
// Both reject: the migration genuinely fails, and the concurrent
// caller either observes the busy lock (and the holder never
// completes) or re-runs the same failing migration itself.
expect(results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
// The real migration error must not be swallowed as a race.
const messages = results.map((r) =>
r.status === "rejected" ? String((r.reason as Error).message) : "",
);
expect(messages.some((m) => /Migration failed/i.test(m))).toBe(true);
// And the bookkeeping still reflects the failure: the deleted row
// was not silently restored.
const count = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM _emdash_migrations
`.execute(ctx.db);
expect(Number(count.rows[0]?.count)).toBe(MIGRATION_COUNT - 1);
} finally {
await teardownTestPostgresDatabase(ctx);
}
});
});