feat: add migration advisory lock to database-postgres (E00-S03-T04)
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user