diff --git a/.kiro/specs/controller-status/tasks.md b/.kiro/specs/controller-status/tasks.md index a05e2d97..ac702a5d 100644 --- a/.kiro/specs/controller-status/tasks.md +++ b/.kiro/specs/controller-status/tasks.md @@ -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. diff --git a/src/controller/ha.h b/src/controller/ha.h index 1461043b..1a997328 100644 --- a/src/controller/ha.h +++ b/src/controller/ha.h @@ -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) ──────── */ diff --git a/src/controller/testsuite/test_ctrlstatus.c b/src/controller/testsuite/test_ctrlstatus.c index f657a4ab..6c02f084 100644 --- a/src/controller/testsuite/test_ctrlstatus.c +++ b/src/controller/testsuite/test_ctrlstatus.c @@ -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); } diff --git a/src/controller/testsuite/test_ha_role.c b/src/controller/testsuite/test_ha_role.c index eea626c4..ae77700f 100644 --- a/src/controller/testsuite/test_ha_role.c +++ b/src/controller/testsuite/test_ha_role.c @@ -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); diff --git a/src/include/typemap.h b/src/include/typemap.h index 709fc024..382909cb 100644 --- a/src/include/typemap.h +++ b/src/include/typemap.h @@ -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) */ diff --git a/src/schema/controller.capnp b/src/schema/controller.capnp index 2cd6846c..50909cb9 100644 --- a/src/schema/controller.capnp +++ b/src/schema/controller.capnp @@ -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 ──────────────────────────────