feat(ha-heartbeat-committed-propagation): add xce_ha_heartbeat_req sender (task 1.2)

Send the HA heartbeat as OP_nop + the existing HeartbeatMsg body carrying
{term, committed_lsn, committed_config_tag} (senderPriority unused). Generic
xce_nop_req untouched; C-side body support pre-existed (no typemap edit). Req 1.1
producer half. Round-trip test proves the 3 fields survive the wire; lib suite
0 failures under ASAN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-06-20 11:40:31 +00:00
parent f4327ce4f6
commit 282a82ff7e
4 changed files with 129 additions and 1 deletions

View file

@ -9,7 +9,7 @@
- Observable: the controller suite builds clean and passes unchanged; every read/write of the field uses atomic accessors (no plain access remains). - Observable: the controller suite builds clean and passes unchanged; every read/write of the field uses atomic accessors (no plain access remains).
- _Requirements: 2.3_ - _Requirements: 2.3_
- _Boundary: ha.h + last_known_committed_config_tag accessors_ - _Boundary: ha.h + last_known_committed_config_tag accessors_
- [ ] 1.2 Add the HA heartbeat sender `xce_ha_heartbeat_req` - [x] 1.2 Add the HA heartbeat sender `xce_ha_heartbeat_req`
- Add `xce_ha_heartbeat_req(endpoint_t* ep, uint64_t term, uint64_t committed_lsn, uint64_t committed_config_tag)` (`src/include/request.h` + `src/lib/request.c`) that sends an `OPCode_nop` request carrying a `heartbeatmsg` body (`HeartbeatMsg{term, committedLsn, committedConfigTag}`; `senderPriority` unused). Leave the generic `xce_nop_req` untouched. - Add `xce_ha_heartbeat_req(endpoint_t* ep, uint64_t term, uint64_t committed_lsn, uint64_t committed_config_tag)` (`src/include/request.h` + `src/lib/request.c`) that sends an `OPCode_nop` request carrying a `heartbeatmsg` body (`HeartbeatMsg{term, committedLsn, committedConfigTag}`; `senderPriority` unused). Leave the generic `xce_nop_req` untouched.
- TDD: a round-trip test encodes via the new sender and decodes a request with `body_which == Request_body_heartbeatmsg` carrying the three field values; a guard assertion confirms `xce_nop_req` still sends a bodyless nop. - TDD: a round-trip test encodes via the new sender and decodes a request with `body_which == Request_body_heartbeatmsg` carrying the three field values; a guard assertion confirms `xce_nop_req` still sends a bodyless nop.
- Observable: the round-trip test decodes the three fields from the new sender, and the bodyless-`xce_nop_req` guard still holds. - Observable: the round-trip test decodes the three fields from the new sender, and the bodyless-`xce_nop_req` guard still holds.
@ -57,4 +57,6 @@
- The HA heartbeat is `OPCode_nop` with a `heartbeatmsg` body — opcode and body are independent in this schema (mirrors `OP_hello` carrying `ctrlhellomsg` vs `replymsg`). Dispatch routes by `OPCode_nop` (unchanged); the consumer branches on `body_which`. - The HA heartbeat is `OPCode_nop` with a `heartbeatmsg` body — opcode and body are independent in this schema (mirrors `OP_hello` carrying `ctrlhellomsg` vs `replymsg`). Dispatch routes by `OPCode_nop` (unchanged); the consumer branches on `body_which`.
- Do NOT reuse `cluster_apply_ctrlhello_reply` for the heartbeat path — it truncates (`ha_rejoin_reconcile`). The heartbeat updater is reconcile-free (Req 3.1). - Do NOT reuse `cluster_apply_ctrlhello_reply` for the heartbeat path — it truncates (`ha_rejoin_reconcile`). The heartbeat updater is reconcile-free (Req 3.1).
- Do NOT touch the client keepalive `OPCode_heartbeat` / `xce_heartbeat` (a different message) or the generic `xce_nop_req` (client nop). - Do NOT touch the client keepalive `OPCode_heartbeat` / `xce_heartbeat` (a different message) or the generic `xce_nop_req` (client nop).
- 1.1 LEARNING: `ha.h` is `src/controller/ha.h` (NOT `src/controller/include/ha.h`). `last_known_committed_config_tag` is now `_Atomic uint64_t` (`ha.h:172`), sibling of `last_known_committed_lsn` (`ha.h:170`); read via `atomic_load_explicit(acquire)`, write via `atomic_store_explicit(release)`.
- 1.2 LEARNING: the C-side `heartbeatmsg` body support PRE-EXISTED (no typemap edit needed) — `heartbeat_msg_t` typedef `typemap.h:17`, `xce_request_t.heartbeatmsg` member `typemap.h:287`, `struct heartbeat_msg_s{sender_priority,term,committed_lsn,committed_config_tag}` `typemap.h:445` (order matches schema `@0..@3`); enum `Request_body_heartbeatmsg == 22` (rides `OPCode_nop`). The codec (`encode/decode/free_HeartbeatMsg` + the request arm) was generated; only producer/consumer were missing. Test round-trip pattern: heap-`calloc` packet consumed+freed by `xce_decode` (caller does NOT free pkt), decoded body freed by `xce_free_request` (see `test_wal_wire.c`). Sender uses `xce_request(ep, &req)` (non-blocking).
- The vote-floor formula is NOT edited — task 3.2 is a verification test only. Safety rests on the term-guard (task 2.2) + monotonicity (task 2.1) + cluster-monotonic committed; see design.md Security Considerations. - The vote-floor formula is NOT edited — task 3.2 is a verification test only. Safety rests on the term-guard (task 2.2) + monotonicity (task 2.1) + cluster-monotonic committed; see design.md Security Considerations.

