feat(ha-heartbeat-committed-propagation): standby OP_nop consume branch + term-guard (task 2.2)

Append a committed-consume branch to the OP_nop handler (existing liveness/reply
byte-unchanged): ha_consume_heartbeat_committed applies the non-primary gate
(3.4) + stale-term guard (accept hb->term >= ha->term, 3.2), then delegates to
the monotonic ha_observe_heartbeat_committed (1.2). Absent body / zero / failed
gate are no-ops (4.2). No vote-floor edit — safety is the term-guard. 4 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-06-20 12:02:15 +00:00
parent 8bfa861be2
commit 90e7179a89
5 changed files with 200 additions and 1 deletions

View file

@ -18,6 +18,7 @@
#include <unity/unity.h>
#include "ha.h"
#include "typemap.h" /* heartbeat_msg_t for the consume-branch tests */
#include "xce/cluster_def.h" /* full cluster_t (embedded ha_state_t / persist_t) */
/* ── helpers ────────────────────────────────────────────────── */
@ -197,6 +198,140 @@ static void test_observe_no_reconcile_no_other_mutation(void)
printf("test_observe_no_reconcile_no_other_mutation: PASS\n");
}
/* ── task 2.2: standby consume branch — term-guard + non-primary gate ─────────
*
* ha_consume_heartbeat_committed(cluster_t*, const heartbeat_msg_t*) is the
* safety-critical wrapper the OP_nop handler calls: it applies the non-primary
* gate (3.4) and the term-guard (3.2) before delegating to the monotonic
* ha_observe_heartbeat_committed (1.2). It is NULL-safe and zero-safe (4.2).
* Unit-testable without a socket/endpoint.
*/
/* (e) Term-valid heartbeat on a standby advances the learned committed (1.2). */
static void test_consume_term_valid_advances(void)
{
cluster_t c;
hb_setup_cluster(&c);
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
memory_order_release);
atomic_store_explicit(&c.ha.term, 5, memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_lsn, 4,
memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_config_tag, 0x11,
memory_order_release);
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = 5, /* == standby term → passes term-guard */
.committed_lsn = 9,
.committed_config_tag = 0xAA,
};
ha_consume_heartbeat_committed(&c, &hb);
TEST_ASSERT_EQUAL_UINT64(9, hb_lsn(&c)); /* advanced (1.2) */
TEST_ASSERT_EQUAL_UINT64(0xAA, hb_tag(&c)); /* tag moved with lsn */
hb_teardown_cluster(&c);
printf("test_consume_term_valid_advances: PASS\n");
}
/* (f) Stale-term heartbeat (hb->term < ha->term) carrying a higher committed
* leaves the learned committed UNCHANGED — the term-guard fences a stale primary
* from inflating the vote-freshness floor (3.2). */
static void test_consume_stale_term_ignored(void)
{
cluster_t c;
hb_setup_cluster(&c);
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
memory_order_release);
atomic_store_explicit(&c.ha.term, 7, memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_lsn, 4,
memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_config_tag, 0x11,
memory_order_release);
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = 6, /* < standby term 7 → fails term-guard */
.committed_lsn = 99,
.committed_config_tag = 0xBB,
};
ha_consume_heartbeat_committed(&c, &hb);
TEST_ASSERT_EQUAL_UINT64(4, hb_lsn(&c)); /* unchanged (3.2) */
TEST_ASSERT_EQUAL_UINT64(0x11, hb_tag(&c)); /* unchanged */
hb_teardown_cluster(&c);
printf("test_consume_stale_term_ignored: PASS\n");
}
/* (g) A primary responder does not consume an inbound committed value — the
* primary uses its own authoritative committed (3.4). */
static void test_consume_primary_does_not_consume(void)
{
cluster_t c;
hb_setup_cluster(&c);
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_PRIMARY,
memory_order_release);
atomic_store_explicit(&c.ha.term, 5, memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_lsn, 4,
memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_config_tag, 0x11,
memory_order_release);
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = 9, /* fresh term, higher committed — still ignored */
.committed_lsn = 50,
.committed_config_tag = 0xCC,
};
ha_consume_heartbeat_committed(&c, &hb);
TEST_ASSERT_EQUAL_UINT64(4, hb_lsn(&c)); /* unchanged (3.4) */
TEST_ASSERT_EQUAL_UINT64(0x11, hb_tag(&c)); /* unchanged */
hb_teardown_cluster(&c);
printf("test_consume_primary_does_not_consume: PASS\n");
}
/* (h) NULL body and zero committed are no-ops — an absent/liveness-only
* heartbeat updates nothing (4.2). */
static void test_consume_null_or_zero_noop(void)
{
cluster_t c;
hb_setup_cluster(&c);
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
memory_order_release);
atomic_store_explicit(&c.ha.term, 5, memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_lsn, 6,
memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_config_tag, 0x66,
memory_order_release);
/* NULL body → no-op (absent heartbeatmsg). */
ha_consume_heartbeat_committed(&c, NULL);
TEST_ASSERT_EQUAL_UINT64(6, hb_lsn(&c));
TEST_ASSERT_EQUAL_UINT64(0x66, hb_tag(&c));
/* zero committed (valid term, standby) → no-op. */
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = 5,
.committed_lsn = 0,
.committed_config_tag = 0xDD,
};
ha_consume_heartbeat_committed(&c, &hb);
TEST_ASSERT_EQUAL_UINT64(6, hb_lsn(&c));
TEST_ASSERT_EQUAL_UINT64(0x66, hb_tag(&c));
hb_teardown_cluster(&c);
printf("test_consume_null_or_zero_noop: PASS\n");
}
/* ── suite entry point ─────────────────────────────────────── */
void test_ha_heartbeat_committed(void)
@ -205,4 +340,9 @@ void test_ha_heartbeat_committed(void)
RUN_TEST(test_observe_noop_on_equal_or_lower);
RUN_TEST(test_observe_noop_on_zero);
RUN_TEST(test_observe_no_reconcile_no_other_mutation);
/* task 2.2: consume branch — term-guard + non-primary gate */
RUN_TEST(test_consume_term_valid_advances);
RUN_TEST(test_consume_stale_term_ignored);
RUN_TEST(test_consume_primary_does_not_consume);
RUN_TEST(test_consume_null_or_zero_noop);
}