feat: add migration advisory lock to database-postgres (E00-S03-T04)

This commit is contained in:
implementer
2026-08-30 00:08:57 +00:00
parent 4552ca175e
commit 3f80063303
5 changed files with 142 additions and 8 deletions
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+5 -4
View File
@@ -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';
+132
View File
@@ -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<void> {
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<boolean> {
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<void> {
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();
}
}
}