feat(lock): distributed lock seam + pg_advisory_lock for a remote backend (spec 21) - #112
Merged
Conversation
…kend (spec 21) The concurrency spine's local gofrs/flock cannot serialize two developers on two machines mutating the SAME remote backend's ledger rows / provisioning / port allocation — the central unsolved problem for a team/cloud shared backend (spec 21). This lands the correctness gate: a Locker seam whose remote implementation is a session-scoped pg_advisory_lock on the shared cluster Postgres (Q-REMOTE-LOCK RESOLVED — the DB we already run is the coordinator, so zero new infra and no daemon; session-scoped means it auto-releases on a crash so another machine reconciles cleanly). - internal/lock/distlock.go: the Locker interface, FileLocker (today's flock, verbatim), and LockerFor(remote, subject, path, connect) — remote+connector → PGLocker, else the local flock (a remote backend with no reachable cluster yet degrades safely to single-machine correctness). internal/lock stays a dependency-free leaf (plain bool, no docker import → no cycle). - internal/lock/pglock.go: PGLocker takes a session-scoped pg_advisory_lock keyed by AdvisoryKey (FNV-64a subject hash → deterministic bigint, so every client of one cluster hashes the same subject to the same key). It polls pg_try_advisory_lock (not the blocking form) to honor ctx cancel/timeout, and opens a fresh session per WithLock so the lock never outlives its section. AdvisoryConn is the injectable session seam (transaction-pooling pgbouncer breaks session advisory locks — documented). - internal/provision/advisory.go: the pgx/v5-backed AdvisoryConn + PGLockConnector (the production session), kept out of internal/lock to preserve the leaf. Tests (offline, no live PG): AdvisoryKey determinism, acquire/run/release order, fn-error release, connect/try errors, ctx-timeout on a held key, LockerFor selection, FileLocker, and a 50-goroutine mutual-exclusion test over an in-memory advisory server (compare-and-set) run under -race. This is the foundation; wiring the orchestrator's ~25 lock call sites through the selected Locker + reaching the remote cluster over an SSH forward are the follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First increment of the multi-cloud / remote team backend (spec 21, the full-scope path). This lands the correctness gate — the one thing spec 21 calls "the central unsolved problem."
Why
The concurrency spine's local
gofrs/flockis a lockfile underXDG_RUNTIME_DIR— it cannot serialize two developers on two machines mutating the same remote backend's ledger rows / provisioning / port allocation. Without a distributed lock, twoups from two laptops raceCREATE ROLE, port allocation, and ref rows with no protection.What (Q-REMOTE-LOCK, RESOLVED)
A
Lockerseam whose remote implementation is a session-scopedpg_advisory_lockon the shared cluster Postgres — the DB we already run is the coordinator, so zero new infra and no daemon, and session scope means the lock auto-releases on a crash (akill -9on one machine lets another machine's next command reconcile cleanly).internal/lock/distlock.go—Lockerinterface,FileLocker(today's flock, verbatim),LockerFor(remote, subject, path, connect): remote+connector →PGLocker, else the local flock. A remote backend with no reachable cluster yet degrades safely to single-machine correctness.internal/lockstays a dependency-free leaf (plainbool, no docker import → no cycle).internal/lock/pglock.go—PGLockerhashes the subject to a deterministicbigint(AdvisoryKey, FNV-64a) so every client of one cluster agrees on the key; pollspg_try_advisory_lock(not the blocking form) to honor ctx cancel/timeout; opens a fresh session perWithLock. Documents the transaction-pooling-pgbouncer gotcha.internal/provision/advisory.go— the pgx/v5 session (AdvisoryConn+PGLockConnector), kept out ofinternal/lockto preserve the leaf.Tests (offline, no live Postgres)
AdvisoryKeydeterminism; acquire/run/release ordering; fn-error still releases; connect/TryLock error paths; ctx-timeout on a permanently-held key;LockerForselection;FileLocker; and a 50-goroutine mutual-exclusion test over an in-memory advisory server (compare-and-set) under-race.Follow-ups (not in this PR)
Threading the orchestrator's ~25
lock.WithLockcall sites through the selectedLocker, and reaching the remote cluster's Postgres from the laptop (SSH local-forward) so the connector is non-nil on a remote backend. Then per-user tenant isolation + the remote-authoritative ledger.🤖 Generated with Claude Code