wal_type_name() never had a case for WAL_CONFIG_MARKER (type 50) -- every config-change marker entry printed as UNKNOWN instead of a real, defined type. Added the case plus payload decode (tag/sha256) matching config_repl.c's config_marker_encode/_decode byte layout, hand-decoded here rather than linking config_repl.c into xce_test's link graph. Gap detection also had no way to tell "committed prefix missing!" (real loss) apart from a normal checkpoint-triggered WAL trim (persist.c's trim_wal_segments, floor-guarded so it never removes a segment a standby still needs) -- every long-running node's WAL, once checkpointed, would false-alarm on every inspection. Cross-checks the sibling data/snap/ directory (snapshot LSN is literally in the filename, snap-<hex>.capnp) and downgrades the message when a snapshot covers the gap. Adds waldump_stats_t.leading_gap_explained and 3 new tests plus one assertion on the existing gap test.
430 lines
16 KiB
C
430 lines
16 KiB
C
#define _GNU_SOURCE
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.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);
|
|
/* g_dir has no sibling data/snap/ at all → nothing can explain it */
|
|
TEST_ASSERT_EQUAL_INT(0, st.leading_gap_explained);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* WAL_CONFIG_MARKER must decode as its own named type, not fall into */
|
|
/* the generic UNKNOWN bucket (a real, defined type — wal_types.h). */
|
|
/* ------------------------------------------------------------------ */
|
|
static void test_config_marker_type_decoded(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);
|
|
|
|
uint8_t marker[WAL_CONFIG_MARKER_PAYLOAD_LEN];
|
|
uint64_t tag = 0x1122334455667788ULL;
|
|
for (int i = 0; i < 8; i++)
|
|
marker[i] = (uint8_t)(tag >> (56 - i * 8));
|
|
memset(marker + 8, 0xab, 32);
|
|
put_entry(fp, 3, 2, WAL_CONFIG_MARKER, marker, sizeof(marker), &last, 0);
|
|
fclose(fp);
|
|
|
|
char* buf = NULL;
|
|
size_t bufsz = 0;
|
|
FILE* mem = open_memstream(&buf, &bufsz);
|
|
TEST_ASSERT_NOT_NULL(mem);
|
|
|
|
waldump_stats_t st;
|
|
int rc = waldump_path(g_dir, mem, &st);
|
|
fclose(mem);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
TEST_ASSERT_NOT_NULL(strstr(buf, "CONFIG_MARKER"));
|
|
TEST_ASSERT_NULL(strstr(buf, "UNKNOWN"));
|
|
TEST_ASSERT_NOT_NULL(strstr(buf, "tag=1122334455667788"));
|
|
free(buf);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* A leading gap covered by a sibling data/snap/ snapshot is normal, */
|
|
/* checkpoint-triggered WAL trim — waldump must say so, not alarm. */
|
|
/* ------------------------------------------------------------------ */
|
|
static void test_leading_gap_explained_by_covering_snapshot(void)
|
|
{
|
|
fresh_dir();
|
|
char wal_dir[300], snap_dir[300];
|
|
snprintf(wal_dir, sizeof(wal_dir), "%s/data/wal", g_dir);
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/data/snap", g_dir);
|
|
char data_dir[300];
|
|
snprintf(data_dir, sizeof(data_dir), "%s/data", g_dir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(data_dir, 0755));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(wal_dir, 0755));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0755));
|
|
|
|
/* Snapshot at LSN 12 covers the trimmed prefix [1..12] below. */
|
|
char snap_file[400];
|
|
snprintf(snap_file, sizeof(snap_file), "%s/snap-%016x.capnp", snap_dir,
|
|
0xc);
|
|
FILE* sf = fopen(snap_file, "wb");
|
|
TEST_ASSERT_NOT_NULL(sf);
|
|
fclose(sf);
|
|
|
|
char segp[400];
|
|
snprintf(segp, sizeof(segp), "%s/seg-%016lx.wal", wal_dir,
|
|
(unsigned long)13);
|
|
FILE* fp = fopen(segp, "wb");
|
|
TEST_ASSERT_NOT_NULL(fp);
|
|
uint32_t last = WAL_CRC_NONE;
|
|
put_entry(fp, 0, 13, WAL_NOOP, NULL, 0, &last, 1);
|
|
fclose(fp);
|
|
|
|
char* buf = NULL;
|
|
size_t bufsz = 0;
|
|
FILE* mem = open_memstream(&buf, &bufsz);
|
|
TEST_ASSERT_NOT_NULL(mem);
|
|
|
|
waldump_stats_t st;
|
|
int rc = waldump_path(g_dir, mem, &st);
|
|
fclose(mem);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps);
|
|
TEST_ASSERT_NOT_EQUAL(0, st.leading_gap_explained);
|
|
TEST_ASSERT_NOT_NULL(strstr(buf, "covered by snapshot"));
|
|
TEST_ASSERT_NULL(strstr(buf, "committed prefix missing!"));
|
|
free(buf);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* A snapshot that does NOT cover the full gap must NOT explain it — */
|
|
/* the alarming wording stays, since the gap could still be real loss. */
|
|
/* ------------------------------------------------------------------ */
|
|
static void test_leading_gap_not_explained_by_insufficient_snapshot(void)
|
|
{
|
|
fresh_dir();
|
|
char wal_dir[300], snap_dir[300], data_dir[300];
|
|
snprintf(data_dir, sizeof(data_dir), "%s/data", g_dir);
|
|
snprintf(wal_dir, sizeof(wal_dir), "%s/data/wal", g_dir);
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/data/snap", g_dir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(data_dir, 0755));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(wal_dir, 0755));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0755));
|
|
|
|
/* Snapshot at LSN 5 does NOT cover the trimmed prefix [1..12] below. */
|
|
char snap_file[400];
|
|
snprintf(snap_file, sizeof(snap_file), "%s/snap-%016x.capnp", snap_dir,
|
|
0x5);
|
|
FILE* sf = fopen(snap_file, "wb");
|
|
TEST_ASSERT_NOT_NULL(sf);
|
|
fclose(sf);
|
|
|
|
char segp[400];
|
|
snprintf(segp, sizeof(segp), "%s/seg-%016lx.wal", wal_dir,
|
|
(unsigned long)13);
|
|
FILE* fp = fopen(segp, "wb");
|
|
TEST_ASSERT_NOT_NULL(fp);
|
|
uint32_t last = WAL_CRC_NONE;
|
|
put_entry(fp, 0, 13, WAL_NOOP, NULL, 0, &last, 1);
|
|
fclose(fp);
|
|
|
|
waldump_stats_t st;
|
|
int rc = waldump_path(g_dir, stdout, &st);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps);
|
|
TEST_ASSERT_EQUAL_INT(0, st.leading_gap_explained);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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)
|
|
{
|
|
UnitySetTestFile(__FILE__);
|
|
RUN_TEST(test_contiguous_log_has_no_gaps);
|
|
RUN_TEST(test_missing_prefix_flagged_as_gap);
|
|
RUN_TEST(test_config_marker_type_decoded);
|
|
RUN_TEST(test_leading_gap_explained_by_covering_snapshot);
|
|
RUN_TEST(test_leading_gap_not_explained_by_insufficient_snapshot);
|
|
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);
|
|
}
|