From b028d25dbdfd72f79a1cba9cfb1378b222906fdd Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sat, 27 Jun 2026 04:53:32 +0000 Subject: [PATCH] test(raft-ha-core-safety): machine-checkable walcompare report (task 1.1) Per-member (lsn,term,pcrc,present) report + committed-vs-uncommitted divergence classification via walcompare_report()/--report. Soak signal (OK/exit-code) preserved. 4 new unit tests, all GREEN. --- .kiro/specs/raft-ha-core-safety/tasks.md | 2 +- src/controller/testsuite/test.c | 56 ++-- src/controller/testsuite/test_walcompare.c | 158 ++++++++++ src/controller/testsuite/walcompare.c | 328 +++++++++++++++------ src/controller/testsuite/walcompare.h | 29 ++ 5 files changed, 453 insertions(+), 120 deletions(-) diff --git a/.kiro/specs/raft-ha-core-safety/tasks.md b/.kiro/specs/raft-ha-core-safety/tasks.md index 09d1bfe..0700cf9 100644 --- a/.kiro/specs/raft-ha-core-safety/tasks.md +++ b/.kiro/specs/raft-ha-core-safety/tasks.md @@ -7,7 +7,7 @@ > **Hypothesis discipline:** the term-blind bypass is the primary hypothesis, not proven sole cause. Task 1.3 must reproduce the *live* divergence first; a green synthetic unit test over a still-red soak does not satisfy 1.1/8.1. - [ ] 1. Foundation: deterministic chaotic harness and live-divergence reproduction -- [ ] 1.1 Extend the divergence detector to a machine-checkable per-member report +- [x] 1.1 Extend the divergence detector to a machine-checkable per-member report - Extend `walcompare` to emit a parseable per-member `(lsn -> term, payload-crc, present/MISSING)` report over the committed prefix, and classify each divergence as committed vs uncommitted - Observable completion: `walcompare` prints a parseable per-member report and a committed-vs-uncommitted classification; the existing soak still detects the round-3 LSN divergence through it - _Requirements: 7.1, 7.5_ diff --git a/src/controller/testsuite/test.c b/src/controller/testsuite/test.c index 6681ad6..7a1715e 100644 --- a/src/controller/testsuite/test.c +++ b/src/controller/testsuite/test.c @@ -721,41 +721,54 @@ int cmd_waldump(int argc, char* argv[]) return rc; } -/* walcompare [--committed N] [more...] — diff member WAL - * committed prefixes by (lsn -> term, pcrc) and FAIL (nonzero) on any +/* walcompare [--report] [--committed N] [more...] — diff + * member WAL committed prefixes by (lsn -> term, pcrc) and FAIL (nonzero) on any * committed-prefix divergence (ha-term-safety R6.1/R6.2/R6.3), reusing the * waldump decoder. Each member path may be a seg-*.wal file, a wal/ dir, or a * node/data dir (same resolution as waldump). With no --committed, the shared * committed boundary = the minimum CRC-intact tip across members. The diverging - * LSN and per-member (term, pcrc) identities are reported. Exit 0 = identical, - * nonzero = divergence or load error — so it doubles as a scripted assertion. */ + * LSN and per-member (term, pcrc) identities are reported. With --report, also + * emit a machine-checkable per-member `(lsn, term, pcrc, status)` table and a + * committed-vs-uncommitted classification for each divergence (task 1.1). Exit + * 0 = identical, nonzero = divergence or load error — so it doubles as a + * scripted assertion. */ int cmd_walcompare(int argc, char* argv[]) { - uint64_t committed = 0; /* 0 = derive the shared boundary */ + uint64_t committed = 0; /* 0 = derive the shared boundary */ + int report = 0; /* --report => full per-member machine table */ int ai = 1; - /* Optional global committed boundary applied to every member. */ - if (argc > ai && strcmp(argv[ai], "--committed") == 0) { - if (argc <= ai + 1) { - fprintf(stderr, "walcompare: --committed needs an LSN\n"); - return -1; + /* Optional flags (any order): --report, --committed N. */ + for (;;) { + if (argc > ai && strcmp(argv[ai], "--report") == 0) { + report = 1; + ai += 1; + continue; } - char* end = NULL; - unsigned long v = strtoul(argv[ai + 1], &end, 10); - if (end == argv[ai + 1] || *end != '\0') { - fprintf(stderr, "walcompare: bad --committed LSN '%s'\n", - argv[ai + 1]); - return -1; + if (argc > ai && strcmp(argv[ai], "--committed") == 0) { + if (argc <= ai + 1) { + fprintf(stderr, "walcompare: --committed needs an LSN\n"); + return -1; + } + char* end = NULL; + unsigned long v = strtoul(argv[ai + 1], &end, 10); + if (end == argv[ai + 1] || *end != '\0') { + fprintf(stderr, "walcompare: bad --committed LSN '%s'\n", + argv[ai + 1]); + return -1; + } + committed = (uint64_t)v; + ai += 2; + continue; } - committed = (uint64_t)v; - ai += 2; + break; } int n = argc - ai; if (n < 2) { fprintf(stderr, - "usage: walcompare [--committed N] " - "[more...]\n"); + "usage: walcompare [--report] [--committed N] " + " [more...]\n"); return -1; } if (n > 16) { @@ -770,7 +783,8 @@ int cmd_walcompare(int argc, char* argv[]) } walcompare_divergence_t div; - int rc = walcompare(members, n, stdout, &div); + int rc = report ? walcompare_report(members, n, stdout, &div) + : walcompare(members, n, stdout, &div); if (rc == WALCMP_OK) return 0; if (rc == WALCMP_DIVERGENT) { diff --git a/src/controller/testsuite/test_walcompare.c b/src/controller/testsuite/test_walcompare.c index 25e8e3b..7daa443 100644 --- a/src/controller/testsuite/test_walcompare.c +++ b/src/controller/testsuite/test_walcompare.c @@ -354,6 +354,160 @@ static void test_too_few_members_errors(void) TEST_ASSERT_LESS_THAN_INT(0, rc); } +/* ------------------------------------------------------------------ */ +/* Machine-checkable report: a committed-prefix divergence must emit a */ +/* parseable per-member line set and a committed-vs-uncommitted */ +/* classification (task 1.1, R7.1/R7.5). Captured via open_memstream so */ +/* the exact greppable tokens are asserted, not just the verdict. */ +/* ------------------------------------------------------------------ */ +static void test_machine_report_committed_divergence(void) +{ + wal_spec_t a_specs[] = { + { 1, WAL_NOOP, NULL }, + { 1, WAL_JOB_SUBMIT, "jobA" }, + { 2, WAL_JOB_SUBMIT, "jobB" }, /* LSN 3, term 2 */ + }; + wal_spec_t b_specs[] = { + { 1, WAL_NOOP, NULL }, + { 1, WAL_JOB_SUBMIT, "jobA" }, + { 3, WAL_JOB_SUBMIT, "jobB" }, /* LSN 3, term 3 — divergent */ + }; + char a[256], b[256]; + build_member(a, sizeof(a), a_specs, 3); + build_member(b, sizeof(b), b_specs, 3); + + char* buf = NULL; + size_t len = 0; + FILE* out = open_memstream(&buf, &len); + TEST_ASSERT_NOT_NULL(out); + + walcompare_member_t m[2] = { { a, 0 }, { b, 0 } }; + walcompare_divergence_t div; + int rc = walcompare(m, 2, out, &div); + fclose(out); + + TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc); + /* committed-vs-uncommitted classification line. */ + TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP_DIVERGENCE")); + TEST_ASSERT_NOT_NULL(strstr(buf, "class=committed")); + /* parseable per-member lines with fixed field order. */ + TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=0 lsn=3")); + TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=1 lsn=3")); + TEST_ASSERT_NOT_NULL(strstr(buf, "status=present")); + /* divergence struct carries the committed classification flag. */ + TEST_ASSERT_EQUAL_INT(1, div.committed); + free(buf); +} + +/* ------------------------------------------------------------------ */ +/* walcompare_report emits a full per-member, per-committed-LSN table */ +/* (lsn, term, pcrc, status) for identical members and a clean verdict. */ +/* ------------------------------------------------------------------ */ +static void test_report_full_member_table(void) +{ + wal_spec_t specs[] = { + { 1, WAL_NOOP, NULL }, + { 1, WAL_JOB_SUBMIT, "jobA" }, + { 2, WAL_JOB_SUBMIT, "jobB" }, + }; + char a[256], b[256], c[256]; + build_member(a, sizeof(a), specs, 3); + build_member(b, sizeof(b), specs, 3); + build_member(c, sizeof(c), specs, 3); + + char* buf = NULL; + size_t len = 0; + FILE* out = open_memstream(&buf, &len); + TEST_ASSERT_NOT_NULL(out); + + walcompare_member_t m[3] = { { a, 0 }, { b, 0 }, { c, 0 } }; + walcompare_divergence_t div; + int rc = walcompare_report(m, 3, out, &div); + fclose(out); + + TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc); + /* every member appears at every committed LSN with committed=1. */ + TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=0 lsn=1")); + TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=2 lsn=3")); + TEST_ASSERT_NOT_NULL(strstr(buf, "committed=1")); + TEST_ASSERT_NOT_NULL(strstr(buf, "status=present")); + free(buf); +} + +/* ------------------------------------------------------------------ */ +/* A MISSING committed entry is tagged status=MISSING in the report, */ +/* and classified class=committed (a committed-prefix hole, R4.2/R6.4). */ +/* ------------------------------------------------------------------ */ +static void test_report_missing_tagged(void) +{ + wal_spec_t a_specs[] = { + { 1, WAL_NOOP, NULL }, + { 1, WAL_JOB_SUBMIT, "jobA" }, + { 2, WAL_JOB_SUBMIT, "jobB" }, + }; + wal_spec_t b_specs[] = { + { 1, WAL_NOOP, NULL }, + { 1, WAL_JOB_SUBMIT, "jobA" }, + }; + char a[256], b[256]; + build_member(a, sizeof(a), a_specs, 3); + build_member(b, sizeof(b), b_specs, 2); + + char* buf = NULL; + size_t len = 0; + FILE* out = open_memstream(&buf, &len); + TEST_ASSERT_NOT_NULL(out); + + walcompare_member_t m[2] = { { a, 3 }, { b, 3 } }; + walcompare_divergence_t div; + int rc = walcompare_report(m, 2, out, &div); + fclose(out); + + TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc); + TEST_ASSERT_NOT_NULL(strstr(buf, "status=MISSING")); + TEST_ASSERT_NOT_NULL(strstr(buf, "class=committed")); + TEST_ASSERT_EQUAL_INT(1, div.committed); + free(buf); +} + +/* ------------------------------------------------------------------ */ +/* A divergence in the UNCOMMITTED tail (above the committed boundary) is */ +/* reported class=uncommitted and is NON-fatal: the committed verdict is */ +/* still WALCMP_OK (task 1.1 classification; §6.1 committed-prefix only). */ +/* ------------------------------------------------------------------ */ +static void test_report_uncommitted_tail_classified(void) +{ + wal_spec_t a_specs[] = { + { 1, WAL_NOOP, NULL }, + { 1, WAL_JOB_SUBMIT, "jobA" }, /* committed LSN 2 */ + { 2, WAL_JOB_SUBMIT, "uncA" }, /* LSN 3 uncommitted, divergent */ + }; + wal_spec_t b_specs[] = { + { 1, WAL_NOOP, NULL }, + { 1, WAL_JOB_SUBMIT, "jobA" }, + { 9, WAL_JOB_SUBMIT, "uncB" }, /* LSN 3, different term+bytes */ + }; + char a[256], b[256]; + build_member(a, sizeof(a), a_specs, 3); + build_member(b, sizeof(b), b_specs, 3); + + char* buf = NULL; + size_t len = 0; + FILE* out = open_memstream(&buf, &len); + TEST_ASSERT_NOT_NULL(out); + + /* committed boundary pinned at 2 → LSN-3 divergence is the tail. */ + walcompare_member_t m[2] = { { a, 2 }, { b, 2 } }; + walcompare_divergence_t div; + int rc = walcompare_report(m, 2, out, &div); + fclose(out); + + TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc); /* tail divergence is non-fatal */ + TEST_ASSERT_NOT_NULL(strstr(buf, "class=uncommitted")); + TEST_ASSERT_NOT_NULL(strstr(buf, "committed=0")); + free(buf); +} + void test_walcompare(void) { RUN_TEST(test_identical_members_ok); @@ -367,4 +521,8 @@ void test_walcompare(void) RUN_TEST(test_three_members_divergent_config_marker); RUN_TEST(test_missing_member_errors); RUN_TEST(test_too_few_members_errors); + RUN_TEST(test_machine_report_committed_divergence); + RUN_TEST(test_report_full_member_table); + RUN_TEST(test_report_missing_tagged); + RUN_TEST(test_report_uncommitted_tail_classified); } diff --git a/src/controller/testsuite/walcompare.c b/src/controller/testsuite/walcompare.c index c541091..fdebd8d 100644 --- a/src/controller/testsuite/walcompare.c +++ b/src/controller/testsuite/walcompare.c @@ -132,79 +132,202 @@ static int load_member(const walcompare_member_t* src, member_log_t* m, FILE* ou return 0; } -int walcompare(const walcompare_member_t* members, int n, FILE* out, - walcompare_divergence_t* div) -{ - if (!members || n < 2) - return -EINVAL; - if (n > WALCMP_MAX_MEMBERS) - return -E2BIG; - +/* Loaded cluster state shared by walcompare() and walcompare_report(). */ +typedef struct { member_log_t logs[WALCMP_MAX_MEMBERS]; - int loaded = 0; - int rc = 0; + int n; + int loaded; /* members successfully loaded (for cleanup) */ + uint64_t boundary; /* highest committed LSN compared (committed edge) */ + uint64_t max_tip; /* highest recovered tip across members (tail edge) */ +} wc_cluster_t; + +/* Stable status token for one member at one LSN. */ +static const char* wc_status(const member_log_t* m, uint64_t lsn) +{ + if ((lsn < m->cap) && m->present[lsn]) + return "present"; + if (lsn <= m->committed_lsn) + return "MISSING"; /* a committed entry this member SHOULD hold (R4.2) */ + return "beyond"; /* legitimately not held (above its committed prefix) */ +} + +/* Load all members and compute the committed boundary + tail edge. On a load + * error, frees what loaded, reports to `out`, returns a negative errno. */ +static int wc_load(const walcompare_member_t* members, int n, FILE* out, + wc_cluster_t* cl) +{ + memset(cl, 0, sizeof(*cl)); + cl->n = n; for (int i = 0; i < n; i++) { - rc = load_member(&members[i], &logs[i], out); + int rc = load_member(&members[i], &cl->logs[i], out); if (rc != 0) { if (out) fprintf(out, "walcompare: cannot load member %d ('%s'): %s\n", i, members[i].path ? members[i].path : "(null)", strerror(-rc)); - goto done; + for (int j = 0; j < cl->loaded; j++) + member_log_free(&cl->logs[j]); + return rc; } - loaded = i + 1; + cl->loaded = i + 1; } /* Shared committed boundary = the minimum recovered tip across members: * the largest committed prefix every member can be expected to hold. A * member with an explicit committed_lsn keeps its own. */ - uint64_t shared = logs[0].recovered_tip; + uint64_t shared = cl->logs[0].recovered_tip; for (int i = 1; i < n; i++) - if (logs[i].recovered_tip < shared) - shared = logs[i].recovered_tip; + if (cl->logs[i].recovered_tip < shared) + shared = cl->logs[i].recovered_tip; - uint64_t boundary = 0; /* highest committed LSN compared across the cluster */ for (int i = 0; i < n; i++) { - if (logs[i].committed_lsn == 0) - logs[i].committed_lsn = shared; - if (logs[i].committed_lsn > boundary) - boundary = logs[i].committed_lsn; + if (cl->logs[i].committed_lsn == 0) + cl->logs[i].committed_lsn = shared; + if (cl->logs[i].committed_lsn > cl->boundary) + cl->boundary = cl->logs[i].committed_lsn; + if (cl->logs[i].recovered_tip > cl->max_tip) + cl->max_tip = cl->logs[i].recovered_tip; + } + return 0; +} + +static void wc_unload(wc_cluster_t* cl) +{ + for (int i = 0; i < cl->loaded; i++) + member_log_free(&cl->logs[i]); +} + +/* One machine line for member `i` at `lsn`. Fixed field order, stable tokens. */ +static void wc_emit_member(const wc_cluster_t* cl, int i, uint64_t lsn, + int committed, FILE* out) +{ + const member_log_t* m = &cl->logs[i]; + int holds = (lsn < m->cap) && m->present[lsn]; + fprintf(out, + "WALCMP member=%d lsn=%" PRIu64 " term=%" PRIu64 + " pcrc=%08x status=%s committed=%d\n", + i, lsn, holds ? match_term(m->term[lsn]) : 0, + holds ? m->pcrc[lsn] : 0u, wc_status(m, lsn), committed); +} + +/* Full per-member machine table over [1..max_tip]; committed=1 within the + * committed boundary, 0 in the uncommitted tail. */ +static void wc_emit_table(const wc_cluster_t* cl, FILE* out) +{ + for (uint64_t lsn = 1; lsn <= cl->max_tip; lsn++) { + int committed = (lsn <= cl->boundary) ? 1 : 0; + for (int i = 0; i < cl->n; i++) + wc_emit_member(cl, i, lsn, committed, out); + } +} + +/* Emit the classification + per-member identity for ONE diverging LSN. */ +static void wc_emit_divergence(const wc_cluster_t* cl, uint64_t lsn, + int committed, int compared, int present, + FILE* out) +{ + fprintf(out, + "WALCMP_DIVERGENCE lsn=%" PRIu64 " class=%s compared=%d present=%d\n", + lsn, committed ? "committed" : "uncommitted", compared, present); + for (int i = 0; i < cl->n; i++) + wc_emit_member(cl, i, lsn, committed, out); +} + +/* Compare one committed LSN. Fills `div` (committed=1) and prints the human + + * machine report when divergent. Returns 1 if divergent, else 0. */ +static int wc_compare_committed(const wc_cluster_t* cl, + const walcompare_member_t* members, uint64_t lsn, + FILE* out, walcompare_divergence_t* div) +{ + int have = 0, present = 0, have_ref = 0, divergent = 0; + uint64_t ref_term = 0; + uint32_t ref_pcrc = 0; + + for (int i = 0; i < cl->n; i++) { + if (lsn > cl->logs[i].committed_lsn) + continue; /* beyond member i's committed prefix */ + have++; + const member_log_t* m = &cl->logs[i]; + int holds = (lsn < m->cap) && m->present[lsn]; + if (!holds) { + divergent = 1; /* member should hold this committed LSN but doesn't */ + continue; + } + present++; + uint64_t mt = match_term(m->term[lsn]); + uint32_t mp = m->pcrc[lsn]; + if (!have_ref) { + ref_term = mt; + ref_pcrc = mp; + have_ref = 1; + } else if (mt != ref_term || mp != ref_pcrc) { + divergent = 1; + } } - if (out) + if (have < 2 || !divergent) + return 0; + + if (div) { + memset(div, 0, sizeof(*div)); + div->lsn = lsn; + div->n_members = cl->n; + div->committed = 1; + for (int i = 0; i < cl->n && i < 16; i++) { + const member_log_t* m = &cl->logs[i]; + int holds = (lsn <= m->committed_lsn) && (lsn < m->cap) && + m->present[lsn]; + div->member_present[i] = holds ? 1 : 0; + div->member_term[i] = holds ? match_term(m->term[lsn]) : 0; + div->member_pcrc[i] = holds ? m->pcrc[lsn] : 0; + } + } + if (out) { fprintf(out, - "== walcompare: %d members, committed boundary up to LSN %" PRIu64 - " ==\n", - n, boundary); - - /* Walk every committed LSN. At each LSN, compare the (term, pcrc) of the - * members whose own committed boundary covers it and that hold the entry. - * A member that does not hold a committed LSN it SHOULD (boundary covers it - * but the entry is absent) is itself a committed-prefix divergence: a - * committed entry is missing (R4.2 / R6.4). */ - for (uint64_t lsn = 1; lsn <= boundary; lsn++) { - int have = 0; /* members whose boundary covers this LSN */ - int present = 0; /* of those, how many actually hold it */ - uint64_t ref_term = 0; - uint32_t ref_pcrc = 0; - int have_ref = 0; - int divergent = 0; - - for (int i = 0; i < n; i++) { - if (lsn > logs[i].committed_lsn) - continue; /* this LSN is beyond member i's committed prefix */ - have++; - - int holds = (lsn < logs[i].cap) && logs[i].present[lsn]; - if (!holds) { - /* member i should hold this committed LSN but does not */ - divergent = 1; + "*** DIVERGENCE at committed LSN %" PRIu64 + " (compared %d, present %d) ***\n", + lsn, have, present); + for (int i = 0; i < cl->n; i++) { + if (lsn > cl->logs[i].committed_lsn) { + fprintf(out, + " member %d ('%s'): LSN beyond its committed prefix" + " (committed=%" PRIu64 ")\n", + i, members[i].path, cl->logs[i].committed_lsn); continue; } + int holds = (lsn < cl->logs[i].cap) && cl->logs[i].present[lsn]; + if (holds) + fprintf(out, " member %d ('%s'): term=%" PRIu64 " pcrc=%08x\n", + i, members[i].path, match_term(cl->logs[i].term[lsn]), + cl->logs[i].pcrc[lsn]); + else + fprintf(out, " member %d ('%s'): MISSING committed entry\n", i, + members[i].path); + } + wc_emit_divergence(cl, lsn, 1, have, present, out); + } + return 1; +} + +/* Scan the uncommitted tail (boundary < lsn <= max_tip): emit a NON-fatal + * class=uncommitted classification for any LSN where >=2 members hold the entry + * but disagree on (term, pcrc). Never changes the verdict. */ +static void wc_scan_uncommitted(const wc_cluster_t* cl, FILE* out) +{ + for (uint64_t lsn = cl->boundary + 1; lsn <= cl->max_tip; lsn++) { + int present = 0, have_ref = 0, divergent = 0; + uint64_t ref_term = 0; + uint32_t ref_pcrc = 0; + + for (int i = 0; i < cl->n; i++) { + const member_log_t* m = &cl->logs[i]; + int holds = (lsn < m->cap) && m->present[lsn]; + if (!holds) + continue; present++; - uint64_t mt = match_term(logs[i].term[lsn]); - uint32_t mp = logs[i].pcrc[lsn]; + uint64_t mt = match_term(m->term[lsn]); + uint32_t mp = m->pcrc[lsn]; if (!have_ref) { ref_term = mt; ref_pcrc = mp; @@ -213,60 +336,69 @@ int walcompare(const walcompare_member_t* members, int n, FILE* out, divergent = 1; } } - - if (have < 2) - continue; /* nothing to compare at this LSN */ - if (!divergent) + if (present < 2 || !divergent) continue; - - /* First divergent committed LSN — report and fail. */ - if (div) { - memset(div, 0, sizeof(*div)); - div->lsn = lsn; - div->n_members = n; - for (int i = 0; i < n && i < 16; i++) { - int holds = (lsn <= logs[i].committed_lsn) && - (lsn < logs[i].cap) && logs[i].present[lsn]; - div->member_present[i] = holds ? 1 : 0; - div->member_term[i] = holds ? match_term(logs[i].term[lsn]) : 0; - div->member_pcrc[i] = holds ? logs[i].pcrc[lsn] : 0; - } - } - if (out) { - fprintf(out, - "*** DIVERGENCE at committed LSN %" PRIu64 - " (compared %d, present %d) ***\n", - lsn, have, present); - for (int i = 0; i < n; i++) { - if (lsn > logs[i].committed_lsn) { - fprintf(out, - " member %d ('%s'): LSN beyond its committed prefix" - " (committed=%" PRIu64 ")\n", - i, members[i].path, logs[i].committed_lsn); - continue; - } - int holds = (lsn < logs[i].cap) && logs[i].present[lsn]; - if (holds) - fprintf(out, - " member %d ('%s'): term=%" PRIu64 " pcrc=%08x\n", i, - members[i].path, match_term(logs[i].term[lsn]), - logs[i].pcrc[lsn]); - else - fprintf(out, - " member %d ('%s'): MISSING committed entry\n", i, - members[i].path); - } - } - rc = WALCMP_DIVERGENT; - goto done; + wc_emit_divergence(cl, lsn, 0, present, present, out); } +} + +/* Shared driver for walcompare() (full_report=0) and walcompare_report() + * (full_report=1). */ +static int wc_run(const walcompare_member_t* members, int n, FILE* out, + walcompare_divergence_t* div, int full_report) +{ + if (!members || n < 2) + return -EINVAL; + if (n > WALCMP_MAX_MEMBERS) + return -E2BIG; + + wc_cluster_t cl; + int rc = wc_load(members, n, out, &cl); + if (rc != 0) + return rc; + + if (out) + fprintf(out, + "== walcompare: %d members, committed boundary up to LSN %" PRIu64 + " ==\n", + n, cl.boundary); + + if (full_report && out) + wc_emit_table(&cl, out); + + /* Walk every committed LSN. At each LSN, compare the (term, pcrc) of the + * members whose own committed boundary covers it and that hold the entry. + * A member that does not hold a committed LSN it SHOULD is a committed- + * prefix divergence: a committed entry is missing (R4.2 / R6.4). */ + rc = WALCMP_OK; + for (uint64_t lsn = 1; lsn <= cl.boundary; lsn++) { + if (wc_compare_committed(&cl, members, lsn, out, div)) { + rc = WALCMP_DIVERGENT; /* first committed divergence is the verdict */ + goto done; + } + } + + /* The committed prefix is identical. In full-report mode also classify any + * uncommitted-tail divergence (informational, never fatal). */ + if (full_report && out) + wc_scan_uncommitted(&cl, out); if (out) fprintf(out, "walcompare: OK — committed prefixes identical\n"); - rc = WALCMP_OK; done: - for (int i = 0; i < loaded; i++) - member_log_free(&logs[i]); + wc_unload(&cl); return rc; } + +int walcompare(const walcompare_member_t* members, int n, FILE* out, + walcompare_divergence_t* div) +{ + return wc_run(members, n, out, div, 0); +} + +int walcompare_report(const walcompare_member_t* members, int n, FILE* out, + walcompare_divergence_t* div) +{ + return wc_run(members, n, out, div, 1); +} diff --git a/src/controller/testsuite/walcompare.h b/src/controller/testsuite/walcompare.h index 4f368c7..3a98ea0 100644 --- a/src/controller/testsuite/walcompare.h +++ b/src/controller/testsuite/walcompare.h @@ -32,6 +32,9 @@ typedef struct { uint64_t member_term[16]; /* per-member matched term at lsn */ uint32_t member_pcrc[16]; /* per-member payload CRC at lsn */ int member_present[16]; /* 1 if the member holds this LSN, else 0 */ + int committed; /* 1 = divergence within the committed prefix + * (lsn <= committed boundary, a safety + * violation); 0 = uncommitted tail divergence */ } walcompare_divergence_t; /* Result codes. */ @@ -62,3 +65,29 @@ enum { */ int walcompare(const walcompare_member_t* members, int n, FILE* out, walcompare_divergence_t* div); + +/* Same comparison as walcompare(), but ALSO emits a machine-checkable, + * per-member report over the full log: one + * + * WALCMP member= lsn= term= pcrc=<8hex> status= committed=<0|1> + * + * line per (member, LSN) for every LSN in [1..max-recovered-tip], where + * `committed=1` iff the LSN is within the committed boundary. Each divergence + * additionally emits a + * + * WALCMP_DIVERGENCE lsn= class= compared= present=

+ * + * classification line. A `committed` divergence is fatal (verdict + * WALCMP_DIVERGENT, `div->committed = 1`); an `uncommitted`-tail divergence + * (above the committed boundary) is reported but NON-fatal — the verdict stays + * WALCMP_OK when the committed prefix is identical. Field order is fixed and + * the tokens are stable so the harness can grep them. `out` must be non-NULL. + * + * Backward-compatible: walcompare() keeps its original concise output (the + * `== walcompare ==` header, the human divergence block, and the final + * `walcompare: OK` verdict line) and additionally emits the machine + * classification at a committed divergence; only walcompare_report() prints the + * full per-LSN table and scans the uncommitted tail. + */ +int walcompare_report(const walcompare_member_t* members, int n, FILE* out, + walcompare_divergence_t* div);