feat(controller-status): carry election time on the heartbeat (task 7.3)

The primary echoes its own last_known_elected_at_ms on every outgoing
heartbeat (threaded through xce_ha_heartbeat_req's existing scalar-arg
plumbing, alongside term/committed_lsn/committed_config_tag); a standby
processing a received heartbeat (ha_consume_heartbeat_committed, the sole
production call site) stores it unconditionally into its own
last_known_elected_at_ms, reconcile-free -- no other HA field moves.
ha_observe_heartbeat_committed itself is untouched; the new store sits
next to its call site, not inside it.

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 06:03:03 +00:00
parent 21ae00b375
commit 30adc52ea3
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
9 changed files with 252 additions and 33 deletions

View file

@ -108,7 +108,7 @@
- _Boundary: promotion (ha_do_promote, raft_cb_become_leader)_
- _Depends: 7.1_
- [ ] 7.3 Carry the primary's election time to standbys over the heartbeat
- [x] 7.3 Carry the primary's election time to standbys over the heartbeat
- TDD (red first): a standby that processes a heartbeat carrying the primary's election time stores it as its own learned election-time and leaves every other learned/produced HA field (committed position, role, term, applied position) untouched (reconcile-free).
- The primary includes its own election time in every heartbeat it sends; a standby that receives a heartbeat stores the carried election time as its learned value.
- Observable: the reconcile-free test passes — only the learned election-time changes, nothing else moves; a live/loopback heartbeat round-trip shows the standby's learned election-time matching the primary's.

View file

@ -5901,12 +5901,14 @@ int (*ha_nop_req_hook)(endpoint_t *ep) = NULL;
* ha_heartbeat_req_hook: test-only seam for the HA heartbeat send in
* primary_heartbeat_fn. When non-NULL, primary_heartbeat_fn calls this INSTEAD
* OF xce_ha_heartbeat_req with the SAME (term, committed_lsn,
* committed_config_tag) it stamps onto the wire, so tests can assert the
* heartbeat carries the primary's own current committed state. Req 1.1, 3.4.
* committed_config_tag, elected_at_ms) it stamps onto the wire, so tests can
* assert the heartbeat carries the primary's own current committed state and
* election time. Req 1.1, 3.4, 12.2.
*/
int (*ha_heartbeat_req_hook)(endpoint_t *ep, uint64_t term,
uint64_t committed_lsn,
uint64_t committed_config_tag) = NULL;
uint64_t committed_config_tag,
uint64_t elected_at_ms) = NULL;
/*
* ha_reconnect_connect_hook: test-only seam for the BLOCKING half of the
@ -9257,6 +9259,19 @@ void ha_consume_heartbeat_committed(cluster_t *c, const heartbeat_msg_t *hb)
ha_observe_heartbeat_committed(c, hb->committed_lsn,
hb->committed_config_tag);
/* (Req 12.2) Learn the primary's election time. Unconditional overwrite —
* no monotonic/term compare needed, unlike the committed-position observe
* above: this call site already passed the non-primary gate + stale-term
* guard above, i.e. it is already gated on "this heartbeat is from my
* currently-recognized leader," the SAME precondition
* ha_observe_heartbeat_committed's own committed_lsn/committed_config_tag
* learn relies on. Deliberately NOT added inside
* ha_observe_heartbeat_committed itself (that function's contract/tests
* are unchanged) — a separate, unconditional store immediately alongside
* it, reconcile-free (touches nothing else). */
atomic_store_explicit(&c->ha.last_known_elected_at_ms, hb->elected_at_ms,
memory_order_release);
/* 2026-07-10: drive this node's own committed_lsn + apply pump from the
* position just learned (see ha_standby_advance_committed_and_apply) —
* the commit-then-apply half of the standby ingest path. */
@ -9874,6 +9889,14 @@ static int primary_heartbeat_fn(void *arg)
uint64_t hb_committed_config_tag =
atomic_load_explicit(&ha->committed_config_tag,
memory_order_acquire);
/* Req 12.2: echo the primary's own currently-known election time
* on every heartbeat so standbys can learn it. Self-stamped by
* both promotion roads (task 7.2) at the instant this node
* committed to CTRL_PRIMARY; read unconditionally here, same
* shape as hb_term/hb_committed_lsn/hb_committed_config_tag. */
uint64_t hb_elected_at_ms =
atomic_load_explicit(&ha->last_known_elected_at_ms,
memory_order_acquire);
/* Endpoints evicted this tick; their repl deregistration runs AFTER
* ha->lock is released (repl lock-ordering — mirrors the err handlers). */
@ -9920,17 +9943,20 @@ static int primary_heartbeat_fn(void *arg)
if (ha_heartbeat_req_hook)
rc = ha_heartbeat_req_hook(ep, hb_term,
hb_committed_lsn,
hb_committed_config_tag);
hb_committed_config_tag,
hb_elected_at_ms);
else if (ha_nop_req_hook)
rc = ha_nop_req_hook(ep);
else
rc = xce_ha_heartbeat_req(ep, hb_term,
hb_committed_lsn,
hb_committed_config_tag);
hb_committed_config_tag,
hb_elected_at_ms);
#else
int rc = xce_ha_heartbeat_req(ep, hb_term,
hb_committed_lsn,
hb_committed_config_tag);
hb_committed_config_tag,
hb_elected_at_ms);
#endif
if (rc < 0)
WARN("HA: heartbeat nop to standby[%d] failed: %d", i, rc);

