[E00-S03-T04] Advisory lock prevents concurrent migration runners #179
Closed
opened 2026-08-27 00:07:22 +00:00 by kpcto
·
29 comments
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
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Fabrika/PersonalBlog#179
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.
Intent
Use an advisory lock so concurrent migration runners cannot run at the same time.
Acceptance criteria
acquire()waits, non-blockingtryAcquire()fails fastacquire()calls on the same lock instance are re-entrant-safe — the in-flight acquire is memoized (or a second concurrent acquire throws) — so exactly one connection is checked out and no locked connection leaksdatabase-postgres-lockjob in.gitea/workflows/ci.yml; this additive workflow change requires an explicit human maintainer sign-off (recorded on this issue or PR #393) before mergeExplicitly out of scope
Test plan
Rollback note
Owning stream
platform
Risk quadrant
agent-full
status/readymay only be applied by a human maintainer.Refs #179, review of PR #393 @
a2b9843.Refs #179, review of PR #393 @
dac3679.Refs #179, review of PR #393 @
dac3679.I approve the new additive
database-postgres-lockCI job in.gitea/workflows/ci.yml: it is strictly additive (no existing job modified), uses the same action majors already in the file (actions/checkout@v4,actions/setup-node@v4), has nosecrets:context and no untrusted${{ }}interpolation (staticrun:steps only), and runs a frozen-lockfile install. It matches the merged, security-reviewed precedents #390/#391/#392. I accept this merge gate and its scope (now an explicit acceptance criterion on this issue).Security review of PR #393 (
feature/179) for issue #179 has started. Findings and the final verdict (approve/request-changes) will follow in a closing comment on this issue. This comment is procedural — no verdict yet.Security review of PR #393 — verdict: approve
Reviewed at head
cb1b161(F4-fix commit), basemain@4552ca1(current tip), 7 changed files, all inside the brief's scope. This closes the third review round: prior verdicts wererequest-changesata2b9843(F1/F2/F3, comment 3024) and atdac3679(F1 open + new F4, comment 3041). All four findings are verifiably resolved at head; no new blockers or shoulds found.Prior findings — re-verification at head
.gitea/workflows/ci.yml): RESOLVED. Human maintainer sign-off recorded on this issue (kpcto, comment 3055) — kpcto is the issue author and the only non-bot-*account in the thread, satisfying the criterion "recorded on this issue or PR #393". Verified the sign-off covers the exact workflow content at head: since the signed-off headdac3679, onlylock.tsand the test file changed (git diff dac3679..cb1b161 --stat); the additivedatabase-postgres-lockjob is byte-identical (strictly additive, samecheckout@v4/setup-node@v4majors as the existing jobs, nosecrets:context, no${{ }}interpolation, frozen-lockfile install). Tripwire satisfied, approve no longer gated.acquire()): RESOLVED (verified in code).acquire()memoizes the in-flight acquire (acquireInFlight,lock.ts:80-87); the memo is assigned synchronously before the first await, so concurrent callers share one connection; cleared infinallyon settle (rejection included). Locked in by static assertion + mutation probe + real-stack probe (pool.totalCountstays 1).release()returned a still-locked connection to the pool on unlock failure): RESOLVED (verified in code).release()now destroys the connection on unlock failure —client.release(error as Error)in the catch (lock.ts:155-163) — and returns it to the pool only on the success path (lock.ts:164). Triple-locked: static assertion (suite test attests/database-postgres-lock.test.mjs:188-211), mutation probe (:809-816), and the deterministic stub-pool behavioral probe (:715-775).New findings
tests/packages/database-postgres/src/lock.ts:112-132:tryAcquire()does not memoize concurrent calls the wayacquire()does, so two concurrenttryAcquire()calls on one instance transiently check out two connections. No leak and no wrong result — the loser releases its connection and correctly resolvesfalse— so this is informational only. Optional fix: apply the same in-flight memo if future callers ever firetryAcquire()unawaited in a loop.No blockers, no shoulds. An
approveis permitted: every acceptance criterion maps to tests that fail without the change (criterion → test table in the PR body verified against the actual suite), scope is clean, and the one remaining nit does not gate.Checks (scanner + trace evidence)
gitleaks detect --source . --no-git --redactatcb1b161→no leaks found, exit 0 (~303.40 KB scanned). Theeppp:epppin the probe'sDATABASE_URLis the pre-existing, env-overridable dev-only compose default (compose.yaml:64-65onmain, same pattern as the ledger suite) — not introduced by this PR.osv-scanner --recursive .→No issues found, exit 0 (pnpm-lock.yaml, 19 packages).lock.tsis a library class over the caller's pool with no consumer yet (runner lands in T05/T06), so there is no default-allow path and no alternate entry point to bypass.pg_advisory_lock/pg_try_advisory_lock/pg_advisory_unlockoverhashtextextended($1, 0); the key is the compile-time constantMIGRATION_LOCK_KEYalways bound as$1and never interpolated (interpolation is mutation-probe-enforced).spawnSyncuses fixed argv arrays with no shell;JSON.parseis applied only to local tool output; no untrusted deserialization; no user-influenced outbound URLs (no SSRF).agents/untouched; the only pipeline change is the signed-off additive CI job.node --test tests/database-postgres-lock.test.mjsatcb1b161(Node 22.23.2) → 18 tests, 16 pass / 0 fail / 2 skip (the docker-gated real-stack probe and the Node ≥ 23.6-gated behavioral probe skip by design), matching the PR's claim. CI run #89 atcb1b161: 6/6 jobs success, includingdatabase-postgres-lock(job 139).Verdict rationale
The lock implementation is security-clean (bound-parameter SQL only, no authz surface, no secrets, no injection/SSRF/deserialization vectors), the pipeline tripwire is satisfied by a recorded human decision, and all previously open findings are fixed and test-locked. Merge may proceed; the issue's
status/blocked/needs/human-decisiongates can be lifted by the maintainers.