myxce/src/controller/myapp.c
Rongsong Shen ac46bfa649
fix(controller): restored-but-stale raft term permanently blocked re-election after restart (bug-1695)
Found via the mandatory live HA chaotic soak for this session's controller
audit campaign -- unrelated to any of the 9 audit fixes (bug-1684/1687/
1689/1690/1691/1692/1693/1694), all in ha.c/persist.c/job.c/cluster.c/
sched.c/worker.c; this is in myapp.c, untouched by any of them.

myapp_run()'s bootstrap "is a leader already known" check had a fallback
`c.ha.raft.term > 0` meant to detect genuine new raft activity processed
during the startup-stagger nap. But ha_restore_term() loads ha->term from
disk BEFORE this check runs, on every boot -- and Raft terms only ever
increase, never reset. So `raft.term > 0` is trivially true on every
restart for the rest of the cluster's on-disk lifetime, once it has ever
held one election, regardless of whether any leader is currently
reachable. Live-reproduced: every node, on every restart after the
cluster's first election, unconditionally skipped ha_start_election_async
forever -- no node could ever re-assert leadership through a real
election again. bug-1477's existing cluster_init_phase2 retry loop kept
firing correctly (confirmed in the reproduction logs) but could never
succeed, since the deeper cause -- no election ever runs to produce a
live primary to push config from -- was never addressed by that fix.

Fixed by snapshotting `term_at_boot` right after ha_restore_term()/
ha_init_raft() seed the term, strictly before the stagger nap, and
comparing `raft.term > term_at_boot` instead of `raft.term > 0` -- only a
term that has genuinely ADVANCED during this boot cycle (real vote/
heartbeat traffic processed) counts as evidence a leader is known. The
decision was extracted into a new, unit-testable myapp_leader_already_known()
(startup_validation.c, since controller_unit_test deliberately excludes
myapp.c/main.c). voted_for_name/vft==cur and primary_ctrl_fd>=0 checks
are unchanged.

7 new tests in test_ha_startup.c; RED/GREEN independently verified.
Rigorous A/B live-soak proof (identical 6-round --chaotic run, fixed vs
git-stashed-back-to-original): baseline showed term frozen for the whole
run and zero election activity on any non-primary node; fixed run showed
term genuinely progressing and ctrl-2 achieving a real, log-verified
CTRL_PRIMARY transition via a won PreVote round -- proof a node other
than the original primary can now take over, which was structurally
impossible before. Full controller suite: 1191 tests, 2 failures -- both
the pre-existing, unrelated bug-1561 overlayfs sandbox flake.

Residual, NOT independently verified yet (flagged honestly, not fixed
here): the fixed run's chaotic verdict showed committed_job_loss=6
(baseline 0) -- traced via waldump to one jobid missing from every node's
WAL, at the cold-boot window before round 1. Resembles the already-fixed
bug-1487 false-positive class (apply-pump-lag racing verify_classify's
expect-job check) but the reported mechanism doesn't perfectly match
(bug-1487's job was present-in-WAL/not-yet-visible; this one is described
as absent from every WAL entirely) -- needs independent re-verification
before being dismissed, tracked as a follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
2026-08-25 16:11:37 +00:00

472 lines
24 KiB
C

