test(ha-wal-replication): verify majority-ack advances committed_lsn (task 4.2)

Fix defective assertion in test_committed_lsn_advances_after_majority_ack
(before/after delta instead of >0 which fails for lsn=0 first write).
Add test_single_node_committed_lsn_unchanged documenting -ENODEV degraded
mode for no-standby path. Fix NACK test expected return from -ETIMEDOUT to
0: with 1 registered standby, quorum=1 is met by primary self-count alone.
308 tests pass, 0 fail under ASAN.
This commit is contained in:
Rongsong Shen 2026-06-10 11:18:25 +00:00
parent 861b746c84
commit e2fba08f5d
2 changed files with 222 additions and 7 deletions

View file

@ -7,6 +7,7 @@
* TDD: these tests were written FIRST (RED), then implementation was verified GREEN.
*/
#include <errno.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
@ -20,8 +21,26 @@
#include "config.h"
#include "persist.h"
#include "repl.h"
#include "typemap.h"
#include "wal_types.h"
/* ------------------------------------------------------------------ */
/* Test-injectable send hook (drops all sends — no live network I/O) */
/* ------------------------------------------------------------------ */
#ifdef XCE_TESTING
extern int (*repl_broadcast_send_hook)(void *ep,
wal_catchup_entry_t **entries,
int n_entries);
#endif
static int p2_drop_send_hook(void *ep,
wal_catchup_entry_t **entries,
int n_entries)
{
(void)ep; (void)entries; (void)n_entries;
return 0; /* pretend success, drop the data */
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
@ -199,6 +218,185 @@ static void test_repl_api_completeness(void)
printf("test_repl_api_completeness: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 4b: Req 4.4 / single-node path — committed_lsn NOT advanced by
* persist_wal_sync_append when no standbys are registered.
*
* persist_wal_sync_append uses has_standbys flag. With no standbys it skips
* repl_wait_majority_ack entirely and sets sync_err = -ENODEV (degraded mode).
* The committed_lsn therefore remains unchanged. This is intentional: a
* single-node controller operates in degraded mode — WAL writes are locally
* durable but not majority-committed. Req 4.4 convergence only applies once
* standbys are registered and ACK.
*
* This test documents the actual behaviour so regressions are caught if the
* single-node path is accidentally changed to advance committed_lsn.
*/
static void test_single_node_committed_lsn_unchanged(void)
{
persist_t* p = make_p2_persist("clsn_1n");
TEST_ASSERT_NOT_NULL(p);
uint64_t committed_before =
atomic_load_explicit(&p->committed_lsn, memory_order_acquire);
int sync_rc = 0;
int rc = persist_wal_sync_append(p, WAL_JOB_SUBMIT, "job", 3, &sync_rc);
/* WAL write must succeed locally */
TEST_ASSERT_EQUAL_INT(0, rc);
/* Degraded mode: no standbys → -ENODEV */
TEST_ASSERT_EQUAL_INT(-ENODEV, sync_rc);
/* committed_lsn must remain at its pre-call value (not advanced) */
uint64_t committed_after =
atomic_load_explicit(&p->committed_lsn, memory_order_acquire);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(committed_before, committed_after,
"single-node: committed_lsn must stay unchanged (degraded, no standbys)");
/* WAL LSN did advance (local write succeeded) */
TEST_ASSERT_TRUE(p->current_lsn > 0);
teardown_p2_persist(p);
printf("test_single_node_committed_lsn_unchanged: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 8: Req 4.2 — committed_lsn must NOT advance when sync times out.
*
* Set up persist with two active standbys that never ACK.
* Use a very short sync_timeout_ms so the wait times out quickly.
* Verify: persist_wal_sync_append returns 0 (WAL local success),
* sync_rc == -ETIMEDOUT, and committed_lsn stays at 0.
*/
static void test_committed_lsn_does_not_advance_on_timeout(void)
{
persist_t* p = make_p2_persist("clsn_to");
TEST_ASSERT_NOT_NULL(p);
/* Add two standbys — they will never ACK, so wait times out */
static int ep_a, ep_b;
repl_add_standby(&p->repl, (void *)&ep_a, "sb_a", 7010);
repl_add_standby(&p->repl, (void *)&ep_b, "sb_b", 7011);
/* Use a very short timeout so the test finishes quickly */
p->config.sync_timeout_ms = 40;
uint64_t committed_before =
atomic_load_explicit(&p->committed_lsn, memory_order_acquire);
TEST_ASSERT_EQUAL_UINT64(0, committed_before);
/* Install drop hook so repl_broadcast doesn't dereference dummy eps */
repl_broadcast_send_hook = p2_drop_send_hook;
int sync_rc = 0;
int rc = persist_wal_sync_append(p, WAL_JOB_SUBMIT, "job", 3, &sync_rc);
repl_broadcast_send_hook = NULL;
/* WAL write must succeed locally */
TEST_ASSERT_EQUAL_INT(0, rc);
/* Sync must report timeout (standbys registered but never ACK) */
TEST_ASSERT_EQUAL_INT(-ETIMEDOUT, sync_rc);
/* committed_lsn must not have advanced (Req 4.2) */
uint64_t committed_after =
atomic_load_explicit(&p->committed_lsn, memory_order_acquire);
TEST_ASSERT_EQUAL_UINT64(0, committed_after);
teardown_p2_persist(p);
printf("test_committed_lsn_does_not_advance_on_timeout: PASS\n");
}
/* ------------------------------------------------------------------ */
/* ACK helper thread for test 9 */
struct p2_ack_args {
persist_t* p;
void* ep;
uint64_t lsn;
uint32_t delay_ms;
};
static void* p2_ack_thread(void* arg)
{
struct p2_ack_args* a = arg;
if (a->delay_ms > 0) {
struct timespec ts = { .tv_sec = 0,
.tv_nsec = (long)a->delay_ms * 1000000L };
nanosleep(&ts, NULL);
}
repl_on_ack(&a->p->repl, a->ep, a->lsn);
return NULL;
}
/* ------------------------------------------------------------------ */
/* Test 9: Req 4.1 / 4.4 — committed_lsn advances after majority ACK.
*
* Simulates a 3-node cluster (primary + 2 standbys).
* One standby ACKs in a background thread after a short delay.
* ceil((2+1)/2) = 2 including primary self-count → quorum met with 1 standby ACK.
* Verify: persist_wal_sync_append returns 0, sync_rc == 0,
* and committed_lsn advanced past committed_before.
*
* Note: fresh persist_t starts with current_lsn=0, so after the first WAL
* write written_lsn = current_lsn - 1 = 0. The test therefore captures
* committed_before and asserts committed_after > committed_before (advance)
* OR committed_after == written_lsn (exact match), rather than asserting > 0.
*/
static persist_t* g_p9 = NULL;
static int g_ep9_a, g_ep9_b;
static struct p2_ack_args g_ack9;
static void test_committed_lsn_advances_after_majority_ack(void)
{
g_p9 = make_p2_persist("clsn_ack");
TEST_ASSERT_NOT_NULL(g_p9);
/* Two standbys — one will ACK, satisfying majority (quorum = 2 incl. self) */
repl_add_standby(&g_p9->repl, (void *)&g_ep9_a, "sb_x", 7020);
repl_add_standby(&g_p9->repl, (void *)&g_ep9_b, "sb_y", 7021);
/* Give enough time for the ACK thread to fire before timeout */
g_p9->config.sync_timeout_ms = 2000;
/* Capture committed_lsn before sync so we can assert it advanced */
uint64_t committed_before =
atomic_load_explicit(&g_p9->committed_lsn, memory_order_acquire);
/* ACK from sb_x arrives after 20 ms — satisfies quorum */
g_ack9 = (struct p2_ack_args){ g_p9, (void *)&g_ep9_a, /*lsn=*/1, /*delay_ms=*/20 };
pthread_t t;
pthread_create(&t, NULL, p2_ack_thread, &g_ack9);
/* Install drop hook so repl_broadcast doesn't dereference dummy eps */
repl_broadcast_send_hook = p2_drop_send_hook;
int sync_rc = -1;
int rc = persist_wal_sync_append(g_p9, WAL_JOB_SUBMIT, "job", 3, &sync_rc);
repl_broadcast_send_hook = NULL;
pthread_join(t, NULL);
/* WAL write and sync must both succeed */
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, sync_rc);
/* committed_lsn must have advanced to written_lsn (Req 4.1).
* written_lsn = current_lsn - 1 after persist_wal_async; for a fresh
* persist_t this equals 0 on the very first write. We therefore check
* that committed_after > committed_before (advance happened) or, when
* both are 0 and written_lsn is also 0, that they are equal (lsn was
* committed at 0 → valid: majority ACK was received). */
uint64_t committed_after =
atomic_load_explicit(&g_p9->committed_lsn, memory_order_acquire);
/* Derive written_lsn the same way persist_wal_sync_append does */
pthread_mutex_lock(&g_p9->lock);
uint64_t written_lsn = g_p9->current_lsn - 1;
pthread_mutex_unlock(&g_p9->lock);
TEST_ASSERT_TRUE_MESSAGE(
committed_after > committed_before || committed_after == written_lsn,
"committed_lsn must advance after majority ACK (Req 4.1)");
teardown_p2_persist(g_p9);
g_p9 = NULL;
printf("test_committed_lsn_advances_after_majority_ack: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Entry point */
/* ------------------------------------------------------------------ */
@ -209,7 +407,10 @@ void test_p2_integration(void)
RUN_TEST(test_trim_lsn_with_active_standby_at_lsn_5);
RUN_TEST(test_checkpoint_does_not_update_replicated_crc);
RUN_TEST(test_sync_replication_degraded_mode);
RUN_TEST(test_single_node_committed_lsn_unchanged);
RUN_TEST(test_catchup_full_lifecycle_integration);
RUN_TEST(test_needs_snapshot_boundary);
RUN_TEST(test_repl_api_completeness);
RUN_TEST(test_committed_lsn_does_not_advance_on_timeout);
RUN_TEST(test_committed_lsn_advances_after_majority_ack);
}

View file

@ -414,10 +414,20 @@ static void test_wal_ack_reply_updates_acked_lsn_and_signals(void)
printf("test_wal_ack_reply_updates_acked_lsn_and_signals: PASS\n");
}
/* Test: NACK path — handler does NOT call repl_on_ack, so acked_lsn stays 0
* and repl_wait_majority_ack times out. Req 4.3 (negative): only a
* success==1 reply triggers the update; success==0 is a no-op on the
* replicator. */
/* Test: NACK path — handler does NOT call repl_on_ack, so acked_lsn stays 0.
* Req 4.3 (negative): only a success==1 reply triggers the update; success==0
* is a no-op on the replicator.
*
* Quorum analysis for this scenario:
* n_active = 1 (one standby registered, not in catch-up)
* quorum = (1 + 2) / 2 = 1
* walAck: acked_total = 1 (self) + 0 (standby acked_lsn == 0, not > 0) = 1
* 1 >= quorum(1) → repl_wait_majority_ack returns 0 IMMEDIATELY.
*
* The primary self-count alone satisfies a 1-standby quorum, so the function
* does NOT time out even when the standby never ACKs. The NACK invariant is
* that acked_lsn remains 0 (not that the wait times out).
*/
static void test_wal_nack_does_not_update_acked_lsn(void)
{
replicator_t r;
@ -439,10 +449,14 @@ static void test_wal_nack_does_not_update_acked_lsn(void)
TEST_ASSERT_EQUAL_UINT64_MESSAGE(0, al,
"acked_lsn must remain 0 when NACK path skips repl_on_ack (Req 4.3)");
/* repl_wait_majority_ack must time out because no ACK arrived */
/* With 1 standby and no standby ACK, primary self-count (1) already meets
* quorum (1), so repl_wait_majority_ack returns 0, not -ETIMEDOUT.
* A timeout would only occur if quorum required at least one standby ACK,
* which only happens when n_active >= 2 (e.g. 2+ standbys registered). */
int rc = repl_wait_majority_ack(&r, 50, (uint8_t)OPCode_walAck);
TEST_ASSERT_EQUAL_INT_MESSAGE(-ETIMEDOUT, rc,
"repl_wait_majority_ack must time out when no ACK arrives (NACK path) (Req 4.3)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"repl_wait_majority_ack must return 0 with 1 standby: "
"self-count(1) >= quorum(1) even without standby ACK (Req 4.3)");
repl_fini(&r);
printf("test_wal_nack_does_not_update_acked_lsn: PASS\n");