Postgres with automatic failover: salmon provisions, Patroni decides
On Tue, 06 Oct 2026, by @lucasdicioccio, 2074 words, 0 code snippets, 0 links, 0images.
Generated from specs/pg-patroni.md — the repository is the canonical source, and may be ahead of this page.
Postgres with automatic failover: salmon provisions, Patroni decides
Status: draft / not implemented. A design sketch to react to, not a committed plan.
Companion: pg-switchover.md is the two-node, operator-driven design for
test harnesses and low SLAs. This one is for when nobody should have to be
awake for a failover.
Position
Salmon does not decide when to fail over, and does not know who the
primary is. Safe automatic failover needs consensus, so that a split
network cannot elect two primaries, and a leader that demotes itself when it
loses the quorum, which is Patroni’s form of fencing. run serve is one
process per machine with no coordination between them. Building consensus
into salmon would mean writing a worse etcd.
So the split of work is:
| Salmon | Patroni |
|---|---|
| installs and configures etcd, Patroni, routing | runs Postgres: initdb, start, stop, restart, promote, demote |
| supervises the Patroni and etcd systemd units | supervises Postgres |
| states the cluster-wide config it wants | applies it, restarting members when a setting needs it |
| creates databases, roles, templates, clones, runs migrations, through the leader | holds the leader key in etcd, i.e. the only answer to "who is primary" |
| backups and archiving | uses the archive to rebuild replicas |
The chain of ownership is salmon → systemd → Patroni → Postgres. Salmon’s
supervision (Actions/Upkeep.hs) must stop at the Patroni unit: a salmon
node that restarts Postgres is fighting its owner.
The consequence for everything that exists
The primary’s location never appears in a directive, a ref, help or
notes. This is the opposite of pg-switchover.md, where the operator’s
declaration is the truth. Here, a declared primary would be stale after the
first automatic failover, and the next run up would try to “correct” it.
Existing builtins that must not run on a Patroni member:
| Builtin | Replaced by |
|---|---|
createCluster, pgLocalCluster (initdb, start) | Patroni's bootstrap on the first member, and its replica creation on the others. Debian's postgresql@ units must be disabled or masked, or two supervisors race for the data directory. |
startCluster / stopCluster / restartCluster / promoteCluster | nothing; patronictl if an operator must |
alterSystemSet, reloadConf | the DCS config node (below). ALTER SYSTEM still "works", but Patroni can overwrite what it sets and it does not reach the other members |
hbaLine, allowReplicationFrom, allowClientCertFrom | postgresql.pg_hba in patroni.yml, or the DCS config |
primaryReplicationSetup, standbyReplicationSetup, replicationSlot | Patroni: it creates replicas and manages physical slots for members |
Builtins that still apply, as long as they reach the leader: database,
user, group, grant, adminScript, templates and clones,
SreBox.PostgresMigrations. Every one of them runs
sudo -u postgres psql -p <port> on the local machine today. On a replica,
CREATE DATABASE fails with “cannot execute … in a read-only transaction”.
See “Reaching the leader”.
New builtins
Etcd
- Renders the config and a systemd unit; the
checkisetcdctl endpoint healthagainst the member itself. - The trap is bootstrap.
initial-cluster-stateisnewexactly once in a cluster’s life. After that, a new member is added by a runtime call (etcdctl member add) followed by starting it withexisting, and it cannot be expressed as config alone. A node for “etcd cluster of these three” therefore has a seed phase (all start withnewwhen no member answers) and a join phase (member add, then start). Itscheckcompares the member list, and does not look at a config file. - TLS: peer and client certificates come in pre-provisioned, per the recipes’
secret-transport rule.
Certificatescan mint them in a recipe that owns that choice; the builtin only takes paths. - Several Patroni clusters can share one etcd (each has its own
scopeandnamespace). This is howpg-ha-control-plane.md’s cross-replicated pair becomes safe: two Postgres machines plus a small third one running only etcd, with three votes between them.
Patroni
- Renders
patroni.yml(anEncodeFileContentsvalue, so its content fingerprint reachesnotesand a re-declaration isStaleunderrun serve), plus a systemd unit. - The
checkreads the local REST API on port 8008:GET /health: is Postgres running under Patroni;GET /patroni: state, role,pending_restart, and whether the member is in the cluster at all.- Role is reported, never judged: a replica is as healthy as a leader.
- Debian’s
patronipackage ships its own integration with thepostgresql-commonlayout (pg_createconfig_patroniand a per-clusterpatroni@unit). Whether to build on it or render our own is an open question below.
The cluster-wide config node
This is ALTER SYSTEM’s replacement. Patroni keeps
postgresql.parameters and friends in etcd, applied on every member.
check:GET /config, comparing only the keys this node declares, so that other declarations or operators can own other keys.up:PATCH /configwith those keys.- A second
checkcondition, the same move ascheckServicereadingNeedDaemonReload: after a restart-only setting changes,/patronion each member sayspending_restart. The node is notSuccessuntil nothing is pending, and itsupcan triggerpatronictl restart --pending, which restarts replicas before the leader. Whether salmon should restart members at all, or only report, is an open question. - Leaning, from the Zalando operator (
pkg/cluster/sync.go,syncPatroniConfig,restartInstances): it patches/configonce, since the DCS is shared and any member will do, waitsloop_waitplus two seconds for members to apply it, and restarts only members whosepending_restartis set. Replicas go first, except when a parameter was decreased that a standby needs to be at least the primary’s value (itsrequirePrimaryRestartWhenDecreasedlist, e.g.max_connections): then the primary restarts first, because a standby refuses to start below it. A member that answers an empty config is not initialised yet, so the patch is skipped for it, which for us isUnknown, notFailure.
Routing to the leader
Three options, from least to most machinery. The recipe should offer the first and one of the others:
- libpq multi-host connection strings:
host=a,b,c target_session_attrs=read-write. No new process; works only for libpq clients and only at connect time. vip-manager: a floating IP that follows the leader key in etcd. Needs one L2 segment.- HAProxy with
option httpchkonGET /primary(port 8008): the standard Patroni setup. One port for the leader, one for replicas (GET /replica). It sits in front of, or replaces the upstream of, the existingPgBouncer.
A Haproxy builtin would have the same shape as Nginx: a config value, a
renderer, and a systemd unit. Unlike pgbouncer, HAProxy needs no reload on
failover: its health checks move the traffic.
Done as Salmon.Builtin.Nodes.Haproxy (setup, renderConfig,
patroniRouter): every member is listed under every listener and only the
check path differs (/primary, /replica, /replica?lag=N, /read-only),
so the file is the same text before and after a failover, which is T3 for the
router. on-marked-down shutdown-sessions closes a demoted leader’s sessions
so its clients reconnect to the new one. A member’s traffic port is its own
field, so the upstream can be Postgres or a PgBouncer on the member. Changing
the member set rewrites the file and restarts HAProxy, dropping connections;
a graceful reload (-sf) is not done. Not done either: a recipe placing it
beside patroniMember, TLS, and T1/T2/T6 through it (Layer 3).
Reaching the leader
Admin nodes need a way to reach whichever member is the leader right now, which is not known when the directive is written.
The recommendation is to address the leader by its routed endpoint (the
HAProxy primary port, or the VIP) with a connection string, not by
sudo -u postgres on a machine. That means connection-string variants of the
admin builtins. SreBox.PostgresMigrations.remoteMigrateOpaqueSetup already
works this way; database, user, templates and clones do not. This is the
largest single piece of work in this spec, and it is independently useful:
anything managed (Cloud SQL, see Gcp/PostgrestCloudRun) has the same “no
local superuser” shape.
The alternative, running the admin nodes on every member with a check that
answers Skipped when this member is not the leader, is smaller but racy:
- the leader can change between the check and the
up; - templates would be built by whichever member was the leader at the time.
Refused.
Nodes that must run on exactly one member
Backups (from a replica, by preference) and scheduled jobs (CronTask)
would otherwise run on every member.
-
Backups: a Patroni tag such as
nofailoveror a custom one marks the member dedicated to them. The scheduled command gates itself withcurl -sf localhost:8008/replica, skipping when the member is not a replica. -
PITR:
SreBox.PostgresBackupispg_dump, which has no point-in-time recovery and no use to Patroni. A continuous archive (pgBackRest or WAL-G, both packaged by Debian) earns its place twice: as the disaster recovery story, and as Patroni’screate_replica_methods, which rebuilds a replica from the archive instead of loading the leader with a base backup.Implemented, pgBackRest first (
Salmon.Builtin.Nodes.PgBackRest,Patroni.pat_archive). What writing it settled, and what it did not:- The repository has to be one every member reads (a shared mount, or S3): a replica is built from an archive it can reach.
stanza-createandbackupare “exactly one member” operations too: with onepg1-pathconfigured, pgBackRest refuses both on a standby. The nodes check the repository (which any member can read) and theirupsucceeds only on whichever member leads at that moment; no directive names it.- The scheduled backup gates on
/primary, not/replicaas written above. A backup taken from a standby needsbackup-standbyand a secondpghost reached over ssh or TLS. Not done. archive_mode/archive_commandare localpostgresql.parametersinpatroni.yml, not DCS keys, so that declaring an archive on an existing cluster takes effect;archive_modeis restart-only and shows up aspending_restartfor the config node.- Point-in-time recovery of a whole Patroni cluster is a custom bootstrap of
a new scope (
patroniArchiveRecovering). Rendered; never run. - Shown in a container (
Test.PgBackRestSpec, Layer 2): recovery to a timestamp on a plain cluster, and a second Patroni member made by pgBackRest rather than by a base backup. T7 on the three VMs is not run. - Two defects in the Patroni builtin surfaced on the way, both from keeping
the configuration outside the data directory:
bootstrap.pg_hbanever reaches a replica (nowpostgresql.pg_hba), and Patroni does not create thepostgresql.confit renames (now seeded, empty, once). And Debian’spatronidoes not pull inpython3-etcd.
Switchover, under Patroni
pg-switchover.md’s role node has a counterpart here, and it is opt-in: a
preferred leader.
check:GET /leadernames the preferred member.up:POST /switchover.
Choosing the target (also from the operator, pod.go,
getSwitchoverCandidate): with synchronous_mode on, the candidate is the sync
standby, and salmon waits for one to exist rather than falling back to an
asynchronous member, since that member may lack acknowledged writes. Otherwise
it is the lowest-lag member whose state is running, streaming or in archive recovery. If there is no candidate the switchover is skipped and retried on the
next pass, with the leader left alone. The state that pass needs is re-derived
from /cluster each time, which is the property PostgresPair protects.
The default is no preference. With a preference declared and supervision on, salmon would move the leader back after every automatic failover, as soon as the preferred member is healthy. That is sometimes what is wanted (a preferred site or a bigger machine) and sometimes a flap. It must be a choice, never the default.
Rolling maintenance of members
Restarting or upgrading every member (a Postgres minor update, a Patroni package) is one operation in three steps, and the order is the safety:
- replicas, one at a time, waiting for each to report
streamingagain; - a switchover of the leader to the candidate chosen as above;
- the old leader, now a replica.
The operator’s recreatePods does exactly this, and does not touch the leader
when it has no safe candidate. Salmon’s version is a node whose check is “no
member is on an old version or pending a restart” and whose up walks that
order from the controller. It must tolerate being killed halfway: the next pass
re-observes and continues.
Gating risky operations
Two guards the operator applies to operations that cannot be undone, both expressible with what already exists:
- A maintenance window (
isInMaintenanceWindowinsync.go). Outside the window the operator freezes the desired version, so the change is never seen. OurGateonupTreeWith(“does this traversal want to touch this node at all”) is the place: a time-window gate for nodes that restart or upgrade. The gate reports aSkip, andstatusshows why. - Preconditions and a failure latch (
majorversionupgrade.go). It runs only when every pod is running, every replica isstreamingand lag is under 16 MB, and it labels the podscritical-operationso a node drain cannot interfere. After a failure it records an annotation and does not retry automatically until an operator clears it. The equivalents are the same preconditions in the node’scheck(Unknownwhen unmet, so nothing starts),supGiveUpAfterwith the node parked, and a way to tell sibling nodes on the same cluster to hold still while it runs (aPause, seeOp/Mailbox.hs); that last one has no counterpart yet.
Disaster scenarios
These reuse pg-switchover.md’s scenario catalogue where the cause is the
same, with different expected outcomes, plus the ones only consensus makes
meaningful.
Machines: three VMs, each with postgresql, patroni, etcd-server and
haproxy in the rootfs (the guests cannot reach the network past boot,
see Test.PostgresReplicationSpec). The addresses are
testVmAddr/testVmAddr2/testVmAddr3. HAProxy and the client loop run on
one of them, or on a fourth VM.
| # | Scenario | Assert |
|---|---|---|
| T1 | Kill the leader's machine | a new leader within the TTL (about 30s by default); writes through HAProxy resume; the old leader rejoins as a replica when it comes back |
| T2 | Partition the leader from the other two | the old leader demotes itself once its lease expires; at no moment do two members accept writes (the client loop writes through each member's own port and records which accepted) |
| T3 | After T1, rerun run up on every member and on the controller | nothing changes: no restart, no config write, no role change. This is the salmon-specific assertion: salmon does not fight Patroni |
| T4 | Change a restart-only parameter through the config node | members restart replicas first; pending_restart clears; the node's check reaches Success |
| T5 | Lose etcd quorum (stop two of three) | with failsafe_mode off, the leader goes read-only; with it on, the leader keeps writing while it can reach all members. Pins down which mode the recipe defaults to |
| T6 | Create a database and a clone through the routed endpoint, fail over, repeat | both work on each leader; no admin node ever ran against a replica |
| T7 | Add a fourth member | etcd's join phase runs, not a bootstrap; Patroni builds the replica from the archive if one is configured |
T3 is the test the whole design rests on, and the reason “the primary never appears in a directive” is a rule rather than a guideline.
Phased plan
- Connection-string admin builtins (
database,user, grants, templates, clones). Useful today for managed Postgres, and required here. Etcd, with the seed and join phases; Layer 3, three VMs, including a restart of one member.Patroniplus the cluster-wide config node; T1, T3, T4.- Routing: multi-host connection strings first, then HAProxy; T2, T6.
- Archive and PITR (pgBackRest or WAL-G), and backups on one member; T7. pgBackRest builtin and the Patroni wiring are in; T7 itself is not run.
- The opt-in preferred leader.
Open questions
- Debian’s
pg_createconfig_patroniandpatroni@units, or our own? Theirs fitspostgresql-common’s layout, which the rest ofPostgres.hsassumes (pg_lsclusters,/var/lib/postgresql/$version/$cluster). Ours is simpler to reason about, and portable to non-Debian hosts. - Should the config node restart members (
patronictl restart --pending), or reportpending_restartand leave the restart to an operator? Restarting is whatrun upmeans everywhere else. A restart of the leader under load is also a small outage. Decided (owner, 2026-09-26): restart the members that havepending_restartset, replicas first, and the leader behind a maintenance-window gate (what the Zalando operator does; see the leaning under the config node above). failsafe_modedefault (see T5).- REST API authentication: Patroni’s unsafe endpoints (
/switchover,PATCH /config,/restart) needrestapi.authentication. The credentials come in as a pre-provisioned file. - Which etcd API: Patroni’s
etcd3:section, the v3 API, is the only sensible one with current etcd. Say so in the builtin and do not offer v2.