feat(ha-commit-durability): cap commit self-contribution at durable_lsn + durable-advance commit wake (task 2.2)

This commit is contained in:
Rongsong Shen 2026-06-18 06:29:47 +00:00
parent cc83caf5a0
commit c41edc515e
10 changed files with 395 additions and 13 deletions

View file

@ -79,6 +79,10 @@ static void ce_setup(int n_peers, int n_standbys, uint64_t appended_lsn)
* sentinel), so current_lsn = 1 = "first entry will be lsn 1". */
g_p.current_lsn = appended_lsn + 1;
atomic_store(&g_p.committed_lsn, 0);
/* ha-commit-durability 2.2: self caps at durable_lsn. Stub fixture (no
* flusher) — treat the appended log as durable up front, preserving the
* pre-durability "self covers every appended entry" model. */
atomic_store(&g_p.durable_lsn, appended_lsn);
TEST_ASSERT_EQUAL_INT(0, commit_engine_init(&g_ce, &g_p, n_peers, 8));
}

View file

@ -496,6 +496,11 @@ static void barrier_setup_vcpus(int n_peers, int n_standbys,
g_bp.current_lsn = current_lsn;
atomic_store(&g_bp.committed_lsn, 0);
/* ha-commit-durability 2.2: self caps at durable_lsn. This stub has no
* flusher, so treat the whole appended log (current_lsn - 1) as durable
* up front, preserving the pre-durability "self covers every appended
* entry" model. (current_lsn == 0 → nothing appended → durable 0.) */
atomic_store(&g_bp.durable_lsn, current_lsn > 0 ? current_lsn - 1 : 0);
TEST_ASSERT_EQUAL_INT(0,
commit_engine_init(&g_bce, &g_bp, n_peers, n_vcpus));

View file

@ -8,6 +8,7 @@
#include <unity/unity.h>
#include "commit.h"
#include "config.h"
#include "persist.h"
@ -433,6 +434,69 @@ static void test_durable_lsn_holds_on_fsync_failure(void)
printf("test_durable_lsn_holds_on_fsync_failure: PASS\n");
}
/* ================================================================== *
* task 2.2 — durable-advance wake drives commit (integration) *
* *
* End-to-end over a REAL persist_t + commit_engine_t (single-member, *
* NULL cluster): the group-commit flusher advances durable_lsn and *
* fires the registered durable-advance callback, which the commit *
* engine uses to advance committed_lsn and wake WAIT callers. Proves *
* the persist↔commit wiring works against the real flusher thread — *
* not just the directly-driven callback (covered in *
* test_commit_engine.c). n_peers=1 means self alone is the majority, *
* so committed_lsn tracks durable_lsn with no standby ACKs needed. *
* ------------------------------------------------------------------ */
static void test_durable_advance_drives_commit_single_member(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_durcommit_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
persist_t* p = make_durability_persist(tmp);
TEST_ASSERT_NOT_NULL(p);
/* Single-member commit engine over the real persist. n_vcpus large enough
* to admit a wait. commit_engine_init registers the durable-advance cb. */
commit_engine_t ce;
TEST_ASSERT_EQUAL_INT(0, commit_engine_init(&ce, p, 1, 8));
/* committed starts at 0; nothing appended/durable. */
TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&p->committed_lsn));
/* Append three entries (LSN 1,2,3). They are in page cache only — the slow
* 5s cadence means the append-SIGNAL (not the timer) must drive the flush;
* the real flusher fdatasyncs, advances durable_lsn, and fires the cb,
* which advances committed_lsn for this single-member quorum. */
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "a", 1));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "b", 1));
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "c", 1));
uint64_t tip = p->current_lsn - 1; /* 3 */
TEST_ASSERT_EQUAL_UINT64(3, tip);
/* Wait for the flusher to make the tip durable AND for the wake-driven
* commit to catch up. committed_lsn must reach the tip — driven by the
* durable-advance callback, not by any ACK (there are no standbys). */
uint64_t committed = 0;
for (int i = 0; i < 5000; i++) {
committed = atomic_load(&p->committed_lsn);
if (committed >= tip)
break;
usleep(1000);
}
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
tip, committed,
"durable-advance callback must drive committed_lsn to the durable tip");
/* committed never exceeds durable (the invariant the wake preserves). */
TEST_ASSERT_TRUE(atomic_load(&p->committed_lsn) <= persist_durable_lsn(p));
/* Tear the engine down BEFORE the persist (unhooks the callback so the
* flusher cannot fire against a destroyed engine). */
commit_engine_fini(&ce);
teardown_durability_persist(p);
printf("test_durable_advance_drives_commit_single_member: PASS\n");
}
#endif /* XCE_TESTING */
/* ------------------------------------------------------------------ */
@ -449,6 +513,7 @@ void test_commit_durability(void)
RUN_TEST(test_durable_lsn_never_exceeds_synced_tip);
RUN_TEST(test_durable_lsn_invariants_under_load);
RUN_TEST(test_durable_lsn_holds_on_fsync_failure);
RUN_TEST(test_durable_advance_drives_commit_single_member);
#else
printf("test_commit_durability: skipped (no XCE_TESTING)\n");
#endif

