bug-1879: on a fresh HA cluster the config-holding ctrl-1 started its term-1 election before its peers ran; connect failures count as "no" votes (bug-1423), so it lost at once and became a follower with no leader. ctrl-2/ctrl-3 hold no config and are voters only (by design), so nothing retried until ctrl-1's 10 s standby liveness timeout: every cold start had no primary for ~10 s. - ha_note_leaderless() marks a config-holding node that becomes a follower with no leader known; the liveness poll then retries the election (PreVote) after HA_LEADERLESS_RETRY_MS = 1 s, doubling up to heartbeat_timeout_ms. Primary contact or becoming leader clears it. - ha_heartbeat_is_fresh() answers "no" while leaderless: last_activity then only holds the watchdog's arm time, and the failover gates took it for a live primary. Live cold start: primary 1.7 s after the first node started (was ~10 s). bug-1878: ctrlstatus_fill() reported ha->applied_lsn for every role, a standby-only counter a primary never advances, so every primary showed "appliedLsn: 0". A primary now reports persist->last_applied_lsn. The controller-status design doc's "already correct in every role" note is corrected. Tests: 5 new in test_ha_failover_prevote.c, 1 new in test_ctrlstatus.c (RED: expected 4900, was 0); controller suite 1256/0. HA soaks: sequential 6 rounds PASS, chaotic 6 rounds PASS (zero divergence, zero job loss). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1151 lines
48 KiB
C
1151 lines
48 KiB
C
/* test_ctrlstatus.c — unit tests for the controller status handler
|
|
* (controller-status spec, task 2.1).
|
|
*
|
|
* Exercises ctrlstatus_fill(), the pure local-state snapshot used by
|
|
* handle_ctrlstatus(). Splitting the snapshot from the socket send (mirroring
|
|
* ha_nop_reply_state_report) makes the field sourcing unit-testable without an
|
|
* endpoint. Covers Requirements 1.2, 1.3, 2.1, 2.2, 2.3, 3.1, 3.2, 4.1, 4.2,
|
|
* 5.1, 5.2, 5.3, 6.1, 6.2, 6.3, 6.4.
|
|
*/
|
|
#include <stdatomic.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h> /* setenv */
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#include <unity/unity.h>
|
|
|
|
#include "ha.h"
|
|
#include "include/cluster.h"
|
|
#include "myconf.h"
|
|
#include "persist.h"
|
|
#include "xce/cluster_def.h"
|
|
|
|
#include "xce/ctrlstatus_fmt.h" /* ctrlstatus_format_time_ms (task 6.3) */
|
|
|
|
#include "controller.capnp.h" /* generated codec: CtrlStatus/HeartbeatMsg
|
|
* encode/decode (task 7.1 round-trip tests) */
|
|
|
|
/* ── helpers ────────────────────────────────────────────────── */
|
|
|
|
static ha_config_t cs_valid_cfg(void)
|
|
{
|
|
ha_config_t cfg = {
|
|
.heartbeat_interval_ms = 3000,
|
|
.heartbeat_timeout_ms = 10000,
|
|
.election_timeout_ms = 2000,
|
|
.probe_connect_timeout_ms = 500,
|
|
.max_promotion_retries = 5,
|
|
};
|
|
return cfg;
|
|
}
|
|
|
|
/* Build a minimal cluster_t whose ha/myconf fields the handler reads.
|
|
* Only the fields ctrlstatus_fill() touches are initialised. */
|
|
static void cs_setup_cluster(cluster_t* c, myconf_t* mc)
|
|
{
|
|
memset(c, 0, sizeof(*c));
|
|
memset(mc, 0, sizeof(*mc));
|
|
|
|
ha_config_t cfg = cs_valid_cfg();
|
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&c->ha, &cfg, 0));
|
|
|
|
c->myconf = mc;
|
|
mc->name = "ctrlA";
|
|
mc->cluster_config = NULL; /* cluster name read from peers/own identity */
|
|
|
|
/* own identity (xce_peerinfo_t.name is a char*, not a buffer) */
|
|
c->myhost_info.name = "ctrlA";
|
|
c->myhost = &c->myhost_info;
|
|
|
|
/* loaded config version + checksum */
|
|
mc->config_version_tag = 0xAABBCCDDEE001122ULL;
|
|
for (int i = 0; i < 32; i++)
|
|
mc->config_sha256[i] = (uint8_t)(i + 1);
|
|
|
|
/* configured controllers (peers) */
|
|
mc->peers_cfg.candidates_count = 3;
|
|
strncpy(mc->peers_cfg.candidates[0].name, "ctrlA",
|
|
sizeof(mc->peers_cfg.candidates[0].name) - 1);
|
|
strncpy(mc->peers_cfg.candidates[0].hostname, "10.0.0.1",
|
|
sizeof(mc->peers_cfg.candidates[0].hostname) - 1);
|
|
mc->peers_cfg.candidates[0].port = 7001;
|
|
strncpy(mc->peers_cfg.candidates[1].name, "ctrlB",
|
|
sizeof(mc->peers_cfg.candidates[1].name) - 1);
|
|
strncpy(mc->peers_cfg.candidates[1].hostname, "10.0.0.2",
|
|
sizeof(mc->peers_cfg.candidates[1].hostname) - 1);
|
|
mc->peers_cfg.candidates[1].port = 7002;
|
|
strncpy(mc->peers_cfg.candidates[2].name, "ctrlC",
|
|
sizeof(mc->peers_cfg.candidates[2].name) - 1);
|
|
strncpy(mc->peers_cfg.candidates[2].hostname, "10.0.0.3",
|
|
sizeof(mc->peers_cfg.candidates[2].hostname) - 1);
|
|
mc->peers_cfg.candidates[2].port = 7003;
|
|
}
|
|
|
|
static void cs_teardown_cluster(cluster_t* c)
|
|
{
|
|
ha_fini(&c->ha);
|
|
}
|
|
|
|
/* ── tests ──────────────────────────────────────────────────── */
|
|
|
|
/* bug-1878: a primary never advances ha->applied_lsn (a standby-only
|
|
* counter: how far replicated WAL has been received), so ctrlstatus showed
|
|
* "committedLsn: 4900 / appliedLsn: 0" on every primary. A primary reports
|
|
* its apply cursor, persist->last_applied_lsn (the last entry applied). */
|
|
static void test_ctrlstatus_primary_reports_its_apply_cursor(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.applied_lsn, 0, memory_order_release);
|
|
atomic_store_explicit(&c.persist.committed_lsn, 4900, memory_order_release);
|
|
atomic_store_explicit(&c.persist.last_applied_lsn, 4900,
|
|
memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT64(4900, st.committedLsn);
|
|
TEST_ASSERT_EQUAL_UINT64(4900, st.appliedLsn);
|
|
TEST_ASSERT_TRUE_MESSAGE(st.appliedLsn <= st.committedLsn,
|
|
"a primary applies only committed entries");
|
|
ctrlstatus_free_fields(&st);
|
|
}
|
|
|
|
/* (a) PRIMARY: responderRole==PRIMARY, primaryName==self, peers/version
|
|
* populated; committed/applied/term reflect the atomics. (Req 2.1, 3.1, 3.2,
|
|
* 4.1, 4.2, 5.1, 6.1, 6.2, 6.4) */
|
|
static void test_ctrlstatus_primary(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.term, 7, memory_order_release);
|
|
/* bug-1878: a primary's applied position is persist.last_applied_lsn;
|
|
* ha.applied_lsn (standby-only) is left at the 0 a live primary has */
|
|
atomic_store_explicit(&c.persist.last_applied_lsn, 41, memory_order_release);
|
|
atomic_store_explicit(&c.ha.committed_config_tag, 0xAABBCCDDEE001122ULL,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.persist.committed_lsn, 41, memory_order_release);
|
|
/* A primary voted "self"; primaryName is derived from its OWN name (role
|
|
* == CTRL_PRIMARY), NOT from voted_for_name or the dead primary_host. */
|
|
strncpy(c.ha.voted_for_name, "self", sizeof(c.ha.voted_for_name) - 1);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_PRIMARY, st.responderRole);
|
|
TEST_ASSERT_NOT_NULL(st.responderName);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlA", st.responderName);
|
|
/* Req 2.1: a primary reports itself as the primary (own name). */
|
|
TEST_ASSERT_NOT_NULL(st.primaryName);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlA", st.primaryName);
|
|
TEST_ASSERT_EQUAL_UINT64(7, st.term);
|
|
TEST_ASSERT_EQUAL_UINT64(41, st.appliedLsn);
|
|
TEST_ASSERT_EQUAL_UINT64(41, st.committedLsn);
|
|
TEST_ASSERT_EQUAL_UINT64(0xAABBCCDDEE001122ULL, st.configVersion);
|
|
TEST_ASSERT_EQUAL_UINT64(0xAABBCCDDEE001122ULL, st.committedConfigVersion);
|
|
/* configChecksum: 64-char lowercase-hex of config_sha256[i]=(i+1), i.e.
|
|
* bytes 0x01..0x20. (Req 5.2 content checksum, 5.3 divergence detection) */
|
|
TEST_ASSERT_NOT_NULL(st.configChecksum);
|
|
TEST_ASSERT_EQUAL_UINT(64u, (unsigned)strlen(st.configChecksum));
|
|
TEST_ASSERT_EQUAL_STRING(
|
|
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
|
st.configChecksum);
|
|
TEST_ASSERT_EQUAL_UINT32(3, st.peerCount);
|
|
TEST_ASSERT_EQUAL_INT(3, st.peers.count);
|
|
TEST_ASSERT_NOT_NULL(st.peers.list);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
|
TEST_ASSERT_EQUAL_STRING("10.0.0.2", st.peers.list[1]->host);
|
|
TEST_ASSERT_EQUAL_UINT32(7002, st.peers.list[1]->port);
|
|
TEST_ASSERT_EQUAL_UINT(0, st.fenced);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_primary: PASS\n");
|
|
}
|
|
|
|
/* (b) STANDBY: role==STANDBY and primaryName is empty ("unknown"). The
|
|
* controller does NOT track the current primary's NAME off-primary
|
|
* (primary_host is dead; voted_for_name is the last vote, often self — NOT the
|
|
* elected leader), so a standby honestly reports "unknown"; the client's
|
|
* authoritative path reaches the real primary by rotation. (Req 2.3) */
|
|
static void test_ctrlstatus_standby_known_primary(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
/* Even with a voted_for_name set, a standby must NOT report it as the
|
|
* primary (it is frequently the node's own self-vote). */
|
|
strncpy(c.ha.voted_for_name, "ctrl-1", sizeof(c.ha.voted_for_name) - 1);
|
|
|
|
/* All-0xFF digest → 64 'f' chars: a distinct content checksum from the
|
|
* primary case proves Req 5.3 — same version tag but differing config bytes
|
|
* produce a differing hex; identical bytes would yield identical hex. */
|
|
for (int i = 0; i < 32; i++)
|
|
mc.config_sha256[i] = 0xFFu;
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_STANDBY, st.responderRole);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlA", st.responderName);
|
|
TEST_ASSERT_NOT_NULL(st.primaryName);
|
|
TEST_ASSERT_EQUAL_STRING("", st.primaryName); /* standby → "unknown" */
|
|
TEST_ASSERT_NOT_NULL(st.configChecksum);
|
|
TEST_ASSERT_EQUAL_UINT(64u, (unsigned)strlen(st.configChecksum));
|
|
TEST_ASSERT_EQUAL_STRING(
|
|
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
st.configChecksum);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_standby_known_primary: PASS\n");
|
|
}
|
|
|
|
/* (c) STANDBY with no voted_for_name → empty primaryName ("unknown").
|
|
* (Req 1.3, 2.2) */
|
|
static void test_ctrlstatus_unknown_primary(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
/* voted_for_name left empty by ha_init → unknown */
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_STANDBY, st.responderRole);
|
|
TEST_ASSERT_NOT_NULL(st.primaryName);
|
|
TEST_ASSERT_EQUAL_STRING("", st.primaryName); /* empty ⇒ "unknown" */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_unknown_primary: PASS\n");
|
|
}
|
|
|
|
/* (c2) STANDBY whose voted_for_name == "self" is NOT a real peer name →
|
|
* empty primaryName ("unknown"). "self" is the test/own-vote placeholder
|
|
* (ha.c:1282) and must not be surfaced as a primary. (Req 1.3, 2.2) */
|
|
static void test_ctrlstatus_standby_self_vote_is_unknown(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
strncpy(c.ha.voted_for_name, "self", sizeof(c.ha.voted_for_name) - 1);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_STANDBY, st.responderRole);
|
|
TEST_ASSERT_NOT_NULL(st.primaryName);
|
|
TEST_ASSERT_EQUAL_STRING("", st.primaryName); /* "self" ⇒ "unknown" */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_standby_self_vote_is_unknown: PASS\n");
|
|
}
|
|
|
|
/* (c3) ELECTING (no confirmed primary) → empty primaryName even if a stale
|
|
* voted_for_name is present: only CTRL_STANDBY surfaces voted_for_name as the
|
|
* primary. (Req 2.2) */
|
|
static void test_ctrlstatus_electing_is_unknown(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_ELECTING,
|
|
memory_order_release);
|
|
strncpy(c.ha.voted_for_name, "ctrl-1", sizeof(c.ha.voted_for_name) - 1);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_ELECTING, st.responderRole);
|
|
TEST_ASSERT_NOT_NULL(st.primaryName);
|
|
TEST_ASSERT_EQUAL_STRING("", st.primaryName); /* electing ⇒ "unknown" */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_electing_is_unknown: PASS\n");
|
|
}
|
|
|
|
/* (d) fenced reflects in_catch_up. (Req 6.3) */
|
|
static void test_ctrlstatus_fenced_when_in_catch_up(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.in_catch_up, 1, memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT(1u, st.fenced ? 1u : 0u);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_fenced_when_in_catch_up: PASS\n");
|
|
}
|
|
|
|
/* (e) No-mutation: ctrlstatus_fill() must not change any HA/config state.
|
|
* Snapshot the relevant fields, call fill, assert they are unchanged. (Req 1.2)
|
|
*/
|
|
static void test_ctrlstatus_no_mutation(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.term, 9, memory_order_release);
|
|
atomic_store_explicit(&c.ha.applied_lsn, 100, memory_order_release);
|
|
atomic_store_explicit(&c.persist.committed_lsn, 99, memory_order_release);
|
|
atomic_store_explicit(&c.ha.committed_config_tag, 0x1234ULL,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.in_catch_up, 0, memory_order_release);
|
|
strncpy(c.ha.voted_for_name, "ctrl-1", sizeof(c.ha.voted_for_name) - 1);
|
|
/* Req 12.5: reading the learned election-time must not mutate it either. */
|
|
atomic_store_explicit(&c.ha.last_known_elected_at_ms,
|
|
UINT64_C(1781939830911), memory_order_release);
|
|
|
|
/* Req 13.5: reading the peer connection-status registry must not mutate
|
|
* it either. Populate one live standby slot matching a configured peer
|
|
* so the primary-branch registry scan actually executes during this
|
|
* call (role is already PRIMARY above). */
|
|
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
|
c.ha.repl = &c.persist.repl;
|
|
int dummy_ep;
|
|
c.persist.repl.standbys[0].ep = (repl_ep_t)&dummy_ep;
|
|
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[0].host) - 1);
|
|
c.persist.repl.standbys[0].port = 7002;
|
|
atomic_store_explicit(&c.persist.repl.standbys[0].in_catch_up, 0,
|
|
memory_order_release);
|
|
|
|
int32_t role0 = atomic_load_explicit(&c.ha.role, memory_order_acquire);
|
|
uint64_t term0 = atomic_load_explicit(&c.ha.term, memory_order_acquire);
|
|
uint64_t applied0 = atomic_load_explicit(&c.ha.applied_lsn,
|
|
memory_order_acquire);
|
|
uint64_t committed0 = atomic_load_explicit(&c.persist.committed_lsn,
|
|
memory_order_acquire);
|
|
uint64_t cfgtag0 = atomic_load_explicit(&c.ha.committed_config_tag,
|
|
memory_order_acquire);
|
|
int catchup0 = atomic_load_explicit(&c.ha.in_catch_up,
|
|
memory_order_acquire);
|
|
char voted0[XCE_MAXNAME_LEN];
|
|
strncpy(voted0, c.ha.voted_for_name, sizeof(voted0));
|
|
uint64_t vertag0 = mc.config_version_tag;
|
|
uint64_t elected0 = atomic_load_explicit(&c.ha.last_known_elected_at_ms,
|
|
memory_order_acquire);
|
|
repl_ep_t ep0 = c.persist.repl.standbys[0].ep;
|
|
char host0[XCE_MAXNAME_LEN];
|
|
strncpy(host0, c.persist.repl.standbys[0].host, sizeof(host0));
|
|
uint32_t port0 = c.persist.repl.standbys[0].port;
|
|
int incatchup0 = atomic_load_explicit(
|
|
&c.persist.repl.standbys[0].in_catch_up,
|
|
memory_order_acquire);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
ctrlstatus_free_fields(&st);
|
|
|
|
TEST_ASSERT_EQUAL_INT(role0, atomic_load_explicit(&c.ha.role,
|
|
memory_order_acquire));
|
|
TEST_ASSERT_EQUAL_UINT64(term0, atomic_load_explicit(&c.ha.term,
|
|
memory_order_acquire));
|
|
TEST_ASSERT_EQUAL_UINT64(applied0,
|
|
atomic_load_explicit(&c.ha.applied_lsn,
|
|
memory_order_acquire));
|
|
TEST_ASSERT_EQUAL_UINT64(committed0,
|
|
atomic_load_explicit(&c.persist.committed_lsn,
|
|
memory_order_acquire));
|
|
TEST_ASSERT_EQUAL_UINT64(cfgtag0,
|
|
atomic_load_explicit(&c.ha.committed_config_tag,
|
|
memory_order_acquire));
|
|
TEST_ASSERT_EQUAL_INT(catchup0,
|
|
atomic_load_explicit(&c.ha.in_catch_up,
|
|
memory_order_acquire));
|
|
TEST_ASSERT_EQUAL_STRING(voted0, c.ha.voted_for_name);
|
|
TEST_ASSERT_EQUAL_UINT64(vertag0, mc.config_version_tag);
|
|
TEST_ASSERT_EQUAL_UINT64(elected0,
|
|
atomic_load_explicit(&c.ha.last_known_elected_at_ms,
|
|
memory_order_acquire));
|
|
/* Req 13.5: the connection-registry slot this call's primary-branch
|
|
* peer-status scan read must be unchanged too. */
|
|
TEST_ASSERT_EQUAL_PTR(ep0, c.persist.repl.standbys[0].ep);
|
|
TEST_ASSERT_EQUAL_STRING(host0, c.persist.repl.standbys[0].host);
|
|
TEST_ASSERT_EQUAL_UINT32(port0, c.persist.repl.standbys[0].port);
|
|
TEST_ASSERT_EQUAL_INT(incatchup0,
|
|
atomic_load_explicit(
|
|
&c.persist.repl.standbys[0].in_catch_up,
|
|
memory_order_acquire));
|
|
|
|
repl_fini(&c.persist.repl);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_no_mutation: PASS\n");
|
|
}
|
|
|
|
/* (f) STANDBY reports the LEARNED committed position (Req 10.1, 10.3).
|
|
* A standby has never served as primary, so its node-local committed marker
|
|
* (persist.committed_lsn) is 0, but it has learned a committed position from
|
|
* the primary's heartbeats (last_known_committed_lsn) and has applied entries
|
|
* beyond that learned floor. The reply must report the LEARNED committed LSN +
|
|
* committed config version (not the node-local zero) and the real applied
|
|
* position — with no applied>committed inversion relative to what it knows. */
|
|
static void test_ctrlstatus_standby_reports_learned_committed(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
/* node-local committed marker is unset on a never-primary node */
|
|
atomic_store_explicit(&c.persist.committed_lsn, 0, memory_order_release);
|
|
/* the primary's own committed_config_tag is also unset locally */
|
|
atomic_store_explicit(&c.ha.committed_config_tag, 0, memory_order_release);
|
|
/* applied beyond the learned committed floor */
|
|
atomic_store_explicit(&c.ha.applied_lsn, 38, memory_order_release);
|
|
/* learned from the primary's heartbeats */
|
|
atomic_store_explicit(&c.ha.last_known_committed_lsn, 36,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.last_known_committed_config_tag,
|
|
0x00C0FFEE0000CAFEULL, memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_STANDBY, st.responderRole);
|
|
/* Req 10.1: learned committed values, not the node-local zero */
|
|
TEST_ASSERT_EQUAL_UINT64(36, st.committedLsn);
|
|
TEST_ASSERT_EQUAL_UINT64(0x00C0FFEE0000CAFEULL, st.committedConfigVersion);
|
|
/* Req 10.3: real applied position; no applied>committed inversion vs the
|
|
* node's own knowledge (applied 38 >= learned committed 36 is consistent). */
|
|
TEST_ASSERT_EQUAL_UINT64(38, st.appliedLsn);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_standby_reports_learned_committed: PASS\n");
|
|
}
|
|
|
|
/* (g) STANDBY pre-learn → committed LSN + committed config version are the
|
|
* well-defined "unknown"/zero value (Req 10.2). The node has not yet learned
|
|
* any committed position from a primary, so it must report 0 (not a stale or
|
|
* guessed value), consistent with Req 1.3. */
|
|
static void test_ctrlstatus_standby_prelearn_committed_zero(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.last_known_committed_lsn, 0,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.last_known_committed_config_tag, 0,
|
|
memory_order_release);
|
|
/* even if the node-local primary marker had a stale value, off-primary it
|
|
* must NOT be surfaced — prove the learned (zero) value wins. */
|
|
atomic_store_explicit(&c.persist.committed_lsn, 77, memory_order_release);
|
|
atomic_store_explicit(&c.ha.committed_config_tag, 0xDEADULL,
|
|
memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_STANDBY, st.responderRole);
|
|
TEST_ASSERT_EQUAL_UINT64(0, st.committedLsn); /* Req 10.2 */
|
|
TEST_ASSERT_EQUAL_UINT64(0, st.committedConfigVersion); /* Req 10.2 */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_standby_prelearn_committed_zero: PASS\n");
|
|
}
|
|
|
|
/* (h) PRIMARY reports its OWN authoritative local committed markers
|
|
* (persist.committed_lsn, ha->committed_config_tag) — unchanged behavior. The
|
|
* learned last_known_* values must NOT shadow the primary's own markers. */
|
|
static void test_ctrlstatus_primary_reports_own_committed(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.persist.committed_lsn, 512, memory_order_release);
|
|
atomic_store_explicit(&c.ha.committed_config_tag, 0x0BADF00DULL,
|
|
memory_order_release);
|
|
/* stale learned values must NOT be surfaced on a primary */
|
|
atomic_store_explicit(&c.ha.last_known_committed_lsn, 9,
|
|
memory_order_release);
|
|
atomic_store_explicit(&c.ha.last_known_committed_config_tag, 0x1111ULL,
|
|
memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_PRIMARY, st.responderRole);
|
|
TEST_ASSERT_EQUAL_UINT64(512, st.committedLsn); /* own */
|
|
TEST_ASSERT_EQUAL_UINT64(0x0BADF00DULL, st.committedConfigVersion); /* own */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_primary_reports_own_committed: PASS\n");
|
|
}
|
|
|
|
/* (i) Responder snapshot timestamp (Req 11.1): responder_time_ms is the
|
|
* responder's CLOCK_REALTIME wall clock (epoch ms) at sample time — non-zero
|
|
* and within [before, after] of a test-captured wall-clock sample. */
|
|
static void test_ctrlstatus_responder_time_ms(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
|
|
struct timespec before_ts;
|
|
clock_gettime(CLOCK_REALTIME, &before_ts);
|
|
uint64_t before_ms = (uint64_t)before_ts.tv_sec * 1000u
|
|
+ (uint64_t)before_ts.tv_nsec / 1000000u;
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
struct timespec after_ts;
|
|
clock_gettime(CLOCK_REALTIME, &after_ts);
|
|
uint64_t after_ms = (uint64_t)after_ts.tv_sec * 1000u
|
|
+ (uint64_t)after_ts.tv_nsec / 1000000u;
|
|
|
|
TEST_ASSERT_NOT_EQUAL_UINT64(0, st.responder_time_ms);
|
|
/* within the captured window (1 ms slack each side for clock resolution) */
|
|
TEST_ASSERT_TRUE(st.responder_time_ms + 1u >= before_ms);
|
|
TEST_ASSERT_TRUE(st.responder_time_ms <= after_ms + 1u);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_responder_time_ms: PASS\n");
|
|
}
|
|
|
|
/* (j) Responder snapshot timestamp rendering (Req 11.2, 11.3): the test client
|
|
* renders responder_time_ms (epoch-millis) into the SAME wall-clock string the
|
|
* controller logger (tlog) emits — local-time "YYYY-MM-DD HH:MM:SS,mmm" — so it
|
|
* is byte-comparable against ctrl-N.log. Pin TZ=UTC for determinism, then assert
|
|
* the exact rendered string for a known epoch-millis. */
|
|
static void test_ctrlstatus_format_time_ms(void)
|
|
{
|
|
setenv("TZ", "UTC", 1);
|
|
tzset();
|
|
|
|
/* 1781939830000 ms == 2026-06-20 07:17:10 UTC; +911 ms of fractional part.
|
|
* Renders local time (TZ=UTC) in the tlog prefix shape "%.4d-%.2d-%.2d
|
|
* %.2d:%.2d:%.2d,%.3d". */
|
|
char buf[64];
|
|
uint64_t ms = UINT64_C(1781939830000) + 911u;
|
|
|
|
char* ret = ctrlstatus_format_time_ms(ms, buf, sizeof(buf));
|
|
|
|
TEST_ASSERT_EQUAL_PTR(buf, ret);
|
|
TEST_ASSERT_EQUAL_STRING("2026-06-20 07:17:10,911", buf);
|
|
|
|
/* zero-millis fractional part must still be zero-padded to 3 digits */
|
|
char* ret2 = ctrlstatus_format_time_ms(UINT64_C(1781939830000), buf,
|
|
sizeof(buf));
|
|
TEST_ASSERT_EQUAL_PTR(buf, ret2);
|
|
TEST_ASSERT_EQUAL_STRING("2026-06-20 07:17:10,000", buf);
|
|
|
|
printf("test_ctrlstatus_format_time_ms: PASS\n");
|
|
}
|
|
|
|
/* ── (k) codec round-trip: CtrlStatus.electedAtMs @14 (task 7.1, Req 12.1) ──
|
|
* Design delta "Design Delta — Requirement 12", Schema delta subsection: a new
|
|
* append-only UInt64 epoch-millis field on CtrlStatus, next free ordinal after
|
|
* responderTimeMs @13. This is schema/codec-level ONLY: ctrlstatus_fill does
|
|
* not populate elected_at_ms yet (that wiring is task 7.4, out of boundary for
|
|
* 7.1) -- the struct is built directly to prove the new wire field survives a
|
|
* genuine encode -> bytes -> decode cycle unchanged, mirroring the pattern in
|
|
* src/lib/testsuite/test_ctrlstatus.c's test_ctrlstatus_roundtrip_all_fields.
|
|
*/
|
|
static void test_ctrlstatus_electedatms_roundtrip(void)
|
|
{
|
|
xce_ctrlstatus_t in;
|
|
memset(&in, 0, sizeof(in));
|
|
in.clusterName = "lab-cluster";
|
|
in.responderName = "ctrl-a";
|
|
in.elected_at_ms = UINT64_C(1781939830911); /* known non-zero value */
|
|
|
|
struct capn c;
|
|
struct capn_segment* cs;
|
|
struct CtrlStatus cap_in;
|
|
CtrlStatus_ptr sp;
|
|
|
|
capn_init_malloc(&c);
|
|
cs = capn_root(&c).seg;
|
|
|
|
memset(&cap_in, 0, sizeof(cap_in));
|
|
encode_CtrlStatus(cs, &cap_in, &in);
|
|
|
|
sp = new_CtrlStatus(cs);
|
|
write_CtrlStatus(&cap_in, sp);
|
|
capn_setp(capn_root(&c), 0, sp.p);
|
|
|
|
size_t sz = capn_size(&c);
|
|
uint8_t* buf = (uint8_t*)malloc(sz);
|
|
TEST_ASSERT_NOT_NULL(buf);
|
|
TEST_ASSERT_EQUAL_size_t(sz, capn_write_mem(&c, buf, sz, 0));
|
|
capn_free(&c);
|
|
|
|
struct capn dc;
|
|
struct CtrlStatus cap_out;
|
|
CtrlStatus_ptr dp;
|
|
xce_ctrlstatus_t out;
|
|
|
|
capn_init_mem(&dc, buf, sz, 0);
|
|
dp.p = capn_getp(capn_root(&dc), 0, 1);
|
|
read_CtrlStatus(&cap_out, dp);
|
|
memset(&out, 0, sizeof(out));
|
|
decode_CtrlStatus(&out, &cap_out);
|
|
|
|
TEST_ASSERT_EQUAL_UINT64(UINT64_C(1781939830911), out.elected_at_ms);
|
|
|
|
free_CtrlStatus(&out);
|
|
capn_free(&dc);
|
|
free(buf);
|
|
|
|
printf("test_ctrlstatus_electedatms_roundtrip: PASS\n");
|
|
}
|
|
|
|
/* ── (l) codec round-trip: CtrlPeer.status @3 (task 8.1, Req 13.1) ──
|
|
* Design delta "Design Delta — Requirement 13", Schema delta subsection: a new
|
|
* append-only UInt8 status code on the nested CtrlPeer list element (next
|
|
* free ordinal after port @2). Schema/codec-level only -- ctrlstatus_fill
|
|
* does not source real status values yet (task 8.2, out of boundary here).
|
|
* Proves a CtrlPeer.status value set to a non-default code (2 == catching-up)
|
|
* survives a genuine CtrlStatus encode -> bytes -> decode cycle unchanged,
|
|
* and that a peer whose status is left unset defaults to 0 (unknown) on the
|
|
* wire -- mirroring the peers-list construction pattern in
|
|
* src/lib/testsuite/test_ctrlstatus.c's test_ctrlstatus_roundtrip_all_fields.
|
|
*/
|
|
static void test_ctrlpeer_status_roundtrip(void)
|
|
{
|
|
xce_ctrlpeer_t p0 = {
|
|
.name = "ctrl-a", .host = "10.0.0.1", .port = 9991, .status = 2
|
|
};
|
|
xce_ctrlpeer_t p1 = {
|
|
.name = "ctrl-b", .host = "10.0.0.2", .port = 9992
|
|
/* .status intentionally left unset -- must default to 0 (unknown) */
|
|
};
|
|
xce_ctrlpeer_t* peerlist[2] = { &p0, &p1 };
|
|
|
|
xce_ctrlstatus_t in;
|
|
memset(&in, 0, sizeof(in));
|
|
in.clusterName = "lab-cluster";
|
|
in.responderName = "ctrl-a";
|
|
in.peers.count = 2;
|
|
in.peers.list = peerlist;
|
|
in.peerCount = 2;
|
|
|
|
struct capn c;
|
|
struct capn_segment* cs;
|
|
struct CtrlStatus cap_in;
|
|
CtrlStatus_ptr sp;
|
|
|
|
capn_init_malloc(&c);
|
|
cs = capn_root(&c).seg;
|
|
|
|
memset(&cap_in, 0, sizeof(cap_in));
|
|
encode_CtrlStatus(cs, &cap_in, &in);
|
|
|
|
sp = new_CtrlStatus(cs);
|
|
write_CtrlStatus(&cap_in, sp);
|
|
capn_setp(capn_root(&c), 0, sp.p);
|
|
|
|
size_t sz = capn_size(&c);
|
|
uint8_t* buf = (uint8_t*)malloc(sz);
|
|
TEST_ASSERT_NOT_NULL(buf);
|
|
TEST_ASSERT_EQUAL_size_t(sz, capn_write_mem(&c, buf, sz, 0));
|
|
capn_free(&c);
|
|
|
|
struct capn dc;
|
|
struct CtrlStatus cap_out;
|
|
CtrlStatus_ptr dp;
|
|
xce_ctrlstatus_t out;
|
|
|
|
capn_init_mem(&dc, buf, sz, 0);
|
|
dp.p = capn_getp(capn_root(&dc), 0, 1);
|
|
read_CtrlStatus(&cap_out, dp);
|
|
memset(&out, 0, sizeof(out));
|
|
decode_CtrlStatus(&out, &cap_out);
|
|
|
|
TEST_ASSERT_EQUAL_INT(2, out.peers.count);
|
|
TEST_ASSERT_NOT_NULL(out.peers.list);
|
|
TEST_ASSERT_NOT_NULL(out.peers.list[0]);
|
|
TEST_ASSERT_EQUAL_STRING("ctrl-a", out.peers.list[0]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(2, out.peers.list[0]->status);
|
|
TEST_ASSERT_NOT_NULL(out.peers.list[1]);
|
|
TEST_ASSERT_EQUAL_STRING("ctrl-b", out.peers.list[1]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(0, out.peers.list[1]->status);
|
|
|
|
free_CtrlStatus(&out);
|
|
capn_free(&dc);
|
|
free(buf);
|
|
|
|
printf("test_ctrlpeer_status_roundtrip: PASS\n");
|
|
}
|
|
|
|
/* ── (m) codec round-trip: HeartbeatMsg.electedAtMs @4 (task 7.1, Req 12.1) ──
|
|
* Same design-delta schema addition on the heartbeat message the primary
|
|
* already sends to standbys, next free ordinal after committedConfigTag @3.
|
|
* Direct struct construction + encode/decode -- no heartbeat send/receive
|
|
* logic is exercised (that is task 7.3, out of boundary for 7.1). Also proves
|
|
* the pre-existing sibling fields are undisturbed by the new ordinal. */
|
|
static void test_heartbeatmsg_electedatms_roundtrip(void)
|
|
{
|
|
heartbeat_msg_t in;
|
|
memset(&in, 0, sizeof(in));
|
|
in.sender_priority = 1;
|
|
in.term = 7;
|
|
in.committed_lsn = 100;
|
|
in.committed_config_tag = 200;
|
|
in.elected_at_ms = UINT64_C(1781939830911); /* known non-zero value */
|
|
|
|
struct capn c;
|
|
struct capn_segment* cs;
|
|
struct HeartbeatMsg cap_in;
|
|
HeartbeatMsg_ptr sp;
|
|
|
|
capn_init_malloc(&c);
|
|
cs = capn_root(&c).seg;
|
|
|
|
memset(&cap_in, 0, sizeof(cap_in));
|
|
encode_HeartbeatMsg(cs, &cap_in, &in);
|
|
|
|
sp = new_HeartbeatMsg(cs);
|
|
write_HeartbeatMsg(&cap_in, sp);
|
|
capn_setp(capn_root(&c), 0, sp.p);
|
|
|
|
size_t sz = capn_size(&c);
|
|
uint8_t* buf = (uint8_t*)malloc(sz);
|
|
TEST_ASSERT_NOT_NULL(buf);
|
|
TEST_ASSERT_EQUAL_size_t(sz, capn_write_mem(&c, buf, sz, 0));
|
|
capn_free(&c);
|
|
|
|
struct capn dc;
|
|
struct HeartbeatMsg cap_out;
|
|
HeartbeatMsg_ptr dp;
|
|
heartbeat_msg_t out;
|
|
|
|
capn_init_mem(&dc, buf, sz, 0);
|
|
dp.p = capn_getp(capn_root(&dc), 0, 1);
|
|
read_HeartbeatMsg(&cap_out, dp);
|
|
memset(&out, 0, sizeof(out));
|
|
decode_HeartbeatMsg(&out, &cap_out);
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(1, out.sender_priority);
|
|
TEST_ASSERT_EQUAL_UINT64(7, out.term);
|
|
TEST_ASSERT_EQUAL_UINT64(100, out.committed_lsn);
|
|
TEST_ASSERT_EQUAL_UINT64(200, out.committed_config_tag);
|
|
TEST_ASSERT_EQUAL_UINT64(UINT64_C(1781939830911), out.elected_at_ms);
|
|
|
|
/* heartbeat_msg_t has no heap-owned fields (all scalars) -- no free_* call */
|
|
capn_free(&dc);
|
|
free(buf);
|
|
|
|
printf("test_heartbeatmsg_electedatms_roundtrip: PASS\n");
|
|
}
|
|
|
|
/* (m) PRIMARY: elected_at_ms reports the responder's own learned value
|
|
* (Req 12.1, 12.2, 12.5). Design Delta Req 12 locked decision 2: BOTH
|
|
* promotion roads self-stamp `ha->last_known_elected_at_ms` at the instant
|
|
* they commit to CTRL_PRIMARY -- there is no separate primary-only marker
|
|
* the way there is for committedLsn (Req 10) -- so ctrlstatus_fill needs a
|
|
* single UNCONDITIONAL read of last_known_elected_at_ms for every role. This
|
|
* test seeds that field directly (same convention as the pre-existing
|
|
* committed-position tests) to prove the primary case round-trips it. */
|
|
static void test_ctrlstatus_primary_reports_own_elected_at_ms(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
/* self-stamped by the promotion road (task 7.2); seeded directly here per
|
|
* this task's own testing convention. */
|
|
atomic_store_explicit(&c.ha.last_known_elected_at_ms,
|
|
UINT64_C(1781939830911), memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_PRIMARY, st.responderRole);
|
|
TEST_ASSERT_NOT_EQUAL_UINT64(0, st.elected_at_ms);
|
|
TEST_ASSERT_EQUAL_UINT64(UINT64_C(1781939830911), st.elected_at_ms);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_primary_reports_own_elected_at_ms: PASS\n");
|
|
}
|
|
|
|
/* (n) STANDBY seeded with a learned election-time (simulating an already-
|
|
* processed heartbeat, task 7.3) reports that LEARNED value, not zero (Req
|
|
* 12.1, 12.2). Mirrors test_ctrlstatus_standby_reports_learned_committed's
|
|
* pattern for the committed pair -- except here there is no role-branch: the
|
|
* handler reads the SAME field unconditionally regardless of responder role. */
|
|
static void test_ctrlstatus_standby_reports_learned_elected_at_ms(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
/* learned from the primary's heartbeat (task 7.3); seeded directly here. */
|
|
atomic_store_explicit(&c.ha.last_known_elected_at_ms,
|
|
UINT64_C(1781939830911), memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8((uint8_t)CTRL_STANDBY, st.responderRole);
|
|
TEST_ASSERT_NOT_EQUAL_UINT64(0, st.elected_at_ms);
|
|
TEST_ASSERT_EQUAL_UINT64(UINT64_C(1781939830911), st.elected_at_ms);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_standby_reports_learned_elected_at_ms: PASS\n");
|
|
}
|
|
|
|
/* ── per-controller connection status (task 8.2, Req 13) ──────────────────
|
|
* Design delta "Design Delta — Requirement 13", "ctrlstatus_fill / peer-
|
|
* status delta — Service contract": self always ok; PRIMARY sources every
|
|
* other peer by host+port match against the live replication registry
|
|
* (replicator_t.standbys[]); non-primary sources every other peer as
|
|
* unknown; a self-match miss (misconfiguration) WARNs once and falls
|
|
* through to the normal non-self branches for every peer (no forced ok).
|
|
*/
|
|
|
|
/* (o) PRIMARY: three OTHER configured peers in three distinct connection
|
|
* states -- live+caught-up -> ok, live+catching-up -> catching-up, no live
|
|
* connection at all -> dead -- plus the responder's own entry, always ok
|
|
* regardless of the registry (Req 13.1-13.3, Locked Decisions 3-4). */
|
|
static void test_ctrlstatus_peer_status_primary_sourced_from_registry(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
|
|
/* cs_setup_cluster's default 3 candidates are self=ctrlA + ctrlB/ctrlC;
|
|
* add a 4th (MAX_HA_CANDIDATES == 4 covers self + 3 others exactly) with
|
|
* no matching registry slot at all. */
|
|
mc.peers_cfg.candidates_count = 4;
|
|
strncpy(mc.peers_cfg.candidates[3].name, "ctrlD",
|
|
sizeof(mc.peers_cfg.candidates[3].name) - 1);
|
|
strncpy(mc.peers_cfg.candidates[3].hostname, "10.0.0.4",
|
|
sizeof(mc.peers_cfg.candidates[3].hostname) - 1);
|
|
mc.peers_cfg.candidates[3].port = 7004;
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
|
c.ha.repl = &c.persist.repl;
|
|
|
|
/* ctrlB (10.0.0.2:7002): live, caught up -> ok */
|
|
int dummy_ep_b, dummy_ep_c;
|
|
c.persist.repl.standbys[0].ep = (repl_ep_t)&dummy_ep_b;
|
|
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[0].host) - 1);
|
|
c.persist.repl.standbys[0].port = 7002;
|
|
atomic_store_explicit(&c.persist.repl.standbys[0].in_catch_up, 0,
|
|
memory_order_release);
|
|
|
|
/* ctrlC (10.0.0.3:7003): live, still catching up -> catching-up */
|
|
c.persist.repl.standbys[1].ep = (repl_ep_t)&dummy_ep_c;
|
|
strncpy(c.persist.repl.standbys[1].host, "10.0.0.3",
|
|
sizeof(c.persist.repl.standbys[1].host) - 1);
|
|
c.persist.repl.standbys[1].port = 7003;
|
|
atomic_store_explicit(&c.persist.repl.standbys[1].in_catch_up, 1,
|
|
memory_order_release);
|
|
|
|
/* standbys[2] left zeroed (ep == NULL, host == "") -> ctrlD (10.0.0.4)
|
|
* has no matching slot at all -> dead. */
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(4, st.peerCount);
|
|
TEST_ASSERT_EQUAL_INT(4, st.peers.count);
|
|
TEST_ASSERT_NOT_NULL(st.peers.list);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlA", st.peers.list[0]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[0]->status); /* self: ok */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[1]->status); /* live+caught up */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlC", st.peers.list[2]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(2, st.peers.list[2]->status); /* live+catching up */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlD", st.peers.list[3]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(3, st.peers.list[3]->status); /* no live conn */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
repl_fini(&c.persist.repl);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_peer_status_primary_sourced_from_registry: PASS\n");
|
|
}
|
|
|
|
/* (p) STANDBY: reports ok for its own entry and unknown (0) for every other
|
|
* configured peer, including the primary -- even when a live-looking
|
|
* registry entry exists for that peer, since only the primary has direct
|
|
* connection visibility into every other node (Req 13.2, 13.4, Locked
|
|
* Decision 5). */
|
|
static void test_ctrlstatus_peer_status_standby_unknown_except_self(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
|
|
memory_order_release);
|
|
|
|
/* Even with a live-looking registry entry for ctrlB, a non-primary
|
|
* responder must NOT read it -- it has no direct connection visibility
|
|
* to any peer but itself. */
|
|
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
|
c.ha.repl = &c.persist.repl;
|
|
int dummy_ep;
|
|
c.persist.repl.standbys[0].ep = (repl_ep_t)&dummy_ep;
|
|
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[0].host) - 1);
|
|
c.persist.repl.standbys[0].port = 7002;
|
|
atomic_store_explicit(&c.persist.repl.standbys[0].in_catch_up, 0,
|
|
memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_INT(3, st.peers.count);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlA", st.peers.list[0]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[0]->status); /* self: ok */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(0, st.peers.list[1]->status); /* unknown */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlC", st.peers.list[2]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(0, st.peers.list[2]->status); /* unknown, incl. primary */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
repl_fini(&c.persist.repl);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_peer_status_standby_unknown_except_self: PASS\n");
|
|
}
|
|
|
|
/* (q) Misconfiguration fallback: a responder whose own identity matches NO
|
|
* configured peer still produces a defined, non-crashing result via the
|
|
* normal non-self sourcing -- no entry is force-corrected to ok (design.md
|
|
* Req 13 delta, self-match fallback). One genuine live+caught-up registry
|
|
* slot (matching ctrlB) proves the normal primary-branch sourcing still
|
|
* runs for every peer despite the self-match miss; ctrlA -- which would
|
|
* have been "self" under correct config -- gets the ordinary unmatched ->
|
|
* dead outcome like ctrlC, proving nothing was force-corrected to ok. The
|
|
* implementation also WARNs once per call in this case; this codebase has
|
|
* no log-content capture harness to assert that in a unit test, so it is
|
|
* verified by code inspection instead of a test assertion here. */
|
|
static void test_ctrlstatus_peer_status_self_mismatch_falls_through(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
/* Own identity matches none of the configured peer names (misconfigured
|
|
* --name vs --peers). */
|
|
c.myhost_info.name = "ctrlZ";
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
|
c.ha.repl = &c.persist.repl;
|
|
|
|
/* ctrlB (10.0.0.2:7002): live, caught up -> ok (genuine registry match). */
|
|
int dummy_ep;
|
|
c.persist.repl.standbys[0].ep = (repl_ep_t)&dummy_ep;
|
|
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[0].host) - 1);
|
|
c.persist.repl.standbys[0].port = 7002;
|
|
atomic_store_explicit(&c.persist.repl.standbys[0].in_catch_up, 0,
|
|
memory_order_release);
|
|
/* No matching slot for ctrlA (10.0.0.1:7001) or ctrlC (10.0.0.3:7003). */
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_INT(3, st.peers.count);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlA", st.peers.list[0]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(3, st.peers.list[0]->status); /* NOT forced to ok */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[1]->status); /* genuine match */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlC", st.peers.list[2]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(3, st.peers.list[2]->status);
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
repl_fini(&c.persist.repl);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_peer_status_self_mismatch_falls_through: PASS\n");
|
|
}
|
|
|
|
/* (r) PRIMARY: a killed-then-reconnected standby ends up owning TWO
|
|
* replicator_t.standbys[] slots for the SAME configured peer -- a
|
|
* lower-index slot left behind by the kill (ep == NULL, host/port still
|
|
* populated -- repl_add_standby's ep-identity idempotency check cannot
|
|
* reuse it, so the reconnect lands in a fresh, higher-index slot) and a
|
|
* higher-index slot for the genuine live reconnection (ep != NULL). A
|
|
* first-match-wins scan (pre-fix) reports the stale slot's dead status
|
|
* PERMANENTLY; the fix must scan every slot and prefer any live
|
|
* (ep != NULL) match over a stale (ep == NULL) one, regardless of which
|
|
* index the live slot lands in (Requirements 13.1, 13.3; task 8.6). */
|
|
static void test_ctrlstatus_peer_status_prefers_live_slot_over_stale_ok(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
|
c.ha.repl = &c.persist.repl;
|
|
|
|
/* Stale slot (lower index): ctrlB's OLD connection, killed -- ep
|
|
* cleared but host/port left behind, exactly as repl_remove_standby
|
|
* leaves it (this fixture models the pre-existing registry state,
|
|
* regardless of how it got there -- ctrlstatus_fill's boundary is
|
|
* reading it correctly, not preventing it). */
|
|
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[0].host) - 1);
|
|
c.persist.repl.standbys[0].port = 7002;
|
|
c.persist.repl.standbys[0].ep = NULL;
|
|
|
|
/* Live slot (higher index): ctrlB's NEW connection after reconnecting,
|
|
* fully caught up. */
|
|
int dummy_ep;
|
|
strncpy(c.persist.repl.standbys[1].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[1].host) - 1);
|
|
c.persist.repl.standbys[1].port = 7002;
|
|
c.persist.repl.standbys[1].ep = (repl_ep_t)&dummy_ep;
|
|
atomic_store_explicit(&c.persist.repl.standbys[1].in_catch_up, 0,
|
|
memory_order_release);
|
|
|
|
/* ctrlC: no matching slot at all -- unaffected, still dead. */
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_INT(3, st.peers.count);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlA", st.peers.list[0]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[0]->status); /* self: ok */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(1, st.peers.list[1]->status); /* live slot wins: ok */
|
|
TEST_ASSERT_EQUAL_STRING("ctrlC", st.peers.list[2]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(3, st.peers.list[2]->status); /* unaffected: dead */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
repl_fini(&c.persist.repl);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_peer_status_prefers_live_slot_over_stale_ok: PASS\n");
|
|
}
|
|
|
|
/* (s) Sibling of (r): the live slot is still catching up (not yet acked up
|
|
* to the cluster) -- the stale/live preference must still hold, and the
|
|
* reported status must be catching-up (2), not dead (3) and not the
|
|
* unconditional ok a naive "any live slot -> ok" fix would wrongly give. */
|
|
static void test_ctrlstatus_peer_status_prefers_live_slot_catching_up(void)
|
|
{
|
|
cluster_t c;
|
|
myconf_t mc;
|
|
cs_setup_cluster(&c, &mc);
|
|
|
|
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
|
|
memory_order_release);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, repl_init(&c.persist.repl));
|
|
c.ha.repl = &c.persist.repl;
|
|
|
|
/* Stale slot (lower index): ctrlB's OLD connection, killed. */
|
|
strncpy(c.persist.repl.standbys[0].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[0].host) - 1);
|
|
c.persist.repl.standbys[0].port = 7002;
|
|
c.persist.repl.standbys[0].ep = NULL;
|
|
|
|
/* Live slot (higher index): ctrlB's NEW connection, still catching up. */
|
|
int dummy_ep;
|
|
strncpy(c.persist.repl.standbys[1].host, "10.0.0.2",
|
|
sizeof(c.persist.repl.standbys[1].host) - 1);
|
|
c.persist.repl.standbys[1].port = 7002;
|
|
c.persist.repl.standbys[1].ep = (repl_ep_t)&dummy_ep;
|
|
atomic_store_explicit(&c.persist.repl.standbys[1].in_catch_up, 1,
|
|
memory_order_release);
|
|
|
|
xce_ctrlstatus_t st;
|
|
ctrlstatus_fill(&c, &st);
|
|
|
|
TEST_ASSERT_EQUAL_INT(3, st.peers.count);
|
|
TEST_ASSERT_EQUAL_STRING("ctrlB", st.peers.list[1]->name);
|
|
TEST_ASSERT_EQUAL_UINT8(2, st.peers.list[1]->status); /* live slot wins: catching-up */
|
|
|
|
ctrlstatus_free_fields(&st);
|
|
repl_fini(&c.persist.repl);
|
|
cs_teardown_cluster(&c);
|
|
printf("test_ctrlstatus_peer_status_prefers_live_slot_catching_up: PASS\n");
|
|
}
|
|
|
|
/* ── suite entry point ─────────────────────────────────────── */
|
|
|
|
void test_ctrlstatus(void)
|
|
{
|
|
UnitySetTestFile(__FILE__);
|
|
RUN_TEST(test_ctrlstatus_format_time_ms);
|
|
RUN_TEST(test_ctrlstatus_primary);
|
|
RUN_TEST(test_ctrlstatus_primary_reports_its_apply_cursor);
|
|
RUN_TEST(test_ctrlstatus_standby_known_primary);
|
|
RUN_TEST(test_ctrlstatus_unknown_primary);
|
|
RUN_TEST(test_ctrlstatus_standby_self_vote_is_unknown);
|
|
RUN_TEST(test_ctrlstatus_electing_is_unknown);
|
|
RUN_TEST(test_ctrlstatus_fenced_when_in_catch_up);
|
|
RUN_TEST(test_ctrlstatus_no_mutation);
|
|
RUN_TEST(test_ctrlstatus_standby_reports_learned_committed);
|
|
RUN_TEST(test_ctrlstatus_standby_prelearn_committed_zero);
|
|
RUN_TEST(test_ctrlstatus_primary_reports_own_committed);
|
|
RUN_TEST(test_ctrlstatus_responder_time_ms);
|
|
RUN_TEST(test_ctrlstatus_electedatms_roundtrip);
|
|
RUN_TEST(test_ctrlpeer_status_roundtrip);
|
|
RUN_TEST(test_heartbeatmsg_electedatms_roundtrip);
|
|
RUN_TEST(test_ctrlstatus_primary_reports_own_elected_at_ms);
|
|
RUN_TEST(test_ctrlstatus_standby_reports_learned_elected_at_ms);
|
|
RUN_TEST(test_ctrlstatus_peer_status_primary_sourced_from_registry);
|
|
RUN_TEST(test_ctrlstatus_peer_status_standby_unknown_except_self);
|
|
RUN_TEST(test_ctrlstatus_peer_status_self_mismatch_falls_through);
|
|
RUN_TEST(test_ctrlstatus_peer_status_prefers_live_slot_over_stale_ok);
|
|
RUN_TEST(test_ctrlstatus_peer_status_prefers_live_slot_catching_up);
|
|
}
|