feat(controller-status): allow ctrlstatus through ha_dispatch_gate in all roles incl no-quorum (task 2.2)

This commit is contained in:
Rongsong Shen 2026-06-20 05:04:03 +00:00
parent a6e03d87ab
commit e56409cbd4
3 changed files with 89 additions and 3 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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) */