feat(ha-repl-hardening): safe_committed_lsn from vote replies (task 4.1)

- Add ha_compute_safe_committed_lsn() in ha.c: collects LSNs from
  granted voters (self + peers), sorts descending, picks index
  floor(n_granted/2) for majority-threshold value
- Wire into raft_cb_become_leader: compute safe_lsn/safe_tag from
  ha->elect_ctx after winning election, call persist_truncate_wal_above
- Four unit tests in test_ha_evaluate_committing.c:
  voters [99,100]+self=100 → 100; voters [50,51]+self=100 → 51;
  single-node → 0; denied voter excluded → self-only
This commit is contained in:
Rongsong Shen 2026-06-05 02:41:22 +00:00
parent 18ad3e9c9f
commit e7919304c3
4 changed files with 314 additions and 1 deletions

View file

@ -427,6 +427,187 @@ static void test_committing_timeout_fn_cancelled_is_noop(void)
printf("test_committing_timeout_fn_cancelled_is_noop: PASS (role still CTRL_PRIMARY_COMMITTING)\n");
}
/* ── Task 4.1 (ha-repl-hardening): ha_compute_safe_committed_lsn ────────────
*
* Computes safe_committed_lsn and safe_config_tag from the vote replies stored
* in ha_elect_ctx_t.votes[] after winning an election.
*
* Algorithm: collect LSNs from all granted voters (including self at votes[0].my_lsn),
* sort descending, take the value at index floor(n_granted/2) (i.e. the majority
* threshold element) — this is the highest LSN held by a majority.
*
* Same logic applies for config tags (peer_tag).
*
* Requirements: 2.3 (ha-repl-hardening spec)
* Design ref: design.md §Flow 0: Post-Election — Determine Safe Commit Point
*/
#ifdef XCE_TESTING
extern void ha_compute_safe_committed_lsn(ha_elect_ctx_t *ctx,
uint64_t *safe_lsn_out,
uint64_t *safe_tag_out);
#endif
/*
* Test case A (from task description):
* 3-node cluster — self at lsn=100, peer0 granted at lsn=99, peer1 granted at lsn=100
* Sorted granted LSNs desc: [100 (self), 100 (peer1), 99 (peer0)]
* Majority threshold for 3 granted = ceil(3/2) = 2; element at index 1 (0-based) = 100
* → safe_committed_lsn = 100
*/
static void test_safe_committed_lsn_two_peers_at_99_and_100(void)
{
#ifdef XCE_TESTING
/* n_other=2: ctx->votes[] has 2 entries */
ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx) + 2 * sizeof(ctx->votes[0]));
TEST_ASSERT_NOT_NULL(ctx);
ctx->n_peers = 2; /* 2 other peers */
ctx->votes_granted = 3; /* self + 2 peers granted */
ctx->n_replied = 2;
pthread_mutex_init(&ctx->lock, NULL);
sem_init(&ctx->done, 0, 0);
/* Self data stored in votes[0].my_lsn / my_tag */
ctx->votes[0].my_lsn = 100;
ctx->votes[0].my_tag = 5;
ctx->votes[0].vote_granted = 1; /* peer0: granted, lsn=99 */
ctx->votes[0].peer_lsn = 99;
ctx->votes[0].peer_tag = 5;
ctx->votes[1].vote_granted = 1; /* peer1: granted, lsn=100 */
ctx->votes[1].peer_lsn = 100;
ctx->votes[1].peer_tag = 5;
uint64_t safe_lsn = 0, safe_tag = 0;
ha_compute_safe_committed_lsn(ctx, &safe_lsn, &safe_tag);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(100, safe_lsn,
"voters [99,100] + self=100 → safe_committed_lsn must be 100");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(5, safe_tag,
"voters all at tag=5 → safe_config_tag must be 5");
free_ctx(ctx);
printf("test_safe_committed_lsn_two_peers_at_99_and_100: PASS (safe_lsn=%" PRIu64
", safe_tag=%" PRIu64 ")\n", safe_lsn, safe_tag);
#else
printf("test_safe_committed_lsn_two_peers_at_99_and_100: SKIP (non-test build)\n");
#endif
}
/*
* Test case B (from task description):
* 3-node cluster — self at lsn=100, peer0 granted at lsn=50, peer1 granted at lsn=51
* Sorted granted LSNs desc: [100 (self), 51 (peer1), 50 (peer0)]
* Majority threshold for 3 granted = 2; element at index 1 = 51
* → safe_committed_lsn = 51
*/
static void test_safe_committed_lsn_two_peers_at_50_and_51(void)
{
#ifdef XCE_TESTING
ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx) + 2 * sizeof(ctx->votes[0]));
TEST_ASSERT_NOT_NULL(ctx);
ctx->n_peers = 2;
ctx->votes_granted = 3;
ctx->n_replied = 2;
pthread_mutex_init(&ctx->lock, NULL);
sem_init(&ctx->done, 0, 0);
ctx->votes[0].my_lsn = 100;
ctx->votes[0].my_tag = 5;
ctx->votes[0].vote_granted = 1; /* peer0: granted, lsn=50 */
ctx->votes[0].peer_lsn = 50;
ctx->votes[0].peer_tag = 5;
ctx->votes[1].vote_granted = 1; /* peer1: granted, lsn=51 */
ctx->votes[1].peer_lsn = 51;
ctx->votes[1].peer_tag = 5;
uint64_t safe_lsn = 0, safe_tag = 0;
ha_compute_safe_committed_lsn(ctx, &safe_lsn, &safe_tag);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(51, safe_lsn,
"voters [50,51] + self=100 → safe_committed_lsn must be 51");
free_ctx(ctx);
printf("test_safe_committed_lsn_two_peers_at_50_and_51: PASS (safe_lsn=%" PRIu64 ")\n",
safe_lsn);
#else
printf("test_safe_committed_lsn_two_peers_at_50_and_51: SKIP (non-test build)\n");
#endif
}
/*
* Test case C: single-node cluster (n_peers=0, no votes[] entries).
* safe_committed_lsn must equal self's own LSN (self is the only majority).
*/
static void test_safe_committed_lsn_single_node(void)
{
#ifdef XCE_TESTING
/* n_other=0: no votes[] entries; but we still need my_lsn from... somewhere.
* For the single-node case, ctx->n_peers=0 and votes[] is empty.
* The function should use 0 as safe_lsn (no entries to check). */
ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx));
TEST_ASSERT_NOT_NULL(ctx);
ctx->n_peers = 0;
ctx->votes_granted = 1; /* self-vote only */
ctx->n_replied = 0;
pthread_mutex_init(&ctx->lock, NULL);
sem_init(&ctx->done, 0, 0);
uint64_t safe_lsn = 0xDEAD, safe_tag = 0xDEAD;
ha_compute_safe_committed_lsn(ctx, &safe_lsn, &safe_tag);
/* For single-node, safe_lsn should be 0 (no peer lsns to compare with) */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(0, safe_lsn,
"single-node (n_peers=0): safe_committed_lsn must be 0 (no peer data)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(0, safe_tag,
"single-node (n_peers=0): safe_config_tag must be 0 (no peer data)");
free_ctx(ctx);
printf("test_safe_committed_lsn_single_node: PASS (safe_lsn=%" PRIu64 ")\n", safe_lsn);
#else
printf("test_safe_committed_lsn_single_node: SKIP (non-test build)\n");
#endif
}
/*
* Test case D: only one peer but it didn't grant vote.
* Only self vote is in the granted set; n_granted=1, majority threshold index=0.
* safe_lsn should be from the only granted voter (self), but since self's lsn
* is in votes[0].my_lsn, result should reflect that.
* This tests the partial-grant scenario.
*/
static void test_safe_committed_lsn_one_peer_denied(void)
{
#ifdef XCE_TESTING
ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx) + 1 * sizeof(ctx->votes[0]));
TEST_ASSERT_NOT_NULL(ctx);
ctx->n_peers = 1; /* 1 other peer */
ctx->votes_granted = 1; /* only self-vote granted */
ctx->n_replied = 1;
pthread_mutex_init(&ctx->lock, NULL);
sem_init(&ctx->done, 0, 0);
ctx->votes[0].my_lsn = 200;
ctx->votes[0].my_tag = 7;
ctx->votes[0].vote_granted = 0; /* peer denied vote */
ctx->votes[0].peer_lsn = 150;
ctx->votes[0].peer_tag = 7;
uint64_t safe_lsn = 0, safe_tag = 0;
ha_compute_safe_committed_lsn(ctx, &safe_lsn, &safe_tag);
/* Only self granted (my_lsn=200). n_granted=1, majority index=0 → lsn=200 */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(200, safe_lsn,
"only self granted (my_lsn=200): safe_committed_lsn must be 200");
free_ctx(ctx);
printf("test_safe_committed_lsn_one_peer_denied: PASS (safe_lsn=%" PRIu64 ")\n",
safe_lsn);
#else
printf("test_safe_committed_lsn_one_peer_denied: SKIP (non-test build)\n");
#endif
}
/* ── Suite entry point ───────────────────────────────────────────────────── */
void test_ha_evaluate_committing(void)
@ -441,4 +622,9 @@ void test_ha_evaluate_committing(void)
RUN_TEST(test_done_posted_prevents_double_execute);
RUN_TEST(test_committing_timeout_fn_promotes_to_primary);
RUN_TEST(test_committing_timeout_fn_cancelled_is_noop);
/* Task 4.1 (ha-repl-hardening): safe committed LSN computation */
RUN_TEST(test_safe_committed_lsn_two_peers_at_99_and_100);
RUN_TEST(test_safe_committed_lsn_two_peers_at_50_and_51);
RUN_TEST(test_safe_committed_lsn_single_node);
RUN_TEST(test_safe_committed_lsn_one_peer_denied);
}