View file

@ -26,6 +26,13 @@ extern int xce_decode(io_packet_t*, xce_request_t*);
extern int xce_reply(endpoint_t*, xce_peerinfo_t*, xce_reply_t*); extern int xce_reply(endpoint_t*, xce_peerinfo_t*, xce_reply_t*);
extern int xce_hello_req(endpoint_t*, char*); extern int xce_hello_req(endpoint_t*, char*);
extern int xce_nop_req(endpoint_t*); 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). */
extern int xce_ha_heartbeat_req(endpoint_t* ep, uint64_t term,
uint64_t committed_lsn,
uint64_t committed_config_tag);
extern int xce_shutdown_req(endpoint_t*); extern int xce_shutdown_req(endpoint_t*);
extern void init_reply_by_req(xce_reply_t*, xce_request_t*); extern void init_reply_by_req(xce_reply_t*, xce_request_t*);
extern int xce_join_req(endpoint_t*, int, hostattrs_t*); extern int xce_join_req(endpoint_t*, int, hostattrs_t*);

View file

@ -1138,6 +1138,37 @@ int xce_nop_req(endpoint_t* ep)
return xce_request(ep, &request); return xce_request(ep, &request);
} }
/* 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. */
int xce_ha_heartbeat_req(endpoint_t* ep, uint64_t term, uint64_t committed_lsn,
uint64_t committed_config_tag)
{
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = term,
.committed_lsn = committed_lsn,
.committed_config_tag = committed_config_tag,
};
/* clang-format off */
xce_request_t request = {
.opcode = OPCode_nop,
.body_which = Request_body_heartbeatmsg,
.body = {
.heartbeatmsg = &hb
}
};
/* clang-format on */
if (ep == NULL) {
return -EINVAL;
}
return xce_request(ep, &request);
}
int xce_hello_req(endpoint_t* ep, char* msg) int xce_hello_req(endpoint_t* ep, char* msg)
{ {
/* clang-format off */ /* clang-format off */

View file

@ -16,6 +16,89 @@ static void test_hostctrl_req_null_ep(void)
TEST_ASSERT_EQUAL_INT(-EINVAL, rc); TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
} }
/* ── HA heartbeat sender (xce_ha_heartbeat_req) ───────────────────────────
* The HA heartbeat is an OP_nop request carrying a HeartbeatMsg body. The
* sender builds that request and hands it to xce_request(); since unit tests
* have no live endpoint, these tests round-trip the same request shape the
* sender builds through xce_encode()/xce_decode() and assert the three
* committed-position fields survive the wire. A companion guard confirms the
* generic xce_nop_req still produces a body-less nop. */
/* Build the heartbeat request exactly as xce_ha_heartbeat_req does and encode
* it into a heap io_packet_t. xce_decode() takes ownership of the packet
* (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)
{
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = term,
.committed_lsn = committed_lsn,
.committed_config_tag = committed_config_tag,
};
xce_request_t request = {
.opcode = OPCode_nop,
.body_which = Request_body_heartbeatmsg,
.body = { .heartbeatmsg = &hb },
};
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
/* xce_encode returns 1 on success (mirrors xce_request's encode path). */
TEST_ASSERT_EQUAL_INT(1, xce_encode(&request, pkt));
return pkt;
}
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);
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);
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. */
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);
xce_free_request(&req);
printf("test_ha_heartbeat_req_roundtrip_fields: PASS\n");
}
/* Guard: the generic nop sender still produces a body-less nop request. */
static void test_nop_req_remains_bodyless(void)
{
xce_request_t request
= { .opcode = OPCode_nop, .body_which = Request_body_nomsg };
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
TEST_ASSERT_EQUAL_INT(1, xce_encode(&request, pkt));
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req)); /* consumes/frees pkt */
TEST_ASSERT_EQUAL_INT(OPCode_nop, req.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_nomsg, req.body_which);
xce_free_request(&req);
printf("test_nop_req_remains_bodyless: PASS\n");
}
static void test_hostctrl_req_null_ctrl(void) static void test_hostctrl_req_null_ctrl(void)
{ {
/* non-NULL dummy ep is enough; the NULL ctrl check fires first */ /* non-NULL dummy ep is enough; the NULL ctrl check fires first */
@ -105,4 +188,9 @@ void test_request(void)
RUN_TEST(test_hostctrl_req_reject_untag); RUN_TEST(test_hostctrl_req_reject_untag);
RUN_TEST(test_hostctrl_req_reject_negative_action); RUN_TEST(test_hostctrl_req_reject_negative_action);
RUN_TEST(test_init_reply_by_req_zeroes_reply); RUN_TEST(test_init_reply_by_req_zeroes_reply);
printf("\n>> HA heartbeat sender round-trip tests\n\n");
RUN_TEST(test_ha_heartbeat_req_null_ep);
RUN_TEST(test_ha_heartbeat_req_roundtrip_fields);
RUN_TEST(test_nop_req_remains_bodyless);
} }