forked from lei.chen/myxce
test(ha-commit-durability): end-to-end durable-majority commit + liveness integration tests (task 5.1)
This commit is contained in:
parent
e9e2a6d703
commit
e14bd62115
4 changed files with 404 additions and 0 deletions
|
|
@ -14,6 +14,8 @@
|
|||
#include "config.h"
|
||||
#include "ha.h" /* task 4.3: ha_promotion_gate_decision / fence */
|
||||
#include "persist.h"
|
||||
#include "repl.h" /* task 5.1: repl_add_standby + standby ACK feed */
|
||||
#include "typemap.h" /* task 5.1: wal_catchup_entry_t (send hook) */
|
||||
#include "wal_types.h"
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
|
|
@ -779,6 +781,365 @@ static void test_commit_never_exceeds_durable_invariant(void)
|
|||
printf("test_commit_never_exceeds_durable_invariant: PASS\n");
|
||||
}
|
||||
|
||||
/* ================================================================== *
|
||||
* task 5.1 — wire durable-majority commit end to end *
|
||||
* *
|
||||
* These three tests verify the full durable-majority commit path the *
|
||||
* 2.x/4.x wiring assembled, end to end over a REAL persist_t (real WAL *
|
||||
* + real group-commit flusher thread) and a REAL commit_engine_t with *
|
||||
* registered standbys: *
|
||||
* *
|
||||
* 1. reply-ok ⇒ durable-majority (Req 1.1, 1.4): committed_lsn *
|
||||
* advances and a WAIT caller releases ONLY after the entry is *
|
||||
* durable on a fault-tolerant majority — self durable (durable_lsn *
|
||||
* covers it, via the real flusher) AND a standby ACK. With *
|
||||
* durability held off, even a standby ACK does NOT release. *
|
||||
* 2. sole-durable-contributor loss survives (Req 1.5, 2.5): an op *
|
||||
* committed on self + a standby is durable on >= floor(N/2)+1 *
|
||||
* members, so losing one durable contributor leaves it present on *
|
||||
* a surviving member (modeled at the integration level; the live *
|
||||
* failover proof is task 5.2). *
|
||||
* 3. liveness — durability work off the iomgr (Req 1.6): fdatasync *
|
||||
* runs on the dedicated flusher thread, not the caller/iomgr *
|
||||
* thread, so a heartbeat-style callback is not stalled while a *
|
||||
* durable flush is in flight. *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/* Dummy standby endpoints — the replicator only compares pointers, and
|
||||
* commit_on_ack reads acked_lsn from the registry slot. */
|
||||
static int g_dur_eps[MAX_STANDBYS];
|
||||
|
||||
/* Test-injectable send hook (defined in repl.c under XCE_TESTING) — keeps
|
||||
* repl_broadcast from dereferencing the dummy endpoints as real
|
||||
* endpoint_t when persist_wal_async fans out to the registered standbys.
|
||||
* Same pattern as test_commit_engine.c. */
|
||||
extern int (*repl_broadcast_send_hook)(void* ep, wal_catchup_entry_t** entries,
|
||||
int n_entries);
|
||||
|
||||
static int dur_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 (no real wire in a unit test) */
|
||||
}
|
||||
|
||||
/* Feed a standby ACK exactly as the iomgr read path does: store the
|
||||
* watermark into the registry slot, then notify the commit engine. */
|
||||
static void durable_standby_ack(persist_t* p, commit_engine_t* ce, int idx,
|
||||
uint64_t acked_lsn)
|
||||
{
|
||||
atomic_store(&p->repl.standbys[idx].acked_lsn, acked_lsn);
|
||||
commit_on_ack(ce, idx, acked_lsn);
|
||||
}
|
||||
|
||||
/* A WAIT submit that runs on its own thread, so the test can observe that
|
||||
* it does NOT return while the entry is not yet durable-majority, then DOES
|
||||
* return once it becomes durable-majority (mirrors the off-iomgr AFTER_EVENT
|
||||
* caller blocking on the commit barrier). */
|
||||
typedef struct {
|
||||
commit_engine_t* ce;
|
||||
uint64_t lsn;
|
||||
uint32_t timeout_ms;
|
||||
_Atomic int returned;
|
||||
int rc;
|
||||
} wait_submit_arg_t;
|
||||
|
||||
static void* wait_submit_thread(void* a)
|
||||
{
|
||||
wait_submit_arg_t* w = (wait_submit_arg_t*)a;
|
||||
w->rc = commit_submit(w->ce, w->lsn, COMMIT_MODE_WAIT, w->timeout_ms);
|
||||
atomic_store(&w->returned, 1);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Test 5.1a (MUST-HAVE, Req 1.1 / 1.4): reply-ok only after a durable *
|
||||
* majority. 3-member cluster (self + 2 standbys), so the majority for *
|
||||
* an entry is self + at least 1 standby. We: *
|
||||
* - force the local fdatasync to FAIL, so the flusher cannot advance *
|
||||
* durable_lsn (self is NOT durable); *
|
||||
* - append an entry and deliver a standby ACK covering it; *
|
||||
* - assert committed_lsn does NOT advance and a WAIT submit does NOT *
|
||||
* release — a majority of ACKs is not enough without local *
|
||||
* durability (this is the durable precondition gate; pre-2.2 this *
|
||||
* would have committed on the ACK alone — RED); *
|
||||
* - clear the fault so the real flusher fdatasyncs and advances *
|
||||
* durable_lsn, firing the durable-advance wake; *
|
||||
* - assert committed_lsn now advances and the WAIT submit releases *
|
||||
* promptly (durable self + durable standby ACK = durable majority). *
|
||||
* ------------------------------------------------------------------ */
|
||||
static void test_durable_majority_commit_end_to_end(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_durmaj_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
persist_t* p = make_durability_persist(tmp);
|
||||
TEST_ASSERT_NOT_NULL(p);
|
||||
|
||||
/* Intercept the replication fan-out so persist_wal_async does not try to
|
||||
* send over the dummy endpoints (they are not real endpoint_t). */
|
||||
repl_broadcast_send_hook = dur_drop_send_hook;
|
||||
|
||||
/* Register two standbys against the REAL persist's replicator. n_peers=3
|
||||
* (self + 2 standbys); majority for entry N = self durable + >= 1 standby
|
||||
* with acked_lsn >= N. n_vcpus high enough to admit a WAIT. */
|
||||
TEST_ASSERT_EQUAL_INT(0, repl_add_standby(&p->repl, &g_dur_eps[0], "sb0",
|
||||
7000));
|
||||
TEST_ASSERT_EQUAL_INT(1, repl_add_standby(&p->repl, &g_dur_eps[1], "sb1",
|
||||
7001));
|
||||
|
||||
commit_engine_t ce;
|
||||
TEST_ASSERT_EQUAL_INT(0, commit_engine_init(&ce, p, /*n_peers=*/3,
|
||||
/*n_vcpus=*/8));
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&p->committed_lsn));
|
||||
|
||||
/* Hold durability off: every flusher fdatasync is forced to fail, so
|
||||
* durable_lsn cannot advance. (Fail-loud: the entry is never counted
|
||||
* durable, so the commit cannot advance regardless of ACKs.) */
|
||||
persist_test_set_force_fsync_fail(p, 1);
|
||||
|
||||
/* Append one entry (LSN 1). It is in page cache only; the forced-fail
|
||||
* flusher will NOT make it durable. */
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "a", 1));
|
||||
uint64_t lsn = p->current_lsn - 1; /* 1 */
|
||||
TEST_ASSERT_EQUAL_UINT64(1, lsn);
|
||||
|
||||
/* Register a WAIT for LSN 1 on its own thread (the off-iomgr commit-barrier
|
||||
* caller). It must block: the entry is not durable-majority. */
|
||||
wait_submit_arg_t w = {.ce = &ce, .lsn = lsn, .timeout_ms = 2000};
|
||||
pthread_t th;
|
||||
TEST_ASSERT_EQUAL_INT(0, pthread_create(&th, NULL, wait_submit_thread, &w));
|
||||
|
||||
/* Deliver a covering standby ACK from BOTH standbys (a clear majority of
|
||||
* ACKs). Pre-durability this alone advanced committed_lsn (the bug);
|
||||
* post-2.2 the self-cap holds it at the non-durable self (durable_lsn=0). */
|
||||
durable_standby_ack(p, &ce, 0, lsn);
|
||||
durable_standby_ack(p, &ce, 1, lsn);
|
||||
|
||||
/* Give the (forced-failing) flusher ample time to run; durable_lsn must
|
||||
* stay 0, committed must stay 0, and the WAIT must NOT have returned. */
|
||||
for (int i = 0; i < 300; i++) { /* up to ~300 ms */
|
||||
if (persist_durable_lsn(p) != 0 ||
|
||||
atomic_load(&p->committed_lsn) != 0 ||
|
||||
atomic_load(&w.returned))
|
||||
break;
|
||||
usleep(1000);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
|
||||
0, persist_durable_lsn(p),
|
||||
"durable_lsn must not advance while fdatasync is failing");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
|
||||
0, atomic_load(&p->committed_lsn),
|
||||
"committed_lsn must NOT advance on a standby-ACK majority while the "
|
||||
"local entry is not durable (durable precondition gate)");
|
||||
TEST_ASSERT_FALSE_MESSAGE(
|
||||
atomic_load(&w.returned),
|
||||
"the WAIT (client reply) must NOT release before durable-majority");
|
||||
|
||||
/* Now allow durability: clear the fault and force a flush. The REAL
|
||||
* flusher fdatasyncs, advances durable_lsn over LSN 1, and fires the
|
||||
* durable-advance wake — self is now durable AND both standbys already
|
||||
* ACKed, so the entry is durable on a majority. committed_lsn advances
|
||||
* and the WAIT releases promptly. */
|
||||
persist_test_set_force_fsync_fail(p, 0);
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
|
||||
TEST_ASSERT_TRUE(persist_durable_lsn(p) >= lsn);
|
||||
|
||||
/* The WAIT must now return success (committed) within its timeout. */
|
||||
TEST_ASSERT_EQUAL_INT(0, pthread_join(th, NULL));
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
atomic_load(&w.returned),
|
||||
"the WAIT must release once the entry is durable on a majority");
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(
|
||||
0, w.rc,
|
||||
"commit_submit(WAIT) must return 0 (committed) after durable-majority");
|
||||
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
atomic_load(&p->committed_lsn) >= lsn,
|
||||
"committed_lsn must advance once self is durable AND a standby ACK "
|
||||
"covers the entry (durable majority)");
|
||||
/* The invariant the whole spec rests on: committed never exceeds durable. */
|
||||
TEST_ASSERT_TRUE(atomic_load(&p->committed_lsn) <= persist_durable_lsn(p));
|
||||
|
||||
commit_engine_fini(&ce);
|
||||
repl_remove_standby(&p->repl, &g_dur_eps[0]);
|
||||
repl_remove_standby(&p->repl, &g_dur_eps[1]);
|
||||
teardown_durability_persist(p);
|
||||
repl_broadcast_send_hook = NULL;
|
||||
printf("test_durable_majority_commit_end_to_end: PASS\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Test 5.1b (Req 1.5 / 2.5): losing the sole durable contributor *
|
||||
* leaves the acknowledged op present on a surviving member. *
|
||||
* *
|
||||
* The durable-majority property says: when an op is acknowledged, its *
|
||||
* entry is fdatasync'd on a fault-tolerant majority (self + >= *
|
||||
* floor(N/2) others). For N=3 that is 2 durable copies. So losing ONE *
|
||||
* durable contributor still leaves >= 1 surviving durable copy that *
|
||||
* holds the op — it is recoverable from the survivor. *
|
||||
* *
|
||||
* We model the two durable copies as two independent persist_t over *
|
||||
* separate WAL dirs (a primary's copy and a standby's copy), both made *
|
||||
* genuinely durable (fdatasync'd). We then DROP the "sole durable *
|
||||
* contributor" (the first copy, simulating its loss/restart-with-loss) *
|
||||
* and prove the op is still durably present and recoverable on the *
|
||||
* surviving copy via a fresh-process recovery (no in-memory state). *
|
||||
* *
|
||||
* This is the integration-level assertion of the property that makes *
|
||||
* survival true (committed ⇒ >= floor(N/2)+1 durable copies); the live *
|
||||
* multi-host SIGKILL failover proof is task 5.2. *
|
||||
* ------------------------------------------------------------------ */
|
||||
static void test_sole_durable_contributor_loss_survives(void)
|
||||
{
|
||||
char tmp_a[256];
|
||||
char tmp_b[256];
|
||||
strcpy(tmp_a, "/tmp/xce_durlossA_XXXXXX");
|
||||
strcpy(tmp_b, "/tmp/xce_durlossB_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp_a));
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp_b));
|
||||
|
||||
const char* payload = "committed-op";
|
||||
uint32_t plen = (uint32_t)strlen(payload);
|
||||
|
||||
/* Durable copy A (the contributor we will lose) and durable copy B (the
|
||||
* survivor): write the SAME committed op to both and fdatasync each, so
|
||||
* the op is durable on a 2-of-3 majority. */
|
||||
{
|
||||
persist_t* a = make_durability_persist(tmp_a);
|
||||
TEST_ASSERT_NOT_NULL(a);
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
persist_wal_async(a, WAL_JOB_UPDATE, payload, plen));
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_flush(a));
|
||||
TEST_ASSERT_TRUE(persist_durable_lsn(a) >= 1);
|
||||
teardown_durability_persist(a);
|
||||
}
|
||||
{
|
||||
persist_t* b = make_durability_persist(tmp_b);
|
||||
TEST_ASSERT_NOT_NULL(b);
|
||||
/* The standby applies the SAME replicated entry durably before ACK
|
||||
* (persist_wal_write_replicated fdatasyncs inline — Req 1.3). */
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
persist_wal_write_replicated(b, /*lsn=*/1, WAL_JOB_UPDATE,
|
||||
payload, plen));
|
||||
TEST_ASSERT_TRUE(persist_test_durable_lsn_on_disk(b) >= 1);
|
||||
teardown_durability_persist(b);
|
||||
}
|
||||
|
||||
/* "Lose" the sole durable contributor A: destroy its on-disk WAL entirely
|
||||
* (simulates a member that is gone / came back with a truncated log). */
|
||||
{
|
||||
char rm[600];
|
||||
snprintf(rm, sizeof(rm), "rm -rf %s", tmp_a);
|
||||
TEST_ASSERT_EQUAL_INT(0, system(rm));
|
||||
}
|
||||
|
||||
/* The acknowledged op MUST still be present and recoverable on the
|
||||
* surviving member B — read straight from disk by a fresh process with
|
||||
* NO in-memory state (the durable-majority property in action). */
|
||||
{
|
||||
persist_t* b2 = make_durability_persist(tmp_b);
|
||||
TEST_ASSERT_NOT_NULL(b2);
|
||||
|
||||
/* On-disk durable floor (CRC-validated re-read) still covers the op. */
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
persist_test_durable_lsn_on_disk(b2) >= 1,
|
||||
"the acknowledged op must survive on the durable majority after "
|
||||
"the sole-durable-contributor copy is lost");
|
||||
|
||||
/* And a real recovery reconstructs it cleanly (not truncated). */
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_recover(b2));
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(
|
||||
RECOVER_CLEAN, persist_recovery_verdict(b2),
|
||||
"the survivor recovers the committed op as a clean intact log");
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
persist_durable_floor(b2) >= 1,
|
||||
"the survivor's recovered durable floor covers the committed op");
|
||||
|
||||
teardown_durability_persist(b2);
|
||||
}
|
||||
|
||||
{
|
||||
char rm[600];
|
||||
snprintf(rm, sizeof(rm), "rm -rf %s", tmp_b);
|
||||
(void)system(rm);
|
||||
}
|
||||
printf("test_sole_durable_contributor_loss_survives: PASS "
|
||||
"(durable-majority property; live failover proof is task 5.2)\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Test 5.1c (Req 1.6): durability work does not stall heartbeat / *
|
||||
* election handling — fdatasync runs on the dedicated flusher thread, *
|
||||
* NOT on the caller (iomgr/heartbeat) thread. *
|
||||
* *
|
||||
* Structural + timing assertion: while a durable flush is in flight on *
|
||||
* the flusher thread, a heartbeat-style callback executed on the *
|
||||
* caller/iomgr thread completes within a tight tolerance. We prove the *
|
||||
* flush is OFF the caller thread by capturing the flusher's thread id at *
|
||||
* the fdatasync site (via the XCE_TESTING seam) and asserting it is NOT *
|
||||
* the caller thread. (persist_wal_async only appends + signals; it never *
|
||||
* fdatasyncs inline, so the append path itself never blocks on disk.) *
|
||||
* ------------------------------------------------------------------ */
|
||||
static void test_durability_work_off_iomgr_preserves_liveness(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_durliveness_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
persist_t* p = make_durability_persist(tmp);
|
||||
TEST_ASSERT_NOT_NULL(p);
|
||||
|
||||
pthread_t caller = pthread_self();
|
||||
|
||||
/* Drive a steady stream of appends on the CALLER (iomgr-equivalent)
|
||||
* thread; the real flusher thread does the fdatasync work concurrently.
|
||||
* After each append, a heartbeat-style callback must complete promptly —
|
||||
* the append path signals the flusher and returns without blocking on
|
||||
* fdatasync, so heartbeat handling on this thread is never stalled. */
|
||||
const long tolerance_us = 50 * 1000; /* 50 ms — generous heartbeat budget */
|
||||
for (int i = 0; i < 64; i++) {
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
persist_wal_async(p, WAL_JOB_UPDATE, "h", 1));
|
||||
|
||||
struct timespec t0, t1;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
/* Heartbeat-style work on the caller thread (a cheap atomic read of
|
||||
* commit/durable state, exactly what the heartbeat path inspects). */
|
||||
volatile uint64_t hb = atomic_load(&p->committed_lsn) +
|
||||
persist_durable_lsn(p);
|
||||
(void)hb;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
|
||||
long elapsed_us = (t1.tv_sec - t0.tv_sec) * 1000000L +
|
||||
(t1.tv_nsec - t0.tv_nsec) / 1000L;
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
elapsed_us < tolerance_us,
|
||||
"heartbeat-style work on the caller thread must not be stalled "
|
||||
"by durability (fdatasync) work in flight on the flusher thread");
|
||||
}
|
||||
|
||||
/* Let the flusher make everything durable, then confirm the fdatasync
|
||||
* provably ran on a DIFFERENT thread than this caller — durability work
|
||||
* is off the iomgr/heartbeat thread (Req 1.6). The seam records the
|
||||
* thread id of the last successful flusher fdatasync. */
|
||||
uint64_t tip = p->current_lsn - 1;
|
||||
TEST_ASSERT_TRUE(wait_durable_reaches(p, tip, 5000) >= tip);
|
||||
|
||||
pthread_t flusher_tid = persist_test_last_fdatasync_thread(p);
|
||||
TEST_ASSERT_FALSE_MESSAGE(
|
||||
pthread_equal(flusher_tid, caller),
|
||||
"fdatasync must run on the dedicated flusher thread, never on the "
|
||||
"caller/iomgr/heartbeat thread");
|
||||
|
||||
teardown_durability_persist(p);
|
||||
printf("test_durability_work_off_iomgr_preserves_liveness: PASS\n");
|
||||
}
|
||||
|
||||
/* ================================================================== *
|
||||
* task 3.1 — recovery integrity verdict (Req 2.1) *
|
||||
* *
|
||||
|
|
@ -1662,6 +2023,10 @@ void test_commit_durability(void)
|
|||
RUN_TEST(test_legacy_sync_append_holds_committed_at_durable);
|
||||
RUN_TEST(test_legacy_sync_append_lsn0_edge_valid);
|
||||
RUN_TEST(test_commit_never_exceeds_durable_invariant);
|
||||
/* task 5.1 — durable-majority commit wired end to end */
|
||||
RUN_TEST(test_durable_majority_commit_end_to_end);
|
||||
RUN_TEST(test_sole_durable_contributor_loss_survives);
|
||||
RUN_TEST(test_durability_work_off_iomgr_preserves_liveness);
|
||||
RUN_TEST(test_recover_verdict_clean_intact_log);
|
||||
RUN_TEST(test_recover_verdict_empty_first_boot_clean);
|
||||
RUN_TEST(test_recover_verdict_crc_break_first_entry_truncated);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue