fix(controller-status): ctrlstatus_fill must prefer a live registry slot over a stale disconnected one (task 8.6, bug-1819)

Found live during task 8.5's optional catching-up-observation attempt:
a killed-then-reconnected standby can end up occupying a NEW
replicator_t.standbys[] slot while its OLD slot (host/port still
populated, ep == NULL) also matches the same configured peer.
ctrlstatus_fill's primary-side peer-status loop stopped at the first
host:port match, so it permanently reported that peer dead even
minutes after it had genuinely reconnected and fully caught up --
violating Requirement 13.3's "true, currently-known connection status"
guarantee.

Fix: scan all MAX_STANDBYS slots for a given peer and prefer any slot
with ep != NULL over a slot with ep == NULL; fall back to dead only
when no matching slot is live. Read-only, lock scope unchanged from
task 8.2.

TDD RED->GREEN (1216 Tests: 2 Failures -> 0), independently reproduced
by the reviewer (exact RED replay) plus two independent live
kill/backlog/restart/poll cycles on separate fresh clusters, both
confirming the previously-stuck standby now converges to ok.

design.md's Locked Decision 3 corrected to document the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
This commit is contained in:
Rongsong Shen 2026-09-16 17:53:13 +00:00
parent 464489d972
commit 6c437e2bed
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
5 changed files with 790 additions and 440 deletions

View file

@ -253,22 +253,43 @@ void ctrlstatus_fill(cluster_t* c, xce_ctrlstatus_t* st)
} else if (is_primary) {
/* Reuses repl_active_standby_count's exact ep != NULL &&
* !in_catch_up predicate (repl.c) rather than inventing a
* new one (Locked Decision 3). */
uint8_t status = 3u; /* default: no matching live slot */
* new one (Locked Decision 3).
*
* Scans ALL MAX_STANDBYS slots (does NOT stop at the first
* host/port match) and prefers any slot with ep != NULL
* over a slot with ep == NULL when multiple slots match the
* same configured peer's host:port (task 8.6 fix). A killed
* standby's slot is freed (ep cleared) but keeps its
* host/port populated; when it reconnects, repl_add_standby's
* ep-identity idempotency check cannot reuse that now-ep-NULL
* slot, so the reconnect lands in a NEW slot -- leaving two
* slots that share the same peer's host:port, one stale
* (ep == NULL) and one live (ep != NULL). A first-match-wins
* scan reported the stale, lower-index slot's dead status
* forever, even after the peer genuinely reconnected and
* caught up. If more than one live slot somehow matches, any
* one of them is an acceptable source -- they would all be
* observing the same live peer. */
uint8_t status = 3u; /* default: no matching slot at all */
int live_match = 0; /* found a slot with ep != NULL */
for (int j = 0; j < MAX_STANDBYS; j++) {
if (strcmp(repl->standbys[j].host,
mc->peers_cfg.candidates[i].hostname) == 0 &&
repl->standbys[j].port == p->port) {
if (repl->standbys[j].ep == NULL) {
status = 3u; /* matched slot, no live conn */
} else if (atomic_load_explicit(
&repl->standbys[j].in_catch_up,
memory_order_acquire)) {
status = 2u; /* catching-up */
} else {
status = 1u; /* ok */
}
break;
mc->peers_cfg.candidates[i].hostname) != 0 ||
repl->standbys[j].port != p->port) {
continue; /* not a match for this peer */
}
if (repl->standbys[j].ep == NULL) {
if (!live_match)
status = 3u; /* stale match; keep scanning for a live one */
continue;
}
if (!live_match) {
live_match = 1;
status = atomic_load_explicit(
&repl->standbys[j].in_catch_up,
memory_order_acquire)
? 2u /* live, catching-up */
: 1u; /* live, caught up: ok */
}
}
p->status = status;