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.
465 lines
22 KiB
C
465 lines
22 KiB
C
/*
|
||
* 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 */
|