View file

@ -112,10 +112,37 @@ static void engine_setup_vcpus(int n_peers, int n_standbys, uint64_t appended_ls
}
g_p.current_lsn = appended_lsn + 1;
atomic_store(&g_p.committed_lsn, 0);
/* ha-commit-durability 2.2: the commit majority now caps self at
* durable_lsn, not the appended tip. By default treat every appended
* entry as already fdatasync'd locally (durable_lsn == appended tip), so
* the pre-durability test semantics ("self covers every appended entry")
* are preserved. Tests that exercise the self-cap lower durable_lsn
* explicitly via set_durable() after setup. */
atomic_store(&g_p.durable_lsn, appended_lsn);
TEST_ASSERT_EQUAL_INT(0, commit_engine_init(&g_ce, &g_p, n_peers, n_vcpus));
}
/* Set the local durable watermark (highest LSN fdatasync'd locally). Mirrors
* what the persist group-commit flusher does after a successful fdatasync;
* here it is driven directly so the commit-engine self-cap can be tested in
* isolation from real disk I/O. */
static void set_durable(uint64_t durable_lsn)
{
atomic_store(&g_p.durable_lsn, durable_lsn);
}
/* Drive the registered durable-advance callback exactly as the persist flusher
* does after advancing durable_lsn and releasing p->lock: bump durable_lsn,
* then invoke the hook so the commit engine re-evaluates + wakes WAIT callers.
* Asserts a callback is actually registered (it is, from commit_engine_init). */
static void advance_durable_and_wake(uint64_t durable_lsn)
{
atomic_store(&g_p.durable_lsn, durable_lsn);
TEST_ASSERT_NOT_NULL(g_p.on_durable_advance);
g_p.on_durable_advance(g_p.durable_cb_ctx);
}
static void engine_setup(int n_peers, int n_standbys, uint64_t appended_lsn)
{
engine_setup_vcpus(n_peers, n_standbys, appended_lsn, 8);
@ -311,6 +338,104 @@ static void test_retain_mode_returns_zero_immediately(void)
printf("test_retain_mode_returns_zero_immediately: PASS\n");
}
/* ════════════════════════════════════════════════════════════════════════
* ha-commit-durability task 2.2 — self counts toward the commit majority only
* when durable. The PRIMARY's self-contribution is capped at durable_lsn (the
* highest LSN fdatasync'd locally), NOT the appended tip. (Req 1.2, 1.4)
* ════════════════════════════════════════════════════════════════════════ */
/* ── Test 2.2a: self-cap equals durable_lsn, not the appended tip ─────────
* Appended tip is 5 but only LSN 3 is durable locally. A standby ACKs the
* full tip (5). committed_lsn must stop at 3 — self only covers up to its
* durable watermark, so entries 4,5 are NOT committed despite the standby
* majority covering them. Kills the highest_appended_lsn self-cap. */
static void test_self_cap_equals_durable_not_appended_tip(void)
{
/* Arrange: 3-member cluster, entries 1..5 appended, but durable only to 3 */
engine_setup(3, 2, 5);
append_entries(5);
set_durable(3);
/* Act: standby 0 ACKs the full appended tip (5) — self + sb0 majority
* covers 5, but self is only durable to 3. */
standby_ack(0, 5);
/* Assert: committed caps at the durable self-contribution (3), NOT 5. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
3, committed(),
"self contribution must cap at durable_lsn, not the appended tip");
engine_teardown();
printf("test_self_cap_equals_durable_not_appended_tip: PASS\n");
}
/* ── Test 2.2b: committed_lsn holds over an entry until it is durable ──────
* The core observable (Req 1.2/1.4): with a full standby majority already
* present, committed_lsn does NOT advance over entry 5 until entry 5 is
* durable locally. As durable_lsn steps up, committed tracks it — never
* passing the durable watermark. */
static void test_committed_holds_until_durable_then_advances(void)
{
/* Arrange: 3-member cluster, entries 1..5 appended; nothing durable yet. */
engine_setup(3, 2, 5);
append_entries(5);
set_durable(0);
/* A full standby majority covers the entire tip up front. */
standby_ack(0, 5);
standby_ack(1, 5);
/* Assert: with self durable to 0, NOTHING commits despite the majority. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
0, committed(),
"no entry may commit while self is not durable for it");
/* Durable advances to 2 → committed may advance to 2 (self+majority). */
set_durable(2);
commit_on_ack(&g_ce, 0, 5); /* re-evaluate (an ACK re-fires evaluation) */
TEST_ASSERT_EQUAL_UINT64(2, committed());
/* Durable advances to 5 → committed reaches the tip. */
set_durable(5);
commit_on_ack(&g_ce, 0, 5);
TEST_ASSERT_EQUAL_UINT64(5, committed());
engine_teardown();
printf("test_committed_holds_until_durable_then_advances: PASS\n");
}
/* ── Test 2.2c: single-member self-cap — standalone still gated on durable ─
* n_peers=1 self-quorum: committed advances on self alone, but ONLY up to
* durable_lsn. An appended-but-unsynced entry is not committable even with
* no peers. (Req 1.2, 6.3) */
static void test_single_member_self_cap_gated_on_durable(void)
{
/* Arrange: single-member, entries 1..3 appended, durable only to 1. */
engine_setup(1, 0, 3);
append_entries(3);
set_durable(1);
/* Act: drive an evaluation (membership change re-evaluates). */
commit_on_membership_change(&g_ce);
/* Assert: self-quorum commits only the durable prefix (1), not the tip. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
1, committed(),
"single-member commit must cap at durable_lsn");
/* Durable catches up → committed reaches the tip. */
set_durable(3);
commit_on_membership_change(&g_ce);
TEST_ASSERT_EQUAL_UINT64(3, committed());
engine_teardown();
printf("test_single_member_self_cap_gated_on_durable: PASS\n");
}
/* NOTE: test_durable_advance_wake_completes_blocked_wait (the liveness case
* for the durable-advance wake) is defined AFTER the waiter-thread helpers
* (spawn_waiters/poll_inflight/join_waiters), further down this file. */
/* ════════════════════════════════════════════════════════════════════════
* Task 2.1 — COMMIT_MODE_WAIT blocking semantics, admission control,
* own-term gate. RED against the task-1 engine (WAIT = -ENOSYS).
@ -406,6 +531,53 @@ static int poll_inflight(int want, int n_spawned, uint32_t deadline_ms)
return v;
}
/* ── Test 2.2d: durable-advance wake completes a blocked WAIT promptly ─────
* Liveness for change (B): a COMMIT_MODE_WAIT caller whose standby majority
* already covers its entry, but whose entry is not yet durable locally, must
* complete PROMPTLY when the flusher advances durable_lsn and fires the
* durable-advance callback — NOT only when its timeout expires. The standby
* ACK arrives BEFORE the durable advance, so commit_on_ack alone cannot
* complete it; the durable-advance wake must. (Req 1.2, 1.4) */
static void test_durable_advance_wake_completes_blocked_wait(void)
{
uint64_t t0, elapsed_ms;
/* Arrange: 3-member cluster, entry 1 appended, NOT durable yet. */
engine_setup(3, 2, 1);
append_entries(1);
set_durable(0);
/* Full standby majority ACKs entry 1 up front — but self is not durable,
* so this ACK does NOT commit it (committed holds at 0). */
standby_ack(0, 1);
TEST_ASSERT_EQUAL_UINT64(0, committed());
/* A WAIT caller blocks on entry 1 with a long timeout (5s). It cannot
* complete on the ACK (already delivered) — only the durable-advance wake
* can release it before the timeout. */
spawn_waiters(1, 1, 5000);
(void)poll_inflight(1, 1, 2000); /* ensure the waiter is actually blocked */
/* Act: the "flusher" advances durable_lsn to 1 and fires the wake. */
t0 = mono_ms();
advance_durable_and_wake(1);
join_waiters(1);
elapsed_ms = mono_ms() - t0;
/* Assert: the waiter completed with success, PROMPTLY (well under the 5s
* timeout) — proving the durable-advance wake, not the deadline. */
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, g_wargs[0].rc,
"WAIT must complete on durable-advance, not time out");
TEST_ASSERT_EQUAL_UINT64(1, committed());
TEST_ASSERT_TRUE_MESSAGE(
elapsed_ms < 2000,
"durable-advance wake must complete the wait promptly, not on timeout");
engine_teardown();
printf("test_durable_advance_wake_completes_blocked_wait: PASS\n");
}
/* ── Test 8: WAIT blocks until majority ACK, then returns 0 ───────────────
* (Req 1.1) */
static void test_wait_blocks_until_majority_ack_then_returns_zero(void)
@ -1421,6 +1593,11 @@ void test_commit_engine(void)
RUN_TEST(test_committed_lsn_never_regresses);
RUN_TEST(test_lagging_standby_does_not_block_majority_commit);
RUN_TEST(test_retain_mode_returns_zero_immediately);
/* ha-commit-durability task 2.2 — self counts toward majority only when durable */
RUN_TEST(test_self_cap_equals_durable_not_appended_tip);
RUN_TEST(test_committed_holds_until_durable_then_advances);
RUN_TEST(test_single_member_self_cap_gated_on_durable);
RUN_TEST(test_durable_advance_wake_completes_blocked_wait);
/* Task 2.1 — WAIT semantics, admission control, own-term gate */
RUN_TEST(test_wait_blocks_until_majority_ack_then_returns_zero);
RUN_TEST(test_wait_times_out_with_committed_lsn_unmoved_past_entry);

