Skip to content

fix(tests): detect PG graceful switchover at PostgreSQL level - #100

Merged
renecannao merged 4 commits into
masterfrom
fix/postgresql-switchover-test-detection
Jul 27, 2026
Merged

fix(tests): detect PG graceful switchover at PostgreSQL level#100
renecannao merged 4 commits into
masterfrom
fix/postgresql-switchover-test-detection

Conversation

@renecannao

@renecannao renecannao commented Apr 18, 2026

Copy link
Copy Markdown

Summary

PR #99's functional tests fail on master across PG 15/16/17. The failing assertion (Primary did not change: was 172.30.0.20:5432, now 172.30.0.20:5432) is an inherited check that does not work for PG graceful takeover:

  • PG's graceful takeover does not restart the demoted primary; it only sets primary_conninfo. Per the design, the operator completes the switchover via a PostGracefulTakeoverProcesses hook that creates standby.signal and restarts.
  • Until that restart, orchestrator sees the demoted primary as a separate cluster root (RO=false). So looking for "the RO=false instance in the original cluster" returns the same host before and after — a false negative.

Run: https://github.com/ProxySQL/orchestrator/actions/runs/24606553086

Fix

Replace the cluster-view check with direct PostgreSQL-level signals:

  • pg_is_in_recovery()=f on pgstandby1 (the standby was promoted)
  • default_transaction_read_only=on on pgprimary (the primary was demoted)

The existing writability check against pgstandby1 and the primary_conninfo check on pgprimary stay.