View file

@ -2047,10 +2047,11 @@ extern int (*ha_push_config_hook)(void *c);
/*
* ha_heartbeat_req_hook: test-only seam for the per-standby HA heartbeat send
* in primary_heartbeat_fn (Task 3.1). When non-NULL, primary_heartbeat_fn
* calls this INSTEAD OF xce_ha_heartbeat_req, passing the SAME three values it
* calls this INSTEAD OF xce_ha_heartbeat_req, passing the SAME four values it
* stamps onto the wire — the primary's CURRENT own term / committed_lsn /
* committed_config_tag. A test installs it to capture those args and assert
* the heartbeat carries the primary's own committed state (Req 1.1, 3.4).
* committed_config_tag / elected_at_ms. A test installs it to capture those
* args and assert the heartbeat carries the primary's own committed state and
* election time (Req 1.1, 3.4, 12.2).
*
* Precedence (XCE_TESTING only): ha_heartbeat_req_hook (if set) takes priority,
* else the legacy ha_nop_req_hook (if set), else the real xce_ha_heartbeat_req.
@ -2058,7 +2059,8 @@ extern int (*ha_push_config_hook)(void *c);
*/
extern int (*ha_heartbeat_req_hook)(endpoint_t *ep, uint64_t term,
uint64_t committed_lsn,
uint64_t committed_config_tag);
uint64_t committed_config_tag,
uint64_t elected_at_ms);
/*
* ha_reconnect_connect_hook: test-only seam for the BLOCKING half of the

View file

@ -61,6 +61,12 @@ static uint64_t hb_tag(const cluster_t* c)
memory_order_acquire);
}
static uint64_t hb_elected(const cluster_t* c)
{
return atomic_load_explicit(&c->ha.last_known_elected_at_ms,
memory_order_acquire);
}
/* ── tests ──────────────────────────────────────────────────── */
/* (a) Advance on a strictly higher carried LSN: both the LSN and the config tag
@ -332,6 +338,162 @@ static void test_consume_null_or_zero_noop(void)
printf("test_consume_null_or_zero_noop: PASS\n");
}
/* ── controller-status delta (Req 12.2): learn the primary's election time
* off a received heartbeat ─────────────────────────────────────────────────
*
* ha_consume_heartbeat_committed's SAME call site that feeds
* ha_observe_heartbeat_committed(c, hb->committed_lsn, hb->committed_config_tag)
* also carries hb->elected_at_ms — a genuinely-RECEIVED heartbeat (the
* production caller is cluster.c's OP_nop dispatch) already gates on
* non-primary role + a non-stale term before either call, so the
* elected_at_ms store is a plain unconditional overwrite immediately
* alongside the existing committed-position learn — no separate monotonic/
* term compare (design.md Locked Decision 2). ha_observe_heartbeat_committed
* itself is untouched; these tests exercise ONLY the new sibling store.
*/
/* (i) A term-valid heartbeat stores the carried election time as the
* standby's own learned value (Req 12.2). */
static void test_consume_stores_elected_at_ms(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_elected_at_ms, 111,
memory_order_release);
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = 5, /* == standby term -> passes term-guard */
.committed_lsn = 9,
.committed_config_tag = 0xAA,
.elected_at_ms = 222222,
};
ha_consume_heartbeat_committed(&c, &hb);
TEST_ASSERT_EQUAL_UINT64(222222, hb_elected(&c)); /* learned (12.2) */
hb_teardown_cluster(&c);
printf("test_consume_stores_elected_at_ms: PASS\n");
}
/* (j) Reconcile-free: a heartbeat carrying ONLY a new election time (its
* committed_lsn is 0, a liveness-only no-op for the committed observe) moves
* last_known_elected_at_ms and NOTHING else -- committed position, role,
* term, and applied position are all untouched. Mirrors
* test_observe_no_reconcile_no_other_mutation's shape for the new field
* (Req 12.2, 12.5). This is the RED test: it fails (elected_at_ms stays at
* its seeded value) before the store exists in ha_consume_heartbeat_committed. */
static void test_consume_elected_at_ms_reconcile_free(void)
{
cluster_t c;
hb_setup_cluster(&c);
/* Seed every learned/produced HA field the store must NOT touch. */
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.applied_lsn, 100, memory_order_release);
atomic_store_explicit(&c.persist.committed_lsn, 80, memory_order_release);
atomic_store_explicit(&c.persist.durable_lsn, 80, memory_order_release);
c.persist.current_lsn = 81;
atomic_store_explicit(&c.ha.committed_config_tag, 0xC0FFEE,
memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_lsn, 60,
memory_order_release);
atomic_store_explicit(&c.ha.last_known_committed_config_tag, 0x6060,
memory_order_release);
atomic_store_explicit(&c.ha.last_known_elected_at_ms, 1111,
memory_order_release);
/* Snapshot everything that must remain byte-for-byte unchanged. */
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 pcommit0 = atomic_load_explicit(&c.persist.committed_lsn,
memory_order_acquire);
uint64_t pdur0 = atomic_load_explicit(&c.persist.durable_lsn,
memory_order_acquire);
uint64_t ptip0 = c.persist.current_lsn;
uint64_t ctag0 = atomic_load_explicit(&c.ha.committed_config_tag,
memory_order_acquire);
uint64_t klsn0 = hb_lsn(&c);
uint64_t ktag0 = hb_tag(&c);
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = 5, /* == standby term -> passes term-guard */
.committed_lsn = 0, /* liveness-only -> observe no-ops (4.2) */
.committed_config_tag = 0xDEAD,
.elected_at_ms = 9999,
};
ha_consume_heartbeat_committed(&c, &hb);
/* ONLY last_known_elected_at_ms changed. */
TEST_ASSERT_EQUAL_UINT64(9999, hb_elected(&c));
/* Committed position -- untouched (observe no-op on committed_lsn==0). */
TEST_ASSERT_EQUAL_UINT64(klsn0, hb_lsn(&c));
TEST_ASSERT_EQUAL_UINT64(ktag0, hb_tag(&c));
/* No truncation / no produced-state mutation. */
TEST_ASSERT_EQUAL_UINT64(pcommit0,
atomic_load_explicit(&c.persist.committed_lsn,
memory_order_acquire));
TEST_ASSERT_EQUAL_UINT64(pdur0,
atomic_load_explicit(&c.persist.durable_lsn,
memory_order_acquire));
TEST_ASSERT_EQUAL_UINT64(ptip0, c.persist.current_lsn);
/* No other HA state moved. */
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(ctag0,
atomic_load_explicit(&c.ha.committed_config_tag,
memory_order_acquire));
hb_teardown_cluster(&c);
printf("test_consume_elected_at_ms_reconcile_free: PASS\n");
}
/* (k) A stale-term heartbeat (hb->term < ha->term) must NOT store its carried
* election time either -- the elected_at_ms store sits behind the SAME
* term-guard early-return as the committed-position learn (3.2 shape). */
static void test_consume_stale_term_does_not_store_elected_at_ms(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_elected_at_ms, 4242,
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,
.elected_at_ms = 555555,
};
ha_consume_heartbeat_committed(&c, &hb);
TEST_ASSERT_EQUAL_UINT64(4242, hb_elected(&c)); /* unchanged (3.2 shape) */
hb_teardown_cluster(&c);
printf("test_consume_stale_term_does_not_store_elected_at_ms: PASS\n");
}
/* ── suite entry point ─────────────────────────────────────── */
void test_ha_heartbeat_committed(void)
@ -346,4 +508,8 @@ void test_ha_heartbeat_committed(void)
RUN_TEST(test_consume_stale_term_ignored);
RUN_TEST(test_consume_primary_does_not_consume);
RUN_TEST(test_consume_null_or_zero_noop);
/* controller-status delta (task 7.3): heartbeat-carried election time */
RUN_TEST(test_consume_stores_elected_at_ms);
RUN_TEST(test_consume_elected_at_ms_reconcile_free);
RUN_TEST(test_consume_stale_term_does_not_store_elected_at_ms);
}

