ADR-0023: PostgreSQL Advisory Locks for Distributed Coordination¶
Status: Accepted
Date: 2026-07-27
Deciders: Library maintainers (architecture owner, migration owner)
This record explains why eventsource.locks provides exactly one distributed
mutual-exclusion primitive — PostgreSQLLockManager, built on PostgreSQL
session-scoped advisory locks — instead of a backend-agnostic protocol with
several implementations, and why that primitive looks the way it does: string
keys hashed to a 63-bit lock ID, one dedicated AsyncSession per held lock,
crash release delegated to the database's session lifecycle, and a client-side
poll loop standing in for a server-side lock timeout.
The motivating consumer is the live-migration tooling. MigrationCutover in
src/eventsource/migration/cutover.py wraps its whole cutover sequence in
lock_manager.acquire(migration_lock_key(tenant_id, "cutover"), timeout=...)
and uses try_acquire() / release() as a non-blocking readiness probe before
it commits to the operation. Everything below follows from that single use
case, and the scope limits it implies are stated explicitly in "When Not to Use
This".
The implementation lives in src/eventsource/locks/postgresql.py and is
re-exported from src/eventsource/locks/__init__.py; its behaviour is pinned by
tests/locks/test_postgresql_locks.py (pure-Python units: hashing, key
formatting, error and LockInfo shape) and
tests/integration/locks/test_postgresql_locks_integration.py (exclusion,
timeout, cancellation-safe release, release_all, concurrency — requires a real
PostgreSQL server).
Status¶
Accepted — and retroactive. This record describes a decision that is
already in the code and shipped: PostgreSQLLockManager, LockInfo,
migration_lock_key, LockAcquisitionError, and LockNotHeldError are all
exported from src/eventsource/locks/__init__.py in the released 0.5.0 line,
and MigrationCutover already depends on them. Nothing here is a proposal; the
ADR exists to explain choices that were made incrementally and never written
down.
Amended by ADR 0029. The
names above now live at eventsource.adapters.postgresql.locks
(PostgreSQLLockManager) and eventsource.ports.locks (LockInfo,
migration_lock_key); LockAcquisitionError/LockNotHeldError moved to
eventsource.exceptions and now subclass EventSourceError.
eventsource.locks remains importable as a deprecated shim until 0.8.0. ADR
0029 adds a ports/locks.py Protocol pair (DistributedLock, LockRegistry)
this record did not anticipate, but the amendment is narrow: the Protocols
describe the shape of the dependency, not an equivalence of distributed
semantics, and this record's single-primitive argument and PostgreSQL-only
scope stand untouched. The Decision section below is unmodified.
The record is not frozen, though. Two parts of it have been touched since the original decision and may be touched again:
- The tracing integration was reworked from inheritance to composition
(
refactor: migrate tracing from inheritance to composition pattern), which is why the manager takes an injectedTracerand anenable_tracingflag rather than subclassing a traced base. That is the shape documented under "Tracing by Composition". - The PostgreSQL-only scope is the load-bearing constraint. It stands as long
as there is exactly one consumer with exactly one deployment story. If a
second backend ever becomes necessary, the right move is a new ADR that
supersedes this one and introduces a
LockManagerprotocol — not an edit to this record. See "Future Considerations".
Supersedes: nothing. Superseded by: nothing.
The nearest neighbour is ADR-0009: Multi-Instance Subscription Coordination, which answers the same family of question — how do several instances of the same process agree on who does a once-only job — but resolves it differently, with a pluggable coordination backend rather than a hardcoded one. Read the two together; the contrast between them is deliberate and is discussed under "A Backend-Agnostic LockManager Protocol with SQLite/In-Memory Implementations". ADR-0003's optimistic-concurrency model is a different layer entirely, and "Why ADR-0003 Does Not Cover This" says why.
Context¶
The Problem: Cross-Instance Mutual Exclusion During Migration Cutover¶
The live-migration tooling in src/eventsource/migration/ moves one tenant's
events from a shared store to a dedicated one while the application keeps
serving traffic. The dangerous moment is cutover: the point where routing
flips from the source store to the target store. CutoverManager.execute_cutover()
(in src/eventsource/migration/cutover.py) pauses writes for the tenant,
checks the sync lag one last time against
config.cutover_max_lag_events, moves the tenant's routing state to
CUTOVER_PAUSED, switches routing to the target store, and resumes writes —
rolling the whole thing back if any step fails or the pause budget
(timeout_ms, 100ms by default) is exceeded. That sequence is only correct if
exactly one actor is running it for a given tenant.
Nothing in the library's normal concurrency machinery prevents two actors from running it at once. A typical deployment runs several identical application instances, in separate processes on separate hosts. An operator triggering a cutover from an admin endpoint hits whichever instance the load balancer picks; a retry after a slow response can hit a second one while the first is still mid-sequence. Two concurrent cutovers for the same tenant can interleave their pauses, lag checks, routing-state transitions, and rollbacks: one actor's rollback can undo the other's routing switch, or a routing switch can land on the strength of a lag check the other actor's in-flight sync has already invalidated. The result is routing that points at a store nobody verified — silent divergence between where the application believes events are going and where they actually land.
So the requirement is mutual exclusion across processes, keyed by tenant.
asyncio.Lock handles the intra-process case only; that is why
PostgreSQLLockManager uses one internally (self._lock) purely to guard its
own _held_locks dict, not to provide the guarantee this ADR is about.
Requirements¶
The cutover use case fixes five requirements. Each one is visible in the shape of the API that came out of it, so they are worth stating precisely before the decision section explains how advisory locks satisfy them.
Single-holder guarantee. For a given key, at most one holder across every
process, host, and event loop that talks to the same database. The scope of the
guarantee is the database, not the process: two instances of the application
that share a PostgreSQL server must contend with each other, and the key is what
they contend on. Keys are strings (migration_lock_key(tenant_id, "cutover")
produces "cutover:{tenant_id}"), so exclusion is per tenant and per
operation — a cutover for tenant A must not block a cutover for tenant B, and a
"migration:" lock and a "cutover:" lock on the same tenant are distinct.
Crash release. If the holder is SIGKILLed, loses its network, or its
container is evicted mid-cutover, the lock must become available again without
operator intervention. This is a hard requirement rather than a nicety, and it
is the one that most constrains the design: it rules out any scheme where the
lock is a durable record that only its owner can clear. A cutover is a rare,
high-attention operation, and a permanently wedged lock on a tenant — which
would block every future cutover attempt for that tenant until someone deleted a
row by hand — is a worse operational outcome than a briefly duplicated attempt.
Crucially, the requirement is for release without a lease: no TTL to tune, no
heartbeat to keep alive, no reaper to deploy.
Non-blocking probe. CutoverManager.validate_cutover_readiness() needs to
answer "is anyone else already doing this?" without waiting at all. It is a
pre-flight check run alongside a sync-lag check and a routing-state check, and
its whole value is that it returns immediately with a reason string
("Cutover lock is already held by another process") rather than stalling a
readiness endpoint. The code acquires and immediately releases:
lock_info = await self._lock_manager.try_acquire(lock_key)
if lock_info is None:
return False, "Cutover lock is already held by another process"
# Release the lock immediately - we just wanted to check availability
await self._lock_manager.release(lock_key)
That is a probe, not a claim: readiness is advisory, and the answer can be stale
the instant it is returned. The requirement it imposes is that the primitive
offer a try-style acquisition that reports failure as a value (None) rather
than as an exception, and a matching manual release() — which is why the
module has two acquisition surfaces rather than only the context manager.
Bounded wait. The blocking path must not hang forever. CutoverManager
defaults lock_acquisition_timeout to 0.5 seconds, passes it straight through
as acquire(lock_key, timeout=self._lock_acquisition_timeout), and catches the
resulting LockAcquisitionError to return a failed CutoverResult with
success=False, duration_ms=0.0, and rolled_back=False. Contention is an
expected outcome to report, not an exception to crash on. Note the scale: the
lock timeout is 500ms while the cutover pause budget itself defaults to 100ms,
so the wait for the lock is deliberately allowed to dominate — waiting briefly
for a competitor to finish is fine, waiting unboundedly is not. The requirement
is therefore a timeout, not merely a try: the caller wants a short,
tolerant wait, not an instant give-up.
Async, non-blocking I/O. Per ADR-0001, every
I/O interface in this library is async-first. The lock primitive has to await
rather than block the event loop — including while it waits out contention,
which is why the timeout path sleeps with asyncio.sleep(retry_interval) rather
than occupying the loop. It also has to be cancellation-safe. acquire() is an
@asynccontextmanager that yields a LockInfo and releases in a finally, so
the lock is dropped and its session closed whether the body returns, raises, or
is cancelled — the property exercised by the cancellation-safe release case in
tests/integration/locks/test_postgresql_locks_integration.py. The failure
paths inside _acquire_lock() close the session too, so a timeout or a database
error does not leak a connection.
Two requirements that are conspicuously absent are worth naming, because
leaving them out is what makes the simple design sufficient: there is no
requirement for fairness (nothing guarantees the longest waiter wins — the poll
loop is a free-for-all), and no requirement for reentrancy across managers
(_held_locks is per-PostgreSQLLockManager bookkeeping, and is_held()
answers only "does this manager hold it", never "is anyone holding it").
Forces¶
The requirements above could be met by several very different designs. What narrowed the field was a set of pressures specific to where this primitive is used — a rare, operator-triggered operation inside a library that tries hard not to impose infrastructure on its users.
PostgreSQL is already a dependency of the only consumer. The migration
tooling is PostgreSQL-shaped from top to bottom: CutoverManager is
constructed with a PostgreSQLLockManager (the type is imported under
TYPE_CHECKING in src/eventsource/migration/cutover.py and named directly in
the constructor signature), and the routing state it flips lives in a database
the caller is already talking to over SQLAlchemy. There is no deployment in
which someone runs a live cutover without a PostgreSQL connection in hand.
That makes a PostgreSQL-based lock free in the only sense that matters
operationally — it adds no new runtime requirement. It also makes the
constructor honest: PostgreSQLLockManager(session_factory) takes an
async_sessionmaker[AsyncSession] the caller already has, and asks for nothing
else.
No new infrastructure. The alternative — Redis, etcd, ZooKeeper, a Kubernetes lease — means a new service to deploy, secure, monitor, upgrade, and reason about the partition behaviour of. For one operation that a given tenant goes through exactly once. That trade is bad on its own terms, and it also cuts against the library's optional-dependency policy (ADR-0015: Optional Dependency Extras), which exists so users are not made to install and run what they do not use. Making a coordinator mandatory for a rare admin operation would be the sharpest possible violation of that principle. Note that this force is about the hard requirement, not about capability: nothing stops a user from coordinating cutovers some other way; the library simply declines to require it.
Connection-pool cost is real, and has to be accepted knowingly. PostgreSQL
advisory locks are session-scoped, which means a held lock occupies a session —
and therefore a pooled connection — for its entire lifetime. The implementation
does not hide this: _acquire_lock() calls self._session_factory() itself,
returns the live session, and acquire() parks it in
_held_locks[key] = (session, lock_id) until release closes it. try_acquire()
does the same, which is why its docstring puts the release obligation on the
caller. N concurrent locks means N concurrent connections, and the class
docstring says so outright: "Each lock uses a dedicated session/connection.
Consider connection pool sizing when using multiple concurrent locks." For a
handful of tenant cutovers this is negligible. For a lock taken on a
per-request or per-event basis it would exhaust a pool, and "When Not to Use
This" draws that line explicitly.
Session semantics cut both ways. Binding the lock to a session is precisely
what buys crash release for free. The server drops every advisory lock held by
a connection when that connection goes away, so there is no lease to renew, no
TTL to tune, no heartbeat task to keep scheduled on a busy event loop, and no
reaper process to deploy and monitor. An entire category of distributed-systems
bookkeeping simply does not exist here. The price is threefold: the dedicated
connection above; a lock lifetime decoupled from transactions, so it neither
participates in nor is released by a COMMIT or ROLLBACK (which is what makes
it usable across the multi-step cutover sequence in the first place, but also
means a rollback does not drop the lock); and the fact that the guarantee is
only as strong as the connection's liveness detection — a wedged TCP connection
that the server has not yet reaped keeps the lock held for as long as it takes
tcp_keepalives to notice.
Cheapness matters more than sophistication. These forces all point the same way because the operation is rare and the failure it prevents is catastrophic but improbable. The design budget for such a feature is small: it should be easy to read, have no moving parts to operate, and be obviously correct rather than cleverly correct. That is why the whole module is roughly 500 lines with four SQL statements in it, and why the timeout is a poll loop rather than anything more subtle.
Why ADR-0003 Does Not Cover This¶
The library already has a concurrency-control story, and it is the first thing
a reader reaches for here: optimistic locking on aggregate streams.
EventStore.append_events() takes an expected_version alongside the events,
and the store raises OptimisticLockError(aggregate_id, expected_version,
actual_version) if another writer got there first. That mechanism is
load-bearing everywhere — it is what makes concurrent writes to the same
aggregate safe without any coordination service at all. So the question is
fair: why is it not enough for cutover?
Because it protects a different unit, in a different style, over a different span of time:
Stream optimistic concurrency (expected_version) |
Advisory lock (PostgreSQLLockManager) |
|
|---|---|---|
| Unit protected | One aggregate's event stream, identified by aggregate_id |
An arbitrary named operation, identified by a string key such as cutover:{tenant_id} |
| Style | Optimistic — let both actors proceed, detect the conflict at write time | Pessimistic — stop the second actor before it starts |
| Span | A single append_events() call |
A multi-step sequence spanning many calls, several components, and a deliberate pause |
| On conflict | The loser's write is rejected with OptimisticLockError; it re-reads and retries |
The loser never enters the critical section; it waits, or reports contention |
| Failure prevented | A lost update within one stream | Two actors interleaving the steps of one operation |
Cutover is not an append. CutoverManager._execute_cutover_locked() pauses
writes for the tenant, reads the sync lag from the SyncLagTracker, moves the
tenant's routing state to CUTOVER_PAUSED, switches routing to the target
store, marks the migration state MIGRATED, and resumes writes — rolling all
of it back if any step fails or the pause budget is blown. Its correctness
depends on no one else being inside that sequence, not on any single write
landing at an expected version.
Two details make the gap concrete. First, the state the sequence guards is not
an event stream at all: TenantRoutingRepository exposes set_routing() and
set_migration_state() as plain upserts, with no version column and no
compare-and-set. There is no expected_version to pass, because routing is not
event-sourced. Second — and this is the part that would remain true even if
routing rows did carry versions — a version check would fire too late. Both
actors would already have paused writes for the tenant and validated lag
against a state the other was concurrently mutating; the loser would then find
its final routing write rejected and roll back, potentially undoing a
transition the winner was relying on. The damage is done in the steps before
the conflicting write, so detecting it at the write is the wrong instrument.
The conflict has to be prevented up front, which is the definition of a
pessimistic, operation-level mutex.
The two mechanisms are therefore complementary rather than competing, and they coexist during a migration without interacting: optimistic concurrency guards per-stream integrity on every append, including the appends still flowing through dual-write while the migration runs; the advisory lock guards whole-operation exclusivity for the rare administrative sequence that reconfigures where those appends go. Neither substitutes for the other, and the choice between them is settled by the unit you need to protect — a stream, or a procedure.
A note on the reference. ADR-0003 is cited here by number as the record for
the optimistic-locking contract, but that record has not been written: there is
no 0003-*.md in docs/adrs/, and docs/adrs/index.md lists the number as
planned rather than published. Until it exists, the authoritative description
of the contract is the append_events() docstring in
src/eventsource/stores/interface.py and the OptimisticLockError definition
in src/eventsource/exceptions.py. Whoever writes ADR-0003 should treat this
section as the boundary already drawn from the other side, and stay consistent
with it.