feat(ha-term-safety): out-of-band config bytes, hash-verify-before-activate, serve-fence (task 4.3)
Wires config_committed_marker to a production consumer (config_activate_committed): verifies pushed bytes' sha256 against the committed marker before activation, fences (loud, non-serving, stay-alive) on mismatch, and activates the latest committed marker on recovery. Enforced via ha_dispatch_gate + promotion gate. Fixes bug-981: a 2nd+ config upgrade could permanently fence a standby on a push-before-commit ordering. Adds an off-iomgr delaymgr retry scheduled from the commit-advance hook (atomic read + non-blocking enqueue only on the ACK thread) that re-activates and clears the stale fence once the new marker commits and bytes verify.
This commit is contained in:
parent
c3201a2b1e
commit
be4c1f0721
7 changed files with 1074 additions and 12 deletions
|
|
@ -244,6 +244,211 @@ int config_committed_marker(const cluster_t *c, uint64_t *version_tag,
|
|||
return rc; /* genuinely no committed config marker anywhere */
|
||||
}
|
||||
|
||||
/* ── Config byte delivery: hash-verify-before-activate + serve-fence (task 4.3) ─
|
||||
*
|
||||
* config_committed_marker (4.2) is the AUTHORITY for which configuration is in
|
||||
* effect; the configuration BYTES travel out-of-band on the existing config-push
|
||||
* transport (OPCode_replConfigPush / handle_repl_config_push), never inside a WAL
|
||||
* payload (R7.4/R7.5). config_activate_committed keys that byte delivery to the
|
||||
* committed marker's {version_tag, sha256}: it VERIFIES the locally-held bytes'
|
||||
* sha256 against the committed marker BEFORE activating (R7.8), and on a mismatch
|
||||
* (or bytes absent locally) keeps the member from serving under the new config and
|
||||
* KEEPS RETRYING rather than activating mismatched config (R7.9) — the loud
|
||||
* non-serving fence that NEVER self-terminates. On recovery it activates the config
|
||||
* referenced by the LATEST committed marker (R7.10).
|
||||
*/
|
||||
|
||||
int config_serve_fenced(const cluster_t *c)
|
||||
{
|
||||
if (!c)
|
||||
return 0;
|
||||
return atomic_load_explicit(&c->ha.config_serve_fence,
|
||||
memory_order_acquire) ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Read the locally-held config bytes (resolved via myconf->conffile or
|
||||
* conf/current) and compute their sha256 into out_sha. Returns 0 with the hash on
|
||||
* success; -ENOENT when no local config bytes exist yet (the out-of-band push has
|
||||
* not delivered them). The bytes are the out-of-band-delivered config, NEVER a WAL
|
||||
* payload (R7.4/R7.5). */
|
||||
static int local_config_sha256(cluster_t *c, uint8_t out_sha[32])
|
||||
{
|
||||
myconf_t *mc = c->myconf;
|
||||
if (!mc || !mc->workdir || !mc->name)
|
||||
return -EINVAL;
|
||||
|
||||
/* Resolve the current locally-held versioned config file. */
|
||||
char conf_dir[4096];
|
||||
snprintf(conf_dir, sizeof(conf_dir), "%s/%s", mc->workdir, mc->name);
|
||||
|
||||
char fname[256] = {0};
|
||||
int rc = conf_read_current_filename(conf_dir, fname, sizeof(fname));
|
||||
if (rc != 0)
|
||||
return -ENOENT; /* no local bytes yet — caller fences + retries */
|
||||
|
||||
char path[4352];
|
||||
snprintf(path, sizeof(path), "%s/conf/%s", conf_dir, fname);
|
||||
|
||||
uint8_t *bytes = NULL;
|
||||
size_t blen = 0;
|
||||
rc = conf_read_raw(path, &bytes, &blen);
|
||||
if (rc != 0)
|
||||
return -ENOENT;
|
||||
|
||||
SHA256(bytes, blen, out_sha);
|
||||
free(bytes);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int config_activate_committed(cluster_t *c)
|
||||
{
|
||||
if (!c)
|
||||
return -EINVAL;
|
||||
|
||||
/* 1. The committed marker is the authority for the target config (R7.7/R7.10:
|
||||
* on recovery this is the LATEST committed marker). */
|
||||
uint64_t marker_tag = 0;
|
||||
uint8_t marker_sha[32];
|
||||
int rc = config_committed_marker(c, &marker_tag, marker_sha);
|
||||
if (rc == -ENOENT) {
|
||||
/* No committed config marker exists (legacy / first boot, or the marker
|
||||
* has not committed yet). Absent-marker is NOT a hash MISMATCH, so it must
|
||||
* NOT fence the node (a benign "awaiting bytes/marker" state, never a hard
|
||||
* divergence fence — R7.9/R7.10). FALL BACK to the existing
|
||||
* load_primary_config behavior so any locally-held bytes are activated
|
||||
* (config_version_tag is set for the freshness gate and the push ACK).
|
||||
* A missing local config file (-ENOENT from load_primary_config) is also
|
||||
* benign first-boot — the node fetches config post-election. Clear any
|
||||
* stale fence either way: an absent marker is never a divergence. */
|
||||
atomic_store_explicit(&c->ha.config_serve_fence, 0, memory_order_release);
|
||||
int lrc = load_primary_config(c);
|
||||
if (lrc != 0 && lrc != -ENOENT)
|
||||
WARN("config_activate_committed: no committed marker; "
|
||||
"load_primary_config fallback rc=%d", lrc);
|
||||
return 0;
|
||||
}
|
||||
if (rc != 0)
|
||||
return rc; /* -EINVAL / -EIO surfaced to the caller */
|
||||
|
||||
/* 2. Hash-verify the LOCALLY-HELD (out-of-band-delivered) bytes against the
|
||||
* committed marker BEFORE activating (R7.8). */
|
||||
uint8_t local_sha[32];
|
||||
rc = local_config_sha256(c, local_sha);
|
||||
bool verified = (rc == 0 && memcmp(local_sha, marker_sha, 32) == 0);
|
||||
|
||||
if (!verified) {
|
||||
/* 3. MISMATCH or bytes not yet present locally: the bytes for this
|
||||
* committed marker have not arrived/verified out-of-band. Keep the
|
||||
* member NON-SERVING under the new config and KEEP RETRYING — the loud
|
||||
* non-self-terminating fence (R7.9). NEVER activate mismatched bytes.
|
||||
* Stays ALIVE: returns -EAGAIN so the caller retries when the matching
|
||||
* bytes arrive via the existing config-push transport. */
|
||||
int was = atomic_exchange_explicit(&c->ha.config_serve_fence, 1,
|
||||
memory_order_acq_rel);
|
||||
if (!was)
|
||||
ERROR("config serve-fence: committed config marker tag=%016" PRIx64
|
||||
" references bytes whose sha256 is not present/matching locally "
|
||||
"(rc=%d) -- member NON-SERVING under the new config, awaiting "
|
||||
"out-of-band config-push bytes; staying alive and retrying",
|
||||
marker_tag, rc);
|
||||
return -EAGAIN;
|
||||
}
|
||||
|
||||
/* 4. VERIFIED: the locally-held bytes hash-match the committed marker —
|
||||
* activate the configuration (load it into myconf), then clear the fence. */
|
||||
rc = load_primary_config(c);
|
||||
if (rc != 0) {
|
||||
/* The verified bytes failed to load (e.g. decode error). Stay fenced and
|
||||
* retrying rather than serving an unloadable config — never self-terminate. */
|
||||
int was = atomic_exchange_explicit(&c->ha.config_serve_fence, 1,
|
||||
memory_order_acq_rel);
|
||||
if (!was)
|
||||
ERROR("config serve-fence: hash-verified config bytes for committed "
|
||||
"marker tag=%016" PRIx64 " failed to load (rc=%d) -- member "
|
||||
"NON-SERVING, staying alive and retrying", marker_tag, rc);
|
||||
return rc;
|
||||
}
|
||||
|
||||
atomic_store_explicit(&c->ha.config_serve_fence, 0, memory_order_release);
|
||||
INFO("config activated from committed marker tag=%016" PRIx64
|
||||
" (hash-verified out-of-band bytes)", marker_tag);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Commit-advance retry trigger (task 4.3 remediation r2, Finding D / R7.9) ──
|
||||
*
|
||||
* The config MARKER (WAL stream) and the config BYTES (out-of-band config-push)
|
||||
* arrive on INDEPENDENT transports. On a 2nd-or-later upgrade the bytes can be
|
||||
* saved locally BEFORE the standby's committed prefix advances past the NEW
|
||||
* marker: at that moment config_committed_marker still returns the OLDER marker,
|
||||
* so the freshly-saved NEW bytes hash-MISMATCH it and config_activate_committed
|
||||
* sets the serve-fence (-EAGAIN). The NEW marker commits later via the WAL path,
|
||||
* but on_commit_advanced runs on the iomgr ACK thread and MUST NOT do disk I/O,
|
||||
* so it cannot re-activate inline; and the primary's "config-behind" re-push net
|
||||
* does NOT fire because conf_save_config already advanced this member's advertised
|
||||
* config_version_tag. Without a retry the standby stays PERMANENTLY fenced (and
|
||||
* non-promotable) for that upgrade until reboot — a §7.9 liveness violation.
|
||||
*
|
||||
* The fix: when commit advances WHILE this member is serve-fenced, schedule a
|
||||
* one-shot OFF-IOMGR delaymgr task that re-runs config_activate_committed. The
|
||||
* delaymgr worker runs on a thread allowed to block on the disk reads
|
||||
* (scan_latest_committed_marker pread + load_primary_config). Once the NEW marker
|
||||
* is in the committed prefix AND the locally-held bytes hash-match it, the retry
|
||||
* activates and CLEARS the fence; a genuine still-MISMATCH (or no-bytes-yet) keeps
|
||||
* the fence and the next commit-advance reschedules. -ENOENT still falls back
|
||||
* without fencing (the no-marker path is benign). Idempotent + self-limiting: the
|
||||
* scheduler is a no-op once unfenced, so there is no busy-spin. */
|
||||
|
||||
/* config_activate_retry_fn: the delaymgr task body (runs OFF the iomgr ACK
|
||||
* thread). Re-runs config_activate_committed so a freshly-committed config marker
|
||||
* is activated — and a stale serve-fence cleared — once its out-of-band bytes
|
||||
* verify. `arg` is the cluster_t* (passed directly; no heap ctx to free, so the
|
||||
* delaymgr node-killer that frees only the delay_task_t never leaks). */
|
||||
int config_activate_retry_fn(void *arg)
|
||||
{
|
||||
cluster_t *c = (cluster_t *)arg;
|
||||
if (!c)
|
||||
return -EINVAL;
|
||||
if (!config_serve_fenced(c))
|
||||
return 0; /* already cleared by another path — nothing to retry */
|
||||
|
||||
int rc = config_activate_committed(c);
|
||||
if (rc == 0)
|
||||
INFO("config commit-advance retry: serve-fence CLEARED — newly committed "
|
||||
"config marker activated (hash-verified out-of-band bytes)");
|
||||
else if (rc == -EAGAIN)
|
||||
TRACE("config commit-advance retry: still fenced (marker/bytes not yet "
|
||||
"matching) — will retry on the next commit advance");
|
||||
else
|
||||
WARN("config commit-advance retry: config_activate_committed rc=%d", rc);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* config_schedule_activate_retry: enqueue config_activate_retry_fn on the
|
||||
* delaymgr (NON-BLOCKING — delay_task_ms only does ordq_put). Called from the
|
||||
* commit-advance path (iomgr ACK thread) so activation's disk I/O runs OFF that
|
||||
* thread (ha-async-io invariant). No-op when the member is NOT serve-fenced (so
|
||||
* the steady state schedules nothing) or when the delaymgr is not running (the
|
||||
* unit-test fixture path, which never inits delaymgr — avoids leaking a task into
|
||||
* an un-drained queue). Returns 0 on enqueue/no-op, negative on enqueue failure. */
|
||||
int config_schedule_activate_retry(cluster_t *c)
|
||||
{
|
||||
if (!c)
|
||||
return -EINVAL;
|
||||
if (!config_serve_fenced(c))
|
||||
return 0; /* steady state: nothing to retry, schedule nothing */
|
||||
if (!c->delaymgr.running)
|
||||
return 0; /* delaymgr not running (test fixture) — caller drives directly */
|
||||
|
||||
/* One-shot, minimal delay: run promptly but off the iomgr ACK thread. */
|
||||
int rc = delay_task_ms(&c->delaymgr, 1, 1,
|
||||
(delay_task_entry_fn_t)config_activate_retry_fn, c);
|
||||
if (rc < 0)
|
||||
WARN("config commit-advance retry: delay_task_ms failed: %d — fence will "
|
||||
"clear on a later commit advance or config-push", rc);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* config_commit_change (ha-term-safety task 4.2 — LIVE wiring): order a config
|
||||
* change through the unified (term,lsn) log as the CONSENSUS RECORD and make the
|
||||
* derived committed marker authoritative, retiring committed_config_tag to a
|
||||
|
|
@ -991,7 +1196,14 @@ int handle_repl_config_push(cluster_t *c, endpoint_t *ep, xce_request_t *req)
|
|||
return -EBADMSG;
|
||||
}
|
||||
|
||||
/* Save to disk and reload */
|
||||
/* ha-term-safety task 4.3 (remediation r1, Finding A): SAVE the pushed bytes
|
||||
* to disk out-of-band, but do NOT activate them here. config_activate_committed
|
||||
* is the SOLE activation point — it loads the bytes into the live config ONLY
|
||||
* after hash-verifying them against the latest committed WAL_CONFIG_MARKER
|
||||
* (R7.5/R7.8). A pre-emptive load_primary_config here would make the just-pushed
|
||||
* bytes LIVE before the gate runs, turning verify-before-activate into an
|
||||
* after-the-fact overlay. The bytes travel out-of-band via this transport and
|
||||
* NEVER inside a WAL payload (R7.4/R7.5). */
|
||||
int rc = conf_save_config(c->myconf, rcpr->raw_bytes, rcpr->raw_bytes_len,
|
||||
rcpr->raw_sha256);
|
||||
if (rc != 0) {
|
||||
|
|
@ -999,14 +1211,19 @@ int handle_repl_config_push(cluster_t *c, endpoint_t *ep, xce_request_t *req)
|
|||
return rc;
|
||||
}
|
||||
|
||||
rc = load_primary_config(c);
|
||||
if (rc != 0) {
|
||||
WARN("handle_repl_config_push: load_primary_config failed: %d", rc);
|
||||
return rc;
|
||||
}
|
||||
INFO("handle_repl_config_push: config bytes saved out-of-band "
|
||||
"(pushed tag=%016" PRIx64 ") — activation gated on committed marker",
|
||||
rcpr->version_tag);
|
||||
|
||||
INFO("handle_repl_config_push: config saved and loaded (tag=%016" PRIx64 ")",
|
||||
c->myconf->config_version_tag);
|
||||
/* Key activation to the committed marker. On mismatch (the marker references
|
||||
* different/later bytes than the ones just pushed) config_activate_committed
|
||||
* sets the loud non-serving serve-fence and returns -EAGAIN; the member STAYS
|
||||
* ALIVE and a later config-push / commit-advance retries (R7.9). -ENOENT
|
||||
* (no committed marker yet — first boot/bootstrap) falls back to loading the
|
||||
* locally-held bytes; a successful verify activates. */
|
||||
int act_rc = config_activate_committed(c);
|
||||
if (act_rc != 0 && act_rc != -EAGAIN)
|
||||
WARN("handle_repl_config_push: config_activate_committed rc=%d", act_rc);
|
||||
|
||||
/* ACK the primary */
|
||||
repl_config_push_reply_t reply_body = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue