[E00-S03-T04] Advisory lock prevents concurrent migration runners #393
No Reviewers
Labels
Clear labels
agent/analyst-drafted
agent/analyst-drafted
needs/human-decision
needs/human-decision
needs/security-review
needs/security-review
tier/t0
tier/t1
tier/t2
tier/t3
kind
bug
kind
bug
kind
epic
kind
epic
kind
initiative
EPPP programme initiative
kind
story
kind
story
kind
task
EPPP engineering card/task decomposed from a story
kind
toil
kind
toil
loop
1
loop
1
loop
2
loop
2
loop
3
loop
3
risk
agent-full
risk
agent-full
risk
human-gated
risk
human-gated
risk
human-only
risk
human-only
size
l
size
l
size
m
size
m
size
s
size
s
status
blocked
status
blocked
status
done
Workflow: Done
status
in-progress
status
in-progress
status
proposed
status
proposed
status
ready
status
ready
status
review
status
review
stream
checkout
stream
checkout
stream
onboarding
stream
onboarding
stream
platform
stream
platform
trivial — implementer only, auto-merge
standard — implementer + reviewer + tester
complex — security if triggered, human merge
critical — full chain + security, human merge
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Fabrika/PersonalBlog#393
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What changed
Implements [E00-S03-T04] Advisory lock prevents concurrent migration runners (#179): a session-scoped PostgreSQL advisory lock in the
database-postgrespackage that serializes migration runners, verified against a real database with a concurrent migration lock test.packages/database-postgres/src/lock.ts—MigrationLockover the package-ownedpgPool:acquire()takes the blocking, session-scoped advisory lock (SELECT pg_advisory_lock(hashtextextended($1, 0))) on a dedicated connection checked out of the pool (pool.connect()) — a second runner waits here while the first holds the lock;tryAcquire()is the non-blocking form (SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired) and resolvesfalsewhile another runner holds the lock — a second runner fails fast;release()unlocks the holding session (SELECT pg_advisory_unlock(hashtextextended($1, 0))) before disposing of the connection — on success it returns the connection to the pool, and on unlock failure (statement timeout / cancellation) it destroys the connection instead (client.release(error)), so a pooled connection is never reused while its session still holds the lock (reviewer finding F4); the server releases the lock when the holding session ends — the issue's rollback note ("the lock releases when the runner exits"). The lock key is the stable constantMIGRATION_LOCK_KEY = 'personal-blog-migrations', always a bound parameter ($1) — the SQL never interpolates it.isHeldgetter for callers.acquire()(security-review finding F2):acquire()memoizes the in-flight acquire inacquireInFlight— a concurrentacquire()on the same instance returns the same in-flight promise instead of checking out another connection, so exactly one connection is checked out and no locked connection leaks. The memo is cleared once the acquire settles.tryAcquire()/release()paths unchanged.packages/database-postgres/src/index.ts— driver boundary re-export:MigrationLock+MIGRATION_LOCK_KEYare re-exported from the package entrypoint, so no other workspace package needs thepgdriver to lock migration state (isolation E00-S03-T02 stays intact — the module imports onlyimport type { Pool, PoolClient }and lives inside the owner package).tests/database-postgres-lock.test.mjs— suite locking in all reworked acceptance criteria + the F4 failure path: static assertions on the committed source (blocking and non-blocking acquire SQL keyed by a bound parameter, session-scoped release ordering, release() destroys the connection on unlock failure, re-entrant in-flight acquire memoization, driver-boundary re-export, CI enforcement) each backed by a mutation probe proving non-vacuousness. The real-stack probe is the authoritative behavioral check (F3): it starts the committed composedbservice (isolated projecteppp-lock-probe+ host port 55433), executes the committed lock module with two runner instances against the real database, and asserts the first runner holds exactly one granted advisory lock, the second runner'stryAcquire()fails fast and its blockingacquire()waits (server-reportedwait_event advisory) then acquires after release, no advisory lock remains after both release, a session that closes (the runner exits) releases the lock, and two concurrentacquire()calls on one instance check out exactly one connection (pool.totalCountstays 1) with the lock granted once and nothing left after release (F2, behavioral). A new deterministic stub-pool behavioral probe (no database/Docker needed; runs on Node ≥ 23.6, i.e. the CI Node 24) drives the committedrelease()control flow and proves the F4 contract: unlock rejects →client.release(error)destroys the connection; unlock succeeds → plainclient.release()returns it to the pool. The docker-gated probe skips cleanly where Docker or Node ≥ 23.6 (type stripping) is unavailable..gitea/workflows/ci.yml— newdatabase-postgres-lockjob runsnode --test tests/database-postgres-lock.test.mjson every PR so the advisory-lock criterion gates merges. Additive only (no existing job modified, same action majors, nosecrets:context, no untrusted interpolation). Explicit human maintainer sign-off recorded (kpcto, issue #179 comment) — F1 resolved.docs/development/non-container.mdpackage table andpackages/database-postgres/package.jsondescription updated;.gitignoreignores the transient probe files the real-stack/behavioral probes write into the package (removed in theirfinally).Explicitly out of scope per the brief, not touched: migration ledger (E00-S03-T03), failure diagnostic (E00-S03-T05), ready-before-migrations gate (E00-S03-T06).
Security-review rework (findings F1–F4 on this PR)
database-postgres-lockjob is strictly additive and matches the security-reviewed #390/#391/#392 precedent. Human maintainer sign-off (kpcto) recorded on issue #179 — resolved.acquire()on the same instance leaks a locked connectionacquire()memoizes the in-flight acquire (acquireInFlight) and returns it on re-entry, so exactly one connection is checked out and no locked connection leaks. Locked in statically (criterion test + mutation probe) and behaviorally (real-stack probe:pool.totalCountstays 1 under two concurrentacquire()calls).release()returns the connection to the pool even when the unlock statement failsrelease()now destroys the connection on unlock failure (client.release(error)in the catch of thepg_advisory_unlockquery — the pool drops the client, ending the session and its lock) and keeps the plainclient.release()only on the success path, so a pooled connection is never reused while its session still holds the advisory lock. Locked in by a new static assertion + mutation probe (replacingrelease(error)withrelease()fails the criterion) and a deterministic stub-pool behavioral probe of the committed module's control flow (unlock rejects →release(error)destroy; unlock succeeds → plainrelease()).Criterion → test table
tests/database-postgres-lock.test.mjs— "the lock module exists in the driver-owner package and defines the migration lock key", "acquire() takes the blocking session-scoped advisory lock keyed by a bound parameter (a second runner waits)" (SELECT pg_advisory_lock(hashtextextended($1, 0))on a dedicatedpool.connect()connection, key bound as$1, never interpolated) and "tryAcquire() fails fast while another runner holds the lock (a second runner fails)" (SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired); mutation probes "removing pg_advisory_lock …", "removing pg_try_advisory_lock …", "interpolating the lock key into the SQL …", "a placeholder lock module …"; real-stack probe — first runner holds exactly one granted advisory lock (pg_locksbaseline+1) and no lock remains after both runners releasetests/database-postgres-lock.test.mjs— real-stack probe: the second runner'stryAcquire()resolvesfalsewhile the first holds (fails), its blockingacquire()is observed waiting (server-reportedwait_event advisoryinpg_stat_activity) and resolves once the first releases (waits), and a dedicated session that holds the lock then closes (the runner exits) releases it so a third runner acquires (rollback note); statically locked by "release() unlocks the holding session and returns the connection to the pool" (unlock beforeclient.release()) + mutation probe "removing pg_advisory_unlock …"acquire()calls on the same lock instance are re-entrant-safe — the in-flight acquire is memoized — so exactly one connection is checked out and no locked connection leakstests/database-postgres-lock.test.mjs— "concurrent acquire() on the same instance is re-entrant-safe (in-flight acquire memoized, exactly one connection)" (acquireInFlightfield, re-entry guard returns the in-flight acquire, memo cleared on settle) + mutation probe "removing the in-flight acquire memo makes the re-entrancy criterion fail"; real-stack probe — two concurrentacquire()calls on one instance leavepool.totalCountat 1 (exactly one connection checked out), the advisory lock granted once (pg_locksbaseline+1), and nothing left held after release (no locked connection leak)tests/database-postgres-lock.test.mjs— the docker-gated real-stack probe runs the committed lock module against the committed composedbservice and asserts every criterion behaviorally (see rows above); every static assertion has a mutation probe (8 probes: 7 source mutations + placeholder module) proving it fails on a violationdatabase-postgres-lockjob in.gitea/workflows/ci.yml; the additive workflow change requires an explicit human maintainer sign-off before mergetests/database-postgres-lock.test.mjs— "the advisory-lock criterion is enforced in CI" (root test glob covers the suite and.gitea/workflows/ci.ymlruns it);.gitea/workflows/ci.yml—database-postgres-lockjob runsnode --test tests/database-postgres-lock.test.mjson every PR; human maintainer sign-off on the workflow change recorded on issue #179 (kpcto) — F1 resolvedtests/database-postgres-lock.test.mjs— "release() destroys the connection when the unlock statement fails (never reuses a still-locked connection)" (static:client.release(error)in the catch of thepg_advisory_unlockquery, plainclient.release()only on the success path after the try/catch) + mutation probe "returning the connection to the pool on unlock failure makes the failure-path criterion fail" (replacing the destroy with a plain release fails the assertion) + "release() destroys the connection on unlock failure and returns it to the pool on success (behavioral probe)" — a deterministic stub-pool probe drives the committed module: unlock rejects →client.release(error)destroys the connection, unlock succeeds → plainclient.release()returns it to the poolTest plan executed
node --test tests/database-postgres-lock.test.mjs→ 18 tests, 16 pass / 0 fail / 2 skip on Node 22.23.2 (the docker-gated real-stack probe and the TS-stripping-gated behavioral probe skip on Node 22 per the suite's conservative>= 23.6gate; both run in CI on Node 24 — the behavioral probe needs no Docker).LOCK_RELEASE_FAILURE_PROBE_RESULT {"failurePath":{"rejected":true,"events":["unlock:fail","release(error)"],"destroyed":true,"stateCleared":true},"successPath":{"events":["unlock:ok","release()"],"destroyed":false,"stateCleared":true}}— on unlock failure the committedrelease()callsclient.release(error)(destroy; the pool drops the client so the session and its lock end) and rejects; on success it calls the plainclient.release()(return to pool). The mutation probe proves the static assertion is non-vacuous (replacing the destroy with a plain release fails it).node --test "tests/**/*.test.mjs"): 162 tests — 141 pass / 9 fail / 12 skip on Node 22.23.2; the 9 failures are pre-existing environment artifacts, verified identical on the pristine base commitdac3679in a clean worktree (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24 — andpnpmis not on PATH for the spawned-command tests):tests/frozen-install.test.mjs×5 andtests/root-commands.test.mjs×3 fail onpnpm: not found/engine gate,tests/node-engine.test.mjs×1 asserts the runtime is Node 24.x. CI runs Node 24 with corepack, where these pass (prior CI runs #87/#88 fully green on the same codebase).pnpm typecheck/pnpm buildover the workspace → exit 0 (both@personal-blog/database-postgresand the whole workspace);pnpm install --frozen-lockfile→ passes (supply-chain policy check clean, 19 entries).Risks / notes
pgthrough the package's own dependency links).-p eppp-lock-probe) and host port 55433 so it never collides with the compose-config suite's default-project containers, the ledger probe's project (eppp-ledger-probe)/port 55432, or the default 5432 binding when several suites run on the same host. The re-entrancy check uses a freshPoolsototalCount/idleCountare exact.release()unlocks before disposing of the connection — on success the client returns to the pool, and on unlock failure the connection is destroyed (client.release(error)) so a still-locked session is never reused (F4); if a runner exits without releasing, the server releases the lock when the session ends — the issue's rollback note ("the lock releases when the runner exits"). The lock performs no ledger writes (T03) and no diagnostics (T05) — both out of scope.release()control flow — the latter runs in CI on Node 24 without needing Docker, so the F4 contract is gated even where the docker-gated real-stack probe skips..gitea/workflows/ci.ymlchange is strictly additive (new job, no existing job modified) and matches the security-reviewed #390/#391/#392 precedent; per the review-checklist pipeline tripwire the human maintainer sign-off was required and is recorded on issue #179 (kpcto, comment) — resolved.Refs #179