View file

@ -319,13 +319,14 @@ extern int (*ha_nop_req_hook)(endpoint_t *ep);
/*
* ha_heartbeat_req_hook: XCE_TESTING hook (task 3.1). When non-NULL,
* primary_heartbeat_fn calls this with the (term, committed_lsn,
* committed_config_tag) it stamps onto the heartbeat, so a test can assert the
* heartbeat carries the primary's OWN current committed state. Declared in
* ha.h.
* committed_config_tag, elected_at_ms) it stamps onto the heartbeat, so a
* test can assert the heartbeat carries the primary's OWN current committed
* state and election time (Req 12.2). Declared in ha.h.
*/
extern int (*ha_heartbeat_req_hook)(endpoint_t *ep, uint64_t term,
uint64_t committed_lsn,
uint64_t committed_config_tag);
uint64_t committed_config_tag,
uint64_t elected_at_ms);
/*
* primary_heartbeat_fn_for_test: test entry-point that exercises the primary
@ -359,16 +360,19 @@ static int g_hb_calls = 0;
static uint64_t g_hb_term = 0;
static uint64_t g_hb_committed_lsn = 0;
static uint64_t g_hb_committed_config_tag = 0;
static uint64_t g_hb_elected_at_ms = 0;
static int stub_heartbeat_req(endpoint_t *ep, uint64_t term,
uint64_t committed_lsn,
uint64_t committed_config_tag)
uint64_t committed_config_tag,
uint64_t elected_at_ms)
{
(void)ep;
g_hb_calls++;
g_hb_term = term;
g_hb_committed_lsn = committed_lsn;
g_hb_committed_config_tag = committed_config_tag;
g_hb_elected_at_ms = elected_at_ms;
return 0;
}
@ -599,11 +603,16 @@ static void test_primary_hb_stamps_own_committed_and_term(void)
const uint64_t PRIMARY_TERM = 7;
const uint64_t PRIMARY_COMMITTED_LSN = 42;
const uint64_t PRIMARY_COMMITTED_TAG = 13;
const uint64_t PRIMARY_ELECTED_AT_MS = 1700000000123ULL;
atomic_store_explicit(&c.ha.term, PRIMARY_TERM, memory_order_release);
atomic_store_explicit(&c.persist.committed_lsn, PRIMARY_COMMITTED_LSN,
memory_order_release);
atomic_store_explicit(&c.ha.committed_config_tag, PRIMARY_COMMITTED_TAG,
memory_order_release);
/* Req 12.2: the primary's own currently-known election time (self-stamped
* by promotion, task 7.2) must be echoed onto the outgoing heartbeat. */
atomic_store_explicit(&c.ha.last_known_elected_at_ms, PRIMARY_ELECTED_AT_MS,
memory_order_release);
/* Poison the standby-side last_known_* fields with DIFFERENT values: a
* heartbeat that mistakenly read these (the 3.4 bug) would carry them. */
@ -622,6 +631,7 @@ static void test_primary_hb_stamps_own_committed_and_term(void)
g_hb_calls = 0;
g_hb_term = g_hb_committed_lsn = g_hb_committed_config_tag = 0;
g_hb_elected_at_ms = 0;
ha_heartbeat_req_hook = stub_heartbeat_req;
primary_heartbeat_fn_for_test(&c);
@ -636,6 +646,8 @@ static void test_primary_hb_stamps_own_committed_and_term(void)
"Req 1.1/3.4: heartbeat must carry the primary's OWN persist.committed_lsn");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(PRIMARY_COMMITTED_TAG, g_hb_committed_config_tag,
"Req 1.1/3.4: heartbeat must carry the primary's OWN committed_config_tag");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(PRIMARY_ELECTED_AT_MS, g_hb_elected_at_ms,
"Req 12.2: heartbeat must carry the primary's OWN last_known_elected_at_ms");
/* last_push_time still updated on success — bookkeeping unchanged. */
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, (int)sctx->last_push_time,

