feat(controller-status): add election-time schema fields + ha_state_t (task 7.1)

Adds CtrlStatus.electedAtMs@14 and HeartbeatMsg.electedAtMs@4 to the
capnp schema, their hand-authored typemap.h counterparts, and a new
ha_state_t.last_known_elected_at_ms field alongside last_known_committed_lsn.
Schema/state plumbing only -- nothing stamps or consumes these fields yet
(tasks 7.2-7.5). Codec round-trip tests confirm both new fields survive
encode/decode unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
This commit is contained in:
Rongsong Shen 2026-09-16 04:21:02 +00:00
parent 7556856e5c
commit cae1039ed7
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
6 changed files with 184 additions and 1 deletions

View file

@ -92,7 +92,7 @@
## 7. Delta: primary election term + time (Req 12)
> Sequential — the election-time stamp touches the same HA promotion/heartbeat control flow as tasks 2.2/6.2 (ha.c); no `(P)`. Read-only reporting only: this task must not change how elections are conducted, how term advances, or how/when role transitions to primary — every write here is an additional observational stamp at an already-decided instant. `electedTerm` needs no new field (design.md Locked Decision 1); it is a CLI-side derivation added in 7.5, not new state.
- [ ] 7.1 Add the election-time schema fields and controller state
- [x] 7.1 Add the election-time schema fields and controller state
- Add a wall-clock election-time field to the status reply payload (next free ordinal, append-only) and to the heartbeat message the primary already sends to standbys (next free ordinal).
- Add the corresponding piece of HA state that holds the currently-known primary's election time, alongside the existing learned-committed-position state.
- Add the matching fields to the hand-authored C structs backing these two payloads (this schema's `$C.mapname` types are NOT auto-generated — the generated codec references hand-written typedefs in `typemap.h`, per tasks 1.1/2.1/6.1's established convention) and verify each new field field-for-field against the regenerated codec.

View file

@ -184,6 +184,17 @@ typedef struct {
sem_t heartbeat_seen; /* Req 1: posted by heartbeat handler during CTRL_INIT */
_Atomic uint64_t last_known_committed_config_tag; /* Req 8: from primary heartbeats */
_Atomic uint64_t committed_config_tag; /* Req 8.1: set on quorum ACK */
/* ── New field added by controller-status spec delta (Req 12, task 7.1) ── */
_Atomic uint64_t last_known_elected_at_ms; /* Currently-known primary's
* election time (epoch ms): self-stamped
* by both promotion roads at the instant
* each commits to CTRL_PRIMARY (task 7.2,
* not yet wired), unconditionally learned
* off-primary via heartbeat (task 7.3, not
* yet wired) -- mirrors last_known_committed_lsn/
* last_known_committed_config_tag above.
* 0/unset at init (ha_init memset) until
* the first promotion or heartbeat. */
/* ── New fields added by election-iomgr-refactor spec (Task 2.1) ── */
void *client_ssl_ctx; /* SSL_CTX* (TLS_client_method); created in myapp_run */
/* ── New field added by raft-drive-async spec (Task 3.1) ──────── */

View file

@ -24,6 +24,9 @@
#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)
@ -511,6 +514,125 @@ static void test_ctrlstatus_format_time_ms(void)
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: 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");
}
/* ── suite entry point ─────────────────────────────────────── */
void test_ctrlstatus(void)
@ -528,4 +650,6 @@ void test_ctrlstatus(void)
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_heartbeatmsg_electedatms_roundtrip);
}

View file

