Appearance
Background leader
internal/platform/db/leader elects one background leader per API deployment. Every API replica runs one Elector. The replica whose Elector holds a PostgreSQL session advisory lock is the leader. Every other replica is a standby and tries the lock again on a fixed interval.
This page is the reference for:
- The lock key
- Config
- Elector API
- Which loops run on the leader
- Readiness
- Metric
- Demotion and the overlap window
- Session connection requirement
- Bound on an orphaned lock session
See also:
- docs/reference/platform/db.md: the pools the composition root builds, one of which supplies the
Elector's connection.
The lock key
The leader lock is pg_try_advisory_lock(1886156915, 1), the two-int4 form of the advisory lock functions. 1886156915 is 0x706C7873, ASCII plxs. The held lock shows up in pg_locks as:
| Column | Value |
|---|---|
locktype | advisory |
classid | 1886156915 |
objid | 1 |
objsubid | 2 |
PostgreSQL keeps two-int4 keys and single-bigint keys apart: pg_locks shows a two-int4 key with objsubid = 2 and a bigint key with objsubid = 1. The transaction-scoped locks the repositories take through pg_advisory_xact_lock(bigint) therefore never collide with the leader lock, not even the bigint whose halves equal the pair, (1886156915::bigint << 32) | 1.
Advisory locks are scoped to one database. Run this query in the plexsphere database to find the leader's backend:
sql
SELECT pid, granted
FROM pg_locks
WHERE locktype = 'advisory'
AND classid = 1886156915
AND objid = 1
AND objsubid = 2
AND database = (SELECT oid FROM pg_database WHERE datname = current_database());One granted row is the leader's backend; pg_stat_activity maps its pid to a client address. No row means no replica leads at the moment.
The PostgreSQL manual covers advisory locks and the pg_locks view.
Config
leader.New(cfg) builds an Elector from a leader.Config:
| Field | Type | Default | Purpose |
|---|---|---|---|
Pool | *pgxpool.Pool | none, required | Supplies the connections the lock is tried on. New returns ErrNilPool when it is nil. |
CheckInterval | time.Duration | 5s (DefaultCheckInterval) | How often a standby tries the lock and the leader verifies it, and how long the leader's SELECT 1 may take before the check fails. Zero or a negative value uses the default. |
Logger | *slog.Logger | slog.Default() | Receives acquisition, loss, and failure lines. |
Registerer | prometheus.Registerer | nil | Receives the plexsphere_background_leader gauge. Nil registers no gauge. |
Elector API
Run
Run(ctx) takes part in the election until ctx is cancelled. It checks once when it starts and then every CheckInterval:
- On a standby, a check acquires a connection from
Pooland runspg_try_advisory_lockon it. When another session holds the lock, the connection goes back to the pool unchanged. When the lock is granted, the connection is hijacked out of the pool, the replica leads, and a new term begins. - A failed acquire or query is logged at
WARNasleader: acquire lock connection: <cause>. The replica stays a standby and tries again at the next check.Rundoes not return. - On the leader, a check runs
SELECT 1on the held connection. The check fails when the query returns an error and when it does not answer within oneCheckInterval. Either one demotes the replica, see Demotion and the overlap window. - When
ctxis cancelled,Runrunspg_advisory_unlockunder a 2-second context that outlivesctx, closes the connection, ends the term, and returns nil. A standby replica takes the lock at its next check, within oneCheckInterval.
When a panic unwinds Run, it ends the term with ErrLeadershipLost and closes the lock connection before the panic propagates. It does not unlock, because the panic may have left the connection mid-query; the server releases the lock when the session ends. When the replica led, it logs background leader lock abandoned after a panic at WARN. A replica whose election has stopped therefore never keeps a lock that no check verifies any more.
Never run two Run calls at once. Run may be called again after an earlier call returned or panicked, which is what the Supervisor of internal/platform/worker does when it restarts a worker.
Term
Term(parent) returns a context, its cancel function, and a bool. On the leader the bool is true and the context ends when parent ends or when the replica loses leadership, whichever comes first. After a loss, context.Cause on the context returns ErrLeadershipLost. On a standby the bool is false and the context is already cancelled. Always call the cancel function.
Do
Do(ctx, fn) runs one unit of background work on the leader. fn receives the term context, so it can stop when leadership ends.
| Situation | fn called | Result |
|---|---|---|
| Standby | no | (false, nil) |
Leader, leadership held until fn returns | yes | (true, err), where err is fn's error |
Leader, leadership lost while fn runs and ctx still live | yes | (false, nil), logged at INFO |
A demotion never counts as a failed tick.
IsLeader
IsLeader() reports whether the replica holds the lock. It reads an atomic and is safe to call from any goroutine.
Which loops run on the leader
The background-leader surface builds the Elector on the worker tier pool and publishes it on Runtime.Leader. It holds index 0 of the surface registry, so every later surface reads the elector while its own bundle is still being built and gates its loops on it. A boot without PLEXSPHERE_DSN builds no Elector, and such a boot wires no background loop at all, so nothing is left ungated by accident.
Every loop, and the Elector itself, runs as a supervised worker of internal/platform/worker, registered by name. The election is the worker background-leader, and each loop below is named in its table's first column. The supervisor starts all of them just before the API serves its routes.
Two launchers in ../../../cmd/plexsphere/background_loops.go place a loop. runLeaderTicker ticks a pass on a fixed cadence and asks Do before each tick. runWhileLeader hands a self-paced loop the context of one term and starts it again in the next one.
These tick on the leader through runLeaderTicker:
| Loop | Pass |
|---|---|
invitations-expire | Expires the elapsed pending invitations. |
approvals-expire | Expires the proposals past their deadline. |
actions-timeout | Times out the expired live executions. |
access-session-sweep | Revokes the expired and idle sessions. |
auth-state-sweep | Drains the elapsed sign-in states, browser sessions and device authorizations. |
rate-budget-sweep | Reclaims the rate-budget token buckets idle for longer than an hour. |
sse-nonce-sweep | Deletes the signed-event-bus nonces last seen before the freshness window, once per PLEXSPHERE_SSE_NONCE_TTL. |
credentials-sweeper | Expires the credentials past their TTL. |
cloud-credentials-sweeper | Expires the cloud credentials past their TTL. |
management-fleet-reconcile | Places Projects and converges cluster packages. |
provisioning-broker-reconcile | Reconciles the declared provisioned resources. |
artifacts-refresh | Re-indexes the artifact catalogue. |
blueprint-catalogs-tracking | Re-imports the track-tag catalog sources. |
peers-endpoint-sweeper | Tombstones the endpoint observations past TTL. |
reachability-evaluator | Runs the stale and the recovery pass over Nodes. |
These run while leader through runWhileLeader, because they pace themselves instead of ticking:
| Loop | Pass |
|---|---|
audit-archiver | Drains the audit chains to the object store. |
authz-outbox-consumer | Mirrors the outbox rows into SpiceDB. |
These stay on every replica:
| Loop | Why |
|---|---|
capacity-collector, the capacity collector's sampling loop | Every replica samples and tracks the same hysteresis. Only the audit row for a crossing is gated, through capacity.WithCrossingGate. |
observability-routing-consume, the observability routing consume loops | A durable spreads its deliveries across every puller bound to it, so each replica drains its share. Only the durable removals are gated, through services.WithDurableDeletion. |
sse-relay and sse-signing-key-rotation-watcher, the signed event bus relay and the signing-key rotation watcher | The FOR UPDATE SKIP LOCKED lock on the relay's cursor row serialises the ticks across replicas, so a leader would only add a handover delay. The watcher runs on every replica because each replica has its own in-memory key cache to invalidate. |
peers-relay-assigner, the peers relay-assigner heartbeat | It re-flips a local readiness flag and takes no write. |
A gate that is given no predicate stays open, so a single-replica deployment records every crossing and removes every orphaned durable.
The boot-time passes are not gated. A sweep that drains the rows which fell due while the process was offline runs on every replica before it reports ready, and the compare-and-set writes those passes take keep the outcome correct whichever replica gets there first.
Readiness
Seven of the ticked loops carry a /readyz probe: invitations-expire, approvals-expire, access-session-sweeper, actions-timeout, management-fleet-reconcile, provisioning-broker-reconcile, and artifacts-refresh. A probe reports the outcome of the last tick its loop ran on this replica, and it runs no pass of its own.
| Replica state | What the probe reports |
|---|---|
| Booted, no tick yet | The boot pass, which gates readiness before the API serves its routes |
| Leader, last tick succeeded | Pass |
| Leader, last tick failed | That tick's error, until a later tick succeeds |
| Standby | Pass, because a standby runs no pass and has no outcome of its own |
A demoted leader's probe clears on its first standby tick, so a replica that stops leading does not keep reporting the error of the last pass it ran. None of the seven is boot-latched: a failing tick flips the leader to HTTP 503 and takes it out of its Service until a later tick succeeds.
A pass that panics counts as a failed tick, and the loop keeps ticking. The replica logs background loop tick panicked at ERROR with the loop, panic, and stack attributes, and the loop's probe reports plexsphere: background loop "<loop>": tick panicked: <value> until a later tick succeeds. A loop without a probe leaves only that line and the WARN line every failed tick writes.
A sweep probe reports the outcome of a tick, so it cannot see a loop that has stopped ticking altogether. A loop that stops after exhausting its restart budget is reported by the workers probe instead, see Background workers.
The probe name access-session-sweeper differs from its loop name access-session-sweep; the other six match.
Metric
| Metric | Type | Labels | Value |
|---|---|---|---|
plexsphere_background_leader | gauge | none | 1 while the replica leads, 0 otherwise |
Summed over all replicas of a deployment, the gauge is 1 in steady state. It is 0 while no replica holds the lock and can be 2 for the length of the overlap window below.
New reuses a gauge already registered on the same Registerer, so two electors that share a registry write to one series. Tests that run two electors in one process give each its own prometheus.NewRegistry().
Demotion and the overlap window
The server drops a session advisory lock when the session ends: a terminated backend, a server restart, or a broken connection. The leader notices at its next check, when SELECT 1 fails or stays unanswered for one CheckInterval. It then:
- ends the term with cause
ErrLeadershipLost, which cancels everyTermcontext and everyDoin flight, turnsIsLeaderfalse, and sets the gauge to0; - closes the connection;
- logs a
WARNline with the ping error.
The replica is then a standby and tries the lock again at its next check.
The timeout covers the connection that breaks without the client learning it. After a network partition, or a failover to a new primary, SELECT 1 gets no answer and the kernel retransmits it for minutes before it reports the socket broken. An unbounded check would hold the term open for that whole time, while the server has already let another replica take the lock.
A new leader can start before the old one notices, so leadership can overlap for at most one CheckInterval until the old leader's next check, one more for a SELECT 1 that never answers, and one tick that was already running. The Elector does not fence writes made in that window. Work run on the leader has to stay correct when two replicas run it at once, for example by writing with compare-and-set updates.
Session connection requirement
The lock lives on one database session, held on one connection that the Elector hijacks out of the pool. The pool no longer tracks that connection, so MaxConnLifetime, idle recycling, and pool health checks never close it while it holds the lock. A connection left in the pool would be retired at MaxConnLifetime, and the lock would go with it.
The hijack happens only once the lock is granted. A standby's check borrows a connection from the pool and releases it again when the lock is refused, so an election that is refused every few seconds for the life of the process costs the pool nothing. Hijacking before the outcome is known would destroy one pooled connection per check, and the pool would open a fresh backend to replace each one.
While a replica leads it keeps that one connection open.
Bound on an orphaned lock session
The session that carries the lock takes an idle_session_timeout of four CheckIntervals, set on the connection as soon as the lock is granted. The leader's SELECT 1 runs once per interval, so a leader that is alive never comes close to it.
The bound matters when a leader's process stops without closing the connection: a node that loses power, a hard kill, a partition. The server-side backend then sits waiting for bytes that never come, and it holds the lock while it waits. TCP keepalives eventually tear it down — with Linux defaults, hours later. Until then no standby can take the lock, and the whole background plane of the deployment stands still. idle_session_timeout ends that session within four check intervals instead, and the lock goes with it.
The timeout is not a lease. It bounds a session that has stopped talking; it never expires under a leader that is merely slow, because a leader that reaches its own check runs a query, which ends the idle period.
A transaction-mode connection pooler, such as PgBouncer with pool_mode = transaction, cannot carry a session lock, because it hands the server session to another client after every transaction. Connect the API to PostgreSQL directly or through a session-mode pooler.
Cross-references
../../contributing/layout.md: the bounded-context map locating this package in the codebase.../../../internal/platform/db/leader/: the package source.../../../tests/e2e/dev/multi-replica/chainsaw-test.yaml: the end-to-end proof. It scales the dev overlay's API Deployment to two replicas and asserts one granted lock row against one replica reporting the gauge at1. It then expires an approval and an invitation and reads one outbox row per aggregate, across a leader handover.../index.md: the Reference quadrant index.