View file

@ -44,10 +44,12 @@ extern int (*ha_reconnect_connect_hook)(cluster_t *c, int peer_idx);
extern int primary_heartbeat_fn_for_test(cluster_t *c);
extern int standby_ep_ctx_build_for_test(endpoint_t *ep, ha_state_t *ha,
cluster_t *c, int peer_idx);
/* ha_heartbeat_req_hook: per-standby stamp hook (endpoint, term, committed_lsn, tag). */
/* ha_heartbeat_req_hook: per-standby stamp hook (endpoint, term, committed_lsn,
* tag, elected_at_ms). */
extern int (*ha_heartbeat_req_hook)(endpoint_t *ep, uint64_t term,
uint64_t committed_lsn,
uint64_t committed_config_tag);
uint64_t committed_config_tag,
uint64_t elected_at_ms);
extern int (*ha_nop_req_hook)(endpoint_t *ep);
@ -195,9 +197,11 @@ static int ps_heal_nop_hook(endpoint_t *ep)
* standby_endpoints so it's never reached. Only installed during partition;
* it must never block the tick. */
static int partition_hb_hook(endpoint_t *ep, uint64_t term,
uint64_t committed_lsn, uint64_t committed_config_tag)
uint64_t committed_lsn, uint64_t committed_config_tag,
uint64_t elected_at_ms)
{
(void)ep; (void)term; (void)committed_lsn; (void)committed_config_tag;
(void)elected_at_ms;
atomic_fetch_add(&g_survivor_nops, 1);
return 0;
}

