-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add native failover-slot support for Spock 6 #458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,24 +5,36 @@ import ( | |
| "crypto/tls" | ||
| "errors" | ||
| "fmt" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/rs/zerolog" | ||
|
|
||
| "github.com/pgEdge/control-plane/server/internal/certificates" | ||
| "github.com/pgEdge/control-plane/server/internal/database" | ||
| "github.com/pgEdge/control-plane/server/internal/ds" | ||
| "github.com/pgEdge/control-plane/server/internal/patroni" | ||
| "github.com/pgEdge/control-plane/server/internal/postgres" | ||
| "github.com/pgEdge/control-plane/server/internal/utils" | ||
| ) | ||
|
|
||
| // patroniRequestTimeout bounds the Patroni REST calls | ||
| // reconcileSynchronizedStandbySlots makes. patroni.NewClient's default | ||
| // http.Client has no request timeout of its own, and this reconciliation | ||
| // tends to have work to do right after a failover -- exactly when | ||
| // Patroni's REST API is most likely to be transiently unresponsive | ||
| // (mid-election) -- so an explicit bound here keeps a stalled connection | ||
| // from blocking this instance's status collection indefinitely. | ||
| const patroniRequestTimeout = 10 * time.Second | ||
|
|
||
| type InstanceMonitor struct { | ||
| statusMonitor *Monitor | ||
| databaseID string | ||
| instanceID string | ||
| dbName string | ||
| dbSvc *database.Service | ||
| certSvc *certificates.Service | ||
| logger zerolog.Logger | ||
| } | ||
|
|
||
| func NewInstanceMonitor( | ||
|
|
@@ -39,6 +51,7 @@ func NewInstanceMonitor( | |
| dbName: dbName, | ||
| dbSvc: dbSvc, | ||
| certSvc: certSvc, | ||
| logger: logger, | ||
| } | ||
| m.statusMonitor = NewMonitor( | ||
| logger, | ||
|
|
@@ -175,6 +188,126 @@ func (m *InstanceMonitor) populateFromDbConn( | |
| Status: sub.Status, | ||
| }) | ||
| } | ||
|
|
||
| // Logged and swallowed rather than propagated: this is a | ||
| // best-effort background reconciliation, not a health signal. | ||
| // Letting it fail the whole status collection here would report | ||
| // an otherwise-healthy primary as errored (discarding the | ||
| // version/subscription data this same pass already collected) | ||
| // over what's usually a transient Patroni REST hiccup -- and | ||
| // since the reconciliation is self-correcting on every poll (see | ||
| // its own doc comment), nothing is lost by retrying next tick | ||
| // instead of surfacing this as an instance-level error now. | ||
| if err := m.reconcileSynchronizedStandbySlots(ctx, conn, info, pgVersion, spockVersion); err != nil { | ||
| m.logger.Err(err). | ||
| Str("database_id", m.databaseID). | ||
| Str("instance_id", m.instanceID). | ||
| Msg("failed to reconcile synchronized_standby_slots") | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // reconcileSynchronizedStandbySlots keeps Postgres 17+'s | ||
| // synchronized_standby_slots GUC in sync with this node's actual current | ||
| // physical standby topology, on the current primary only. This is what | ||
| // makes native failover slots (see postgres.NeedsNativeFailoverSlots) | ||
| // safe to fail over onto: without it, a promoted replica's logical slots | ||
| // have no guarantee the outgoing primary's not-yet-decoded WAL was ever | ||
| // received by the physical standby that just became primary. | ||
| // | ||
| // This runs here, in the same 5s poll that already detects a role change | ||
| // (rather than e.g. a Patroni on_role_change callback), because it's the | ||
| // one thing in this codebase that already knows a role change happened | ||
| // -- Control Plane's own spec-driven reconciliation never runs on its | ||
| // own initiative when Patroni autonomously promotes a replica, and | ||
| // wiring a callback into the Postgres/Patroni container image would be | ||
| // new plumbing (script delivery, auth back to Control Plane) with no | ||
| // existing precedent anywhere in this codebase. See the design doc for | ||
| // the fuller comparison. | ||
| // | ||
| // Deliberately idempotent and self-correcting rather than cached: it | ||
| // re-derives the desired value and compares against the GUC's own live | ||
| // setting on every call, so a prior partial failure (e.g. the DCS patch | ||
| // below succeeds but the reload doesn't) is retried on the very next | ||
| // tick rather than silently stuck behind an in-memory "already handled" | ||
| // flag. | ||
| func (m *InstanceMonitor) reconcileSynchronizedStandbySlots( | ||
| ctx context.Context, | ||
| conn postgres.Executor, | ||
| info *database.ConnectionInfo, | ||
| pgVersionStr, spockVersionStr string, | ||
| ) error { | ||
| // A malformed version string here isn't treated as an error condition | ||
| // -- it fails open to "not eligible," the same way | ||
| // needsOutputPluginLibraries and nativeFailoverSlotMajors (see | ||
| // postgres/gucs.go) treat an unresolvable version as "doesn't need | ||
| // this" rather than an error. In practice this path is unreachable: | ||
| // pgVersionStr/spockVersionStr come from GetPostgresVersion()/ | ||
| // GetSpockVersion(), which only ever produce clean, well-formed | ||
| // version strings for a live connection. | ||
| pgVersion, err := ds.ParseVersion(pgVersionStr) | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| spockVersion, err := ds.ParseVersion(spockVersionStr) | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| pgMajor, ok := pgVersion.Major() | ||
| if !ok { | ||
| return nil | ||
| } | ||
| spockMajor, ok := spockVersion.Major() | ||
| if !ok { | ||
| return nil | ||
| } | ||
| if !postgres.NeedsNativeFailoverSlots(spockMajor, pgMajor) { | ||
| // Not a native-failover-slot cluster (e.g. Spock 5.x, or PG < 17) | ||
| // -- leave synchronized_standby_slots alone entirely. Its | ||
| // Postgres default is an empty string (no synchronization | ||
| // requirement), so there's nothing to reconcile toward. | ||
| return nil | ||
| } | ||
|
|
||
| slotNames, err := postgres.PhysicalReplicationSlotNames().Scalars(ctx, conn) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to list physical replication slots: %w", err) | ||
| } | ||
| desired := strings.Join(slotNames, ",") | ||
|
|
||
| current, err := postgres.CurrentSynchronizedStandbySlots().Scalar(ctx, conn) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to read current synchronized_standby_slots: %w", err) | ||
| } | ||
| if current == desired { | ||
| return nil | ||
| } | ||
|
|
||
| // Bounded independently of the caller's context: this reconciliation | ||
| // is most likely to have work to do right after a failover, which is | ||
| // exactly when Patroni's own REST API is most likely to be | ||
| // transiently unresponsive (mid-election). http.DefaultClient (what | ||
| // patroni.NewClient falls back to) has no request timeout of its | ||
| // own, so without this a stalled connection here could block this | ||
| // instance's status collection well past its usual 5s cadence. | ||
| patchCtx, cancel := context.WithTimeout(ctx, patroniRequestTimeout) | ||
| defer cancel() | ||
|
|
||
| client := patroni.NewClient(info.PatroniURL(), nil) | ||
| _, err = client.PatchDynamicConfig(patchCtx, &patroni.DynamicConfig{ | ||
| PostgreSQL: &patroni.DynamicPostgreSQLConfig{ | ||
| Parameters: utils.PointerTo(map[string]any{ | ||
| "synchronized_standby_slots": desired, | ||
| }), | ||
| }, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to patch synchronized_standby_slots to %q: %w", desired, err) | ||
| } | ||
| if err := client.Reload(patchCtx); err != nil { | ||
| return fmt.Errorf("failed to reload after patching synchronized_standby_slots: %w", err) | ||
|
Comment on lines
+298
to
+310
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect the Patroni client constructor and its timeout handling.
set -euo pipefail
fd -t f 'client.go' server/internal/patroni --exec ast-grep outline {} --items all
echo '--- NewClient definition ---'
ast-grep run --lang go --pattern 'func NewClient($$$) $_ { $$$ }' server/internal/patroni
echo '--- Timeout / http.Client configuration ---'
rg -nP -C 5 '(Timeout|http\.Client|DefaultClient)' server/internal/patroni
echo '--- Existing NewClient call sites for comparison ---'
rg -nP -C 3 'patroni\.NewClient\s*\(' server/internalRepository: pgEdge/control-plane Length of output: 23156 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- Patroni client request methods ---'
sed -n '420,555p' server/internal/patroni/client.go
sed -n '630,710p' server/internal/patroni/client.go
echo '--- Monitor method and context construction ---'
sed -n '80,145p' server/internal/monitor/instance_monitor.go
sed -n '230,285p' server/internal/monitor/instance_monitor.go
rg -n -P -C 5 '(Monitor|monitor|context\.(WithTimeout|WithDeadline)|NewTicker|poll)' server/internal/monitor server/internal | head -n 300
echo '--- Patroni configuration and documentation references ---'
rg -n -i -P -C 3 '(patch.*dynamic|dynamic.*config|reload|synchronized_standby_slots|loop_wait|Patroni)' README.md docs server internal 2>/dev/null | head -n 400Repository: pgEdge/control-plane Length of output: 50378 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- Monitor implementation ---'
fd -t f -i 'monitor.go' server/internal/monitor --exec sh -c 'echo "### $1"; sed -n "1,240p" "$1"' sh {}
echo '--- Instance monitor entry points and callers ---'
rg -n -P -C 8 'func \(m \*InstanceMonitor\)|NewInstanceMonitor|instanceMonitor|checkStatus|context\.(WithTimeout|WithDeadline)' server/internal/monitor server/internal | head -n 350
echo '--- Monitor intervals and cancellation ---'
rg -n -P -C 6 '(InstanceMonitorRefreshInterval|type Monitor|func \(.*Monitor.*Start|func \(.*Monitor.*Stop|context\.)' server/internal/monitorRepository: pgEdge/control-plane Length of output: 50376 🌐 Web query:
💡 Result: When you use the Patroni REST API PATCH /config endpoint, the configuration changes are propagated through the following mechanism: 1. DCS Propagation: The PATCH request updates the global dynamic configuration stored in the Distributed Configuration Store (DCS), such as etcd, Consul, or ZooKeeper [1][2]. 2. Local Propagation: Patroni nodes watch the DCS for changes [2]. When an update is detected, each node updates its own internal view of the configuration [2][3]. 3. postgresql.conf Modification: Patroni ensures that dynamic configuration options (such as postgresql.parameters) are applied to the PostgreSQL instance [2][4]. It manages this by rewriting the postgresql.conf file [4][5]. Specifically, Patroni renames the original configuration file to postgresql.base.conf (or uses a custom base file if defined via custom_conf) and modifies postgresql.conf to include this base file [5][6]. This allows Patroni to write new, dynamic configuration parameters directly into postgresql.conf while maintaining the original settings as the base [4][6]. 4. Application of Changes: - For configuration changes that do not require a restart (e.g., those that can be reloaded via SIGHUP), Patroni triggers a configuration reload asynchronously on the nodes upon the next wake-up cycle [2][4]. - For PostgreSQL parameters that require a restart (those with postmaster context), Patroni will set a pending_restart flag in the node's status (visible in the API and via patronictl) [2][5]. The administrator must then explicitly trigger a restart (e.g., via the /restart REST API endpoint or patronictl restart) to apply these changes [2][5]. Additionally, Patroni maintains a local file named patroni.dynamic.json in the PostgreSQL data directory, which serves as a local dump of the DCS configuration to allow for recovery if the DCS is unavailable [2][3]. Citations:
🌐 Web query:
💡 Result: The Patroni REST API endpoint PATCH /config does not require or use the /reload endpoint to apply configuration changes; they are distinct mechanisms for different types of configuration [1][2]. 1. Dynamic Configuration (PATCH /config): When you update configuration via the PATCH /config endpoint, these changes are stored in the Distributed Configuration Store (DCS) [1][3]. Patroni nodes monitor the DCS and apply these updates asynchronously during their next wake-up cycle [1][3]. You do not need to trigger a reload manually for these changes to take effect [1]. If a change requires a PostgreSQL restart (e.g., modifying certain PostgreSQL parameters with postmaster context), Patroni will set a pending_restart flag, and you must then explicitly trigger a restart using the POST /restart endpoint or patronictl restart [4][1]. 2. Local Configuration (/reload endpoint): The POST /reload endpoint is specifically designed for reloading the local Patroni configuration file (patroni.yml) [4][5][2]. Sending a request to this endpoint is equivalent to sending a SIGHUP signal to the Patroni process, instructing it to re-read the local YAML configuration files without restarting the Patroni daemon itself [5][1][2]. In summary, PATCH /config updates global dynamic settings via the DCS and is processed automatically, whereas POST /reload is used exclusively to apply changes made manually to local configuration files on the Patroni node [1][2]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
client = Path("server/internal/patroni/client.go").read_text()
monitor = Path("server/internal/monitor/monitor.go").read_text()
instance = Path("server/internal/monitor/instance_monitor.go").read_text()
service = Path("server/internal/monitor/service.go").read_text()
checks = {
"nil client selects http.DefaultClient": "if client == nil" in client and "client = http.DefaultClient" in client,
"default client has no configured timeout in repository": "http.DefaultClient.Timeout" not in client,
"requests use caller context": "http.NewRequestWithContext(ctx" in client,
"reload is a separate POST endpoint": 'c.endpoint("reload")' in client,
"patch targets dynamic config endpoint": 'http.MethodPatch, c.endpoint("config")' in client,
"monitor invokes checks with its start context": "m.Check(ctx)" in monitor,
"monitor does not derive a per-check deadline": "context.WithTimeout" not in monitor and "context.WithDeadline" not in monitor,
"instance monitor starts with application context": "mon.Start(s.appCtx)" in service,
"reconcile calls reload after patch": "client.PatchDynamicConfig" in instance and "client.Reload(ctx)" in instance,
}
for label, result in checks.items():
print(f"{'PASS' if result else 'FAIL'}: {label}")
if not all(checks.values()):
raise SystemExit(1)
PY
echo '--- Relevant source lines ---'
nl -ba server/internal/patroni/client.go | sed -n '430,438p;512,533p;641,655p'
nl -ba server/internal/monitor/monitor.go | sed -n '25,58p'
nl -ba server/internal/monitor/service.go | sed -n '61,72p;176,187p'Repository: pgEdge/control-plane Length of output: 652 🌐 Web query:
💡 Result: In PostgreSQL 17, the synchronized_standby_slots configuration parameter is a postmaster-level parameter that cannot be changed via a reload (SIGHUP) [1][2]. According to the official PostgreSQL 17 documentation, this parameter can only be set in the postgresql.conf file or on the server command line [1][2]. Changes to this parameter require a server restart to take effect. The synchronized_standby_slots parameter is used to specify a comma-separated list of physical replication slot names that logical WAL sender processes must wait for before sending decoded changes to plugins [1][2]. This ensures that logical failover slots do not consume changes until those changes have been received and flushed to the specified physical standby servers, facilitating reliable logical replication failover [1][3]. Citations:
Apply 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| return nil | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,14 @@ var dynamicGUCs = ds.NewSet( | |
| "max_replication_slots", | ||
| "wal_keep_segments", | ||
| "wal_keep_size", | ||
| // Never generated as a static default (see postgres.DefaultGUCs' doc | ||
| // comment) -- its correct value depends on live replication topology, | ||
| // so it's only ever pushed here directly via the Patroni REST client's | ||
| // PatchDynamicConfig, by InstanceMonitor's runtime reconciliation (see | ||
| // server/internal/monitor/instance_monitor.go). Listed here purely so | ||
| // this file stays the one place documenting every GUC this codebase | ||
| // manages through Patroni's DCS, reload-safe. | ||
| "synchronized_standby_slots", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This setting is related to synchronous physical replicas. We currently support only asynchronous replicas, so we shouldn't set this parameter.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checked the actual docs and it turns out this isn't tied to synchronous replication at all, it works the same with async standbys. But there is a real catch worth knowing about. If a physical standby lags or drops, the primary just waits for it before sending changes to Spock peers, no timeout. And since a node can already span multiple hosts today, that's not a hypothetical, a slow or disconnected replica could stall a node's whole outbound replication. So it's not exactly the sync replication issue you mentioned, but there is a real tradeoff here worth talking about before we lock t |
||
| ) | ||
|
|
||
| // ExtractPatroniControlledGUCs extracts the GUCs that Patroni controls into a | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this necessary? As I mentioned in another comment, we don't support synchronous replicas right now, so I don't think we should set
synchronized_standby_slots.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same answer as the other thread, this is what makes a failover slot safe to use after a promotion. Without it, a promoted standby has no guarantee it got the WAL a failover enabled slot needed.
One thing worth flagging though, if a physical standby lags or drops, the primary waits for it with no timeout, and nodes can already span multiple hosts today, so that's a real risk. Mechanism's doing what it's supposed to, just introduces a new way a slow replica could stall replication. Happy to keep it or talk through bounding it if that's a concern.