From 3799bd6af43fc67a2a8be2bbec3b3879d64fde7a Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sat, 20 Jun 2026 09:17:07 +0000 Subject: [PATCH] feat(controller-status): off-primary learned committed + responder timestamp in ctrlstatus_fill (task 6.2) Role-branch the committed sourcing: PRIMARY reports its own authoritative persist.committed_lsn / committed_config_tag; off-primary reports the LEARNED ha->last_known_committed_lsn / last_known_committed_config_tag instead of the primary-only raw markers that read 0 on a standby (fixes the applied>committed inversion). Stamp responder_time_ms from CLOCK_REALTIME for log correlation. Read-only (Req 10.1-10.4, 11.1). 4 new TDD tests; controller suite 110/0/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .kiro/specs/controller-status/tasks.md | 2 +- src/controller/ctrlstatus.c | 43 +++++- src/controller/testsuite/test_ctrlstatus.c | 146 +++++++++++++++++++++ 3 files changed, 183 insertions(+), 8 deletions(-) diff --git a/.kiro/specs/controller-status/tasks.md b/.kiro/specs/controller-status/tasks.md index 6deba1f6..1086531e 100644 --- a/.kiro/specs/controller-status/tasks.md +++ b/.kiro/specs/controller-status/tasks.md @@ -70,7 +70,7 @@ - Observable: the build regenerates `controller.capnp.h/.c` clean, the typemap matches the codec, and a codec round-trip carries the new field's value unchanged. - _Requirements: 11.1_ - _Boundary: schema + typemap_ -- [ ] 6.2 Source off-primary committed values + stamp the timestamp in `ctrlstatus_fill` +- [x] 6.2 Source off-primary committed values + stamp the timestamp in `ctrlstatus_fill` - TDD (red first, in `test_ctrlstatus.c`): a STANDBY whose learned committed position is non-zero while its local committed marker is zero and it has applied entries reports the LEARNED committed LSN and committed config version (not zero) and the real applied position (no applied-greater-than-committed inversion); a STANDBY that has not yet learned any committed position reports committed LSN and committed config version as zero/unknown; a PRIMARY reports its own authoritative local committed LSN and committed config tag (unchanged); and the responder snapshot timestamp is non-zero and within tolerance of a test-captured wall-clock sample. Assert the handler still mutates no state. - Change `ctrlstatus_fill` (`src/controller/ctrlstatus.c`): role-branch the committed sourcing — when the responder is primary, keep its own committed LSN / committed config tag; otherwise report the learned committed LSN / learned committed config tag. Stamp the responder snapshot timestamp from the wall clock (`CLOCK_REALTIME`, NOT the monotonic clock) at snapshot time. All additions are atomic/aligned loads + a clock read — no state mutation. - Observable: the new unit tests go red → green; the standby case reports the learned non-zero committed value instead of zero; the no-mutation assertion holds. diff --git a/src/controller/ctrlstatus.c b/src/controller/ctrlstatus.c index 8ec6d202..5718621f 100644 --- a/src/controller/ctrlstatus.c +++ b/src/controller/ctrlstatus.c @@ -17,6 +17,7 @@ #include #include #include +#include #include "cluster.h" #include "ha.h" @@ -104,10 +105,18 @@ void ctrlstatus_fill(cluster_t* c, xce_ctrlstatus_t* st) st->primaryName = strdup(""); /* unknown — not reliably known off-primary */ } - /* loaded config version (+ committed version) */ - st->configVersion = mc ? mc->config_version_tag : 0; - st->committedConfigVersion = atomic_load_explicit(&ha->committed_config_tag, - memory_order_acquire); + /* responder snapshot timestamp (Req 11.1): CLOCK_REALTIME (wall clock, NOT + * CLOCK_MONOTONIC which is used for heartbeat liveness) at the sample + * moment, in epoch millis, so an operator can line a captured status up + * against this responder's log timestamps. Stamped here with the other + * field loads to mark the snapshot instant. */ + struct timespec ctrlstatus_ts; + clock_gettime(CLOCK_REALTIME, &ctrlstatus_ts); + st->responder_time_ms = (uint64_t)ctrlstatus_ts.tv_sec * 1000u + + (uint64_t)ctrlstatus_ts.tv_nsec / 1000000u; + + /* loaded config version */ + st->configVersion = mc ? mc->config_version_tag : 0; /* configChecksum: 64-char lowercase-hex of the loaded config_sha256[32] * (Req 5.2 content checksum, 5.3 divergence detection — see NOTE above). @@ -126,9 +135,29 @@ void ctrlstatus_fill(cluster_t* c, xce_ctrlstatus_t* st) } } - /* commit / apply progress (Req 6.2) */ - st->committedLsn = atomic_load_explicit(&c->persist.committed_lsn, - memory_order_acquire); + /* commit / apply progress (Req 6.2) — committed sourcing is role-branched + * (Req 10.1/10.2): a PRIMARY's own live markers are authoritative; any + * other role (STANDBY/ELECTING/INIT) has never served as primary, so its + * node-local committed marker is unset (0) and the only honest "committed" + * knowledge is what it has LEARNED from the primary's heartbeats. Reporting + * the learned pair avoids a misleading committed=0 while the node has + * clearly applied replicated entries, and keeps committed/applied sourced + * consistently (no spurious applied>committed inversion, Req 10.3). Pre- + * learn the learned values are 0 → well-defined "unknown" (Req 10.2). */ + if (st->responderRole == (uint8_t)CTRL_PRIMARY) { + st->committedLsn = atomic_load_explicit(&c->persist.committed_lsn, + memory_order_acquire); + st->committedConfigVersion = + atomic_load_explicit(&ha->committed_config_tag, + memory_order_acquire); + } else { + st->committedLsn = atomic_load_explicit(&ha->last_known_committed_lsn, + memory_order_acquire); + /* last_known_committed_config_tag is a plain (non-atomic) uint64_t + * written under ha->lock; a best-effort aligned 8-byte read is correct + * here and must NOT take the lock on this read path. */ + st->committedConfigVersion = ha->last_known_committed_config_tag; + } st->appliedLsn = atomic_load_explicit(&ha->applied_lsn, memory_order_acquire); diff --git a/src/controller/testsuite/test_ctrlstatus.c b/src/controller/testsuite/test_ctrlstatus.c index 623e85e8..d8b67800 100644 --- a/src/controller/testsuite/test_ctrlstatus.c +++ b/src/controller/testsuite/test_ctrlstatus.c @@ -10,6 +10,7 @@ #include #include #include +#include #include @@ -332,6 +333,147 @@ static void test_ctrlstatus_no_mutation(void) 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); + c.ha.last_known_committed_config_tag = 0x00C0FFEE0000CAFEULL; + + 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); + c.ha.last_known_committed_config_tag = 0; + /* 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); + c.ha.last_known_committed_config_tag = 0x1111ULL; + + 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"); +} + /* ── suite entry point ─────────────────────────────────────── */ void test_ctrlstatus(void) @@ -343,4 +485,8 @@ void test_ctrlstatus(void) 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); }