View file

@ -27,12 +27,15 @@ extern int xce_reply(endpoint_t*, xce_peerinfo_t*, xce_reply_t*);
extern int xce_hello_req(endpoint_t*, char*);
extern int xce_nop_req(endpoint_t*);
/* HA heartbeat: an OP_nop request carrying a HeartbeatMsg body with the
* primary's committed position. Non-blocking, no reply awaited (the standby's
* StateReport rides the normal nop reply). senderPriority is unused (0).
* Generic xce_nop_req is left unchanged (body-less). */
* primary's committed position and its own election time (Req 12.2 — echoed
* to standbys so they can learn the currently-known primary's election
* time). Non-blocking, no reply awaited (the standby's StateReport rides the
* normal nop reply). senderPriority is unused (0). Generic xce_nop_req is
* left unchanged (body-less). */
extern int xce_ha_heartbeat_req(endpoint_t* ep, uint64_t term,
uint64_t committed_lsn,
uint64_t committed_config_tag);
uint64_t committed_config_tag,
uint64_t elected_at_ms);
extern int xce_shutdown_req(endpoint_t*);
extern void init_reply_by_req(xce_reply_t*, xce_request_t*);
extern int xce_join_req(endpoint_t*, int, hostattrs_t*);

View file

@ -1347,18 +1347,20 @@ int xce_nop_req(endpoint_t* ep)
}
/* xce_ha_heartbeat_req: send the HA heartbeat as an OP_nop request carrying a
* HeartbeatMsg body with the primary's committed position. Clone of the nop
* send path plus a heartbeatmsg body. Non-blocking, no reply awaited (the
* standby's StateReport rides the normal nop reply). senderPriority is unused
* (0). Generic xce_nop_req stays body-less. */
* HeartbeatMsg body with the primary's committed position and its own
* election time (Req 12.2). Clone of the nop send path plus a heartbeatmsg
* body. Non-blocking, no reply awaited (the standby's StateReport rides the
* normal nop reply). senderPriority is unused (0). Generic xce_nop_req stays
* body-less. */
int xce_ha_heartbeat_req(endpoint_t* ep, uint64_t term, uint64_t committed_lsn,
uint64_t committed_config_tag)
uint64_t committed_config_tag, uint64_t elected_at_ms)
{
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = term,
.committed_lsn = committed_lsn,
.committed_config_tag = committed_config_tag,
.elected_at_ms = elected_at_ms,
};
/* clang-format off */
xce_request_t request = {

View file

@ -29,13 +29,15 @@ static void test_hostctrl_req_null_ep(void)
* (free_packet + FREEUP), so the caller must NOT free the returned pkt — it is
* consumed by the subsequent xce_decode(). Mirrors the test_wal_wire pattern. */
static io_packet_t *encode_heartbeat(uint64_t term, uint64_t committed_lsn,
uint64_t committed_config_tag)
uint64_t committed_config_tag,
uint64_t elected_at_ms)
{
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = term,
.committed_lsn = committed_lsn,
.committed_config_tag = committed_config_tag,
.elected_at_ms = elected_at_ms,
};
xce_request_t request = {
.opcode = OPCode_nop,
@ -53,26 +55,28 @@ static io_packet_t *encode_heartbeat(uint64_t term, uint64_t committed_lsn,
static void test_ha_heartbeat_req_null_ep(void)
{
/* Mirror the nop sender's NULL-endpoint guard. */
int rc = xce_ha_heartbeat_req(NULL, 1, 1, 1);
int rc = xce_ha_heartbeat_req(NULL, 1, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_ha_heartbeat_req_roundtrip_fields(void)
{
/* Arrange/Act: encode a heartbeat carrying a committed position, decode. */
io_packet_t *pkt = encode_heartbeat(7, 9, 4);
/* Arrange/Act: encode a heartbeat carrying a committed position and an
* election time (Req 12.2), decode. */
io_packet_t *pkt = encode_heartbeat(7, 9, 4, 12345);
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req)); /* consumes/frees pkt */
/* Assert: OP_nop + heartbeatmsg arm with the three fields intact. */
/* Assert: OP_nop + heartbeatmsg arm with all four fields intact. */
TEST_ASSERT_EQUAL_INT(OPCode_nop, req.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_heartbeatmsg, req.body_which);
TEST_ASSERT_NOT_NULL(req.body.heartbeatmsg);
TEST_ASSERT_EQUAL_UINT64(7, req.body.heartbeatmsg->term);
TEST_ASSERT_EQUAL_UINT64(9, req.body.heartbeatmsg->committed_lsn);
TEST_ASSERT_EQUAL_UINT64(4, req.body.heartbeatmsg->committed_config_tag);
TEST_ASSERT_EQUAL_UINT64(12345, req.body.heartbeatmsg->elected_at_ms);
xce_free_request(&req);
printf("test_ha_heartbeat_req_roundtrip_fields: PASS\n");