forked from lei.chen/myxce
feat(ha-term-safety): WAL term seam + recovery read-back + legacy-0 rule (task 1.3)
persist_set_term sets the live election term so appends stamp hdr.term (fixes the G1 term-0 stamping); replay_wal builds an in-memory per-LSN term index (freed in persist_fini, no on-disk change); persist_entry_term reads it back (0=absent). Legacy-0 -> WAL_LEGACY_TERM=1 at read-back only (raw 0 retained so present-legacy-0 is distinguishable from absent). 40-byte wal_entry_hdr_t preserved (value fix, R1.7). 5 new tests (test_term_safety_adapter.c); reviewer APPROVED. Feeds task 2.2. All controller test groups report 0 failures; the meson full-suite timeout is the bug-924 overlayfs /tmp fdatasync artifact, not this task.
This commit is contained in:
parent
a95d57f733
commit
d540ebd183
6 changed files with 420 additions and 1 deletions
|
|
@ -23,7 +23,7 @@
|
|||
- _Boundary: repl/vote wire (Cap'n Proto), controller vote intake_
|
||||
- _Depends: 1.1_
|
||||
|
||||
- [ ] 1.3 Add the WAL term seam, recovery read-back, and the legacy-zero-term matching rule
|
||||
- [x] 1.3 Add the WAL term seam, recovery read-back, and the legacy-zero-term matching rule
|
||||
- Implement the `persist_set_term` seam (sets the controller's live election term) and a per-LSN recovered term index built during WAL replay, plus an accessor returning the recorded term for an LSN (0 when absent); preserve the 40-byte on-disk header (value fix only)
|
||||
- Pin the legacy-zero-term rule (REVIEW ISSUE 2): a pre-feature entry carrying term 0 maps to a single, precisely-defined "lowest-known term" for `(term,lsn)` matching so recovery never fails and a legacy entry is never spuriously treated as divergent from another legacy entry; document and test the exact mapping
|
||||
- Observable completion: after replaying a WAL with mixed real and legacy-0 terms, the accessor returns the recorded term for real entries and the defined lowest-known term for legacy-0 entries; header size unchanged
|
||||
|
|
@ -144,3 +144,7 @@
|
|||
- **Pre-existing controller-test failures (NOT this task):** test_repl_unit.c `test_wal_catchup_batch_*` fail with -EAGAIN + ASAN leaks under overlayfs `/tmp` (bug-850 fsync env artifact). The diff touches none of repl.c/persist.c/test_repl_unit.c/ha_apply_wal_catchup_entry; the append-only capnp change cannot affect the @3 entries-list decode. The controller suite needs a real-fsync filesystem for clean validation.
|
||||
|
||||
- **CORRECTION (2026-06-22): env unblocked + iter-1 fully validated.** User refreshed the c-capnproto flake pin (bug-918 resolved). A CLEAN controller build now passes the full controller suite green (Ok:1 Fail:0, ~13s). The earlier `test_wal_catchup_*` failures were a STALE incremental-build artifact (iter-1 capnp schema compiled against the drifted toolchain) — NOT an overlayfs fsync issue and NOT a source regression. Validation gate for tasks 2.x+ is reliable: `/xce-build controller` (clean) or incremental `meson compile -C xce.build xce_controller controller_unit_test && meson test -C xce.build "controller test"`.
|
||||
|
||||
- **1.3 done.** persist_set_term + per-LSN in-memory term index (built in replay_wal, freed in persist_fini) + persist_entry_term accessor; legacy-0 -> WAL_LEGACY_TERM=1 at read-back only (raw 0 kept so present-legacy-0=1 vs absent=0). 40-byte header preserved. 5 new tests in test_term_safety_adapter.c.
|
||||
- **Task 2.2 MUST document (review MEDIUM):** in a mixed pre/post-feature cluster where the post-feature leader is in term 1, the (term,lsn) check cannot distinguish a legacy-0 entry from a real term-1 entry by term alone; the Raft log-matching invariant + the bounded upgrade window make this safe, but note it in the on_get_entry_term/conflict-truncate comment.
|
||||
- **Validation env (bug-924):** controller suite creates many /tmp/xce_* WAL dirs; overlayfs fdatasync degrades as they accumulate -> non-deterministic meson timeouts. Reliable gate: `rm -rf /tmp/xce_*` then run the `controller_unit_test` binary directly (all groups 0 failures). Pre-existing ASAN leak in test_commit_barrier_reply.c (not gated by meson detect_leaks=0).
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ controller_unit_test_src = [
|
|||
'testsuite/test_ctrlstatus.c',
|
||||
'testsuite/test_ha_heartbeat_committed.c',
|
||||
'testsuite/test_ha_vote_floor_heartbeat.c',
|
||||
'testsuite/test_term_safety_adapter.c',
|
||||
'testsuite/test_waldump.c',
|
||||
'testsuite/waldump.c',
|
||||
'ctrlstatus.c',
|
||||
|
|
|
|||
|
|
@ -392,6 +392,90 @@ static void* fsync_worker(void* arg)
|
|||
return NULL;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* WAL term adapter (ha-term-safety task 1.3, R1.1/R1.5/R1.6/R1.7/R1.8) *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/* Record an entry's RAW hdr.term at its 1-based LSN in the in-memory per-LSN
|
||||
* term index (R1.5). Grows the index on demand; LSNs replay/append contiguously
|
||||
* so the array is dense (slot i == LSN i+1). Any gap left by a non-contiguous
|
||||
* LSN (should not happen on the contiguous WAL) is zero-filled by realloc-then-
|
||||
* memset, which read back as "absent" until/unless written. The raw term
|
||||
* (including a legacy 0) is stored verbatim; the legacy-0 -> WAL_LEGACY_TERM
|
||||
* mapping is applied only at READ-BACK (persist_entry_term), keeping "present
|
||||
* legacy-0" distinguishable from "absent".
|
||||
*
|
||||
* Callers: the live append path (holds p->lock) and the single-threaded
|
||||
* recovery path (replay_wal — no other thread touches the index before the node
|
||||
* participates, so no lock is required there). Best-effort: an allocation
|
||||
* failure is logged loudly and leaves the index unextended (read-back then
|
||||
* reports the LSN absent) — it never aborts and never corrupts existing
|
||||
* entries. */
|
||||
static void record_entry_term(persist_t* p, uint64_t lsn, uint64_t term)
|
||||
{
|
||||
if (!p || lsn == 0)
|
||||
return; /* LSN 0 is reserved — never an entry */
|
||||
|
||||
if (lsn > p->term_index_cap) {
|
||||
/* Grow geometrically to amortize; never below the requested LSN. */
|
||||
uint64_t new_cap = p->term_index_cap ? p->term_index_cap * 2 : 64;
|
||||
if (new_cap < lsn)
|
||||
new_cap = lsn;
|
||||
uint64_t* grown = realloc(p->term_index, new_cap * sizeof(uint64_t));
|
||||
if (!grown) {
|
||||
ERROR("persist: term-index realloc failed (lsn=%" PRIu64
|
||||
", cap=%" PRIu64 "->%" PRIu64 "); term for this LSN will read "
|
||||
"back as absent", lsn, p->term_index_cap, new_cap);
|
||||
return;
|
||||
}
|
||||
/* Zero-fill the newly grown region so any unwritten slot reads absent. */
|
||||
memset(grown + p->term_index_cap, 0,
|
||||
(new_cap - p->term_index_cap) * sizeof(uint64_t));
|
||||
p->term_index = grown;
|
||||
p->term_index_cap = new_cap;
|
||||
}
|
||||
|
||||
p->term_index[lsn - 1] = term;
|
||||
if (lsn > p->term_index_count)
|
||||
p->term_index_count = lsn;
|
||||
}
|
||||
|
||||
void persist_set_term(persist_t* p, uint64_t term)
|
||||
{
|
||||
if (!p)
|
||||
return;
|
||||
/* p->term is read on the append path under p->lock; take the lock so the
|
||||
* stamped value is consistent with the append that reads it. */
|
||||
pthread_mutex_lock(&p->lock);
|
||||
p->term = term;
|
||||
pthread_mutex_unlock(&p->lock);
|
||||
}
|
||||
|
||||
uint64_t persist_entry_term(const persist_t* p, uint64_t lsn)
|
||||
{
|
||||
if (!p || lsn == 0)
|
||||
return 0;
|
||||
|
||||
/* Take p->lock to read consistently against concurrent record_entry_term
|
||||
* on the append path. Cast away const — the lock is logically a mutable
|
||||
* sub-object of an otherwise-const read. */
|
||||
persist_t* mp = (persist_t*)p;
|
||||
pthread_mutex_lock(&mp->lock);
|
||||
|
||||
uint64_t result = 0;
|
||||
if (lsn <= mp->term_index_count && mp->term_index != NULL) {
|
||||
uint64_t raw = mp->term_index[lsn - 1];
|
||||
/* Legacy-zero rule (R1.8): a present entry carrying term 0 maps to the
|
||||
* single lowest-known real Raft term, applied uniformly so two legacy-0
|
||||
* entries at the same LSN match. Absent LSNs are caught by the bound
|
||||
* above and return 0. */
|
||||
result = (raw == 0) ? WAL_LEGACY_TERM : raw;
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mp->lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -460,6 +544,13 @@ int persist_init(persist_t* p, persist_config_t* cfg, cluster_t* c)
|
|||
p->on_durable_advance = NULL;
|
||||
p->durable_cb_ctx = NULL;
|
||||
|
||||
/* Per-LSN term index starts empty (ha-term-safety 1.3, R1.5): no entries
|
||||
* recorded yet. Grown on demand by record_entry_term during replay/append;
|
||||
* freed in persist_fini. */
|
||||
p->term_index = NULL;
|
||||
p->term_index_count = 0;
|
||||
p->term_index_cap = 0;
|
||||
|
||||
/* Create WAL and snapshot directories */
|
||||
if (mkdir_p(p->wal_path, 0700) != 0) return -errno;
|
||||
if (mkdir_p(p->snap_path, 0700) != 0) return -errno;
|
||||
|
|
@ -560,6 +651,12 @@ void persist_fini(persist_t* p)
|
|||
pthread_cond_destroy(&p->checkpoint_done);
|
||||
pthread_mutex_destroy(&p->fsync_mutex);
|
||||
pthread_cond_destroy(&p->fsync_cond);
|
||||
|
||||
/* Free the in-memory per-LSN term index (ha-term-safety 1.3). */
|
||||
free(p->term_index);
|
||||
p->term_index = NULL;
|
||||
p->term_index_count = 0;
|
||||
p->term_index_cap = 0;
|
||||
}
|
||||
|
||||
/* Forward declaration — defined after checkpoint internals */
|
||||
|
|
@ -621,6 +718,10 @@ int persist_wal_async(persist_t* p, uint32_t type,
|
|||
p->total_wal_bytes += entry_size;
|
||||
p->last_crc = hdr.crc;
|
||||
|
||||
/* Record this entry's term at its LSN for recovery read-back (ha-term-safety
|
||||
* 1.3, R1.5). Under p->lock; stamps the same hdr.term written to disk. */
|
||||
record_entry_term(p, hdr.lsn, hdr.term);
|
||||
|
||||
/* Replicate to standbys — never replicate WAL_CHECKPOINT entries.
|
||||
* last_replicated_crc is distinct from last_crc:
|
||||
* last_crc includes checkpoint markers; last_replicated_crc skips them.
|
||||
|
|
@ -757,6 +858,14 @@ int persist_wal_write_replicated(persist_t* p, uint64_t lsn, uint32_t type,
|
|||
p->total_wal_bytes += entry_size;
|
||||
p->last_crc = hdr.crc;
|
||||
|
||||
/* Record this entry's term at its LSN for recovery read-back (ha-term-safety
|
||||
* 1.3, R1.5). Stamps the same hdr.term written to disk. NOTE: the streamed
|
||||
* envelope does not yet carry the leader's term, so this records the local
|
||||
* best-effort p->term (the design's known hazard closed by task 2.x, which
|
||||
* carries the leader's term on the envelope; this task only provides the
|
||||
* read-back seam over whatever term was persisted). */
|
||||
record_entry_term(p, hdr.lsn, hdr.term);
|
||||
|
||||
/* Advance current_lsn to one past the given lsn (max-idempotent — mirrors
|
||||
* apply_wal_entry's monotonic advance). */
|
||||
if (lsn + 1 > p->current_lsn)
|
||||
|
|
@ -1058,6 +1167,11 @@ static int persist_write_checkpoint_marker(persist_t* p)
|
|||
p->total_wal_bytes += sizeof(hdr);
|
||||
p->last_crc = hdr.crc;
|
||||
/* NOTE: last_replicated_crc is NOT updated — WAL_CHECKPOINT is never replicated */
|
||||
/* NOTE: the term index is NOT updated for WAL_CHECKPOINT — replay_wal SKIPS
|
||||
* checkpoint markers (local bookkeeping, not a logical Raft entry), so the
|
||||
* recovered index never holds a checkpoint LSN; the live index must match
|
||||
* (ha-term-safety 1.3). persist_entry_term is for (term,lsn) reconcile, which
|
||||
* never compares against a checkpoint marker. */
|
||||
|
||||
pthread_mutex_unlock(&p->lock);
|
||||
return 0;
|
||||
|
|
@ -1583,6 +1697,19 @@ static int replay_wal(persist_t* p, uint64_t from_lsn,
|
|||
/* Advance segment-local CRC */
|
||||
last_crc = hdr.crc;
|
||||
|
||||
/* Record this entry's term at its LSN for recovery read-back
|
||||
* (ha-term-safety 1.3, R1.5). Record EVERY CRC-validated logical
|
||||
* entry — including snapshot-covered ones below from_lsn — so the
|
||||
* index spans the full LSN range the reconcile/promotion logic may
|
||||
* query. EXCLUDE WAL_CHECKPOINT markers (type == WAL_CHECKPOINT) and
|
||||
* unknown future types (type > WAL_CHECKPOINT): they are skipped
|
||||
* below and are never logical Raft entries, so the live append path
|
||||
* does not record them either (kept symmetric). The raw hdr.term is
|
||||
* stored verbatim (legacy 0 included); the legacy-0 -> WAL_LEGACY_TERM
|
||||
* mapping is applied only at read-back. */
|
||||
if (hdr.type < WAL_CHECKPOINT)
|
||||
record_entry_term(p, hdr.lsn, hdr.term);
|
||||
|
||||
/* Skip entries already covered by the snapshot */
|
||||
if (hdr.lsn <= from_lsn) {
|
||||
free(payload);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ extern void test_commit_durability(void);
|
|||
extern void test_ctrlstatus(void);
|
||||
extern void test_ha_heartbeat_committed(void);
|
||||
extern void test_ha_vote_floor_heartbeat(void);
|
||||
extern void test_term_safety_adapter(void);
|
||||
extern void test_waldump(void);
|
||||
|
||||
void setUp(void) { return; }
|
||||
|
|
@ -196,6 +197,7 @@ int main(int argc, char* argv[])
|
|||
TEST(test_ctrlstatus);
|
||||
TEST(test_ha_heartbeat_committed);
|
||||
TEST(test_ha_vote_floor_heartbeat);
|
||||
TEST(test_term_safety_adapter);
|
||||
TEST(test_waldump);
|
||||
|
||||
return UNITY_END();
|
||||
|
|
|
|||
229
src/controller/testsuite/test_term_safety_adapter.c
Normal file
229
src/controller/testsuite/test_term_safety_adapter.c
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
#define _GNU_SOURCE
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <unity/unity.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "persist.h"
|
||||
#include "wal_types.h"
|
||||
|
||||
/* ha-term-safety task 1.3 — WAL term seam + recovery read-back + legacy-0 rule.
|
||||
*
|
||||
* The seam under test:
|
||||
* - persist_set_term(p, T) sets the controller's live election term so a
|
||||
* subsequent append stamps hdr.term = T (R1.1, R1.6).
|
||||
* - replay_wal retains each entry's hdr.term per LSN in an in-memory index
|
||||
* (R1.5), read back via persist_entry_term(p, lsn).
|
||||
* - persist_entry_term returns 0 for an absent LSN; a legacy entry carrying
|
||||
* hdr.term == 0 maps to the single lowest-known real Raft term (1) so a
|
||||
* legacy-0 entry is never spuriously divergent from another legacy-0 entry
|
||||
* and recovery never fails on legacy-0 (R1.8).
|
||||
* - the on-disk wal_entry_hdr_t stays 40 bytes (R1.7).
|
||||
*/
|
||||
|
||||
/* The defined legacy-0 mapping (lowest real Raft term). Kept local to the test
|
||||
* so the assertion is independent of the implementation constant. */
|
||||
#define LEGACY_LOWEST_TERM 1u
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static persist_t* make_fresh_persist(const char* tmpdir)
|
||||
{
|
||||
persist_t* p = calloc(1, sizeof(persist_t));
|
||||
if (!p) return NULL;
|
||||
|
||||
persist_config_t cfg = persist_config_defaults();
|
||||
cfg.wal_sync_ms = 5000; /* slow fsync — no races in tests */
|
||||
cfg.snap_keep_n = 2;
|
||||
|
||||
persist_set_workdir(p, tmpdir, "ctrl-test");
|
||||
|
||||
if (persist_init(p, &cfg, NULL) != 0) {
|
||||
free(p);
|
||||
return NULL;
|
||||
}
|
||||
if (persist_start(p) != 0) {
|
||||
persist_fini(p);
|
||||
free(p);
|
||||
return NULL;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
static void teardown_persist(persist_t* p)
|
||||
{
|
||||
persist_stop(p);
|
||||
persist_fini(p);
|
||||
free(p);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Test: header layout preserved (R1.7) — value fix, not a format */
|
||||
/* change. The on-disk WAL entry header must stay exactly 40 bytes. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void test_wal_entry_hdr_size_is_40(void)
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT64(40u, (uint64_t)sizeof(wal_entry_hdr_t));
|
||||
printf("test_wal_entry_hdr_size_is_40: PASS\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Test: persist_set_term makes a subsequent append stamp hdr.term */
|
||||
/* (R1.1, R1.6), recovered back via persist_entry_term. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void test_set_term_stamps_append(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_term_set_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
const uint64_t T = 7;
|
||||
|
||||
/* Phase 1: set the live term, then append — the append stamps hdr.term=T. */
|
||||
{
|
||||
persist_t* p = make_fresh_persist(tmp);
|
||||
TEST_ASSERT_NOT_NULL(p);
|
||||
|
||||
persist_set_term(p, T);
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "a", 1));
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
|
||||
|
||||
/* Live read-back of the just-appended entry's term (LSN 1). */
|
||||
TEST_ASSERT_EQUAL_UINT64(T, persist_entry_term(p, 1));
|
||||
|
||||
teardown_persist(p);
|
||||
}
|
||||
|
||||
/* Phase 2: fresh persist_t recovers the WAL and reads the term back. */
|
||||
{
|
||||
persist_t* p2 = make_fresh_persist(tmp);
|
||||
TEST_ASSERT_NOT_NULL(p2);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_recover(p2));
|
||||
TEST_ASSERT_EQUAL_UINT64(T, persist_entry_term(p2, 1));
|
||||
|
||||
teardown_persist(p2);
|
||||
}
|
||||
|
||||
printf("test_set_term_stamps_append: PASS\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Test: replay_wal of a WAL with mixed real-term and legacy-0 */
|
||||
/* entries — persist_entry_term returns the real term for real */
|
||||
/* entries and the defined lowest-known term for legacy-0 (R1.5, */
|
||||
/* R1.8). Recovery never fails on legacy-0 entries. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void test_recover_mixed_real_and_legacy_terms(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_term_mixed_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
/* Phase 1: write LSN 1,2 with term 0 (legacy — the pre-feature default),
|
||||
* then LSN 3,4 with real term 5. The append path stamps hdr.term=p->term,
|
||||
* so leaving the term unset (0) produces genuine legacy-0 entries. */
|
||||
{
|
||||
persist_t* p = make_fresh_persist(tmp);
|
||||
TEST_ASSERT_NOT_NULL(p);
|
||||
|
||||
/* Legacy-0 entries: no persist_set_term call (p->term stays 0). */
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "l", 1)); /* lsn 1 */
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "m", 1)); /* lsn 2 */
|
||||
|
||||
/* Real-term entries. */
|
||||
persist_set_term(p, 5);
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "r", 1)); /* lsn 3 */
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "s", 1)); /* lsn 4 */
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
|
||||
teardown_persist(p);
|
||||
}
|
||||
|
||||
/* Phase 2: fresh recover — read back each LSN's term. */
|
||||
{
|
||||
persist_t* p2 = make_fresh_persist(tmp);
|
||||
TEST_ASSERT_NOT_NULL(p2);
|
||||
|
||||
/* Recovery must NOT fail on the legacy-0 entries. */
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_recover(p2));
|
||||
TEST_ASSERT_EQUAL_INT(RECOVER_CLEAN, persist_recovery_verdict(p2));
|
||||
|
||||
/* Legacy-0 entries map to the single lowest-known real term (1) — both
|
||||
* the same, so two legacy-0 entries are never seen as divergent. */
|
||||
TEST_ASSERT_EQUAL_UINT64(LEGACY_LOWEST_TERM, persist_entry_term(p2, 1));
|
||||
TEST_ASSERT_EQUAL_UINT64(LEGACY_LOWEST_TERM, persist_entry_term(p2, 2));
|
||||
|
||||
/* Real-term entries return their recorded term verbatim. */
|
||||
TEST_ASSERT_EQUAL_UINT64(5, persist_entry_term(p2, 3));
|
||||
TEST_ASSERT_EQUAL_UINT64(5, persist_entry_term(p2, 4));
|
||||
|
||||
teardown_persist(p2);
|
||||
}
|
||||
|
||||
printf("test_recover_mixed_real_and_legacy_terms: PASS\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Test: persist_entry_term returns 0 for an absent LSN (no entry). */
|
||||
/* Distinguishes "absent" (0) from a present legacy-0 entry (mapped */
|
||||
/* to lowest-known), which is the precision REVIEW ISSUE 2 requires. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void test_entry_term_absent_lsn_is_zero(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_term_absent_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
persist_t* p = make_fresh_persist(tmp);
|
||||
TEST_ASSERT_NOT_NULL(p);
|
||||
|
||||
persist_set_term(p, 3);
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(p, WAL_JOB_UPDATE, "a", 1)); /* lsn 1 */
|
||||
|
||||
/* LSN 0 is reserved (never an entry) and LSN 2 was never appended. */
|
||||
TEST_ASSERT_EQUAL_UINT64(0, persist_entry_term(p, 0));
|
||||
TEST_ASSERT_EQUAL_UINT64(0, persist_entry_term(p, 2));
|
||||
TEST_ASSERT_EQUAL_UINT64(0, persist_entry_term(p, 999999));
|
||||
|
||||
/* The present entry still answers its real term. */
|
||||
TEST_ASSERT_EQUAL_UINT64(3, persist_entry_term(p, 1));
|
||||
|
||||
teardown_persist(p);
|
||||
printf("test_entry_term_absent_lsn_is_zero: PASS\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Test: persist_entry_term is NULL-safe. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void test_entry_term_null_safe(void)
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT64(0, persist_entry_term(NULL, 1));
|
||||
/* persist_set_term must not crash on NULL. */
|
||||
persist_set_term(NULL, 9);
|
||||
printf("test_entry_term_null_safe: PASS\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Suite entry point */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void test_term_safety_adapter(void)
|
||||
{
|
||||
RUN_TEST(test_wal_entry_hdr_size_is_40);
|
||||
RUN_TEST(test_set_term_stamps_append);
|
||||
RUN_TEST(test_recover_mixed_real_and_legacy_terms);
|
||||
RUN_TEST(test_entry_term_absent_lsn_is_zero);
|
||||
RUN_TEST(test_entry_term_null_safe);
|
||||
}
|
||||
|
|
@ -171,6 +171,23 @@ struct persist_s {
|
|||
uint64_t total_wal_bytes;
|
||||
char seg_path[4096];
|
||||
|
||||
/* Per-LSN recovered/live term index (ha-term-safety task 1.3, R1.5).
|
||||
* IN-MEMORY ONLY — no on-disk layout change (R1.7). Records each entry's
|
||||
* hdr.term keyed by LSN so the term can be read back after recovery and
|
||||
* fed to the reconcile/promotion logic (on_get_entry_term, task 2.2).
|
||||
*
|
||||
* term_index[lsn - 1] holds the RAW recorded term for a 1-based LSN (LSN 0
|
||||
* is reserved and never indexed). term_index_count is the highest LSN
|
||||
* recorded (== number of contiguous slots in use), used as the presence
|
||||
* watermark so persist_entry_term distinguishes an ABSENT LSN (returns 0)
|
||||
* from a PRESENT legacy-0 entry (mapped to the lowest-known term). Grown on
|
||||
* demand by record_entry_term during replay_wal and live appends; freed in
|
||||
* persist_fini. Guarded by p->lock (written under the append lock and the
|
||||
* single-threaded recovery path; read by accessors that take p->lock). */
|
||||
uint64_t* term_index; /* term_index[i] = term of LSN (i+1) */
|
||||
uint64_t term_index_count; /* highest recorded LSN (slots in use) */
|
||||
uint64_t term_index_cap; /* allocated slot capacity */
|
||||
|
||||
/* Recovery-integrity verdict (ha-commit-durability task 3.1, Req 2.1).
|
||||
* Set by persist_recover/replay_wal at startup: RECOVER_CLEAN when the
|
||||
* local WAL forms a valid CRC chain to a clean entry boundary (incl. an
|
||||
|
|
@ -271,6 +288,45 @@ typedef struct persist_s persist_t;
|
|||
* Derives wal_path and snap_path as <workdir>/<name>/data/{wal,snap}. */
|
||||
void persist_set_workdir(persist_t* p, const char* workdir, const char* name);
|
||||
|
||||
/* ============================================================
|
||||
* WAL term adapter (ha-term-safety task 1.3, Req 1.1/1.5/1.6/1.7/1.8)
|
||||
*
|
||||
* Today every WAL append stamps hdr.term = p->term, but p->term is never
|
||||
* assigned, so all entries carry term 0 (the G1 bug). The on-disk
|
||||
* wal_entry_hdr_t already has a 40-byte `term` field; this is a VALUE fix, NOT
|
||||
* a layout change (R1.7).
|
||||
* ============================================================ */
|
||||
|
||||
/* Legacy-zero-term rule (R1.8) — REVIEW ISSUE 2, made precise:
|
||||
*
|
||||
* A WAL entry that predates this feature carries hdr.term == 0. For (term,lsn)
|
||||
* matching it must map to a SINGLE, well-defined "lowest-known term" so that
|
||||
* (a) recovery never fails on legacy-0 entries, and
|
||||
* (b) a legacy-0 entry is never spuriously treated as divergent from another
|
||||
* legacy-0 entry at the same LSN.
|
||||
* The rule: legacy-0 -> WAL_LEGACY_TERM (1, the lowest real Raft term — Raft
|
||||
* terms start at 1; term 0 is the pre-election "no term" placeholder). It is
|
||||
* applied UNIFORMLY at read-back (persist_entry_term), so every legacy-0 entry
|
||||
* answers the same term 1 and matches another legacy-0 entry at the same LSN.
|
||||
* The raw 0 is still stored in the index (so "present legacy-0" stays
|
||||
* distinguishable from "absent"); only the read-back value is mapped. */
|
||||
#define WAL_LEGACY_TERM 1u
|
||||
|
||||
/* Set the controller's live election term so subsequent WAL appends stamp it
|
||||
* into hdr.term (R1.1/R1.6). Called from the append callback (on_persist_entry,
|
||||
* task 2.2) and on term adoption, so no append records a zero placeholder while
|
||||
* a valid term is in effect. No on-disk layout change; value fix only (R1.7).
|
||||
* Safe on a NULL p (no-op). */
|
||||
void persist_set_term(persist_t* p, uint64_t term);
|
||||
|
||||
/* Recovered/live per-entry term for a 1-based LSN (lsn == Raft index), feeding
|
||||
* the task-2.2 on_get_entry_term callback and the library's init restore
|
||||
* (R1.5). Returns 0 when the LSN is ABSENT (never recorded). A PRESENT entry
|
||||
* that carries a legacy zero term is mapped to WAL_LEGACY_TERM (R1.8) so it is
|
||||
* never confused with an absent LSN and matches other legacy-0 entries.
|
||||
* Safe on a NULL p (returns 0). */
|
||||
uint64_t persist_entry_term(const persist_t* p, uint64_t lsn);
|
||||
|
||||
/* Lifecycle */
|
||||
int persist_init(persist_t* p, persist_config_t* config, cluster_t* c);
|
||||
int persist_start(persist_t* p);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue