diff --git a/.kiro/specs/controller-status/tasks.md b/.kiro/specs/controller-status/tasks.md index c44b875..0607abf 100644 --- a/.kiro/specs/controller-status/tasks.md +++ b/.kiro/specs/controller-status/tasks.md @@ -18,7 +18,7 @@ - Observable: the unit tests pass for the primary, standby, and unknown-primary cases, and a no-mutation assertion holds. - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 3.1, 3.2, 4.1, 4.2, 5.1, 5.2, 5.3, 6.1, 6.2, 6.3, 6.4_ - _Boundary: handle_ctrlstatus_ -- [ ] 2.2 Allow the status query through the HA dispatch gate in every role +- [x] 2.2 Allow the status query through the HA dispatch gate in every role - Add the status opcode to the dispatch-gate allow-lists for primary (including the no-quorum branch), standby, electing, and initializing — so a status read always answers (including when fenced or without quorum), rather than being rejected as standby/not-ready. Scope the change strictly to this read-only opcode. - TDD: an integration test confirms the status request is answered (not rejected) on a standby, an electing node, an initializing node, and a primary without quorum; and a guard test confirms no other opcode's gating changed. - Observable: the gate test shows the status query answered in all four role conditions and unchanged behavior for at least one previously primary-only opcode. diff --git a/src/controller/ha.c b/src/controller/ha.c index 295e77c..4feb65d 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -2088,6 +2088,10 @@ int ha_dispatch_gate(ha_state_t *ha, int opcode) /* Read-only queries */ case OPCode_get: case OPCode_probeConfigTag: + /* Read-only controller status (controller-status 7.1, 7.2): + * answers the local identity/health view even without quorum — + * precisely when an operator most wants it. */ + case OPCode_ctrlstatus: /* Fact-bearing ops — reports of events that already happened * (worker job transitions/rusage, scheduler pend reasons). * Rejecting them loses the fact forever: the reporter never @@ -2118,7 +2122,10 @@ int ha_dispatch_gate(ha_state_t *ha, int opcode) opcode == OPCode_nop || opcode == OPCode_replConfigPush || opcode == OPCode_walCatchupEntry || - opcode == OPCode_snapshotTransfer) + opcode == OPCode_snapshotTransfer || + /* Read-only controller status — answerable in every role + * (controller-status 7.1, 7.2). */ + opcode == OPCode_ctrlstatus) return 0; return -XCE_ERR_NOT_READY; @@ -2136,7 +2143,10 @@ int ha_dispatch_gate(ha_state_t *ha, int opcode) opcode == OPCode_nop || opcode == OPCode_replConfigPush || opcode == OPCode_walCatchupEntry || - opcode == OPCode_snapshotTransfer) + opcode == OPCode_snapshotTransfer || + /* Read-only controller status — answerable in every role + * (controller-status 7.1, 7.2). */ + opcode == OPCode_ctrlstatus) return 0; return -XCE_ERR_NOT_READY; @@ -2164,6 +2174,9 @@ int ha_dispatch_gate(ha_state_t *ha, int opcode) case OPCode_upgradeConfigAck: case OPCode_snapshotTransfer: case OPCode_walCatchupEntry: + /* Read-only controller status — a standby answers with its local + * view (it reports the primary it knows) (controller-status 7.1, 7.2). */ + case OPCode_ctrlstatus: return 0; default: return -XCE_ERR_STANDBY; diff --git a/src/controller/testsuite/test_ha_role.c b/src/controller/testsuite/test_ha_role.c index fab37f5..4d77c10 100644 --- a/src/controller/testsuite/test_ha_role.c +++ b/src/controller/testsuite/test_ha_role.c @@ -791,6 +791,77 @@ static void test_election_stagger_desyncs_by_priority(void) printf("test_election_stagger_desyncs_by_priority: PASS\n"); } +/* ── controller-status task 2.2: dispatch gate admits OPCode_ctrlstatus in + * EVERY role — STANDBY, ELECTING, INIT, and PRIMARY-without-quorum — so the + * read-only status query always answers (even fenced / without quorum), + * rather than being rejected as -XCE_ERR_STANDBY / -XCE_ERR_NOT_READY. + * + * Requirements: controller-status 7.1, 7.2 (failover-aware, any-node delivery); + * design §"Dispatch-gate allow-lists — Service contract". + * + * Scope guard: the change must be strictly scoped to OPCode_ctrlstatus — no + * other opcode's gating may widen. This test also asserts that a previously + * primary-only-when-serving read (OPCode_clusterconf) is STILL rejected on a + * STANDBY after the change, proving the allow-list was not broadened. + * + * Note on PRIMARY-without-quorum: ha_has_write_quorum() returns false when the + * connected-standby count is below n_peers/2. With n_peers == 0 (default + * ha_init) the threshold is 0 and quorum is trivially satisfied, so to exercise + * the no-quorum branch we set n_peers high enough that 0 connected standbys + * fail the >= n_peers/2 test. */ +static void test_dispatch_gate_allows_ctrlstatus_in_every_role(void) +{ + ha_state_t ha; + ha_config_t cfg = { + .heartbeat_interval_ms = 3000, + .heartbeat_timeout_ms = 10000, + .election_timeout_ms = 2000, + .probe_connect_timeout_ms = 500, + .max_promotion_retries = 5, + }; + TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0)); + + /* CTRL_STANDBY — must allow ctrlstatus (Req 7.1, 7.2) */ + atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + ha_dispatch_gate(&ha, OPCode_ctrlstatus), + "CTRL_STANDBY must allow OPCode_ctrlstatus (Req 7.1, 7.2)"); + + /* CTRL_ELECTING — must allow ctrlstatus */ + atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + ha_dispatch_gate(&ha, OPCode_ctrlstatus), + "CTRL_ELECTING must allow OPCode_ctrlstatus (Req 7.1, 7.2)"); + + /* CTRL_INIT — must allow ctrlstatus */ + atomic_store_explicit(&ha.role, (int32_t)CTRL_INIT, memory_order_release); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + ha_dispatch_gate(&ha, OPCode_ctrlstatus), + "CTRL_INIT must allow OPCode_ctrlstatus (Req 7.1, 7.2)"); + + /* CTRL_PRIMARY without write quorum — must allow ctrlstatus. + * Force the no-quorum branch: with no replicator and zero connected + * standby_endpoints, ha_has_write_quorum() returns false once n_peers > 1 + * (connected 0 < n_peers/2). */ + ha.n_peers = 3; /* threshold = n_peers/2 = 1; connected = 0 → no quorum */ + atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + ha_dispatch_gate(&ha, OPCode_ctrlstatus), + "CTRL_PRIMARY without quorum must allow OPCode_ctrlstatus (Req 7.1, 7.2)"); + + /* Scope guard (Req: change strictly scoped to ctrlstatus): a read that is + * primary-only-when-serving (OPCode_clusterconf) must STILL be rejected on + * a STANDBY — proving the allow-list was not widened for any other opcode. */ + atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); + TEST_ASSERT_EQUAL_INT_MESSAGE(-XCE_ERR_STANDBY, + ha_dispatch_gate(&ha, OPCode_clusterconf), + "CTRL_STANDBY must STILL reject OPCode_clusterconf — gate change must " + "be scoped strictly to OPCode_ctrlstatus"); + + ha_fini(&ha); + printf("test_dispatch_gate_allows_ctrlstatus_in_every_role: PASS\n"); +} + void test_ha_role(void) { RUN_TEST(test_ha_init_default_role_is_standby); @@ -835,6 +906,8 @@ void test_ha_role(void) RUN_TEST(test_dispatch_gate_allows_prevote_in_voter_roles); /* raft/ha-prevote (bug-606): failover election stagger desyncs survivors */ RUN_TEST(test_election_stagger_desyncs_by_priority); + /* controller-status task 2.2: dispatch gate admits ctrlstatus in every role */ + RUN_TEST(test_dispatch_gate_allows_ctrlstatus_in_every_role); } /* test_am_i_primary suite deleted — am_i_primary() removed (election-iomgr-refactor) */