View file

@ -151,6 +151,11 @@ static void dc_setup(const char *suffix, int n_peers, int n_standbys,
g_dc.persist.current_lsn = appended_lsn + 1;
pthread_mutex_unlock(&g_dc.persist.lock);
atomic_store(&g_dc.persist.committed_lsn, 0);
/* ha-commit-durability 2.2: self caps at durable_lsn. This fixture primes
* the WAL tip in-memory (no bytes written → the flusher can't advance
* durable_lsn itself), so mark the primed log durable up front to preserve
* the pre-durability "self covers every appended entry" commit model. */
atomic_store(&g_dc.persist.durable_lsn, appended_lsn);
for (int i = 0; i < n_standbys; i++) {
snprintf(name, sizeof(name), "dcsb%d", i);
@ -570,6 +575,9 @@ static void rj_set_local_state(uint64_t tip_lsn, uint64_t committed)
g_dc.persist.current_lsn = tip_lsn + 1;
pthread_mutex_unlock(&g_dc.persist.lock);
atomic_store(&g_dc.persist.committed_lsn, committed);
/* ha-commit-durability 2.2: keep durable_lsn at the primed tip so the
* commit-majority self-cap matches this in-memory-primed WAL state. */
atomic_store(&g_dc.persist.durable_lsn, tip_lsn);
}
/* Demote the fixture to STANDBY (the role rejoin reconciliation runs in) and

View file

@ -78,6 +78,12 @@ static void majority_setup(int n_peers, int n_standbys, uint64_t appended_lsn)
}
g_mp.current_lsn = appended_lsn + 1;
atomic_store(&g_mp.committed_lsn, 0);
/* ha-commit-durability 2.2: the commit majority now caps self at
* durable_lsn. This stub has no flusher, so mark every appended entry
* durable up front to preserve the pre-durability model ("self covers
* every appended entry"); the durability precondition is exercised
* separately in test_commit_durability.c / test_commit_engine.c. */
atomic_store(&g_mp.durable_lsn, appended_lsn);
TEST_ASSERT_EQUAL_INT(0, commit_engine_init(&g_meng, &g_mp, n_peers, 8));
repl_set_commit_engine(&g_mp.repl, &g_meng);