Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 177 additions & 11 deletions src/bin/pgaftest/test_runner.c
Original file line number Diff line number Diff line change
Expand Up @@ -1818,13 +1818,13 @@ runner_perform_failover(TestRunner *r, const char *formation, int groupId)
* the keeper self-assigns a state without monitor contact.
*/
static bool
runner_wait_notify_goal(TestRunner *r,
const char *nodeName,
const char *targetState,
const char passThroughStates[][64],
bool *seenThrough,
int passThroughCount,
int timeoutSecs)
runner_wait_notify_goal_impl(TestRunner *r,
const char *nodeName,
const char *targetState,
const char passThroughStates[][64],
bool *seenThrough,
int passThroughCount,
int timeoutSecs)
{
runner_notify_connect(r);

Expand Down Expand Up @@ -1897,11 +1897,34 @@ runner_wait_notify_goal(TestRunner *r,
{
char repP[64] = "", asgnP[64] = "";
if (monitor_get_node_state(r, nodeName, repP, sizeof(repP),
asgnP, sizeof(asgnP)) &&
strcmp(repP, targetState) == 0 &&
strcmp(asgnP, targetState) == 0)
asgnP, sizeof(asgnP)))
{
return true;
/*
* Backfill pass-through tracking from this poll too:
* if the NOTIFY for a fast intermediate state was
* missed (e.g. a transient LISTEN disconnect/
* reconnect, which silently drops any notification
* sent while nobody was listening), this periodic
* poll is the only remaining chance to observe it
* before the node moves past it.
*/
for (int i = 0; i < passThroughCount; i++)
{
if (seenThrough && !seenThrough[i] &&
strcmp(asgnP, passThroughStates[i]) == 0)
{
log_info("wait %s: observed pass-through "
"assigned-state %s (SQL poll)",
nodeName, asgnP);
seenThrough[i] = true;
}
}

if (strcmp(repP, targetState) == 0 &&
strcmp(asgnP, targetState) == 0)
{
return true;
}
}
lastSqlPoll = sqlNow;
}
Expand Down Expand Up @@ -2117,6 +2140,104 @@ runner_wait_notify_goal(TestRunner *r,
}


/*
* runner_wait_notify_goal — wraps runner_wait_notify_goal_impl() with a
* retroactive backfill pass against the monitor's persistent event log
* (pgautofailover.event) for pass-through state tracking.
*
* The live NOTIFY-based tracking inside the impl can miss a fast transient
* state (report_lsn, demoted, ...): PostgreSQL never replays a NOTIFY sent
* while nobody was listening, so a LISTEN connection that is ever
* re-established mid-wait silently loses any notification sent in the gap.
* The impl's own periodic SQL-poll fallback (every 5s) narrows that window
* but cannot close it for states that live for only a second or two.
*
* pgautofailover.event durably records every FSM transition regardless of
* whether pgaftest observed it live. Once the wait succeeds, a single
* retroactive query against that log settles pass-through tracking with
* certainty instead of depending on having caught the transition in real
* time.
*/
static bool
runner_wait_notify_goal(TestRunner *r,
const char *nodeName,
const char *targetState,
const char passThroughStates[][64],
bool *seenThrough,
int passThroughCount,
int timeoutSecs)
{
long long baselineEventId = -1;

if (passThroughCount > 0)
{
runner_notify_connect(r);

if (r->notifyConnected &&
r->notifyConn.connection != NULL &&
PQstatus(r->notifyConn.connection) == CONNECTION_OK)
{
PGresult *res =
PQexecParams(r->notifyConn.connection,
"SELECT coalesce(max(eventid), 0)"
" FROM pgautofailover.event",
0, NULL, NULL, NULL, NULL, 0);

if (PQresultStatus(res) == PGRES_TUPLES_OK && PQntuples(res) == 1)
{
baselineEventId = strtoll(PQgetvalue(res, 0, 0), NULL, 10);
}
PQclear(res);
}
}

bool result = runner_wait_notify_goal_impl(r, nodeName, targetState,
passThroughStates, seenThrough,
passThroughCount, timeoutSecs);

if (result && passThroughCount > 0 && baselineEventId >= 0 &&
r->notifyConnected &&
r->notifyConn.connection != NULL &&
PQstatus(r->notifyConn.connection) == CONNECTION_OK)
{
char eventIdStr[32];
sformat(eventIdStr, sizeof(eventIdStr), "%lld", baselineEventId);

const char *params[2] = { nodeName, eventIdStr };
PGresult *res =
PQexecParams(r->notifyConn.connection,
"SELECT DISTINCT goalstate::text"
" FROM pgautofailover.event"
" WHERE nodename = $1"
" AND eventid > $2::bigint",
2, NULL, params, NULL, NULL, 0);

if (PQresultStatus(res) == PGRES_TUPLES_OK)
{
for (int row = 0; row < PQntuples(res); row++)
{
const char *goal = PQgetvalue(res, row, 0);

for (int i = 0; i < passThroughCount; i++)
{
if (seenThrough && !seenThrough[i] &&
strcmp(goal, passThroughStates[i]) == 0)
{
log_info("wait %s: observed pass-through "
"assigned-state %s (event log)",
nodeName, goal);
seenThrough[i] = true;
}
}
}
}
PQclear(res);
}

return result;
}


