myxce/src/controller/ha_convergence.c
Rongsong Shen 40f510fb14 fix(controller): bug-1433 async-thread fdatasync block + bug-1434 committed_lsn CAS race
bug-1433: raft_cb_become_leader runs synchronously off peer_ctrl_dispatch_to_pool,
which hardcodes VTASK_LIVENESS for every inbound peer-control packet including
vote replies. The bug-1431 round-2 fix (flusher_flush_once force-flush in
persist_promotion_advance_committed) was reachable from that never-blocks task
class -- same bug family as bug-1198. Removed the force-flush; round-3
(durable-capped only) plus the already-shipped bug-1432 immediate-first-heartbeat
fix close the visibility-latency gap non-blockingly. TRACKED RESIDUAL:
persist_apply_pump_run_sync is still reachable on the same VTASK_LIVENESS path
and does bounded pread() -- lower risk (page-cache-hot) but strictly still
blocking I/O; not safe to remove outright (reopens bug-1043-family job loss),
needs its own follow-up (reclassify peer_ctrl_classify's winning-vote-reply case
or dispatch promotion tail onto a fresh VTASK_LIVE_BLOCK vtask).

bug-1434: persist_promotion_advance_committed and ha_standby_advance_committed_and_apply
raced on persist_t.committed_lsn via an unlocked load-then-conditional-store during
the promotion window (before ha->role flips to CTRL_PRIMARY) -- a losing racer could
regress committed_lsn. Added persist_committed_lsn_advance_atomic (CAS retry loop,
lock-free) and routed both call sites through it.

Validated: 19/19 unit test suites, 6-round chaotic soak (0 loss/divergence) + 6-round
sequential+leadership-coverage soak, both PASS. Also folds in raft-apply-decoupling
and raft-ha-core-safety validate-impl phase bumps to "implemented" and clears
config-upgrade-snapshot's now-satisfied entity-bearing-snapshots block.
2026-07-11 12:16:28 +00:00

465 lines
22 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* ha_convergence.c — demotion convergence + self-restart primitive
* (ha-convergence-recovery Requirement 1).
*
* ───────────────────────────────────────────────────────────────────────────
* This TU owns the self-restart primitive and the deferred demotion-convergence
* task body. The synchronous demotion BRANCH (hook disable + task scheduling)
* lives in raft_cb_become_follower (ha.c) because that callback is file-static;
* it calls ha_schedule_demotion_convergence() declared here.
*
* Split of responsibilities (design "Demotion Convergence + Self-Restart"):
* - raft_cb_become_follower (ha.c): read prior role → if was-PRIMARY, disable
* the 3 persistence hooks INLINE (Req 1.1),
* then schedule the one-shot convergence
* task off the callback thread (D1).
* - ha_demotion_convergence_fn (here, delaymgr task body): truncate above
* committed_lsn (Req 1.2) → WARN range
* (Req 1.5) → ha_self_restart (Req 1.3).
* - ha_self_restart (here): flush logs → execv(saved argv); exec
* return → ERROR + abort (FATAL, Req 1.4);
* under XCE_TESTING route to the restart /
* fatal seams (Req 1.6, 1.4).
*
* controller_saved_argv lives in main.c (the real argv capture); this TU only
* consumes it through the ha.h accessor.
*
* fd hygiene (Req 1.3 / design fd audit): every controller-to-controller socket
* — listener, accepted peer/replication connections, and outbound vote/peer
* connect sockets — is created FD_CLOEXEC in src/lib/netio.c (socket_create
* sets it at netio.c:219; socket_accept at netio.c:308). The re-exec'd image
* therefore inherits no live sockets and re-creates all endpoints through the
* boot path; no extra close-before-exec sweep is required here.
* ───────────────────────────────────────────────────────────────────────────
*/
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <tlog.h>
#include "cluster.h"
#include "delay.h"
#include "ha.h"
#include "persist.h"
#include "xce/cluster_def.h"
#ifdef XCE_TESTING
/* XCE_TESTING seams (Req 1.6, 1.4) — defined here, declared in ha.h.
* Tests set these before exercising the restart/fatal contract. */
int (*ha_restart_hook)(cluster_t *c) = NULL;
void (*ha_fatal_hook)(const char *msg) = NULL;
/* Quorum-transition emission-count seam (task 5.x, Req 8.1-8.3) — defined here,
* declared in ha.h. ha_log_quorum_transition invokes this once per WARN it
* emits so the counter-hook tests can assert once-per-transition + counts.
* NULL until a test installs it. */
void (*ha_quorum_transition_log_hook)(int lost, int connected,
int required) = NULL;
#endif
#ifndef XCE_TESTING
#define HA_SELF_RESTART_RETRY_MS 5000
static int ha_self_restart_retry_fn(void *arg);
/* ha_self_restart_enter_fence_and_retry: the non-abort response to a failed
* re-exec (2026-07-10, supersedes ha-convergence-recovery Req 1.4's original
* "terminate fatally" text — see requirements.md's superseding note). Sets
* the sticky, never-live-cleared ha->self_restart_pending flag (checked
* unconditionally by raft_cb_become_leader / promotion_fence_timeout_fn
* alongside apply_divergent, ha.c) so this node can never become primary
* while it still carries its stale pre-demotion in-memory state, then
* schedules a bounded periodic retry of the re-exec. This satisfies the same
* safety property Req 1.4 protects — a divergent ex-primary never serves
* authoritatively — via the project-wide fail-loud invariant (a controller
* MUST NEVER self-terminate as a designed response to an operational
* condition; CLAUDE.md "Process Lifecycle") instead of abort(): staying
* alive and retrying self-heals automatically once the transient condition
* (ENOMEM, EMFILE, a permissions change mid-upgrade) clears, whereas a dead
* process needs manual admin recovery and degrades quorum meanwhile. */
static void ha_self_restart_enter_fence_and_retry(cluster_t *c)
{
atomic_store_explicit(&c->ha.self_restart_pending, 1, memory_order_release);
if (c->delaymgr.running) {
int rc = delay_task_ms(&c->delaymgr, HA_SELF_RESTART_RETRY_MS, 1,
ha_self_restart_retry_fn, c);
if (rc < 0)
WARN("HA self-restart: retry scheduling failed: %d — node stays "
"fenced (non-promotable) with no further automatic retry "
"attempt", rc);
} else {
WARN("HA self-restart: delaymgr not running — node stays fenced "
"(non-promotable) with no automatic retry");
}
}
/* ha_self_restart_retry_fn: delaymgr task body — bounded periodic retry of
* the re-exec. A successful retry replaces the process image entirely (this
* function never returns in that case); a repeated failure re-arms itself
* via ha_self_restart's own failure path. */
static int ha_self_restart_retry_fn(void *arg)
{
cluster_t *c = (cluster_t *)arg;
if (!c)
return -EINVAL;
return ha_self_restart(c);
}
#endif
/*
* ha_self_restart — the truncate-and-restart primitive's restart half.
*
* Production (Req 1.3): flush logs, then execv() the saved argv so the
* controller rebuilds in-memory state purely from the surviving (committed)
* WAL prefix via boot recovery. NEVER returns on success (execv replaces the
* process image). If exec cannot be performed, do NOT abort (see
* ha_self_restart_enter_fence_and_retry above) — enter a permanent
* non-promotable fence and keep retrying periodically, per the project's
* never-self-terminate invariant.
*
* Test (Req 1.6): under XCE_TESTING invoke ha_restart_hook(c) instead of
* execv and return its result on the success path; a non-zero hook return
* models an exec failure and routes to ha_fatal_hook (retained as the test
* seam name — it still marks "restart could not be performed", now backed by
* the fence-and-retry response in production rather than abort), so a test
* binary can assert that failure path was taken without dying.
*/
int ha_self_restart(cluster_t *c)
{
#ifdef XCE_TESTING
/* Test contract: the restart seam stands in for execv. */
int rc = 0;
if (ha_restart_hook != NULL)
rc = ha_restart_hook(c);
if (rc != 0 || ha_restart_hook == NULL) {
/* Restart could not be performed — same non-abort response as
* production (Req 1.6 mirrors ha_self_restart_enter_fence_and_retry's
* flag-setting without the real delaymgr retry, since a unit fixture
* has no live delaymgr to retry on). */
if (c)
atomic_store_explicit(&c->ha.self_restart_pending, 1,
memory_order_release);
if (ha_fatal_hook != NULL)
ha_fatal_hook("ha_self_restart: restart seam failed — fenced "
"non-promotable, staying alive");
return rc != 0 ? rc : -1;
}
return 0;
#else
char **argv = controller_saved_argv();
if (argv == NULL || argv[0] == NULL) {
ERROR("HA self-restart: no saved argv — cannot re-exec; entering "
"non-promotable fence and retrying periodically (stay alive)");
ha_self_restart_enter_fence_and_retry(c);
return -1;
}
INFO("HA self-restart: re-executing %s to rebuild from committed state",
argv[0]);
tlog_exit(); /* flush + drain the async log buffer before exec replaces us */
execv(argv[0], argv);
/* execv only returns on failure. A demoted ex-primary must never continue
* serving with potentially divergent state (ha-convergence-recovery Req
* 1.4) — but per the project-wide fail-loud invariant (never self-
* terminate as a designed response to an operational condition), the
* correct response is a permanent non-promotable fence that stays alive
* and keeps retrying, not abort(). */
ERROR("HA self-restart: execv(%s) failed: %d — entering non-promotable "
"fence and retrying periodically (stay alive)", argv[0], errno);
ha_self_restart_enter_fence_and_retry(c);
return -1;
#endif
}
/*
* ha_demotion_convergence_ctx_t — heap context for the one-shot delaymgr task.
* Holds only the cluster pointer; committed_lsn is read at task time (not
* schedule time) so a late-advancing commit still truncates to the right
* boundary.
*/
typedef struct {
cluster_t *c;
} ha_demotion_convergence_ctx_t;
/*
* ha_demotion_convergence_fn — delaymgr task body (runs OFF the become-follower
* callback thread, D1). Truncate above committed_lsn (Req 1.2), WARN with the
* truncated range (Req 1.5), then self-restart (Req 1.3). On truncation failure
* (Req error strategy) ERROR then restart anyway — the rejoin reconciliation
* loop converges the untruncated case at the next join.
*
* In XCE_TESTING the test fixture drives this entry directly (un-started
* delaymgr); ha_self_restart routes to the restart seam there. The ctx is
* freed before restart in production restart never returns, so only the test
* path reaches the free — kept on every non-restarting exit for safety.
*/
int ha_demotion_convergence_fn(void *arg)
{
ha_demotion_convergence_ctx_t *ctx = (ha_demotion_convergence_ctx_t *)arg;
if (ctx == NULL || ctx->c == NULL) {
free(ctx);
return -EINVAL;
}
cluster_t *c = ctx->c;
uint64_t committed = atomic_load_explicit(&c->persist.committed_lsn,
memory_order_acquire);
/* Prior log tip for the WARN range (read under the persist lock). */
pthread_mutex_lock(&c->persist.lock);
uint64_t prior_tip = c->persist.current_lsn > 0
? c->persist.current_lsn - 1
: 0;
pthread_mutex_unlock(&c->persist.lock);
int trc = persist_truncate_wal_above(&c->persist, committed);
if (trc < 0) {
ERROR("HA demotion convergence: truncate above committed_lsn=%" PRIu64
" failed: %d — restarting anyway (rejoin reconciliation will "
"converge)", committed, trc);
} else {
/* Req 1.5: log the discarded range — (committed_lsn, prior tip]. */
WARN("HA demotion convergence: truncated WAL above committed_lsn=%"
PRIu64 " (prior tip=%" PRIu64 "); discarding provisional suffix, "
"restarting to rebuild from committed state",
committed, prior_tip);
}
/* Free the task ctx before restart (production exec never returns). */
free(ctx);
/* Req 1.3: rebuild in-memory state through boot recovery. */
return ha_self_restart(c);
}
/*
* ha_schedule_demotion_convergence — called from raft_cb_become_follower's
* was-PRIMARY branch AFTER the persistence hooks have been disabled inline.
* Schedules exactly one one-shot delaymgr task running ha_demotion_convergence_fn
* so the file I/O (truncation) and exec never run on the callback thread (D1).
*
* Returns 0 on success, negative on allocation/scheduling failure. On failure
* the node stays demoted with hooks disabled; the untruncated suffix is then
* reconciled by rejoin reconciliation at the next join (crash-safe by design).
*/
int ha_schedule_demotion_convergence(cluster_t *c)
{
if (c == NULL)
return -EINVAL;
ha_demotion_convergence_ctx_t *ctx = calloc(1, sizeof(*ctx));
if (ctx == NULL) {
ERROR("HA demotion convergence: ctx alloc failed — suffix will be "
"reconciled at next rejoin");
return -ENOMEM;
}
ctx->c = c;
/* One-shot (runcount=1), minimal delay: the work must run promptly but off
* the iomgr callback thread. delay_task_ms only enqueues (ordq_put); the
* delaymgr worker runs it. In XCE_TESTING the fixture drives the queued
* task synchronously via its stored entry/args. */
int rc = delay_task_ms(&c->delaymgr, 1, 1,
(delay_task_entry_fn_t)ha_demotion_convergence_fn,
ctx);
if (rc < 0) {
ERROR("HA demotion convergence: delay_task_ms failed: %d", rc);
free(ctx);
return rc;
}
return 0;
}
/*
* ha-term-safety task 3.2: the rejoin delaymgr task body (ha_rejoin_reconcile_fn)
* and its heap context (ha_rejoin_reconcile_ctx_t) are REMOVED. They drove the
* watermark-clamped truncate + self-restart that is now retired — divergent-suffix
* reconciliation is owned by the raft library's (term,lsn) AppendEntries path
* (raft_on_append_entries_request → on_truncate_from). See ha_rejoin_reconcile
* below (now a no-op CtrlHelloReply hook).
*/
/*
* ha_rejoin_reconcile — converge a rejoining node whose local WAL log runs ahead
* of the cluster's committed boundary (ha-convergence-recovery Req 2.1–2.3, D5).
*
* Called from the CtrlHelloReply intake (cluster_apply_ctrlhello_reply) after the
* primary-reported committed boundary is stored — the moment the rejoining node
* learns where the cluster has committed.
*
* Decision (design "Rejoin Reconciliation", research D5):
* - local_tip = persist.current_lsn - 1.
* - If local_tip <= primary_committed_lsn → return 0, NO side effects (Req 2.3).
* - If the rejoin_reconciled once-guard is already set → return 0; never restart
* a second time in one process lifetime (Req 2.1 defensive guard; "once" is
* also structural — after restart the tip equals the cutoff, so the condition
* cannot re-fire for the same divergence).
* - Else: cutoff = max(primary_committed_lsn, local committed_lsn) — the CLAMP
* (Req 2.2): never truncate at or below the locally known committed prefix.
* Set the once-guard, then defer truncation + restart to a delaymgr one-shot
* (the intake may run on iomgr, so file I/O / exec must leave it — D5/D1).
*
* Returns 0 on the no-action paths and on a successfully scheduled reconcile;
* negative on a scheduling/allocation failure (the once-guard is rolled back so a
* later rejoin can retry).
*/
int ha_rejoin_reconcile(cluster_t *c, uint64_t primary_committed_lsn)
{
if (c == NULL)
return -EINVAL;
/* ha-term-safety task 3.2: the committed-boundary (watermark) truncate is
* RETIRED. Divergent-suffix reconciliation is now owned by the raft library:
* an incoming AppendEntries from the leader is routed through
* raft_on_append_entries_request (ha_consume_append_entries), and the (term,
* lsn) log-matching there drives on_truncate_from to delete exactly the
* divergent suffix (G3) — never a committed-prefix entry (R3.3), and never by
* an LSN watermark that is term-blind. A node whose tip runs ahead of the
* cluster keeps its entries until the leader's AE proves the conflict by term,
* then the library truncates precisely; until reconciled the existing
* promotion-time fence keeps the member non-promotable (R3.5) without
* self-restarting.
*
* This function is retained as the CtrlHelloReply hook so the learned
* committed boundary is still recorded by the caller (cluster_apply_ctrlhello_
* reply), but it no longer performs a watermark truncate + self-restart. The
* local_tip vs boundary comparison and the clamped-cutoff heuristic that drove
* the old delaymgr truncate/restart task are removed: they are replaced by the
* library's term-aware truncation, not duplicated here.
*/
(void)primary_committed_lsn;
return 0;
}
/*
* cluster_apply_ctrlhello_reply — standby-side CtrlHelloReply intake body.
*
* Lives in this (unit-linkable) TU rather than cluster.c so the rejoin
* intake-integration test can drive the real path: cluster.c (the daemon-only
* vtask handler graph) is NOT part of controller_unit_test_src, but
* ha_convergence.c is. cluster.c's myreq_handler calls this on every
* CtrlHelloReply; the function stores the primary-reported committed boundary +
* config tag (existing behavior), then is the rejoin trigger point.
*
* Rejoin trigger (Req 2.1–2.3, design "Rejoin Reconciliation", research D5):
* AFTER the store, ha_rejoin_reconcile(c, committed_lsn) evaluates the local tip
* against the primary-reported boundary and (when the tip runs ahead) defers a
* clamped truncate + one-time restart. ha_rejoin_reconcile is internally a
* no-op when the tip is at/below the boundary or already reconciled, so it is
* safe to call on every CtrlHelloReply.
*/
void cluster_apply_ctrlhello_reply(cluster_t *c, uint64_t committed_lsn,
uint64_t config_tag)
{
atomic_store_explicit(&c->ha.last_known_committed_lsn, committed_lsn,
memory_order_release);
atomic_store_explicit(&c->ha.last_known_committed_config_tag, config_tag,
memory_order_release);
INFO("HA: CtrlHelloReply from primary: committed_lsn=%" PRIu64
" config_tag=%" PRIu64, committed_lsn, config_tag);
/* Req 2.1–2.3: reconcile a log that runs ahead of the cluster boundary. */
(void)ha_rejoin_reconcile(c, committed_lsn);
}
/*
* ha_log_quorum_transition — write-quorum loss/restore edge logger
* (ha-convergence-recovery task 5.2, Req 8.1, 8.2, 8.3).
*
* Evaluate the current write-quorum state and compare it against the
* ha_state_t.quorum_lost edge-detector flag. On a TRANSITION — and only on a
* transition — atomically flip the flag via CAS and emit exactly one WARN
* carrying the connected/required member counts:
* - loss: "HA quorum lost: connected=%d required=%d" (Req 8.1)
* - restore: "HA quorum restored: connected=%d required=%d" (Req 8.2)
*
* The flag makes the logging an edge detector, NOT a latch: repeated calls while
* the state is unchanged emit nothing (Req 8.3 — no log storms across the
* dispatch-gate backstop's per-rejection calls), but a later loss/restore cycle
* logs again. The CAS guarantees once-per-transition under concurrency: only the
* caller that actually flips the flag emits the WARN.
*
* Observability only — this never returns a status and never gates an operation
* outcome; the dispatch gate's existing -XCE_ERR_NOT_READY behavior is unchanged.
*
* connected/required mirror the ha_has_write_quorum BODY (ha.c): with the repl
* back-pointer wired, connected = repl_active_standby_count(repl) and the
* threshold required = n_peers/2 (integer division — for n_peers=3 → 1). When
* the back-pointer is NULL (legacy contexts), connected falls back to the
* standby_endpoints count, matching the gate's own fallback exactly. The quorum
* decision math is NOT changed here — this only reports what that body decides.
*/
void ha_log_quorum_transition(ha_state_t *ha)
{
if (ha == NULL)
return;
/* Single-node clusters always hold quorum (ha_has_write_quorum returns true
* for n_peers <= 1); there is no transition to observe. */
if (ha->n_peers <= 1)
return;
/* Mirror ha_has_write_quorum's connected-count derivation (ha.c body). */
int connected;
if (ha->repl != NULL)
connected = repl_active_standby_count(ha->repl);
else
connected = ha->standby_endpoints ? ha->standby_endpoints->count : 0;
int required = (int)(ha->n_peers / 2);
int lost_now = (connected < required) ? 1 : 0;
/* Edge detection: only the caller that flips quorum_lost from its prior value
* to lost_now emits. CAS expected=!lost_now → desired=lost_now; if the flag
* already equals lost_now (no transition) the CAS fails and we return
* silently (Req 8.3). */
int expected = lost_now ? 0 : 1;
if (!atomic_compare_exchange_strong_explicit(
&ha->quorum_lost, &expected, lost_now,
memory_order_acq_rel, memory_order_acquire))
return; /* no edge (or another thread already logged it) — no WARN */
if (lost_now)
WARN("HA quorum lost: connected=%d required=%d", connected, required);
else
WARN("HA quorum restored: connected=%d required=%d", connected, required);
#ifdef XCE_TESTING
/* Emission-count seam (Req 8.1-8.3 observable): invoked once per WARN. */
if (ha_quorum_transition_log_hook != NULL)
ha_quorum_transition_log_hook(lost_now, connected, required);
#endif
}
#ifdef XCE_TESTING
/* ───────────────────────────────────────────────────────────────────────────
* ha_walack_continuation_for_test — task 4.2 GREEN.
*
* Test-only shim onto the REAL post-ACK catch-up decision ha_walack_on_ack
* (config_repl.c, D6). The production walAck branch of
* repl_ack_decode_and_dispatch (ha.c) calls the SAME ha_walack_on_ack after
* repl_on_ack, so production and the unit tests exercise identical logic
* (derive target = max(committed_lsn, last_written); behind target → schedule
* one single-flight continuation; at parity → repl_complete_catchup, Req
* 5.1/5.2/7.1). This shim exists only because the production caller decodes a
* real Cap'n Proto WalAckReply off the wire, which is too heavy to drive from a
* unit fixture.
* ─────────────────────────────────────────────────────────────────────────── */
int ha_walack_continuation_for_test(cluster_t *c, void *ep, int peer_idx,
uint64_t acked_lsn)
{
return ha_walack_on_ack(c, ep, peer_idx, acked_lsn);
}
#endif /* XCE_TESTING */