myxce/src/controller/testsuite/test_waldump.c
Rongsong Shen 20e59d0b42 checkpoint: bug-851 root cause + raft TLA+ conformance spec family
Investigation & fixes (failover committed-job loss):
- root-cause bug-851 on an owned 3-node cluster: chaotic failover causes
  silent committed-prefix divergence (WAL hdr.term always 0; two leaders
  per term; no (term,lsn) reconcile). Evidence in repro-851/ + INVESTIGATION.
- live-validated fixes 848/849/850 (recovery-on-promotion, advertise real
  applied_lsn, persist-layer contiguity guard); add waldump WAL inspector.

Specs (strict raft.tla conformance — library owns the log):
- TLA-gap-analysis.md: impl vs raft/raft.tla, gaps G1-G6.
- raft-log-replication, raft-commit-safety, raft-sim-conformance: extend
  src/lib/raft to own log/commit/verification via callbacks (specs generated,
  cross-spec review passed).
- ha-term-safety: controller-side adapter + config marker (R1-R7); design done.
- config-upgrade-snapshot: R8 descoped here (deps entity-bearing-snapshots, bug-857).
- roadmap + buglog (851/854/857) + .wolf knowledge updated.
2026-06-21 14:02:15 +00:00

295 lines
11 KiB
C

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <zlib.h>
#include <unity/unity.h>
#include "config.h" /* WAL_HDR_CRC_SIZE, WAL_CRC_NONE */
#include "controller.capnp.h" /* Job encode for the JOB_SUBMIT decode test */
#include "wal_types.h"
#include "waldump.h"
/* ------------------------------------------------------------------ *
* Test harness: hand-write raw WAL segments so the test fully controls
* LSNs (to inject gaps), the CRC chain (to inject a break), and a real
* Cap'n Proto Job payload (to exercise the submit jobid decode). This
* also pins the on-disk WAL format independently of persist.c.
* ------------------------------------------------------------------ */
static char g_dir[256];
/* Append one entry to `fp`, chaining prev_crc from *last_crc and updating it.
* Mirrors persist.c's append: CRC32 over WAL_HDR_CRC_SIZE header bytes + payload. */
static void put_entry(FILE* fp, uint64_t term, uint64_t lsn, uint32_t type,
const void* payload, uint32_t plen, uint32_t* last_crc,
int first_in_seg)
{
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.term = term;
hdr.lsn = lsn;
hdr.timestamp = 0;
hdr.type = type;
hdr.payload_len = plen;
hdr.prev_crc = first_in_seg ? WAL_CRC_NONE : *last_crc;
uint32_t c = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
if (plen > 0)
c = crc32(c, (const Bytef*)payload, plen);
hdr.crc = c;
*last_crc = c;
fwrite(&hdr, sizeof(hdr), 1, fp);
if (plen > 0)
fwrite(payload, plen, 1, fp);
}
/* Encode a minimal Cap'n Proto Job carrying `jobid`+`subtime` (what a real
* WAL_JOB_SUBMIT payload looks like). Returns malloc'd bytes; caller frees.
* subtime lets a test make two payloads byte-identical (same subtime) or
* distinct (different subtime) to exercise bug-845 dup detection. */
static uint8_t* encode_job(uint64_t jobid, uint64_t subtime, size_t* out_len)
{
struct capn c;
capn_init_malloc(&c);
capn_ptr root = capn_root(&c);
Job_ptr jp = new_Job(root.seg);
struct Job cj;
memset(&cj, 0, sizeof(cj));
cj.jobid = jobid;
cj.subtime = subtime;
cj.state = 1;
write_Job(&cj, jp);
capn_setp(root, 0, jp.p);
size_t sz = capn_size(&c) + 64;
uint8_t* buf = malloc(sz);
int64_t n = capn_write_mem(&c, buf, sz, 0);
capn_free(&c);
*out_len = (size_t)n;
return buf;
}
static const char* seg_path(uint64_t start_lsn, char* out, size_t n)
{
snprintf(out, n, "%s/seg-%016lx.wal", g_dir, (unsigned long)start_lsn);
return out;
}
/* setUp/tearDown are owned by the shared controller_test runner, so each
* test mints its own fresh temp WAL dir here instead. */
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_waldump_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
/* ------------------------------------------------------------------ */
/* A healthy, contiguous log from LSN 1: no gaps, tip == last entry. */
/* ------------------------------------------------------------------ */
static void test_contiguous_log_has_no_gaps(void)
{
fresh_dir();
char path[512];
FILE* fp = fopen(seg_path(1, path, sizeof(path)), "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
size_t p2, p3;
uint8_t* j2 = encode_job(1001, 100, &p2); /* distinct jobs → no dup */
uint8_t* j3 = encode_job(1002, 100, &p3);
put_entry(fp, 0, 2, WAL_JOB_SUBMIT, j2, (uint32_t)p2, &last, 0);
put_entry(fp, 0, 3, WAL_JOB_SUBMIT, j3, (uint32_t)p3, &last, 0);
free(j2);
free(j3);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(3, st.n_entries);
TEST_ASSERT_EQUAL_UINT(2, st.n_submits);
TEST_ASSERT_EQUAL_UINT(0, st.n_gaps);
TEST_ASSERT_EQUAL_UINT(0, st.n_dup_submits); /* distinct jobids */
TEST_ASSERT_EQUAL_UINT64(1, st.first_lsn);
TEST_ASSERT_EQUAL_UINT64(3, st.last_lsn);
TEST_ASSERT_EQUAL_UINT64(3, st.recovered_tip);
TEST_ASSERT_EQUAL_INT(0, st.crc_broken);
}
/* ------------------------------------------------------------------ */
/* The failover bug signature: log starts at a high LSN (empty-rejoin */
/* caught up from the watermark) so the committed prefix is missing. */
/* ------------------------------------------------------------------ */
static void test_missing_prefix_flagged_as_gap(void)
{
fresh_dir();
char path[512];
/* seg file named from its first LSN, 13 (like ctrl-3 in the live repro) */
FILE* fp = fopen(seg_path(13, path, sizeof(path)), "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 13, WAL_NOOP, NULL, 0, &last, 1);
size_t p14, p15;
uint8_t* j14 = encode_job(2001, 100, &p14);
uint8_t* j15 = encode_job(2002, 100, &p15);
put_entry(fp, 0, 14, WAL_JOB_SUBMIT, j14, (uint32_t)p14, &last, 0);
put_entry(fp, 0, 15, WAL_JOB_SUBMIT, j15, (uint32_t)p15, &last, 0);
free(j14);
free(j15);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(3, st.n_entries);
TEST_ASSERT_EQUAL_UINT64(13, st.first_lsn);
/* prefix [1..12] absent → exactly one gap */
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps);
}
/* ------------------------------------------------------------------ */
/* bug-845: each job double-appended (same jobid at adjacent LSN). The */
/* identical-payload case = a literal byte-for-byte double-write. */
/* ------------------------------------------------------------------ */
static void test_duplicate_submit_detection(void)
{
fresh_dir();
char path[512];
FILE* fp = fopen(seg_path(1, path, sizeof(path)), "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
/* Same jobid + same subtime → byte-identical payload (literal dup). */
size_t pa, pb;
uint8_t* ja = encode_job(7001, 555, &pa);
uint8_t* jb = encode_job(7001, 555, &pb);
put_entry(fp, 0, 2, WAL_JOB_SUBMIT, ja, (uint32_t)pa, &last, 0);
put_entry(fp, 0, 3, WAL_JOB_SUBMIT, jb, (uint32_t)pb, &last, 0);
/* A second job, also doubled but with a DIFFERING subtime per copy. */
size_t pc, pd;
uint8_t* jc = encode_job(7002, 600, &pc);
uint8_t* jd = encode_job(7002, 601, &pd);
put_entry(fp, 0, 4, WAL_JOB_SUBMIT, jc, (uint32_t)pc, &last, 0);
put_entry(fp, 0, 5, WAL_JOB_SUBMIT, jd, (uint32_t)pd, &last, 0);
free(ja); free(jb); free(jc); free(jd);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(4, st.n_submits);
TEST_ASSERT_EQUAL_UINT(2, st.n_dup_submits); /* both pairs flagged */
TEST_ASSERT_EQUAL_UINT(1, st.n_dup_identical); /* only the first is byte-identical */
TEST_ASSERT_EQUAL_UINT(0, st.n_gaps);
}
/* ------------------------------------------------------------------ */
/* An interior hole (a skipped LSN between two present entries). */
/* ------------------------------------------------------------------ */
static void test_interior_gap_flagged(void)
{
fresh_dir();
/* Two segments: [1..2] then [5..6]; 3,4 are missing. */
char p1[512], p2[512];
FILE* f1 = fopen(seg_path(1, p1, sizeof(p1)), "wb");
uint32_t last = WAL_CRC_NONE;
put_entry(f1, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
put_entry(f1, 0, 2, WAL_JOB_UPDATE, "x", 1, &last, 0);
fclose(f1);
FILE* f2 = fopen(seg_path(5, p2, sizeof(p2)), "wb");
uint32_t last2 = WAL_CRC_NONE;
put_entry(f2, 0, 5, WAL_JOB_UPDATE, "y", 1, &last2, 1);
put_entry(f2, 0, 6, WAL_JOB_UPDATE, "z", 1, &last2, 0);
fclose(f2);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(4, st.n_entries);
TEST_ASSERT_EQUAL_UINT(2, st.n_segments);
TEST_ASSERT_EQUAL_UINT64(1, st.first_lsn);
TEST_ASSERT_EQUAL_UINT64(6, st.last_lsn);
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps); /* the 3..4 hole */
}
/* ------------------------------------------------------------------ */
/* A CRC break truncates the recovered tip below the last physical LSN. */
/* ------------------------------------------------------------------ */
static void test_crc_break_sets_recovered_tip(void)
{
fresh_dir();
char path[512];
FILE* fp = fopen(seg_path(1, path, sizeof(path)), "wb");
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
put_entry(fp, 0, 2, WAL_JOB_UPDATE, "ok", 2, &last, 0);
fclose(fp);
/* Corrupt the second entry's stored CRC in place. */
fp = fopen(path, "r+b");
wal_entry_hdr_t h;
long off2 = sizeof(wal_entry_hdr_t) + 0; /* entry 1 has 0 payload */
fseek(fp, off2, SEEK_SET);
fread(&h, sizeof(h), 1, fp);
h.crc ^= 0xdeadbeefu; /* break it */
fseek(fp, off2, SEEK_SET);
fwrite(&h, sizeof(h), 1, fp);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT64(1, st.recovered_tip); /* stops before the bad LSN 2 */
TEST_ASSERT_NOT_EQUAL(0, st.crc_broken);
}
/* ------------------------------------------------------------------ */
/* Missing path is a hard error. */
/* ------------------------------------------------------------------ */
static void test_missing_path_errors(void)
{
waldump_stats_t st;
int rc = waldump_path("/tmp/xce_waldump_does_not_exist_zzz", stdout, &st);
TEST_ASSERT_LESS_THAN_INT(0, rc);
}
/* ------------------------------------------------------------------ */
/* A non-WAL file (e.g. a config blob) must ERROR, not report 0 entries.*/
/* ------------------------------------------------------------------ */
static void test_non_wal_file_errors(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/cluster.bin", g_dir);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
const char junk[] = "not a wal segment — a config blob";
fwrite(junk, sizeof(junk), 1, fp);
fclose(fp);
/* Pointing at a non-seg-*.wal file must be a hard error (-EINVAL), not a
* silent "0 entries" scan. */
waldump_stats_t st;
int rc = waldump_path(path, stdout, &st);
TEST_ASSERT_LESS_THAN_INT(0, rc);
}
void test_waldump(void)
{
RUN_TEST(test_contiguous_log_has_no_gaps);
RUN_TEST(test_missing_prefix_flagged_as_gap);
RUN_TEST(test_duplicate_submit_detection);
RUN_TEST(test_interior_gap_flagged);
RUN_TEST(test_crc_break_sets_recovered_tip);
RUN_TEST(test_missing_path_errors);
RUN_TEST(test_non_wal_file_errors);
}