The round-trip section now:

  • Gates on SWITCHOVER_OK derived from the direct PG check.
  • Discovers the new cluster name dynamically after pgprimary is restarted as a standby (it joins pgstandby1's cluster, which is not the original PG_CLUSTER).
  • Uses pg_is_in_recovery() on pgprimary to verify the round-trip instead of the split-brain cluster view.

Test plan

  • Functional tests pass on PG 15/16/17 (the three matrix jobs that previously failed)
  • MySQL matrix + Raft job remain green (unchanged logic)
  • Dead-primary failover still recovers after the full round-trip sequence

Summary by CodeRabbit

  • Tests
    • Strengthened PostgreSQL switchover validation by using direct database state checks for promotion and read-only status instead of relying on orchestrator-observed primary parsing.
    • Improved graceful switchover round-trip coverage by recloning and restarting workloads, waiting for confirmed streaming, and confirming the correct cluster association before takeover.
    • Updated functional PostgreSQL setup to enable wal_log_hints and expanded replication access to support dynamic primary_conninfo configuration.
  • Chores
    • Updated the functional Docker Compose setup to use persistent named volumes for primary and standby data.

@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@renecannao, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9696dd71-b6f1-44a7-b5b0-0a62edc9caa0

📥 Commits

Reviewing files that changed from the base of the PR and between 0b1860c and 519c63a.

📒 Files selected for processing (1)
  • tests/functional/test-postgresql.sh
📝 Walkthrough

Walkthrough

This change updates PostgreSQL functional switchover coverage, persistent test storage, and PostgreSQL initialization. The test now uses direct database-state assertions, reclones and validates streaming replicas, dynamically resolves cluster identity, and restores standby state after round-trip takeover.

Changes

PostgreSQL graceful switchover

Layer / File(s) Summary
Persistent test environment
tests/functional/docker-compose.yml, tests/functional/postgres/init-primary.sh
Adds persistent PostgreSQL volumes, enables wal_log_hints, and permits orchestrator replication authentication.
Initial switchover validation
tests/functional/test-postgresql.sh
Verifies standby promotion with pg_is_in_recovery() and demoted-primary read-only state before enabling the round-trip test.
Round-trip takeover and standby restoration
tests/functional/test-postgresql.sh
Reclones pgprimary, waits for streaming, discovers a shared cluster name, performs takeover through that cluster, validates promotion directly, and reactivates pgstandby1 as a standby.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TestScript
  participant PostgreSQL
  participant Orchestrator
  TestScript->>PostgreSQL: Verify promotion and read-only state
  TestScript->>PostgreSQL: Recl on pgprimary from pgstandby1
  PostgreSQL-->>TestScript: Report streaming readiness
  TestScript->>Orchestrator: Discover NEW_CLUSTER
  TestScript->>Orchestrator: Run graceful-master-takeover-auto
  TestScript->>PostgreSQL: Verify final promotion
Loading

Possibly related issues

Possibly related PRs

  • ProxySQL/orchestrator#99 — Both PRs update PostgreSQL graceful-switchover functional coverage and its takeover validation.

Poem

🐰 Hop, hop, the primaries trade,
Replicas stream where paths are laid.
The database tells what’s true,
While orchestrator finds the queue.
Back again, the standby stays—
A rabbit cheers the tested ways!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: validating graceful switchover using PostgreSQL-level signals in tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/postgresql-switchover-test-detection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the PostgreSQL functional tests to verify switchover success through direct database queries instead of relying on Orchestrator's cluster view, which can be inconsistent during graceful takeovers. It also updates the topology re-unification logic to dynamically track cluster names. The review feedback suggests initializing variables to ensure compatibility with shell safety flags and optimizing performance by reducing redundant API calls within polling loops.


# Verify orchestrator sees pgstandby1 as primary and pgprimary as standby
TOPOLOGY_OK=false
NEW_CLUSTER=""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It is good practice to initialize all variables used in comparisons, especially since set -u is enabled at the beginning of the script. While PRIMARY_CLUSTER is assigned inside the loop, initializing it here alongside NEW_CLUSTER improves clarity and safety.

Suggested change
NEW_CLUSTER=""
NEW_CLUSTER=""
PRIMARY_CLUSTER=""

Comment on lines +257 to +271
NEW_CLUSTER=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null | python3 -c "
import json, sys
for inst in json.load(sys.stdin):
if not inst.get('ReadOnly', True):
print(inst['Key']['Hostname'])
if inst['Key']['Hostname'] == '172.30.0.21':
print(inst.get('ClusterName', ''))
sys.exit(0)
" 2>/dev/null || echo "")
if [ "$PRIMARY_HOST" = "172.30.0.21" ] || [ "$PRIMARY_HOST" = "pgstandby1" ]; then
TOPOLOGY_OK=true
# Verify pgprimary (172.30.0.20) joined the same cluster as pgstandby1
PRIMARY_CLUSTER=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null | python3 -c "
import json, sys
for inst in json.load(sys.stdin):
if inst['Key']['Hostname'] == '172.30.0.20':
print(inst.get('ClusterName', ''))
sys.exit(0)
" 2>/dev/null || echo "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The script calls the /api/all-instances endpoint twice within each iteration of the loop. Since this endpoint returns data for all instances, it is more efficient to fetch the JSON once and then parse it for both hostnames. This reduces the number of API calls and potential network overhead during the test.

Suggested change
NEW_CLUSTER=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null | python3 -c "
import json, sys
for inst in json.load(sys.stdin):
if not inst.get('ReadOnly', True):
print(inst['Key']['Hostname'])
if inst['Key']['Hostname'] == '172.30.0.21':
print(inst.get('ClusterName', ''))
sys.exit(0)
" 2>/dev/null || echo "")
if [ "$PRIMARY_HOST" = "172.30.0.21" ] || [ "$PRIMARY_HOST" = "pgstandby1" ]; then
TOPOLOGY_OK=true
# Verify pgprimary (172.30.0.20) joined the same cluster as pgstandby1
PRIMARY_CLUSTER=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null | python3 -c "
import json, sys
for inst in json.load(sys.stdin):
if inst['Key']['Hostname'] == '172.30.0.20':
print(inst.get('ClusterName', ''))
sys.exit(0)
" 2>/dev/null || echo "")
ALL_INSTANCES=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null)
NEW_CLUSTER=$(echo "$ALL_INSTANCES" | python3 -c "
import json, sys
for inst in json.load(sys.stdin):
if inst['Key']['Hostname'] == '172.30.0.21':
print(inst.get('ClusterName', ''))
sys.exit(0)
" 2>/dev/null || echo "")
# Verify pgprimary (172.30.0.20) joined the same cluster as pgstandby1
PRIMARY_CLUSTER=$(echo "$ALL_INSTANCES" | python3 -c "
import json, sys
for inst in json.load(sys.stdin):
if inst['Key']['Hostname'] == '172.30.0.20':
print(inst.get('ClusterName', ''))
sys.exit(0)
" 2>/dev/null || echo "")

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/functional/test-postgresql.sh (1)

289-316: ⚠️ Potential issue | 🟠 Major

Avoid mutating pgstandby1 after a failed round-trip.

Because this script intentionally continues after fail calls, a failed BACK_CODE or failed BACK_PROMOTED check still falls through to reactivating pgstandby1. That can corrupt the setup for the downstream dead-primary failover test and obscure the original failure.

Proposed fix
             if [ "$BACK_CODE" = "OK" ]; then
                 pass "Round-trip graceful takeover API returned OK"
             else
                 fail "Round-trip graceful takeover returned: $BACK_CODE — $BACK_RESULT"
             fi
 
-            sleep 10
+            BACK_OK=false
+            if [ "$BACK_CODE" = "OK" ]; then
+                sleep 10
 
-            # Verify pgprimary is now promoted (not in recovery)
-            BACK_PROMOTED=$($COMPOSE exec -T pgprimary psql -U postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null | tr -d '[:space:]')
-            if [ "$BACK_PROMOTED" = "f" ]; then
-                pass "Round-trip complete: pgprimary is primary again"
-            else
-                fail "Round-trip incomplete: pgprimary pg_is_in_recovery='$BACK_PROMOTED' (expected f)"
+                # Verify pgprimary is now promoted (not in recovery)
+                BACK_PROMOTED=$($COMPOSE exec -T pgprimary psql -U postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null | tr -d '[:space:]')
+                if [ "$BACK_PROMOTED" = "f" ]; then
+                    pass "Round-trip complete: pgprimary is primary again"
+                    BACK_OK=true
+                else
+                    fail "Round-trip incomplete: pgprimary pg_is_in_recovery='$BACK_PROMOTED' (expected f)"
+                fi
             fi
 
             # After round-trip, pgstandby1 is the demoted primary — reactivate
             # it as a live standby so the downstream failover-kill test has a
             # replica to promote.
-            echo "Reactivating pgstandby1 as a live standby of pgprimary..."
-            $COMPOSE exec -T pgstandby1 bash -c 'touch /var/lib/postgresql/data/standby.signal && chown postgres:postgres /var/lib/postgresql/data/standby.signal' || true
-            $COMPOSE restart pgstandby1
+            if [ "$BACK_OK" = "true" ]; then
+                echo "Reactivating pgstandby1 as a live standby of pgprimary..."
+                $COMPOSE exec -T pgstandby1 bash -c 'touch /var/lib/postgresql/data/standby.signal && chown postgres:postgres /var/lib/postgresql/data/standby.signal' || true
+                $COMPOSE restart pgstandby1
+            else
+                skip "Skipping pgstandby1 reactivation — round-trip did not complete"
+            fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/functional/test-postgresql.sh` around lines 289 - 316, The script
currently always reactivates pgstandby1 even if the round-trip checks failed;
wrap the reactivation block (the lines touching pgstandby1: the touch/chown and
$COMPOSE restart pgstandby1) in a guard that only runs when both BACK_CODE =
"OK" and BACK_PROMOTED = "f" (i.e. after the pass calls), or alternatively add
an immediate exit/return after the fail calls; reference BACK_CODE,
BACK_PROMOTED, the pass/fail calls and the pgstandby1 reactivation commands to
locate and change the logic so no mutation happens when the round-trip failed.
🧹 Nitpick comments (1)
tests/functional/test-postgresql.sh (1)

255-287: Use one topology snapshot per polling iteration.

NEW_CLUSTER and PRIMARY_CLUSTER are read from separate /api/all-instances calls while rediscovery is actively changing state. Comparing values from different snapshots can make this poll flaky; fetch once and derive both values from the same JSON payload.

Proposed refactor
         NEW_CLUSTER=""
         for i in $(seq 1 30); do
-            NEW_CLUSTER=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null | python3 -c "
+            ALL_INSTANCES=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null || echo "[]")
+            NEW_CLUSTER=$(printf '%s' "$ALL_INSTANCES" | python3 -c "
 import json, sys
 for inst in json.load(sys.stdin):
     if inst['Key']['Hostname'] == '172.30.0.21':
         print(inst.get('ClusterName', ''))
         sys.exit(0)
 " 2>/dev/null || echo "")
             # Verify pgprimary (172.30.0.20) joined the same cluster as pgstandby1
-            PRIMARY_CLUSTER=$(curl -s --max-time 10 "$ORC_URL/api/all-instances" 2>/dev/null | python3 -c "
+            PRIMARY_CLUSTER=$(printf '%s' "$ALL_INSTANCES" | python3 -c "
 import json, sys
 for inst in json.load(sys.stdin):
     if inst['Key']['Hostname'] == '172.30.0.20':
         print(inst.get('ClusterName', ''))
         sys.exit(0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/functional/test-postgresql.sh` around lines 255 - 287, The loop
currently calls /api/all-instances twice and assigns to NEW_CLUSTER and
PRIMARY_CLUSTER from separate snapshots, causing flakiness; change to fetch the
topology once per iteration (e.g., store the curl output into a local variable
like SNAPSHOT_JSON) and then extract both NEW_CLUSTER and PRIMARY_CLUSTER from
that same JSON payload (use the existing python3 parsing blocks or jq) so both
values come from the identical snapshot before comparing them and before any
re-seed calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/functional/test-postgresql.sh`:
- Around line 155-177: The gate SWITCHOVER_OK is set true after checking
PROMOTED only, allowing later steps to proceed even if DEMOTED_RO check fails;
change the logic so SWITCHOVER_OK is only set to true when both conditions
succeed: check PROMOTED (pg_is_in_recovery=false) and then check DEMOTED_RO
(SHOW default_transaction_read_only = "on"), and only set SWITCHOVER_OK=true
after the DEMOTED_RO branch confirms success (or compute SWITCHOVER_OK as the
conjunction of the two checks using PROMOTED and DEMOTED_RO variables); update
references to SWITCHOVER_OK accordingly so downstream round-trip runs only when
both promotion and demotion succeeded.

---

Outside diff comments:
In `@tests/functional/test-postgresql.sh`:
- Around line 289-316: The script currently always reactivates pgstandby1 even
if the round-trip checks failed; wrap the reactivation block (the lines touching
pgstandby1: the touch/chown and $COMPOSE restart pgstandby1) in a guard that
only runs when both BACK_CODE = "OK" and BACK_PROMOTED = "f" (i.e. after the
pass calls), or alternatively add an immediate exit/return after the fail calls;
reference BACK_CODE, BACK_PROMOTED, the pass/fail calls and the pgstandby1
reactivation commands to locate and change the logic so no mutation happens when
the round-trip failed.

---

Nitpick comments:
In `@tests/functional/test-postgresql.sh`:
- Around line 255-287: The loop currently calls /api/all-instances twice and
assigns to NEW_CLUSTER and PRIMARY_CLUSTER from separate snapshots, causing
flakiness; change to fetch the topology once per iteration (e.g., store the curl
output into a local variable like SNAPSHOT_JSON) and then extract both
NEW_CLUSTER and PRIMARY_CLUSTER from that same JSON payload (use the existing
python3 parsing blocks or jq) so both values come from the identical snapshot
before comparing them and before any re-seed calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5bd71028-cc57-4822-bf9e-15dc1a716448

📥 Commits

Reviewing files that changed from the base of the PR and between 4457c7a and 36bcf26.

📒 Files selected for processing (1)
  • tests/functional/test-postgresql.sh

Comment thread tests/functional/test-postgresql.sh
@renecannao

Copy link
Copy Markdown
Author

Follow-up commit d5e02264 — the round-trip's wait-for-LSN timed out because the demoted primary's WAL receiver could not authenticate to the new primary. PostgreSQLReconfigureStandby sets primary_conninfo with orchestrator's credentials, but the test cluster's pg_hba.conf only allowed replication for the repl user. Added a host replication orchestrator entry so the restart completes end-to-end.

@renecannao

Copy link
Copy Markdown
Author

Re-running functional-postgresql jobs — previous CI logs expired (HTTP 410). Investigating remaining failures.

renecannao and others added 3 commits July 27, 2026 17:03
…a cluster view

The post-merge CI run surfaced that the inherited "primary did not change"
check is unreliable for PG graceful takeover. Because the demoted primary
keeps running until the operator restarts it with standby.signal,
orchestrator sees two cluster roots afterward (one per host, each
RO=false), and "find RO=false in original cluster" returns the same host
both before and after — producing a spurious failure.

Replace with direct PostgreSQL-level verification:

- pgstandby1 has pg_is_in_recovery()=false (promoted)
- pgprimary has default_transaction_read_only=on (demoted)

Existing writability and primary_conninfo checks remain.

The round-trip section now gates on SWITCHOVER_OK from the direct check
and discovers the new cluster name dynamically (pgstandby1 becomes a new
cluster root after the first takeover, so the round-trip API call must
target its cluster, not the original PG_CLUSTER). The round-trip
completion check also uses pg_is_in_recovery() on pgprimary instead of
the split-brain cluster view.
The round-trip round of the PG functional test hung for 60s on the
second graceful-master-takeover-auto: the wait-for-LSN step never
completed because the demoted primary's WAL receiver could not
authenticate to the new primary.

Root cause: PostgreSQLReconfigureStandby / PostgreSQLRepositionAsStandby
set primary_conninfo using orchestrator's credentials
(PostgreSQLTopologyUser / PostgreSQLTopologyPassword), but the test
cluster's pg_hba.conf only had "host replication repl" — no entry for
the orchestrator user. Streaming replication was silently rejected.

Add "host replication orchestrator all md5" so that once the operator
restarts the demoted primary with standby.signal, the primary_conninfo
orchestrator set actually authenticates. This matches the operational
prerequisite a real deployment would need to satisfy for the
PostGracefulTakeoverProcesses hook to work with only a restart.
First graceful switchover already succeeded under #100's PG-level checks,
but the round-trip failed: restarting the demoted primary with only
standby.signal left it past the new timeline fork, so it could not stream
and catch-up timed out (pg_is_in_recovery stayed true).

- Named volumes for PG data so datadir can be rewritten offline
- Reclone demoted primary via pg_basebackup onto the new primary
- Wait for walreceiver streaming before asserting topology
- Enable wal_log_hints for operators who prefer pg_rewind
@renecannao
renecannao force-pushed the fix/postgresql-switchover-test-detection branch from d5e0226 to 0b1860c Compare July 27, 2026 15:06
The reclone wait loop compared against t,streaming, but casting the
bool to text yields true/false, so a healthy streaming standby was
treated as a failure (last=true,streaming).
@renecannao
renecannao merged commit 0b79ec0 into master Jul 27, 2026
17 checks passed
@renecannao
renecannao deleted the fix/postgresql-switchover-test-detection branch July 27, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant