diff --git a/.kiro/specs/ha-commit-engine/tasks.md b/.kiro/specs/ha-commit-engine/tasks.md index d436e83..20bafa9 100644 --- a/.kiro/specs/ha-commit-engine/tasks.md +++ b/.kiro/specs/ha-commit-engine/tasks.md @@ -47,7 +47,7 @@ - Observable completion: new cases FAIL (red) — current `ha_has_write_quorum` counts `standby_endpoints->count` including catch-up (grep ha.c for the current anchor near ha.c:1507) - _Requirements: 3.1, 3.2, 3.3, 6.1, 6.2_ -- [ ] 4.2 Implement the quorum-accounting fix to green +- [x] 4.2 Implement the quorum-accounting fix to green (incl. reviewer-mandated wired gate test 29; reviewed in main context — reviewer subagent unavailable) - Add `repl_active_standby_count(replicator_t *r)` to repl.c (under `r->lock`); add `replicator_t *repl` back-pointer to `ha_state_t` (ha.h); `ha_has_write_quorum` uses the helper when the back-pointer is wired, falls back to the existing `standby_endpoints` count when NULL (legacy test contexts); quorum condition itself unchanged (`active >= n_peers / 2`, `n_peers <= 1 → true`) - Observable completion: all task 4.1 tests PASS; existing dispatch-gate tests still green - _Requirements: 3.1, 3.2, 3.3, 6.1, 6.2_ diff --git a/.wolf/memory.md b/.wolf/memory.md index 2d12216..7237973 100644 --- a/.wolf/memory.md +++ b/.wolf/memory.md @@ -902,3 +902,11 @@ | 16:20 | Adversarial review of ha-commit-engine task 4.1 (RED quorum tests): boundary clean, 5 RED + 3 pins verified, full census 8 fails (3 bug-411 + 5 RED), APPROVED | test_commit_engine.c, repl.h, repl.c | approved | ~25k | | 09:14 | Edited .kiro/specs/ha-commit-engine/tasks.md | inline fix | ~17 | | 09:15 | Edited .kiro/specs/ha-commit-engine/tasks.md | 1→2 lines | ~280 | +| 09:19 | Edited src/controller/ha.h | added error handling | ~214 | +| 09:19 | Edited src/controller/testsuite/test_commit_engine.c | added error handling | ~544 | +| 09:20 | Edited src/controller/testsuite/test_commit_engine.c | added error handling | ~60 | +| 09:23 | Edited src/controller/repl.c | added 2 condition(s) | ~223 | +| 09:23 | Edited src/controller/ha.c | added error handling | ~202 | +| 2026-06-11 | ha-commit-engine task 4.2: replaced repl_active_standby_count -1 stub with registry-truth count (ep!=NULL && !in_catch_up under r->lock); added ha_state_t.repl back-pointer (ha.h); ha_has_write_quorum uses helper when wired, legacy standby_endpoints fallback when NULL; added wired gate test 29 (RED: Expected -10013 Was 0 → GREEN) | src/controller/repl.c, ha.c, ha.h, testsuite/test_commit_engine.c | 29 tests 0 failures; full census = exactly 3 bug-411 baseline failures unchanged | ~60k | +| 16:40 | Adversarial review ha-commit-engine task 4.2 (quorum-accounting fix): boundary clean, mutant run proves test 29 killing power (Expected -10013 Was 0), 29/29 group PASS, full census = exactly 3 bug-411 baseline failures, no sanitizer output — APPROVED | ha.c, ha.h, repl.c, test_commit_engine.c | approved | ~30k | +| 15:28 | Edited .kiro/specs/ha-commit-engine/tasks.md | inline fix | ~42 | diff --git a/src/controller/ha.c b/src/controller/ha.c index f34103a..f33b12e 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -1513,11 +1513,21 @@ static bool ha_has_write_quorum(ha_state_t *ha) if (ha->n_peers <= 1) return true; - /* Count connected standbys. standby_endpoints is populated by - * raft_cb_become_leader (promoted vote eps) and on_standby_connected - * (late joiners); removed by error handlers. */ - int connected = ha->standby_endpoints + /* Count standbys able to receive new log entries. When the replicator + * back-pointer is wired (task 5.3 init), use registry truth: slots with + * ep != NULL && !in_catch_up under r->lock (Req 3.1-3.3) — catch-up + * members cannot ACK new entries, so they must not count toward write + * admission. NULL back-pointer (legacy contexts) falls back to the + * standby_endpoints count populated by raft_cb_become_leader (promoted + * vote eps) and on_standby_connected (late joiners); removed by error + * handlers. */ + int connected; + if (ha->repl) { + connected = repl_active_standby_count(ha->repl); + } else { + connected = ha->standby_endpoints ? ha->standby_endpoints->count : 0; + } /* Need at least n_peers/2 connected standbys to achieve quorum. * Integer division: n_peers/2 for n_peers=3 → 1 standby minimum. */ diff --git a/src/controller/ha.h b/src/controller/ha.h index fa6fd08..2e1c46a 100644 --- a/src/controller/ha.h +++ b/src/controller/ha.h @@ -192,6 +192,12 @@ typedef struct { uint64_t standby_reported_lsn[MAX_HA_CANDIDATES]; /* Last applied_lsn reported by each standby */ bool standby_converged[MAX_HA_CANDIDATES]; /* Whether standby[i] has converged with primary */ int peer_connect_fail_count[MAX_HA_CANDIDATES]; /* throttle log spam on repeated connect failure */ + /* ── New field added by ha-commit-engine spec (Task 4.2) ──────── */ + replicator_t *repl; /* Back-pointer to the standby registry (persist_t.repl). + * When set, ha_has_write_quorum counts registry truth via + * repl_active_standby_count (ep != NULL && !in_catch_up); + * NULL → legacy standby_endpoints->count fallback. + * (Req 3.1-3.3, 6.1, 6.2; wired at init by task 5.3.) */ } ha_state_t; /* ── Public API ────────────────────────────────────────────── */ diff --git a/src/controller/repl.c b/src/controller/repl.c index 279ad68..8e2063c 100644 --- a/src/controller/repl.c +++ b/src/controller/repl.c @@ -550,12 +550,28 @@ int repl_needs_snapshot(replicator_t *r, uint64_t standby_lsn, * Write-admission quorum accounting (ha-commit-engine task 4) * ============================================================ */ -/* RED scaffold for task 4.1: deliberately inert so the task-4.1 tests fail - * on assertions rather than at link time (-1 is never a valid count). - * The real counting — slots with ep != NULL && !in_catch_up under r->lock — - * is implemented in task 4.2. */ +/* repl_active_standby_count — registry-truth count of standbys able to + * receive new log entries: slots with ep != NULL && !in_catch_up, evaluated + * under r->lock. NULL registry counts zero. Consumed by ha_has_write_quorum + * (ha.c) for write admission. Takes ONLY r->lock — never the commit-engine + * mutex (lock-ordering rule: gate path holds no locks when calling in). + * (ha-commit-engine Req 3.1, 3.2, 3.3) */ int repl_active_standby_count(replicator_t *r) { - (void)r; - return -1; + int i; + int count = 0; + + if (!r) + return 0; + + mylock_lock(&r->lock); + for (i = 0; i < MAX_STANDBYS; i++) { + if (r->standbys[i].ep != NULL && + !atomic_load_explicit(&r->standbys[i].in_catch_up, + memory_order_acquire)) + count++; + } + mylock_unlock(&r->lock); + + return count; } diff --git a/src/controller/testsuite/test_commit_engine.c b/src/controller/testsuite/test_commit_engine.c index cf9d937..d47b445 100644 --- a/src/controller/testsuite/test_commit_engine.c +++ b/src/controller/testsuite/test_commit_engine.c @@ -1061,6 +1061,45 @@ static void test_gate_reaccepts_after_reconnect_without_reset(void) printf("test_gate_reaccepts_after_reconnect_without_reset: PASS\n"); } +/* ── Test 29: WIRED gate — registry truth excludes catch-up at the gate ─── + * Task 4.2 (reviewer-mandated RED for the gate half): n_peers=3 primary with + * the ha_state_t.repl back-pointer wired to a registry whose ONLY connected + * standby is in catch-up. standby_endpoints still lists the endpoint (as + * on_standby_connected would), so the legacy count says 1 — but the registry + * truth says 0 active. The gate must reject the write with NOT_READY; once + * catch-up clears, the very next dispatch is admitted (stateless, Req 6.2). + * (Req 3.1, 3.2, 6.1, 6.2) */ +static void test_gate_wired_registry_catchup_standby_rejects_then_admits(void) +{ + int rc_catchup, rc_cleared; + + /* Arrange: 3-member primary; endpoint registered in standby_endpoints + * AND in the replicator registry, where it is marked in catch-up */ + gate_setup(3, 1); + TEST_ASSERT_EQUAL_INT(0, repl_init(&g_reg)); + TEST_ASSERT_EQUAL_INT(0, repl_add_standby(&g_reg, &g_eps[0], "sb0", 7000)); + repl_start_catchup(&g_reg, &g_eps[0], 0); + g_gate_ha.repl = &g_reg; /* task 5.3 wires this at init; set directly here */ + + /* Act: dispatch while the only standby is catching up... */ + rc_catchup = ha_dispatch_gate(&g_gate_ha, OPCode_jobsub); + /* ...then catch-up completes — nothing else changes */ + repl_complete_catchup(&g_reg, &g_eps[0]); + rc_cleared = ha_dispatch_gate(&g_gate_ha, OPCode_jobsub); + + /* Assert: catch-up-only standby → no write quorum at the gate */ + TEST_ASSERT_EQUAL_INT_MESSAGE(-XCE_ERR_NOT_READY, rc_catchup, + "wired gate must reject writes when the only standby is in catch-up"); + /* ...and clearing catch-up re-admits with no reset call */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc_cleared, + "wired gate must admit writes once catch-up completes"); + + g_gate_ha.repl = NULL; + repl_fini(&g_reg); + gate_teardown(); + printf("test_gate_wired_registry_catchup_standby_rejects_then_admits: PASS\n"); +} + /* ── Test group entry point ──────────────────────────────────────────────── */ void test_commit_engine(void) @@ -1097,5 +1136,7 @@ void test_commit_engine(void) RUN_TEST(test_gate_single_member_always_admits_writes); RUN_TEST(test_gate_rejects_writes_with_zero_connected_standbys); RUN_TEST(test_gate_reaccepts_after_reconnect_without_reset); + /* Task 4.2 — wired gate: registry truth at ha_dispatch_gate */ + RUN_TEST(test_gate_wired_registry_catchup_standby_rejects_then_admits); UNITY_END(); }