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:
parent
464489d972
commit
6c437e2bed
5 changed files with 790 additions and 440 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -986,6 +986,111 @@ static void test_ctrlstatus_peer_status_self_mismatch_falls_through(void)
|
|||
printf("test_ctrlstatus_peer_status_self_mismatch_falls_through: PASS\n");
|
||||
}
|
||||
|
||||
/* (r) PRIMARY: a killed-then-reconnected standby ends up owning TWO
|
||||
* replicator_t.standbys[] slots for the SAME configured peer -- a
|
||||
* lower-index slot left behind by the kill (ep == NULL, host/port still
|
||||
* populated -- repl_add_standby's ep-identity idempotency check cannot
|
||||
* reuse it, so the reconnect lands in a fresh, higher-index slot) and a
|
||||
* higher-index slot for the genuine live reconnection (ep != NULL). A
|
||||
* first-match-wins scan (pre-fix) reports the stale slot's dead status
|
||||
* PERMANENTLY; the fix must scan every slot and prefer any live
|
||||
* (ep != NULL) match over a stale (ep == NULL) one, regardless of which
|
||||
* index the live slot lands in (Requirements 13.1, 13.3; task 8.6). */
|
||||
static void test_ctrlstatus_peer_status_prefers_live_slot_over_stale_ok(void)
|
||||
{
|
||||
cluster_t c;
|
||||
myconf_t mc;
|
||||
cs_setup_cluster(&c, &mc);
|
||||
|
||||
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
||||
memory_order_release);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
||||
c.ha.repl = &c.persist.repl;
|
||||
|
||||
/* Stale slot (lower index): ctrlB's OLD connection, killed -- ep
|
||||
* cleared but host/port left behind, exactly as repl_remove_standby
|
||||
* leaves it (this fixture models the pre-existing registry state,
|
||||
* regardless of how it got there -- ctrlstatus_fill's boundary is
|
||||
* reading it correctly, not preventing it). */
|
||||
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
||||
sizeof(c.persist.repl.standbys[0].host) - 1);
|
||||
c.persist.repl.standbys[0].port = 7002;
|
||||
c.persist.repl.standbys[0].ep = NULL;
|
||||
|
||||
/* Live slot (higher index): ctrlB's NEW connection after reconnecting,
|
||||
* fully caught up. */
|
||||
int dummy_ep;
|
||||
strncpy(c.persist.repl.standbys[1].host, "10.0.0.2",
|
||||
sizeof(c.persist.repl.standbys[1].host) - 1);
|
||||
c.persist.repl.standbys[1].port = 7002;
|
||||
c.persist.repl.standbys[1].ep = (repl_ep_t)&dummy_ep;
|
||||
atomic_store_explicit(&c.persist.repl.standbys[1].in_catch_up, 0,
|
||||
memory_order_release);
|
||||
|
||||
/* ctrlC: no matching slot at all -- unaffected, still dead. */
|
||||
|
||||
xce_ctrlstatus_t st;
|
||||
ctrlstatus_fill(&c, &st);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(3, st.peers.count);
|
||||
TEST_ASSERT_EQUAL_STRING("ctrlA", st.peers.list[0]->name);
|
||||
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[0]->status); /* self: ok */
|
||||
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
||||
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[1]->status); /* live slot wins: ok */
|
||||
TEST_ASSERT_EQUAL_STRING("ctrlC", st.peers.list[2]->name);
|
||||
TEST_ASSERT_EQUAL_UINT8(3, st.peers.list[2]->status); /* unaffected: dead */
|
||||
|
||||
ctrlstatus_free_fields(&st);
|
||||
repl_fini(&c.persist.repl);
|
||||
cs_teardown_cluster(&c);
|
||||
printf("test_ctrlstatus_peer_status_prefers_live_slot_over_stale_ok: PASS\n");
|
||||
}
|
||||
|
||||
/* (s) Sibling of (r): the live slot is still catching up (not yet acked up
|
||||
* to the cluster) -- the stale/live preference must still hold, and the
|
||||
* reported status must be catching-up (2), not dead (3) and not the
|
||||
* unconditional ok a naive "any live slot -> ok" fix would wrongly give. */
|
||||
static void test_ctrlstatus_peer_status_prefers_live_slot_catching_up(void)
|
||||
{
|
||||
cluster_t c;
|
||||
myconf_t mc;
|
||||
cs_setup_cluster(&c, &mc);
|
||||
|
||||
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
||||
memory_order_release);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
||||
c.ha.repl = &c.persist.repl;
|
||||
|
||||
/* Stale slot (lower index): ctrlB's OLD connection, killed. */
|
||||
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
||||
sizeof(c.persist.repl.standbys[0].host) - 1);
|
||||
c.persist.repl.standbys[0].port = 7002;
|
||||
c.persist.repl.standbys[0].ep = NULL;
|
||||
|
||||
/* Live slot (higher index): ctrlB's NEW connection, still catching up. */
|
||||
int dummy_ep;
|
||||
strncpy(c.persist.repl.standbys[1].host, "10.0.0.2",
|
||||
sizeof(c.persist.repl.standbys[1].host) - 1);
|
||||
c.persist.repl.standbys[1].port = 7002;
|
||||
c.persist.repl.standbys[1].ep = (repl_ep_t)&dummy_ep;
|
||||
atomic_store_explicit(&c.persist.repl.standbys[1].in_catch_up, 1,
|
||||
memory_order_release);
|
||||
|
||||
xce_ctrlstatus_t st;
|
||||
ctrlstatus_fill(&c, &st);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(3, st.peers.count);
|
||||
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
||||
TEST_ASSERT_EQUAL_UINT8(2, st.peers.list[1]->status); /* live slot wins: catching-up */
|
||||
|
||||
ctrlstatus_free_fields(&st);
|
||||
repl_fini(&c.persist.repl);
|
||||
cs_teardown_cluster(&c);
|
||||
printf("test_ctrlstatus_peer_status_prefers_live_slot_catching_up: PASS\n");
|
||||
}
|
||||
|
||||
/* ── suite entry point ─────────────────────────────────────── */
|
||||
|
||||
void test_ctrlstatus(void)
|
||||
|
|
@ -1011,4 +1116,6 @@ void test_ctrlstatus(void)
|
|||
RUN_TEST(test_ctrlstatus_peer_status_primary_sourced_from_registry);
|
||||
RUN_TEST(test_ctrlstatus_peer_status_standby_unknown_except_self);
|
||||
RUN_TEST(test_ctrlstatus_peer_status_self_mismatch_falls_through);
|
||||
RUN_TEST(test_ctrlstatus_peer_status_prefers_live_slot_over_stale_ok);
|
||||
RUN_TEST(test_ctrlstatus_peer_status_prefers_live_slot_catching_up);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue