diff --git a/.kiro/specs/controller-status/tasks.md b/.kiro/specs/controller-status/tasks.md index 0607abf4..add63875 100644 --- a/.kiro/specs/controller-status/tasks.md +++ b/.kiro/specs/controller-status/tasks.md @@ -27,7 +27,7 @@ - _Depends: 2.1_ - [ ] 3. libxce client API -- [ ] 3.1 Add the failover-aware any-node status call +- [x] 3.1 Add the failover-aware any-node status call - Add the opcode-only request sender, the send-and-decode call, and the failover-aware client read (retryable, non-write) that returns whatever reachable node answers, plus the free helper. On no reachable controller, return a typed failure without a partial/empty success. - TDD: a round-trip test decodes every status field from a handler reply; a no-reachable-controller test returns a typed failure. - Observable: the client decodes all fields from a live/loopback handler reply and surfaces a typed error when no controller is reachable. @@ -69,3 +69,4 @@ - The authoritative redirect (3.2) is what makes wait-primary trustworthy (no stale-standby false success) — it is the resolution of the design-review decision; do not collapse it back into a plain any-node read for wait-primary. - 1.1 LEARNING (affects later tasks): the `$C.mapname(...)` capnp types are NOT auto-generated in this schema — the generated `controller.capnp.c` references HAND-AUTHORED C typedefs in `src/include/typemap.h` (same convention as `xce_clusterconf_t`). So `xce_ctrlstatus_t`/`xce_ctrlpeer_t` (and the `xce_reply_t.data.ctrlstatus` union member) live in `typemap.h`, verified field-for-field against the regenerated codec. `OPCode_ctrlstatus = 40`, `RequestReply_data_ctrlstatus = 12`. `configChecksum` is `xce_data_t*`. The handler/libxce tasks build/encode against these typemap.h types. - 2.1 LEARNING + SCHEMA AMENDMENT: capnp `Data` in THIS schema maps to the project's STRUCTURED `xce_data_t` union (table-switched), NOT raw bytes — `encode_Data_ptr` can't serialize a SHA-256 blob. So `configChecksum @6` was changed `Data → Text` (a 64-char lowercase-hex string of the full 32-byte `config_sha256`); typemap member is `char*`; freed in `ctrlstatus_free_fields`. Ordinal @6 unchanged, no released decoder yet → safe. Handler lives in NEW `src/controller/ctrlstatus.c` (`ctrlstatus_fill`/`ctrlstatus_free_fields`/`handle_ctrlstatus`), proto in `src/controller/include/cluster.h`, routed at `cluster.c` opcode switch, test `src/controller/testsuite/test_ctrlstatus.c` (registered in `controller_test.c`, built via `controller/meson.build`). `primary_host` read under `ha->lock`; peer name/host strings are BORROWED from myconf (not freed). The libxce client (3.x) decodes `configChecksum` as Text. +- 3.1 LEARNING: the ctx-side reply union `xce_ctx_t.reply.u` (in `src/include/xcectx.h`) is SEPARATE from `xce_reply_t.data` (typemap.h) — the decode path needs BOTH a `ctrlstatus` member there AND a `case OPCode_ctrlstatus` in `xcectx.c` handle_reply (mirror clusterconf). Free: clusterconf's `xce_free_clusterconf` macro is a LATENT-BROKEN alias (undefined symbol, never expanded); use the REAL generated free — `xce_free_ctrlstatus(s) → free_CtrlStatus(s)`, `xcecli_free_ctrlstatus(s) → xce_free_ctrlstatus(s)` (any TU expanding it must include the generated `controller.capnp.h`). `xcecli_ctrlstatus` is is_write=false; NULL ctx → `-EINVAL`. diff --git a/src/include/client.h b/src/include/client.h index 69573199..190567c8 100644 --- a/src/include/client.h +++ b/src/include/client.h @@ -53,6 +53,7 @@ extern int xcecli_workerlist(xcecli_ctx_t, xce_worker_t***); extern int xcecli_jobdispatch(xcecli_ctx_t, xce_job_dispatch_t*); extern int xcecli_joblist(xcecli_ctx_t, xce_job_t***); extern int xcecli_clusterconf(xcecli_ctx_t, xce_clusterconf_t**); +extern int xcecli_ctrlstatus(xcecli_ctx_t, xce_ctrlstatus_t**); extern int xcecli_notify_jobfinish(xcecli_ctx_t, xce_jobfinish_t*); extern int xcecli_notify_jobstart(xcecli_ctx_t, job_pidinfo_t*); extern int xcecli_notify_jobfail(xcecli_ctx_t c, job_failinfo_t*); @@ -88,5 +89,9 @@ extern int xcecli_jobinfo(xcecli_ctx_t, uint64_t, xce_job_t**); #define xcecli_free_queues(nqueues, queues) xce_free_queues(nqueues, queues) #define xcecli_free_groups(ngroups, groups) xce_free_groups(ngroups, groups) #define xcecli_free_clusterconf(conf) xce_free_clusterconf(conf) +/* ctrlstatus reply is freed by the generated codec free (free_CtrlStatus, + * declared in the generated controller.capnp.h). */ +#define xce_free_ctrlstatus(s) free_CtrlStatus(s) +#define xcecli_free_ctrlstatus(s) xce_free_ctrlstatus(s) #endif diff --git a/src/include/request.h b/src/include/request.h index 7427cc75..1834cb05 100644 --- a/src/include/request.h +++ b/src/include/request.h @@ -49,6 +49,7 @@ extern int xce_startjob_req(endpoint_t*, xce_job_exec_t*); extern int xce_clusterconf_req(endpoint_t*); extern int xce_reply_clusterconf(endpoint_t*, uint64_t, int, xce_peerinfo_t*, xce_clusterconf_t*); +extern int xce_ctrlstatus_req(endpoint_t*); extern int xce_get_req(endpoint_t*, xce_get_t*); extern int xce_reply_get(endpoint_t*, xce_peerinfo_t*, xce_reply_t*, xce_check_fn_t, void*, int); diff --git a/src/include/xceapi.h b/src/include/xceapi.h index 0352b7cf..df6aa670 100644 --- a/src/include/xceapi.h +++ b/src/include/xceapi.h @@ -18,6 +18,7 @@ extern int xce_jobdispatch(xce_ctx_t*, xce_job_dispatch_t*); extern int xce_jobsubmit(xce_ctx_t*, xce_job_submit_t*, uint64_t*); extern int xce_joblist(xce_ctx_t*, xce_job_t***); extern int xce_clusterconf(xce_ctx_t*, xce_clusterconf_t**); +extern int xce_ctrlstatus(xce_ctx_t*, xce_ctrlstatus_t**); extern int xce_push(xce_ctx_t*, xce_check_fn_t, void*, int, int, int, void**); extern int xce_update_rows(xce_ctx_t*, int, int, xlist_t*); extern int xce_resval(xce_ctx_t*, char*, char*, xce_resval_t***); diff --git a/src/include/xcectx.h b/src/include/xcectx.h index 7e321778..4dcbcedc 100644 --- a/src/include/xcectx.h +++ b/src/include/xcectx.h @@ -44,6 +44,7 @@ typedef struct { dataset_t* get; uint64_t jobid; xce_clusterconf_t* clusterconf; + xce_ctrlstatus_t* ctrlstatus; } u; } reply; } xce_ctx_t; diff --git a/src/lib/client.c b/src/lib/client.c index d94286c6..01d3c3fd 100644 --- a/src/lib/client.c +++ b/src/lib/client.c @@ -324,6 +324,18 @@ int xcecli_clusterconf(xcecli_ctx_t c, xce_clusterconf_t** conf) xce_clusterconf(&(ctx->ctx), conf)); } +int xcecli_ctrlstatus(xcecli_ctx_t c, xce_ctrlstatus_t** status) +{ + api_ctx_t* ctx = (api_ctx_t*)c; + + if (c == NULL) { + return -EINVAL; + } + + return XCECLI_FAILOVER_CALL(ctx, /*is_write=*/false, + xce_ctrlstatus(&(ctx->ctx), status)); +} + int xcecli_notify_jobfinish(xcecli_ctx_t c, xce_jobfinish_t* jfinish) { api_ctx_t* ctx = (api_ctx_t*)c; diff --git a/src/lib/meson.build b/src/lib/meson.build index 0e5401d6..1735a237 100644 --- a/src/lib/meson.build +++ b/src/lib/meson.build @@ -178,6 +178,7 @@ libxce_test_src = ['testsuite/test.c', 'testsuite/test_request_send_propagation.c', 'testsuite/test_raft.c', 'testsuite/test_wal_wire.c', + 'testsuite/test_ctrlstatus.c', 'raft/raft_sim.c', ] diff --git a/src/lib/request.c b/src/lib/request.c index d1ef8dc1..7994f15f 100644 --- a/src/lib/request.c +++ b/src/lib/request.c @@ -1743,6 +1743,18 @@ int xce_clusterconf_req(endpoint_t* ep) return xce_request(ep, &request); } +int xce_ctrlstatus_req(endpoint_t* ep) +{ + /* clang-format off */ + xce_request_t request = { + .opcode = OPCode_ctrlstatus, + .body_which = Request_body_nomsg + }; + /* clang-format on */ + + return xce_request(ep, &request); +} + int xce_update_req(endpoint_t* ep, xce_update_data_t* data) { /* clang-format off */ diff --git a/src/lib/testsuite/test.c b/src/lib/testsuite/test.c index c688b5e8..ae0fb051 100644 --- a/src/lib/testsuite/test.c +++ b/src/lib/testsuite/test.c @@ -41,6 +41,7 @@ extern void test_netio_close_propagation(void); extern void test_request_send_propagation(void); extern void test_raft(void); extern void test_wal_wire(void); +extern void test_ctrlstatus(void); void setUp(void) { return; } @@ -151,6 +152,7 @@ int main(int argc, char* argv[]) TEST(test_request_send_propagation); TEST(test_raft); TEST(test_wal_wire); + TEST(test_ctrlstatus); return UNITY_END(); } diff --git a/src/lib/testsuite/test_ctrlstatus.c b/src/lib/testsuite/test_ctrlstatus.c new file mode 100644 index 00000000..3aad11cd --- /dev/null +++ b/src/lib/testsuite/test_ctrlstatus.c @@ -0,0 +1,211 @@ +/* test_ctrlstatus.c — Unity tests for the controller-status (ctrlstatus) + * libxce client reply path (controller-status task 3.1). + * + * Proves the reply path that xcecli_ctrlstatus relies on: + * 1. A true ENCODE→DECODE round-trip of xce_ctrlstatus_t through the + * generated Cap'n Proto codec (encode_CtrlStatus/write_CtrlStatus → + * bytes → read_CtrlStatus/decode_CtrlStatus): every status field, + * including the peers list and the configChecksum hex Text, survives + * the wire (Req 1.1 — the client decodes every field a handler emits). + * 2. xcecli_ctrlstatus(NULL, ...) returns a typed negative rather than a + * partial/empty success (Req 7.3 — no reachable controller ⇒ typed + * failure). + * + * A full live round-trip (client → running controller → handler reply) + * needs a controller and is the E2E task; here we prove the encode/decode + * round-trip + the no-context typed failure only. + */ +#include +#include +#include +#include + +#include "client.h" +#include "request.h" +#include "xce/types.h" + +/* generated Cap'n Proto codec for CtrlStatus / CtrlPeer */ +#include "controller.capnp.h" + +/* ── encode→decode round-trip of xce_ctrlstatus_t ───────────────────────── */ + +static void test_ctrlstatus_roundtrip_all_fields(void) +{ + /* Arrange: a fully-populated status with two configured peers */ + xce_ctrlpeer_t p0 = { + .name = "ctrl-a", .host = "10.0.0.1", .port = 9991 + }; + xce_ctrlpeer_t p1 = { + .name = "ctrl-b", .host = "10.0.0.2", .port = 9992 + }; + xce_ctrlpeer_t* peerlist[2] = { &p0, &p1 }; + + xce_ctrlstatus_t in; + memset(&in, 0, sizeof(in)); + in.clusterName = "lab-cluster"; + in.primaryName = "ctrl-a"; + in.responderName = "ctrl-b"; + in.responderRole = 3; /* STANDBY */ + in.term = 42; + in.configVersion = 0x1122334455667788ULL; + in.configChecksum = "1122334455667788aabbccddeeff0011" + "2233445566778899aabbccddeeff0011"; + in.committedConfigVersion = 0x1122334455667788ULL; + in.committedLsn = 5000; + in.appliedLsn = 4999; + in.fenced = 1; + in.peers.count = 2; + in.peers.list = peerlist; + in.peerCount = 2; + + /* Act: encode into a capnp segment, serialize to bytes, then decode + * a fresh struct back out — mirrors xce_encode/xce_decode. */ + struct capn c; + struct capn_segment* cs; + struct CtrlStatus cap_in; + CtrlStatus_ptr sp; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + + memset(&cap_in, 0, sizeof(cap_in)); + encode_CtrlStatus(cs, &cap_in, &in); + + sp = new_CtrlStatus(cs); + write_CtrlStatus(&cap_in, sp); + capn_setp(capn_root(&c), 0, sp.p); + + size_t sz = capn_size(&c); + uint8_t* buf = (uint8_t*)malloc(sz); + TEST_ASSERT_NOT_NULL(buf); + TEST_ASSERT_EQUAL_size_t(sz, capn_write_mem(&c, buf, sz, 0)); + capn_free(&c); + + /* decode side */ + struct capn dc; + struct CtrlStatus cap_out; + CtrlStatus_ptr dp; + xce_ctrlstatus_t out; + + capn_init_mem(&dc, buf, sz, 0); + dp.p = capn_getp(capn_root(&dc), 0, 1); + read_CtrlStatus(&cap_out, dp); + memset(&out, 0, sizeof(out)); + decode_CtrlStatus(&out, &cap_out); + + /* Assert: every scalar/text field survived */ + TEST_ASSERT_NOT_NULL(out.clusterName); + TEST_ASSERT_EQUAL_STRING("lab-cluster", out.clusterName); + TEST_ASSERT_NOT_NULL(out.primaryName); + TEST_ASSERT_EQUAL_STRING("ctrl-a", out.primaryName); + TEST_ASSERT_NOT_NULL(out.responderName); + TEST_ASSERT_EQUAL_STRING("ctrl-b", out.responderName); + TEST_ASSERT_EQUAL_UINT8(3, out.responderRole); + TEST_ASSERT_EQUAL_UINT64(42, out.term); + TEST_ASSERT_EQUAL_UINT64(0x1122334455667788ULL, out.configVersion); + TEST_ASSERT_NOT_NULL(out.configChecksum); + TEST_ASSERT_EQUAL_STRING(in.configChecksum, out.configChecksum); + TEST_ASSERT_EQUAL_UINT64(0x1122334455667788ULL, out.committedConfigVersion); + TEST_ASSERT_EQUAL_UINT64(5000, out.committedLsn); + TEST_ASSERT_EQUAL_UINT64(4999, out.appliedLsn); + TEST_ASSERT_EQUAL_UINT32(1, out.fenced); + TEST_ASSERT_EQUAL_UINT32(2, out.peerCount); + + /* Assert: the peers list survived with both entries */ + TEST_ASSERT_EQUAL_INT(2, out.peers.count); + TEST_ASSERT_NOT_NULL(out.peers.list); + TEST_ASSERT_NOT_NULL(out.peers.list[0]); + TEST_ASSERT_EQUAL_STRING("ctrl-a", out.peers.list[0]->name); + TEST_ASSERT_EQUAL_STRING("10.0.0.1", out.peers.list[0]->host); + TEST_ASSERT_EQUAL_UINT32(9991, out.peers.list[0]->port); + TEST_ASSERT_NOT_NULL(out.peers.list[1]); + TEST_ASSERT_EQUAL_STRING("ctrl-b", out.peers.list[1]->name); + TEST_ASSERT_EQUAL_STRING("10.0.0.2", out.peers.list[1]->host); + TEST_ASSERT_EQUAL_UINT32(9992, out.peers.list[1]->port); + + free_CtrlStatus(&out); + capn_free(&dc); + free(buf); + + printf("test_ctrlstatus_roundtrip_all_fields: PASS\n"); +} + +/* An "unknown" primary (empty primaryName) must round-trip as empty/NULL, + * never garbage — the client renders it as "unknown" (Req 1.3, 2.2). */ +static void test_ctrlstatus_roundtrip_unknown_primary(void) +{ + xce_ctrlstatus_t in; + memset(&in, 0, sizeof(in)); + in.clusterName = "lab-cluster"; + in.primaryName = ""; /* unknown */ + in.responderName = "ctrl-a"; + in.responderRole = 1; /* ELECTING */ + in.peers.count = 0; + in.peers.list = NULL; + in.peerCount = 0; + + struct capn c; + struct capn_segment* cs; + struct CtrlStatus cap_in; + CtrlStatus_ptr sp; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + memset(&cap_in, 0, sizeof(cap_in)); + encode_CtrlStatus(cs, &cap_in, &in); + sp = new_CtrlStatus(cs); + write_CtrlStatus(&cap_in, sp); + capn_setp(capn_root(&c), 0, sp.p); + + size_t sz = capn_size(&c); + uint8_t* buf = (uint8_t*)malloc(sz); + TEST_ASSERT_NOT_NULL(buf); + capn_write_mem(&c, buf, sz, 0); + capn_free(&c); + + struct capn dc; + struct CtrlStatus cap_out; + CtrlStatus_ptr dp; + xce_ctrlstatus_t out; + + capn_init_mem(&dc, buf, sz, 0); + dp.p = capn_getp(capn_root(&dc), 0, 1); + read_CtrlStatus(&cap_out, dp); + memset(&out, 0, sizeof(out)); + decode_CtrlStatus(&out, &cap_out); + + TEST_ASSERT_EQUAL_STRING("ctrl-a", out.responderName); + TEST_ASSERT_EQUAL_UINT8(1, out.responderRole); + /* empty primaryName decodes to NULL or "" — not a stale/guessed value */ + if (out.primaryName != NULL) { + TEST_ASSERT_EQUAL_STRING("", out.primaryName); + } + TEST_ASSERT_EQUAL_INT(0, out.peers.count); + TEST_ASSERT_EQUAL_UINT32(0, out.peerCount); + + free_CtrlStatus(&out); + capn_free(&dc); + free(buf); + + printf("test_ctrlstatus_roundtrip_unknown_primary: PASS\n"); +} + +/* ── no reachable controller ⇒ typed failure (Req 7.3) ──────────────────── */ + +static void test_xcecli_ctrlstatus_null_ctx_typed_failure(void) +{ + xce_ctrlstatus_t* status = (xce_ctrlstatus_t*)0xdeadbeef; + int rc = xcecli_ctrlstatus(NULL, &status); + /* typed negative, not a partial/empty success */ + TEST_ASSERT_EQUAL_INT(-EINVAL, rc); + printf("test_xcecli_ctrlstatus_null_ctx_typed_failure: PASS\n"); +} + +/* ── Suite entry point ──────────────────────────────────────────────────── */ + +void test_ctrlstatus(void) +{ + RUN_TEST(test_ctrlstatus_roundtrip_all_fields); + RUN_TEST(test_ctrlstatus_roundtrip_unknown_primary); + RUN_TEST(test_xcecli_ctrlstatus_null_ctx_typed_failure); +} diff --git a/src/lib/xceapi.c b/src/lib/xceapi.c index 96c3d384..4107e863 100644 --- a/src/lib/xceapi.c +++ b/src/lib/xceapi.c @@ -427,6 +427,36 @@ int xce_clusterconf(xce_ctx_t* ctx, xce_clusterconf_t** conf) return 0; } +int xce_ctrlstatus(xce_ctx_t* ctx, xce_ctrlstatus_t** status) +{ + int rc, st; + + if ((ctx == NULL) || (status == NULL)) { + return -EINVAL; + } + + if (ctx->state != XCE_CTX_RUN) { + return -EINVAL; + } + + rc = xce_ctrlstatus_req(ctx->ep); + if (rc < 0) { + return rc; + } + + rc = cbuf_get(&(ctx->wbuf), &st); + if (rc < 0) { + return rc; + } + + if (st < 0) { + return st; + } + + MOVE_PTR((*status), ctx->reply.u.ctrlstatus); + return 0; +} + int xce_update_resvals(xce_ctx_t* ctx, int count, xce_resval_t** vals) { int rc; diff --git a/src/lib/xcectx.c b/src/lib/xcectx.c index 278ed5dd..1af032c1 100644 --- a/src/lib/xcectx.c +++ b/src/lib/xcectx.c @@ -75,6 +75,13 @@ static int handle_reply(xce_ctx_t* ctx, xce_reply_t* reply) rc = 1; } break; + case OPCode_ctrlstatus: + rc = -1; + if (reply->data_which == RequestReply_data_ctrlstatus) { + MOVE_PTR(ctx->reply.u.ctrlstatus, reply->data.ctrlstatus); + rc = 1; + } + break; default: rc = -ENOENT; break;