diff --git a/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md b/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md index 0d5cbbb..cb627c4 100644 --- a/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md +++ b/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md @@ -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). - _Requirements: 2.3_ - _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. - 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. @@ -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`. - 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). +- 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. diff --git a/src/include/request.h b/src/include/request.h index 1834cb0..1ced058 100644 --- a/src/include/request.h +++ b/src/include/request.h @@ -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_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). */ +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 void init_reply_by_req(xce_reply_t*, xce_request_t*); extern int xce_join_req(endpoint_t*, int, hostattrs_t*); diff --git a/src/lib/request.c b/src/lib/request.c index 7994f15..013b040 100644 --- a/src/lib/request.c +++ b/src/lib/request.c @@ -1138,6 +1138,37 @@ int xce_nop_req(endpoint_t* ep) 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) { /* clang-format off */ diff --git a/src/lib/testsuite/test_request.c b/src/lib/testsuite/test_request.c index 46d8070..685845d 100644 --- a/src/lib/testsuite/test_request.c +++ b/src/lib/testsuite/test_request.c @@ -16,6 +16,89 @@ static void test_hostctrl_req_null_ep(void) 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) { /* 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_negative_action); 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); }