load_snapshot_entities' queue-frame decode migrates from the old raw
{name_len,name,status} layout to {payload_len, capnp QueueAdminState
bytes}, still calling the unmodified restore_queue_state. Adds a new
host-frame section decode after queues, dispatching to
restore_host_state (worker.c, task 3) with the same bounds-checking
and restore-before-advance discipline as the existing job/queue loops.
Un-ignores test_snapent_load_snapshot_restores_entities_before_advancing_position
(blocked since task 2.2) -- now passes for real. Also fixes a genuine
regression in test_snapshot_recv_out_of_order_chunks_assemble_correctly
(test_config_repl.c): its synthetic checkpoint-body filler landed on
the new host_count field's offset, tripping the cluster==NULL guard --
pinned to a well-formed host_count=0, matching that test's existing
fmt/job_count/queue_count precedent. Task 4 of 19.
Requirements: 1.10
4407 lines
172 KiB
C
4407 lines
172 KiB
C
/* test_config_repl.c — Unity tests for config replication Phase 1 */
|
|
#define _GNU_SOURCE
|
|
#include <unity/unity.h>
|
|
#include <dirent.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <openssl/sha.h>
|
|
#include <openssl/ssl.h>
|
|
#include <openssl/err.h>
|
|
#include <pthread.h>
|
|
#include <stdatomic.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
#include <time.h>
|
|
#include <utime.h>
|
|
#include <unistd.h>
|
|
|
|
#include <capnp_c.h>
|
|
|
|
#include "commit.h"
|
|
#include "config_repl.h"
|
|
#include "controller.capnp.h"
|
|
#include "ha.h"
|
|
#include "myconf.h"
|
|
#include "persist.h"
|
|
#include "repl.h"
|
|
#include "request.h"
|
|
#include "typemap.h"
|
|
#include "wal_types.h"
|
|
#include "xce/cluster_def.h"
|
|
|
|
#ifdef XCE_TESTING
|
|
/* Declared in ha.h — test seam for ha_apply_wal_catchup_entry.
|
|
* ep and c are passed as NULL in unit tests (ACK path is skipped when
|
|
* either is NULL — Task 3.1, ha-wal-replication). */
|
|
int ha_apply_wal_catchup_entry(ha_state_t *ha, persist_t *p,
|
|
wal_catchup_entry_t *entry,
|
|
struct endpoint_t *ep, cluster_t *c);
|
|
#endif
|
|
|
|
/* Per-test directory helpers — not Unity setUp/tearDown (those are in
|
|
* controller_test.c). Each test that needs a temp dir calls cr_setup()
|
|
* at the top and cr_teardown() via the label before returning. */
|
|
|
|
static char g_tmpdir[256];
|
|
/* g_instdir = g_tmpdir/ctrl0 — the effective_dir used by conf_save_config() */
|
|
static char g_instdir[300];
|
|
static char g_conf_dir[356];
|
|
|
|
/* tmpdir string for load_primary_config tests (no conf/current) */
|
|
static char tmpdir[256];
|
|
|
|
static void cr_setup(void)
|
|
{
|
|
snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/test_cr_%d_%ld",
|
|
(int)getpid(), (long)time(NULL));
|
|
mkdir(g_tmpdir, 0700);
|
|
/* conf_save_config() writes to workdir/name/conf — mirror that here */
|
|
snprintf(g_instdir, sizeof(g_instdir), "%s/ctrl0", g_tmpdir);
|
|
mkdir(g_instdir, 0700);
|
|
snprintf(g_conf_dir, sizeof(g_conf_dir), "%s/conf", g_instdir);
|
|
mkdir(g_conf_dir, 0700);
|
|
}
|
|
|
|
static void cr_teardown(void)
|
|
{
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_tmpdir);
|
|
system(cmd);
|
|
g_tmpdir[0] = '\0';
|
|
g_conf_dir[0] = '\0';
|
|
}
|
|
|
|
/* ── conf_read_current_filename ─────────────────── */
|
|
|
|
static void test_conf_read_current_filename_missing(void)
|
|
{
|
|
cr_setup();
|
|
char out[256] = {0};
|
|
int rc = conf_read_current_filename(g_tmpdir, out, sizeof(out));
|
|
TEST_ASSERT_EQUAL_INT(-ENOENT, rc);
|
|
cr_teardown();
|
|
printf("test_conf_read_current_filename_missing: PASS\n");
|
|
}
|
|
|
|
static void test_conf_read_current_filename_empty_file(void)
|
|
{
|
|
cr_setup();
|
|
char ptr_path[400];
|
|
snprintf(ptr_path, sizeof(ptr_path), "%s/current", g_conf_dir);
|
|
int fd = open(ptr_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
|
TEST_ASSERT_NOT_EQUAL(-1, fd);
|
|
close(fd);
|
|
|
|
char out[256] = {0};
|
|
/* conf/current lives under g_instdir/conf — pass g_instdir as base */
|
|
int rc = conf_read_current_filename(g_instdir, out, sizeof(out));
|
|
TEST_ASSERT_EQUAL_INT(-ENODATA, rc);
|
|
cr_teardown();
|
|
printf("test_conf_read_current_filename_empty_file: PASS\n");
|
|
}
|
|
|
|
static void test_conf_read_current_filename_path_separator(void)
|
|
{
|
|
cr_setup();
|
|
char ptr_path[400];
|
|
snprintf(ptr_path, sizeof(ptr_path), "%s/current", g_conf_dir);
|
|
FILE *f = fopen(ptr_path, "w");
|
|
fprintf(f, "cluster.bin/../evil\n");
|
|
fclose(f);
|
|
|
|
char out[256] = {0};
|
|
/* conf/current lives under g_instdir/conf — pass g_instdir as base */
|
|
int rc = conf_read_current_filename(g_instdir, out, sizeof(out));
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
cr_teardown();
|
|
printf("test_conf_read_current_filename_path_separator: PASS\n");
|
|
}
|
|
|
|
static void test_conf_read_current_filename_bad_prefix(void)
|
|
{
|
|
cr_setup();
|
|
char ptr_path[400];
|
|
snprintf(ptr_path, sizeof(ptr_path), "%s/current", g_conf_dir);
|
|
FILE *f = fopen(ptr_path, "w");
|
|
fprintf(f, "badprefix.aabbccddeeff\n");
|
|
fclose(f);
|
|
|
|
char out[256] = {0};
|
|
/* conf/current lives under g_instdir/conf — pass g_instdir as base */
|
|
int rc = conf_read_current_filename(g_instdir, out, sizeof(out));
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
cr_teardown();
|
|
printf("test_conf_read_current_filename_bad_prefix: PASS\n");
|
|
}
|
|
|
|
static void test_conf_read_current_filename_wrong_hex_length(void)
|
|
{
|
|
cr_setup();
|
|
char ptr_path[400];
|
|
snprintf(ptr_path, sizeof(ptr_path), "%s/current", g_conf_dir);
|
|
FILE *f = fopen(ptr_path, "w");
|
|
fprintf(f, "cluster.bin.aabb\n"); /* only 4 hex chars, need 12 */
|
|
fclose(f);
|
|
|
|
char out[256] = {0};
|
|
/* conf/current lives under g_instdir/conf — pass g_instdir as base */
|
|
int rc = conf_read_current_filename(g_instdir, out, sizeof(out));
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
cr_teardown();
|
|
printf("test_conf_read_current_filename_wrong_hex_length: PASS\n");
|
|
}
|
|
|
|
/* ── conf_save_config + conf_read_current_filename round-trip ─ */
|
|
|
|
static void test_conf_save_creates_pointer_file(void)
|
|
{
|
|
cr_setup();
|
|
const uint8_t data[] = "fake capnproto config blob";
|
|
size_t len = sizeof(data) - 1;
|
|
uint8_t sha256[32];
|
|
SHA256(data, len, sha256);
|
|
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.workdir = g_tmpdir;
|
|
myconf.name = "ctrl0";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conffile = NULL;
|
|
|
|
int rc = conf_save_config(&myconf, data, len, sha256);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* conf/current must exist */
|
|
char ptr_path[400];
|
|
snprintf(ptr_path, sizeof(ptr_path), "%s/current", g_conf_dir);
|
|
TEST_ASSERT_EQUAL_INT(0, access(ptr_path, F_OK));
|
|
|
|
free(myconf.conffile);
|
|
cr_teardown();
|
|
printf("test_conf_save_creates_pointer_file: PASS\n");
|
|
}
|
|
|
|
static void test_conf_save_filename_has_12_hex_suffix(void)
|
|
{
|
|
cr_setup();
|
|
const uint8_t data[] = "another fake blob";
|
|
size_t len = sizeof(data) - 1;
|
|
uint8_t sha256[32];
|
|
SHA256(data, len, sha256);
|
|
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.workdir = g_tmpdir;
|
|
myconf.name = "ctrl0";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conffile = NULL;
|
|
|
|
conf_save_config(&myconf, data, len, sha256);
|
|
|
|
char fname[256] = {0};
|
|
/* conf_save_config() writes to workdir/name/conf — read from g_instdir */
|
|
int rc = conf_read_current_filename(g_instdir, fname, sizeof(fname));
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
TEST_ASSERT_EQUAL_INT(0, strncmp(fname, "cluster.bin.", 12));
|
|
const char *hex = fname + 12;
|
|
TEST_ASSERT_EQUAL_INT(12, (int)strlen(hex));
|
|
TEST_ASSERT_EQUAL_INT(12, (int)strspn(hex, "0123456789abcdef"));
|
|
|
|
free(myconf.conffile);
|
|
cr_teardown();
|
|
printf("test_conf_save_filename_has_12_hex_suffix: PASS\n");
|
|
}
|
|
|
|
/* ── conf_trim_old_versions ─────────────────────── */
|
|
|
|
static void test_conf_trim_keeps_only_n_versions(void)
|
|
{
|
|
cr_setup();
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.workdir = g_tmpdir;
|
|
myconf.name = "ctrl0";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conffile = NULL;
|
|
|
|
uint8_t sha256[32];
|
|
for (int i = 0; i < 3; i++) {
|
|
char data[32];
|
|
snprintf(data, sizeof(data), "config version %d", i);
|
|
SHA256((uint8_t *)data, strlen(data), sha256);
|
|
usleep(15000); /* ensure distinct mtime ordering */
|
|
conf_save_config(&myconf, (uint8_t *)data, strlen(data), sha256);
|
|
free(myconf.conffile);
|
|
myconf.conffile = NULL;
|
|
}
|
|
|
|
/* Count cluster.bin.* files (exclude .tmp) */
|
|
int count = 0;
|
|
DIR *d = opendir(g_conf_dir);
|
|
TEST_ASSERT_NOT_NULL(d);
|
|
struct dirent *e;
|
|
while ((e = readdir(d)) != NULL) {
|
|
if (strncmp(e->d_name, "cluster.bin.", 12) == 0 &&
|
|
strstr(e->d_name, ".tmp") == NULL)
|
|
count++;
|
|
}
|
|
closedir(d);
|
|
TEST_ASSERT_EQUAL_INT(2, count);
|
|
|
|
free(myconf.conffile);
|
|
cr_teardown();
|
|
printf("test_conf_trim_keeps_only_n_versions: PASS\n");
|
|
}
|
|
|
|
/* ── conf_get_saved_tag ─────────────────────────── */
|
|
|
|
static void test_conf_get_saved_tag_no_config(void)
|
|
{
|
|
cr_setup();
|
|
/* conf_get_saved_tag looks for conf/current under its argument;
|
|
* after the path fix, conf lives under workdir/name = g_instdir */
|
|
uint64_t tag = conf_get_saved_tag(g_instdir);
|
|
TEST_ASSERT_EQUAL_UINT64(0, tag);
|
|
cr_teardown();
|
|
printf("test_conf_get_saved_tag_no_config: PASS\n");
|
|
}
|
|
|
|
static void test_conf_get_saved_tag_after_save(void)
|
|
{
|
|
cr_setup();
|
|
const uint8_t data[] = "tag test blob";
|
|
size_t len = sizeof(data) - 1;
|
|
uint8_t sha256[32];
|
|
SHA256(data, len, sha256);
|
|
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.workdir = g_tmpdir;
|
|
myconf.name = "ctrl0";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conffile = NULL;
|
|
|
|
conf_save_config(&myconf, data, len, sha256);
|
|
free(myconf.conffile);
|
|
|
|
uint64_t tag = conf_get_saved_tag(g_instdir);
|
|
TEST_ASSERT_NOT_EQUAL(0, tag);
|
|
|
|
/* Tag must equal first 8 bytes of sha256 in host order */
|
|
uint64_t expected;
|
|
memcpy(&expected, sha256, 8);
|
|
/* be64toh conversion */
|
|
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
|
|
expected = __builtin_bswap64(expected);
|
|
#endif
|
|
TEST_ASSERT_EQUAL_UINT64(expected, tag);
|
|
|
|
cr_teardown();
|
|
printf("test_conf_get_saved_tag_after_save: PASS\n");
|
|
}
|
|
|
|
/* ── conf_check_stale ───────────────────────────── */
|
|
|
|
static void test_conf_check_stale_disabled(void)
|
|
{
|
|
cr_setup();
|
|
/* timeout 0 = disabled, never stale */
|
|
TEST_ASSERT_EQUAL_INT(0, conf_check_stale(g_instdir, 0));
|
|
cr_teardown();
|
|
printf("test_conf_check_stale_disabled: PASS\n");
|
|
}
|
|
|
|
static void test_conf_check_stale_fresh(void)
|
|
{
|
|
cr_setup();
|
|
const uint8_t data[] = "stale test blob";
|
|
uint8_t sha256[32];
|
|
SHA256(data, sizeof(data) - 1, sha256);
|
|
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.workdir = g_tmpdir;
|
|
myconf.name = "ctrl0";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conffile = NULL;
|
|
conf_save_config(&myconf, data, sizeof(data) - 1, sha256);
|
|
free(myconf.conffile);
|
|
|
|
/* Just saved — not stale with 24h timeout */
|
|
TEST_ASSERT_EQUAL_INT(0, conf_check_stale(g_instdir, 86400));
|
|
cr_teardown();
|
|
printf("test_conf_check_stale_fresh: PASS\n");
|
|
}
|
|
|
|
static void test_conf_check_stale_aged(void)
|
|
{
|
|
cr_setup();
|
|
const uint8_t data[] = "old config blob";
|
|
uint8_t sha256[32];
|
|
SHA256(data, sizeof(data) - 1, sha256);
|
|
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.workdir = g_tmpdir;
|
|
myconf.name = "ctrl0";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conffile = NULL;
|
|
conf_save_config(&myconf, data, sizeof(data) - 1, sha256);
|
|
free(myconf.conffile);
|
|
|
|
/* Age the versioned file by 20 seconds via utime */
|
|
char fname[256] = {0};
|
|
/* conf_save_config() writes to workdir/name/conf — read from g_instdir */
|
|
conf_read_current_filename(g_instdir, fname, sizeof(fname));
|
|
char ver_path[512];
|
|
snprintf(ver_path, sizeof(ver_path), "%s/%s", g_conf_dir, fname);
|
|
|
|
struct utimbuf old_time;
|
|
old_time.actime = time(NULL) - 20;
|
|
old_time.modtime = time(NULL) - 20;
|
|
utime(ver_path, &old_time);
|
|
|
|
/* 5-second threshold → stale */
|
|
TEST_ASSERT_EQUAL_INT(1, conf_check_stale(g_instdir, 5));
|
|
/* 30-second threshold → not stale */
|
|
TEST_ASSERT_EQUAL_INT(0, conf_check_stale(g_instdir, 30));
|
|
|
|
cr_teardown();
|
|
printf("test_conf_check_stale_aged: PASS\n");
|
|
}
|
|
|
|
/* ── config_contains_reserved_name ─────────────── */
|
|
|
|
static void test_config_contains_reserved_name_absent(void)
|
|
{
|
|
/* NULL config — no reserved name */
|
|
TEST_ASSERT_EQUAL_INT(0, config_contains_reserved_name(NULL));
|
|
printf("test_config_contains_reserved_name_absent: PASS\n");
|
|
}
|
|
|
|
/* ── sanitise_cn ────────────────────────────────── */
|
|
|
|
static void test_sanitise_cn_printable(void)
|
|
{
|
|
char *out = sanitise_cn("ctrl01.example.com", 64);
|
|
TEST_ASSERT_EQUAL_STRING("ctrl01.example.com", out);
|
|
printf("test_sanitise_cn_printable: PASS\n");
|
|
}
|
|
|
|
static void test_sanitise_cn_control_chars(void)
|
|
{
|
|
char *out = sanitise_cn("evil\ninjection", 64);
|
|
/* newline replaced with '?' */
|
|
TEST_ASSERT_NOT_NULL(strchr(out, '?'));
|
|
TEST_ASSERT_NULL(strchr(out, '\n'));
|
|
printf("test_sanitise_cn_control_chars: PASS\n");
|
|
}
|
|
|
|
static void test_sanitise_cn_truncated(void)
|
|
{
|
|
char *out = sanitise_cn("abcdefghij", 5);
|
|
TEST_ASSERT_EQUAL_INT(5, (int)strlen(out));
|
|
printf("test_sanitise_cn_truncated: PASS\n");
|
|
}
|
|
|
|
/* ── load_primary_config (via test-only helper) ── */
|
|
|
|
#ifdef XCE_TESTING
|
|
static void test_load_primary_config_missing_returns_error(void)
|
|
{
|
|
/* Create a minimal cluster_t with no conf/current file */
|
|
cluster_t c;
|
|
memset(&c, 0, sizeof(c));
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
|
|
/* Use a fresh tmpdir with no conf/current */
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/test_lpc_%d_%ld",
|
|
(int)getpid(), (long)time(NULL));
|
|
mkdir(tmpdir, 0700);
|
|
|
|
myconf.workdir = tmpdir;
|
|
myconf.name = "ctrl0";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conf_stale_timeout = 86400;
|
|
myconf.conffile = NULL;
|
|
c.myconf = &myconf;
|
|
|
|
/* No conf/current exists — must fail (not FATAL in test context) */
|
|
int rc = load_primary_config_for_test(&c);
|
|
TEST_ASSERT_TRUE(rc < 0);
|
|
/* conffile must still be NULL (no heap allocation on failure) */
|
|
TEST_ASSERT_NULL(myconf.conffile);
|
|
|
|
/* Cleanup */
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
|
|
printf("test_load_primary_config_missing_returns_error: PASS\n");
|
|
}
|
|
#endif
|
|
|
|
/* ── test_conf_save_config_path_consistency ─────── */
|
|
|
|
static void test_conf_save_config_path_consistency(void)
|
|
{
|
|
/* Verify conf_save_config() writes to workdir/name/conf, not workdir/conf.
|
|
* This mirrors the path that load_primary_config() reads from. */
|
|
char workdir[256];
|
|
snprintf(workdir, sizeof(workdir), "/tmp/test_pathbug_%d", (int)getpid());
|
|
char instdir[300];
|
|
snprintf(instdir, sizeof(instdir), "%s/ctrl01", workdir);
|
|
char conf_dir[356];
|
|
snprintf(conf_dir, sizeof(conf_dir), "%s/conf", instdir);
|
|
|
|
mkdir(workdir, 0700);
|
|
/* conf_save_config() builds effective_dir = workdir/name internally,
|
|
* then mkdirs effective_dir/conf — but effective_dir itself must exist. */
|
|
mkdir(instdir, 0700);
|
|
|
|
/* Fake 32-byte sha256 and dummy payload */
|
|
uint8_t sha256[32];
|
|
memset(sha256, 0xab, sizeof(sha256));
|
|
const uint8_t payload[] = "dummy config bytes for path test";
|
|
size_t plen = sizeof(payload) - 1;
|
|
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.workdir = workdir;
|
|
myconf.name = "ctrl01";
|
|
myconf.conf_keep_n = 2;
|
|
myconf.conffile = NULL;
|
|
|
|
int rc = conf_save_config(&myconf, payload, plen, sha256);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* Versioned file must be under workdir/name/conf/, not workdir/conf/ */
|
|
TEST_ASSERT_NOT_NULL(myconf.conffile);
|
|
TEST_ASSERT_EQUAL_INT(0, strncmp(myconf.conffile, conf_dir,
|
|
strlen(conf_dir)));
|
|
|
|
/* conf/current pointer file must exist at workdir/name/conf/current */
|
|
char ptr_path[400];
|
|
snprintf(ptr_path, sizeof(ptr_path), "%s/current", conf_dir);
|
|
TEST_ASSERT_EQUAL_INT(0, access(ptr_path, F_OK));
|
|
|
|
/* workdir/conf/ must NOT exist (wrong location) */
|
|
char wrong_conf[300];
|
|
snprintf(wrong_conf, sizeof(wrong_conf), "%s/conf", workdir);
|
|
struct stat st;
|
|
TEST_ASSERT_NOT_EQUAL(0, stat(wrong_conf, &st));
|
|
|
|
free(myconf.conffile);
|
|
|
|
/* Cleanup */
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", workdir);
|
|
system(cmd);
|
|
|
|
printf("test_conf_save_config_path_consistency: PASS\n");
|
|
}
|
|
|
|
/* ── WAL_CONFIG_MARKER codec (ha-term-safety task 4.1) ───────────────
|
|
* The config-change marker payload carries ONLY {version_tag, sha256} —
|
|
* a reference, never the configuration bytes (R7.2/R7.4). These tests
|
|
* exercise the fixed-size encode/decode codec that rides inside the
|
|
* raft_entry_blob_t envelope with type == WAL_CONFIG_MARKER. */
|
|
|
|
/* Round-trip: encode {version_tag, sha256} then decode → identical, and the
|
|
* encoded form is exactly the fixed marker size (tag + 32-byte hash) with NO
|
|
* configuration bytes appended. */
|
|
static void test_config_marker_encode_decode_roundtrip(void)
|
|
{
|
|
const uint64_t version_tag = 0x0123456789abcdefULL;
|
|
uint8_t sha256[32];
|
|
for (int i = 0; i < 32; i++)
|
|
sha256[i] = (uint8_t)(0xA0 + i);
|
|
|
|
uint8_t buf[64];
|
|
size_t enc_len = 0;
|
|
int rc = config_marker_encode(version_tag, sha256, buf, sizeof(buf), &enc_len);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* Encoded form carries NO config bytes: exactly 8 (tag) + 32 (sha256). */
|
|
TEST_ASSERT_EQUAL_UINT(WAL_CONFIG_MARKER_PAYLOAD_LEN, enc_len);
|
|
TEST_ASSERT_EQUAL_UINT(8u + 32u, enc_len);
|
|
|
|
uint64_t got_tag = 0;
|
|
uint8_t got_sha[32];
|
|
memset(got_sha, 0, sizeof(got_sha));
|
|
rc = config_marker_decode(buf, enc_len, &got_tag, got_sha);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
TEST_ASSERT_EQUAL_HEX64(version_tag, got_tag);
|
|
TEST_ASSERT_EQUAL_HEX8_ARRAY(sha256, got_sha, 32);
|
|
|
|
printf("test_config_marker_encode_decode_roundtrip: PASS\n");
|
|
}
|
|
|
|
/* The WAL entry type value must be exactly 50 and must not collide with the
|
|
* neighbouring WAL_NOOP (40) or WAL_CHECKPOINT (100) types (design.md). */
|
|
static void test_config_marker_enum_value_is_50(void)
|
|
{
|
|
TEST_ASSERT_EQUAL_INT(50, WAL_CONFIG_MARKER);
|
|
TEST_ASSERT_NOT_EQUAL(WAL_NOOP, WAL_CONFIG_MARKER);
|
|
TEST_ASSERT_NOT_EQUAL(WAL_CHECKPOINT, WAL_CONFIG_MARKER);
|
|
/* Strictly between WAL_NOOP and WAL_CHECKPOINT. */
|
|
TEST_ASSERT_TRUE(WAL_CONFIG_MARKER > WAL_NOOP);
|
|
TEST_ASSERT_TRUE(WAL_CONFIG_MARKER < WAL_CHECKPOINT);
|
|
printf("test_config_marker_enum_value_is_50: PASS\n");
|
|
}
|
|
|
|
/* A truncated (short) buffer must fail cleanly with -EINVAL, never read past
|
|
* the end of the supplied buffer. */
|
|
static void test_config_marker_decode_truncated_fails(void)
|
|
{
|
|
uint8_t sha256[32];
|
|
memset(sha256, 0x5c, sizeof(sha256));
|
|
uint8_t buf[WAL_CONFIG_MARKER_PAYLOAD_LEN];
|
|
size_t enc_len = 0;
|
|
TEST_ASSERT_EQUAL_INT(0, config_marker_encode(0xdeadbeefcafef00dULL, sha256,
|
|
buf, sizeof(buf), &enc_len));
|
|
|
|
uint64_t tag = 0;
|
|
uint8_t sha_out[32];
|
|
/* One byte short of a full marker payload → reject, no OOB read. */
|
|
int rc = config_marker_decode(buf, enc_len - 1, &tag, sha_out);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
|
|
/* Zero-length buffer → reject. */
|
|
rc = config_marker_decode(buf, 0, &tag, sha_out);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
|
|
printf("test_config_marker_decode_truncated_fails: PASS\n");
|
|
}
|
|
|
|
/* A too-small output buffer must fail with -ENOSPC and not overflow. */
|
|
static void test_config_marker_encode_short_buffer_fails(void)
|
|
{
|
|
uint8_t sha256[32];
|
|
memset(sha256, 0x11, sizeof(sha256));
|
|
uint8_t small[WAL_CONFIG_MARKER_PAYLOAD_LEN - 1];
|
|
size_t enc_len = 123; /* sentinel — must be left untouched on failure */
|
|
int rc = config_marker_encode(7, sha256, small, sizeof(small), &enc_len);
|
|
TEST_ASSERT_EQUAL_INT(-ENOSPC, rc);
|
|
printf("test_config_marker_encode_short_buffer_fails: PASS\n");
|
|
}
|
|
|
|
/* ── Config marker append + committed-config derive (task 4.2) ────────
|
|
*
|
|
* These bring a node up as the raft LEADER so config_append_marker routes the
|
|
* WAL_CONFIG_MARKER through ha_primary_log_append → raft_log_append (the same
|
|
* unified-log path job state uses), and assert the committed config is DERIVED
|
|
* from the latest committed marker within the committed prefix (R7.1/7.3/7.6/
|
|
* 7.7/7.11). Mirrors make_cluster/make_leader from test_term_safety_adapter.c. */
|
|
|
|
static cluster_t *t42_make_cluster(const char *tmpdir)
|
|
{
|
|
cluster_t *c = calloc(1, sizeof(cluster_t));
|
|
if (!c) return NULL;
|
|
|
|
persist_config_t cfg = persist_config_defaults();
|
|
cfg.wal_sync_ms = 5000;
|
|
cfg.snap_keep_n = 2;
|
|
|
|
persist_set_workdir(&c->persist, tmpdir, "ctrl-test");
|
|
if (persist_init(&c->persist, &cfg, c) != 0) { free(c); return NULL; }
|
|
if (persist_start(&c->persist) != 0) {
|
|
persist_fini(&c->persist);
|
|
free(c);
|
|
return NULL;
|
|
}
|
|
c->ha.raft_c = c;
|
|
return c;
|
|
}
|
|
|
|
static void t42_teardown(cluster_t *c)
|
|
{
|
|
if (!c) return;
|
|
persist_stop(&c->persist);
|
|
persist_fini(&c->persist);
|
|
free(c);
|
|
}
|
|
|
|
/* Bring the node up as the raft LEADER at `term` (so raft_log_append is enabled
|
|
* — it returns -EPERM off the leader; see make_leader in the adapter suite). */
|
|
static void t42_make_leader(cluster_t *c, uint64_t term, int n_peers)
|
|
{
|
|
atomic_store(&c->ha.role, CTRL_PRIMARY);
|
|
atomic_store(&c->ha.term, term);
|
|
ha_init_raft(&c->ha, c, n_peers);
|
|
c->ha.raft.term = term;
|
|
c->ha.raft.role = RAFT_LEADER;
|
|
}
|
|
|
|
/* Fill an sha256 with a deterministic pattern; tag = be64 of first 8 bytes,
|
|
* matching the config_repl version_tag convention (config_version_tag). */
|
|
static uint64_t t42_make_ref(uint8_t sha256[32], uint8_t seed)
|
|
{
|
|
for (int i = 0; i < 32; i++)
|
|
sha256[i] = (uint8_t)(seed + i);
|
|
uint64_t tag;
|
|
memcpy(&tag, sha256, 8);
|
|
return be64toh(tag);
|
|
}
|
|
|
|
/* (a) Appending a config change writes a WAL_CONFIG_MARKER through the unified
|
|
* log: the entry lands at an LSN with the live election term stamped, and its
|
|
* payload decodes to {version_tag, sha256} (R7.1/7.3). */
|
|
static void test_config_marker_append_via_unified_log(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr42_append_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
const uint64_t T = 7;
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, T, /*n_peers*/1);
|
|
|
|
uint8_t sha[32];
|
|
uint64_t tag = t42_make_ref(sha, 0x10);
|
|
|
|
uint64_t lsn = 0;
|
|
TEST_ASSERT_EQUAL_INT(0, config_append_marker(c, tag, sha, &lsn));
|
|
|
|
/* Library assigned LSN 1 and stamped the live term for that entry. */
|
|
TEST_ASSERT_EQUAL_UINT64(1, lsn);
|
|
TEST_ASSERT_EQUAL_UINT64(1, c->ha.raft.log_len);
|
|
TEST_ASSERT_EQUAL_UINT64(T, persist_entry_term(&c->persist, 1));
|
|
|
|
t42_teardown(c);
|
|
printf("test_config_marker_append_via_unified_log: PASS\n");
|
|
}
|
|
|
|
/* (b) After the marker COMMITS (committed_lsn covers its LSN), the derived
|
|
* committed-config reference == that marker's {version_tag, sha256} (R7.7). */
|
|
static void test_committed_marker_derives_after_commit(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr42_derive_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/4, /*n_peers*/1);
|
|
|
|
/* A job entry then a config marker, so the marker is NOT at LSN 1. */
|
|
wal_jobstate_t js = { .jobid = 1, .state = 1 };
|
|
uint64_t jlsn = 0;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
ha_primary_log_append(c, WAL_JOB_UPDATE, &js, sizeof(js), &jlsn));
|
|
|
|
uint8_t sha[32];
|
|
uint64_t tag = t42_make_ref(sha, 0x40);
|
|
uint64_t mlsn = 0;
|
|
TEST_ASSERT_EQUAL_INT(0, config_append_marker(c, tag, sha, &mlsn));
|
|
TEST_ASSERT_EQUAL_UINT64(2, mlsn);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
|
|
/* Drive committed_lsn to the marker's LSN (the commit advance source). */
|
|
atomic_store_explicit(&c->persist.committed_lsn, mlsn, memory_order_release);
|
|
|
|
uint64_t got_tag = 0;
|
|
uint8_t got_sha[32];
|
|
memset(got_sha, 0, sizeof(got_sha));
|
|
TEST_ASSERT_EQUAL_INT(0, config_committed_marker(c, &got_tag, got_sha));
|
|
TEST_ASSERT_EQUAL_HEX64(tag, got_tag);
|
|
TEST_ASSERT_EQUAL_HEX8_ARRAY(sha, got_sha, 32);
|
|
|
|
t42_teardown(c);
|
|
printf("test_committed_marker_derives_after_commit: PASS\n");
|
|
}
|
|
|
|
/* (c) An UNCOMMITTED marker (LSN > committed_lsn) does NOT change the derived
|
|
* committed config: with committed_lsn at the first marker, a later second
|
|
* marker is invisible until committed (R7.7/7.11). */
|
|
static void test_uncommitted_marker_not_yet_committed_config(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr42_uncommit_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/5, /*n_peers*/1);
|
|
|
|
uint8_t sha1[32], sha2[32];
|
|
uint64_t tag1 = t42_make_ref(sha1, 0x10);
|
|
uint64_t tag2 = t42_make_ref(sha2, 0x80);
|
|
|
|
uint64_t l1 = 0, l2 = 0;
|
|
TEST_ASSERT_EQUAL_INT(0, config_append_marker(c, tag1, sha1, &l1));
|
|
TEST_ASSERT_EQUAL_INT(0, config_append_marker(c, tag2, sha2, &l2));
|
|
TEST_ASSERT_EQUAL_UINT64(1, l1);
|
|
TEST_ASSERT_EQUAL_UINT64(2, l2);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
|
|
/* Only the FIRST marker is committed. The second is uncommitted. */
|
|
atomic_store_explicit(&c->persist.committed_lsn, l1, memory_order_release);
|
|
|
|
uint64_t got_tag = 0;
|
|
uint8_t got_sha[32];
|
|
TEST_ASSERT_EQUAL_INT(0, config_committed_marker(c, &got_tag, got_sha));
|
|
/* Derived config is the FIRST (committed) marker, NOT the uncommitted one. */
|
|
TEST_ASSERT_EQUAL_HEX64(tag1, got_tag);
|
|
TEST_ASSERT_EQUAL_HEX8_ARRAY(sha1, got_sha, 32);
|
|
|
|
/* With NOTHING committed, there is no committed config marker at all. */
|
|
atomic_store_explicit(&c->persist.committed_lsn, 0ULL, memory_order_release);
|
|
TEST_ASSERT_EQUAL_INT(-ENOENT, config_committed_marker(c, &got_tag, got_sha));
|
|
|
|
t42_teardown(c);
|
|
printf("test_uncommitted_marker_not_yet_committed_config: PASS\n");
|
|
}
|
|
|
|
/* (d) A divergent config marker at a conflicting (term,lsn) is truncated by the
|
|
* reconcile path exactly like a job entry: on_truncate_from(index) removes the
|
|
* marker (and everything above), leaving the committed prefix intact. This
|
|
* proves the marker is an ORDINARY log entry under (term,lsn) matching (R7.6).
|
|
*
|
|
* We append a committed job at LSN 1, then a divergent marker at LSN 2, drive
|
|
* committed_lsn to 1, then truncate from LSN 2 (the conflict point) and confirm
|
|
* the marker is gone while the committed job prefix survives. */
|
|
static void test_divergent_marker_truncated_like_job_entry(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr42_trunc_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/6, /*n_peers*/1);
|
|
|
|
/* LSN 1: committed job entry. LSN 2: a (divergent) config marker. */
|
|
wal_jobstate_t js = { .jobid = 9, .state = 2 };
|
|
uint64_t jlsn = 0;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
ha_primary_log_append(c, WAL_JOB_UPDATE, &js, sizeof(js), &jlsn));
|
|
TEST_ASSERT_EQUAL_UINT64(1, jlsn);
|
|
|
|
uint8_t sha[32];
|
|
uint64_t tag = t42_make_ref(sha, 0x55);
|
|
uint64_t mlsn = 0;
|
|
TEST_ASSERT_EQUAL_INT(0, config_append_marker(c, tag, sha, &mlsn));
|
|
TEST_ASSERT_EQUAL_UINT64(2, mlsn);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
|
|
/* The committed prefix is LSN 1 (the job). The marker at LSN 2 is the
|
|
* divergent suffix. The derived committed config is therefore NONE yet. */
|
|
atomic_store_explicit(&c->persist.committed_lsn, 1ULL, memory_order_release);
|
|
uint64_t got_tag = 0;
|
|
uint8_t got_sha[32];
|
|
TEST_ASSERT_EQUAL_INT(-ENOENT, config_committed_marker(c, &got_tag, got_sha));
|
|
|
|
/* Reconcile truncates from the conflict point (LSN 2) exactly as it would a
|
|
* job entry — the SAME on_truncate_from seam used by (term,lsn) matching. */
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
ha_cb_truncate_from_for_test(&c->ha, /*index*/2));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
|
|
/* The marker is gone (term index shrank); the committed job prefix is intact. */
|
|
TEST_ASSERT_EQUAL_UINT64(0, persist_entry_term(&c->persist, 2));
|
|
TEST_ASSERT_EQUAL_UINT64(6, persist_entry_term(&c->persist, 1));
|
|
/* Still no committed config marker — the divergent one never committed. */
|
|
TEST_ASSERT_EQUAL_INT(-ENOENT, config_committed_marker(c, &got_tag, got_sha));
|
|
|
|
t42_teardown(c);
|
|
printf("test_divergent_marker_truncated_like_job_entry: PASS\n");
|
|
}
|
|
|
|
/* (e) INTEGRATION (Finding 1 remediation): drive a config change through the
|
|
* LIVE commit path (config_commit_change — the helper do_upgrade_config_with_bytes
|
|
* now calls) and assert (i) a WAL_CONFIG_MARKER lands at the LIVE term, and
|
|
* (ii) once the marker COMMITS through the real WAL commit barrier, the DERIVED
|
|
* committed config (config_committed_marker) equals it. This proves the live
|
|
* config-change path appends + commits + derives the marker — not the retired
|
|
* term-blind committed_config_tag push/ack/advance.
|
|
*
|
|
* A single-member commit engine is wired so config_commit_change's WAL barrier
|
|
* (repl_wait_majority_ack/OPCode_walAck → commit_submit) self-quorums and
|
|
* advances committed_lsn for real, exactly as the production single-node path. */
|
|
static void test_config_commit_change_live_path_commits_and_derives(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr42_live_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
const uint64_t T = 9;
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, T, /*n_peers*/1);
|
|
|
|
/* Single-member commit engine: the marker's WAL LSN self-quorums, so
|
|
* driving commit_submit on it advances committed_lsn through the REAL commit
|
|
* machinery — the same path that commits the marker in production once a
|
|
* majority acks the WAL stream (no manual committed_lsn poke). */
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, /*n_peers*/1, /*n_vcpus*/8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
|
|
uint8_t sha[32];
|
|
uint64_t tag = t42_make_ref(sha, 0x30);
|
|
|
|
/* The LIVE entry point do_upgrade_config_with_bytes now calls: append the
|
|
* WAL_CONFIG_MARKER consensus record + out-of-band byte push + the existing
|
|
* majority barrier. With no standbys (single member) the byte barrier
|
|
* self-quorums and the call returns 0. */
|
|
int acked = -1;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
config_commit_change(c, (const uint8_t *)"cfg-bytes", 9, sha, tag, &acked));
|
|
|
|
/* (i) The marker landed at LSN 1 with the LIVE election term stamped. */
|
|
TEST_ASSERT_EQUAL_UINT64(1, c->ha.raft.log_len);
|
|
TEST_ASSERT_EQUAL_UINT64(T, persist_entry_term(&c->persist, 1));
|
|
/* No standbys registered → zero out-of-band pushes (barrier owns the verdict). */
|
|
TEST_ASSERT_EQUAL_INT(0, acked);
|
|
|
|
/* Make the marker durable (advances durable_lsn) then commit its WAL LSN
|
|
* through the real commit engine (self-quorum), exactly as a majority WAL
|
|
* ack does in production — this advances committed_lsn so the derive sees
|
|
* the marker in the committed prefix. */
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_submit(&c->commit, /*marker LSN*/1, COMMIT_MODE_WAIT, 1000));
|
|
TEST_ASSERT_TRUE(
|
|
atomic_load_explicit(&c->persist.committed_lsn, memory_order_acquire) >= 1);
|
|
|
|
/* (ii) The DERIVED committed config equals the change just committed. */
|
|
uint64_t got_tag = 0;
|
|
uint8_t got_sha[32];
|
|
TEST_ASSERT_EQUAL_INT(0, config_committed_marker(c, &got_tag, got_sha));
|
|
TEST_ASSERT_EQUAL_HEX64(tag, got_tag);
|
|
TEST_ASSERT_EQUAL_HEX8_ARRAY(sha, got_sha, 32);
|
|
|
|
/* committed_config_tag is now a CACHE mirroring the derived marker. */
|
|
TEST_ASSERT_EQUAL_HEX64(tag,
|
|
atomic_load_explicit(&c->ha.committed_config_tag, memory_order_acquire));
|
|
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_config_commit_change_live_path_commits_and_derives: PASS\n");
|
|
}
|
|
|
|
/* (f) COMPACTION SAFETY (Finding 2 remediation): a checkpoint that TRIMS the WAL
|
|
* segment holding a committed marker must NOT lose the derived committed config.
|
|
* Without the sentinel-carried reference + fallback, the live-WAL scan regresses
|
|
* to -ENOENT after the trim. We commit a marker, force a NEW segment + checkpoint
|
|
* (which trims the old segment containing the marker), and assert the derive
|
|
* STILL returns the committed reference (R7.7/R7.10). */
|
|
static void test_committed_marker_survives_checkpoint_trim(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr42_trim_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
const uint64_t T = 11;
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, T, /*n_peers*/1);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, /*n_peers*/1, /*n_vcpus*/8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
|
|
uint8_t sha[32];
|
|
uint64_t tag = t42_make_ref(sha, 0x70);
|
|
|
|
/* Append the marker (segment 0, the lowest-LSN segment) and commit its WAL
|
|
* LSN through the real engine (self-quorum) so it enters the committed
|
|
* prefix — the production marker-commit path. */
|
|
int acked = -1;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
config_commit_change(c, (const uint8_t *)"cfg", 3, sha, tag, &acked));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_submit(&c->commit, /*marker LSN*/1, COMMIT_MODE_WAIT, 1000));
|
|
uint64_t committed =
|
|
atomic_load_explicit(&c->persist.committed_lsn, memory_order_acquire);
|
|
TEST_ASSERT_TRUE(committed >= 1);
|
|
|
|
/* Sanity: before any trim, the live-WAL scan finds the marker. */
|
|
uint64_t got_tag = 0;
|
|
uint8_t got_sha[32];
|
|
TEST_ASSERT_EQUAL_INT(0, config_committed_marker(c, &got_tag, got_sha));
|
|
TEST_ASSERT_EQUAL_HEX64(tag, got_tag);
|
|
|
|
/* Roll to a NEW segment so the checkpoint's trim has an older segment (the
|
|
* one holding the marker) to unlink — the compaction hazard. */
|
|
TEST_ASSERT_EQUAL_INT(0, persist_test_force_new_segment(&c->persist));
|
|
|
|
/* Checkpoint: stamps the committed-marker reference into the sentinel, then
|
|
* trims the older segment (which physically held the marker). */
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&c->persist));
|
|
|
|
/* The marker's segment is gone, so the live-WAL scan alone would regress to
|
|
* -ENOENT; the derive MUST fall back to the durable sentinel reference. */
|
|
got_tag = 0;
|
|
memset(got_sha, 0, sizeof(got_sha));
|
|
TEST_ASSERT_EQUAL_INT(0, config_committed_marker(c, &got_tag, got_sha));
|
|
TEST_ASSERT_EQUAL_HEX64(tag, got_tag);
|
|
TEST_ASSERT_EQUAL_HEX8_ARRAY(sha, got_sha, 32);
|
|
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_committed_marker_survives_checkpoint_trim: PASS\n");
|
|
}
|
|
|
|
/* ── Config byte delivery: hash-verify-before-activate + serve-fence (task 4.3) ─
|
|
*
|
|
* config_activate_committed is the CONSUMER of config_committed_marker (4.2's
|
|
* derive). It keys the out-of-band config-push byte delivery to the committed
|
|
* marker's {version_tag, sha256}: it VERIFIES the locally-held bytes' sha256
|
|
* against the committed marker BEFORE activating (R7.8), and on a mismatch (or
|
|
* bytes absent locally) keeps the member from serving under the new configuration
|
|
* and KEEPS RETRYING rather than activating mismatched config (R7.9) — the loud
|
|
* non-serving fence that NEVER self-terminates. On recovery it activates the
|
|
* configuration referenced by the LATEST committed marker (R7.10).
|
|
*
|
|
* These tests reuse the 4.2 leader fixture (t42_*) but additionally wire c->myconf
|
|
* so the locally-held config bytes path (conf_save_config / conf_read_raw /
|
|
* load_primary_config) is exercised. */
|
|
|
|
/* Build a VALID Cap'n Proto ClusterConfig blob (decodable by load_primary_config)
|
|
* so the match path can actually ACTIVATE. Mirrors cb5_make_config_blob. */
|
|
static uint8_t *t43_make_config_blob(size_t *len_out)
|
|
{
|
|
cluster_config_t cfg;
|
|
struct capn cap;
|
|
struct capn_segment *cs;
|
|
ClusterConfig_ptr p;
|
|
uint8_t *buf;
|
|
size_t sz;
|
|
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.clustername = (char *)"t43-upgrade";
|
|
cfg.config_generation = 1;
|
|
|
|
capn_init_malloc(&cap);
|
|
cs = capn_root(&cap).seg;
|
|
encode_ClusterConfig_ptr(cs, &p, &cfg);
|
|
capn_setp(capn_root(&cap), 0, p.p);
|
|
sz = (size_t)capn_size(&cap);
|
|
buf = (uint8_t *)malloc(sz);
|
|
TEST_ASSERT_NOT_NULL(buf);
|
|
capn_write_mem(&cap, buf, sz, 0);
|
|
capn_free(&cap);
|
|
*len_out = sz;
|
|
return buf;
|
|
}
|
|
|
|
/* Wire a myconf_t into the cluster so conf_save_config / load_primary_config
|
|
* resolve under <tmpdir>/<name>/conf (the production layout). */
|
|
static myconf_t *t43_attach_myconf(cluster_t *c, const char *tmpdir)
|
|
{
|
|
myconf_t *mc = calloc(1, sizeof(myconf_t));
|
|
TEST_ASSERT_NOT_NULL(mc);
|
|
mc->workdir = strdup(tmpdir);
|
|
mc->name = strdup("ctrl-test");
|
|
mc->conf_keep_n = 2;
|
|
mc->conffile = NULL;
|
|
c->myconf = mc;
|
|
return mc;
|
|
}
|
|
|
|
static void t43_free_myconf(cluster_t *c)
|
|
{
|
|
myconf_t *mc = c->myconf;
|
|
if (!mc) return;
|
|
if (mc->cluster_config) {
|
|
free_ClusterConfig(mc->cluster_config);
|
|
free(mc->cluster_config);
|
|
}
|
|
free(mc->conffile);
|
|
free(mc->workdir);
|
|
free(mc->name);
|
|
free(mc);
|
|
c->myconf = NULL;
|
|
}
|
|
|
|
/* Commit a WAL_CONFIG_MARKER for `sha`/`tag` through the real commit engine so
|
|
* config_committed_marker returns it (the committed-prefix authority 4.3 reads). */
|
|
static void t43_commit_marker(cluster_t *c, uint64_t tag, const uint8_t sha[32])
|
|
{
|
|
uint64_t mlsn = 0;
|
|
TEST_ASSERT_EQUAL_INT(0, config_append_marker(c, tag, sha, &mlsn));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_submit(&c->commit, mlsn, COMMIT_MODE_WAIT, 1000));
|
|
TEST_ASSERT_TRUE(
|
|
atomic_load_explicit(&c->persist.committed_lsn, memory_order_acquire) >= mlsn);
|
|
}
|
|
|
|
/* (4.3-a) MATCH activates: a committed marker whose locally-held bytes' sha256
|
|
* MATCHES the marker activates the configuration and leaves the member UN-fenced
|
|
* (R7.8). */
|
|
static void test_config_activate_match_activates_and_unfences(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_match_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/3, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* The locally-held bytes (saved exactly as the config-push handler would). */
|
|
size_t blen = 0;
|
|
uint8_t *bytes = t43_make_config_blob(&blen);
|
|
uint8_t sha[32];
|
|
SHA256(bytes, blen, sha);
|
|
uint64_t tag;
|
|
{ uint64_t t; memcpy(&t, sha, 8); tag = be64toh(t); }
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, bytes, blen, sha));
|
|
free(bytes);
|
|
|
|
/* Commit a marker that references EXACTLY those bytes. */
|
|
t43_commit_marker(c, tag, sha);
|
|
|
|
/* HASH-VERIFIED → activate, fence stays clear. */
|
|
TEST_ASSERT_EQUAL_INT(0, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(0, config_serve_fenced(c));
|
|
/* Activation loaded the config into myconf. */
|
|
TEST_ASSERT_NOT_NULL(mc->cluster_config);
|
|
TEST_ASSERT_EQUAL_HEX64(tag, mc->config_version_tag);
|
|
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_config_activate_match_activates_and_unfences: PASS\n");
|
|
}
|
|
|
|
/* (4.3-b) MISMATCH fences + retries, NEVER activates: an injected hash mismatch
|
|
* (locally-held bytes whose sha256 != the committed marker) keeps the member
|
|
* FENCED (non-serving), ALIVE, and retrying, and does NOT activate the mismatched
|
|
* config (R7.9). */
|
|
static void test_config_activate_mismatch_fences_and_does_not_activate(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_mismatch_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/4, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* Locally-held bytes are blob A (sha A). */
|
|
size_t blenA = 0;
|
|
uint8_t *bytesA = t43_make_config_blob(&blenA);
|
|
uint8_t shaA[32];
|
|
SHA256(bytesA, blenA, shaA);
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, bytesA, blenA, shaA));
|
|
free(bytesA);
|
|
|
|
/* But the committed marker references a DIFFERENT hash (injected mismatch:
|
|
* the bytes for this marker have NOT arrived out-of-band yet). */
|
|
uint8_t shaB[32];
|
|
uint64_t tagB = t42_make_ref(shaB, 0x99); /* deterministic != shaA */
|
|
TEST_ASSERT_FALSE(memcmp(shaA, shaB, 32) == 0);
|
|
t43_commit_marker(c, tagB, shaB);
|
|
|
|
/* MISMATCH → fenced, NOT activated, member stays alive (returns, no abort). */
|
|
TEST_ASSERT_EQUAL_INT(-EAGAIN, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(1, config_serve_fenced(c));
|
|
/* The mismatched marker's config was NOT activated: the active config tag is
|
|
* still the locally-held blob A's tag, NEVER the marker's tagB. */
|
|
TEST_ASSERT_NOT_EQUAL(tagB, mc->config_version_tag);
|
|
|
|
/* RETRYING: a second call while still mismatched stays fenced (idempotent,
|
|
* never activates mismatched) — the member keeps retrying, alive. */
|
|
TEST_ASSERT_EQUAL_INT(-EAGAIN, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(1, config_serve_fenced(c));
|
|
TEST_ASSERT_NOT_EQUAL(tagB, mc->config_version_tag);
|
|
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_config_activate_mismatch_fences_and_does_not_activate: PASS\n");
|
|
}
|
|
|
|
/* (4.3-c) OBTAINED-OUT-OF-BAND bytes are verified before activate: a member is
|
|
* fenced while the marker's bytes are absent locally; once the matching bytes
|
|
* arrive out-of-band (saved exactly as handle_repl_config_push would), the next
|
|
* activate hash-verifies them against the marker and activates, clearing the
|
|
* fence (R7.5/R7.8). */
|
|
static void test_config_activate_obtained_bytes_verified_before_activate(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_obtain_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/5, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* The marker's referenced bytes. Compute its hash; commit the marker BEFORE
|
|
* the bytes are present locally (no conf_save_config yet). */
|
|
size_t blen = 0;
|
|
uint8_t *bytes = t43_make_config_blob(&blen);
|
|
uint8_t sha[32];
|
|
SHA256(bytes, blen, sha);
|
|
uint64_t tag;
|
|
{ uint64_t t; memcpy(&t, sha, 8); tag = be64toh(t); }
|
|
t43_commit_marker(c, tag, sha);
|
|
|
|
/* Bytes NOT present locally yet → fenced, not activated (R7.9). */
|
|
TEST_ASSERT_EQUAL_INT(-EAGAIN, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(1, config_serve_fenced(c));
|
|
|
|
/* Bytes arrive out-of-band: the config-push transport saves them locally
|
|
* exactly as handle_repl_config_push does (conf_save_config). */
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, bytes, blen, sha));
|
|
free(bytes);
|
|
|
|
/* Now the obtained bytes hash-verify against the committed marker → activate,
|
|
* fence clears. */
|
|
TEST_ASSERT_EQUAL_INT(0, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(0, config_serve_fenced(c));
|
|
TEST_ASSERT_NOT_NULL(mc->cluster_config);
|
|
TEST_ASSERT_EQUAL_HEX64(tag, mc->config_version_tag);
|
|
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_config_activate_obtained_bytes_verified_before_activate: PASS\n");
|
|
}
|
|
|
|
/* (4.3-d) RECOVERY activates the LATEST committed marker: with two committed
|
|
* markers, config_activate_committed activates the config referenced by the
|
|
* LATEST committed marker (the one config_committed_marker derives), obtaining
|
|
* its bytes from local out-of-band storage (R7.10). */
|
|
static void test_config_activate_recovery_uses_latest_committed_marker(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_recover_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/6, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* Two distinct configs, each committed as a marker in turn; the LATEST wins. */
|
|
size_t blen1 = 0, blen2 = 0;
|
|
uint8_t *b1 = t43_make_config_blob(&blen1);
|
|
/* Make blob 2 differ so its hash differs from blob 1. */
|
|
uint8_t *b2 = t43_make_config_blob(&blen2);
|
|
TEST_ASSERT_TRUE(blen2 >= 1);
|
|
b2[blen2 - 1] ^= 0xFF; /* perturb → distinct sha */
|
|
uint8_t sha1[32], sha2[32];
|
|
SHA256(b1, blen1, sha1);
|
|
SHA256(b2, blen2, sha2);
|
|
TEST_ASSERT_FALSE(memcmp(sha1, sha2, 32) == 0);
|
|
uint64_t tag2;
|
|
{ uint64_t t; memcpy(&t, sha2, 8); tag2 = be64toh(t); }
|
|
|
|
uint64_t tag1; { uint64_t t; memcpy(&t, sha1, 8); tag1 = be64toh(t); }
|
|
t43_commit_marker(c, tag1, sha1);
|
|
t43_commit_marker(c, tag2, sha2);
|
|
|
|
/* On recovery, the LOCALLY-held bytes are the LATEST config (blob 2 — what the
|
|
* out-of-band store holds after the latest push). */
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, b2, blen2, sha2));
|
|
free(b1);
|
|
free(b2);
|
|
|
|
/* Activation targets the LATEST committed marker (tag2), hash-verifies the
|
|
* local bytes, and activates it. */
|
|
TEST_ASSERT_EQUAL_INT(0, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(0, config_serve_fenced(c));
|
|
TEST_ASSERT_EQUAL_HEX64(tag2, mc->config_version_tag);
|
|
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_config_activate_recovery_uses_latest_committed_marker: PASS\n");
|
|
}
|
|
|
|
/* ── task 4.3 REMEDIATION r1: production integration of the consumer ───────────
|
|
*
|
|
* Round-1 review REJECTED 4.3 for three integration gaps, fixed below:
|
|
* A — verify-before-activate was BYPASSED on the push path (handle_repl_config_
|
|
* push pre-activated via load_primary_config before the gate ran).
|
|
* B — recovery-activate had NO production call site (boot loaded blindly).
|
|
* C — the serve-fence had NO production reader (ha_dispatch_gate ignored it).
|
|
*/
|
|
|
|
/* (4.3-A) PUSH-PATH SOLE ACTIVATION: config_activate_committed is the SOLE
|
|
* activation point. With a committed marker that references DIFFERENT bytes than
|
|
* the ones just delivered out-of-band, the push-path sequence (conf_save_config
|
|
* then config_activate_committed — NO pre-emptive load_primary_config) must NOT
|
|
* make the pushed bytes live (verify-before-activate is a real GATE, not an
|
|
* after-the-fact overlay). Regression for Finding A. */
|
|
static void test_push_path_mismatch_does_not_preactivate(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_pushgate_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/7, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* A committed marker references blob B (sha B) — the config the cluster
|
|
* agreed to, whose bytes have NOT yet arrived here. */
|
|
size_t blenB = 0;
|
|
uint8_t *bytesB = t43_make_config_blob(&blenB);
|
|
bytesB[blenB - 1] ^= 0x5A; /* perturb → distinct sha */
|
|
uint8_t shaB[32];
|
|
SHA256(bytesB, blenB, shaB);
|
|
uint64_t tagB; { uint64_t t; memcpy(&t, shaB, 8); tagB = be64toh(t); }
|
|
t43_commit_marker(c, tagB, shaB);
|
|
free(bytesB);
|
|
|
|
/* The push delivers blob A (a DIFFERENT/older config). The push handler now
|
|
* SAVES the bytes but does NOT pre-activate; activation is gated. */
|
|
size_t blenA = 0;
|
|
uint8_t *bytesA = t43_make_config_blob(&blenA);
|
|
uint8_t shaA[32];
|
|
SHA256(bytesA, blenA, shaA);
|
|
TEST_ASSERT_FALSE(memcmp(shaA, shaB, 32) == 0);
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, bytesA, blenA, shaA));
|
|
free(bytesA);
|
|
|
|
/* The push-path SOLE activation: gate fences (marker references blob B, not
|
|
* the just-saved blob A) and does NOT load blob A. */
|
|
TEST_ASSERT_EQUAL_INT(-EAGAIN, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(1, config_serve_fenced(c));
|
|
/* Blob A was NOT made live: no decoded config, no version tag for A. */
|
|
TEST_ASSERT_NULL(mc->cluster_config);
|
|
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_push_path_mismatch_does_not_preactivate: PASS\n");
|
|
}
|
|
|
|
/* (4.3-A/B) NO-MARKER FALLBACK: when there is NO committed marker yet (legacy /
|
|
* first boot, or the marker has not committed), config_activate_committed FALLS
|
|
* BACK to the existing load_primary_config behavior — it activates the
|
|
* locally-held bytes (so config_version_tag is set for the freshness gate and the
|
|
* push ACK) and does NOT fence. Absent-marker is NOT a mismatch. */
|
|
static void test_config_activate_no_marker_falls_back_and_loads(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_fallback_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/8, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* Local bytes present, but NO committed marker exists. */
|
|
size_t blen = 0;
|
|
uint8_t *bytes = t43_make_config_blob(&blen);
|
|
uint8_t sha[32];
|
|
SHA256(bytes, blen, sha);
|
|
uint64_t tag; { uint64_t t; memcpy(&t, sha, 8); tag = be64toh(t); }
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, bytes, blen, sha));
|
|
free(bytes);
|
|
|
|
/* No marker → fall back to load_primary_config (blind load), NOT a fence. */
|
|
TEST_ASSERT_EQUAL_INT(0, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(0, config_serve_fenced(c));
|
|
TEST_ASSERT_NOT_NULL(mc->cluster_config);
|
|
TEST_ASSERT_EQUAL_HEX64(tag, mc->config_version_tag);
|
|
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_config_activate_no_marker_falls_back_and_loads: PASS\n");
|
|
}
|
|
|
|
/* (4.3-C) FENCE IS ENFORCED by a production reader: while the config serve-fence
|
|
* is set, ha_dispatch_gate refuses config-dependent state-changing operations on
|
|
* the PRIMARY (loud non-serving, member stays alive) and a fenced member is
|
|
* non-promotable (ha_config_promotion_fenced). Read-only/replication ops still
|
|
* pass so the bytes can arrive out-of-band. Once correct bytes arrive and
|
|
* re-verify clears the fence, the member serves again. Regression for Finding C. */
|
|
static void test_serve_fence_enforced_by_dispatch_gate_and_promotion(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_fence_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/9, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* The committed config (blob TARGET) whose bytes have not yet arrived. */
|
|
size_t tlen = 0;
|
|
uint8_t *tbytes = t43_make_config_blob(&tlen);
|
|
uint8_t tsha[32];
|
|
SHA256(tbytes, tlen, tsha);
|
|
uint64_t ttag; { uint64_t t; memcpy(&t, tsha, 8); ttag = be64toh(t); }
|
|
t43_commit_marker(c, ttag, tsha);
|
|
|
|
/* Stale local bytes (blob A) that do NOT match the committed marker. */
|
|
size_t alen = 0;
|
|
uint8_t *abytes = t43_make_config_blob(&alen);
|
|
abytes[alen - 1] ^= 0x33;
|
|
uint8_t asha[32];
|
|
SHA256(abytes, alen, asha);
|
|
TEST_ASSERT_FALSE(memcmp(asha, tsha, 32) == 0);
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, abytes, alen, asha));
|
|
free(abytes);
|
|
|
|
/* Mismatch → fenced. */
|
|
TEST_ASSERT_EQUAL_INT(-EAGAIN, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(1, config_serve_fenced(c));
|
|
|
|
/* PRODUCTION READER: a fenced PRIMARY refuses config-dependent state-changing
|
|
* ops (loud non-serving) but stays alive — the gate returns an error, not a
|
|
* crash/exit. */
|
|
TEST_ASSERT_TRUE(ha_dispatch_gate(&c->ha, OPCode_jobsub) < 0);
|
|
/* Replication / read-only ops still pass so the correct bytes can arrive. */
|
|
TEST_ASSERT_EQUAL_INT(0, ha_dispatch_gate(&c->ha, OPCode_replConfigPush));
|
|
TEST_ASSERT_EQUAL_INT(0, ha_dispatch_gate(&c->ha, OPCode_get));
|
|
/* Non-promotable while fenced. */
|
|
TEST_ASSERT_EQUAL_INT(1, ha_config_promotion_fenced(&c->ha));
|
|
|
|
/* Correct bytes arrive out-of-band (saved as the push handler would). */
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, tbytes, tlen, tsha));
|
|
free(tbytes);
|
|
|
|
/* Re-verify clears the fence → member serves and is promotable again. */
|
|
TEST_ASSERT_EQUAL_INT(0, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(0, config_serve_fenced(c));
|
|
TEST_ASSERT_EQUAL_HEX64(ttag, mc->config_version_tag);
|
|
TEST_ASSERT_EQUAL_INT(0, ha_dispatch_gate(&c->ha, OPCode_jobsub));
|
|
TEST_ASSERT_EQUAL_INT(0, ha_config_promotion_fenced(&c->ha));
|
|
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_serve_fence_enforced_by_dispatch_gate_and_promotion: PASS\n");
|
|
}
|
|
|
|
/* Append a WAL_CONFIG_MARKER for `sha`/`tag` and flush it to the WAL, but do NOT
|
|
* advance the committed prefix past it — returns its LSN. Models a STANDBY whose
|
|
* local WAL tip runs ahead of its committed_lsn: on a standby committed_lsn is
|
|
* driven by the primary's commit-advance (commit_set_raft_commit_index / the
|
|
* library), NOT by local durability, so a flushed-but-not-yet-committed marker is
|
|
* the real push-before-commit state. The caller pins committed_lsn back to the
|
|
* prior value (see the test) so config_committed_marker still derives the OLDER
|
|
* marker until the NEW one is explicitly committed. */
|
|
static uint64_t t43_append_marker_uncommitted(cluster_t *c, uint64_t tag,
|
|
const uint8_t sha[32])
|
|
{
|
|
uint64_t mlsn = 0;
|
|
TEST_ASSERT_EQUAL_INT(0, config_append_marker(c, tag, sha, &mlsn));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
|
return mlsn;
|
|
}
|
|
|
|
/* (4.3-D / R7.9) COMMIT-ADVANCE RETRY clears a push-before-commit fence: the
|
|
* config MARKER (WAL) and config BYTES (out-of-band push) arrive on INDEPENDENT
|
|
* transports. On a 2nd upgrade the NEW bytes are saved BEFORE the NEW marker
|
|
* enters the committed prefix, so config_committed_marker still returns the OLDER
|
|
* marker → the freshly-saved NEW bytes hash-MISMATCH it → the member is serve-
|
|
* FENCED. The NEW marker commits later via the WAL path, but on_commit_advanced
|
|
* cannot re-activate (no disk I/O on the iomgr ACK thread) and the primary's
|
|
* config-behind re-push net does not fire (the push already advanced the advertised
|
|
* tag) — WITHOUT the commit-advance retry trigger the standby would stay
|
|
* PERMANENTLY fenced. This test reproduces that ordering and asserts the retry
|
|
* trigger (config_activate_retry_fn — the SAME off-thread entry the production
|
|
* scheduler enqueues) CLEARS the fence and activates the NEW config once the NEW
|
|
* marker is committed. Regression for Finding D.
|
|
*
|
|
* The single-member commit engine advances committed_lsn to the durable WAL tip,
|
|
* so to hold the NEW marker out of the committed prefix this test PINS
|
|
* committed_lsn at the OLD marker's LSN (modeling a standby whose committed prefix
|
|
* lags its local WAL tip), then advances it past the NEW marker to model the NEW
|
|
* marker committing via the WAL path. */
|
|
static void test_commit_advance_retry_clears_push_before_commit_fence(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr43_retry_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
t42_make_leader(c, /*term*/10, /*n_peers*/1);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
commit_engine_init(&c->commit, &c->persist, 1, 8));
|
|
repl_set_commit_engine(&c->persist.repl, &c->commit);
|
|
myconf_t *mc = t43_attach_myconf(c, tmp);
|
|
|
|
/* ── Upgrade 1 (OLD): commit OLD marker + save OLD bytes + activate. The
|
|
* member is UN-fenced and serving the OLD config. ───────────────────────── */
|
|
size_t blenO = 0;
|
|
uint8_t *bytesO = t43_make_config_blob(&blenO);
|
|
uint8_t shaO[32];
|
|
SHA256(bytesO, blenO, shaO);
|
|
uint64_t tagO; { uint64_t t; memcpy(&t, shaO, 8); tagO = be64toh(t); }
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, bytesO, blenO, shaO));
|
|
free(bytesO);
|
|
t43_commit_marker(c, tagO, shaO);
|
|
TEST_ASSERT_EQUAL_INT(0, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(0, config_serve_fenced(c));
|
|
TEST_ASSERT_EQUAL_HEX64(tagO, mc->config_version_tag);
|
|
|
|
/* The OLD marker is the committed prefix boundary the standby currently sees. */
|
|
uint64_t committed_at_old =
|
|
atomic_load_explicit(&c->persist.committed_lsn, memory_order_acquire);
|
|
|
|
/* ── Upgrade 2 (NEW): the BYTES arrive (and are saved) BEFORE the NEW marker
|
|
* enters the committed prefix — the push-before-commit ordering. ────────── */
|
|
size_t blenN = 0;
|
|
uint8_t *bytesN = t43_make_config_blob(&blenN);
|
|
bytesN[blenN - 1] ^= 0xA5; /* perturb → distinct sha */
|
|
uint8_t shaN[32];
|
|
SHA256(bytesN, blenN, shaN);
|
|
uint64_t tagN; { uint64_t t; memcpy(&t, shaN, 8); tagN = be64toh(t); }
|
|
TEST_ASSERT_FALSE(memcmp(shaO, shaN, 32) == 0);
|
|
|
|
/* The NEW marker is APPENDED + flushed to the WAL (local tip advances). */
|
|
uint64_t new_mlsn = t43_append_marker_uncommitted(c, tagN, shaN);
|
|
TEST_ASSERT_TRUE(new_mlsn > committed_at_old);
|
|
|
|
/* The OLD config is currently LIVE (activated in upgrade 1). Capture the live
|
|
* decoded-config pointer as the activation witness: load_primary_config frees
|
|
* and reallocates mc->cluster_config, so a CHANGED pointer proves a (re)load
|
|
* happened. conf_save_config (below) only writes bytes + bumps the advertised
|
|
* config_version_tag — it does NOT decode/activate — so it must NOT change this
|
|
* pointer; only a successful config_activate_committed does. */
|
|
cluster_config_t *live_before = mc->cluster_config;
|
|
TEST_ASSERT_NOT_NULL(live_before);
|
|
|
|
/* The NEW bytes are saved out-of-band (exactly as handle_repl_config_push
|
|
* does). Note conf_save_config in this single-member fixture re-evaluates the
|
|
* commit barrier, which would advance committed_lsn to the durable WAL tip, and
|
|
* it bumps the advertised config_version_tag to tagN (this is the very reason
|
|
* the primary's config-behind re-push net does not re-fire — Finding D); we
|
|
* save FIRST, then PIN committed_lsn back below the NEW marker to model a
|
|
* standby whose committed prefix (driven by the primary's commit-advance, not
|
|
* local durability) has NOT yet advanced past the NEW marker. */
|
|
TEST_ASSERT_EQUAL_INT(0, conf_save_config(mc, bytesN, blenN, shaN));
|
|
atomic_store_explicit(&c->persist.committed_lsn, committed_at_old,
|
|
memory_order_release);
|
|
|
|
/* Activate now: config_committed_marker still returns the OLDER marker (tagO),
|
|
* so the freshly-saved NEW bytes MISMATCH it → serve-FENCED, NEW config NOT
|
|
* ACTIVATED. The live decoded config is UNCHANGED (still the OLD one) — the
|
|
* mismatch gate never loaded the NEW bytes. This is the permanent-fence state
|
|
* the retry trigger must rescue. */
|
|
TEST_ASSERT_EQUAL_INT(-EAGAIN, config_activate_committed(c));
|
|
TEST_ASSERT_EQUAL_INT(1, config_serve_fenced(c));
|
|
TEST_ASSERT_TRUE(mc->cluster_config == live_before); /* NEW not activated */
|
|
|
|
/* ── The NEW marker COMMITS later via the WAL path (the standby's committed
|
|
* prefix advances past it, as the primary's commit-advance heartbeat would
|
|
* drive). config_committed_marker now derives the NEW marker. ───────────── */
|
|
atomic_store_explicit(&c->persist.committed_lsn, new_mlsn,
|
|
memory_order_release);
|
|
|
|
/* DRIVE THE RETRY TRIGGER: the SAME off-thread activation entry the production
|
|
* commit-advance path schedules (ha_cb_commit_advanced_stub →
|
|
* config_schedule_activate_retry → delaymgr → config_activate_retry_fn). In
|
|
* production the delaymgr worker runs this OFF the iomgr ACK thread; here we
|
|
* invoke the entry directly (the fixture's delaymgr is not started). Now
|
|
* config_committed_marker returns the NEW marker and the saved NEW bytes
|
|
* hash-verify → the fence CLEARS and the NEW config ACTIVATES (cluster_config
|
|
* is reloaded — a fresh pointer). */
|
|
TEST_ASSERT_EQUAL_INT(0, config_activate_retry_fn(c));
|
|
TEST_ASSERT_EQUAL_INT(0, config_serve_fenced(c)); /* fence CLEARED */
|
|
TEST_ASSERT_EQUAL_HEX64(tagN, mc->config_version_tag); /* NEW now live */
|
|
TEST_ASSERT_NOT_NULL(mc->cluster_config);
|
|
TEST_ASSERT_TRUE(mc->cluster_config != live_before); /* (re)loaded NEW */
|
|
|
|
free(bytesN);
|
|
t43_free_myconf(c);
|
|
commit_engine_fini(&c->commit);
|
|
t42_teardown(c);
|
|
printf("test_commit_advance_retry_clears_push_before_commit_fence: PASS\n");
|
|
}
|
|
|
|
/* ── wait_all_with_timeout (XCE_TESTING shim) ───── */
|
|
|
|
#ifdef XCE_TESTING
|
|
|
|
/* Thread args for timeout tests */
|
|
typedef struct {
|
|
atomic_int *stop; /* poll this; exit when set to allow pthread_join to return */
|
|
push_ctx_t *ctx;
|
|
} timeout_test_arg_t;
|
|
|
|
/* Polls stop flag at 5ms intervals; exits when stop is set WITHOUT setting acked.
|
|
* This simulates a synchronous push that respects the done signal. */
|
|
static void *slow_push_thread(void *arg)
|
|
{
|
|
timeout_test_arg_t *a = (timeout_test_arg_t *)arg;
|
|
while (!atomic_load(a->stop))
|
|
usleep(5000);
|
|
/* Thread exits without setting acked — deadline fired before completion */
|
|
a->ctx->done = 1;
|
|
return NULL;
|
|
}
|
|
|
|
static void *fast_push_thread(void *arg)
|
|
{
|
|
push_ctx_t *ctx = (push_ctx_t *)arg;
|
|
ctx->acked = 1;
|
|
ctx->done = 1;
|
|
return NULL;
|
|
}
|
|
|
|
static void test_wait_all_with_timeout_slow_threads(void)
|
|
{
|
|
/* 2 threads that poll the done flag and exit without acking.
|
|
* Timeout is 100ms. Expect: acked==0, done==1, returns within ~200ms. */
|
|
const int N = 2;
|
|
pthread_t threads[2];
|
|
push_ctx_t ctxs[2];
|
|
timeout_test_arg_t args[2];
|
|
atomic_int done;
|
|
|
|
memset(ctxs, 0, sizeof(ctxs));
|
|
atomic_init(&done, 0);
|
|
|
|
for (int i = 0; i < N; i++) {
|
|
ctxs[i].started = 1;
|
|
args[i].stop = &done;
|
|
args[i].ctx = &ctxs[i];
|
|
pthread_create(&threads[i], NULL, slow_push_thread, &args[i]);
|
|
}
|
|
|
|
struct timespec t0, t1;
|
|
clock_gettime(CLOCK_MONOTONIC, &t0);
|
|
int acked = wait_all_with_timeout_for_test(threads, ctxs, N, 100, &done);
|
|
clock_gettime(CLOCK_MONOTONIC, &t1);
|
|
|
|
long elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000
|
|
+ (t1.tv_nsec - t0.tv_nsec) / 1000000;
|
|
|
|
/* Threads did not ack within the 100ms timeout */
|
|
TEST_ASSERT_EQUAL_INT(0, acked);
|
|
/* done flag must be set by wait_all_with_timeout */
|
|
TEST_ASSERT_EQUAL_INT(1, atomic_load(&done));
|
|
/* Total elapsed should be close to the 100ms timeout + small join overhead */
|
|
TEST_ASSERT_LESS_THAN(400, (int)elapsed_ms);
|
|
|
|
printf("test_wait_all_with_timeout_slow_threads: PASS (elapsed=%ldms acked=%d)\n",
|
|
elapsed_ms, acked);
|
|
}
|
|
|
|
static void test_wait_all_with_timeout_fast_threads(void)
|
|
{
|
|
/* 3 threads that complete immediately; timeout is 200ms.
|
|
* Expect: all 3 acked, returns quickly (well under 200ms). */
|
|
const int N = 3;
|
|
pthread_t threads[3];
|
|
push_ctx_t ctxs[3];
|
|
atomic_int done;
|
|
|
|
memset(ctxs, 0, sizeof(ctxs));
|
|
atomic_init(&done, 0);
|
|
|
|
for (int i = 0; i < N; i++) {
|
|
ctxs[i].started = 1;
|
|
pthread_create(&threads[i], NULL, fast_push_thread, &ctxs[i]);
|
|
}
|
|
|
|
struct timespec t0, t1;
|
|
clock_gettime(CLOCK_MONOTONIC, &t0);
|
|
int acked = wait_all_with_timeout_for_test(threads, ctxs, N, 200, &done);
|
|
clock_gettime(CLOCK_MONOTONIC, &t1);
|
|
|
|
long elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000
|
|
+ (t1.tv_nsec - t0.tv_nsec) / 1000000;
|
|
|
|
TEST_ASSERT_EQUAL_INT(N, acked);
|
|
TEST_ASSERT_EQUAL_INT(1, atomic_load(&done));
|
|
/* Should have returned long before the 200ms timeout */
|
|
TEST_ASSERT_LESS_THAN(200, (int)elapsed_ms);
|
|
|
|
printf("test_wait_all_with_timeout_fast_threads: PASS (elapsed=%ldms acked=%d)\n",
|
|
elapsed_ms, acked);
|
|
}
|
|
|
|
/* ── push_config_transport_only endpoint lifetime (bug-1381) ───── */
|
|
|
|
/* push_thread_send_hook: test-only seam declared in config_repl.c (same
|
|
* pattern as xce_wal_stream_request_hook) letting this test intercept and
|
|
* park push_thread's send so it can probe endpoint_ref/unref bracketing
|
|
* around a concurrent teardown, without a real transport connection. */
|
|
extern int (*push_thread_send_hook)(endpoint_t *ep, xce_peerinfo_t *from,
|
|
uint64_t new_tag, const uint8_t *sha256,
|
|
const uint8_t *bytes, size_t blen);
|
|
|
|
static atomic_int g_cpto_parked;
|
|
static atomic_int g_cpto_release;
|
|
|
|
static int cpto_parking_send_hook(endpoint_t *ep, xce_peerinfo_t *from,
|
|
uint64_t new_tag, const uint8_t *sha256,
|
|
const uint8_t *bytes, size_t blen)
|
|
{
|
|
(void)ep; (void)from; (void)new_tag; (void)sha256; (void)bytes; (void)blen;
|
|
atomic_store_explicit(&g_cpto_parked, 1, memory_order_release);
|
|
while (!atomic_load_explicit(&g_cpto_release, memory_order_acquire))
|
|
usleep(1000);
|
|
return 0;
|
|
}
|
|
|
|
static int cpto_wait_flag(atomic_int *flag, int target, int timeout_ms)
|
|
{
|
|
int waited = 0;
|
|
while (atomic_load_explicit(flag, memory_order_acquire) != target) {
|
|
if (waited >= timeout_ms)
|
|
return -1;
|
|
usleep(2000);
|
|
waited += 2;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
typedef struct {
|
|
cluster_t *c;
|
|
int acked;
|
|
int n;
|
|
} cpto_driver_arg_t;
|
|
|
|
static void *cpto_driver_fn(void *arg)
|
|
{
|
|
cpto_driver_arg_t *a = (cpto_driver_arg_t *)arg;
|
|
uint8_t sha[32];
|
|
uint8_t body[4] = { 1, 2, 3, 4 };
|
|
memset(sha, 0, sizeof(sha));
|
|
a->acked = push_config_transport_only(a->c, body, sizeof(body), sha, 99,
|
|
&a->n);
|
|
return NULL;
|
|
}
|
|
|
|
/*
|
|
* bug-1381: push_config_transport_only spawns a push_thread per registered
|
|
* standby that reads ctx->ep for the full duration of the transport send.
|
|
* Before the fix, no ref was taken on ep, so a concurrent RDHUP teardown
|
|
* (endpoint_killer -> endpoint_unref, the bug-1298 refcount system) could
|
|
* free ep out from under the still-running push_thread -- a UAF. This test
|
|
* parks a push_thread mid-send via push_thread_send_hook, simulates the
|
|
* owner's teardown, and verifies ep survives until the send completes.
|
|
*/
|
|
static void test_configpush_transport_holds_endpoint_ref_across_send(void)
|
|
{
|
|
cluster_t c;
|
|
memset(&c, 0, sizeof(c));
|
|
xce_peerinfo_t myhost_info;
|
|
memset(&myhost_info, 0, sizeof(myhost_info));
|
|
c.myhost = &myhost_info;
|
|
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&c.ha, &cfg, 0);
|
|
|
|
c.ha.standby_endpoints = calloc(1, sizeof(zarray_t));
|
|
TEST_ASSERT_NOT_NULL(c.ha.standby_endpoints);
|
|
zarray_init(c.ha.standby_endpoints, 4, NULL);
|
|
|
|
/* Heap-allocated endpoint standing in for a real standby connection:
|
|
* refcnt starts at 1, representing the owner (the standby zarray)
|
|
* holding the creator reference. */
|
|
endpoint_t *ep = endpoint_get();
|
|
TEST_ASSERT_NOT_NULL(ep);
|
|
zarray_store(c.ha.standby_endpoints, ep);
|
|
|
|
atomic_store(&g_cpto_parked, 0);
|
|
atomic_store(&g_cpto_release, 0);
|
|
push_thread_send_hook = cpto_parking_send_hook;
|
|
|
|
cpto_driver_arg_t driver = { .c = &c, .acked = -1, .n = -1 };
|
|
pthread_t driver_thread;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
pthread_create(&driver_thread, NULL, cpto_driver_fn, &driver));
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, cpto_wait_flag(&g_cpto_parked, 1, 2000),
|
|
"push_thread must park in the send hook before we probe refcnt");
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(2,
|
|
atomic_load_explicit(&ep->refcnt, memory_order_acquire),
|
|
"push_config_transport_only must hold its own ref (owner's 1 + "
|
|
"push_thread's 1) while the send is in flight");
|
|
|
|
/* Simulate the owner's teardown (RDHUP -> zarray_del -> endpoint_killer
|
|
* -> endpoint_unref, bug-1298) racing the in-flight push. */
|
|
endpoint_unref(ep);
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(1,
|
|
atomic_load_explicit(&ep->refcnt, memory_order_acquire),
|
|
"ep must survive the owner's unref while push_thread is still in "
|
|
"flight -- bug-1381");
|
|
|
|
atomic_store_explicit(&g_cpto_release, 1, memory_order_release);
|
|
pthread_join(driver_thread, NULL);
|
|
|
|
TEST_ASSERT_EQUAL_INT(1, driver.n);
|
|
TEST_ASSERT_EQUAL_INT(1, driver.acked);
|
|
/* ep is now freed (push_thread's completion unref dropped the last
|
|
* reference) -- do not touch it again. */
|
|
|
|
push_thread_send_hook = NULL;
|
|
zarray_fini(c.ha.standby_endpoints);
|
|
free(c.ha.standby_endpoints);
|
|
c.ha.standby_endpoints = NULL;
|
|
ha_fini(&c.ha);
|
|
printf("test_configpush_transport_holds_endpoint_ref_across_send: PASS\n");
|
|
}
|
|
|
|
#endif /* XCE_TESTING */
|
|
|
|
/* ── raw capnp shims (Task A) ───────────────────── */
|
|
|
|
/* Test 1: xce_replconfig_req_ssl encodes a request that round-trips
|
|
* through the capnp decoder and preserves the version_tag. */
|
|
static void test_replconfig_req_ssl_encode(void)
|
|
{
|
|
int sv[2];
|
|
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
|
|
|
|
/* Wrap the writer end of the socketpair in a bare BIO/SSL pair so we
|
|
* can call SSL_write without doing a TLS handshake. Instead, we use
|
|
* a memory BIO and let xce_replconfig_req_ssl write into it. */
|
|
SSL_library_init();
|
|
SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
|
|
TEST_ASSERT_NOT_NULL(ctx);
|
|
SSL *ssl = SSL_new(ctx);
|
|
TEST_ASSERT_NOT_NULL(ssl);
|
|
/* Attach the writer fd as the underlying BIO. We don't perform a
|
|
* handshake; SSL_write will just SYSCALL through to the fd. This
|
|
* works because BIO_set_nbio is off and OpenSSL is willing to
|
|
* write plaintext until handshake completes — except newer OpenSSL
|
|
* refuses. Use a simpler approach: do the encode + raw fd write. */
|
|
|
|
/* Cleaner approach: bypass SSL wrapper and just exercise the encode
|
|
* by re-using the public helper but with our own raw fd write.
|
|
* We test the encoding path by invoking xce_replconfig_req_ssl
|
|
* with a properly-constructed SSL only if available, otherwise
|
|
* fall back to capnp encode verification via the public function
|
|
* called against a duplicated socket pair. */
|
|
SSL_free(ssl);
|
|
SSL_CTX_free(ctx);
|
|
|
|
/* Use a pipe-based "raw" verification: capnp encode the request
|
|
* ourselves the same way the function does, then decode and check
|
|
* version_tag. This validates the wire layout without requiring
|
|
* a full SSL handshake in unit tests. */
|
|
{
|
|
struct capn c;
|
|
struct capn_segment *cs;
|
|
struct Request rq;
|
|
struct ReplConfigRequest req_struct;
|
|
ReplConfigRequest_ptr req_ptr;
|
|
Request_ptr rp;
|
|
|
|
capn_init_malloc(&c);
|
|
cs = capn_root(&c).seg;
|
|
|
|
memset(&rq, 0, sizeof(rq));
|
|
memset(&req_struct, 0, sizeof(req_struct));
|
|
req_struct.versionTag = 0xdeadbeefcafef00dULL;
|
|
|
|
req_ptr = new_ReplConfigRequest(cs);
|
|
write_ReplConfigRequest(&req_struct, req_ptr);
|
|
|
|
rq.opcode = OPCode_replConfig;
|
|
rq.body_which = Request_body_replconfigrequestmsg;
|
|
rq.body.replconfigrequestmsg = req_ptr;
|
|
encode_PeerInfo_ptr(cs, &rq.from, NULL);
|
|
|
|
rp = new_Request(cs);
|
|
write_Request(&rq, rp);
|
|
capn_setp(capn_root(&c), 0, rp.p);
|
|
|
|
int64_t sz = capn_size(&c);
|
|
TEST_ASSERT_TRUE(sz > 0);
|
|
uint8_t *buf = (uint8_t *)malloc((size_t)sz);
|
|
TEST_ASSERT_NOT_NULL(buf);
|
|
capn_write_mem(&c, buf, (size_t)sz, 0);
|
|
capn_free(&c);
|
|
|
|
/* Decode and verify */
|
|
struct capn d;
|
|
TEST_ASSERT_EQUAL_INT(0, capn_init_mem(&d, buf, (size_t)sz, 0));
|
|
Request_ptr dp;
|
|
dp.p = capn_getp(capn_root(&d), 0, 1);
|
|
struct Request drq;
|
|
read_Request(&drq, dp);
|
|
TEST_ASSERT_EQUAL_INT(OPCode_replConfig, drq.opcode);
|
|
TEST_ASSERT_EQUAL_INT(Request_body_replconfigrequestmsg,
|
|
drq.body_which);
|
|
|
|
ReplConfigRequest_ptr drp = drq.body.replconfigrequestmsg;
|
|
struct ReplConfigRequest drs;
|
|
read_ReplConfigRequest(&drs, drp);
|
|
TEST_ASSERT_EQUAL_UINT64(0xdeadbeefcafef00dULL, drs.versionTag);
|
|
|
|
capn_free(&d);
|
|
free(buf);
|
|
}
|
|
|
|
close(sv[0]);
|
|
close(sv[1]);
|
|
printf("test_replconfig_req_ssl_encode: PASS\n");
|
|
}
|
|
|
|
/* Test 2: xce_replconfig_reply_raw — verify NULL endpoint returns
|
|
* -EINVAL and that the encode path with real bytes preserves the
|
|
* sha256 and bytes payloads through a round-trip decoder. */
|
|
static void test_replconfig_reply_raw_encode(void)
|
|
{
|
|
/* NULL endpoint guard */
|
|
{
|
|
uint8_t sha[32];
|
|
uint8_t cfg[64];
|
|
memset(sha, 0xaa, sizeof(sha));
|
|
memset(cfg, 0xbb, sizeof(cfg));
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL,
|
|
xce_replconfig_reply_raw(NULL, NULL, 1, 0x42,
|
|
sha, sizeof(sha),
|
|
cfg, sizeof(cfg)));
|
|
}
|
|
|
|
/* Encode-path test: directly build the same capnp structure that
|
|
* xce_replconfig_reply_raw builds, then walk down Request →
|
|
* RequestReply → ReplConfigReply and read sha256/bytes via raw
|
|
* capn_list8 access. */
|
|
{
|
|
struct capn c;
|
|
struct capn_segment *cs;
|
|
struct Request rq;
|
|
struct RequestReply rr;
|
|
struct ReplConfigReply reply_struct;
|
|
ReplConfigReply_ptr reply_ptr;
|
|
RequestReply_ptr rr_ptr;
|
|
Request_ptr rp;
|
|
|
|
const uint64_t tag = 0xfeedfacecafebabeULL;
|
|
const uint64_t seq = 99;
|
|
uint8_t sha256[32];
|
|
uint8_t config[128];
|
|
for (size_t i = 0; i < sizeof(sha256); i++) sha256[i] = (uint8_t)(0x10 + i);
|
|
for (size_t i = 0; i < sizeof(config); i++) config[i] = (uint8_t)(i * 3);
|
|
|
|
capn_init_malloc(&c);
|
|
cs = capn_root(&c).seg;
|
|
|
|
memset(&rq, 0, sizeof(rq));
|
|
memset(&rr, 0, sizeof(rr));
|
|
memset(&reply_struct, 0, sizeof(reply_struct));
|
|
|
|
reply_struct.upToDate = 0;
|
|
reply_struct.versionTag = tag;
|
|
{
|
|
capn_list8 sl = capn_new_list8(cs, (int)sizeof(sha256));
|
|
capn_setv8(sl, 0, sha256, (int)sizeof(sha256));
|
|
reply_struct.sha256.p = sl.p;
|
|
}
|
|
{
|
|
capn_list8 bl = capn_new_list8(cs, (int)sizeof(config));
|
|
capn_setv8(bl, 0, config, (int)sizeof(config));
|
|
reply_struct.bytes.p = bl.p;
|
|
}
|
|
|
|
reply_ptr = new_ReplConfigReply(cs);
|
|
write_ReplConfigReply(&reply_struct, reply_ptr);
|
|
|
|
rr.seq = seq;
|
|
rr.opcode = OPCode_replConfig;
|
|
rr.status = 0;
|
|
rr.data_which = RequestReply_data_replconfigreplymsg;
|
|
rr.data.replconfigreplymsg = reply_ptr;
|
|
|
|
rr_ptr = new_RequestReply(cs);
|
|
write_RequestReply(&rr, rr_ptr);
|
|
|
|
rq.opcode = OPCode_reply;
|
|
rq.seq = seq;
|
|
rq.body_which = Request_body_replymsg;
|
|
rq.body.replymsg = rr_ptr;
|
|
encode_PeerInfo_ptr(cs, &rq.from, NULL);
|
|
|
|
rp = new_Request(cs);
|
|
write_Request(&rq, rp);
|
|
capn_setp(capn_root(&c), 0, rp.p);
|
|
|
|
int64_t sz = capn_size(&c);
|
|
uint8_t *buf = (uint8_t *)malloc((size_t)sz);
|
|
capn_write_mem(&c, buf, (size_t)sz, 0);
|
|
capn_free(&c);
|
|
|
|
/* Decode */
|
|
struct capn d;
|
|
capn_init_mem(&d, buf, (size_t)sz, 0);
|
|
Request_ptr dp; dp.p = capn_getp(capn_root(&d), 0, 1);
|
|
struct Request drq;
|
|
read_Request(&drq, dp);
|
|
TEST_ASSERT_EQUAL_INT(Request_body_replymsg, drq.body_which);
|
|
|
|
struct RequestReply drr;
|
|
read_RequestReply(&drr, drq.body.replymsg);
|
|
TEST_ASSERT_EQUAL_INT(RequestReply_data_replconfigreplymsg,
|
|
drr.data_which);
|
|
|
|
struct ReplConfigReply drs;
|
|
read_ReplConfigReply(&drs, drr.data.replconfigreplymsg);
|
|
TEST_ASSERT_EQUAL_INT(0, drs.upToDate);
|
|
TEST_ASSERT_EQUAL_UINT64(tag, drs.versionTag);
|
|
|
|
capn_list8 ds; ds.p = drs.sha256.p;
|
|
capn_list8 db; db.p = drs.bytes.p;
|
|
TEST_ASSERT_EQUAL_INT(32, capn_len(ds));
|
|
TEST_ASSERT_EQUAL_INT(128, capn_len(db));
|
|
|
|
uint8_t got_sha[32];
|
|
uint8_t got_cfg[128];
|
|
TEST_ASSERT_EQUAL_INT(32, capn_getv8(ds, 0, got_sha, 32));
|
|
TEST_ASSERT_EQUAL_INT(128, capn_getv8(db, 0, got_cfg, 128));
|
|
TEST_ASSERT_EQUAL_INT(0, memcmp(got_sha, sha256, 32));
|
|
TEST_ASSERT_EQUAL_INT(0, memcmp(got_cfg, config, 128));
|
|
|
|
capn_free(&d);
|
|
free(buf);
|
|
}
|
|
|
|
printf("test_replconfig_reply_raw_encode: PASS\n");
|
|
}
|
|
|
|
/* Test 3: xce_upgrade_config_req_extract_raw — build a raw
|
|
* OP_upgradeConfig packet using the same list8 bypass pattern, then
|
|
* parse it back and verify sha256 + bytes are recovered intact. */
|
|
static void test_upgrade_config_req_extract_raw(void)
|
|
{
|
|
const uint8_t sha256[32] = {
|
|
0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,
|
|
0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,0x10,
|
|
0x11,0x12,0x13,0x14,0x15,0x16,0x17,0x18,
|
|
0x19,0x1a,0x1b,0x1c,0x1d,0x1e,0x1f,0x20 };
|
|
const uint8_t cfg[] = "fake-upgrade-config-blob-payload-data";
|
|
const size_t cfglen = sizeof(cfg) - 1;
|
|
|
|
/* Build a raw OP_upgradeConfig request packet */
|
|
struct capn c;
|
|
struct capn_segment *cs;
|
|
struct Request rq;
|
|
struct UpgradeConfigRequest u_struct;
|
|
UpgradeConfigRequest_ptr u_ptr;
|
|
Request_ptr rp;
|
|
|
|
capn_init_malloc(&c);
|
|
cs = capn_root(&c).seg;
|
|
memset(&rq, 0, sizeof(rq));
|
|
memset(&u_struct, 0, sizeof(u_struct));
|
|
|
|
{
|
|
capn_list8 sl = capn_new_list8(cs, (int)sizeof(sha256));
|
|
capn_setv8(sl, 0, sha256, (int)sizeof(sha256));
|
|
u_struct.sha256.p = sl.p;
|
|
}
|
|
{
|
|
capn_list8 bl = capn_new_list8(cs, (int)cfglen);
|
|
capn_setv8(bl, 0, cfg, (int)cfglen);
|
|
u_struct.bytes.p = bl.p;
|
|
}
|
|
|
|
u_ptr = new_UpgradeConfigRequest(cs);
|
|
write_UpgradeConfigRequest(&u_struct, u_ptr);
|
|
|
|
rq.opcode = OPCode_upgradeConfig;
|
|
rq.body_which = Request_body_upgradeconfigrequestmsg;
|
|
rq.body.upgradeconfigrequestmsg = u_ptr;
|
|
encode_PeerInfo_ptr(cs, &rq.from, NULL);
|
|
|
|
rp = new_Request(cs);
|
|
write_Request(&rq, rp);
|
|
capn_setp(capn_root(&c), 0, rp.p);
|
|
|
|
int64_t sz = capn_size(&c);
|
|
uint8_t *buf = (uint8_t *)malloc((size_t)sz);
|
|
capn_write_mem(&c, buf, (size_t)sz, 0);
|
|
capn_free(&c);
|
|
|
|
/* Extract via the function under test */
|
|
uint8_t *out_bytes = NULL;
|
|
uint8_t *out_sha256 = NULL;
|
|
size_t out_len = 0;
|
|
int rc = xce_upgrade_config_req_extract_raw(buf, (size_t)sz,
|
|
&out_bytes, &out_len,
|
|
&out_sha256);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
TEST_ASSERT_NOT_NULL(out_sha256);
|
|
TEST_ASSERT_NOT_NULL(out_bytes);
|
|
TEST_ASSERT_EQUAL_INT((int)cfglen, (int)out_len);
|
|
TEST_ASSERT_EQUAL_INT(0, memcmp(out_sha256, sha256, 32));
|
|
TEST_ASSERT_EQUAL_INT(0, memcmp(out_bytes, cfg, cfglen));
|
|
|
|
free(out_bytes);
|
|
free(out_sha256);
|
|
free(buf);
|
|
|
|
/* xce_upgrade_config_req_extract: returns -EINVAL on a zeroed request
|
|
* (body_which is 0, not Request_body_upgradeconfigrequestmsg). */
|
|
{
|
|
xce_request_t dummy_req;
|
|
memset(&dummy_req, 0, sizeof(dummy_req));
|
|
uint8_t *b = NULL, *s = NULL;
|
|
size_t l = 0;
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL,
|
|
xce_upgrade_config_req_extract(&dummy_req, &b, &l, &s));
|
|
}
|
|
|
|
printf("test_upgrade_config_req_extract_raw: PASS\n");
|
|
}
|
|
|
|
/* ── Task 2.1: OPCode_walCatchupEntry handler ────────────────────────────── */
|
|
|
|
#ifdef XCE_TESTING
|
|
|
|
/* Helpers shared by Task 2.1 tests: create/destroy a minimal persist_t. */
|
|
|
|
static persist_t *make_test_persist_catchup(char *tmpdir_out, size_t sz)
|
|
{
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
if (!p) { fprintf(stderr, "calloc failed\n"); exit(1); }
|
|
|
|
persist_config_t cfg = persist_config_defaults();
|
|
cfg.wal_sync_ms = 5000; /* no real fsync in unit tests */
|
|
|
|
snprintf(tmpdir_out, sz, "/tmp/xce_catchup_XXXXXX");
|
|
if (!mkdtemp(tmpdir_out)) {
|
|
fprintf(stderr, "mkdtemp failed\n");
|
|
free(p);
|
|
exit(1);
|
|
}
|
|
|
|
persist_set_workdir(p, tmpdir_out, "ctrl-catchup");
|
|
persist_init(p, &cfg, NULL);
|
|
persist_start(p);
|
|
return p;
|
|
}
|
|
|
|
static void destroy_test_persist_catchup(persist_t *p)
|
|
{
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
}
|
|
|
|
/* bug-850/851 contiguity guard (persist.c:839): persist_wal_write_replicated
|
|
* REFUSES a forward gap (entry.lsn > current_lsn → -EAGAIN) so a standby never
|
|
* creates a WAL hole. A fresh persist has current_lsn == 1 (LSN 0 reserved,
|
|
* persist.c:542). The catch-up tests below choose their own non-1 starting LSNs
|
|
* (42, 10, 20, …); seed current_lsn to the first applied entry's LSN so the
|
|
* first entry is contiguous (entry.lsn == current_lsn) and the guard accepts
|
|
* the in-order stream — mirroring the real catch-up streamer, which streams
|
|
* contiguously from current_lsn. current_lsn is a plain lock-protected field;
|
|
* persist.c sets it directly under p->lock (e.g. persist.c:542,715), so do the
|
|
* same here. */
|
|
static void seed_persist_current_lsn(persist_t *p, uint64_t next_lsn)
|
|
{
|
|
pthread_mutex_lock(&p->lock);
|
|
p->current_lsn = next_lsn;
|
|
pthread_mutex_unlock(&p->lock);
|
|
}
|
|
|
|
/* Test 2.1-A: successful single WAL entry advances applied_lsn */
|
|
static void test_wal_catchup_entry_advances_applied_lsn(void)
|
|
{
|
|
char tmpdir[256];
|
|
persist_t *p = make_test_persist_catchup(tmpdir, sizeof(tmpdir));
|
|
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
/* applied_lsn starts at 0 */
|
|
TEST_ASSERT_EQUAL_UINT64(0,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
/* Seed current_lsn so the entry at LSN 42 is contiguous (bug-850 guard). */
|
|
seed_persist_current_lsn(p, 42);
|
|
|
|
/* Build a single-entry WAL catch-up with LSN=42, no payload */
|
|
wal_catchup_entry_t entry;
|
|
memset(&entry, 0, sizeof(entry));
|
|
entry.lsn = 42;
|
|
entry.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
entry.payload = NULL; /* payload_len=0; apply_wal_entry handles NULL */
|
|
entry.n_entries = 0;
|
|
entry.entries = NULL;
|
|
|
|
/* GREEN will implement ha_apply_wal_catchup_entry() */
|
|
int rc = ha_apply_wal_catchup_entry(&ha, p, &entry, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
/* bug-991: applied_lsn is the LAST CONTIGUOUSLY-HELD LSN, so a single applied
|
|
* entry at LSN 42 advances applied_lsn to 42 (NOT 43). The +1 over-advertised
|
|
* the standby's watermark and made the leader's catch-up streamer skip the
|
|
* real next-needed LSN. */
|
|
TEST_ASSERT_EQUAL_UINT64(42,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
ha_fini(&ha);
|
|
destroy_test_persist_catchup(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_catchup_entry_advances_applied_lsn: PASS\n");
|
|
}
|
|
|
|
/* Test 2.1-B: apply_wal_entry failure (NULL persist = -EINVAL) is logged and
|
|
* skipped — applied_lsn must NOT advance, handler must not crash.
|
|
* Req 3.3: corrupt/error entries are logged and skipped without crashing. */
|
|
static void test_wal_catchup_entry_error_skipped_no_crash(void)
|
|
{
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
atomic_store_explicit(&ha.applied_lsn, 10, memory_order_release);
|
|
|
|
/* Pass NULL persist — apply_wal_entry returns -EINVAL for NULL p */
|
|
wal_catchup_entry_t entry;
|
|
memset(&entry, 0, sizeof(entry));
|
|
entry.lsn = 99;
|
|
entry.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
entry.payload = NULL;
|
|
entry.n_entries = 0;
|
|
entry.entries = NULL;
|
|
|
|
/* Handler must NOT crash; must log ERROR; applied_lsn must remain at 10 */
|
|
int rc = ha_apply_wal_catchup_entry(&ha, /*persist=*/NULL, &entry, NULL, NULL);
|
|
/* rc is non-zero (error propagated from apply_wal_entry(-EINVAL)) */
|
|
TEST_ASSERT_TRUE(rc != 0);
|
|
/* applied_lsn must not advance on failure */
|
|
TEST_ASSERT_EQUAL_UINT64(10,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
ha_fini(&ha);
|
|
printf("test_wal_catchup_entry_error_skipped_no_crash: PASS\n");
|
|
}
|
|
|
|
/* Test 2.1-C: NULL entry is handled gracefully (null guard) */
|
|
static void test_wal_catchup_entry_null_entry_no_crash(void)
|
|
{
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* NULL entry — must not crash, must return error */
|
|
int rc = ha_apply_wal_catchup_entry(&ha, NULL, NULL, NULL, NULL);
|
|
TEST_ASSERT_TRUE(rc != 0);
|
|
|
|
ha_fini(&ha);
|
|
printf("test_wal_catchup_entry_null_entry_no_crash: PASS\n");
|
|
}
|
|
|
|
/* ── Task 2.2: batch WAL catch-up entries on standby ─────────────────────── */
|
|
|
|
/* Test 2.2-A: batch of 3 entries — applied_lsn advances to last+1.
|
|
* Requirements 5.3, 5.5: apply each in order, advance after full batch. */
|
|
static void test_wal_catchup_batch_advances_applied_lsn(void)
|
|
{
|
|
char tmpdir[256];
|
|
persist_t *p = make_test_persist_catchup(tmpdir, sizeof(tmpdir));
|
|
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* applied_lsn starts at 0 */
|
|
TEST_ASSERT_EQUAL_UINT64(0,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
/* Seed current_lsn so the batch [10,11,12] is contiguous (bug-850 guard). */
|
|
seed_persist_current_lsn(p, 10);
|
|
|
|
/* Build 3 sub-entries at LSNs 10, 11, 12 */
|
|
wal_catchup_entry_t e0, e1, e2;
|
|
memset(&e0, 0, sizeof(e0));
|
|
memset(&e1, 0, sizeof(e1));
|
|
memset(&e2, 0, sizeof(e2));
|
|
e0.lsn = 10; e0.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
e1.lsn = 11; e1.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
e2.lsn = 12; e2.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
|
|
wal_catchup_entry_t *batch_entries[3] = { &e0, &e1, &e2 };
|
|
|
|
/* Outer entry carries the batch; lsn/entry_type ignored when n_entries > 0 */
|
|
wal_catchup_entry_t outer;
|
|
memset(&outer, 0, sizeof(outer));
|
|
outer.n_entries = 3;
|
|
outer.entries = batch_entries;
|
|
|
|
int rc = ha_apply_wal_catchup_entry(&ha, p, &outer, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* bug-991: applied_lsn = last contiguously-held LSN = last_entry->lsn = 12
|
|
* (NOT 13). */
|
|
TEST_ASSERT_EQUAL_UINT64(12,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
ha_fini(&ha);
|
|
destroy_test_persist_catchup(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_catchup_batch_advances_applied_lsn: PASS\n");
|
|
}
|
|
|
|
/* Test 2.2-B: partial batch (2 of max 3) — applied_lsn advances correctly.
|
|
* Requirement 5.4: partial batch (fewer than max) sent and applied without wait. */
|
|
static void test_wal_catchup_partial_batch(void)
|
|
{
|
|
char tmpdir[256];
|
|
persist_t *p = make_test_persist_catchup(tmpdir, sizeof(tmpdir));
|
|
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* Pre-set applied_lsn to 5 to verify monotonic advance */
|
|
atomic_store_explicit(&ha.applied_lsn, 5, memory_order_release);
|
|
|
|
/* Seed current_lsn so the batch [20,21] is contiguous (bug-850 guard). */
|
|
seed_persist_current_lsn(p, 20);
|
|
|
|
wal_catchup_entry_t e0, e1;
|
|
memset(&e0, 0, sizeof(e0));
|
|
memset(&e1, 0, sizeof(e1));
|
|
e0.lsn = 20; e0.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
e1.lsn = 21; e1.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
|
|
wal_catchup_entry_t *batch_entries[2] = { &e0, &e1 };
|
|
|
|
wal_catchup_entry_t outer;
|
|
memset(&outer, 0, sizeof(outer));
|
|
outer.n_entries = 2;
|
|
outer.entries = batch_entries;
|
|
|
|
int rc = ha_apply_wal_catchup_entry(&ha, p, &outer, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* bug-991: applied_lsn = last contiguously-held LSN = 21 (NOT 22). Monotonic:
|
|
* advances from the pre-set 5 to 21. */
|
|
TEST_ASSERT_EQUAL_UINT64(21,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
ha_fini(&ha);
|
|
destroy_test_persist_catchup(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_catchup_partial_batch: PASS\n");
|
|
}
|
|
|
|
/* Test 2.2-C: batch with one error entry — error is logged and skipped,
|
|
* remaining entries are still applied, applied_lsn advances for the good ones.
|
|
* Requirement 3.3/5.5: error in batch → skip that entry, continue with rest. */
|
|
static void test_wal_catchup_batch_error_entry_skipped(void)
|
|
{
|
|
char tmpdir[256];
|
|
persist_t *p = make_test_persist_catchup(tmpdir, sizeof(tmpdir));
|
|
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* Seed current_lsn so the first good entry at LSN 30 is contiguous (bug-850
|
|
* guard). The NULL sub-entry is skipped (no write, current_lsn stays at 31);
|
|
* the test only requires applied_lsn >= 30 (the first good entry held). */
|
|
seed_persist_current_lsn(p, 30);
|
|
|
|
/* Good entry at LSN 30, then a NULL pointer (simulates corrupt entry),
|
|
* then good entry at LSN 32. */
|
|
wal_catchup_entry_t e0, e2;
|
|
memset(&e0, 0, sizeof(e0));
|
|
memset(&e2, 0, sizeof(e2));
|
|
e0.lsn = 30; e0.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
e2.lsn = 32; e2.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
|
|
/* NULL in the middle represents a missing/corrupt sub-entry */
|
|
wal_catchup_entry_t *batch_entries[3] = { &e0, NULL, &e2 };
|
|
|
|
wal_catchup_entry_t outer;
|
|
memset(&outer, 0, sizeof(outer));
|
|
outer.n_entries = 3;
|
|
outer.entries = batch_entries;
|
|
|
|
/* Handler must not crash; applied_lsn advances for successful entries */
|
|
int rc = ha_apply_wal_catchup_entry(&ha, p, &outer, NULL, NULL);
|
|
/* Overall rc == 0: outer batch call succeeds even with inner skips */
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* bug-991: applied_lsn = last contiguously-held LSN. e0 (LSN 30) applies,
|
|
* the NULL sub-entry is skipped, e2 (LSN 32) applies → last-held is 32 (or at
|
|
* least 30 if e2 were dropped). Must reach at least 30 (e0 held). */
|
|
uint64_t final_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
TEST_ASSERT_TRUE(final_lsn >= 30);
|
|
|
|
ha_fini(&ha);
|
|
destroy_test_persist_catchup(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_catchup_batch_error_entry_skipped: PASS\n");
|
|
}
|
|
|
|
/* Test 2.2-D: n_entries==0 falls through to single-entry path (backward compat) */
|
|
static void test_wal_catchup_batch_zero_entries_fallback(void)
|
|
{
|
|
char tmpdir[256];
|
|
persist_t *p = make_test_persist_catchup(tmpdir, sizeof(tmpdir));
|
|
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* Seed current_lsn so the fallback single entry at LSN 50 is contiguous
|
|
* (bug-850 guard). */
|
|
seed_persist_current_lsn(p, 50);
|
|
|
|
/* n_entries == 0 → single-entry path from task 2.1 */
|
|
wal_catchup_entry_t entry;
|
|
memset(&entry, 0, sizeof(entry));
|
|
entry.lsn = 50;
|
|
entry.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
entry.n_entries = 0;
|
|
entry.entries = NULL;
|
|
|
|
int rc = ha_apply_wal_catchup_entry(&ha, p, &entry, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
/* bug-991: single-entry path advances applied_lsn to the last-held LSN = 50
|
|
* (NOT 51). */
|
|
TEST_ASSERT_EQUAL_UINT64(50,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
ha_fini(&ha);
|
|
destroy_test_persist_catchup(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_catchup_batch_zero_entries_fallback: PASS\n");
|
|
}
|
|
|
|
/* ── Task 6.2: additional WAL catch-up unit tests ───────────────────────── */
|
|
|
|
/* Test 6.2-A: batch of exactly 256 entries (the maximum batch size from the
|
|
* sender side, Requirement 5.2) — all applied, applied_lsn advances by 256.
|
|
* Tests: "apply batch of 256" from task 6.2 spec.
|
|
* Requirements 3.1, 3.5, 5.3, 5.5. */
|
|
static void test_wal_catchup_batch_256_entries(void)
|
|
{
|
|
char tmpdir[256];
|
|
persist_t *p = make_test_persist_catchup(tmpdir, sizeof(tmpdir));
|
|
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* Seed current_lsn so the consecutive batch starting at LSN 1000 is
|
|
* contiguous (bug-850 guard). */
|
|
seed_persist_current_lsn(p, 1000);
|
|
|
|
/* Allocate 256 sub-entries at consecutive LSNs starting at 1000 */
|
|
const int N = 256;
|
|
wal_catchup_entry_t entries[N];
|
|
wal_catchup_entry_t *ptrs[N];
|
|
|
|
for (int i = 0; i < N; i++) {
|
|
memset(&entries[i], 0, sizeof(entries[i]));
|
|
entries[i].lsn = (uint64_t)(1000 + i);
|
|
entries[i].entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
entries[i].n_entries = 0;
|
|
entries[i].entries = NULL;
|
|
ptrs[i] = &entries[i];
|
|
}
|
|
|
|
wal_catchup_entry_t outer;
|
|
memset(&outer, 0, sizeof(outer));
|
|
outer.n_entries = N;
|
|
outer.entries = ptrs;
|
|
|
|
/* applied_lsn starts at 0 */
|
|
TEST_ASSERT_EQUAL_UINT64(0,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
int rc = ha_apply_wal_catchup_entry(&ha, p, &outer, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* bug-991: applied_lsn = last contiguously-held LSN = last_entry->lsn =
|
|
* 1000+255 = 1255 (NOT 1256). */
|
|
TEST_ASSERT_EQUAL_UINT64(1255,
|
|
(uint64_t)atomic_load_explicit(&ha.applied_lsn, memory_order_acquire));
|
|
|
|
ha_fini(&ha);
|
|
destroy_test_persist_catchup(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_catchup_batch_256_entries: PASS\n");
|
|
}
|
|
|
|
/* Test 6.2-B: applied_lsn advances only AFTER the full batch is written, not
|
|
* during mid-batch entry application. This verifies Req 5.5: the standby's
|
|
* next heartbeat reply reflects the post-batch state, not a partial state.
|
|
*
|
|
* Strategy: start with a pre-set applied_lsn, apply a batch of 4 entries,
|
|
* verify that applied_lsn does NOT advance to an intermediate value and IS
|
|
* exactly the last contiguously-held LSN at the end (bug-991: last_lsn, not
|
|
* last_lsn+1). We confirm the "only-after-full-batch" invariant by checking the
|
|
* exact final value equals last_lsn (not some intermediate held LSN from a
|
|
* mid-batch advance).
|
|
* Requirements 5.3, 5.5. */
|
|
static void test_wal_catchup_batch_applied_lsn_only_after_full_batch(void)
|
|
{
|
|
char tmpdir[256];
|
|
persist_t *p = make_test_persist_catchup(tmpdir, sizeof(tmpdir));
|
|
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* Pre-set applied_lsn to a known value so we can distinguish partial
|
|
* from full advance. */
|
|
atomic_store_explicit(&ha.applied_lsn, 100, memory_order_release);
|
|
|
|
/* Seed current_lsn so the batch [200..203] is contiguous (bug-850 guard). */
|
|
seed_persist_current_lsn(p, 200);
|
|
|
|
/* Build a batch of 4 entries at LSNs 200, 201, 202, 203 */
|
|
wal_catchup_entry_t e0, e1, e2, e3;
|
|
memset(&e0, 0, sizeof(e0));
|
|
memset(&e1, 0, sizeof(e1));
|
|
memset(&e2, 0, sizeof(e2));
|
|
memset(&e3, 0, sizeof(e3));
|
|
e0.lsn = 200; e0.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
e1.lsn = 201; e1.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
e2.lsn = 202; e2.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
e3.lsn = 203; e3.entry_type = (uint8_t)WAL_JOB_UPDATE;
|
|
|
|
wal_catchup_entry_t *ptrs[4] = { &e0, &e1, &e2, &e3 };
|
|
|
|
wal_catchup_entry_t outer;
|
|
memset(&outer, 0, sizeof(outer));
|
|
outer.n_entries = 4;
|
|
outer.entries = ptrs;
|
|
|
|
int rc = ha_apply_wal_catchup_entry(&ha, p, &outer, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* bug-991: applied_lsn must be exactly 203 (the last contiguously-held LSN) —
|
|
* the post-batch value. The test for correctness is that the final value
|
|
* equals the LAST entry's lsn, not any intermediate held LSN.
|
|
* Specifically, it must NOT remain at 200 or 201 (partial advance). */
|
|
uint64_t final_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
/* Must have advanced past starting value (100) */
|
|
TEST_ASSERT_GREATER_THAN_UINT64(100, final_lsn);
|
|
/* Must equal the last-held LSN (203), not any intermediate value */
|
|
TEST_ASSERT_EQUAL_UINT64(203, final_lsn);
|
|
|
|
ha_fini(&ha);
|
|
destroy_test_persist_catchup(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_catchup_batch_applied_lsn_only_after_full_batch: PASS\n");
|
|
}
|
|
|
|
/* ── Task 2.3: OPCode_nop heartbeat reply carries stateReport ──────────── */
|
|
|
|
#ifdef XCE_TESTING
|
|
/* Test seam declared in cluster.c under #ifdef XCE_TESTING —
|
|
* fills *sr from ha->applied_lsn / myconf->config_version_tag,
|
|
* sets reply_out->data_which = stateReportMsg and reply_out->data.state_report = sr.
|
|
* Req 1.1, 1.3, 1.4. */
|
|
void ha_nop_reply_state_report(ha_state_t *ha, myconf_t *myconf,
|
|
state_report_t *sr, xce_reply_t *reply_out);
|
|
|
|
/* Test 2.3-A: nop reply carries stateReport with applied_lsn and config_tag.
|
|
* Requirements 1.1, 1.3, 1.4: standby populates stateReport in every
|
|
* OPCode_nop heartbeat reply with current applied_lsn and config_version_tag. */
|
|
static void test_nop_reply_carries_state_report(void)
|
|
{
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* Set a known applied_lsn */
|
|
atomic_store_explicit(&ha.applied_lsn, 12345ULL, memory_order_release);
|
|
|
|
/* Build a minimal myconf with a known config_version_tag */
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
myconf.config_version_tag = 0xABCD1234ULL;
|
|
|
|
state_report_t sr;
|
|
memset(&sr, 0, sizeof(sr));
|
|
xce_reply_t reply;
|
|
memset(&reply, 0, sizeof(reply));
|
|
|
|
ha_nop_reply_state_report(&ha, &myconf, &sr, &reply);
|
|
|
|
/* Reply must use stateReportMsg discriminant */
|
|
TEST_ASSERT_EQUAL_INT(RequestReply_data_stateReportMsg, reply.data_which);
|
|
|
|
/* state_report pointer must point at our sr */
|
|
TEST_ASSERT_EQUAL_PTR(&sr, reply.data.state_report);
|
|
|
|
/* applied_lsn must match ha->applied_lsn */
|
|
TEST_ASSERT_EQUAL_UINT64(12345ULL, sr.applied_lsn);
|
|
|
|
/* config_tag must match myconf->config_version_tag */
|
|
TEST_ASSERT_EQUAL_UINT64(0xABCD1234ULL, sr.config_tag);
|
|
|
|
ha_fini(&ha);
|
|
printf("test_nop_reply_carries_state_report: PASS\n");
|
|
}
|
|
|
|
/* Test 2.3-B: nop reply when applied_lsn==0 (fresh standby, no config).
|
|
* Requirement 1.4: config_tag reports zero when no config received yet. */
|
|
static void test_nop_reply_zero_state_fresh_standby(void)
|
|
{
|
|
ha_state_t ha;
|
|
memset(&ha, 0, sizeof(ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(&ha, &cfg, 0);
|
|
|
|
/* applied_lsn stays at 0; config_version_tag stays at 0 → no config yet */
|
|
myconf_t myconf;
|
|
memset(&myconf, 0, sizeof(myconf));
|
|
|
|
state_report_t sr;
|
|
memset(&sr, 0, sizeof(sr));
|
|
xce_reply_t reply;
|
|
memset(&reply, 0, sizeof(reply));
|
|
|
|
ha_nop_reply_state_report(&ha, &myconf, &sr, &reply);
|
|
|
|
TEST_ASSERT_EQUAL_INT(RequestReply_data_stateReportMsg, reply.data_which);
|
|
TEST_ASSERT_EQUAL_PTR(&sr, reply.data.state_report);
|
|
TEST_ASSERT_EQUAL_UINT64(0ULL, sr.applied_lsn);
|
|
TEST_ASSERT_EQUAL_UINT64(0ULL, sr.config_tag);
|
|
|
|
ha_fini(&ha);
|
|
printf("test_nop_reply_zero_state_fresh_standby: PASS\n");
|
|
}
|
|
#endif /* XCE_TESTING */
|
|
|
|
#endif /* XCE_TESTING */
|
|
|
|
/* ── Task 3.4: ha_stream_wal_to_standby batch WAL streaming ──────────────── */
|
|
|
|
#ifdef XCE_TESTING
|
|
|
|
/*
|
|
* xce_wal_stream_request_hook: test-only seam declared in config_repl.c.
|
|
* When non-NULL, ha_stream_wal_to_standby() calls this INSTEAD OF xce_request()
|
|
* for each batch message, allowing tests to inspect and count batches without
|
|
* a real endpoint.
|
|
*
|
|
* Signature: int hook(endpoint_t *ep, xce_request_t *req)
|
|
* ep — outbound endpoint (may be NULL in tests)
|
|
* req — the batch OPCode_walCatchupEntry request being sent
|
|
* Returns 0 on success (the hook owns no resources in the req).
|
|
*/
|
|
extern int (*xce_wal_stream_request_hook)(endpoint_t *ep, xce_request_t *req);
|
|
|
|
/* Helper: write N minimal WAL entries into a single segment file in wal_dir.
|
|
* Entries have consecutive LSNs starting at start_lsn.
|
|
* Returns 0 on success, -1 on error. */
|
|
static int write_test_wal_segment(const char *wal_dir, uint64_t start_lsn,
|
|
int n_entries)
|
|
{
|
|
char seg_path[4096];
|
|
snprintf(seg_path, sizeof(seg_path),
|
|
"%s/seg-%016" PRIx64 ".wal", wal_dir, start_lsn);
|
|
|
|
int fd = open(seg_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
|
if (fd < 0) return -1;
|
|
|
|
for (int i = 0; i < n_entries; i++) {
|
|
wal_entry_hdr_t hdr;
|
|
memset(&hdr, 0, sizeof(hdr));
|
|
hdr.lsn = start_lsn + (uint64_t)i;
|
|
hdr.type = WAL_JOB_UPDATE;
|
|
hdr.payload_len = 0;
|
|
hdr.term = 1;
|
|
ssize_t nw = write(fd, &hdr, sizeof(hdr));
|
|
if (nw != (ssize_t)sizeof(hdr)) { close(fd); return -1; }
|
|
}
|
|
close(fd);
|
|
return 0;
|
|
}
|
|
|
|
/* Batch-counting state for the hook */
|
|
static int g_wal_stream_batch_count = 0;
|
|
static int g_wal_stream_total_entries = 0;
|
|
static int g_wal_stream_last_n_entries = 0;
|
|
|
|
static int counting_wal_stream_hook(endpoint_t *ep, xce_request_t *req)
|
|
{
|
|
(void)ep;
|
|
if (!req || !req->body.walcatchupentrymsg) return 0;
|
|
wal_catchup_entry_t *batch = (wal_catchup_entry_t *)req->body.walcatchupentrymsg;
|
|
g_wal_stream_batch_count++;
|
|
g_wal_stream_last_n_entries = batch->n_entries;
|
|
g_wal_stream_total_entries += batch->n_entries;
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* Test 3.4-A: streaming 500 entries produces exactly 2 batches
|
|
* (batch 1: 256 entries, batch 2: 244 entries).
|
|
* Requirement 5.2: accumulate up to 256 entries per batch.
|
|
* Requirement 5.4: partial final batch is sent immediately.
|
|
*/
|
|
static void test_wal_stream_500_entries_two_batches(void)
|
|
{
|
|
/* Set up WAL directory with 500 consecutive entries starting at LSN 1 */
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_wal_stream_%d_%ld",
|
|
(int)getpid(), (long)time(NULL));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(tmpdir, 0700));
|
|
|
|
/* Write all 500 entries in a single segment starting at LSN 1 */
|
|
int rc = write_test_wal_segment(tmpdir, /*start_lsn=*/1, /*n_entries=*/500);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "Failed to write test WAL segment");
|
|
|
|
/* Install counting hook */
|
|
xce_wal_stream_request_hook = counting_wal_stream_hook;
|
|
g_wal_stream_batch_count = 0;
|
|
g_wal_stream_total_entries = 0;
|
|
g_wal_stream_last_n_entries = 0;
|
|
|
|
/* Stream entries [1..500]: standby_lsn=0, committed_lsn=500 */
|
|
rc = ha_stream_wal_to_standby(/*ep=*/NULL, tmpdir, /*p=*/NULL,
|
|
/*leader_term=*/7,
|
|
/*standby_lsn=*/0,
|
|
/*committed_lsn=*/500);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "ha_stream_wal_to_standby failed");
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(2, g_wal_stream_batch_count,
|
|
"500 entries must produce exactly 2 batches (256+244)");
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(500, g_wal_stream_total_entries,
|
|
"All 500 entries must be sent");
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(244, g_wal_stream_last_n_entries,
|
|
"Final batch must contain 244 entries (500 - 256)");
|
|
|
|
xce_wal_stream_request_hook = NULL;
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_stream_500_entries_two_batches: PASS\n");
|
|
}
|
|
|
|
/*
|
|
* Test 3.4-B: streaming fewer than 256 entries produces exactly 1 partial batch.
|
|
* Requirement 5.4: partial final batch sent immediately without waiting.
|
|
*/
|
|
static void test_wal_stream_partial_batch(void)
|
|
{
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_wal_stream_partial_%d_%ld",
|
|
(int)getpid(), (long)time(NULL));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(tmpdir, 0700));
|
|
|
|
/* Write 10 entries starting at LSN 5 */
|
|
int rc = write_test_wal_segment(tmpdir, /*start_lsn=*/5, /*n_entries=*/10);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
xce_wal_stream_request_hook = counting_wal_stream_hook;
|
|
g_wal_stream_batch_count = 0;
|
|
g_wal_stream_total_entries = 0;
|
|
g_wal_stream_last_n_entries = 0;
|
|
|
|
/* Stream entries [6..14]: standby_lsn=5, committed_lsn=14 */
|
|
rc = ha_stream_wal_to_standby(/*ep=*/NULL, tmpdir, /*p=*/NULL,
|
|
/*leader_term=*/7,
|
|
/*standby_lsn=*/5,
|
|
/*committed_lsn=*/14);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "ha_stream_wal_to_standby failed");
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_wal_stream_batch_count,
|
|
"< 256 entries must produce exactly 1 partial batch");
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(9, g_wal_stream_total_entries,
|
|
"9 entries in range [6..14] must be sent");
|
|
|
|
xce_wal_stream_request_hook = NULL;
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_stream_partial_batch: PASS\n");
|
|
}
|
|
|
|
/*
|
|
* Test 3.4-C: empty range (standby_lsn == committed_lsn) sends zero batches.
|
|
* Requirement 5.4: no batch is sent when there are no entries to stream.
|
|
*/
|
|
static void test_wal_stream_empty_range_no_batches(void)
|
|
{
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_wal_stream_empty_%d_%ld",
|
|
(int)getpid(), (long)time(NULL));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(tmpdir, 0700));
|
|
|
|
xce_wal_stream_request_hook = counting_wal_stream_hook;
|
|
g_wal_stream_batch_count = 0;
|
|
g_wal_stream_total_entries = 0;
|
|
|
|
/* standby_lsn == committed_lsn: nothing to stream */
|
|
int rc = ha_stream_wal_to_standby(/*ep=*/NULL, tmpdir, /*p=*/NULL,
|
|
/*leader_term=*/7,
|
|
/*standby_lsn=*/100,
|
|
/*committed_lsn=*/100);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "Empty range must return 0");
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_wal_stream_batch_count,
|
|
"Empty range must produce zero batches");
|
|
|
|
xce_wal_stream_request_hook = NULL;
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_stream_empty_range_no_batches: PASS\n");
|
|
}
|
|
|
|
/* Write N entries starting at start_lsn where entries[checkpoint_idx] (0-based
|
|
* within the range) is a WAL_CHECKPOINT marker (local-only, never a logical
|
|
* Raft entry — persist_write_checkpoint_marker consumes a real LSN slot for
|
|
* it, exactly like a live checkpoint firing mid-stream would). All other
|
|
* entries are WAL_JOB_UPDATE. */
|
|
static int write_test_wal_segment_with_checkpoint_at(const char *wal_dir,
|
|
uint64_t start_lsn,
|
|
int n_entries,
|
|
int checkpoint_idx)
|
|
{
|
|
char seg_path[4096];
|
|
snprintf(seg_path, sizeof(seg_path),
|
|
"%s/seg-%016" PRIx64 ".wal", wal_dir, start_lsn);
|
|
|
|
int fd = open(seg_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
|
if (fd < 0) return -1;
|
|
|
|
for (int i = 0; i < n_entries; i++) {
|
|
wal_entry_hdr_t hdr;
|
|
memset(&hdr, 0, sizeof(hdr));
|
|
hdr.lsn = start_lsn + (uint64_t)i;
|
|
hdr.type = (i == checkpoint_idx) ? WAL_CHECKPOINT : WAL_JOB_UPDATE;
|
|
hdr.payload_len = 0;
|
|
hdr.term = 1;
|
|
ssize_t nw = write(fd, &hdr, sizeof(hdr));
|
|
if (nw != (ssize_t)sizeof(hdr)) { close(fd); return -1; }
|
|
}
|
|
close(fd);
|
|
return 0;
|
|
}
|
|
|
|
/* Captures every streamed entry's lsn/entry_type/prev_log_index across all
|
|
* batches, in send order. */
|
|
#define CAP_CKPT_MAX 32
|
|
static uint64_t g_cap_ckpt_lsn[CAP_CKPT_MAX];
|
|
static uint8_t g_cap_ckpt_type[CAP_CKPT_MAX];
|
|
static uint64_t g_cap_ckpt_prev_idx[CAP_CKPT_MAX];
|
|
static int g_cap_ckpt_count = 0;
|
|
|
|
static int capture_all_entries_wal_stream_hook(endpoint_t *ep, xce_request_t *req)
|
|
{
|
|
(void)ep;
|
|
if (!req || !req->body.walcatchupentrymsg) return 0;
|
|
wal_catchup_entry_t *batch =
|
|
(wal_catchup_entry_t *)req->body.walcatchupentrymsg;
|
|
for (int i = 0; i < batch->n_entries && g_cap_ckpt_count < CAP_CKPT_MAX; i++) {
|
|
wal_catchup_entry_t *e = batch->entries[i];
|
|
if (!e) continue;
|
|
g_cap_ckpt_lsn[g_cap_ckpt_count] = e->lsn;
|
|
g_cap_ckpt_type[g_cap_ckpt_count] = e->entry_type;
|
|
g_cap_ckpt_prev_idx[g_cap_ckpt_count] = e->prev_log_index;
|
|
g_cap_ckpt_count++;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* The catch-up streamer must SKIP WAL_CHECKPOINT markers entirely — they are
|
|
* local-only bookkeeping (persist_write_checkpoint_marker's own doc comment:
|
|
* "replay_wal SKIPS checkpoint markers... the recovered index never holds a
|
|
* checkpoint LSN; the live index must match"), never a logical Raft log
|
|
* entry, and never sent on the live broadcast path either
|
|
* (persist_wal_write_replicated rejects type==WAL_CHECKPOINT with -EINVAL).
|
|
* A standby receiving one gets stuck retrying the same LSN forever.
|
|
*
|
|
* Fixture: LSN [1..5], LSN 3 is a checkpoint marker (consumes a real LSN slot,
|
|
* exactly like a live checkpoint firing mid-range). Streaming standby_lsn=0,
|
|
* committed_lsn=5 must emit exactly 4 real entries (1,2,4,5), never lsn=3, and
|
|
* the entry after the gap (lsn=4) must chain prev_log_index=2 (the last REAL
|
|
* entry before the gap), not 3.
|
|
*
|
|
* RED before the fix: the streamer builds/sends a catchup_entry for every
|
|
* hdr.type in range with no filter, so lsn=3/type=WAL_CHECKPOINT is included
|
|
* and lsn=4's prev_log_index is 3.
|
|
*/
|
|
static void test_wal_stream_skips_checkpoint_marker(void)
|
|
{
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_wal_stream_ckpt_%d_%ld",
|
|
(int)getpid(), (long)time(NULL));
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(tmpdir, 0700));
|
|
|
|
int rc = write_test_wal_segment_with_checkpoint_at(
|
|
tmpdir, /*start_lsn=*/1, /*n_entries=*/5, /*checkpoint_idx=*/2);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
xce_wal_stream_request_hook = capture_all_entries_wal_stream_hook;
|
|
g_cap_ckpt_count = 0;
|
|
|
|
rc = ha_stream_wal_to_standby(/*ep=*/NULL, tmpdir, /*p=*/NULL,
|
|
/*leader_term=*/7,
|
|
/*standby_lsn=*/0,
|
|
/*committed_lsn=*/5);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "ha_stream_wal_to_standby failed");
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(4, g_cap_ckpt_count,
|
|
"the checkpoint marker at lsn=3 must never be streamed as an entry "
|
|
"(4 real entries: 1,2,4,5)");
|
|
|
|
for (int i = 0; i < g_cap_ckpt_count; i++) {
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE(3, (int)g_cap_ckpt_lsn[i],
|
|
"lsn=3 (the checkpoint marker) must never appear in a streamed entry");
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE((int)WAL_CHECKPOINT, (int)g_cap_ckpt_type[i],
|
|
"no streamed entry may carry entry_type==WAL_CHECKPOINT");
|
|
}
|
|
|
|
/* Entries in order must be lsn 1,2,4,5 with lsn=4's prev_log_index=2
|
|
* (the last real entry before the checkpoint gap, not the marker's lsn 3). */
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(1, g_cap_ckpt_lsn[0], "entry 0 must be lsn=1");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(2, g_cap_ckpt_lsn[1], "entry 1 must be lsn=2");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(4, g_cap_ckpt_lsn[2], "entry 2 must be lsn=4 (lsn=3 skipped)");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(5, g_cap_ckpt_lsn[3], "entry 3 must be lsn=5");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(2, g_cap_ckpt_prev_idx[2],
|
|
"lsn=4 must chain prev_log_index=2 (the last real entry before the "
|
|
"checkpoint gap), not the skipped marker's lsn=3");
|
|
|
|
xce_wal_stream_request_hook = NULL;
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_wal_stream_skips_checkpoint_marker: PASS\n");
|
|
}
|
|
|
|
/*
|
|
* bug-1028 — the catch-up streamer must source each streamed entry's term and
|
|
* the AppendEntries prev_log_term from the AUTHORITATIVE per-LSN term index
|
|
* (persist_entry_term), NOT the raw on-disk hdr.term. A legacy-0 entry has
|
|
* hdr.term==0 on disk but persist_entry_term maps it to WAL_LEGACY_TERM(1) —
|
|
* which is exactly what the standby's logOk and the library send path compute.
|
|
* If the streamer sends the raw 0, prev_log_term(0) != standby's term(1) → logOk
|
|
* rejects the new-term entry forever (the "stream WAL batches" wedge).
|
|
*
|
|
* Fixture: LSN 1 appended at term 0 (legacy), LSN 2 at term 3. Streaming
|
|
* (standby_lsn=1, committed=2] must emit LSN 2 with term=3 and prev_log_term=1
|
|
* (NOT 0). RED before the fix (streamer sent hdr.term=0 for the predecessor).
|
|
*/
|
|
static uint64_t g_cap_term_for_lsn2 = 0;
|
|
static uint64_t g_cap_prevterm_for_lsn2 = 0;
|
|
static int g_cap_saw_lsn2 = 0;
|
|
|
|
static int capture_terms_wal_stream_hook(endpoint_t *ep, xce_request_t *req)
|
|
{
|
|
(void)ep;
|
|
if (!req || !req->body.walcatchupentrymsg) return 0;
|
|
wal_catchup_entry_t *batch =
|
|
(wal_catchup_entry_t *)req->body.walcatchupentrymsg;
|
|
for (int i = 0; i < batch->n_entries; i++) {
|
|
wal_catchup_entry_t *e = batch->entries[i];
|
|
if (e && e->lsn == 2) {
|
|
g_cap_saw_lsn2 = 1;
|
|
g_cap_term_for_lsn2 = e->term;
|
|
g_cap_prevterm_for_lsn2 = e->prev_log_term;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static void test_wal_stream_term_from_index_not_raw_hdr(void)
|
|
{
|
|
char tmp[256];
|
|
strcpy(tmp, "/tmp/xce_cr_streamterm_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
|
|
|
cluster_t *c = t42_make_cluster(tmp);
|
|
TEST_ASSERT_NOT_NULL(c);
|
|
|
|
/* LSN 1: legacy entry (term 0 on disk → persist_entry_term maps to 1). */
|
|
persist_set_term(&c->persist, 0);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(&c->persist, WAL_JOB_UPDATE,
|
|
NULL, 0));
|
|
/* LSN 2: real new-term entry (term 3). */
|
|
persist_set_term(&c->persist, 3);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(&c->persist, WAL_JOB_UPDATE,
|
|
NULL, 0));
|
|
|
|
/* Sanity: the authoritative index normalises legacy-0 → 1, keeps 3. */
|
|
TEST_ASSERT_EQUAL_UINT64(WAL_LEGACY_TERM, persist_entry_term(&c->persist, 1));
|
|
TEST_ASSERT_EQUAL_UINT64(3, persist_entry_term(&c->persist, 2));
|
|
|
|
xce_wal_stream_request_hook = capture_terms_wal_stream_hook;
|
|
g_cap_saw_lsn2 = 0; g_cap_term_for_lsn2 = 0; g_cap_prevterm_for_lsn2 = 0;
|
|
|
|
/* Standby holds LSN 1, catching up to committed 2 — streams just LSN 2,
|
|
* whose prev tuple references LSN 1 (the legacy entry). */
|
|
int rc = ha_stream_wal_to_standby(/*ep=*/NULL, c->persist.wal_path,
|
|
&c->persist,
|
|
/*leader_term=*/atomic_load_explicit(
|
|
&c->ha.term, memory_order_acquire),
|
|
/*standby_lsn=*/1, /*committed_lsn=*/2);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "ha_stream_wal_to_standby failed");
|
|
xce_wal_stream_request_hook = NULL;
|
|
|
|
TEST_ASSERT_TRUE_MESSAGE(g_cap_saw_lsn2, "LSN 2 must be streamed");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(3, g_cap_term_for_lsn2,
|
|
"streamed entry term must come from persist_entry_term (3)");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(WAL_LEGACY_TERM, g_cap_prevterm_for_lsn2,
|
|
"bug-1028: prev_log_term must be the index-normalised legacy term (1), "
|
|
"NOT raw on-disk hdr.term (0) — else the standby's logOk rejects forever");
|
|
|
|
t42_teardown(c);
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
|
|
system(cmd);
|
|
printf("test_wal_stream_term_from_index_not_raw_hdr: PASS\n");
|
|
}
|
|
|
|
/* ── Task 3.5: Snapshot chunk transfer ──────────────────────── */
|
|
|
|
static snapshot_transfer_t g_snaptest_chunks[16];
|
|
static int g_snaptest_chunk_count = 0;
|
|
|
|
static void snaptest_reset(void)
|
|
{
|
|
memset(g_snaptest_chunks, 0, sizeof(g_snaptest_chunks));
|
|
g_snaptest_chunk_count = 0;
|
|
}
|
|
|
|
static int snaptest_hook(endpoint_t *ep, xce_request_t *req)
|
|
{
|
|
(void)ep;
|
|
if (g_snaptest_chunk_count >= 16) return -1;
|
|
memcpy(&g_snaptest_chunks[g_snaptest_chunk_count],
|
|
req->body.snapshottransfermsg, sizeof(snapshot_transfer_t));
|
|
g_snaptest_chunk_count++;
|
|
return 0;
|
|
}
|
|
|
|
static void test_snapshot_transfer_two_chunks(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaptrans_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_path[512];
|
|
snprintf(snap_path, sizeof(snap_path), "%s/snap-0000000000000064.capnp", tmpdir);
|
|
FILE *f = fopen(snap_path, "wb");
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
char header[16] = "XCESNAP\0";
|
|
uint64_t lsn = 100;
|
|
memcpy(header + 8, &lsn, 8);
|
|
fwrite(header, 1, 16, f);
|
|
char zero[4096];
|
|
memset(zero, 0, sizeof(zero));
|
|
for (int i = 16; i < 131072; i += 4096)
|
|
fwrite(zero, 1, (i + 4096 > 131072) ? (size_t)(131072 - i) : 4096, f);
|
|
fclose(f);
|
|
|
|
extern int (*xce_snapshot_chunk_hook)(endpoint_t *, xce_request_t *);
|
|
xce_snapshot_chunk_hook = snaptest_hook;
|
|
snaptest_reset();
|
|
|
|
extern int ha_send_snapshot_to_standby(endpoint_t *, const char *,
|
|
uint64_t, uint64_t);
|
|
int rc = ha_send_snapshot_to_standby(NULL, tmpdir, lsn, 0);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
TEST_ASSERT_EQUAL_INT(2, g_snaptest_chunk_count);
|
|
TEST_ASSERT_EQUAL_UINT64(100, g_snaptest_chunks[0].snapshot_lsn);
|
|
TEST_ASSERT_EQUAL_UINT32(0, g_snaptest_chunks[0].chunk_index);
|
|
TEST_ASSERT_EQUAL_UINT64(0, g_snaptest_chunks[0].chunk_offset);
|
|
TEST_ASSERT_EQUAL_UINT64(131072, g_snaptest_chunks[0].total_size);
|
|
TEST_ASSERT_EQUAL_INT(0, g_snaptest_chunks[0].is_last);
|
|
TEST_ASSERT_EQUAL_UINT64(131072, g_snaptest_chunks[1].total_size);
|
|
TEST_ASSERT_EQUAL_UINT32(1, g_snaptest_chunks[1].chunk_index);
|
|
TEST_ASSERT_EQUAL_UINT64(65536, g_snaptest_chunks[1].chunk_offset);
|
|
TEST_ASSERT_EQUAL_INT(1, g_snaptest_chunks[1].is_last);
|
|
|
|
xce_snapshot_chunk_hook = NULL;
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_transfer_two_chunks: PASS\n");
|
|
}
|
|
|
|
static void test_snapshot_transfer_small_less_than_one_chunk(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaptrans_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_path[512];
|
|
snprintf(snap_path, sizeof(snap_path), "%s/snap-000000000000000a.capnp", tmpdir);
|
|
FILE *f = fopen(snap_path, "wb");
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
char buf[64];
|
|
memset(buf, 0, sizeof(buf));
|
|
memcpy(buf, "XCESNAP\0", 8);
|
|
uint64_t lsn = 10;
|
|
memcpy(buf + 8, &lsn, 8);
|
|
fwrite(buf, 1, sizeof(buf), f);
|
|
fclose(f);
|
|
|
|
extern int (*xce_snapshot_chunk_hook)(endpoint_t *, xce_request_t *);
|
|
xce_snapshot_chunk_hook = snaptest_hook;
|
|
snaptest_reset();
|
|
|
|
extern int ha_send_snapshot_to_standby(endpoint_t *, const char *,
|
|
uint64_t, uint64_t);
|
|
int rc = ha_send_snapshot_to_standby(NULL, tmpdir, lsn, 0);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
TEST_ASSERT_EQUAL_INT(1, g_snaptest_chunk_count);
|
|
TEST_ASSERT_EQUAL_UINT64(10, g_snaptest_chunks[0].snapshot_lsn);
|
|
TEST_ASSERT_EQUAL_UINT32(0, g_snaptest_chunks[0].chunk_index);
|
|
TEST_ASSERT_EQUAL_UINT64(64, g_snaptest_chunks[0].total_size);
|
|
TEST_ASSERT_EQUAL_INT(1, g_snaptest_chunks[0].is_last);
|
|
|
|
xce_snapshot_chunk_hook = NULL;
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_transfer_small_less_than_one_chunk: PASS\n");
|
|
}
|
|
|
|
/* bug-1386: a chunk send that fails mid-transfer (e.g. -ECONNRESET while the
|
|
* standby endpoint is mid-teardown/reconnect) must stop the loop and be
|
|
* propagated, not be silently swallowed while the function still reports
|
|
* "transfer complete". */
|
|
static int g_snaptest_fail_after_calls = -1; /* -1 = never fail */
|
|
|
|
static int snaptest_fail_after_hook(endpoint_t *ep, xce_request_t *req)
|
|
{
|
|
(void)ep;
|
|
if (g_snaptest_chunk_count >= 16) return -1;
|
|
memcpy(&g_snaptest_chunks[g_snaptest_chunk_count],
|
|
req->body.snapshottransfermsg, sizeof(snapshot_transfer_t));
|
|
g_snaptest_chunk_count++;
|
|
if (g_snaptest_fail_after_calls >= 0 &&
|
|
g_snaptest_chunk_count > g_snaptest_fail_after_calls)
|
|
return -ECONNRESET;
|
|
return 0;
|
|
}
|
|
|
|
static void test_snapshot_transfer_stops_and_propagates_send_failure(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaptrans_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
/* 3 chunks: 2 full 64KB chunks + a partial third, so a mid-stream
|
|
* failure on chunk 1 leaves chunk 2 (the would-be is_last chunk)
|
|
* unsent. */
|
|
char snap_path[512];
|
|
snprintf(snap_path, sizeof(snap_path), "%s/snap-00000000000000c8.capnp", tmpdir);
|
|
FILE *f = fopen(snap_path, "wb");
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
char header[16] = "XCESNAP\0";
|
|
uint64_t lsn = 200;
|
|
memcpy(header + 8, &lsn, 8);
|
|
fwrite(header, 1, 16, f);
|
|
char zero[4096];
|
|
memset(zero, 0, sizeof(zero));
|
|
size_t total = 65536 + 65536 + 4096;
|
|
for (size_t i = 16; i < total; i += 4096)
|
|
fwrite(zero, 1, (i + 4096 > total) ? (total - i) : 4096, f);
|
|
fclose(f);
|
|
|
|
extern int (*xce_snapshot_chunk_hook)(endpoint_t *, xce_request_t *);
|
|
xce_snapshot_chunk_hook = snaptest_fail_after_hook;
|
|
snaptest_reset();
|
|
g_snaptest_fail_after_calls = 1; /* chunk_index 0 succeeds, chunk_index 1 fails */
|
|
|
|
extern int ha_send_snapshot_to_standby(endpoint_t *, const char *,
|
|
uint64_t, uint64_t);
|
|
int rc = ha_send_snapshot_to_standby(NULL, tmpdir, lsn, 0);
|
|
|
|
TEST_ASSERT_EQUAL_INT(-ECONNRESET, rc);
|
|
/* Loop must stop right after the failing chunk — chunk_index 2 (the
|
|
* is_last chunk) must never be attempted. */
|
|
TEST_ASSERT_EQUAL_INT(2, g_snaptest_chunk_count);
|
|
TEST_ASSERT_EQUAL_INT(0, g_snaptest_chunks[1].is_last);
|
|
|
|
xce_snapshot_chunk_hook = NULL;
|
|
g_snaptest_fail_after_calls = -1;
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_transfer_stops_and_propagates_send_failure: PASS\n");
|
|
}
|
|
|
|
/* ── Task 3.5c: Standby snapshot receive handler ──────────────────────────── */
|
|
|
|
/* Helper: initialise a minimal ha_state_t for snapshot receive tests */
|
|
static void snap_recv_ha_init(ha_state_t *ha)
|
|
{
|
|
memset(ha, 0, sizeof(*ha));
|
|
ha_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
cfg.heartbeat_timeout_ms = 10000;
|
|
cfg.election_timeout_ms = 1500;
|
|
ha_init(ha, &cfg, 0);
|
|
}
|
|
|
|
/* Helper: write a minimal valid snapshot file to path (XCESNAP header + body) */
|
|
static void write_test_snapshot(const char *path, uint64_t lsn, size_t body_size)
|
|
{
|
|
FILE *f = fopen(path, "wb");
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(f, "Failed to create test snapshot file");
|
|
char header[16];
|
|
memcpy(header, "XCESNAP\0", 8);
|
|
memcpy(header + 8, &lsn, 8);
|
|
fwrite(header, 1, 16, f);
|
|
if (body_size > 16) {
|
|
char *zeros = calloc(1, body_size - 16);
|
|
fwrite(zeros, 1, body_size - 16, f);
|
|
free(zeros);
|
|
}
|
|
fclose(f);
|
|
}
|
|
|
|
/* Test 3.5c-A: receive 2 chunks, verify snap.part assembled and renamed correctly,
|
|
* applied_lsn set to checkpoint_lsn.
|
|
* Requirements: 4.2, 4.3 */
|
|
static void test_snapshot_recv_two_chunks_assembles_and_renames(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaprecv_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
/* Create snap_dir subdirectory (matches persist->snap_path layout) */
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
/* Build a fake 128-byte snapshot body (two 64-byte chunks).
|
|
* payload_bytes contains the raw file bytes for each chunk.
|
|
* We skip writing an actual snapshot source file — the receiver
|
|
* writes snap.part and renames it; we use the resulting file to verify.
|
|
*
|
|
* The body must be a WELL-FORMED entity-bearing checkpoint, not opaque
|
|
* filler (ha-entity-snapshots task 5.2): load_snapshot() now actually
|
|
* parses everything past the legacy header — a 0xAA/0xBB filler tail
|
|
* used to be silently ignored (load_snapshot only ever read the 57-byte
|
|
* header) but is now correctly rejected as an unrecognized
|
|
* format_version. Layout (design.md "Checkpoint File Layout"): the
|
|
* 57-byte legacy header (cfgref_present=0 at byte 16, so bytes 17-56 are
|
|
* unread and left zero) + format_version=2 + job_count=0 + queue_count=0
|
|
* (69 bytes total), padded with zero filler out to 128 bytes to keep the
|
|
* two-64-byte-chunk shape this test exercises — the padding is never
|
|
* read (load_snapshot_entities stops right after the queue_count=0
|
|
* loop), so its content is immaterial. */
|
|
uint64_t snap_lsn = 100;
|
|
size_t total_size = 128;
|
|
uint8_t body[128];
|
|
memset(body, 0, sizeof(body));
|
|
memcpy(body, "XCESNAP\0", 8);
|
|
memcpy(body + 8, &snap_lsn, 8);
|
|
/* body[16] (cfgref_present) left 0 by the memset above. */
|
|
uint32_t fake_fmt = 2u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION
|
|
* (config-upgrade-snapshot task 1 bumped 1->2) */
|
|
uint32_t fake_job_count = 0u;
|
|
uint32_t fake_queue_count = 0u;
|
|
memcpy(body + 57, &fake_fmt, sizeof(fake_fmt));
|
|
memcpy(body + 61, &fake_job_count, sizeof(fake_job_count));
|
|
memcpy(body + 65, &fake_queue_count, sizeof(fake_queue_count));
|
|
|
|
uint8_t chunk0[64];
|
|
uint8_t chunk1[64];
|
|
memcpy(chunk0, body, 64);
|
|
memcpy(chunk1, body + 64, 64);
|
|
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
TEST_ASSERT_NOT_NULL(p);
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
persist_set_workdir(p, tmpdir, "ctrl-snaprecv");
|
|
persist_init(p, &pcfg, NULL);
|
|
persist_start(p);
|
|
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
/* Chunk 0 */
|
|
snapshot_transfer_t c0;
|
|
memset(&c0, 0, sizeof(c0));
|
|
c0.snapshot_lsn = snap_lsn;
|
|
c0.chunk_index = 0;
|
|
c0.chunk_offset = 0;
|
|
c0.total_size = (uint64_t)total_size;
|
|
c0.is_last = 0;
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0, chunk0, 64);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* snap.part must exist after first chunk */
|
|
char part_path[600];
|
|
snprintf(part_path, sizeof(part_path), "%s/snap.part", snap_dir);
|
|
struct stat st;
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(part_path, &st),
|
|
"snap.part should exist after chunk 0");
|
|
TEST_ASSERT_EQUAL_UINT64(64, (uint64_t)st.st_size);
|
|
|
|
/* Chunk 1 (isLast=true) */
|
|
snapshot_transfer_t c1;
|
|
memset(&c1, 0, sizeof(c1));
|
|
c1.snapshot_lsn = snap_lsn;
|
|
c1.chunk_index = 1;
|
|
c1.chunk_offset = 64;
|
|
c1.total_size = (uint64_t)total_size;
|
|
c1.is_last = 1;
|
|
|
|
rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c1, chunk1, 64);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* snap.part must be gone (renamed) */
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, stat(part_path, &st),
|
|
"snap.part should be gone after isLast");
|
|
|
|
/* Final file snap-{lsn:016x}.bin must exist */
|
|
char final_path[600];
|
|
snprintf(final_path, sizeof(final_path),
|
|
"%s/snap-%016" PRIx64 ".bin", snap_dir, snap_lsn);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(final_path, &st),
|
|
"snap-{lsn}.bin should exist after isLast");
|
|
TEST_ASSERT_EQUAL_UINT64((uint64_t)total_size, (uint64_t)st.st_size);
|
|
|
|
/* applied_lsn must be set to checkpoint_lsn (= snap_lsn = 100) */
|
|
uint64_t got_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
TEST_ASSERT_EQUAL_UINT64(snap_lsn, got_lsn);
|
|
|
|
ha_fini(&ha);
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_recv_two_chunks_assembles_and_renames: PASS\n");
|
|
}
|
|
|
|
/* config-upgrade-snapshot task 1 (Req 1.9, 5.7): SNAPSHOT_FORMAT_VERSION was
|
|
* bumped 1->2 to reserve the version for the HostAdminState/QueueAdminState
|
|
* capnp migration of the host/queue admin-state frames. A checkpoint written
|
|
* under the PRIOR format_version (1 -- the raw queue-frame layout this spec
|
|
* migrates off of) must be hard-rejected fail-loud by a binary built against
|
|
* the bumped SNAPSHOT_FORMAT_VERSION=2, never silently reinterpreted (Design
|
|
* Invariant 4; persist.c:2541's existing `fmt != SNAPSHOT_FORMAT_VERSION`
|
|
* check, unchanged logic, new constant). Same well-formed-except-for-
|
|
* format-version fixture shape as
|
|
* test_snapshot_recv_two_chunks_assembles_and_renames above, but the
|
|
* embedded format_version is pinned to the literal PRIOR value (1)
|
|
* regardless of whatever persist.c's SNAPSHOT_FORMAT_VERSION currently is —
|
|
* this is exactly the "old on-disk checkpoint, new binary" skew the version
|
|
* bump must reject. */
|
|
static void test_snapshot_recv_old_format_version_rejected_fail_loud(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaprecv_oldfmt_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
uint64_t snap_lsn = 200;
|
|
size_t total_size = 128;
|
|
uint8_t body[128];
|
|
memset(body, 0, sizeof(body));
|
|
memcpy(body, "XCESNAP\0", 8);
|
|
memcpy(body + 8, &snap_lsn, 8);
|
|
/* body[16] (cfgref_present) left 0 by the memset above. */
|
|
uint32_t old_fmt = 1u; /* the pre-migration raw queue-frame layout --
|
|
* deliberately NOT persist.c's current
|
|
* SNAPSHOT_FORMAT_VERSION */
|
|
uint32_t fake_job_count = 0u;
|
|
uint32_t fake_queue_count = 0u;
|
|
memcpy(body + 57, &old_fmt, sizeof(old_fmt));
|
|
memcpy(body + 61, &fake_job_count, sizeof(fake_job_count));
|
|
memcpy(body + 65, &fake_queue_count, sizeof(fake_queue_count));
|
|
|
|
uint8_t chunk0[64];
|
|
uint8_t chunk1[64];
|
|
memcpy(chunk0, body, 64);
|
|
memcpy(chunk1, body + 64, 64);
|
|
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
TEST_ASSERT_NOT_NULL(p);
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
persist_set_workdir(p, tmpdir, "ctrl-snaprecv-oldfmt");
|
|
persist_init(p, &pcfg, NULL);
|
|
persist_start(p);
|
|
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
/* Chunk 0 — assembling snap.part never itself parses the body, so this
|
|
* must still succeed regardless of what format_version the bytes carry. */
|
|
snapshot_transfer_t c0;
|
|
memset(&c0, 0, sizeof(c0));
|
|
c0.snapshot_lsn = snap_lsn;
|
|
c0.chunk_index = 0;
|
|
c0.chunk_offset = 0;
|
|
c0.total_size = (uint64_t)total_size;
|
|
c0.is_last = 0;
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0, chunk0, 64);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "assembling a chunk never parses the body");
|
|
|
|
/* Chunk 1 (isLast=true) triggers rename + persist_load_snapshot — this is
|
|
* where the format-version check fires and must fail loud. */
|
|
snapshot_transfer_t c1;
|
|
memset(&c1, 0, sizeof(c1));
|
|
c1.snapshot_lsn = snap_lsn;
|
|
c1.chunk_index = 1;
|
|
c1.chunk_offset = 64;
|
|
c1.total_size = (uint64_t)total_size;
|
|
c1.is_last = 1;
|
|
|
|
rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c1, chunk1, 64);
|
|
TEST_ASSERT_TRUE_MESSAGE(rc != 0,
|
|
"a checkpoint encoded under the prior SNAPSHOT_FORMAT_VERSION must be "
|
|
"rejected fail-loud by a binary built against the bumped version, "
|
|
"never silently reinterpreted (Requirement 5.7)");
|
|
|
|
/* A rejected load must never advance applied_lsn (no partial/silent
|
|
* acceptance of mismatched-format bytes). */
|
|
uint64_t got_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(0, got_lsn,
|
|
"a rejected format-mismatched checkpoint must never advance applied_lsn");
|
|
|
|
ha_fini(&ha);
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
|
|
char cmd2[512];
|
|
snprintf(cmd2, sizeof(cmd2), "rm -rf %s", tmpdir);
|
|
system(cmd2);
|
|
printf("test_snapshot_recv_old_format_version_rejected_fail_loud: PASS\n");
|
|
}
|
|
|
|
/* Test 6.1: a legacy (pre-ha-entity-snapshots) position-only snapshot —
|
|
* exactly the 16-byte XCESNAP magic+lsn header, no entity payload — driven
|
|
* through the real receive handler end-to-end. persist_load_snapshot's
|
|
* entities_restored out-param comes back false (design.md "Checkpoint File
|
|
* Layout": "does more data exist" is the legacy discriminator), and
|
|
* ha_handle_snapshot_chunk must still: (a) return rc==0 (a legacy file is
|
|
* not a failure, Requirement 2.4/5.1/5.2), and (b) advance ha->applied_lsn
|
|
* exactly as it does for an entity-bearing file (Requirement 4.1/4.2 —
|
|
* the applied_lsn gating is unchanged by task 6.1; only a new informational
|
|
* log line is added on this path).
|
|
* Requirements: 2.4, 4.1, 4.2, 5.1, 5.2 */
|
|
static void test_snapshot_recv_legacy_file_advances_without_entities(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaprecv_legacy_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
/* Legacy body: magic(8) + checkpoint_lsn(8), nothing else — the file
|
|
* ends exactly at the legacy header, so load_snapshot's "does more data
|
|
* exist" discriminator reports this as legacy (entities_restored=false)
|
|
* rather than corrupt or entity-bearing. */
|
|
uint64_t snap_lsn = 300;
|
|
uint8_t body[16];
|
|
memcpy(body, "XCESNAP\0", 8);
|
|
memcpy(body + 8, &snap_lsn, 8);
|
|
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
TEST_ASSERT_NOT_NULL(p);
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
persist_set_workdir(p, tmpdir, "ctrl-snaprecv-legacy");
|
|
persist_init(p, &pcfg, NULL);
|
|
persist_start(p);
|
|
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
snapshot_transfer_t c0;
|
|
memset(&c0, 0, sizeof(c0));
|
|
c0.snapshot_lsn = snap_lsn;
|
|
c0.chunk_index = 0;
|
|
c0.chunk_offset = 0;
|
|
c0.total_size = sizeof(body);
|
|
c0.is_last = 1;
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0, body, sizeof(body));
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
|
|
"a legacy position-only checkpoint must not be treated as a failure");
|
|
|
|
/* Final file must exist (rename still happens on the legacy path) */
|
|
char final_path[600];
|
|
snprintf(final_path, sizeof(final_path),
|
|
"%s/snap-%016" PRIx64 ".bin", snap_dir, snap_lsn);
|
|
struct stat st;
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(final_path, &st),
|
|
"snap-{lsn}.bin should exist after isLast");
|
|
|
|
/* applied_lsn advances to the legacy file's recorded position — same
|
|
* rc-gates-applied_lsn ordering as the entity-bearing path, unchanged by
|
|
* task 6.1 (Requirement 4.1, 4.2). */
|
|
uint64_t got_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
TEST_ASSERT_EQUAL_UINT64(snap_lsn, got_lsn);
|
|
|
|
ha_fini(&ha);
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_recv_legacy_file_advances_without_entities: PASS\n");
|
|
}
|
|
|
|
/* Test 3.5c-B: size mismatch on isLast returns error (verify size == total_size).
|
|
* Requirements: 4.3 */
|
|
static void test_snapshot_recv_size_mismatch_fails(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaprecv2_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
TEST_ASSERT_NOT_NULL(p);
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
persist_set_workdir(p, tmpdir, "ctrl-snaprecv2");
|
|
persist_init(p, &pcfg, NULL);
|
|
persist_start(p);
|
|
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
uint64_t snap_lsn = 200;
|
|
uint8_t chunk0[64];
|
|
memset(chunk0, 0xCC, 64);
|
|
|
|
/* Claim total_size=200 but only send 64 bytes with is_last=true */
|
|
snapshot_transfer_t c0;
|
|
memset(&c0, 0, sizeof(c0));
|
|
c0.snapshot_lsn = snap_lsn;
|
|
c0.chunk_index = 0;
|
|
c0.chunk_offset = 0;
|
|
c0.total_size = 200; /* mismatch — only 64 bytes will be written */
|
|
c0.is_last = 1;
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0, chunk0, 64);
|
|
/* Must fail — size mismatch */
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, rc, "size mismatch must return error");
|
|
|
|
/* applied_lsn must remain 0 (not advanced on failure) */
|
|
uint64_t got_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
TEST_ASSERT_EQUAL_UINT64(0, got_lsn);
|
|
|
|
ha_fini(&ha);
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_recv_size_mismatch_fails: PASS\n");
|
|
}
|
|
|
|
/* Test 3.5c-C: NULL snap_dir or NULL chunk returns error gracefully */
|
|
static void test_snapshot_recv_null_args_fail(void)
|
|
{
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, NULL, NULL, NULL, NULL, 0);
|
|
TEST_ASSERT_NOT_EQUAL(0, rc);
|
|
|
|
ha_fini(&ha);
|
|
printf("test_snapshot_recv_null_args_fail: PASS\n");
|
|
}
|
|
|
|
/* Test 6.4-A: 128KB snapshot in 2 equal chunks (65536 bytes each) — standby
|
|
* receives both chunks, verifies total_size matches, snap.part is atomically
|
|
* renamed to snap-{lsn}.bin, persist_load_snapshot is called successfully,
|
|
* and applied_lsn equals the checkpoint_lsn embedded in the snapshot.
|
|
*
|
|
* This is the primary task-6.4 coverage test for Req 4.2, 4.3.
|
|
* Uses the same 65536-byte chunk size as the sender test
|
|
* (test_snapshot_transfer_two_chunks) to exercise the full receive path. */
|
|
static void test_snapshot_recv_128kb_two_chunks(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snap128kb_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
TEST_ASSERT_NOT_NULL(p);
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
persist_set_workdir(p, tmpdir, "ctrl-snap128kb");
|
|
persist_init(p, &pcfg, NULL);
|
|
persist_start(p);
|
|
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
/* 128KB snapshot split into two 65536-byte chunks */
|
|
const uint64_t snap_lsn = 0x64; /* LSN 100 — matches sender test */
|
|
const size_t chunk_size = 65536;
|
|
const size_t total_size = 131072; /* 128 * 1024 */
|
|
|
|
/* Allocate and zero-fill chunks */
|
|
uint8_t *chunk0 = calloc(1, chunk_size);
|
|
uint8_t *chunk1 = calloc(1, chunk_size);
|
|
TEST_ASSERT_NOT_NULL(chunk0);
|
|
TEST_ASSERT_NOT_NULL(chunk1);
|
|
|
|
/* Embed valid XCESNAP header in chunk0 so persist_load_snapshot succeeds.
|
|
* chunk0/chunk1 are otherwise zero-filled, but a well-formed entity-
|
|
* bearing checkpoint (ha-entity-snapshots task 5.2) needs format_version
|
|
* = SNAPSHOT_FORMAT_VERSION right after the 57-byte legacy header, or
|
|
* load_snapshot() correctly rejects an all-zero format_version as
|
|
* unrecognized rather than silently ignoring the body as before. The
|
|
* job_count=0/queue_count=0 that follow are already satisfied by the
|
|
* zero-fill; the rest of the 128KB body is never read (load_snapshot
|
|
* stops right after the queue_count=0 loop) so it stays harmless
|
|
* filler. */
|
|
memcpy(chunk0, "XCESNAP\0", 8);
|
|
memcpy(chunk0 + 8, &snap_lsn, 8);
|
|
/* chunk0[16] (cfgref_present) already 0 from calloc. */
|
|
const uint32_t fake_fmt = 2u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION
|
|
* (config-upgrade-snapshot task 1 bumped 1->2) */
|
|
memcpy(chunk0 + 57, &fake_fmt, sizeof(fake_fmt));
|
|
|
|
/* ── Chunk 0: not last ── */
|
|
snapshot_transfer_t c0;
|
|
memset(&c0, 0, sizeof(c0));
|
|
c0.snapshot_lsn = snap_lsn;
|
|
c0.chunk_index = 0;
|
|
c0.chunk_offset = 0;
|
|
c0.total_size = (uint64_t)total_size;
|
|
c0.is_last = 0;
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0, chunk0, chunk_size);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "chunk 0 must succeed");
|
|
|
|
/* snap.part must exist and be exactly chunk_size bytes */
|
|
char part_path[600];
|
|
snprintf(part_path, sizeof(part_path), "%s/snap.part", snap_dir);
|
|
struct stat st;
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(part_path, &st),
|
|
"snap.part must exist after chunk 0");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE((uint64_t)chunk_size, (uint64_t)st.st_size,
|
|
"snap.part must be chunk_size bytes after chunk 0");
|
|
|
|
/* ── Chunk 1: isLast=true ── */
|
|
snapshot_transfer_t c1;
|
|
memset(&c1, 0, sizeof(c1));
|
|
c1.snapshot_lsn = snap_lsn;
|
|
c1.chunk_index = 1;
|
|
c1.chunk_offset = (uint64_t)chunk_size;
|
|
c1.total_size = (uint64_t)total_size;
|
|
c1.is_last = 1;
|
|
|
|
rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c1, chunk1, chunk_size);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "chunk 1 (isLast) must succeed");
|
|
|
|
/* snap.part must be gone (atomically renamed) */
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, stat(part_path, &st),
|
|
"snap.part must be gone after isLast rename");
|
|
|
|
/* Final file snap-{lsn:016x}.bin must exist with total_size bytes */
|
|
char final_path[600];
|
|
snprintf(final_path, sizeof(final_path),
|
|
"%s/snap-%016" PRIx64 ".bin", snap_dir, snap_lsn);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(final_path, &st),
|
|
"snap-{lsn}.bin must exist after isLast");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE((uint64_t)total_size, (uint64_t)st.st_size,
|
|
"snap-{lsn}.bin must be exactly total_size bytes");
|
|
|
|
/* applied_lsn must equal checkpoint_lsn (snap_lsn) after successful load */
|
|
uint64_t got_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(snap_lsn, got_lsn,
|
|
"applied_lsn must equal checkpoint_lsn after load");
|
|
|
|
free(chunk0);
|
|
free(chunk1);
|
|
ha_fini(&ha);
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_recv_128kb_two_chunks: PASS\n");
|
|
}
|
|
|
|
/* bug-1389: a live --force-snapshot soak observed the vtask pool dispatching
|
|
* a 12-chunk transfer's chunks out of chunk_index order — chunk 2 was
|
|
* processed AFTER chunks 3 and 4 (arrival order 0,1,3,4,2,5,6,7,8,9,10,11).
|
|
* Live crc32 instrumentation proved every chunk's CONTENT survived the wire
|
|
* intact; the bug was that ha_handle_snapshot_chunk appended bytes in
|
|
* ARRIVAL order (fopen "ab") instead of writing each chunk at its own
|
|
* chunk_offset, so chunks 2/3/4 landed at the wrong file positions —
|
|
* same total size, wrong content, exactly matching the corruption the soak
|
|
* saw. This test replays that exact arrival order and asserts the
|
|
* assembled file is byte-identical to the correctly-ordered source,
|
|
* regardless of the order ha_handle_snapshot_chunk was called in. */
|
|
static void test_snapshot_recv_out_of_order_chunks_assemble_correctly(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snaprecv3_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
/* 4 chunks of 64 bytes each = 256-byte body. Must be a WELL-FORMED
|
|
* entity-bearing checkpoint (same reason as
|
|
* test_snapshot_recv_two_chunks_assembles_and_renames above): legacy
|
|
* 57-byte header (cfgref_present=0) + format_version=2 + job_count=0 +
|
|
* queue_count=0 + host_count=0 (config-upgrade-snapshot task 4 --
|
|
* load_snapshot_entities now reads a host section after the queue
|
|
* section, so it must likewise be pinned to a well-formed 0 here rather
|
|
* than left as position-dependent filler), then filler out to 256
|
|
* bytes. The filler is position-dependent (distinct per source byte,
|
|
* not per chunk) so ANY chunk landing at the wrong file offset is
|
|
* immediately detectable via a whole-buffer memcmp, regardless of
|
|
* which physical chunk a given structural field happens to fall in --
|
|
* except for the 4 structural-field byte ranges explicitly overlaid
|
|
* below (fmt/job_count/queue_count/host_count), which must stay
|
|
* well-formed for the load to succeed at all. */
|
|
uint64_t snap_lsn = 400;
|
|
size_t total_size = 256;
|
|
uint8_t body[256];
|
|
memset(body, 0, sizeof(body));
|
|
memcpy(body, "XCESNAP\0", 8);
|
|
memcpy(body + 8, &snap_lsn, 8);
|
|
for (int i = 16; i < 256; i++)
|
|
body[i] = (uint8_t)(i * 3 + 7); /* position-dependent, non-repeating */
|
|
uint32_t fake_fmt = 2u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION
|
|
* (config-upgrade-snapshot task 1 bumped 1->2) */
|
|
uint32_t fake_job_count = 0u;
|
|
uint32_t fake_queue_count = 0u;
|
|
uint32_t fake_host_count = 0u;
|
|
memcpy(body + 57, &fake_fmt, sizeof(fake_fmt));
|
|
memcpy(body + 61, &fake_job_count, sizeof(fake_job_count));
|
|
memcpy(body + 65, &fake_queue_count, sizeof(fake_queue_count));
|
|
memcpy(body + 69, &fake_host_count, sizeof(fake_host_count));
|
|
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
TEST_ASSERT_NOT_NULL(p);
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
persist_set_workdir(p, tmpdir, "ctrl-snaprecv3");
|
|
persist_init(p, &pcfg, NULL);
|
|
persist_start(p);
|
|
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
/* Deliver out of chunk_index order: 0, 2, 1, 3 (isLast). */
|
|
int order[4] = { 0, 2, 1, 3 };
|
|
for (int oi = 0; oi < 4; oi++) {
|
|
int idx = order[oi];
|
|
snapshot_transfer_t c;
|
|
memset(&c, 0, sizeof(c));
|
|
c.snapshot_lsn = snap_lsn;
|
|
c.chunk_index = (uint32_t)idx;
|
|
c.chunk_offset = (uint64_t)(idx * 64);
|
|
c.total_size = (uint64_t)total_size;
|
|
c.is_last = (idx == 3);
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c, body + idx * 64, 64);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
}
|
|
|
|
/* Final file must exist (finalize triggers once all 4 chunks are in,
|
|
* regardless of the delivery order above) and be byte-identical to the
|
|
* correctly-ordered source body — proves each chunk landed at its own
|
|
* chunk_offset, not at the position it happened to arrive in. */
|
|
char final_path[600];
|
|
snprintf(final_path, sizeof(final_path),
|
|
"%s/snap-%016" PRIx64 ".bin", snap_dir, snap_lsn);
|
|
struct stat st;
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(final_path, &st),
|
|
"snap-{lsn}.bin should exist once all "
|
|
"chunks are in, regardless of arrival order");
|
|
TEST_ASSERT_EQUAL_UINT64((uint64_t)total_size, (uint64_t)st.st_size);
|
|
|
|
FILE *f = fopen(final_path, "rb");
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
uint8_t got[256];
|
|
TEST_ASSERT_EQUAL_size_t(256, fread(got, 1, 256, f));
|
|
fclose(f);
|
|
TEST_ASSERT_EQUAL_MEMORY_MESSAGE(body, got, 256,
|
|
"assembled file must be byte-identical to the source despite "
|
|
"out-of-chunk_index-order delivery (bug-1389)");
|
|
|
|
uint64_t got_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
|
|
memory_order_acquire);
|
|
TEST_ASSERT_EQUAL_UINT64(snap_lsn, got_lsn);
|
|
|
|
ha_fini(&ha);
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_recv_out_of_order_chunks_assemble_correctly: PASS\n");
|
|
}
|
|
|
|
/* Test 3.5d: reconnected standby discards snap.part and restarts from chunk 0.
|
|
*
|
|
* Design §Snapshot Transfer: "Mid-transfer reconnect always restarts snapshot
|
|
* from chunk 0 — the standby discards its partial snap.part file on reconnect."
|
|
* The discard (unlink) is done by the ctrlhello handler before the first chunk
|
|
* of a new transfer arrives. This test verifies that:
|
|
* 1. After receiving chunk 0 (creating snap.part), if the caller unlinks
|
|
* snap.part (simulating reconnect/reset) and then calls ha_handle_snapshot_chunk
|
|
* again with chunk_index=0, the function creates a fresh snap.part
|
|
* (not appending to a previous stale file).
|
|
* 2. The resulting snap.part contains only the new chunk's bytes (not a
|
|
* concatenation of old + new data).
|
|
*
|
|
* Requirement: 4.4 (ha-repl-hardening) — retry by reconnecting initiates new transfer.
|
|
*/
|
|
static void test_snapshot_recv_reconnect_discards_snappart(void)
|
|
{
|
|
char tmpdir[] = "/tmp/xcectest_snapreconn_XXXXXX";
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
char snap_dir[512];
|
|
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
|
|
|
|
persist_t *p = calloc(1, sizeof(persist_t));
|
|
TEST_ASSERT_NOT_NULL(p);
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
persist_set_workdir(p, tmpdir, "ctrl-snapreconn");
|
|
persist_init(p, &pcfg, NULL);
|
|
persist_start(p);
|
|
|
|
ha_state_t ha;
|
|
snap_recv_ha_init(&ha);
|
|
|
|
uint64_t snap_lsn = 300;
|
|
uint8_t first[64];
|
|
uint8_t second[64];
|
|
memset(first, 0xAA, 64);
|
|
memset(second, 0xBB, 64);
|
|
|
|
/* ── First transfer attempt: send chunk 0 ── */
|
|
snapshot_transfer_t c0;
|
|
memset(&c0, 0, sizeof(c0));
|
|
c0.snapshot_lsn = snap_lsn;
|
|
c0.chunk_index = 0;
|
|
c0.chunk_offset = 0;
|
|
c0.total_size = 64; /* single-chunk transfer */
|
|
c0.is_last = 0; /* not last — simulates partial in-flight transfer */
|
|
|
|
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0, first, 64);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* snap.part must exist and contain exactly 64 bytes (first chunk) */
|
|
char part_path[600];
|
|
snprintf(part_path, sizeof(part_path), "%s/snap.part", snap_dir);
|
|
struct stat st;
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(part_path, &st),
|
|
"snap.part must exist after first chunk");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(64, (uint64_t)st.st_size,
|
|
"snap.part must be 64 bytes after first chunk");
|
|
|
|
/* ── Simulate reconnect: caller discards snap.part ── */
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, unlink(part_path),
|
|
"unlink snap.part must succeed (simulating reconnect)");
|
|
|
|
/* ── Second transfer attempt: restart from chunk 0 ── */
|
|
snapshot_transfer_t c0b;
|
|
memset(&c0b, 0, sizeof(c0b));
|
|
c0b.snapshot_lsn = snap_lsn;
|
|
c0b.chunk_index = 0;
|
|
c0b.chunk_offset = 0;
|
|
c0b.total_size = 64;
|
|
c0b.is_last = 0; /* still not last — just verifying discard */
|
|
|
|
rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0b, second, 64);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
|
|
/* snap.part must exist and contain exactly 64 bytes (second chunk only,
|
|
* not 128 bytes which would indicate old data was appended) */
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat(part_path, &st),
|
|
"snap.part must exist after reconnect chunk 0");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(64, (uint64_t)st.st_size,
|
|
"snap.part must be 64 bytes (not 128) — stale data discarded");
|
|
|
|
/* Verify the content is the second chunk (0xBB bytes), not the first (0xAA) */
|
|
FILE *f = fopen(part_path, "rb");
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(f, "snap.part must be readable");
|
|
uint8_t buf[64];
|
|
size_t n = fread(buf, 1, 64, f);
|
|
fclose(f);
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(64, (uint64_t)n, "must read 64 bytes from snap.part");
|
|
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, buf[0],
|
|
"snap.part content must be second chunk (0xBB), not first (0xAA)");
|
|
|
|
ha_fini(&ha);
|
|
persist_stop(p);
|
|
persist_fini(p);
|
|
free(p);
|
|
|
|
char cmd[512];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
|
system(cmd);
|
|
printf("test_snapshot_recv_reconnect_discards_snappart: PASS\n");
|
|
}
|
|
|
|
/* ── Task 5.1: handle_ctrlhello — connection setup only ─────────────────────
|
|
*
|
|
* Verify that after the ha-repl-hardening simplification, handle_ctrlhello
|
|
* does connection bookkeeping only and enqueues NO replication messages.
|
|
*
|
|
* Requirements: 1.1, 1.2
|
|
*/
|
|
|
|
static int g_ctrlhello_repl_msg_count = 0;
|
|
|
|
/* Count any xce_request calls (via the wal stream hook) that arrive during
|
|
* handle_ctrlhello. After simplification this must remain 0.
|
|
* Note: the WAL streaming hook is for ha_stream_wal_to_standby; if
|
|
* handle_ctrlhello were still calling xce_request directly the test would
|
|
* need a separate seam — but since the simplified code has none, we simply
|
|
* verify no crash and correct state. */
|
|
|
|
static void test_ctrlhello_no_repl_messages_enqueued(void)
|
|
{
|
|
/* Build a minimal cluster_t in CTRL_STANDBY role.
|
|
* Intentionally leave persist.repl, persist.wal_path, and
|
|
* standby_endpoints uninitialised (zero/NULL) — the simplified
|
|
* handle_ctrlhello must not touch any of these fields.
|
|
* The old code would crash or corrupt memory if it reached
|
|
* repl_add_standby / WAL streaming / push_config_to_standbys
|
|
* with NULL repl state. */
|
|
cluster_t c;
|
|
memset(&c, 0, sizeof(c));
|
|
|
|
ha_config_t hcfg;
|
|
memset(&hcfg, 0, sizeof(hcfg));
|
|
hcfg.heartbeat_timeout_ms = 10000;
|
|
hcfg.election_timeout_ms = 1500;
|
|
ha_init(&c.ha, &hcfg, 0);
|
|
|
|
c.ha.primary_ctrl_fd = -1;
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY, memory_order_release);
|
|
|
|
/* Give myconf a non-zero config_version_tag so the old config-push branch
|
|
* would have fired (standby.config_tag=0 != primary_tag=0xCAFE). */
|
|
myconf_t conf;
|
|
memset(&conf, 0, sizeof(conf));
|
|
conf.config_version_tag = 0xCAFEULL;
|
|
c.myconf = &conf;
|
|
|
|
/* Give persist a non-zero committed_lsn so the old WAL-streaming branch
|
|
* would have fired (standby.applied_lsn=0 < committed_lsn=100). */
|
|
atomic_store_explicit(&c.persist.committed_lsn, 100ULL, memory_order_release);
|
|
/* Leave persist.wal_path as NULL / empty — old code would have opened NULL. */
|
|
|
|
/* Build a minimal CtrlHelloRequest: standby reports lsn=0, tag=0 */
|
|
ctrl_hello_req_t hello;
|
|
memset(&hello, 0, sizeof(hello));
|
|
hello.name = "standby-test";
|
|
hello.applied_lsn = 0;
|
|
hello.config_tag = 0;
|
|
|
|
xce_request_t req;
|
|
memset(&req, 0, sizeof(req));
|
|
req.body_which = Request_body_ctrlhellomsg;
|
|
req.body.ctrlhellomsg = &hello;
|
|
|
|
endpoint_t ep;
|
|
memset(&ep, 0, sizeof(ep));
|
|
ep.fd = 77;
|
|
|
|
/* Reset repl message counter */
|
|
g_ctrlhello_repl_msg_count = 0;
|
|
|
|
/* Call the simplified handle_ctrlhello — must return 0 without crash */
|
|
int rc = handle_ctrlhello(&c, &ep, &req);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
|
|
"handle_ctrlhello must return 0 (Req 1.1)");
|
|
|
|
/* Connection bookkeeping must have happened */
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(77, c.ha.primary_ctrl_fd,
|
|
"handle_ctrlhello must set primary_ctrl_fd = ep->fd (Req 1.1)");
|
|
|
|
/* ha_on_heartbeat_received must have been called (last_activity updated) */
|
|
TEST_ASSERT_TRUE_MESSAGE(c.ha.last_activity > 0,
|
|
"handle_ctrlhello must call ha_on_heartbeat_received (Req 1.2)");
|
|
|
|
/* Idempotency: second call must not overwrite primary_ctrl_fd */
|
|
ep.fd = 99;
|
|
handle_ctrlhello(&c, &ep, &req);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(77, c.ha.primary_ctrl_fd,
|
|
"handle_ctrlhello: second call must not overwrite primary_ctrl_fd (Req 1.1)");
|
|
|
|
/* Verify no replication messages were counted.
|
|
* With the simplified implementation, handle_ctrlhello never calls
|
|
* xce_request() — so g_ctrlhello_repl_msg_count stays 0. If xce_request
|
|
* were called for OPCode_replConfigPush or OPCode_walCatchupEntry the
|
|
* test binary would crash (NULL ep infrastructure) before reaching here. */
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_ctrlhello_repl_msg_count,
|
|
"handle_ctrlhello must enqueue no OPCode_replConfigPush or "
|
|
"OPCode_walCatchupEntry messages (Req 1.1, 1.2)");
|
|
|
|
ha_fini(&c.ha);
|
|
printf("test_ctrlhello_no_repl_messages_enqueued: PASS\n");
|
|
}
|
|
|
|
/* Declare handle_ctrlhello for this translation unit (defined in config_repl.c) */
|
|
extern int handle_ctrlhello(cluster_t *c, endpoint_t *ep, xce_request_t *req);
|
|
|
|
#endif /* XCE_TESTING */
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
* xce-audit-invariants controller pass, thread-role violation 2 fix
|
|
* (2026-07-18, the more urgent violation — catch-up can stream an unbounded
|
|
* amount of WAL data): ha_catchup_continuation_fn must not block the
|
|
* delaymgr thread — the SAME single thread that drives heartbeats and
|
|
* election timeouts cluster-wide (ha-async-io.md thread-role contract).
|
|
*
|
|
* These tests drive a REAL, STARTED delaymgr (single worker thread, exactly
|
|
* as production — delaymgr_t.worker is a single pthread, not a pool) so the
|
|
* scheduled one-shot continuation task is picked up and run BY that thread,
|
|
* proving whether it blocks. ha_repl_stream_wal_hook gates the blocking
|
|
* streaming call so the test controls exactly when it completes. A cheap
|
|
* PROBE task queued on the SAME delaymgr right after the continuation is
|
|
* scheduled stands in for "the next heartbeat/election timer" — whether it
|
|
* fires promptly is the load-bearing assertion.
|
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
#ifdef XCE_TESTING
|
|
|
|
typedef struct {
|
|
pthread_mutex_t mu;
|
|
pthread_cond_t release_cv;
|
|
pthread_cond_t entered_cv;
|
|
int gate_open;
|
|
_Atomic int entered;
|
|
_Atomic int calls;
|
|
} cc_gate_t;
|
|
|
|
static cc_gate_t g_cc_gate;
|
|
|
|
static void cc_gate_init(cc_gate_t *g)
|
|
{
|
|
pthread_mutex_init(&g->mu, NULL);
|
|
pthread_cond_init(&g->release_cv, NULL);
|
|
pthread_cond_init(&g->entered_cv, NULL);
|
|
g->gate_open = 0;
|
|
atomic_store(&g->entered, 0);
|
|
atomic_store(&g->calls, 0);
|
|
}
|
|
|
|
static void cc_gate_destroy(cc_gate_t *g)
|
|
{
|
|
pthread_mutex_destroy(&g->mu);
|
|
pthread_cond_destroy(&g->release_cv);
|
|
pthread_cond_destroy(&g->entered_cv);
|
|
}
|
|
|
|
static void cc_gate_open(cc_gate_t *g)
|
|
{
|
|
pthread_mutex_lock(&g->mu);
|
|
g->gate_open = 1;
|
|
pthread_cond_broadcast(&g->release_cv);
|
|
pthread_mutex_unlock(&g->mu);
|
|
}
|
|
|
|
static int cc_gate_wait_entered(cc_gate_t *g, int n, int timeout_ms)
|
|
{
|
|
struct timespec deadline;
|
|
clock_gettime(CLOCK_REALTIME, &deadline);
|
|
deadline.tv_sec += timeout_ms / 1000;
|
|
deadline.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
|
|
if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; }
|
|
|
|
pthread_mutex_lock(&g->mu);
|
|
int rc = 0;
|
|
while (atomic_load(&g->entered) < n) {
|
|
rc = pthread_cond_timedwait(&g->entered_cv, &g->mu, &deadline);
|
|
if (rc != 0) break;
|
|
}
|
|
int entered = atomic_load(&g->entered);
|
|
pthread_mutex_unlock(&g->mu);
|
|
return entered >= n ? 0 : -1;
|
|
}
|
|
|
|
/* Stand-in for the blocking WAL stream (ha_stream_wal_to_standby): count,
|
|
* signal entry, park until the test opens the gate, then return success.
|
|
* Runs on a slow-lane worker (flag ON) or inline on the delaymgr worker
|
|
* thread (flag OFF). */
|
|
static int cc_gated_stream_hook(void *ep, const char *wal_path,
|
|
uint64_t standby_lsn, uint64_t committed_lsn)
|
|
{
|
|
(void)ep; (void)wal_path; (void)standby_lsn; (void)committed_lsn;
|
|
atomic_fetch_add(&g_cc_gate.calls, 1);
|
|
|
|
pthread_mutex_lock(&g_cc_gate.mu);
|
|
atomic_fetch_add(&g_cc_gate.entered, 1);
|
|
pthread_cond_broadcast(&g_cc_gate.entered_cv);
|
|
while (!g_cc_gate.gate_open)
|
|
pthread_cond_wait(&g_cc_gate.release_cv, &g_cc_gate.mu);
|
|
pthread_mutex_unlock(&g_cc_gate.mu);
|
|
return 0;
|
|
}
|
|
|
|
/* Real, started delaymgr pass-through runner — production's run_delay_tasks
|
|
* (cluster.c) is not linked into controller_unit_test; mirrors
|
|
* test_commit_durability.c's dur_run_delay_task. */
|
|
static int cc_run_delay_task(delay_task_entry_fn_t entry, void *arg)
|
|
{
|
|
return entry(arg);
|
|
}
|
|
|
|
static int g_cc_ep_placeholder; /* fake non-NULL repl_ep_t identity */
|
|
|
|
typedef struct { _Atomic int fired; } cc_probe_t;
|
|
|
|
static int cc_probe_fn(void *arg)
|
|
{
|
|
cc_probe_t *p = (cc_probe_t *)arg;
|
|
atomic_store(&p->fired, 1);
|
|
return 0;
|
|
}
|
|
|
|
static int cc_poll_flag(_Atomic int *flag, int timeout_ms)
|
|
{
|
|
for (int i = 0; i < timeout_ms; i++) {
|
|
if (atomic_load(flag))
|
|
break;
|
|
struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000L }; /* 1 ms */
|
|
nanosleep(&ts, NULL);
|
|
}
|
|
return atomic_load(flag);
|
|
}
|
|
|
|
static void cc_run_offload_probe(int offload_on, const char *label)
|
|
{
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_cc_%s_XXXXXX",
|
|
offload_on ? "on" : "off");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
|
|
|
|
cluster_t c;
|
|
memset(&c, 0, sizeof(c));
|
|
|
|
persist_config_t pcfg = persist_config_defaults();
|
|
pcfg.wal_sync_ms = 5000;
|
|
persist_set_workdir(&c.persist, tmpdir, "ctrl-test");
|
|
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &pcfg, &c));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
|
|
pthread_mutex_lock(&c.persist.lock);
|
|
c.persist.current_lsn = 11; /* tip = 10 */
|
|
pthread_mutex_unlock(&c.persist.lock);
|
|
atomic_store(&c.persist.committed_lsn, 10);
|
|
atomic_store(&c.persist.durable_lsn, 10);
|
|
|
|
TEST_ASSERT_TRUE(repl_add_standby(&c.persist.repl, &g_cc_ep_placeholder,
|
|
"ccsb0", 7600) >= 0);
|
|
|
|
ha_config_t hcfg = {
|
|
.heartbeat_interval_ms = 3000,
|
|
.heartbeat_timeout_ms = 10000,
|
|
.election_timeout_ms = 2000,
|
|
.probe_connect_timeout_ms = 500,
|
|
.max_promotion_retries = 5,
|
|
};
|
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &hcfg, 0));
|
|
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&c.slowlane, 2, 4));
|
|
TEST_ASSERT_EQUAL_INT(0, delaymgr_init(&c.delaymgr, 5, cc_run_delay_task));
|
|
TEST_ASSERT_EQUAL_INT(0, delaymgr_start(&c.delaymgr));
|
|
|
|
cc_gate_init(&g_cc_gate);
|
|
ha_repl_stream_wal_hook = cc_gated_stream_hook;
|
|
ha_set_catchup_offload_for_test(offload_on);
|
|
|
|
/* acked_lsn=0 < target(=10) -> schedules exactly one continuation. */
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
ha_walack_continuation_for_test(&c, &g_cc_ep_placeholder, 0, 0));
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, cc_gate_wait_entered(&g_cc_gate, 1, 1000),
|
|
"the streaming call must reach the gate (on the slow lane, or — "
|
|
"RED — inline on the delaymgr thread)");
|
|
|
|
/* Queue a cheap probe task RIGHT AFTER, on the SAME single-worker
|
|
* delaymgr — a stand-in for the next heartbeat/election timer. */
|
|
cc_probe_t probe;
|
|
atomic_store(&probe.fired, 0);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
delay_task_ms(&c.delaymgr, 1, 1, cc_probe_fn, &probe));
|
|
|
|
if (offload_on) {
|
|
TEST_ASSERT_TRUE_MESSAGE(cc_poll_flag(&probe.fired, 1000),
|
|
"GREEN: with catch-up offloaded to the slow lane, the delaymgr "
|
|
"worker thread must be free to run the NEXT queued task "
|
|
"promptly, even while the streaming body is still parked");
|
|
} else {
|
|
TEST_ASSERT_FALSE_MESSAGE(cc_poll_flag(&probe.fired, 300),
|
|
"RED: with offload OFF the streaming body runs INLINE on the "
|
|
"delaymgr worker thread, so the next queued task (standing in "
|
|
"for a heartbeat/election timer) cannot fire until it returns "
|
|
"-- this is the blocking-on-delaymgr shape the fix removes");
|
|
}
|
|
|
|
cc_gate_open(&g_cc_gate);
|
|
|
|
/* Drain: wait for catchup_inflight[0] to clear (lane/inline body done),
|
|
* then confirm the probe (queued behind it in the OFF/RED case) fires. */
|
|
for (int i = 0; i < 1000; i++) {
|
|
if (atomic_load_explicit(&c.ha.catchup_inflight[0],
|
|
memory_order_acquire) == 0)
|
|
break;
|
|
struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000L };
|
|
nanosleep(&ts, NULL);
|
|
}
|
|
TEST_ASSERT_TRUE_MESSAGE(cc_poll_flag(&probe.fired, 1000),
|
|
"the probe task must eventually fire once the gate is released");
|
|
|
|
ha_repl_stream_wal_hook = NULL;
|
|
ha_set_catchup_offload_for_test(0); /* restore production default */
|
|
cc_gate_destroy(&g_cc_gate);
|
|
delaymgr_stop(&c.delaymgr);
|
|
delaymgr_fini(&c.delaymgr);
|
|
slowlane_fini(&c.slowlane);
|
|
ha_fini(&c.ha);
|
|
persist_stop(&c.persist);
|
|
persist_fini(&c.persist);
|
|
repl_fini(&c.persist.repl);
|
|
printf("%s: PASS\n", label);
|
|
}
|
|
|
|
static void test_catchup_offload_probe_runs_promptly(void)
|
|
{
|
|
cc_run_offload_probe(1, "test_catchup_offload_probe_runs_promptly");
|
|
}
|
|
|
|
static void test_catchup_inline_probe_blocks_RED(void)
|
|
{
|
|
cc_run_offload_probe(0, "test_catchup_inline_probe_blocks_RED");
|
|
}
|
|
|
|
#endif /* XCE_TESTING */
|
|
|
|
/* ── dispatch ───────────────────────────────────── */
|
|
|
|
void test_config_repl(void)
|
|
{
|
|
UnitySetTestFile(__FILE__);
|
|
RUN_TEST(test_conf_read_current_filename_missing);
|
|
RUN_TEST(test_conf_read_current_filename_empty_file);
|
|
RUN_TEST(test_conf_read_current_filename_path_separator);
|
|
RUN_TEST(test_conf_read_current_filename_bad_prefix);
|
|
RUN_TEST(test_conf_read_current_filename_wrong_hex_length);
|
|
RUN_TEST(test_conf_save_creates_pointer_file);
|
|
RUN_TEST(test_conf_save_filename_has_12_hex_suffix);
|
|
RUN_TEST(test_conf_trim_keeps_only_n_versions);
|
|
RUN_TEST(test_conf_get_saved_tag_no_config);
|
|
RUN_TEST(test_conf_get_saved_tag_after_save);
|
|
RUN_TEST(test_conf_check_stale_disabled);
|
|
RUN_TEST(test_conf_check_stale_fresh);
|
|
RUN_TEST(test_conf_check_stale_aged);
|
|
RUN_TEST(test_config_contains_reserved_name_absent);
|
|
RUN_TEST(test_sanitise_cn_printable);
|
|
RUN_TEST(test_sanitise_cn_control_chars);
|
|
RUN_TEST(test_sanitise_cn_truncated);
|
|
RUN_TEST(test_conf_save_config_path_consistency);
|
|
/* Task 4.1: WAL_CONFIG_MARKER type + reference-only codec */
|
|
RUN_TEST(test_config_marker_enum_value_is_50);
|
|
RUN_TEST(test_config_marker_encode_decode_roundtrip);
|
|
RUN_TEST(test_config_marker_decode_truncated_fails);
|
|
RUN_TEST(test_config_marker_encode_short_buffer_fails);
|
|
/* Task 4.2: append marker via unified log + derive committed config */
|
|
RUN_TEST(test_config_marker_append_via_unified_log);
|
|
RUN_TEST(test_committed_marker_derives_after_commit);
|
|
RUN_TEST(test_uncommitted_marker_not_yet_committed_config);
|
|
RUN_TEST(test_divergent_marker_truncated_like_job_entry);
|
|
/* Task 4.2 r1: live wiring + compaction-safe derive */
|
|
RUN_TEST(test_config_commit_change_live_path_commits_and_derives);
|
|
RUN_TEST(test_committed_marker_survives_checkpoint_trim);
|
|
|
|
RUN_TEST(test_config_activate_match_activates_and_unfences);
|
|
RUN_TEST(test_config_activate_mismatch_fences_and_does_not_activate);
|
|
RUN_TEST(test_config_activate_obtained_bytes_verified_before_activate);
|
|
RUN_TEST(test_config_activate_recovery_uses_latest_committed_marker);
|
|
/* Task 4.3 r1 remediation: production integration (push-path sole activation,
|
|
* no-marker fallback, serve-fence enforced by dispatch gate + promotion). */
|
|
RUN_TEST(test_push_path_mismatch_does_not_preactivate);
|
|
RUN_TEST(test_config_activate_no_marker_falls_back_and_loads);
|
|
RUN_TEST(test_serve_fence_enforced_by_dispatch_gate_and_promotion);
|
|
/* Task 4.3 r2 remediation: commit-advance retry clears a push-before-commit
|
|
* serve-fence (Finding D / R7.9). */
|
|
RUN_TEST(test_commit_advance_retry_clears_push_before_commit_fence);
|
|
#ifdef XCE_TESTING
|
|
RUN_TEST(test_load_primary_config_missing_returns_error);
|
|
RUN_TEST(test_wait_all_with_timeout_slow_threads);
|
|
RUN_TEST(test_wait_all_with_timeout_fast_threads);
|
|
/* bug-1381: push_config_transport_only endpoint-lifetime regression */
|
|
RUN_TEST(test_configpush_transport_holds_endpoint_ref_across_send);
|
|
/* Task 2.1: OPCode_walCatchupEntry handler */
|
|
RUN_TEST(test_wal_catchup_entry_advances_applied_lsn);
|
|
RUN_TEST(test_wal_catchup_entry_error_skipped_no_crash);
|
|
RUN_TEST(test_wal_catchup_entry_null_entry_no_crash);
|
|
/* Task 2.2: batch WAL catch-up entries */
|
|
RUN_TEST(test_wal_catchup_batch_advances_applied_lsn);
|
|
RUN_TEST(test_wal_catchup_partial_batch);
|
|
RUN_TEST(test_wal_catchup_batch_error_entry_skipped);
|
|
RUN_TEST(test_wal_catchup_batch_zero_entries_fallback);
|
|
/* Task 6.2: additional WAL catch-up and batch handling tests */
|
|
RUN_TEST(test_wal_catchup_batch_256_entries);
|
|
RUN_TEST(test_wal_catchup_batch_applied_lsn_only_after_full_batch);
|
|
/* Task 2.3: OPCode_nop heartbeat reply carries stateReport */
|
|
RUN_TEST(test_nop_reply_carries_state_report);
|
|
RUN_TEST(test_nop_reply_zero_state_fresh_standby);
|
|
/* Task 3.4: ha_stream_wal_to_standby batch WAL streaming */
|
|
RUN_TEST(test_wal_stream_500_entries_two_batches);
|
|
RUN_TEST(test_wal_stream_partial_batch);
|
|
RUN_TEST(test_wal_stream_empty_range_no_batches);
|
|
RUN_TEST(test_wal_stream_skips_checkpoint_marker);
|
|
RUN_TEST(test_wal_stream_term_from_index_not_raw_hdr); /* bug-1028 */
|
|
/* Task 3.5: snapshot chunk transfer (sender side) */
|
|
RUN_TEST(test_snapshot_transfer_two_chunks);
|
|
RUN_TEST(test_snapshot_transfer_small_less_than_one_chunk);
|
|
RUN_TEST(test_snapshot_transfer_stops_and_propagates_send_failure);
|
|
/* Task 3.5c: standby snapshot receive handler */
|
|
RUN_TEST(test_snapshot_recv_two_chunks_assembles_and_renames);
|
|
/* config-upgrade-snapshot task 1: prior SNAPSHOT_FORMAT_VERSION rejected
|
|
* fail-loud by a binary built against the bumped version */
|
|
RUN_TEST(test_snapshot_recv_old_format_version_rejected_fail_loud);
|
|
/* Task 6.1: legacy position-only file consumed end-to-end via the
|
|
* receive handler — entities_restored=false path */
|
|
RUN_TEST(test_snapshot_recv_legacy_file_advances_without_entities);
|
|
RUN_TEST(test_snapshot_recv_size_mismatch_fails);
|
|
RUN_TEST(test_snapshot_recv_null_args_fail);
|
|
/* Task 6.4-A: 128KB snapshot in 2 chunks — full-size receive path */
|
|
RUN_TEST(test_snapshot_recv_128kb_two_chunks);
|
|
RUN_TEST(test_snapshot_recv_out_of_order_chunks_assemble_correctly);
|
|
/* Task 3.5d: reconnect discards snap.part */
|
|
RUN_TEST(test_snapshot_recv_reconnect_discards_snappart);
|
|
/* Task 5.1: handle_ctrlhello connection setup only — no repl messages */
|
|
RUN_TEST(test_ctrlhello_no_repl_messages_enqueued);
|
|
/* xce-audit-invariants controller pass, thread-role violation 2 fix
|
|
* (2026-07-18): catch-up continuation slow-lane offload */
|
|
RUN_TEST(test_catchup_offload_probe_runs_promptly);
|
|
RUN_TEST(test_catchup_inline_probe_blocks_RED);
|
|
#endif
|
|
/* Task A: raw capnp shims */
|
|
RUN_TEST(test_replconfig_req_ssl_encode);
|
|
RUN_TEST(test_replconfig_reply_raw_encode);
|
|
RUN_TEST(test_upgrade_config_req_extract_raw);
|
|
}
|