From 3f80063303560e10cb569831277f8a0d0099ae7e Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 00:08:57 +0000 Subject: [PATCH] feat: add migration advisory lock to database-postgres (E00-S03-T04) --- .gitignore | 5 +- docs/development/non-container.md | 2 +- packages/database-postgres/package.json | 2 +- packages/database-postgres/src/index.ts | 9 +- packages/database-postgres/src/lock.ts | 132 ++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 packages/database-postgres/src/lock.ts diff --git a/.gitignore b/.gitignore index c908d5d..5a3b2f5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,10 @@ coverage/ # Logs *.log -# Transient host-side probe file written by tests/database-postgres-ledger.test.mjs -# into the database-postgres package (removed in its finally block) +# Transient host-side probe files written by the database-postgres test +# suites into the package (removed in their finally blocks) .ledger-probe-*.mjs +.lock-probe-*.mjs # OS / editor .DS_Store diff --git a/docs/development/non-container.md b/docs/development/non-container.md index f86cec7..813a18b 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -17,7 +17,7 @@ The workspace is a pnpm monorepo with three package groups: | --- | --- | --- | | `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | -| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03) is implemented here; the migration runner (advisory lock, failure diagnostics) lands in later stories. | +| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03) and the migration advisory lock (E00-S03-T04) are implemented here; the migration runner (failure diagnostics) lands in later stories. | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | A dependency-boundary rule (`dependency-boundaries.json`, enforced by diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 223a180..016de03 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP PostgreSQL database adapter package. The single workspace package allowed to import the pg driver and Kysely (E00-S03-T02); the migration ledger (E00-S03-T03) is implemented here; the migration runner (advisory lock, diagnostics) lands in later stories.", + "description": "EPPP PostgreSQL database adapter package. The single workspace package allowed to import the pg driver and Kysely (E00-S03-T02); the migration ledger (E00-S03-T03) and the migration advisory lock (E00-S03-T04) are implemented here; the migration runner (failure diagnostics) lands in later stories.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/database-postgres/src/index.ts b/packages/database-postgres/src/index.ts index c25db95..df7ec9e 100644 --- a/packages/database-postgres/src/index.ts +++ b/packages/database-postgres/src/index.ts @@ -9,10 +9,10 @@ * * This module is the driver boundary: it imports the PostgreSQL driver (`pg`) * and Kysely and re-exports the pieces the adapter is built on — the driver - * surface (E00-S03-T02) and the migration ledger (E00-S03-T03). The advisory - * lock (E00-S03-T04) and failure diagnostic (E00-S03-T05) land in later - * stories; until then the re-exports keep the driver reachable only from here - * — the isolation is real, not a placeholder. + * surface (E00-S03-T02), the migration ledger (E00-S03-T03) and the migration + * advisory lock (E00-S03-T04). The failure diagnostic (E00-S03-T05) lands in + * a later story; until then the re-exports keep the driver reachable only + * from here — the isolation is real, not a placeholder. */ import { Pool } from 'pg'; @@ -20,3 +20,4 @@ import { Kysely, PostgresDialect } from 'kysely'; export { Pool, Kysely, PostgresDialect }; export { MigrationLedger, MIGRATION_LEDGER_TABLE } from './ledger.js'; +export { MigrationLock, MIGRATION_LOCK_KEY } from './lock.js'; diff --git a/packages/database-postgres/src/lock.ts b/packages/database-postgres/src/lock.ts new file mode 100644 index 0000000..88a3def --- /dev/null +++ b/packages/database-postgres/src/lock.ts @@ -0,0 +1,132 @@ +/** + * Migration advisory lock — [E00-S03-T04]. + * + * The advisory lock is the PostgreSQL-side guarantee that concurrent migration + * runners cannot run at the same time: the migration runner (built on this + * boundary in later stories — failure diagnostic E00-S03-T05, ready gate + * E00-S03-T06) takes the lock before applying migrations, so a second runner + * either waits (`acquire()`) or fails fast (`tryAcquire()`) while the first + * holds it. + * + * The lock lives in `database-postgres` — the single workspace package + * allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the + * database exclusively through the package-owned `pg` Pool, so no other + * package needs the driver to lock migration state. + * + * Lock model: + * - session-scoped advisory lock (`pg_advisory_lock`/`pg_try_advisory_lock` + * on the `bigint` key `hashtextextended($1, 0)`), so it lives exactly as + * long as the holding database session and no longer; + * - held on a dedicated connection checked out of the caller's pool + * (`pool.connect()`), so the lock never taints a pooled connection that + * other queries share; + * - released explicitly by `release()` (`pg_advisory_unlock` first, then + * the client goes back to the pool), and released implicitly by the + * server when the holding session ends — the issue's rollback note: "the + * lock releases when the runner exits". + * + * The lock key is always passed as a bound parameter (`$1`) — the SQL never + * interpolates it, so the only interpolated value is the `$1` placeholder + * itself. `hashtextextended($1, 0)` maps the key string to a stable `bigint` + * advisory-lock key: deterministic for a given database, identical across all + * sessions, so every runner contends on the same lock. + */ + +import type { Pool, PoolClient } from 'pg'; + +/** Namespace string for the migration advisory lock. */ +export const MIGRATION_LOCK_KEY = 'personal-blog-migrations'; + +/** + * The migration advisory lock: serializes migration runners so two runners + * can never apply migrations at the same time. Instances are cheap and share + * the caller's pool; the lock performs no ledger writes (migration ledger is + * E00-S03-T03) and no diagnostics (E00-S03-T05). + */ +export class MigrationLock { + private readonly pool: Pool; + private client: PoolClient | null = null; + private held = false; + + /** @param pool The package-owned PostgreSQL pool (`pg.Pool`). */ + constructor(pool: Pool) { + this.pool = pool; + } + + /** True while this instance holds the advisory lock. */ + get isHeld(): boolean { + return this.held; + } + + /** + * Acquires the migration advisory lock, blocking until it is free — a + * second runner waits here while the first holds the lock. Idempotent: + * acquiring an already-held instance is a no-op. The lock is held on a + * dedicated connection until `release()` (or until the session ends — e.g. + * the runner exits and the pool closes its connections). + */ + async acquire(): Promise { + if (this.held) return; + const client = await this.pool.connect(); + try { + await client.query( + 'SELECT pg_advisory_lock(hashtextextended($1, 0))', + [MIGRATION_LOCK_KEY], + ); + } catch (error) { + client.release(); + throw error; + } + this.client = client; + this.held = true; + } + + /** + * Non-blocking acquire: resolves `true` when this runner got the lock and + * `false` when another runner holds it — a second runner fails fast while + * the first holds the lock. Also resolves `true` when this instance + * already holds the lock. + */ + async tryAcquire(): Promise { + if (this.held) return true; + const client = await this.pool.connect(); + try { + const result = await client.query<{ acquired: boolean }>( + 'SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired', + [MIGRATION_LOCK_KEY], + ); + const acquired = result.rows[0]?.acquired === true; + if (acquired) { + this.client = client; + this.held = true; + return true; + } + client.release(); + return false; + } catch (error) { + client.release(); + throw error; + } + } + + /** + * Releases the advisory lock: unlocks it on the holding session + * (`pg_advisory_unlock`) and returns the connection to the pool, so the + * lock is gone before any other query could reuse that pooled connection. + * Idempotent: releasing an instance that does not hold the lock is a no-op. + */ + async release(): Promise { + if (!this.held || this.client === null) return; + const client = this.client; + this.client = null; + this.held = false; + try { + await client.query( + 'SELECT pg_advisory_unlock(hashtextextended($1, 0))', + [MIGRATION_LOCK_KEY], + ); + } finally { + client.release(); + } + } +}