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.
295 lines
11 KiB
C
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);
|
|
}
|