#define _GNU_SOURCE
#include <errno.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <openssl/ssl.h>
#include "cluster.h"
#include "ha.h"
#include "myconf.h"
#include "netio.h"
#include "persist.h"
#include "delay.h"
#include "request.h"
#include "utils.h"
#include "xce/cluster_def.h"
/* forward declaration — implemented in startup_validation.c */
int validate_startup_config(const char *config_path,
char *warn_buf, size_t warn_len);
/* forward declaration — implemented in config_repl.c */
int load_primary_config(cluster_t *c);
/* ha-term-safety task 4.3: hash-verified recovery-activate from the latest
* committed WAL_CONFIG_MARKER (-ENOENT falls back to load_primary_config). */
int config_activate_committed(cluster_t *c);
/* bug-1695: bootstrap "leader already known" decision — implemented in
* startup_validation.c (not myapp.c) so it links into controller_unit_test,
* which does not build myapp.c (myapp_run is an untestable integration entry
* point requiring a live network socket). See that function's doc comment
* in startup_validation.c for the full contract. */
int myapp_leader_already_known(ha_state_t *ha, const char *my_name,
uint64_t term_at_boot);
time_t last_print_time = 0;
unsigned long npkts = 0;
unsigned long npkts_old = 0;
/*
* myapp_run — fully non-blocking startup path (Task 5.1 / Req 1.1, 9.3, 9.4).
*
* Sequence:
* 1. cluster_init_phase1 — hooks, managers, peer zarray
* 2. SSL contexts — server ctx + client ctx for outgoing votes
* 3. iomgr + vtaskmgr — init and start before election
* 4. cluster_register_listener — listener in epoll before election
* 5. delaymgr_init/start — required before ha_start_election_async
* (election timeout watchdog uses delaymgr)
* 6. persist_init/start — WAL directories + fsync thread
* 7. load_primary_config — pre-load config_tag for freshness gate
* 8. ha_start_election_async — non-blocking: arms watchdog, connects peers
* 9. cluster_run — iomgr event loop; election resolves inside
*
* Removed (Req 9.3): ha_wait_for_heartbeat_or_timeout, am_i_primary_quorum,
* for(;;) retry loop, cluster_init_phase2, cluster_start,
* post-loop config fetching.
* These are replaced by the freshness gate in voting and primary push
* replication (ha_elect_evaluate_and_signal calls cluster_init_phase2 from
* vtask context after the election terminal state is reached).
*/
int myapp_run(myconf_t *myconf)
{
cluster_t c;
int rc;
memset(&c, 0x0, sizeof(c));
c.myconf = myconf;
/* ── Step 1: Phase 1 init ─────────────────────────────────────────────── */
rc = cluster_init_phase1(&c);
if (rc < 0) {
ERROR("cluster_init_phase1: %s", strerror(-rc));
cluster_fini(&c);
return rc;
}
/* Wire raft_c before iomgr starts so raft_cb_persist can look up
* candidate names when processing incoming vote requests during the
* startup stagger nap. Without this, voted_for_name is cleared and the
* already_voted check fails → unnecessary duplicate elections. */
c.ha.raft_c = &c;
/* ── Step 2: SSL contexts ─────────────────────────────────────────────── */
/* Validate cert/key files early to fail fast before any network setup */
{
char warn_buf[256] = {0};
validate_startup_config(myconf->conffile, warn_buf, sizeof(warn_buf));
if (warn_buf[0]) {
WARN("startup: %s", warn_buf);
}
}
initialize_openssl();
/* Server context — used for incoming connections and iomgr */
SSL_CTX *ssl_ctx = create_ssl_context(myconf->certfile, myconf->keyfile);
if (!ssl_ctx) {
ERROR("create_ssl_context failed");
cluster_fini(&c);
return -EINVAL;
}
c.ha.ssl_ctx = ssl_ctx;
/* Client context — used for outgoing vote connections (Req 2.2) */
SSL_CTX *client_ssl_ctx = SSL_CTX_new(TLS_client_method());
if (!client_ssl_ctx) {
ERROR("SSL_CTX_new(TLS_client_method) failed");
cluster_fini(&c);
return -EINVAL;
}
SSL_CTX_set_verify(client_ssl_ctx, SSL_VERIFY_NONE, NULL);
c.ha.client_ssl_ctx = client_ssl_ctx;
/* ── Step 3: iomgr + vtaskmgr ────────────────────────────────────────── */
/* Req 1.2: open the listener socket before any election probe so that
* concurrent-startup peers can reach this node's vote handler. */
rc = cluster_open_listener(&c);
if (rc < 0) {
ERROR("cluster_open_listener: %s", strerror(-rc));
cluster_fini(&c);
return rc;
}
/* Req 1.2: init and start iomgr/vtaskmgr before election so that incoming
* OP_requestVote RPCs are dispatched during the election phase. */
iomgr_init(&(c.iomgr));
iomgr_set_ssl_ctx(&(c.iomgr), ssl_ctx);
{
int nvcpus = (myconf->vcpus > 0) ? myconf->vcpus : 8;
vtaskmgr_init(&(c.vtaskmgr), nvcpus);
}
iomgr_start(&(c.iomgr));
vtaskmgr_start(&(c.vtaskmgr));
/* ── Step 4: register listener ───────────────────────────────────────── */
/* Register listener with epoll before election so iomgr can accept and
* dispatch incoming OP_requestVote connections. */
{
int monitor_rc = cluster_register_listener(&c);
if (monitor_rc < 0) {
ERROR("cluster_register_listener: %s", strerror(-monitor_rc));
cluster_fini(&c);
return monitor_rc;
}
}
/* ── Step 5: delaymgr ────────────────────────────────────────────────── */
/* Init and start delaymgr before ha_start_election_async.
* The election timeout watchdog uses delay_task_ms() which requires
* delaymgr to be running (Req 1.3 non-blocking election watchdog).
* Pass NULL runner_fn → delaymgr uses its built-in default runner. */
delaymgr_init(&(c.delaymgr), 200 /* precision_ms */, NULL);
delaymgr_start(&(c.delaymgr));
/* ── Step 6: persist ─────────────────────────────────────────────────── */
/* Init persist before election so WAL writes are possible as soon as
* cluster_init_phase2 runs from vtask context after election result. */
{
const char *workdir = myconf->workdir ? myconf->workdir
: DEFAULT_WORKDIR;
const char *name = myconf->name ? myconf->name
: "controller";
persist_config_t pcfg = persist_config_defaults();
/* --wal-sync-ms flush-cadence override (0 = keep the persist
* default). Applied before persist_init so persistence starts at
* the requested cadence (ha-commit-durability Req 3.1). */
persist_config_set_wal_sync_ms(&pcfg, myconf->wal_sync_ms);
/* --max-wal-records checkpoint-trigger override (0 = keep the
* persist default). raft-ha-core-safety task 5.10a / ha-entity-
* snapshots task 7.1: lets a soak force real checkpoints on a
* deterministic record count instead of the 10000-record default. */
persist_config_set_max_wal_records(&pcfg, myconf->max_wal_records);
/* --wal-segment-bytes physical-segment-size override (0 = keep the
* persist default). raft-ha-core-safety task 5.10a / ha-entity-
* snapshots task 7.1: a segment is only trimmable once it rotates
* out, so forcing a real snapshot-triggered catch-up in the soak
* needs a small deterministic segment size instead of the 64 MB
* production default. */
persist_config_set_wal_segment_bytes(&pcfg, myconf->wal_segment_bytes);
persist_set_workdir(&c.persist, workdir, name);
rc = persist_init(&c.persist, &pcfg, &c);
if (rc != 0) {
ERROR("persist_init: %s", strerror(-rc));
cluster_fini(&c);
return rc;
}
}
rc = persist_start(&c.persist);
if (rc != 0) {
ERROR("persist_start: %s", strerror(-rc));
cluster_fini(&c);
return rc;
}
/* bug-1416 round 2: close the recovery_complete gate HERE, at the
* earliest safe moment, not just around persist_recover()'s own call
* later (cluster_init_phase2, well after election). This node's own
* WAL/committed-state materialization is not truly "recovered" until
* that call runs -- and this process can receive and durably APPLY
* live-replicated entries (ha_apply_single_entry / ha_apply_term_
* entry_via_library) before then, e.g. while stuck retrying its own
* election as a candidate that keeps losing/timing out before
* discovering a live primary and stepping down (confirmed live: a
* node stuck ELECTING for tens of seconds while already accepting
* AppendEntries from a genuine leader). Set BEFORE ha_start_election_
* async/cluster_run() below can process any network traffic, so no
* live-replication apply is ever reachable before this gate is
* closed. persist_recover() clears it (and issues a catch-up kick)
* once its own replay+verification completes. */
persist_mark_recovery_pending(&c.persist);
/* ── Step 7: pre-load config_tag ─────────────────────────────────────── */
/* Pre-load config from disk if a local config file exists, so that this
* node's config_tag is known before election. The freshness gate in
* handle_request_vote (Req 4.2) requires config_tag to be set here so that
* a node with a valid local config wins over nodes with no config (tag=0).
* If no config file exists yet, config_version_tag stays 0 and the node
* will receive the config via the primary's replication push post-election
* (Req 7.3). */
{
/* ha-term-safety task 4.3 (remediation r1, Finding B / R7.10): activate
* the config referenced by the LATEST COMMITTED WAL_CONFIG_MARKER,
* hash-VERIFIED against the locally-held bytes, instead of a blind
* load_primary_config. persist recovery (step 6 above) has already
* re-derived committed_lsn, so config_committed_marker reads the committed
* prefix here. Semantics:
* - committed marker present + bytes hash-match → activate (verified).
* - committed marker present + bytes mismatch/absent → loud serve-fence,
* member STAYS ALIVE and retries when correct bytes arrive
* out-of-band post-election (NEVER self-terminates).
* - NO committed marker (legacy / first boot) → -ENOENT falls back to
* the existing load_primary_config blind-load (no false fence). */
int pre_rc = config_activate_committed(&c);
if (pre_rc == 0) {
INFO("Pre-election config activated: tag=%016llx",
(unsigned long long)c.myconf->config_version_tag);
} else if (pre_rc == -EAGAIN) {
ERROR("Pre-election config serve-FENCED: committed config marker "
"references bytes not yet present/matching locally — member "
"NON-SERVING under the new config, staying alive and awaiting "
"out-of-band config-push (R7.9)");
}
/* Ignore other errors — node may have no config yet (first boot/standby);
* it fetches config via the primary's push post-election (Req 7.3). */
/* config-upgrade-snapshot task 6.2 (Req 5.2): resume Activation-Fence
* verification here too, unconditionally, so a crash/restart resumes
* materialization-verification before serving rather than implicitly
* treating a fresh process's zero-initialized (unfenced) fence as
* "already verified". Sequenced AFTER config_activate_committed
* deliberately: that call is what establishes/repairs which config
* BYTES are locally active for the committed marker; only once that
* has run does the separate, sequential question
* upgrade_materialization_verify asks -- has THIS MEMBER'S OWN
* checkpoint machinery actually materialized under that marker? --
* become meaningful to (re-)evaluate. persist_recover() (step 6,
* above) has already restored c.persist.cfgref_* from the latest
* loaded checkpoint by this point, so the materialization signal
* this reads is accurate here, at boot, same as it would be after
* any later checkpoint. Same "ignore other errors, log loud only on
* the fenced case" treatment as config_activate_committed above --
* ha_dispatch_gate and the promotion gate (ha.c) are what actually
* enforce the fence from here on; this call's only job is to seed
* the correct fence value at boot instead of leaving a freshly-
* started process at its unfenced zero-default regardless of
* whether materialization genuinely completed before the crash. */
int mat_rc = upgrade_materialization_verify(&c);
if (mat_rc == -EAGAIN) {
ERROR("Pre-election upgrade materialization FENCED: this "
"member's own upgrade-snapshot materialization for the "
"committed config marker is incomplete or hash-mismatched "
"— member NON-SERVING and NON-PROMOTABLE, staying alive "
"and retrying (config-upgrade-snapshot R1.8/3.2/3.4/5.2)");
}
/* Ignore other errors/success silently — mirrors the "ignore other
* errors" handling above; ha_dispatch_gate / the promotion gate
* enforce the fence going forward. */
}
/* ── Step 8: ha_start_election_async ─────────────────────────────────── */
/* Build peer pointer array from peers_cfg.
* n_peers = total cluster size (including self).
* peers[] = array of OTHER candidates (excluding self, identified by
* ha->my_priority). ha_start_election_async handles single-node
* shortcircuit when n_peers == 1. */
const xce_cluster_config_t *pcfg = &myconf->peers_cfg;
int n_peers = (pcfg->candidates_count > 0) ? pcfg->candidates_count : 1;
xce_candidate_t *peer_ptrs[MAX_HA_CANDIDATES];
int n_other = 0;
for (int i = 0; i < pcfg->candidates_count; i++) {
if (i != c.ha.my_priority) {
peer_ptrs[n_other++] = (xce_candidate_t *)&pcfg->candidates[i];
}
}
/* Advertise the candidate freshness for the boot RequestVote payload
* (ha-commit-durability task 4.2). TAG axis = the config-epoch fold
* ha_vote_self_freshness computes (matches the voter side); LSN axis = the
* BARE durable WAL tip persist_durable_floor (via
* ha_candidate_advertise_lsn). A candidate may only advertise a log
* position it can durably back, so a node booted with a truncated log
* cannot over-advertise a stale volatile learned-committed value and win
* (design §1a/§5a). The bare durable tip is still the genuine voter-floor
* local-state input on the peer side (peers now fold durable_floor, not
* applied_lsn), so the most-durable node remains grantable — no livelock
* (design §4). */
uint64_t local_tag = myconf->config_version_tag;
uint64_t local_lsn = (uint64_t)atomic_load_explicit(&c.ha.applied_lsn,
memory_order_acquire);
uint64_t my_tag = 0, my_lsn = 0;
ha_vote_self_freshness(&c.ha, local_tag, local_lsn, &my_tag, NULL);
my_lsn = ha_candidate_advertise_lsn(&c.persist);
/* Initialize raft state machine once with cluster topology.
* Must happen before any election or watchdog path that calls
* raft_on_leader_lost() / raft_start_election(). */
ha_init_raft(&c.ha, &c, n_peers);
/* bug-1695: snapshot ha->raft.term immediately after ha_init_raft() seeds
* it from the just-restored (ha_restore_term(), inside
* cluster_init_phase1 above) on-disk term — strictly BEFORE the
* startup-stagger nap below and before any raft callback (vote grant /
* AppendEntries UpdateTerm) can fire from iomgr traffic processed during
* the nap. myapp_leader_already_known() requires raft.term to have
* genuinely advanced past this baseline — not merely be nonzero — to
* treat "a leader is known" as true; see that function's doc comment for
* the full rationale (a merely-restored, never-since-advanced term was
* trivially nonzero on every restart after the cluster's first election,
* permanently skipping every later election attempt). */
uint64_t term_at_boot = c.ha.raft.term;
/* Startup stagger: delay election start by priority * election_priority_step_ms.
* Node 0 starts first; lower-priority nodes wait, see the winner's vote request
* arrive, and grant the vote instead of starting their own election.
* Uses the same step as the existing blocking-election probe path (Req 1.2). */
if (c.ha.my_priority > 0) {
uint32_t step_ms = c.ha.config.election_priority_step_ms
? c.ha.config.election_priority_step_ms
: HA_DEFAULT_ELECTION_PRIORITY_STEP_MS;
long delay_ns = (long)c.ha.my_priority * (long)step_ms * 1000000L;
struct timespec jitter = {
.tv_sec = delay_ns / 1000000000L,
.tv_nsec = delay_ns % 1000000000L,
};
nanosleep(&jitter, NULL);
}
/* If a leader is already known after the startup stagger (either because
* iomgr processed an incoming vote request and updated the raft term, or
* because ctrlhello arrived on a promoted vote ep), skip starting our own
* election. The voted_for_name check catches the synchronous vote-grant
* path (iomgr was already running); the raft term + primary_ctrl_fd checks
* catch the case where iomgr was NOT running during the nap (connections
* queued in the kernel backlog) but the winner's ctrlhello has already
* established the control channel.
*
* voted_for_name is combined with term: a self-vote means this node was
* a candidate, not a follower — no external leader is known. In
* production raft_cb_persist records the self-vote under this node's own
* name (myconf->name); the "self" placeholder only appears in test mode,
* so both spellings must be checked. A stale vote (voted_for_term <
* ha->term) is also discarded. All these cases must fall through to
* ha_start_election_async.
*
* bug-1695: the raft-term fallback specifically requires raft.term to
* have ADVANCED past term_at_boot (captured above, right after
* ha_init_raft()) — not merely be nonzero. See
* myapp_leader_already_known()'s doc comment (startup_validation.c) for
* the full rationale: a bare `> 0` was trivially true on every restart
* after the cluster's first election, since ha_restore_term() restores a
* persisted, never-decreasing term. */
{
int leader_known = myapp_leader_already_known(&c.ha, myconf->name,
term_at_boot);
if (leader_known) {
pthread_mutex_lock(&c.ha.lock);
uint64_t vft_log = c.ha.voted_for_term;
pthread_mutex_unlock(&c.ha.lock);
INFO("myapp_run: leader already known after startup delay "
"(voted_for=%s, voted_term=%" PRIu64 ", raft_term=%" PRIu64
", term_at_boot=%" PRIu64 ", primary_fd=%d) — skipping own "
"election",
c.ha.voted_for_name[0] ? c.ha.voted_for_name : "(none)",
vft_log, c.ha.raft.term, term_at_boot, c.ha.primary_ctrl_fd);
goto skip_election;
}
}
/* Non-blocking: arms election timeout watchdog, connects outgoing vote
* endpoints, returns immediately. Election resolves via iomgr callbacks;
* ha_elect_evaluate_and_signal calls cluster_init_phase2 from vtask context
* and sets ha->role atomically. ha_dispatch_gate opens when role is set. */
ha_start_election_async(&c.ha, &c, n_peers,
(n_other > 0) ? peer_ptrs : NULL,
my_tag, my_lsn, myconf->name);
skip_election:;
/* All standbys wait passively for the primary to connect outbound and push
* config via ctrlhello. fetch_config_from_primary has been removed:
* standbys never initiate outbound connects after an election result. */
{
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(&c.ha.role,
memory_order_acquire);
if (role == CTRL_STANDBY || role == CTRL_INIT) {
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_STANDBY,
memory_order_release);
/* bug-849: a follower that REJOINS an existing cluster skips its own
* election (a primary already exists), so the election-result path
* that calls cluster_init_phase2 — which builds resources, runs
* persist_recover, and (bug-848) advertises the recovered
* applied_lsn — NEVER runs on this path. The node then comes up on
* an un-recovered WAL (current_lsn=1, applied_lsn=0), the primary
* re-streams the whole log over its consistent records, and the
* committed prefix is lost. Run phase2 HERE too (idempotent via
* phase2_done, same once-per-process contract as the election
* callers) so EVERY boot path recovers its own WAL and advertises
* its real position BEFORE cluster_run starts accepting replication.
* Recovery is decoupled from the primary's config push: a restarted
* node already loaded its config from disk pre-election. */
if (!c.ha.phase2_done) {
int prc = cluster_init_phase2(&c);
if (prc != 0) {
WARN("myapp_run: cluster_init_phase2 on standby rejoin "
"failed (rc=%d) — WAL not recovered; member stays "
"alive and will retry (bug-1477: this passive "
"skip_election path never sees the election-result "
"callers that would otherwise retry it)", prc);
ha_schedule_phase2_retry(&c);
} else {
c.ha.phase2_done = 1;
}
}
INFO("myapp_run: CTRL_STANDBY — waiting for primary to connect and push config");
/* Arm heartbeat watchdog even when we skipped the election.
* If the known primary never connects within heartbeat_timeout_ms,
* the watchdog fires ha_on_primary_ctrl_fd_rdhup which increments
* the term (clearing voted_for) and starts a fresh election. */
ha_arm_heartbeat_timeout_vtask(&c.ha, &c);
}
}
/* ── Step 9: cluster_run ─────────────────────────────────────────────── */
/* Req 9.4: myapp_run exits into cluster_run within 10ms of startup.
* cluster_run is the iomgr event loop — just waits for stop signal.
* All election I/O and role transitions happen inside the loop. */
rc = cluster_run(&c);
cluster_fini(&c);
myconf_fini(myconf);
return rc;
}