chore: baseline snapshot — prior-session work + ha-consistency-invariants spec family

Pre-existing uncommitted work: ha-repl-hardening payload data plane (7.x),
heartbeat liveness fix (Req 1.3), connection-reuse ASAN fixes, doc cleanup.
New: ha-consistency-invariants umbrella (requirements/design/research) and
its four implementation sub-specs (specs generated via batch, cross-spec
review applied), steering roadmap + product.md Raft+CP decision.
This commit is contained in:
Rongsong Shen 2026-06-11 13:21:50 +00:00
parent 084244941b
commit 06135954ff
88 changed files with 25611 additions and 5285 deletions

View file

@ -540,7 +540,10 @@ static void raft_cb_become_leader(void *ctx, uint64_t term)
ectx->votes[i].peer->hostname,
(uint32_t)ectx->votes[i].peer->port);
if (rc >= 0)
TRACE("HA: repl registered standby ep[%d] (slot %d)", i, rc);
INFO("HA: repl registered standby ep[%d] (slot %d, %s:%u)",
i, rc,
ectx->votes[i].peer->hostname,
ectx->votes[i].peer->port);
else
WARN("HA: repl_add_standby ep[%d] failed: %d", i, rc);
}
@ -1489,6 +1492,38 @@ int ha_dispatch_repl_action_for_standby(ha_state_t *ha,
* For CTRL_INIT / CTRL_ELECTING the allowlist is tighter: only
* OP_requestVote and OP_probeConfigTag pass through.
*/
/* ha_has_write_quorum — check whether the cluster has enough connected
* standbys to achieve majority for state-changing operations.
*
* Quorum = ceil(n_peers / 2) + 1 (self-vote included).
* Example: n_peers=3 → quorum=2 → need ≥1 connected standby.
*
* Called from ha_dispatch_gate for CTRL_PRIMARY — if the cluster loses
* majority (standby failures), write ops are rejected with -XCE_ERR_NOT_READY
* to preserve the majority-consistency invariant.
*
* Read-only queries (joblist, reslist, hostlist, etc.) remain available
* regardless of majority status — they don't mutate state. */
static bool ha_has_write_quorum(ha_state_t *ha)
{
if (!ha)
return false;
/* Single-node cluster: no standbys needed — self is the majority. */
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
? 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. */
return connected >= (int)(ha->n_peers / 2);
}
int ha_dispatch_gate(ha_state_t *ha, int opcode)
{
ctrl_role_t role =
@ -1496,6 +1531,31 @@ int ha_dispatch_gate(ha_state_t *ha, int opcode)
switch (role) {
case CTRL_PRIMARY:
/* State-changing operations require majority so committed entries
* are durable across failover. Read-only queries and replication
* ops are always allowed. */
if (!ha_has_write_quorum(ha)) {
switch (opcode) {
/* Replication ops — always allowed (they build majority) */
case OPCode_heartbeat:
case OPCode_replConfig:
case OPCode_replConfigPush:
case OPCode_upgradeConfigAck:
case OPCode_hello:
case OPCode_walAck:
case OPCode_replEntry:
case OPCode_replAck:
case OPCode_replCatchUpDone:
case OPCode_walCatchupEntry:
case OPCode_snapshotTransfer:
/* Read-only queries */
case OPCode_get:
case OPCode_probeConfigTag:
return 0;
default:
return -XCE_ERR_NOT_READY;
}
}
return 0;
case CTRL_INIT:
@ -2623,12 +2683,18 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
int peer_idx = vctx->peer_idx;
uint64_t committed_lsn = 0;
uint64_t committed_tag = 0;
uint64_t last_written = 0; /* latest WAL LSN — may be > committed */
#ifndef XCE_TESTING
if (c) {
committed_lsn = atomic_load_explicit(&c->persist.committed_lsn,
memory_order_acquire);
committed_tag = atomic_load_explicit(&ha->committed_config_tag,
memory_order_acquire);
/* current_lsn is the next LSN to write; last written = current - 1.
* Read without lock — monotonic, approximate value is sufficient
* for the replication decision. */
last_written = (c->persist.current_lsn > 0)
? c->persist.current_lsn - 1 : 0;
}
#else
/* In test mode, c may be NULL; caller sets ha fields directly */
@ -2658,8 +2724,15 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
oldest_wal_lsn = ha_compute_oldest_wal_lsn(c->persist.wal_path);
}
#endif
/* Replication target: use max(committed_lsn, last_written) so
* standbys can catch up even when committed_lsn is stuck at 0
* (no replicator-registered standbys to advance it). */
uint64_t target_lsn = committed_lsn;
if (last_written > target_lsn)
target_lsn = last_written;
ha_dispatch_repl_action_for_standby(ha, c, ep, peer_idx,
committed_tag, committed_lsn,
committed_tag, target_lsn,
oldest_wal_lsn);
} else if (rep_s.data_which == RequestReply_data_walackreplymsg) {
/* Task 4.1: WAL ACK reply from standby — extract lsn + success,
@ -3750,8 +3823,8 @@ static void on_standby_connected(endpoint_t *ep)
int rc = repl_add_standby(&c->persist.repl, ep,
peer->hostname, (uint32_t)peer->port);
if (rc >= 0)
TRACE("HA: repl registered late-joiner standby peer[%d] (slot %d)",
sctx->peer_idx, rc);
INFO("HA: repl registered late-joiner standby peer[%d] (slot %d, %s:%u)",
sctx->peer_idx, rc, peer->hostname, (uint32_t)peer->port);
else
WARN("HA: repl_add_standby late-joiner peer[%d] failed: %d",
sctx->peer_idx, rc);