@ -229,6 +229,44 @@ static void test_ha_init_zeroes_standby_convergence_fields(void)
printf("test_ha_init_zeroes_standby_convergence_fields: PASS\n");
}
/* ── Task 7.1 (controller-status delta, Req 12.1): ha_state_t.last_known_elected_at_ms
* zero-initialised by ha_init ──────────────────────────────────────────────
*
* New HA state (design.md "Design Delta — Requirement 12", Locked Decision 2)
* holding the currently-known primary's election time, declared alongside
* last_known_committed_lsn/last_known_committed_config_tag. Stamped by
* promotion (task 7.2, not yet wired) and learned via heartbeat (task 7.3,
* not yet wired); this test only proves the field starts at the well-defined
* zero/unknown value before any election, matching its siblings.
*/
static void test_ha_init_zeroes_last_known_elected_at_ms(void)
{
ha_state_t ha;
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,
};
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
TEST_ASSERT_EQUAL_UINT64(
0, atomic_load_explicit(&ha.last_known_elected_at_ms,
memory_order_acquire));
/* field must be writable (mirrors the sibling last_known_* fields) */
atomic_store_explicit(&ha.last_known_elected_at_ms,
UINT64_C(1781939830911), memory_order_release);
TEST_ASSERT_EQUAL_UINT64(
UINT64_C(1781939830911),
atomic_load_explicit(&ha.last_known_elected_at_ms,
memory_order_acquire));
ha_fini(&ha);
printf("test_ha_init_zeroes_last_known_elected_at_ms: PASS\n");
}
/* ── Task 3.1: ha_process_standby_state convergence tracking ─────────────── */
/*
@ -991,6 +1029,7 @@ void test_ha_role(void)
RUN_TEST(test_ha_init_zeroes_new_fields);
/* Task 1.4: ha-repl-hardening per-standby convergence fields */
RUN_TEST(test_ha_init_zeroes_standby_convergence_fields);
RUN_TEST(test_ha_init_zeroes_last_known_elected_at_ms);
/* Task 3.1: ha_process_standby_state convergence tracking */
RUN_TEST(test_process_standby_state_converged);
RUN_TEST(test_process_standby_state_lsn_behind);

View file

@ -284,6 +284,8 @@ typedef struct {
} peers;
uint32_t peerCount;
uint64_t responder_time_ms; /* @13: responder wall-clock at sample time (epoch ms) */
uint64_t elected_at_ms; /* @14: current primary's election time (epoch ms),
* learned off-primary via heartbeat; 0 = unknown */
} xce_ctrlstatus_t;
/* ── Worker rejoin/resync job-state reconciliation (worker-rejoin-resync
@ -613,6 +615,8 @@ struct heartbeat_msg_s {
uint64_t term; /* sender's current term */
uint64_t committed_lsn; /* Req 5: primary's committed WAL LSN */
uint64_t committed_config_tag; /* Req 8: primary's committed config tag */
uint64_t elected_at_ms; /* Req 12.2: primary's own last_known_elected_at_ms,
* echoed on every heartbeat */
};
/* CtrlHello state sync (Req 6.5, 6.6, 6.7) */

View file

@ -551,6 +551,9 @@ struct CtrlStatus $C.mapname("xce_ctrlstatus_t") {
peers @11: List(CtrlPeer) $C.mapname("peers.list") $C.maplistcount("peers.count");
peerCount @12: UInt32;
responderTimeMs @13: UInt64 $C.mapname("responder_time_ms"); # responder's wall-clock at sample time (epoch milliseconds)
electedAtMs @14: UInt64 $C.mapname("elected_at_ms");
# epoch millis of the current primary's election, learned off-primary via
# heartbeat; 0/unset when no primary is known (gated together with primaryName)
}
struct ClusterConf $C.mapname("xce_clusterconf_t") {
@ -909,6 +912,8 @@ struct HeartbeatMsg $C.mapname("heartbeat_msg_t") {
term @1: UInt64 $C.mapname("term");
committedLsn @2: UInt64 $C.mapname("committed_lsn");
committedConfigTag @3: UInt64 $C.mapname("committed_config_tag");
electedAtMs @4: UInt64 $C.mapname("elected_at_ms");
# primary's own last_known_elected_at_ms, echoed on every heartbeat (Req 12.2)
}
# ── Config replication message structs ──────────────────────────────