feat: add migration advisory lock to database-postgres (E00-S03-T04)
This commit is contained in:
+3
-2
@@ -13,9 +13,10 @@ coverage/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Transient host-side probe file written by tests/database-postgres-ledger.test.mjs
|
# Transient host-side probe files written by the database-postgres test
|
||||||
# into the database-postgres package (removed in its finally block)
|
# suites into the package (removed in their finally blocks)
|
||||||
.ledger-probe-*.mjs
|
.ledger-probe-*.mjs
|
||||||
|
.lock-probe-*.mjs
|
||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.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. |
|
| `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/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. |
|
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
||||||
|
|
||||||
A dependency-boundary rule (`dependency-boundaries.json`, enforced by
|
A dependency-boundary rule (`dependency-boundaries.json`, enforced by
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
*
|
*
|
||||||
* This module is the driver boundary: it imports the PostgreSQL driver (`pg`)
|
* 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
|
* 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
|
* surface (E00-S03-T02), the migration ledger (E00-S03-T03) and the migration
|
||||||
* lock (E00-S03-T04) and failure diagnostic (E00-S03-T05) land in later
|
* advisory lock (E00-S03-T04). The failure diagnostic (E00-S03-T05) lands in
|
||||||
* stories; until then the re-exports keep the driver reachable only from here
|
* a later story; until then the re-exports keep the driver reachable only
|
||||||
* — the isolation is real, not a placeholder.
|
* from here — the isolation is real, not a placeholder.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
@@ -20,3 +20,4 @@ import { Kysely, PostgresDialect } from 'kysely';
|
|||||||
|
|
||||||
export { Pool, Kysely, PostgresDialect };
|
export { Pool, Kysely, PostgresDialect };
|
||||||
export { MigrationLedger, MIGRATION_LEDGER_TABLE } from './ledger.js';
|
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