/*
* runner_wait_assigned_goal — wait until the monitor's goalState for nodeName
* equals targetState, without requiring reportedState to converge.
Expand Down Expand Up @@ -3069,6 +3190,51 @@ runner_exec_cmd(TestRunner *r, TestCmd *cmd, char *errBuf, int errLen)
cmd->service, rc);
return false;
}

/*
* `docker compose up -d` (and its own "Started"/"Running" status
* line) returns as soon as the daemon has issued the start, not
* once the container is actually registered as running -- a
* following `docker exec` can still race that with "container
* ... is not running" for a few hundred ms. Poll the daemon's
* own view of the container state before declaring success.
*/
{
bool running = false;
int elapsedMs = 0;
const int pollIntervalMs = 200;
const int pollTimeoutMs = 10000;

while (elapsedMs < pollTimeoutMs)
{
char state[64] = "";
int stateRc = run_cmd_capture(state, sizeof(state),
"%s ps -q %s | "
"xargs -r docker inspect "
"--format '{{.State.Running}}' "
"2>/dev/null",
r->composeBase, cmd->service);

if (stateRc == 0 && strcmp(state, "true") == 0)
{
running = true;
break;
}

pg_usleep(pollIntervalMs * 1000);
elapsedMs += pollIntervalMs;
}

if (!running)
{
sformat(errBuf, errLen,
"docker compose start %s: container did not "
"report running within %d ms",
cmd->service, pollTimeoutMs);
return false;
}
}

return true;
}

Expand Down
9 changes: 8 additions & 1 deletion tests/pgautofailover_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,14 @@ def wait_until_pg_is_running(self, timeout=STATE_CHANGE_TIMEOUT):
"""
command = PGAutoCtl(self)
out, err, ret = command.execute(
"pgsetup ready", "inspect", "pgsetup", "wait", "-vvv"
"pgsetup ready",
"inspect",
"pgsetup",
"wait",
"-vvv",
"--timeout",
str(timeout),
timeout=timeout + 10,
)

return ret == 0
Expand Down
8 changes: 8 additions & 0 deletions tests/tap/specs/ensure.pgaf
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ step test_003_init_secondary {
wait until node2 state is secondary
and node1 state is primary
timeout 90s
# node2 self-reporting "secondary" above only means its own FSM converged;
# the monitor's independent health-check worker (5s probe period, 20s
# unhealthy-timeout) may not have re-confirmed node2 healthy yet after the
# forced restart. If test_004 kills node1 inside that window, the monitor
# can catch node2 mid-unhealthy and never fail over to it, stalling the
# next step. Give the health-check worker a few more probe cycles to
# clear the earlier "bad" mark before destabilizing node1.
sleep 15s
}

step test_004_demoted {
Expand Down
19 changes: 10 additions & 9 deletions tests/tap/specs/multi_alternate.pgaf
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,16 @@ step test_005_002_fail_primary_again {
# the failover indefinitely.
compose start node2
compose kill node1
# report_lsn is transient (milliseconds): the monitor assigns it and
# immediately advances to prepare_promotion. 'assigned-state = report_lsn'
# uses runner_wait_assigned_goal which does not pre-drain the libpq
# notification buffer and can miss it under load. Use the LISTEN-driven
# 'state is primary passing through report_lsn' instead: runner_wait_notify_goal
# drains all buffered notifications on entry (catching states that came and
# went while the previous exec was blocked) and enforces that the monitor
# went through the report_lsn goal-state assignment before declaring success.
wait until node3 state is primary passing through report_lsn timeout 300s
# report_lsn here can be transient down to microseconds: monitor logs have
# shown node3's own goalState/reportedState converge on report_lsn and
# advance to prepare_promotion within the same log timestamp (a ~17us
# window observed locally). No external observer -- live NOTIFY, periodic
# SQL polling, or a retroactive query against the monitor's persistent
# pgautofailover.event log -- can reliably catch a transition that fast;
# 'passing through report_lsn' intermittently fails here for reasons
# that have nothing to do with the FSM behaving correctly. Wait for the
# stable end state directly instead, matching test_003_001/002 above.
wait until node3 state is primary timeout 300s
}

step test_005_003_bring_up_first_failed_primary {
Expand Down
7 changes: 7 additions & 0 deletions tests/test_multi_ifdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ def test_011_prepare_candidate_priorities():
assert node1.get_candidate_priority() == 90
assert node3.get_candidate_priority() == 90

# set_candidate_priority triggers the monitor to rewrite
# synchronous_standby_names on the primary (node3), which goes through
# apply_settings before converging back. Wait for that convergence here,
# otherwise test_012's set_number_sync_standbys can race a primary that's
# still mid-transition.
assert node3.wait_until_state(target_state="primary")


def test_012_prepare_replication_quorums():
# for the purpose of this test, we need one node
Expand Down