fix(controller): record standby state under its absolute peer index (bug-1891)

A promoted vote endpoint carries a peer index relative to the votes
array (which leaves out the primary); a late-joiner's heartbeat endpoint
carries the absolute index. repl_ack_decode_and_dispatch recorded state
reports under the raw index, so with ctrl-1 primary, ctrl-3's vote
endpoint (relative 1) and a rejoined ctrl-2's heartbeat endpoint
(absolute 1) shared standby_reported_*[1]. Each handler then acted on
the other's position over its own endpoint: the snapshot meant for the
empty, rejoining ctrl-2 went to the caught-up ctrl-3, and ctrl-2 never
caught up.

Resolve the absolute index with ha_repl_ack_abs_peer_idx (as the
WAL-ack branch already did) for the state report and for
ha_walack_on_ack, and size catchup_inflight by MAX_HA_CANDIDATES so
every absolute index fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-09-29 15:12:53 +00:00
parent 1922ac803e
commit 4922f6c844
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
6 changed files with 188 additions and 10 deletions

View file

@ -2929,7 +2929,7 @@ static void catchup_lane_fn(void *arg)
clear_and_done: clear_and_done:
/* Req 5.2: release the single-flight slot so the next lagging ACK schedules /* Req 5.2: release the single-flight slot so the next lagging ACK schedules
* again. release order so the decision path's acquire load observes it. */ * again. release order so the decision path's acquire load observes it. */
if (peer_idx >= 0 && peer_idx < MAX_STANDBYS) if (peer_idx >= 0 && peer_idx < MAX_HA_CANDIDATES)
atomic_store_explicit(&c->ha.catchup_inflight[peer_idx], 0, atomic_store_explicit(&c->ha.catchup_inflight[peer_idx], 0,
memory_order_release); memory_order_release);
free(ctx); free(ctx);
@ -2990,7 +2990,7 @@ int ha_catchup_continuation_fn(void *arg)
* but whose key the lane has not yet removed. Release the guard so * but whose key the lane has not yet removed. Release the guard so
* the next WAL ack retries cleanly (mirrors ha_connect_to_peer's * the next WAL ack retries cleanly (mirrors ha_connect_to_peer's
* -EEXIST handling in ha.c). */ * -EEXIST handling in ha.c). */
if (peer_idx >= 0 && peer_idx < MAX_STANDBYS) if (peer_idx >= 0 && peer_idx < MAX_HA_CANDIDATES)
atomic_store_explicit(&c->ha.catchup_inflight[peer_idx], 0, atomic_store_explicit(&c->ha.catchup_inflight[peer_idx], 0,
memory_order_release); memory_order_release);
free(ctx); free(ctx);
@ -3004,7 +3004,7 @@ int ha_catchup_continuation_fn(void *arg)
* ack, unlike the one-shot demotion-convergence task). */ * ack, unlike the one-shot demotion-convergence task). */
WARN("HA catch-up continuation: slowlane_submit failed for " WARN("HA catch-up continuation: slowlane_submit failed for "
"standby[%d]: %d — retry next ack", peer_idx, srv); "standby[%d]: %d — retry next ack", peer_idx, srv);
if (peer_idx >= 0 && peer_idx < MAX_STANDBYS) if (peer_idx >= 0 && peer_idx < MAX_HA_CANDIDATES)
atomic_store_explicit(&c->ha.catchup_inflight[peer_idx], 0, atomic_store_explicit(&c->ha.catchup_inflight[peer_idx], 0,
memory_order_release); memory_order_release);
free(ctx); free(ctx);
@ -3061,7 +3061,7 @@ int ha_walack_on_ack(cluster_t *c, void *ep, int peer_idx, uint64_t acked_lsn)
{ {
if (c == NULL || ep == NULL) if (c == NULL || ep == NULL)
return -EINVAL; return -EINVAL;
if (peer_idx < 0 || peer_idx >= MAX_STANDBYS) if (peer_idx < 0 || peer_idx >= MAX_HA_CANDIDATES)
return -EINVAL; return -EINVAL;
uint64_t target = catchup_derive_target(c); uint64_t target = catchup_derive_target(c);

View file

@ -6870,8 +6870,14 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx,
(void)raft_on_higher_term_observed(&ha->raft, sr.term); (void)raft_on_higher_term_observed(&ha->raft, sr.term);
pthread_mutex_unlock(&ha->raft_lock); pthread_mutex_unlock(&ha->raft_lock);
/* peer_idx from standby_ep_ctx_t (layout-compatible with vote_ep_ctx_t) */ /* bug-1891: the ABSOLUTE peer index. A promoted vote ep carries a
int peer_idx = vctx->peer_idx; * relative votes index (see ha_repl_ack_abs_peer_idx); recording
* its report under the raw index shared a slot with another
* standby's heartbeat ep, so one handler acted on the other's
* position -- e.g. sent a rejoining standby's snapshot to a peer
* that was already caught up. */
int peer_idx = ha_repl_ack_abs_peer_idx(
ha, /*ep_is_vote*/ vctx->ctx != NULL, vctx->peer_idx);
uint64_t committed_lsn = 0; uint64_t committed_lsn = 0;
uint64_t committed_tag = 0; uint64_t committed_tag = 0;
uint64_t last_written = 0; /* latest WAL LSN — may be > committed */ uint64_t last_written = 0; /* latest WAL LSN — may be > committed */
@ -6961,7 +6967,7 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx,
* decision the unit test drives via ha_walack_continuation_for_test. * decision the unit test drives via ha_walack_continuation_for_test.
* Scheduling is O(1) on iomgr; all WAL file I/O runs in the * Scheduling is O(1) on iomgr; all WAL file I/O runs in the
* deferred continuation task. */ * deferred continuation task. */
ha_walack_on_ack(c, ep, vctx->peer_idx, acked_lsn); ha_walack_on_ack(c, ep, ack_abs_idx, acked_lsn); /* bug-1891 */
} else if (!success) { } else if (!success) {
/* bug-1421: a WAL NACK can also carry a higher term (the /* bug-1421: a WAL NACK can also carry a higher term (the
* standby rejected this entry as stale) — route it through * standby rejected this entry as stale) — route it through
@ -7043,6 +7049,26 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx,
return 0; return 0;
} }
#ifdef XCE_TESTING
/* bug-1891: drive repl_ack_decode_and_dispatch for one inbound packet on a
* promoted vote endpoint (ep_is_vote=1, peer_idx relative to the votes
* array) or a heartbeat standby endpoint (ep_is_vote=0, peer_idx absolute).
* Takes ownership of pkt. */
int ha_repl_ack_dispatch_for_test(ha_state_t *ha, endpoint_t *ep,
int ep_is_vote, int peer_idx,
io_packet_t *pkt)
{
static ha_elect_ctx_t vote_ctx; /* only its non-NULL-ness is read */
vote_ep_ctx_t vctx;
memset(&vctx, 0, sizeof(vctx));
vctx.ha = ha;
vctx.ctx = ep_is_vote ? &vote_ctx : NULL;
vctx.peer_idx = peer_idx;
return repl_ack_decode_and_dispatch(ep, &vctx, pkt);
}
#endif
/* /*
* peer_ctrl_msg_handler_inline: the state-aware peer-control routing body. * peer_ctrl_msg_handler_inline: the state-aware peer-control routing body.
* Routes by ha->role at dispatch time — no explicit handler reassignment needed. * Routes by ha->role at dispatch time — no explicit handler reassignment needed.

View file

@ -279,7 +279,7 @@ typedef struct {
* lifetime cannot re-fire the restart. * lifetime cannot re-fire the restart.
* 0 at init (cleared by ha_init memset). */ * 0 at init (cleared by ha_init memset). */
/* ── New field added by ha-convergence-recovery spec (Task 4.x) ──────── */ /* ── New field added by ha-convergence-recovery spec (Task 4.x) ──────── */
_Atomic int catchup_inflight[MAX_STANDBYS]; /* Per-standby single-flight _Atomic int catchup_inflight[MAX_HA_CANDIDATES]; /* Per-standby (absolute peer index, bug-1891) single-flight
* guard for ACK-driven catch-up * guard for ACK-driven catch-up
* continuation (Req 5.2): set when a * continuation (Req 5.2): set when a
* continuation is scheduled for a * continuation is scheduled for a
@ -667,6 +667,16 @@ int ha_raft_on_repl_nack(cluster_t *c, int abs_peer_idx, uint64_t reply_term,
* remap-vs-passthrough distinction cannot drift. */ * remap-vs-passthrough distinction cannot drift. */
int ha_repl_ack_abs_peer_idx(ha_state_t *ha, int ep_is_vote, int peer_idx); int ha_repl_ack_abs_peer_idx(ha_state_t *ha, int ep_is_vote, int peer_idx);
#ifdef XCE_TESTING
/* bug-1891: run one inbound packet through the primary's replication-reply
* dispatcher as if it arrived on a promoted vote endpoint (ep_is_vote=1,
* relative votes index) or a heartbeat standby endpoint (ep_is_vote=0,
* absolute index). Takes ownership of pkt. */
int ha_repl_ack_dispatch_for_test(ha_state_t *ha, endpoint_t *ep,
int ep_is_vote, int peer_idx,
io_packet_t *pkt);
#endif
/* /*
* ha_schedule_demotion_convergence: schedule the one-shot delaymgr task that * ha_schedule_demotion_convergence: schedule the one-shot delaymgr task that
* truncates above committed_lsn (Req 1.2), WARN-logs the discarded range * truncates above committed_lsn (Req 1.2), WARN-logs the discarded range
@ -762,7 +772,7 @@ void cluster_apply_ctrlhello_reply(cluster_t *c, uint64_t committed_lsn,
* c — cluster_t* (real persist + repl registry; never NULL). * c — cluster_t* (real persist + repl registry; never NULL).
* ep — repl registry endpoint key for the standby (endpoint_t* / * ep — repl registry endpoint key for the standby (endpoint_t* /
* repl_ep_t); identifies the standby slot for streaming + parity. * repl_ep_t); identifies the standby slot for streaming + parity.
* peer_idx — 0-based standby slot index (into c->ha.catchup_inflight[]). * peer_idx — absolute peer index (into c->ha.catchup_inflight[]).
* acked_lsn— the LSN the standby just acknowledged. * acked_lsn— the LSN the standby just acknowledged.
* *
* Returns 0 on the parity / single-flight-suppressed paths and on a successfully * Returns 0 on the parity / single-flight-suppressed paths and on a successfully
@ -2293,7 +2303,7 @@ void raft_cb_become_leader_with_cluster_for_test(cluster_t *c, uint64_t term);
* c — cluster_t* (real persist + repl registry; never NULL in tests) * c — cluster_t* (real persist + repl registry; never NULL in tests)
* ep — repl registry endpoint key for the standby (the same pointer * ep — repl registry endpoint key for the standby (the same pointer
* passed to repl_add_standby); identifies the standby slot. * passed to repl_add_standby); identifies the standby slot.
* peer_idx — 0-based standby slot index (into ha.catchup_inflight[]). * peer_idx — absolute peer index (into ha.catchup_inflight[]).
* acked_lsn— the LSN the standby just acknowledged. * acked_lsn— the LSN the standby just acknowledged.
* *
* Returns 0 on the no-op / parity paths and on a successfully scheduled * Returns 0 on the no-op / parity paths and on a successfully scheduled

View file

@ -175,6 +175,7 @@ controller_unit_test_src = [
'testsuite/test_dispatch_fail_retry.c', 'testsuite/test_dispatch_fail_retry.c',
'testsuite/test_jobdispatch_no_double_notify.c', 'testsuite/test_jobdispatch_no_double_notify.c',
'testsuite/test_worker_resync.c', 'testsuite/test_worker_resync.c',
'testsuite/test_standby_state_peer_idx.c',
'testsuite/test_upgrade_snapshot_restore.c', 'testsuite/test_upgrade_snapshot_restore.c',
'testsuite/test_upgrade_snapshot_apply.c', 'testsuite/test_upgrade_snapshot_apply.c',
'testsuite/test_bug857_regression.c', 'testsuite/test_bug857_regression.c',

View file

@ -112,6 +112,7 @@ extern void test_dispatch_fail_terminal(void);
extern void test_dispatch_fail_retry(void); extern void test_dispatch_fail_retry(void);
extern void test_jobdispatch_no_double_notify(void); extern void test_jobdispatch_no_double_notify(void);
extern void test_worker_resync(void); extern void test_worker_resync(void);
extern void test_standby_state_peer_idx(void);
void setUp(void) { return; } void setUp(void) { return; }
@ -301,6 +302,7 @@ int main(int argc, char* argv[])
TEST(test_dispatch_fail_retry); TEST(test_dispatch_fail_retry);
TEST(test_jobdispatch_no_double_notify); TEST(test_jobdispatch_no_double_notify);
TEST(test_worker_resync); TEST(test_worker_resync);
TEST(test_standby_state_peer_idx);
return UNITY_END(); return UNITY_END();
} }

View file

@ -0,0 +1,139 @@
/*
* test_standby_state_peer_idx.c — bug-1891: a standby's heartbeat state
* report must be recorded under that standby's own ABSOLUTE peer index.
*
* Two kinds of endpoint deliver state reports to the primary: a promoted vote
* endpoint (its vote_ep_ctx_t.peer_idx is RELATIVE to the votes array, which
* leaves out the primary itself) and a heartbeat standby endpoint (peer_idx
* already absolute). repl_ack_decode_and_dispatch recorded the report under
* the raw peer_idx. With ctrl-1 primary (self 0), ctrl-3's promoted vote
* endpoint (relative 1) and a rejoined ctrl-2's heartbeat endpoint
* (absolute 1) then shared ha->standby_reported_*[1]: one handler read the
* other's position and acted on its own endpoint, so the snapshot meant for
* the empty, rejoining ctrl-2 was sent to the caught-up ctrl-3 instead.
*/
#define _GNU_SOURCE
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "cluster.h"
#include "controller.capnp.h"
#include "ha.h"
#include "netio.h"
#include "request.h"
#define SENTINEL_LSN 777
#define CONFIG_TAG 42
static ha_state_t g_sp_ha;
static ha_config_t g_sp_cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
};
/* Primary at absolute index 0 of a 3-node cluster; every slot starts at a
* sentinel so a write to the wrong one shows. */
static void sp_setup(void)
{
int i;
TEST_ASSERT_EQUAL_INT(0, ha_init(&g_sp_ha, &g_sp_cfg, 0));
g_sp_ha.n_peers = 3;
atomic_store_explicit(&g_sp_ha.role, (int32_t)CTRL_PRIMARY,
memory_order_release);
atomic_store_explicit(&g_sp_ha.committed_config_tag, CONFIG_TAG,
memory_order_release);
atomic_store_explicit(&g_sp_ha.last_known_committed_lsn, 114,
memory_order_release);
for (i = 0; i < MAX_HA_CANDIDATES; i++) {
g_sp_ha.standby_reported_lsn[i] = SENTINEL_LSN;
g_sp_ha.standby_reported_tag[i] = CONFIG_TAG;
}
}
/* A heartbeat NOP reply carrying a state report, as the standby sends it. */
static io_packet_t* sp_state_report_pkt(uint64_t applied_lsn)
{
state_report_t sr;
xce_reply_t rep;
xce_request_t req;
io_packet_t* pkt;
memset(&sr, 0, sizeof(sr));
sr.applied_lsn = applied_lsn;
sr.config_tag = CONFIG_TAG;
memset(&rep, 0, sizeof(rep));
rep.opcode = OPCode_nop;
rep.data_which = RequestReply_data_stateReportMsg;
rep.data.state_report = &sr;
memset(&req, 0, sizeof(req));
req.opcode = OPCode_reply;
req.body_which = Request_body_replymsg;
req.body.replymsg = &rep;
pkt = calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
TEST_ASSERT_TRUE_MESSAGE(xce_encode(&req, pkt) >= 0,
"failed to encode the state report");
return pkt;
}
/* Baseline: a heartbeat standby endpoint's index is already absolute. */
static void test_heartbeat_ep_report_lands_on_its_absolute_index(void)
{
endpoint_t ep;
sp_setup();
memset(&ep, 0, sizeof(ep));
ha_repl_ack_dispatch_for_test(&g_sp_ha, &ep, /*ep_is_vote=*/0,
/*peer_idx=*/1, sp_state_report_pkt(5));
TEST_ASSERT_EQUAL_UINT64(5, g_sp_ha.standby_reported_lsn[1]);
TEST_ASSERT_EQUAL_UINT64(SENTINEL_LSN, g_sp_ha.standby_reported_lsn[2]);
ha_fini(&g_sp_ha);
printf("test_heartbeat_ep_report_lands_on_its_absolute_index: PASS\n");
}
/* THE BUG: a promoted vote endpoint's relative index 1 is peer 2 (self is
* 0); its report must not land on peer 1's slot. */
static void test_vote_ep_report_lands_on_its_absolute_index(void)
{
endpoint_t ep;
sp_setup();
memset(&ep, 0, sizeof(ep));
ha_repl_ack_dispatch_for_test(&g_sp_ha, &ep, /*ep_is_vote=*/1,
/*peer_idx=*/1, sp_state_report_pkt(5));
TEST_ASSERT_EQUAL_UINT64_MESSAGE(5, g_sp_ha.standby_reported_lsn[2],
"bug-1891: peer 2's state report was not recorded under peer 2");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(SENTINEL_LSN,
g_sp_ha.standby_reported_lsn[1],
"bug-1891: peer 2's state report overwrote peer 1's slot");
ha_fini(&g_sp_ha);
printf("test_vote_ep_report_lands_on_its_absolute_index: PASS\n");
}
void test_standby_state_peer_idx(void)
{
printf("\n>> standby state report peer index tests (bug-1891)\n\n");
RUN_TEST(test_heartbeat_ep_report_lands_on_its_absolute_index);
RUN_TEST(test_vote_ep_report_lands_on_its_absolute_index);
}