replay_wal() (persist.c ~3902) tallied *new_job_submits_out only for hdr.type == WAL_JOB_SUBMIT. WAL_JOB_ARRAY_SUBMIT entries were dispatched and correctly materialized N real jobmgr entries via apply_committed_entry()'s array case, but never counted -- persist_verify_apply_correctness() then saw real jobmgr growth exceed the undercounted expectation and declared APPLY_DIVERGENT, engaging a PERMANENT, non-self-healing fence (distinct from task 4.2's self-healing Live Apply Resource Fence). Live-reproduced during task 6.1's soak attempt on 2 of 3 nodes in a single small-array (N=2) sub-round via two different trigger paths. Deterministic, not size-dependent -- the first array submission to a live cluster would fence any node that restarts or hits an election-path recovery fallback before its next checkpoint, shrinking the promotable pool. A pre-existing comment at persist.c ~3863-3874 (predating this spec) had already flagged this exact code region as needing re-verification before tasks 4.1/6.1+ went live; this is that re-verification, and it failed. Fix adds a WAL_JOB_ARRAY_SUBMIT branch tallying apply_rc (the genuine per-record element count apply_committed_entry()'s array case already returns) when apply_rc > 0, mirroring the existing dedup-safe pattern for WAL_JOB_SUBMIT. Traced the full return-value space of restore_job_array()/apply_committed_entry()'s array case to confirm every tallied value is provably equal to real jobmgr growth -- the fix cannot overcount and so cannot mask a genuine divergence. Verified via a from-scratch live 3-node HA soak re-run (--array-soak), independently by both implementer and reviewer: zero APPLY_DIVERGENT across all three original trigger paths, one-WAL-record/one-commit- barrier confirmed for N=2 and N=10000, per-element post-failover status/cancel/completion confirmed. Task 6.0a of job-array-single-wal-record (Requirements 3.1, 3.2, 4.1, 9.4). Inserted mid-run after live soak testing surfaced the bug; user confirmed sequencing (fix now, blocking task 6.1+).
5953 lines
257 KiB
C
5953 lines
257 KiB
C
#define _GNU_SOURCE
|
||
#include "persist.h"
|
||
#include "config.h"
|
||
#include "ctrl_metrics.h"
|
||
#include "hook.h"
|
||
#include "xce/cluster_def.h"
|
||
#include "xce/errnox.h"
|
||
#include "xce/types.h"
|
||
#include "controller.capnp.h"
|
||
#include "xlist.h"
|
||
#include "cluster.h"
|
||
#include "commit.h" /* commit_submit RETAIN registration (barrier-rollout 1.2) */
|
||
|
||
#include <assert.h>
|
||
#include <stdbool.h>
|
||
#include <dirent.h>
|
||
#include <errno.h>
|
||
#include <fcntl.h>
|
||
#include <inttypes.h>
|
||
#include <pthread.h>
|
||
#include <stdatomic.h>
|
||
#include <stdint.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <sys/statfs.h>
|
||
#include <time.h>
|
||
#include <unistd.h>
|
||
#include <zlib.h>
|
||
|
||
/* restore_* functions defined in job.c / worker.c / queue.c */
|
||
extern int restore_job(cluster_t* c, uint64_t jobid, const void* capnp_payload,
|
||
size_t len, uint64_t entry_lsn);
|
||
extern int restore_job_state(cluster_t* c, uint64_t jobid, uint32_t state);
|
||
extern int worker_from_capnp(cluster_t* c,
|
||
const void* capnp_payload, size_t len);
|
||
extern int restore_queue_state(cluster_t* c,
|
||
const char* name, uint32_t status);
|
||
|
||
/* raft-apply-decoupling task 3.2: pending command-apply hand-off list node
|
||
* (producer side; the not-yet-built ordered apply pump, task 6.1/6.2, is
|
||
* the consumer). Declared here (rather than persist.h) so the list stays
|
||
* an implementation detail — persist.h exposes only the opaque `void*`
|
||
* head field plus persist_register_pending_apply/persist_has_pending_apply.
|
||
* See persist.h's doc comment on persist_register_pending_apply for the
|
||
* full contract. */
|
||
typedef struct pending_command_apply {
|
||
uint64_t lsn;
|
||
pending_apply_notify_fn on_applied;
|
||
void* ctx;
|
||
struct pending_command_apply* next;
|
||
} pending_command_apply_t;
|
||
|
||
/* ---- Cap'n Proto serialization helpers (WAL payload encoding) ---------- */
|
||
|
||
/* Serialize xce_job_t to a malloc'd Cap'n Proto buffer.
|
||
* Caller must free(*out) on success. */
|
||
static int capnp_encode_job(const xce_job_t* j, uint8_t** out, size_t* out_len)
|
||
{
|
||
struct capn c;
|
||
struct capn_segment* cs;
|
||
struct Job cj;
|
||
Job_ptr jp;
|
||
|
||
capn_init_malloc(&c);
|
||
cs = capn_root(&c).seg;
|
||
encode_Job(cs, &cj, (xce_job_t*)j);
|
||
jp = new_Job(cs);
|
||
write_Job(&cj, jp);
|
||
capn_setp(capn_root(&c), 0, jp.p);
|
||
|
||
*out_len = capn_size(&c);
|
||
*out = malloc(*out_len);
|
||
if (!*out) { capn_free(&c); return -ENOMEM; }
|
||
capn_write_mem(&c, *out, *out_len, 0);
|
||
capn_free(&c);
|
||
return 0;
|
||
}
|
||
|
||
/* Serialize xce_worker_t to a malloc'd Cap'n Proto buffer. */
|
||
static int capnp_encode_worker(const xce_worker_t* w, uint8_t** out, size_t* out_len)
|
||
{
|
||
struct capn c;
|
||
struct capn_segment* cs;
|
||
struct Worker cw;
|
||
Worker_ptr wp;
|
||
|
||
capn_init_malloc(&c);
|
||
cs = capn_root(&c).seg;
|
||
encode_Worker(cs, &cw, (xce_worker_t*)w);
|
||
wp = new_Worker(cs);
|
||
write_Worker(&cw, wp);
|
||
capn_setp(capn_root(&c), 0, wp.p);
|
||
|
||
*out_len = capn_size(&c);
|
||
*out = malloc(*out_len);
|
||
if (!*out) { capn_free(&c); return -ENOMEM; }
|
||
capn_write_mem(&c, *out, *out_len, 0);
|
||
capn_free(&c);
|
||
return 0;
|
||
}
|
||
|
||
/* WAL_WORKER_CTRL payload: [uint32_t detail][capnp bytes...] or [uint32_t detail][name\0] */
|
||
#define WORKER_WAL_HDR_SIZE sizeof(uint32_t)
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Constants */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
#define WAL_SEG_FMT "%s/seg-%016" PRIx64 ".wal"
|
||
|
||
/* NFS_SUPER_MAGIC — reject WAL on NFS mounts (unreliable fsync) */
|
||
#define NFS_SUPER_MAGIC 0x6969
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Internal helpers */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
static uint64_t now_us(void)
|
||
{
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_REALTIME, &ts);
|
||
return (uint64_t)ts.tv_sec * 1000000ULL + (uint64_t)(ts.tv_nsec / 1000);
|
||
}
|
||
|
||
/* Recursive mkdir — equivalent to mkdir -p */
|
||
static int mkdir_p(const char* path, mode_t mode)
|
||
{
|
||
char tmp[4096];
|
||
char* p;
|
||
size_t len;
|
||
|
||
snprintf(tmp, sizeof(tmp), "%s", path);
|
||
len = strlen(tmp);
|
||
if (len > 0 && tmp[len - 1] == '/')
|
||
tmp[len - 1] = '\0';
|
||
|
||
for (p = tmp + 1; *p; p++) {
|
||
if (*p == '/') {
|
||
*p = '\0';
|
||
if (mkdir(tmp, mode) != 0 && errno != EEXIST)
|
||
return -1;
|
||
*p = '/';
|
||
}
|
||
}
|
||
if (mkdir(tmp, mode) != 0 && errno != EEXIST)
|
||
return -1;
|
||
return 0;
|
||
}
|
||
|
||
/* Open (and pre-allocate) a new WAL segment named after current_lsn */
|
||
static int open_new_segment(persist_t* p)
|
||
{
|
||
snprintf(p->seg_path, sizeof(p->seg_path),
|
||
WAL_SEG_FMT, p->wal_path, p->current_lsn);
|
||
|
||
int fd = open(p->seg_path, O_RDWR | O_CREAT | O_TRUNC, 0600);
|
||
if (fd < 0)
|
||
return -errno;
|
||
|
||
/* Pre-allocate segment space to avoid fragmentation and metadata updates
|
||
* on every write. posix_fallocate is best-effort — ignore errors. */
|
||
posix_fallocate(fd, 0, (off_t)p->config.wal_segment_bytes);
|
||
|
||
p->wal_fd = fd;
|
||
p->seg_offset = 0;
|
||
return 0;
|
||
}
|
||
|
||
/* Close current segment and open a fresh one */
|
||
static int rotate_segment(persist_t* p)
|
||
{
|
||
if (p->wal_fd >= 0) {
|
||
fdatasync(p->wal_fd);
|
||
close(p->wal_fd);
|
||
p->wal_fd = -1;
|
||
}
|
||
return open_new_segment(p);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Demand-driven group-commit flusher (ha-commit-durability task 2.1) */
|
||
/* */
|
||
/* Repurposes the old fixed-interval fsync timer into a flusher driven */
|
||
/* by the append-vs-synced gap. On each pass it batches one fdatasync */
|
||
/* over all entries appended since the last sync, advances durable_lsn */
|
||
/* to the synced tip, and wakes the commit evaluation. --wal-sync-ms */
|
||
/* now bounds the MAX batching latency (a no-further-append entry is */
|
||
/* still synced within the cadence), not a durability gap. */
|
||
/* */
|
||
/* LOST-WAKEUP / CORRECTNESS REASONING (gating item): */
|
||
/* */
|
||
/* The "work pending" condition is LEVEL-triggered on two persistent */
|
||
/* variables, NOT an edge: pending ⇔ durable_lsn < current_lsn - 1 */
|
||
/* (flusher_work_pending below, evaluated under p->lock). Because it */
|
||
/* is a comparison of stored state — not a transient flag consumed on */
|
||
/* read — a signal that races the flusher's wait can never be lost: */
|
||
/* */
|
||
/* - persist_wal_async advances current_lsn under p->lock, THEN */
|
||
/* signals fsync_cond. If the flusher is mid-flush when the append */
|
||
/* lands, current_lsn has already moved past the tip the flusher */
|
||
/* captured, so after that flush durable_lsn < current_lsn - 1 */
|
||
/* STILL holds; the flusher re-checks the level under p->lock on */
|
||
/* its next loop iteration and flushes again (it does NOT wait */
|
||
/* first). The missed signal is therefore harmless — the level */
|
||
/* itself carries the pending work forward. */
|
||
/* - If the append's signal arrives while the flusher sits in */
|
||
/* pthread_cond_wait, it wakes it. If it arrives in the tiny window */
|
||
/* after the flusher decided "no work" but before it re-enters the */
|
||
/* wait, the flusher re-evaluates the level under p->lock before */
|
||
/* waiting (we re-check inside the fsync_mutex-held region by */
|
||
/* reading current_lsn/durable_lsn under p->lock first), so it */
|
||
/* will not block while work is pending. */
|
||
/* */
|
||
/* "NEVER durable before its fdatasync": the flusher snapshots */
|
||
/* tip = current_lsn - 1 AND calls fdatasync WHILE HOLDING p->lock. */
|
||
/* persist_wal_async also pwrites under p->lock, so no append can */
|
||
/* extend the segment between the tip snapshot and the fdatasync — by */
|
||
/* the time fdatasync returns, every byte for LSNs <= tip is on stable */
|
||
/* storage. durable_lsn is only then advanced to tip (monotonic max). */
|
||
/* Releasing p->lock and broadcasting commit-eval happens after the */
|
||
/* advance, so a WAIT caller that observes durable_lsn >= its LSN is */
|
||
/* guaranteed the bytes are synced. */
|
||
/* */
|
||
/* Lock discipline: p->lock and fsync_mutex are NEVER held together */
|
||
/* (fsync_mutex guards only the cond wait; the flush section takes */
|
||
/* p->lock alone). This keeps fdatasync off any lock the iomgr / */
|
||
/* heartbeat path needs and preserves liveness (Req 1.6). */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/* Test-only fault-injection probe (ha-commit-durability task 2.1 error path).
|
||
* Returns true when the test has forced fdatasync to be treated as failed.
|
||
* Compiled to a constant `false` in production (no -DXCE_TESTING), so the
|
||
* fail-loud gate below collapses to the pure rc != 0 check with zero overhead
|
||
* and no dependency on the test-only struct field. */
|
||
static inline bool flusher_fsync_forced_fail(persist_t* p)
|
||
{
|
||
#ifdef XCE_TESTING
|
||
return atomic_load_explicit(&p->test_force_fsync_fail,
|
||
memory_order_acquire) != 0;
|
||
#else
|
||
(void)p;
|
||
return false;
|
||
#endif
|
||
}
|
||
|
||
/* Returns true iff there is un-synced appended data (level predicate).
|
||
* MUST be called with p->lock held. current_lsn - 1 is the appended tip
|
||
* (current_lsn is the NEXT lsn to assign); LSN 0 reserved, so a fresh
|
||
* store has current_lsn == 1 → tip 0 → no pending work. */
|
||
static bool flusher_work_pending_locked(persist_t* p)
|
||
{
|
||
uint64_t tip = (p->current_lsn > 0) ? p->current_lsn - 1 : 0;
|
||
uint64_t dur = atomic_load_explicit(&p->durable_lsn, memory_order_acquire);
|
||
return dur < tip;
|
||
}
|
||
|
||
/* Perform one group-commit flush: snapshot the appended tip, fdatasync the
|
||
* live segment, and advance durable_lsn to the synced tip. Takes p->lock
|
||
* for the whole snapshot+sync+advance so no concurrent append can extend
|
||
* the segment between the tip read and the fdatasync. Returns the new
|
||
* durable_lsn (unchanged if there was nothing to sync). */
|
||
static uint64_t flusher_flush_once(persist_t* p)
|
||
{
|
||
pthread_mutex_lock(&p->lock);
|
||
|
||
uint64_t tip = (p->current_lsn > 0) ? p->current_lsn - 1 : 0;
|
||
uint64_t dur = atomic_load_explicit(&p->durable_lsn, memory_order_acquire);
|
||
|
||
if (dur >= tip || p->wal_fd < 0) {
|
||
/* Nothing new to sync (or no segment yet). Still flush the fd for a
|
||
* clean-shutdown / fallback pass, but do not advance the watermark.
|
||
* The watermark is already <= tip here, so a failed fallback sync
|
||
* cannot make it claim more than is durable — but we still surface
|
||
* the error loudly so a degrading disk is never silent (Req 1.1). */
|
||
if (p->wal_fd >= 0) {
|
||
int rc = fdatasync(p->wal_fd);
|
||
if (rc != 0 || flusher_fsync_forced_fail(p)) {
|
||
int e = (rc != 0) ? errno : EIO;
|
||
ERROR("persist: flusher fallback fdatasync FAILED (errno=%d %s)"
|
||
" — durable_lsn held at %" PRIu64 "; WAL not advanced",
|
||
e, strerror(e), dur);
|
||
}
|
||
}
|
||
pthread_mutex_unlock(&p->lock);
|
||
return dur;
|
||
}
|
||
|
||
/* fdatasync under p->lock: appends also hold p->lock to pwrite, so the
|
||
* segment cannot grow between this tip snapshot and the sync. Every byte
|
||
* for LSNs <= tip is therefore on stable storage when fdatasync returns
|
||
* SUCCESSFULLY. */
|
||
int rc = fdatasync(p->wal_fd);
|
||
|
||
/* FAIL-LOUD GATE (Req 1.1/1.2): advance durable_lsn ONLY on a successful
|
||
* sync. On EIO/ENOSPC the bytes are NOT on stable storage, so leaving the
|
||
* watermark unchanged keeps the non-durable LSNs from being counted toward
|
||
* the commit majority. We do NOT retry into an advanced watermark — the
|
||
* level predicate stays pending and the next pass retries the sync. */
|
||
if (rc != 0 || flusher_fsync_forced_fail(p)) {
|
||
int e = (rc != 0) ? errno : EIO;
|
||
ERROR("persist: flusher fdatasync FAILED (errno=%d %s) — durable_lsn "
|
||
"held at %" PRIu64 " (tip=%" PRIu64 " NOT made durable); commit "
|
||
"majority will not count these LSNs",
|
||
e, strerror(e), dur, tip);
|
||
pthread_mutex_unlock(&p->lock);
|
||
return dur;
|
||
}
|
||
|
||
/* Monotonic advance to the synced tip — never past it (the invariant
|
||
* durable_lsn <= current_lsn - 1 is preserved because tip == current_lsn-1
|
||
* was read under this same lock). One fdatasync advances durable_lsn past
|
||
* however many LSNs were appended since the last sync (batching). */
|
||
if (tip > dur)
|
||
atomic_store_explicit(&p->durable_lsn, tip, memory_order_release);
|
||
|
||
#ifdef XCE_TESTING
|
||
/* Record which thread ran this successful fdatasync (task 5.1 / Req 1.6):
|
||
* a liveness test asserts this is the dedicated flusher thread, never the
|
||
* caller/iomgr/heartbeat thread. Written under p->lock; read after the
|
||
* flusher has caught up, so no extra synchronization is needed. */
|
||
p->test_last_fdatasync_thread = pthread_self();
|
||
atomic_store_explicit(&p->test_last_fdatasync_thread_set, 1,
|
||
memory_order_release);
|
||
#endif
|
||
|
||
pthread_mutex_unlock(&p->lock);
|
||
return tip;
|
||
}
|
||
|
||
static void* fsync_worker(void* arg)
|
||
{
|
||
persist_t* p = arg;
|
||
|
||
pthread_mutex_lock(&p->fsync_mutex);
|
||
while (!atomic_load_explicit(&p->fsync_stop, memory_order_acquire)) {
|
||
/* Re-evaluate the level predicate BEFORE waiting so a signal that
|
||
* raced in just before we entered the wait is not needed for
|
||
* correctness — if work is already pending we skip the wait entirely
|
||
* and flush. (Reads current_lsn/durable_lsn under p->lock.) */
|
||
pthread_mutex_lock(&p->lock);
|
||
bool pending = flusher_work_pending_locked(p);
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
if (!pending) {
|
||
/* No pending work: wait for an append-signal OR the cadence
|
||
* deadline (bounded fallback flush so a lone no-further-append
|
||
* entry is still synced within wal_sync_ms — Req 3.1). */
|
||
long ms = (long)p->config.wal_sync_ms;
|
||
struct timespec dl;
|
||
clock_gettime(CLOCK_REALTIME, &dl);
|
||
dl.tv_sec += ms / 1000;
|
||
dl.tv_nsec += (ms % 1000) * 1000000L;
|
||
if (dl.tv_nsec >= 1000000000L) {
|
||
dl.tv_sec += 1;
|
||
dl.tv_nsec -= 1000000000L;
|
||
}
|
||
pthread_cond_timedwait(&p->fsync_cond, &p->fsync_mutex, &dl);
|
||
|
||
if (atomic_load_explicit(&p->fsync_stop, memory_order_acquire))
|
||
break;
|
||
}
|
||
|
||
/* Flush section: never hold fsync_mutex across the fdatasync (the
|
||
* flush takes p->lock alone). Release fsync_mutex, flush, re-acquire.
|
||
* After the flush we loop and re-check the level — if a new append
|
||
* landed during the flush, the level is still pending and we flush
|
||
* again without needing its signal (lost-wakeup-safe). */
|
||
pthread_mutex_unlock(&p->fsync_mutex);
|
||
uint64_t before = atomic_load_explicit(&p->durable_lsn,
|
||
memory_order_acquire);
|
||
uint64_t after = flusher_flush_once(p);
|
||
|
||
/* COMMIT-EVAL WAKE (task 2.2 consumer wired): durable_lsn is advanced
|
||
* INSIDE flusher_flush_once (atomic store with release) BEFORE we
|
||
* return here, and flusher_flush_once has already RELEASED p->lock.
|
||
* Now — holding NO persist lock — invoke the registered durable-advance
|
||
* callback so the commit engine re-evaluates its majority and wakes any
|
||
* COMMIT_MODE_WAIT caller whose entry just became durable locally.
|
||
*
|
||
* LOST-WAKEUP SAFETY: the wake fires strictly AFTER the durable_lsn
|
||
* release-store, and the commit engine's predicate re-reads durable_lsn
|
||
* via an acquire load under ce->lock — so a WAIT caller either (a) sees
|
||
* the advanced watermark on its own re-check, or (b) is blocked in
|
||
* pthread_cond_wait and is woken by this broadcast; it can never both
|
||
* miss the watermark AND miss the wake. We only fire the callback when
|
||
* durable_lsn actually advanced (after > before): a no-op flush (or a
|
||
* failed fdatasync that held the watermark) needs no commit re-eval,
|
||
* avoiding spurious wakes.
|
||
*
|
||
* LOCK ORDERING (cycle-free): the flusher releases p->lock inside
|
||
* flusher_flush_once, THEN the callback takes ce->lock (commit side).
|
||
* The commit side only ever takes ce->lock and reads persist atomics —
|
||
* it never takes p->lock while holding ce->lock. So the only ordering
|
||
* is p->lock → ce->lock; there is no ce->lock → p->lock edge, hence no
|
||
* deadlock cycle. The callback explicitly MUST NOT take p->lock. */
|
||
if (after > before && p->on_durable_advance != NULL) {
|
||
p->on_durable_advance(p->durable_cb_ctx);
|
||
}
|
||
|
||
pthread_mutex_lock(&p->fsync_mutex);
|
||
}
|
||
pthread_mutex_unlock(&p->fsync_mutex);
|
||
return NULL;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* WAL term adapter (ha-term-safety task 1.3, R1.1/R1.5/R1.6/R1.7/R1.8) *
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/* Record an entry's RAW hdr.term at its 1-based LSN in the in-memory per-LSN
|
||
* term index (R1.5). Grows the index on demand; LSNs replay/append contiguously
|
||
* so the array is dense (slot i == LSN i+1). Any gap left by a non-contiguous
|
||
* LSN (should not happen on the contiguous WAL) is zero-filled by realloc-then-
|
||
* memset, which read back as "absent" until/unless written. The raw term
|
||
* (including a legacy 0) is stored verbatim; the legacy-0 -> WAL_LEGACY_TERM
|
||
* mapping is applied only at READ-BACK (persist_entry_term), keeping "present
|
||
* legacy-0" distinguishable from "absent".
|
||
*
|
||
* Callers: the live append path (holds p->lock) and the single-threaded
|
||
* recovery path (replay_wal — no other thread touches the index before the node
|
||
* participates, so no lock is required there). Best-effort: an allocation
|
||
* failure is logged loudly and leaves the index unextended (read-back then
|
||
* reports the LSN absent) — it never aborts and never corrupts existing
|
||
* entries. */
|
||
static void record_entry_term(persist_t* p, uint64_t lsn, uint64_t term)
|
||
{
|
||
if (!p || lsn == 0)
|
||
return; /* LSN 0 is reserved — never an entry */
|
||
|
||
if (lsn > p->term_index_cap) {
|
||
/* Grow geometrically to amortize; never below the requested LSN. */
|
||
uint64_t new_cap = p->term_index_cap ? p->term_index_cap * 2 : 64;
|
||
if (new_cap < lsn)
|
||
new_cap = lsn;
|
||
uint64_t* grown = realloc(p->term_index, new_cap * sizeof(uint64_t));
|
||
if (!grown) {
|
||
ERROR("persist: term-index realloc failed (lsn=%" PRIu64
|
||
", cap=%" PRIu64 "->%" PRIu64 "); term for this LSN will read "
|
||
"back as absent", lsn, p->term_index_cap, new_cap);
|
||
return;
|
||
}
|
||
/* Zero-fill the newly grown region so any unwritten slot reads absent. */
|
||
memset(grown + p->term_index_cap, 0,
|
||
(new_cap - p->term_index_cap) * sizeof(uint64_t));
|
||
p->term_index = grown;
|
||
p->term_index_cap = new_cap;
|
||
}
|
||
|
||
p->term_index[lsn - 1] = term;
|
||
if (lsn > p->term_index_count)
|
||
p->term_index_count = lsn;
|
||
}
|
||
|
||
/* entry_term_locked: the lock-free core of persist_entry_term — read the
|
||
* recorded Raft term for `lsn` from the in-memory term index, applying the
|
||
* legacy-0 rule (R1.8: a present term-0 entry maps to WAL_LEGACY_TERM; an absent
|
||
* LSN returns 0). Caller MUST hold p->lock. Used by the append path
|
||
* (persist_wal_async) to source the AppendEntries prev_log_term while already
|
||
* under the lock — calling persist_entry_term there would re-acquire p->lock
|
||
* and deadlock. */
|
||
static uint64_t entry_term_locked(const persist_t* p, uint64_t lsn)
|
||
{
|
||
if (!p || lsn == 0)
|
||
return 0;
|
||
if (lsn <= p->term_index_count && p->term_index != NULL) {
|
||
uint64_t raw = p->term_index[lsn - 1];
|
||
return (raw == 0) ? WAL_LEGACY_TERM : raw;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Shrink the in-memory per-LSN term index so that every LSN > `keep_lsn` reads
|
||
* back as absent (ha-term-safety task 2.2). Called from the truncate path after
|
||
* the on-disk entries above keep_lsn are removed, keeping the term index
|
||
* consistent with the WAL: a later persist_entry_term on a truncated LSN must
|
||
* return 0. The backing allocation is left as-is (only the live count moves);
|
||
* the zeroed-on-grow contract means a later re-append at those LSNs overwrites
|
||
* cleanly. Caller must hold p->lock. */
|
||
static void truncate_term_index_locked(persist_t* p, uint64_t keep_lsn)
|
||
{
|
||
if (!p)
|
||
return;
|
||
if (keep_lsn < p->term_index_count)
|
||
p->term_index_count = keep_lsn;
|
||
}
|
||
|
||
void persist_set_term(persist_t* p, uint64_t term)
|
||
{
|
||
if (!p)
|
||
return;
|
||
/* p->term is read on the append path under p->lock; take the lock so the
|
||
* stamped value is consistent with the append that reads it. */
|
||
pthread_mutex_lock(&p->lock);
|
||
p->term = term;
|
||
pthread_mutex_unlock(&p->lock);
|
||
}
|
||
|
||
uint64_t persist_entry_term(const persist_t* p, uint64_t lsn)
|
||
{
|
||
if (!p || lsn == 0)
|
||
return 0;
|
||
|
||
/* Take p->lock to read consistently against concurrent record_entry_term
|
||
* on the append path. Cast away const — the lock is logically a mutable
|
||
* sub-object of an otherwise-const read. */
|
||
persist_t* mp = (persist_t*)p;
|
||
pthread_mutex_lock(&mp->lock);
|
||
|
||
/* Legacy-zero rule (R1.8) lives in entry_term_locked: a present entry
|
||
* carrying term 0 maps to the single lowest-known real Raft term, applied
|
||
* uniformly so two legacy-0 entries at the same LSN match; absent LSNs
|
||
* return 0. */
|
||
uint64_t result = entry_term_locked(mp, lsn);
|
||
|
||
pthread_mutex_unlock(&mp->lock);
|
||
return result;
|
||
}
|
||
|
||
uint64_t persist_entry_term_locked(const persist_t* p, uint64_t lsn)
|
||
{
|
||
return entry_term_locked(p, lsn);
|
||
}
|
||
|
||
/* persist_reconcile_checkpoint_term — raft-ha-core-safety task 3.2 (Req
|
||
* 3.1/5.1), Snapshot Boundary Reconcile. See persist.h for the full contract.
|
||
* Reuses record_entry_term (same translation unit) to authoritatively
|
||
* overwrite whatever term (if any) the local term index held at
|
||
* checkpoint_lsn — this is a genuine reconcile-overwrite, not an idempotent
|
||
* skip, because a pre-snapshot divergent entry may have left a stale local
|
||
* term at this exact LSN. Only the checkpoint LSN's own term is reconciled:
|
||
* post-install Raft semantics mean no AppendEntries prevLogIndex/prevLogTerm
|
||
* check against this member ever references an LSN below the checkpoint
|
||
* again (the checkpoint is the new log-start boundary), and the WAL bytes
|
||
* below it were compacted away, so there is no data to reconstruct a full
|
||
* per-LSN history from even if it were needed. */
|
||
void persist_reconcile_checkpoint_term(persist_t* p, uint64_t checkpoint_lsn,
|
||
uint64_t checkpoint_term)
|
||
{
|
||
if (!p || checkpoint_lsn == 0)
|
||
return;
|
||
|
||
pthread_mutex_lock(&p->lock);
|
||
record_entry_term(p, checkpoint_lsn, checkpoint_term);
|
||
pthread_mutex_unlock(&p->lock);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Public API */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
void persist_set_workdir(persist_t* p, const char* workdir, const char* name)
|
||
{
|
||
char effective[4096];
|
||
snprintf(effective, sizeof(effective), "%s/%s", workdir, name);
|
||
snprintf(p->wal_path, sizeof(p->wal_path), "%s/data/wal", effective);
|
||
snprintf(p->snap_path, sizeof(p->snap_path), "%s/data/snap", effective);
|
||
}
|
||
|
||
int persist_init(persist_t* p, persist_config_t* cfg, cluster_t* c)
|
||
{
|
||
p->cluster = c;
|
||
p->config = cfg ? *cfg : persist_config_defaults();
|
||
p->wal_fd = -1;
|
||
p->last_replicated_crc = WAL_CRC_NONE;
|
||
p->last_crc = WAL_CRC_NONE;
|
||
|
||
/* LSN 0 is RESERVED as the "none / empty" sentinel — it is NEVER assigned
|
||
* to a real WAL entry. A fresh store therefore starts current_lsn at 1, so
|
||
* the first-ever appended entry (e.g. the promotion WAL_NOOP) is lsn 1 and
|
||
* lsn 0 unambiguously means "nothing here":
|
||
* - committed_lsn == 0 → nothing committed (the lsn-1 NOOP is committable
|
||
* 0 → 1, vs. the old lsn-0 entry which could never advance committed_lsn);
|
||
* - writable_lsn == 0 → own-term gate off (the gate armed at lsn 1 is
|
||
* effective, vs. lsn 0 which read as "gate off" on a cold-start leader);
|
||
* - standby_lsn == 0 → empty standby (the streamer's `hdr.lsn > 0`
|
||
* filter streams the lsn-1 entry, closing the cold-start WAL hole that
|
||
* dropped lsn 0 on `0 > 0`).
|
||
* persist_recover() overwrites current_lsn from the durable snapshot/WAL tip
|
||
* (load_snapshot / replay_wal), so this fresh default only applies before any
|
||
* recovery — i.e. a brand-new store with no snapshot and no WAL. */
|
||
p->current_lsn = 1;
|
||
|
||
/* Enforce minimum value for max_wal_records to prevent runaway checkpointing */
|
||
if (p->config.max_wal_records < 100) {
|
||
ERROR("persist: max_wal_records=%u too low (minimum 100) — setting to 100",
|
||
p->config.max_wal_records);
|
||
p->config.max_wal_records = 100;
|
||
}
|
||
|
||
/* Enforce minimum value for wal_segment_bytes to prevent a rotation storm
|
||
* (must comfortably fit at least one max-payload entry: header + up to
|
||
* MAX_WAL_PAYLOAD_BYTES). */
|
||
if (p->config.wal_segment_bytes < 4096) {
|
||
ERROR("persist: wal_segment_bytes=%u too low (minimum 4096) — setting to 4096",
|
||
p->config.wal_segment_bytes);
|
||
p->config.wal_segment_bytes = 4096;
|
||
}
|
||
|
||
/* Initialise atomic checkpoint-trigger counters */
|
||
atomic_store(&p->wal_record_count, 0);
|
||
atomic_store(&p->checkpoint_in_progress, 0);
|
||
|
||
/* Durable watermark starts at 0 ("nothing fdatasync'd"): mirrors the
|
||
* LSN-0-reserved committed_lsn convention. It advances only on the
|
||
* flusher thread after a successful fdatasync (ha-commit-durability 2.1). */
|
||
atomic_store(&p->durable_lsn, 0);
|
||
|
||
/* Recovery-integrity verdict defaults to CLEAN (ha-commit-durability 3.1):
|
||
* a fresh store has no corruption. persist_recover overwrites this with the
|
||
* replay outcome (RECOVER_TRUNCATED only on a structural log corruption). */
|
||
p->recovery_verdict = RECOVER_CLEAN;
|
||
|
||
/* Sticky "unrecoverable, loudly fenced" flag starts clear (Req 2.4): a fresh
|
||
* store is promotable. Set by persist_recover_loud_fence_if_unrecoverable
|
||
* on an unrecoverable boot; cleared on catch-up (never self-exits). */
|
||
atomic_store(&p->recovery_unrecoverable, false);
|
||
|
||
/* Post-replay apply-correctness verdict defaults to CLEAN (raft-apply-
|
||
* decoupling task 5.1, Req 8.2): a fresh store has nothing to diverge
|
||
* from. persist_recover overwrites both after every replay. */
|
||
p->apply_verdict = APPLY_CLEAN;
|
||
atomic_store(&p->last_applied_lsn, 0);
|
||
|
||
/* Live Apply Resource Fence starts clear (job-array-single-wal-record
|
||
* task 4.2, Req 9.4): a fresh store has nothing stuck failing to apply.
|
||
* Set by persist_mark_apply_resource_fenced on a resource-exhaustion
|
||
* apply failure; cleared by persist_clear_apply_resource_fence the
|
||
* instant a retry of the same LSN succeeds -- see persist_t's field doc
|
||
* comment for why this is deliberately separate from
|
||
* recovery_unrecoverable just above. */
|
||
atomic_store(&p->apply_resource_fenced_lsn, 0);
|
||
|
||
/* bug-1416: defaults to true (nothing to gate) so every fixture that
|
||
* calls persist_init() but never persist_recover() -- most of this
|
||
* spec's own test suite -- sees identical, ungated apply-pump/live-
|
||
* replication behavior to before this flag existed. Only persist_
|
||
* recover() ever sets this false, for its own single-threaded
|
||
* replay+verify duration (see the field's doc comment in persist.h). */
|
||
atomic_store(&p->recovery_complete, true);
|
||
|
||
/* Durable-advance wake hook starts unregistered (ha-commit-durability 2.2):
|
||
* the commit engine registers it in commit_engine_init. Until then every
|
||
* flusher durable advance is a no-op wake. */
|
||
p->on_durable_advance = NULL;
|
||
p->durable_cb_ctx = NULL;
|
||
|
||
/* Truncation wake hook starts unregistered (raft-apply-decoupling task
|
||
* 1.4): the commit engine registers it in commit_engine_init. Until
|
||
* then every truncation is a no-op wake. */
|
||
p->on_truncate = NULL;
|
||
p->truncate_cb_ctx = NULL;
|
||
|
||
/* Per-LSN term index starts empty (ha-term-safety 1.3, R1.5): no entries
|
||
* recorded yet. Grown on demand by record_entry_term during replay/append;
|
||
* freed in persist_fini. */
|
||
p->term_index = NULL;
|
||
p->term_index_count = 0;
|
||
p->term_index_cap = 0;
|
||
|
||
/* Pending command-apply hand-off list starts empty (raft-apply-
|
||
* decoupling task 3.2): no committed-outcome continuations have
|
||
* registered yet. Freed in persist_fini. */
|
||
p->pending_applies = NULL;
|
||
pthread_mutex_init(&p->pending_applies_lock, NULL);
|
||
|
||
/* Ordered apply pump starts idle (raft-apply-decoupling task 6.1): no
|
||
* walk scheduled or running yet. Freed/destroyed in persist_fini. */
|
||
pthread_mutex_init(&p->apply_pump_lock, NULL);
|
||
atomic_store(&p->apply_pump_scheduled, 0);
|
||
|
||
/* Apply-pump segment-lookup cache starts invalid (raft-apply-decoupling
|
||
* task 7.4 perf-fix): no segment/fd cached yet. -1 is always a safe
|
||
* fallback trigger for apply_pump_read_entry's fast path (see persist.h
|
||
* doc comment). */
|
||
p->apply_pump_cache_fd = -1;
|
||
p->apply_pump_cache_seg_lsn = 0;
|
||
p->apply_pump_cache_off = 0;
|
||
p->apply_pump_cache_next_lsn = 0;
|
||
|
||
/* Create WAL and snapshot directories */
|
||
if (mkdir_p(p->wal_path, 0700) != 0) return -errno;
|
||
if (mkdir_p(p->snap_path, 0700) != 0) return -errno;
|
||
|
||
/* Reject NFS mounts — fsync durability is not guaranteed over NFS */
|
||
struct statfs sfs;
|
||
if (statfs(p->wal_path, &sfs) == 0 &&
|
||
(unsigned long)sfs.f_type == (unsigned long)NFS_SUPER_MAGIC) {
|
||
ERROR("persist: WAL path %s is on NFS — unsafe for fsync durability",
|
||
p->wal_path);
|
||
return -EREMOTE;
|
||
}
|
||
|
||
pthread_mutex_init(&p->lock, NULL);
|
||
pthread_mutex_init(&p->checkpoint_mutex, NULL);
|
||
pthread_cond_init(&p->checkpoint_done, NULL);
|
||
pthread_mutex_init(&p->fsync_mutex, NULL);
|
||
pthread_cond_init(&p->fsync_cond, NULL);
|
||
repl_init(&p->repl); /* P2-4: fan-out replicator lifecycle */
|
||
|
||
WARN("WAL/snapshot stored unencrypted in %s — "
|
||
"ensure filesystem is encrypted if job payloads contain sensitive data",
|
||
p->wal_path);
|
||
return 0;
|
||
}
|
||
|
||
int persist_start(persist_t* p)
|
||
{
|
||
ctrl_metrics_init();
|
||
|
||
/* Register AFTER_EVENT persistence hooks if we have a cluster back-pointer */
|
||
if (p->cluster != NULL) {
|
||
xce_add_hook(&p->cluster->hooksys, XCE_CONTROLLER_HOOK_JOB,
|
||
(hook_fn_t)persist_job_hook_fun, p);
|
||
xce_add_hook(&p->cluster->hooksys, XCE_CONTROLLER_HOOK_QUEUE,
|
||
(hook_fn_t)persist_queue_hook_fun, p);
|
||
xce_add_hook(&p->cluster->hooksys, XCE_CONTROLLER_HOOK_WORKER,
|
||
(hook_fn_t)persist_worker_hook_fun, p);
|
||
}
|
||
|
||
atomic_store_explicit(&p->fsync_stop, 0, memory_order_release);
|
||
int rc = pthread_create(&p->fsync_thread, NULL, fsync_worker, p);
|
||
if (rc == 0)
|
||
atomic_store_explicit(&p->fsync_thread_running, 1, memory_order_release);
|
||
return rc;
|
||
}
|
||
|
||
void persist_stop(persist_t* p)
|
||
{
|
||
if (atomic_load_explicit(&p->fsync_thread_running, memory_order_acquire)) {
|
||
/* Set the stop flag and wake the timer wait under fsync_mutex so the
|
||
* worker cannot miss the signal between its flag check and the wait. */
|
||
pthread_mutex_lock(&p->fsync_mutex);
|
||
atomic_store_explicit(&p->fsync_stop, 1, memory_order_release);
|
||
pthread_cond_signal(&p->fsync_cond);
|
||
pthread_mutex_unlock(&p->fsync_mutex);
|
||
pthread_join(p->fsync_thread, NULL);
|
||
atomic_store_explicit(&p->fsync_thread_running, 0, memory_order_release);
|
||
}
|
||
|
||
pthread_mutex_lock(&p->lock);
|
||
if (p->wal_fd >= 0) {
|
||
/* Final synchronous flush-or-exit on stop: fdatasync everything
|
||
* appended so far and advance durable_lsn to the synced tip so a
|
||
* post-stop read reflects the durable state (the flusher thread is
|
||
* already joined and will not advance it again).
|
||
*
|
||
* FAIL-LOUD GATE (Req 1.1/1.2): advance the watermark ONLY when the
|
||
* final sync SUCCEEDS — on EIO/ENOSPC the bytes are not on stable
|
||
* storage, so durable_lsn must stay where it is and the failure is
|
||
* surfaced loudly. */
|
||
int rc = fdatasync(p->wal_fd);
|
||
if (rc == 0 && !flusher_fsync_forced_fail(p)) {
|
||
uint64_t tip = (p->current_lsn > 0) ? p->current_lsn - 1 : 0;
|
||
uint64_t dur = atomic_load_explicit(&p->durable_lsn,
|
||
memory_order_acquire);
|
||
if (tip > dur)
|
||
atomic_store_explicit(&p->durable_lsn, tip,
|
||
memory_order_release);
|
||
} else {
|
||
int e = (rc != 0) ? errno : EIO;
|
||
ERROR("persist: final fdatasync on stop FAILED (errno=%d %s) — "
|
||
"durable_lsn left unchanged; appended-but-unsynced entries "
|
||
"are NOT durable",
|
||
e, strerror(e));
|
||
}
|
||
close(p->wal_fd);
|
||
p->wal_fd = -1;
|
||
}
|
||
pthread_mutex_unlock(&p->lock);
|
||
}
|
||
|
||
void persist_fini(persist_t* p)
|
||
{
|
||
repl_fini(&p->repl); /* P2-4: fan-out replicator lifecycle */
|
||
pthread_mutex_destroy(&p->lock);
|
||
pthread_mutex_destroy(&p->checkpoint_mutex);
|
||
pthread_cond_destroy(&p->checkpoint_done);
|
||
pthread_mutex_destroy(&p->fsync_mutex);
|
||
pthread_cond_destroy(&p->fsync_cond);
|
||
|
||
/* Free the in-memory per-LSN term index (ha-term-safety 1.3). */
|
||
free(p->term_index);
|
||
p->term_index = NULL;
|
||
p->term_index_count = 0;
|
||
p->term_index_cap = 0;
|
||
|
||
/* Free any pending command-apply hand-off entries never drained by a
|
||
* (not-yet-built) task 6 apply pump (raft-apply-decoupling task 3.2).
|
||
* Each node's `ctx` is opaque to persist.c (owned by the registering
|
||
* caller, e.g. job.c's jobsubmit_async_ctx_t) — freeing the node here
|
||
* does NOT free ctx; a caller shutting down with entries still pending
|
||
* is responsible for its own ctx lifetime (test harnesses that tear
|
||
* down mid-flight must account for this). */
|
||
pending_command_apply_t* pn =
|
||
(pending_command_apply_t*)p->pending_applies;
|
||
while (pn != NULL) {
|
||
pending_command_apply_t* next = pn->next;
|
||
free(pn);
|
||
pn = next;
|
||
}
|
||
p->pending_applies = NULL;
|
||
pthread_mutex_destroy(&p->pending_applies_lock);
|
||
|
||
/* raft-apply-decoupling task 6.1: no walk can still be in flight by the
|
||
* time persist_fini runs (the caller owns quiescing any vtask pool
|
||
* first, same precondition every other persist_t resource here already
|
||
* assumes). */
|
||
pthread_mutex_destroy(&p->apply_pump_lock);
|
||
|
||
/* raft-apply-decoupling task 7.4 perf-fix: close any cached segment fd
|
||
* left open by apply_pump_read_entry. No walk can be in flight here
|
||
* (same precondition as apply_pump_lock's destroy just above), so no
|
||
* lock is needed. */
|
||
if (p->apply_pump_cache_fd >= 0) {
|
||
close(p->apply_pump_cache_fd);
|
||
p->apply_pump_cache_fd = -1;
|
||
}
|
||
}
|
||
|
||
/* Forward declaration — defined after checkpoint internals */
|
||
static void schedule_checkpoint(persist_t* persist);
|
||
|
||
int persist_wal_async(persist_t* p, uint32_t type,
|
||
const void* payload, uint32_t len)
|
||
{
|
||
/* WAL_CHECKPOINT is a local segment marker, never written via this path */
|
||
if (type == WAL_CHECKPOINT)
|
||
return -EINVAL;
|
||
|
||
pthread_mutex_lock(&p->lock);
|
||
|
||
/* Open first segment lazily */
|
||
if (p->wal_fd < 0) {
|
||
if (open_new_segment(p) != 0) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
}
|
||
|
||
/* Build entry header */
|
||
wal_entry_hdr_t hdr;
|
||
memset(&hdr, 0, sizeof(hdr));
|
||
hdr.term = p->term;
|
||
hdr.lsn = p->current_lsn++;
|
||
hdr.timestamp = now_us();
|
||
hdr.type = type;
|
||
hdr.payload_len = len;
|
||
hdr.prev_crc = (p->seg_offset == 0) ? WAL_CRC_NONE : p->last_crc;
|
||
|
||
/* CRC32 over WAL_HDR_CRC_SIZE bytes of header (excludes crc + prev_crc
|
||
* fields themselves) then over the payload */
|
||
uint32_t c = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||
if (payload && len > 0)
|
||
c = crc32(c, (const Bytef*)payload, len);
|
||
hdr.crc = c;
|
||
|
||
/* Write header */
|
||
ssize_t hw = pwrite(p->wal_fd, &hdr, sizeof(hdr), (off_t)p->seg_offset);
|
||
if (hw != (ssize_t)sizeof(hdr)) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
|
||
/* Write payload (if any) */
|
||
if (payload && len > 0) {
|
||
ssize_t pw = pwrite(p->wal_fd, payload, len,
|
||
(off_t)(p->seg_offset + sizeof(hdr)));
|
||
if (pw != (ssize_t)len) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
}
|
||
|
||
uint32_t entry_size = (uint32_t)(sizeof(hdr) + len);
|
||
p->seg_offset += entry_size;
|
||
p->total_wal_bytes += entry_size;
|
||
p->last_crc = hdr.crc;
|
||
|
||
/* Record this entry's term at its LSN for recovery read-back (ha-term-safety
|
||
* 1.3, R1.5). Under p->lock; stamps the same hdr.term written to disk. */
|
||
record_entry_term(p, hdr.lsn, hdr.term);
|
||
|
||
/* Replicate to standbys — never replicate WAL_CHECKPOINT entries.
|
||
* last_replicated_crc is distinct from last_crc:
|
||
* last_crc includes checkpoint markers; last_replicated_crc skips them.
|
||
* This maintains a continuous CRC chain for standbys (design doc Part 2.3).
|
||
* Note: WAL_CHECKPOINT is blocked by the assert at function entry, so
|
||
* every entry that reaches here is a replicable entry. */
|
||
p->last_replicated_crc = hdr.crc;
|
||
/* ha-term-safety task 3.4: source the AppendEntries prev_log_term (term of
|
||
* the entry at hdr.lsn-1) under p->lock via the lock-free accessor, so the
|
||
* live broadcast carries the leader's term + prev tuple and the standby
|
||
* routes it through the library (raft_on_append_entries_request). 0 when
|
||
* hdr.lsn == 1 (no predecessor). */
|
||
uint64_t prev_log_term = entry_term_locked(p, hdr.lsn - 1);
|
||
repl_broadcast(&p->repl, &hdr, payload, len, prev_log_term);
|
||
|
||
/* Rotate segment when full */
|
||
if (p->seg_offset >= p->config.wal_segment_bytes) {
|
||
if (rotate_segment(p) != 0) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
}
|
||
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
/* Signal the group-commit flusher (ha-commit-durability 2.1): current_lsn
|
||
* advanced and the entry's bytes are pwritten, so durable_lsn < tip now
|
||
* holds (the level predicate). The signal is sent AFTER releasing p->lock
|
||
* and is level-triggered — if it races the flusher and is "lost", the
|
||
* flusher re-checks durable_lsn < current_lsn-1 under p->lock and flushes
|
||
* anyway (see fsync_worker). We do NOT fdatasync inline on this path; the
|
||
* flusher batches the sync across all pending appends. */
|
||
pthread_mutex_lock(&p->fsync_mutex);
|
||
pthread_cond_signal(&p->fsync_cond);
|
||
pthread_mutex_unlock(&p->fsync_mutex);
|
||
|
||
/* WAL record-count checkpoint trigger: schedule an async checkpoint once
|
||
* the record count reaches the configured threshold. The fetch_add is
|
||
* outside the lock — atomic_fetch_add is sufficient for the counter; the
|
||
* checkpoint itself is guarded by its own CAS inside schedule_checkpoint. */
|
||
if (atomic_fetch_add(&p->wal_record_count, 1) + 1 >=
|
||
p->config.max_wal_records) {
|
||
schedule_checkpoint(p);
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
/* Lock-held core of persist_truncate_wal_above (defined below): assumes the
|
||
* caller already holds p->lock and never touches the lock itself. The
|
||
* replicated-write conflict guard reuses it to repair a divergent entry while
|
||
* still under p->lock (the follower-AE-conflict truncate contract). */
|
||
static int truncate_wal_above_locked(persist_t* p, uint64_t cutoff_lsn);
|
||
|
||
/* read_entry_meta_locked: scan the on-disk WAL for the entry at `lsn` and
|
||
* return its (payload_len, payload-only CRC, raw stored term). The
|
||
* replicated-write conflict guard uses this to compare a re-streamed entry
|
||
* against the copy already on disk WITHOUT trusting the LSN alone. pcrc is
|
||
* computed as crc32(0, payload, len) — the SAME payload-only fingerprint
|
||
* walcompare/waldump compare across members (NOT the chained header CRC); an
|
||
* empty (PLEN=0) entry yields len=0, pcrc=0. Reads through the page cache so
|
||
* a written-but-not-yet-fdatasync'd entry is still visible. Caller MUST hold
|
||
* p->lock. Returns 0 if the entry is found, -1 if absent or unreadable. */
|
||
static int read_entry_meta_locked(persist_t* p, uint64_t lsn,
|
||
uint32_t* out_len, uint32_t* out_pcrc,
|
||
uint64_t* out_term)
|
||
{
|
||
if (!p || lsn == 0)
|
||
return -1;
|
||
|
||
DIR* d = opendir(p->wal_path);
|
||
if (!d)
|
||
return -1;
|
||
|
||
uint64_t seg_lsns[4096];
|
||
int nseg = 0;
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL &&
|
||
nseg < (int)(sizeof(seg_lsns) / sizeof(seg_lsns[0]))) {
|
||
if (strncmp(e->d_name, "seg-", 4) != 0)
|
||
continue;
|
||
uint64_t sl = 0;
|
||
if (sscanf(e->d_name, "seg-%" SCNx64 ".wal", &sl) != 1)
|
||
continue;
|
||
seg_lsns[nseg++] = sl;
|
||
}
|
||
closedir(d);
|
||
if (nseg == 0)
|
||
return -1;
|
||
|
||
/* Ascending sort by start LSN (segment count is tiny). */
|
||
for (int i = 1; i < nseg; i++) {
|
||
uint64_t key = seg_lsns[i];
|
||
int j = i - 1;
|
||
while (j >= 0 && seg_lsns[j] > key) {
|
||
seg_lsns[j + 1] = seg_lsns[j];
|
||
j--;
|
||
}
|
||
seg_lsns[j + 1] = key;
|
||
}
|
||
|
||
int rc = -1;
|
||
for (int si = 0; si < nseg && rc != 0; si++) {
|
||
/* Segments are LSN-ordered; one whose start LSN already exceeds the
|
||
* target cannot contain it (nor can any later one). */
|
||
if (seg_lsns[si] > lsn)
|
||
break;
|
||
|
||
char seg_path[4096];
|
||
snprintf(seg_path, sizeof(seg_path),
|
||
WAL_SEG_FMT, p->wal_path, seg_lsns[si]);
|
||
int fd = open(seg_path, O_RDONLY);
|
||
if (fd < 0)
|
||
continue;
|
||
|
||
uint32_t off = 0;
|
||
for (;;) {
|
||
wal_entry_hdr_t hdr;
|
||
ssize_t hn = pread(fd, &hdr, sizeof(hdr), (off_t)off);
|
||
if (hn < (ssize_t)sizeof(hdr))
|
||
break; /* short read / EOF / pre-allocated zeros */
|
||
if (hdr.lsn == 0 && hdr.type == 0 && hdr.payload_len == 0)
|
||
break;
|
||
|
||
if (hdr.lsn == lsn) {
|
||
uint32_t pc = 0;
|
||
if (hdr.payload_len > 0) {
|
||
uint8_t* pl = malloc(hdr.payload_len);
|
||
if (!pl)
|
||
break; /* leave rc == -1: unreadable → conservative */
|
||
ssize_t pn = pread(fd, pl, hdr.payload_len,
|
||
(off_t)(off + sizeof(hdr)));
|
||
if (pn == (ssize_t)hdr.payload_len)
|
||
pc = crc32(0L, (const Bytef*)pl, hdr.payload_len);
|
||
free(pl);
|
||
if (pn != (ssize_t)hdr.payload_len)
|
||
break; /* unreadable body → conservative */
|
||
}
|
||
if (out_len)
|
||
*out_len = hdr.payload_len;
|
||
if (out_pcrc)
|
||
*out_pcrc = pc;
|
||
if (out_term)
|
||
*out_term = hdr.term;
|
||
rc = 0;
|
||
break;
|
||
}
|
||
|
||
off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
}
|
||
close(fd);
|
||
}
|
||
return rc;
|
||
}
|
||
|
||
int persist_wal_write_replicated(persist_t* p, uint64_t lsn, uint32_t type,
|
||
const void* payload, uint32_t len)
|
||
{
|
||
if (!p)
|
||
return -EINVAL;
|
||
|
||
/* WAL_CHECKPOINT is a local-only marker, never streamed to standbys. */
|
||
if (type == WAL_CHECKPOINT)
|
||
return -EINVAL;
|
||
|
||
/* Reject oversized payloads BEFORE touching the segment. */
|
||
if (len > MAX_WAL_PAYLOAD_BYTES)
|
||
return -EMSGSIZE;
|
||
|
||
pthread_mutex_lock(&p->lock);
|
||
|
||
/* Tip-repair bookkeeping (set only on the TIP-conflict repair branch below):
|
||
* the truncate-and-reappend repair transiently lowers committed_lsn to
|
||
* lsn-1, so we snapshot the true committed boundary here and restore it
|
||
* EXACTLY after the re-append re-establishes the entry at `lsn` (never let a
|
||
* same-(term,lsn) payload repair regress the committed boundary — commit
|
||
* monotonicity / State Machine Safety). */
|
||
int repaired_tip = 0;
|
||
uint64_t repaired_committed_save = 0;
|
||
|
||
/* Payload-aware conflict guard (raft-ha-core-safety task 2.2): this lsn is
|
||
* already persisted (current_lsn advanced past it). The legacy guard was
|
||
* (lsn)-only — it no-op'd ANY re-stream, including a corrective full-payload
|
||
* write over a previously-stored empty (PLEN=0) body, which is exactly the
|
||
* cross-term catch-up payload-loss signature (a committed LSN that carries
|
||
* the right (term,lsn) but an empty payload). Compare (payload-crc, len)
|
||
* against the copy already on disk and act on the difference:
|
||
* (c) identical payload → idempotent no-op (keep).
|
||
* (b) incoming EMPTY over a stored entry → never degrade (keep stored);
|
||
* an empty body is never the durable form of a known entry.
|
||
* (a) incoming NON-EMPTY whose payload differs from the stored copy at an
|
||
* existing (term,lsn) → a genuine payload conflict whose handling
|
||
* depends on WHERE the conflict sits relative to the stored tip:
|
||
* · AT THE TIP (lsn == current_lsn - 1, nothing stored above) →
|
||
* REPAIR: truncate just this one entry and re-append the
|
||
* authoritative payload at the same (term,lsn). Because nothing
|
||
* is stored above it, no distinct committed entry is destroyed,
|
||
* and the committed boundary is restored verbatim afterwards.
|
||
* · BELOW THE TIP (lsn < current_lsn - 1, committed entries stored
|
||
* ABOVE the conflict) → MUST NOT truncate: dropping [lsn..tip]
|
||
* would delete committed entries above the conflict AND roll
|
||
* committed_lsn/durable_lsn backward — a State Machine Safety /
|
||
* commit-monotonicity violation. Instead keep the stored entry
|
||
* untouched, raise a loud non-promotable fence, and let catch-up
|
||
* reconverge. The real source fix (the streamer must stop
|
||
* shipping an empty body) is task 2.4; the guard's only job here
|
||
* is to NOT make things worse (never drop a committed entry,
|
||
* never regress a boundary) and to surface the corruption loudly.
|
||
* An unreadable stored entry stays a conservative no-op — never truncate a
|
||
* committed LSN we cannot read back. */
|
||
if (lsn + 1 <= p->current_lsn) {
|
||
uint32_t inc_pcrc =
|
||
(payload && len > 0) ? crc32(0L, (const Bytef*)payload, len) : 0;
|
||
uint32_t st_len = 0;
|
||
uint32_t st_pcrc = 0;
|
||
uint64_t st_term = 0;
|
||
int have = read_entry_meta_locked(p, lsn, &st_len, &st_pcrc,
|
||
&st_term);
|
||
|
||
int payload_matches =
|
||
(have == 0 && st_len == len && st_pcrc == inc_pcrc);
|
||
|
||
/* (c) identical payload, (b) empty incoming, or an unreadable stored
|
||
* entry → leave the stored entry untouched. */
|
||
if (payload_matches || len == 0 || have != 0) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return 0;
|
||
}
|
||
|
||
/* (a) non-empty incoming whose payload differs at an existing (term,lsn)
|
||
* — a genuine payload conflict. Branch on tip vs below-tip. */
|
||
uint64_t tip_lsn = (p->current_lsn > 0) ? p->current_lsn - 1 : 0;
|
||
|
||
if (lsn != tip_lsn) {
|
||
/* BELOW-TIP conflict: committed entries are stored ABOVE `lsn`.
|
||
* Truncating here would delete them and roll committed_lsn/
|
||
* durable_lsn backward (State Machine Safety / commit-monotonicity
|
||
* violation — bug-class the round-1 review rejected). Do NOT
|
||
* truncate, do NOT lower any boundary, do NOT degrade the stored
|
||
* entry. Surface loudly and raise the sticky non-promotable fence
|
||
* (reuse the recovery_unrecoverable mechanism: loud, stays ALIVE,
|
||
* non-promotable, self-heals via persist_clear_recovery_unrecoverable
|
||
* once catch-up reaches a committed-consistent state — never
|
||
* self-exits, per the controller invariant). The source fix lives
|
||
* in task 2.4 (the streamer must stop shipping an empty payload). */
|
||
ERROR("persist: persist_wal_write_replicated payload CONFLICT BELOW "
|
||
"TIP at lsn=%" PRIu64 " (stored_term=%" PRIu64
|
||
" stored_len=%u stored_pcrc=%08x vs incoming_len=%u "
|
||
"incoming_pcrc=%08x); current_lsn=%" PRIu64 " committed_lsn=%"
|
||
PRIu64 " — refusing to truncate committed entries above the "
|
||
"conflict (would regress the committed boundary). Keeping the "
|
||
"stored entry, raising the non-promotable fence and staying "
|
||
"ALIVE; awaiting a correct re-stream (source fix: task 2.4)",
|
||
lsn, st_term, st_len, st_pcrc, len, inc_pcrc, p->current_lsn,
|
||
atomic_load_explicit(&p->committed_lsn, memory_order_acquire));
|
||
atomic_store_explicit(&p->recovery_unrecoverable, true,
|
||
memory_order_release);
|
||
pthread_mutex_unlock(&p->lock);
|
||
return 0; /* no-op the write: keep stored, never degrade/regress */
|
||
}
|
||
|
||
/* TIP conflict: `lsn` is the highest stored entry, so nothing is stored
|
||
* above it — truncate-and-reappend replaces EXACTLY this one divergent
|
||
* entry. truncate_wal_above_locked(lsn-1) drops it and sets
|
||
* current_lsn = lsn, so control falls through to the append below and
|
||
* re-persists the authoritative entry at this exact LSN. Snapshot the
|
||
* committed boundary first: the truncate transiently rewrites
|
||
* committed_lsn to lsn-1, and we restore it verbatim after the
|
||
* re-append so a same-(term,lsn) payload repair never regresses (nor
|
||
* spuriously advances) the committed boundary. */
|
||
repaired_committed_save =
|
||
atomic_load_explicit(&p->committed_lsn, memory_order_acquire);
|
||
int trc = truncate_wal_above_locked(p, lsn - 1);
|
||
if (trc != 0) {
|
||
ERROR("persist: persist_wal_write_replicated repair-truncate FAILED "
|
||
"for lsn=%" PRIu64 " (rc=%d) — divergent entry NOT repaired",
|
||
lsn, trc);
|
||
pthread_mutex_unlock(&p->lock);
|
||
return trc;
|
||
}
|
||
repaired_tip = 1;
|
||
/* fall through to the contiguity guard + append (lsn == current_lsn). */
|
||
}
|
||
|
||
/* Contiguity guard (bug-850): REFUSE a forward GAP. current_lsn is the
|
||
* next-expected LSN, so a replicated entry with lsn > current_lsn would
|
||
* leave [current_lsn .. lsn-1] missing AND advance current_lsn past that
|
||
* hole — after which the idempotency guard above silently DROPS the later
|
||
* in-order catch-up of the prefix (the failover prefix-loss mechanism).
|
||
* Append only the contiguous next entry (lsn == current_lsn); reject a gap
|
||
* without writing or acking so the ACK-driven catch-up continuation
|
||
* re-streams from current_lsn and the log fills in order. This is Raft
|
||
* log-matching: append entry N only when the log already holds N-1. A
|
||
* behind standby that keeps receiving live broadcasts simply drops the
|
||
* out-of-order ones until catch-up backfills the gap (no loss). -EAGAIN is
|
||
* the "retry via catch-up" signal the apply path treats as a quiet skip. */
|
||
if (lsn > p->current_lsn) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EAGAIN;
|
||
}
|
||
|
||
/* Open first segment lazily. open_new_segment names the file from
|
||
* current_lsn — for a fresh standby receiving lsn 1 first, current_lsn is
|
||
* 1 (reserve-lsn-0), so the segment is seg-...001.wal. */
|
||
if (p->wal_fd < 0) {
|
||
if (open_new_segment(p) != 0) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
}
|
||
|
||
/* Build entry header. Unlike persist_wal_async, lsn is GIVEN (not
|
||
* current_lsn++). term isn't streamed, so use our best-effort p->term.
|
||
* The CRC chain is SELF-computed (the streamed entry carries no crc):
|
||
* recovery validates an intra-segment chain (prev_crc == last_crc), so the
|
||
* standby's own writes must chain to each other — cross-node CRC equality
|
||
* is neither required nor possible. */
|
||
wal_entry_hdr_t hdr;
|
||
memset(&hdr, 0, sizeof(hdr));
|
||
hdr.term = p->term;
|
||
hdr.lsn = lsn;
|
||
hdr.timestamp = now_us();
|
||
hdr.type = type;
|
||
hdr.payload_len = len;
|
||
hdr.prev_crc = (p->seg_offset == 0) ? WAL_CRC_NONE : p->last_crc;
|
||
|
||
uint32_t c = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||
if (payload && len > 0)
|
||
c = crc32(c, (const Bytef*)payload, len);
|
||
hdr.crc = c;
|
||
|
||
/* Write header */
|
||
ssize_t hw = pwrite(p->wal_fd, &hdr, sizeof(hdr), (off_t)p->seg_offset);
|
||
if (hw != (ssize_t)sizeof(hdr)) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
|
||
/* Write payload (if any) */
|
||
if (payload && len > 0) {
|
||
ssize_t pw = pwrite(p->wal_fd, payload, len,
|
||
(off_t)(p->seg_offset + sizeof(hdr)));
|
||
if (pw != (ssize_t)len) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
}
|
||
|
||
uint32_t entry_size = (uint32_t)(sizeof(hdr) + len);
|
||
p->seg_offset += entry_size;
|
||
p->total_wal_bytes += entry_size;
|
||
p->last_crc = hdr.crc;
|
||
|
||
/* Record this entry's term at its LSN for recovery read-back (ha-term-safety
|
||
* 1.3, R1.5). Stamps the same hdr.term written to disk. NOTE: the streamed
|
||
* envelope does not yet carry the leader's term, so this records the local
|
||
* best-effort p->term (the design's known hazard closed by task 2.x, which
|
||
* carries the leader's term on the envelope; this task only provides the
|
||
* read-back seam over whatever term was persisted). */
|
||
record_entry_term(p, hdr.lsn, hdr.term);
|
||
|
||
/* Advance current_lsn to one past the given lsn (max-idempotent — mirrors
|
||
* apply_wal_entry's monotonic advance). */
|
||
if (lsn + 1 > p->current_lsn)
|
||
p->current_lsn = lsn + 1;
|
||
|
||
/* Durability: fsync the segment BEFORE returning so the caller's ACK
|
||
* implies the entry is on stable storage (persist-before-ACK).
|
||
* persist_wal_async defers fsync to the background thread / persist_flush;
|
||
* here we sync inline because the entry feeds the primary's commit
|
||
* majority and must survive a standby restart. fdatasync is sufficient
|
||
* (entry bytes + segment metadata are pre-allocated by open_new_segment).
|
||
*
|
||
* Fail-loud rc-gate (ha-commit-durability Req 1.3, symmetric with the
|
||
* append-path gate at task 2.1): the ACK is sent by the caller ONLY when
|
||
* this returns success, and the ACK claims the entry is durable. So if
|
||
* the fdatasync FAILS (EIO/ENOSPC, or the forced-fail test seam), we must
|
||
* NOT return success — we log loudly and return -EIO so the caller's
|
||
* existing error path (ha_apply_single_entry) does NOT apply or ACK. */
|
||
int fsrc = fdatasync(p->wal_fd);
|
||
#ifdef XCE_TESTING
|
||
atomic_fetch_add_explicit(&p->test_replicated_fdatasync_count, 1,
|
||
memory_order_relaxed);
|
||
#endif
|
||
if (fsrc != 0 || flusher_fsync_forced_fail(p)) {
|
||
int e = (fsrc != 0) ? errno : EIO;
|
||
ERROR("persist: persist_wal_write_replicated fdatasync FAILED for "
|
||
"lsn=%" PRIu64 " (errno=%d %s) — entry NOT durable; returning "
|
||
"error so the standby does NOT ACK", lsn, e, strerror(e));
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
|
||
/* bug-828 secondary: the entry is now fdatasync'd durable, so advance
|
||
* durable_lsn to the synced tip immediately (mirror persist_flush). The
|
||
* 10ms cadence flusher would otherwise advance it lazily, leaving this
|
||
* standby's ADVERTISED durable position (persist_durable_floor →
|
||
* RequestVoteReply.peerLastLogLsn) lagging its own synced tip during the
|
||
* vote window. A standby that has durably persisted the committed prefix
|
||
* must advertise it at once so a clean failover's safe-commit-from-votes
|
||
* computation sees the true majority-held LSN. Monotonic max; preserves
|
||
* durable_lsn <= current_lsn - 1 (tip read under this same p->lock, after
|
||
* the current_lsn advance above). Reached ONLY past the fail-loud rc-gate
|
||
* — never advances on an fdatasync error. */
|
||
{
|
||
uint64_t tip = (p->current_lsn > 0) ? p->current_lsn - 1 : 0;
|
||
uint64_t dur = atomic_load_explicit(&p->durable_lsn,
|
||
memory_order_acquire);
|
||
if (tip > dur)
|
||
atomic_store_explicit(&p->durable_lsn, tip, memory_order_release);
|
||
}
|
||
|
||
/* Tip-repair committed-boundary restore (raft-ha-core-safety task 2.2): a
|
||
* TIP-conflict repair above truncated [lsn..tip] (rewriting committed_lsn to
|
||
* lsn-1) and has now re-appended + fdatasync'd the authoritative entry at the
|
||
* SAME (term,lsn). Restore the committed boundary VERBATIM so the repair is
|
||
* commit-boundary-neutral: a same-(term,lsn) payload correction must never
|
||
* regress committed_lsn (State Machine Safety / commit monotonicity) nor
|
||
* spuriously advance it (truncate would otherwise have raised it to lsn-1).
|
||
* Reached only past the fail-loud fdatasync gate, with current_lsn re-advanced
|
||
* to lsn+1 and durable_lsn at the synced tip, so the invariant
|
||
* committed_lsn <= min(current_lsn - 1, durable_lsn) holds (saved value was
|
||
* itself <= the pre-repair tip == lsn). The whole repair is serialized under
|
||
* p->lock, so no external observer ever sees the transient lowered value. */
|
||
if (repaired_tip) {
|
||
atomic_store_explicit(&p->committed_lsn, repaired_committed_save,
|
||
memory_order_release);
|
||
}
|
||
|
||
/* Rotate segment when full (same threshold as the primary append). */
|
||
if (p->seg_offset >= p->config.wal_segment_bytes) {
|
||
if (rotate_segment(p) != 0) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
}
|
||
|
||
/* NOTE: no repl_broadcast — standbys never re-replicate. */
|
||
|
||
pthread_mutex_unlock(&p->lock);
|
||
return 0;
|
||
}
|
||
|
||
int persist_flush(persist_t* p)
|
||
{
|
||
pthread_mutex_lock(&p->lock);
|
||
int rc = (p->wal_fd >= 0) ? fdatasync(p->wal_fd) : 0;
|
||
|
||
/* persist_flush is a synchronous durability point (tests, clean shutdown):
|
||
* it fdatasync'd everything appended so far, so advance durable_lsn to the
|
||
* synced tip too. Without this, an explicit-flush-only path (slow flusher
|
||
* cadence) would leave durable_lsn lagging even though the bytes are on
|
||
* disk. Monotonic max; preserves durable_lsn <= current_lsn - 1 (tip read
|
||
* under the same p->lock). Gated on rc == 0 (fail-loud, Req 1.1/1.2): a
|
||
* failed flush must not advance the watermark. */
|
||
if (rc == 0 && !flusher_fsync_forced_fail(p)) {
|
||
uint64_t tip = (p->current_lsn > 0) ? p->current_lsn - 1 : 0;
|
||
uint64_t dur = atomic_load_explicit(&p->durable_lsn,
|
||
memory_order_acquire);
|
||
if (tip > dur)
|
||
atomic_store_explicit(&p->durable_lsn, tip, memory_order_release);
|
||
} else if (rc != 0 || flusher_fsync_forced_fail(p)) {
|
||
int e = (rc != 0) ? errno : EIO;
|
||
ERROR("persist: persist_flush fdatasync FAILED (errno=%d %s) — "
|
||
"durable_lsn not advanced; flushed bytes are NOT durable",
|
||
e, strerror(e));
|
||
}
|
||
pthread_mutex_unlock(&p->lock);
|
||
return rc;
|
||
}
|
||
|
||
uint64_t persist_durable_lsn(persist_t* p)
|
||
{
|
||
return atomic_load_explicit(&p->durable_lsn, memory_order_acquire);
|
||
}
|
||
|
||
uint64_t persist_durable_floor(persist_t* p)
|
||
{
|
||
/* The durable freshness floor (durable last-log index) IS the durable
|
||
* watermark: persist_recover re-derives it from the recovered WAL tip
|
||
* (ha-commit-durability task 3.4), and the flusher advances it on every
|
||
* successful fdatasync — so a thin wrapper avoids a redundant field. NULL
|
||
* guard keeps the accessor safe for the election consumers (task 4.2). */
|
||
return p ? atomic_load_explicit(&p->durable_lsn, memory_order_acquire) : 0;
|
||
}
|
||
|
||
void persist_set_durable_cb(persist_t* p, void (*fn)(void* ctx), void* ctx)
|
||
{
|
||
if (!p)
|
||
return;
|
||
/* Set ctx before fn so the flusher (which checks fn != NULL) never sees a
|
||
* live callback with a stale/uninitialised ctx. Registration happens in
|
||
* commit_engine_init, before the engine is reachable by any ACK path. */
|
||
p->durable_cb_ctx = ctx;
|
||
p->on_durable_advance = fn;
|
||
}
|
||
|
||
/* raft-apply-decoupling task 1.4: registration counterpart of
|
||
* persist_set_durable_cb -- see persist.h for the full contract. */
|
||
void persist_set_truncate_cb(persist_t* p, void (*fn)(void* ctx, uint64_t cutoff_lsn),
|
||
void* ctx)
|
||
{
|
||
if (!p)
|
||
return;
|
||
p->truncate_cb_ctx = ctx;
|
||
p->on_truncate = fn;
|
||
}
|
||
|
||
#ifdef XCE_TESTING
|
||
void persist_test_set_force_fsync_fail(persist_t* p, int fail)
|
||
{
|
||
if (p)
|
||
atomic_store_explicit(&p->test_force_fsync_fail, fail ? 1 : 0,
|
||
memory_order_release);
|
||
}
|
||
|
||
void persist_test_set_force_job_encode_fail_at(persist_t* p, int index)
|
||
{
|
||
if (p)
|
||
atomic_store_explicit(&p->test_force_job_encode_fail_at, index + 1,
|
||
memory_order_relaxed);
|
||
}
|
||
|
||
unsigned persist_test_replicated_fdatasync_count(persist_t* p)
|
||
{
|
||
if (!p)
|
||
return 0;
|
||
return atomic_load_explicit(&p->test_replicated_fdatasync_count,
|
||
memory_order_relaxed);
|
||
}
|
||
|
||
pthread_t persist_test_last_fdatasync_thread(persist_t* p)
|
||
{
|
||
/* If no flusher fdatasync has advanced durable_lsn yet, return the
|
||
* caller's own id so an unprimed read never spuriously looks off-thread.
|
||
* After a flush, return the recorded flusher thread id (Req 1.6). */
|
||
if (!p ||
|
||
atomic_load_explicit(&p->test_last_fdatasync_thread_set,
|
||
memory_order_acquire) == 0)
|
||
return pthread_self();
|
||
return p->test_last_fdatasync_thread;
|
||
}
|
||
|
||
/* Force a fresh WAL segment at the current LSN (test seam — see persist.h).
|
||
* Takes p->lock for the same single-writer serialization the append path uses,
|
||
* so the segment swap cannot race a concurrent write. */
|
||
int persist_test_force_new_segment(persist_t* p)
|
||
{
|
||
if (!p)
|
||
return -EINVAL;
|
||
pthread_mutex_lock(&p->lock);
|
||
int rc = rotate_segment(p);
|
||
pthread_mutex_unlock(&p->lock);
|
||
return rc;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Durability-verification test seam (ha-commit-durability Req 3.2/3.3)*
|
||
* *
|
||
* Re-read + CRC-validate the WAL segments from stable storage and *
|
||
* return the highest fully-persisted LSN — independent of the *
|
||
* in-memory current_lsn / committed_lsn watermarks. Mirrors the *
|
||
* segment-scan + CRC-chain validation of replay_wal(), but is strictly *
|
||
* read-only: it applies no entries, fires no hooks, and mutates no *
|
||
* persist_t state, so it cannot change the externally observable *
|
||
* acknowledgment semantics (Req 3.3). *
|
||
* *
|
||
* Stops at the first corruption / short read / pre-allocated zero *
|
||
* region (exactly like recovery), so only entries genuinely on stable *
|
||
* storage are counted. Returns the highest such LSN, or 0 when nothing *
|
||
* is durable. *
|
||
* ------------------------------------------------------------------ */
|
||
uint64_t persist_test_durable_lsn_on_disk(persist_t* p)
|
||
{
|
||
if (!p)
|
||
return 0;
|
||
|
||
DIR* d = opendir(p->wal_path);
|
||
if (!d)
|
||
return 0; /* no WAL dir → nothing durable */
|
||
|
||
/* Collect segment start LSNs (same bound as replay_wal). */
|
||
uint64_t seg_lsns[4096];
|
||
int nseg = 0;
|
||
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL &&
|
||
nseg < (int)(sizeof(seg_lsns) / sizeof(seg_lsns[0]))) {
|
||
if (strncmp(e->d_name, "seg-", 4) != 0)
|
||
continue;
|
||
uint64_t seg_start = 0;
|
||
if (sscanf(e->d_name, "seg-%" SCNx64 ".wal", &seg_start) != 1)
|
||
continue;
|
||
seg_lsns[nseg++] = seg_start;
|
||
}
|
||
closedir(d);
|
||
|
||
if (nseg == 0)
|
||
return 0;
|
||
|
||
/* Insertion sort by start LSN (segment count is tiny). */
|
||
for (int i = 1; i < nseg; i++) {
|
||
uint64_t key = seg_lsns[i];
|
||
int j = i - 1;
|
||
while (j >= 0 && seg_lsns[j] > key) {
|
||
seg_lsns[j + 1] = seg_lsns[j];
|
||
j--;
|
||
}
|
||
seg_lsns[j + 1] = key;
|
||
}
|
||
|
||
uint64_t highest_durable = 0;
|
||
uint32_t last_crc = WAL_CRC_NONE;
|
||
|
||
for (int si = 0; si < nseg; si++) {
|
||
char seg_path[4096];
|
||
snprintf(seg_path, sizeof(seg_path),
|
||
WAL_SEG_FMT, p->wal_path, seg_lsns[si]);
|
||
|
||
int fd = open(seg_path, O_RDONLY);
|
||
if (fd < 0)
|
||
continue;
|
||
|
||
uint32_t seg_off = 0;
|
||
|
||
for (;;) {
|
||
wal_entry_hdr_t hdr;
|
||
ssize_t hn = pread(fd, &hdr, sizeof(hdr), (off_t)seg_off);
|
||
if (hn == 0)
|
||
break; /* clean end of segment */
|
||
if (hn < (ssize_t)sizeof(hdr))
|
||
break; /* truncated / pre-allocated zeros */
|
||
|
||
/* Pre-allocated zero region: no valid entry is all-zero. */
|
||
if (hdr.lsn == 0 && hdr.type == 0 && hdr.payload_len == 0)
|
||
break;
|
||
|
||
/* CRC chain validation — identical rule to replay_wal. */
|
||
if (seg_off == 0) {
|
||
if (hdr.prev_crc != WAL_CRC_NONE && last_crc != WAL_CRC_NONE &&
|
||
hdr.prev_crc != last_crc)
|
||
break;
|
||
} else {
|
||
if (hdr.prev_crc != last_crc)
|
||
break;
|
||
}
|
||
|
||
/* Verify entry CRC over header + payload. */
|
||
uint32_t expected_crc =
|
||
crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||
|
||
uint8_t* payload = NULL;
|
||
if (hdr.payload_len > 0) {
|
||
payload = malloc(hdr.payload_len);
|
||
if (!payload) {
|
||
close(fd);
|
||
return highest_durable;
|
||
}
|
||
ssize_t pn = pread(fd, payload, hdr.payload_len,
|
||
(off_t)(seg_off + sizeof(hdr)));
|
||
if (pn != (ssize_t)hdr.payload_len) {
|
||
free(payload);
|
||
break;
|
||
}
|
||
expected_crc =
|
||
crc32(expected_crc, (const Bytef*)payload, hdr.payload_len);
|
||
}
|
||
|
||
if (hdr.crc != expected_crc) {
|
||
free(payload);
|
||
break;
|
||
}
|
||
free(payload);
|
||
|
||
/* Entry is intact and durable on disk. */
|
||
last_crc = hdr.crc;
|
||
if (hdr.lsn > highest_durable)
|
||
highest_durable = hdr.lsn;
|
||
|
||
seg_off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
}
|
||
|
||
close(fd);
|
||
}
|
||
|
||
return highest_durable;
|
||
}
|
||
#endif /* XCE_TESTING */
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Checkpoint internals */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/* Write a WAL_CHECKPOINT marker to the current segment.
|
||
* Never calls repl_broadcast() — checkpoint markers are local only. */
|
||
static int persist_write_checkpoint_marker(persist_t* p)
|
||
{
|
||
pthread_mutex_lock(&p->lock);
|
||
|
||
if (p->wal_fd < 0) {
|
||
if (open_new_segment(p) != 0) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
}
|
||
|
||
wal_entry_hdr_t hdr;
|
||
memset(&hdr, 0, sizeof(hdr));
|
||
hdr.term = p->term;
|
||
hdr.lsn = p->current_lsn++;
|
||
hdr.timestamp = now_us();
|
||
hdr.type = WAL_CHECKPOINT;
|
||
hdr.payload_len = 0;
|
||
hdr.prev_crc = (p->seg_offset == 0) ? WAL_CRC_NONE : p->last_crc;
|
||
|
||
uint32_t c = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||
hdr.crc = c;
|
||
|
||
ssize_t hw = pwrite(p->wal_fd, &hdr, sizeof(hdr), (off_t)p->seg_offset);
|
||
if (hw != (ssize_t)sizeof(hdr)) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return -EIO;
|
||
}
|
||
fdatasync(p->wal_fd);
|
||
|
||
p->seg_offset += (uint32_t)sizeof(hdr);
|
||
p->total_wal_bytes += sizeof(hdr);
|
||
p->last_crc = hdr.crc;
|
||
/* NOTE: last_replicated_crc is NOT updated — WAL_CHECKPOINT is never replicated */
|
||
/* NOTE: the term index is NOT updated for WAL_CHECKPOINT — replay_wal SKIPS
|
||
* checkpoint markers (local bookkeeping, not a logical Raft entry), so the
|
||
* recovered index never holds a checkpoint LSN; the live index must match
|
||
* (ha-term-safety 1.3). persist_entry_term is for (term,lsn) reconcile, which
|
||
* never compares against a checkpoint marker. */
|
||
|
||
pthread_mutex_unlock(&p->lock);
|
||
return 0;
|
||
}
|
||
|
||
/* Entity-bearing checkpoint payload (ha-entity-snapshots task 2.2/3.2, Req
|
||
* 1.1-1.4). Physical layout appended after the existing legacy header
|
||
* (design.md "Checkpoint File Layout"):
|
||
* [uint32_t format_version]
|
||
* [uint32_t job_count]
|
||
* [job_count * { uint32_t payload_len; uint8_t capnp_Job_bytes[payload_len] }]
|
||
* [uint32_t queue_count]
|
||
* [queue_count * { uint32_t name_len; char name[name_len]; uint32_t status }] */
|
||
#define SNAPSHOT_FORMAT_VERSION 1u
|
||
|
||
typedef struct {
|
||
uint8_t* buf;
|
||
uint32_t len;
|
||
} snap_job_frame_t;
|
||
|
||
static void snap_free_job_frames(xlist_t* frames)
|
||
{
|
||
for (int i = 0; i < frames->count; i++) {
|
||
snap_job_frame_t* fr = (snap_job_frame_t*)xlist_get(frames, i);
|
||
if (fr) {
|
||
free(fr->buf);
|
||
free(fr);
|
||
}
|
||
}
|
||
xlist_fini(frames);
|
||
}
|
||
|
||
/* Collect + encode every retained job into `frames`, one malloc'd
|
||
* snap_job_frame_t per job, under a point-in-time-consistent walk
|
||
* (design.md "Existing Architecture Analysis" / Requirement 1.2): the
|
||
* jobmgr table's own RTTI_LOCK_READ is held for the ENTIRE walk (excludes
|
||
* concurrent insert/delete), and each retained job's own RTTI_LOCK_READ is
|
||
* ADDITIONALLY taken nested around just that job's encode call (excludes a
|
||
* concurrent per-entity writer for exactly the duration of its encode --
|
||
* the table lock alone does not do this). No lock is held during the
|
||
* disk write itself: this function only builds in-memory frames; the
|
||
* caller writes them to disk after the table walk has fully released both
|
||
* locks.
|
||
*
|
||
* Filters via job_should_retain (Requirement 1.1) so the checkpoint's
|
||
* notion of "live" matches the existing GC retention policy.
|
||
*
|
||
* Returns 0 on success (frames populated; `frames` is always left
|
||
* xlist_init'd -- even zero results -- so the caller can always
|
||
* xlist_fini it) or a negative errno on the first encode failure, in which
|
||
* case every frame collected so far has already been freed and `frames`
|
||
* is empty (Requirement 1.3: a mid-walk failure must not leave partial
|
||
* entity content behind). */
|
||
static int collect_job_frames(persist_t* p, xlist_t* frames)
|
||
{
|
||
cluster_t* c = p->cluster;
|
||
int rc = 0;
|
||
int retained_index = 0;
|
||
|
||
xlist_init(frames, 64);
|
||
|
||
/* No live cluster back-pointer (e.g. a bare unit-test persist_t) --
|
||
* zero jobs is not a failure. */
|
||
if (c == NULL || c->jobmgr == NULL)
|
||
return 0;
|
||
|
||
lock(c->jobmgr, RTTI_LOCK_READ);
|
||
|
||
iterator_t it;
|
||
int irc = iter_make(&it, c->jobmgr);
|
||
if (irc < 0) {
|
||
unlock(c->jobmgr);
|
||
return 0; /* nothing to walk -- mirrors collect_jobs' empty-table handling */
|
||
}
|
||
|
||
for (;;) {
|
||
job_t* j = NULL;
|
||
int nrc = iter_next(&it, (void**)&j);
|
||
if (nrc < 0 || nrc == RTTI_ITER_NO_DATA)
|
||
break;
|
||
if (j == NULL)
|
||
continue;
|
||
if (!job_should_retain(j, &p->config))
|
||
continue;
|
||
|
||
uint8_t* buf = NULL;
|
||
size_t sz = 0;
|
||
int enc_rc;
|
||
|
||
lock(j, RTTI_LOCK_READ);
|
||
#ifdef XCE_TESTING
|
||
int forced = atomic_load_explicit(&p->test_force_job_encode_fail_at,
|
||
memory_order_relaxed);
|
||
if (forced > 0 && (forced - 1) == retained_index)
|
||
enc_rc = -EIO; /* simulated mid-loop encode failure */
|
||
else
|
||
enc_rc = capnp_encode_job(&j->job, &buf, &sz);
|
||
#else
|
||
enc_rc = capnp_encode_job(&j->job, &buf, &sz);
|
||
#endif
|
||
unlock(j);
|
||
|
||
retained_index++;
|
||
|
||
if (enc_rc != 0) {
|
||
rc = enc_rc;
|
||
break;
|
||
}
|
||
|
||
snap_job_frame_t* fr = (snap_job_frame_t*)malloc(sizeof(*fr));
|
||
if (fr == NULL) {
|
||
free(buf);
|
||
rc = -ENOMEM;
|
||
break;
|
||
}
|
||
fr->buf = buf;
|
||
fr->len = (uint32_t)sz;
|
||
if (xlist_store(frames, fr) < 0) {
|
||
free(buf);
|
||
free(fr);
|
||
rc = -ENOMEM;
|
||
break;
|
||
}
|
||
}
|
||
|
||
iter_destroy(&it);
|
||
unlock(c->jobmgr);
|
||
|
||
if (rc != 0)
|
||
snap_free_job_frames(frames); /* Requirement 1.3: discard, not partial */
|
||
|
||
return rc;
|
||
}
|
||
|
||
/* Queue admin-status frame (ha-entity-snapshots task 3.2, Req 1.1). Unlike
|
||
* jobs, a queue's on-disk frame carries no capnp payload at all -- it is a
|
||
* plain (name, status) pair, the entirety of restore_queue_state's contract
|
||
* (queue.c:208). `name` is a malloc'd, NUL-terminated copy; the on-disk
|
||
* frame itself carries no NUL (design.md "Checkpoint File Layout":
|
||
* `{ uint32_t name_len; char name[name_len]; uint32_t status }`). */
|
||
typedef struct {
|
||
char* name;
|
||
uint32_t status;
|
||
} snap_queue_frame_t;
|
||
|
||
static void snap_free_queue_frames(xlist_t* frames)
|
||
{
|
||
for (int i = 0; i < frames->count; i++) {
|
||
snap_queue_frame_t* fr = (snap_queue_frame_t*)xlist_get(frames, i);
|
||
if (fr) {
|
||
free(fr->name);
|
||
free(fr);
|
||
}
|
||
}
|
||
xlist_fini(frames);
|
||
}
|
||
|
||
/* Collect every queue in `frames`, one malloc'd snap_queue_frame_t per
|
||
* queue, under the same point-in-time-consistent walk discipline as
|
||
* collect_job_frames above (design.md "Existing Architecture Analysis" /
|
||
* Requirement 1.2): the qmgr table's own RTTI_LOCK_READ is held for the
|
||
* ENTIRE walk, and each queue's own RTTI_LOCK_READ is ADDITIONALLY taken
|
||
* nested around just that queue's (name, status) read -- mirroring
|
||
* restore_queue_state's own per-entity lock pattern on the load side. No
|
||
* lock is held during the disk write itself.
|
||
*
|
||
* Unlike collect_job_frames, no retention predicate is applied: a queue's
|
||
* mere presence in the live qmgr table already means config provisioned
|
||
* it (design.md "write_snapshot (extended)" Responsibilities), so every
|
||
* queue found is included.
|
||
*
|
||
* Returns 0 on success (frames populated; `frames` is always left
|
||
* xlist_init'd, even for zero results, so the caller can always xlist_fini
|
||
* it) or a negative errno on the first failure (name allocation), in which
|
||
* case every frame collected so far has already been freed and `frames`
|
||
* is empty (Requirement 1.3: a mid-walk failure must not leave partial
|
||
* entity content behind). */
|
||
static int collect_queue_frames(persist_t* p, xlist_t* frames)
|
||
{
|
||
cluster_t* c = p->cluster;
|
||
int rc = 0;
|
||
|
||
xlist_init(frames, 64);
|
||
|
||
/* No live cluster back-pointer (e.g. a bare unit-test persist_t) --
|
||
* zero queues is not a failure. */
|
||
if (c == NULL || c->qmgr == NULL)
|
||
return 0;
|
||
|
||
lock(c->qmgr, RTTI_LOCK_READ);
|
||
|
||
iterator_t it;
|
||
int irc = iter_make(&it, c->qmgr);
|
||
if (irc < 0) {
|
||
unlock(c->qmgr);
|
||
return 0; /* nothing to walk -- mirrors collect_queues' empty-table handling */
|
||
}
|
||
|
||
for (;;) {
|
||
queue_t* q = NULL;
|
||
int nrc = iter_next(&it, (void**)&q);
|
||
if (nrc < 0 || nrc == RTTI_ITER_NO_DATA)
|
||
break;
|
||
if (q == NULL)
|
||
continue;
|
||
|
||
char* name;
|
||
uint32_t status;
|
||
|
||
lock(q, RTTI_LOCK_READ);
|
||
name = q->queue.queue ? strdup(q->queue.queue) : NULL;
|
||
status = q->queue.status;
|
||
unlock(q);
|
||
|
||
if (name == NULL) {
|
||
rc = -ENOMEM;
|
||
break;
|
||
}
|
||
|
||
snap_queue_frame_t* fr = (snap_queue_frame_t*)malloc(sizeof(*fr));
|
||
if (fr == NULL) {
|
||
free(name);
|
||
rc = -ENOMEM;
|
||
break;
|
||
}
|
||
fr->name = name;
|
||
fr->status = status;
|
||
if (xlist_store(frames, fr) < 0) {
|
||
free(name);
|
||
free(fr);
|
||
rc = -ENOMEM;
|
||
break;
|
||
}
|
||
}
|
||
|
||
iter_destroy(&it);
|
||
unlock(c->qmgr);
|
||
|
||
if (rc != 0)
|
||
snap_free_queue_frames(frames); /* Requirement 1.3: discard, not partial */
|
||
|
||
return rc;
|
||
}
|
||
|
||
/* Write an entity-bearing checkpoint: legacy header (unchanged) + entity
|
||
* payload (ha-entity-snapshots task 2.2/3.2, Req 1.1-1.4, 3.1-3.3). Any
|
||
* failure at any step -- collect, encode, write, sync -- leaves NO file at
|
||
* the final path; the tmp file is always unlinked on error. This is a
|
||
* retriable-next-checkpoint-cycle failure, never a crash/abort (controller
|
||
* never self-terminates on an operational condition). */
|
||
static int write_snapshot(persist_t* p)
|
||
{
|
||
char tmp_path[4096];
|
||
char final_path[4096];
|
||
|
||
snprintf(tmp_path, sizeof(tmp_path),
|
||
"%s/snap-%016" PRIx64 ".tmp", p->snap_path, p->checkpoint_lsn);
|
||
snprintf(final_path, sizeof(final_path),
|
||
"%s/snap-%016" PRIx64 ".capnp", p->snap_path, p->checkpoint_lsn);
|
||
|
||
/* Collect + encode entities BEFORE opening the tmp file: a collection
|
||
* failure (Requirement 1.3) then never creates a tmp file at all. */
|
||
xlist_t job_frames;
|
||
int rc = collect_job_frames(p, &job_frames);
|
||
if (rc != 0)
|
||
return rc;
|
||
|
||
xlist_t queue_frames;
|
||
rc = collect_queue_frames(p, &queue_frames);
|
||
if (rc != 0) {
|
||
snap_free_job_frames(&job_frames);
|
||
return rc;
|
||
}
|
||
|
||
int fd = open(tmp_path, O_CREAT | O_WRONLY | O_TRUNC, 0600);
|
||
if (fd < 0) {
|
||
snap_free_job_frames(&job_frames);
|
||
snap_free_queue_frames(&queue_frames);
|
||
return -errno;
|
||
}
|
||
|
||
/* Sentinel: magic (8) + checkpoint_lsn (8) + committed-config reference
|
||
* (1 present + 8 tag + 32 sha = 41 bytes; ha-term-safety task 4.2 r1).
|
||
* The config-ref tail is OPTIONAL on read-back, so an older 16-byte
|
||
* sentinel still loads (load_snapshot reads the tail best-effort). The
|
||
* tag is little-endian on this arch, matching the checkpoint_lsn field. */
|
||
char header[16 + 1 + 8 + 32];
|
||
memset(header, 0, sizeof(header));
|
||
memcpy(header, "XCESNAP\0", 8);
|
||
memcpy(header + 8, &p->checkpoint_lsn, 8);
|
||
header[16] = (char)(p->cfgref_present ? 1 : 0);
|
||
memcpy(header + 17, &p->cfgref_tag, 8);
|
||
memcpy(header + 25, p->cfgref_sha, 32);
|
||
|
||
if (write(fd, header, sizeof(header)) != (ssize_t)sizeof(header))
|
||
rc = -EIO;
|
||
|
||
/* Entity payload: format_version, then job frames (design.md "Checkpoint
|
||
* File Layout"). No table/entity lock is held here -- collect_job_frames
|
||
* already released both before returning. */
|
||
if (rc == 0) {
|
||
uint32_t fmt = SNAPSHOT_FORMAT_VERSION;
|
||
if (write(fd, &fmt, sizeof(fmt)) != (ssize_t)sizeof(fmt))
|
||
rc = -EIO;
|
||
}
|
||
|
||
if (rc == 0) {
|
||
uint32_t job_count = (uint32_t)job_frames.count;
|
||
if (write(fd, &job_count, sizeof(job_count)) != (ssize_t)sizeof(job_count))
|
||
rc = -EIO;
|
||
}
|
||
|
||
for (int i = 0; rc == 0 && i < job_frames.count; i++) {
|
||
snap_job_frame_t* fr = (snap_job_frame_t*)xlist_get(&job_frames, i);
|
||
if (write(fd, &fr->len, sizeof(fr->len)) != (ssize_t)sizeof(fr->len)) {
|
||
rc = -EIO;
|
||
break;
|
||
}
|
||
if (write(fd, fr->buf, fr->len) != (ssize_t)fr->len) {
|
||
rc = -EIO;
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* Queue trailer (ha-entity-snapshots task 3.2, Req 1.1, 1.2): plain
|
||
* (name, status) frames, no capnp -- matches restore_queue_state's
|
||
* exact contract (design.md "Checkpoint File Layout"). */
|
||
if (rc == 0) {
|
||
uint32_t queue_count = (uint32_t)queue_frames.count;
|
||
if (write(fd, &queue_count, sizeof(queue_count)) != (ssize_t)sizeof(queue_count))
|
||
rc = -EIO;
|
||
}
|
||
|
||
for (int i = 0; rc == 0 && i < queue_frames.count; i++) {
|
||
snap_queue_frame_t* fr = (snap_queue_frame_t*)xlist_get(&queue_frames, i);
|
||
uint32_t name_len = (uint32_t)strlen(fr->name);
|
||
if (write(fd, &name_len, sizeof(name_len)) != (ssize_t)sizeof(name_len)) {
|
||
rc = -EIO;
|
||
break;
|
||
}
|
||
if (name_len > 0 &&
|
||
write(fd, fr->name, name_len) != (ssize_t)name_len) {
|
||
rc = -EIO;
|
||
break;
|
||
}
|
||
if (write(fd, &fr->status, sizeof(fr->status)) != (ssize_t)sizeof(fr->status)) {
|
||
rc = -EIO;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (rc == 0 && fdatasync(fd) != 0)
|
||
rc = -errno;
|
||
|
||
close(fd);
|
||
snap_free_job_frames(&job_frames);
|
||
snap_free_queue_frames(&queue_frames);
|
||
|
||
if (rc != 0) {
|
||
unlink(tmp_path);
|
||
return rc;
|
||
}
|
||
|
||
if (rename(tmp_path, final_path) != 0) {
|
||
int saved = errno;
|
||
unlink(tmp_path);
|
||
return -saved;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* scan_latest_committed_config_ref (ha-term-safety task 4.2 remediation r1):
|
||
* walk the local WAL segments and capture the HIGHEST-LSN WAL_CONFIG_MARKER
|
||
* whose LSN is <= committed_lsn into p->cfgref_{present,tag,sha}. This is the
|
||
* SAME committed-prefix marker config_committed_marker derives, cached durably
|
||
* so a later checkpoint can trim the marker's segment without losing the
|
||
* derived committed-config reference (compaction safety). The marker payload is
|
||
* a fixed 40-byte reference (8-byte big-endian version_tag + 32-byte sha256 —
|
||
* mirrors config_marker_encode; the config BYTES are never in the WAL), decoded
|
||
* inline here so persist stays free of a config_repl layering dependency. */
|
||
static void scan_latest_committed_config_ref(persist_t* p)
|
||
{
|
||
uint64_t committed = atomic_load_explicit(&p->committed_lsn,
|
||
memory_order_acquire);
|
||
if (committed == 0)
|
||
return; /* nothing committed → keep any prior cached ref unchanged */
|
||
|
||
DIR* d = opendir(p->wal_path);
|
||
if (!d)
|
||
return;
|
||
|
||
uint64_t seg_lsns[4096];
|
||
int nseg = 0;
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL &&
|
||
nseg < (int)(sizeof(seg_lsns) / sizeof(seg_lsns[0]))) {
|
||
uint64_t sl = 0;
|
||
if (sscanf(e->d_name, "seg-%016" SCNx64 ".wal", &sl) == 1)
|
||
seg_lsns[nseg++] = sl;
|
||
}
|
||
closedir(d);
|
||
if (nseg == 0)
|
||
return;
|
||
|
||
for (int i = 1; i < nseg; i++) {
|
||
uint64_t key = seg_lsns[i];
|
||
int j = i - 1;
|
||
while (j >= 0 && seg_lsns[j] > key) { seg_lsns[j + 1] = seg_lsns[j]; j--; }
|
||
seg_lsns[j + 1] = key;
|
||
}
|
||
|
||
for (int si = 0; si < nseg; si++) {
|
||
char seg_path[4096];
|
||
snprintf(seg_path, sizeof(seg_path), WAL_SEG_FMT, p->wal_path,
|
||
seg_lsns[si]);
|
||
int fd = open(seg_path, O_RDONLY);
|
||
if (fd < 0)
|
||
continue;
|
||
|
||
uint32_t off = 0;
|
||
for (;;) {
|
||
wal_entry_hdr_t hdr;
|
||
ssize_t n = pread(fd, &hdr, sizeof(hdr), (off_t)off);
|
||
if (n < (ssize_t)sizeof(hdr)) break;
|
||
if (hdr.lsn == 0 && hdr.type == 0 && hdr.payload_len == 0) break;
|
||
if (hdr.lsn > committed) break; /* past committed prefix */
|
||
|
||
if (hdr.type == WAL_CONFIG_MARKER &&
|
||
hdr.payload_len >= WAL_CONFIG_MARKER_PAYLOAD_LEN) {
|
||
uint8_t buf[WAL_CONFIG_MARKER_PAYLOAD_LEN];
|
||
ssize_t pr = pread(fd, buf, sizeof(buf),
|
||
(off_t)(off + sizeof(hdr)));
|
||
if (pr == (ssize_t)sizeof(buf)) {
|
||
p->cfgref_tag =
|
||
((uint64_t)buf[0] << 56) | ((uint64_t)buf[1] << 48) |
|
||
((uint64_t)buf[2] << 40) | ((uint64_t)buf[3] << 32) |
|
||
((uint64_t)buf[4] << 24) | ((uint64_t)buf[5] << 16) |
|
||
((uint64_t)buf[6] << 8) | ((uint64_t)buf[7]);
|
||
memcpy(p->cfgref_sha, buf + 8, 32);
|
||
p->cfgref_present = 1; /* later markers overwrite — latest wins */
|
||
}
|
||
}
|
||
off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
}
|
||
close(fd);
|
||
}
|
||
}
|
||
|
||
/* Delete WAL segment files whose start LSN is strictly less than the
|
||
* active segment's start LSN AND strictly less than the trim floor.
|
||
*
|
||
* Bug-1147 (stopgap, raft-drive-async task 3.2): the trim floor is the
|
||
* minimum of the active-segment LSN, every active standby's last acked LSN,
|
||
* and every recently-disconnected slot's last_disconnected_lsn (strategy-A
|
||
* retention discipline). This ensures we NEVER delete a WAL segment that a
|
||
* live standby still needs for catch-up (the bug-795/828 committed-job-loss
|
||
* family). It does NOT fix the empty/far-behind join case (cursor=0) —
|
||
* that requires entity-bearing snapshots (separate spec).
|
||
*
|
||
* Lock ordering (verified 2026-06-26): persist_checkpoint takes p->lock only
|
||
* to snapshot checkpoint_lsn, then RELEASES it before calling
|
||
* trim_wal_segments — so this function runs with NO p->lock held.
|
||
* repl_trim_lsn and repl_expire_disconnect_slots take only r->lock
|
||
* (p->repl.lock); neither ever acquires p->lock or ce->lock, so there is no
|
||
* p->lock↔repl.lock (or ce->lock↔repl.lock) inversion. */
|
||
static void trim_wal_segments(persist_t* p)
|
||
{
|
||
/* Extract the start LSN of the currently-open segment from seg_path.
|
||
* seg_path has the form "<wal_path>/seg-<hex16>.wal".
|
||
* If no segment is open yet (wal_fd < 0), skip trimming. */
|
||
if (p->wal_fd < 0 || p->seg_path[0] == '\0')
|
||
return;
|
||
|
||
const char* seg_basename = strrchr(p->seg_path, '/');
|
||
if (!seg_basename) return;
|
||
seg_basename++; /* skip the '/' */
|
||
|
||
uint64_t active_seg_lsn = 0;
|
||
if (sscanf(seg_basename, "seg-%" SCNx64 ".wal", &active_seg_lsn) != 1)
|
||
return;
|
||
|
||
/* Expire stale disconnected slots so their last_disconnected_lsn guards
|
||
* don't hold WAL segments indefinitely (slot guard expires at
|
||
* last_disconnect_time + snap_interval_s). */
|
||
repl_expire_disconnect_slots(&p->repl, time(NULL),
|
||
p->config.snap_interval_s);
|
||
|
||
/* Compute the trim floor: min(active_seg_lsn, all active standby
|
||
* acked_lsn, all valid disconnected-slot last_disconnected_lsn).
|
||
* Any segment with seg_start_lsn BELOW this floor is disposable. */
|
||
uint64_t floor = repl_trim_lsn(&p->repl, active_seg_lsn,
|
||
p->config.snap_interval_s);
|
||
|
||
DIR* d = opendir(p->wal_path);
|
||
if (!d) return;
|
||
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL) {
|
||
if (strncmp(e->d_name, "seg-", 4) != 0) continue;
|
||
uint64_t seg_start_lsn = 0;
|
||
if (sscanf(e->d_name, "seg-%" SCNx64 ".wal", &seg_start_lsn) != 1) continue;
|
||
/* Only delete segments below BOTH the active segment AND the
|
||
* standby-cursor floor (bug-1147 guard). floor <= active_seg_lsn
|
||
* by construction (repl_trim_lsn caps at checkpoint_lsn=active_seg_lsn),
|
||
* so this check subsumes the old < active_seg_lsn guard. */
|
||
if (seg_start_lsn < floor) {
|
||
char seg_path[4096];
|
||
snprintf(seg_path, sizeof(seg_path), "%s/%s", p->wal_path, e->d_name);
|
||
unlink(seg_path);
|
||
}
|
||
}
|
||
closedir(d);
|
||
}
|
||
|
||
/* Snapshot filename comparison helper for qsort — sorts ascending by name
|
||
* (which sorts ascending by LSN since filenames embed the LSN as hex). */
|
||
static int snap_name_cmp(const void* a, const void* b)
|
||
{
|
||
return strcmp(*(const char* const*)a, *(const char* const*)b);
|
||
}
|
||
|
||
/* Keep only the snap_keep_n most recent snapshot files; delete the rest. */
|
||
/* True when `lsn` is the checkpoint position an in-flight peer snapshot
|
||
* transfer currently references (ha-entity-snapshots task 4.1/4.3, Req
|
||
* 3.1-3.3) — checked directly against snapshot_inflight_lsn[], not gated on
|
||
* snapshot_inflight[] first: the LSN clears before the flag in every
|
||
* ha_snapshot_send_dispatch/snapshot_lane_fn path, so a stale flag=1 can
|
||
* momentarily coexist with an already-cleared LSN=0 (see task 4.1's
|
||
* Implementation Note). A checkpoint's LSN is never 0, so "nonzero and
|
||
* matches" is the correct, race-free protection signal on its own. No
|
||
* cluster (p->cluster == NULL, e.g. standalone lib-level tests) means no HA
|
||
* layer exists to be mid-transfer — nothing is protected. */
|
||
static bool snapshot_lsn_in_flight(persist_t* p, uint64_t lsn)
|
||
{
|
||
if (p->cluster == NULL || lsn == 0)
|
||
return false;
|
||
|
||
for (int i = 0; i < MAX_HA_CANDIDATES; i++) {
|
||
if (atomic_load_explicit(&p->cluster->ha.snapshot_inflight_lsn[i],
|
||
memory_order_acquire) == lsn)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static void rotate_snapshots(persist_t* p)
|
||
{
|
||
DIR* d = opendir(p->snap_path);
|
||
if (!d) return;
|
||
|
||
/* Collect all .capnp snapshot filenames */
|
||
char* names[1024];
|
||
int n = 0;
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL && n < (int)(sizeof(names) / sizeof(names[0]))) {
|
||
size_t len = strlen(e->d_name);
|
||
if (len <= 6 || strcmp(e->d_name + len - 6, ".capnp") != 0) continue;
|
||
names[n] = strdup(e->d_name);
|
||
if (names[n]) n++;
|
||
}
|
||
closedir(d);
|
||
|
||
if (n == 0) return;
|
||
|
||
/* Sort ascending — oldest first */
|
||
qsort(names, (size_t)n, sizeof(names[0]), snap_name_cmp);
|
||
|
||
/* Delete all but the snap_keep_n newest, skipping any file an in-flight
|
||
* peer transfer is still reading from (task 4.3) — it is retried on the
|
||
* next rotation pass once that transfer completes. */
|
||
int keep = (int)p->config.snap_keep_n;
|
||
int delete_up_to = n - keep; /* first index to keep */
|
||
|
||
for (int i = 0; i < delete_up_to && i < n; i++) {
|
||
uint64_t lsn = 0;
|
||
if (sscanf(names[i], "snap-%" SCNx64 ".capnp", &lsn) == 1
|
||
&& snapshot_lsn_in_flight(p, lsn))
|
||
continue;
|
||
|
||
char path[4096];
|
||
snprintf(path, sizeof(path), "%s/%s", p->snap_path, names[i]);
|
||
unlink(path);
|
||
}
|
||
|
||
for (int i = 0; i < n; i++)
|
||
free(names[i]);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Async checkpoint scheduling (schedule_checkpoint + task fn) */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/* checkpoint_task_fn: executed by delaymgr worker thread.
|
||
*
|
||
* The CAS in schedule_checkpoint already holds the "in_progress" slot (=1).
|
||
* We must clear it before calling persist_checkpoint(), which has its own
|
||
* atomic_exchange guard. After persist_checkpoint() returns (it resets the
|
||
* flag to 0), we also reset wal_record_count so the counter starts fresh. */
|
||
static int checkpoint_task_fn(void* arg)
|
||
{
|
||
persist_t* persist = (persist_t*)arg;
|
||
|
||
/* Release the "scheduled" slot so persist_checkpoint's guard can acquire */
|
||
atomic_store(&persist->checkpoint_in_progress, 0);
|
||
|
||
persist_checkpoint(persist);
|
||
|
||
/* Reset the record counter so the next checkpoint window starts clean.
|
||
* Done after persist_checkpoint returns — the flag is already 0 by now. */
|
||
atomic_store(&persist->wal_record_count, 0);
|
||
return 0;
|
||
}
|
||
|
||
/* schedule_checkpoint: idempotent async checkpoint request via delaymgr.
|
||
* Safe to call from any thread. Uses atomic CAS to ensure at most one
|
||
* checkpoint is scheduled or running at a time.
|
||
*
|
||
* If cluster is NULL (unit tests without a live delaymgr) the function
|
||
* falls back to a synchronous checkpoint. */
|
||
static void schedule_checkpoint(persist_t* persist)
|
||
{
|
||
int expected = 0;
|
||
if (!atomic_compare_exchange_strong(&persist->checkpoint_in_progress,
|
||
&expected, 1))
|
||
return; /* already scheduled or running — idempotency guard */
|
||
|
||
if (persist->cluster == NULL) {
|
||
/* Unit-test / no-cluster path: run synchronously and reset. */
|
||
atomic_store(&persist->checkpoint_in_progress, 0);
|
||
persist_checkpoint(persist);
|
||
atomic_store(&persist->wal_record_count, 0);
|
||
return;
|
||
}
|
||
|
||
/* Schedule via delaymgr with 1 s coalescing delay, single execution */
|
||
delay_task(&persist->cluster->delaymgr, 1, 1,
|
||
checkpoint_task_fn, persist);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Public: persist_checkpoint */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
int persist_checkpoint(persist_t* p)
|
||
{
|
||
/* Guard against concurrent checkpoints */
|
||
if (atomic_exchange(&p->checkpoint_in_progress, 1) != 0)
|
||
return -EBUSY;
|
||
|
||
int rc = 0;
|
||
|
||
/* 1. Write WAL_CHECKPOINT marker (local only — never replicated) */
|
||
rc = persist_write_checkpoint_marker(p);
|
||
if (rc != 0)
|
||
goto done;
|
||
|
||
/* 2. Record checkpoint_lsn = the APPLIED cursor (last_applied_lsn), not
|
||
* the physical WAL append tip (current_lsn). write_snapshot() below
|
||
* serializes live jobmgr state, which only reflects entries the
|
||
* apply pump has actually dispatched -- NOT every entry durably
|
||
* appended/committed, since apply is decoupled from commit
|
||
* (raft-apply-decoupling) and can lag behind current_lsn under load.
|
||
* Stamping current_lsn here mislabels the snapshot: on restart,
|
||
* last_applied_lsn is re-seeded FROM checkpoint_lsn and replay
|
||
* resumes strictly above it, so any committed-but-not-yet-applied
|
||
* entries between the true apply cursor and current_lsn at
|
||
* checkpoint time are silently skipped forever, even though jobmgr
|
||
* never actually contained them (persist.h last_applied_lsn doc).
|
||
* Held under apply_pump_lock together with 3a/3b so the label and
|
||
* the jobmgr walk it describes are captured atomically w.r.t. the
|
||
* live apply pump -- the pump can only ever move last_applied_lsn
|
||
* and jobmgr forward, never backward, so freezing it here rules out
|
||
* the label ever claiming more than jobmgr actually holds. */
|
||
pthread_mutex_lock(&p->apply_pump_lock);
|
||
|
||
p->checkpoint_lsn = atomic_load_explicit(&p->last_applied_lsn,
|
||
memory_order_acquire);
|
||
|
||
/* 3a. Capture the latest committed config-marker reference BEFORE trimming
|
||
* (ha-term-safety task 4.2 r1): the trim below can unlink the segment
|
||
* holding a committed WAL_CONFIG_MARKER, so the derived committed config
|
||
* must be cached durably (into the sentinel) before that happens. */
|
||
scan_latest_committed_config_ref(p);
|
||
|
||
/* 3b. Serialize in-memory state to a snapshot file
|
||
* (P0: no live kvmgrs — writes sentinel header + cached config ref) */
|
||
rc = write_snapshot(p);
|
||
|
||
pthread_mutex_unlock(&p->apply_pump_lock);
|
||
|
||
if (rc != 0)
|
||
goto done;
|
||
|
||
/* 4. Trim old WAL segments before the checkpoint */
|
||
trim_wal_segments(p);
|
||
|
||
/* 5. Rotate old snapshots — keep only snap_keep_n most recent */
|
||
rotate_snapshots(p);
|
||
|
||
done:
|
||
atomic_store(&p->checkpoint_in_progress, 0);
|
||
return rc;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Recovery internals */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/* Parse and restore every job frame then every queue frame from the entity
|
||
* payload beginning at the current `fd` offset (design.md "Checkpoint File
|
||
* Layout"): [job_count][job_count * {payload_len; capnp Job bytes}]
|
||
* [queue_count][queue_count * {name_len; name; status}].
|
||
*
|
||
* `file_size` is the checkpoint file's total size (from fstat) and
|
||
* `consumed` is the number of bytes already read from it (legacy header +
|
||
* format_version) — every length-prefixed frame's length is bounds-checked
|
||
* against the ACTUAL remaining bytes in the file before being trusted for a
|
||
* read or allocation, never blindly followed (a corrupt/attacker-controlled
|
||
* length must fail fast, not drive an oversized read).
|
||
*
|
||
* Restores in file order: every job via restore_job, then every queue via
|
||
* restore_queue_state (design.md sequence diagram). Returns 0 only if every
|
||
* frame restored; on the FIRST failure of any kind (short read, oversized
|
||
* length, decode error, or restore error) returns immediately with a
|
||
* negative errno — the caller MUST NOT commit any position change on a
|
||
* non-zero return (Requirement 2.3).
|
||
*
|
||
* restore_job's -EEXIST is treated as success, not a failure: it is job.c's
|
||
* own documented idempotent-replay guard (job already present), needed so a
|
||
* retried load — after an earlier partial attempt already created some jobs
|
||
* before failing on a later one — can continue past already-restored entries
|
||
* rather than aborting on them again. restore_queue_state has no such
|
||
* idempotent-duplicate case (re-applying the same status is already a
|
||
* harmless overwrite), so any non-zero return from it is a genuine failure. */
|
||
static int load_snapshot_entities(int fd, off_t file_size, off_t consumed,
|
||
persist_t* p, uint64_t stored_lsn)
|
||
{
|
||
uint32_t job_count = 0;
|
||
if (read(fd, &job_count, sizeof(job_count)) != (ssize_t)sizeof(job_count))
|
||
return -EIO;
|
||
consumed += (off_t)sizeof(job_count);
|
||
|
||
if (job_count > 0 && (p->cluster == NULL || p->cluster->jobmgr == NULL))
|
||
return -EINVAL; /* entities present but nowhere to restore them into */
|
||
|
||
for (uint32_t i = 0; i < job_count; i++) {
|
||
uint32_t payload_len = 0;
|
||
if (read(fd, &payload_len, sizeof(payload_len)) !=
|
||
(ssize_t)sizeof(payload_len))
|
||
return -EIO;
|
||
consumed += (off_t)sizeof(payload_len);
|
||
|
||
if (payload_len == 0 || (off_t)payload_len > file_size - consumed)
|
||
return -EIO;
|
||
|
||
uint8_t* buf = (uint8_t*)malloc(payload_len);
|
||
if (buf == NULL)
|
||
return -ENOMEM;
|
||
if (read(fd, buf, payload_len) != (ssize_t)payload_len) {
|
||
free(buf);
|
||
return -EIO;
|
||
}
|
||
consumed += (off_t)payload_len;
|
||
|
||
int rrc = restore_job(p->cluster, 0, buf, payload_len, stored_lsn);
|
||
free(buf);
|
||
if (rrc != 0 && rrc != -EEXIST)
|
||
return rrc;
|
||
}
|
||
|
||
uint32_t queue_count = 0;
|
||
if (read(fd, &queue_count, sizeof(queue_count)) !=
|
||
(ssize_t)sizeof(queue_count))
|
||
return -EIO;
|
||
consumed += (off_t)sizeof(queue_count);
|
||
|
||
if (queue_count > 0 && (p->cluster == NULL || p->cluster->qmgr == NULL))
|
||
return -EINVAL; /* entities present but nowhere to restore them into */
|
||
|
||
for (uint32_t i = 0; i < queue_count; i++) {
|
||
uint32_t name_len = 0;
|
||
if (read(fd, &name_len, sizeof(name_len)) != (ssize_t)sizeof(name_len))
|
||
return -EIO;
|
||
consumed += (off_t)sizeof(name_len);
|
||
|
||
if (name_len == 0 || (off_t)name_len > file_size - consumed)
|
||
return -EIO;
|
||
|
||
char* name = (char*)malloc((size_t)name_len + 1);
|
||
if (name == NULL)
|
||
return -ENOMEM;
|
||
if (read(fd, name, name_len) != (ssize_t)name_len) {
|
||
free(name);
|
||
return -EIO;
|
||
}
|
||
name[name_len] = '\0';
|
||
consumed += (off_t)name_len;
|
||
|
||
uint32_t status = 0;
|
||
if (read(fd, &status, sizeof(status)) != (ssize_t)sizeof(status)) {
|
||
free(name);
|
||
return -EIO;
|
||
}
|
||
consumed += (off_t)sizeof(status);
|
||
|
||
int qrc = restore_queue_state(p->cluster, name, status);
|
||
free(name);
|
||
if (qrc != 0)
|
||
return qrc;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
/* Load a snapshot: legacy header (magic + checkpoint_lsn + optional
|
||
* config-ref tail), then — for an entity-bearing checkpoint — every job and
|
||
* queue frame that follows it (ha-entity-snapshots task 5.2, Requirements
|
||
* 2.1-2.4, 5.1, 5.2). See design.md "load_snapshot (extended)" and its
|
||
* sequence diagram for the exact contract this implements.
|
||
*
|
||
* Ordering (Requirement 2.1, 2.2, 2.3 — restore FIRST, validate FULLY, THEN
|
||
* advance position, never the reverse, never partial): the header and any
|
||
* config-ref tail are parsed into LOCAL variables only. Format is then
|
||
* detected by whether any bytes remain past the header (design.md's stated
|
||
* discriminator — not a flag bit). A short file (nothing remains) is a
|
||
* legacy, pre-feature checkpoint: not an error, so its recorded position is
|
||
* committed immediately and *out_entities_restored is false. Otherwise every
|
||
* job then every queue frame is restored via load_snapshot_entities() before
|
||
* ANYTHING is written to p->checkpoint_lsn/p->current_lsn/p->cfgref_*; on
|
||
* any restore failure this function returns non-zero with p's fields
|
||
* PROVABLY untouched (the single commit point below is only ever reached
|
||
* after load_snapshot_entities() returns 0). *out_entities_restored is only
|
||
* set on a rc==0 return, matching persist.h's documented contract.
|
||
*
|
||
* Returns 0 on success, -EINVAL on bad magic / unrecognized entity format,
|
||
* -EIO on a short/corrupt read, -ENOMEM on allocation failure, -errno on
|
||
* open/fstat failure, or a restore_job/restore_queue_state error code. */
|
||
static int load_snapshot(persist_t* p, const char* path, uint64_t lsn,
|
||
bool* out_entities_restored)
|
||
{
|
||
(void)lsn; /* lsn is encoded in the file itself — we re-read it */
|
||
|
||
int fd = open(path, O_RDONLY);
|
||
if (fd < 0)
|
||
return -errno;
|
||
|
||
struct stat st;
|
||
if (fstat(fd, &st) != 0) {
|
||
int saved = errno;
|
||
close(fd);
|
||
return -saved;
|
||
}
|
||
|
||
/* magic(8) + checkpoint_lsn(8) + optional config-ref tail(1+8+32). */
|
||
char header[16 + 1 + 8 + 32];
|
||
ssize_t n = read(fd, header, sizeof(header));
|
||
|
||
if (n < 16) {
|
||
close(fd);
|
||
return -EIO;
|
||
}
|
||
|
||
if (memcmp(header, "XCESNAP\0", 8) != 0) {
|
||
close(fd);
|
||
return -EINVAL;
|
||
}
|
||
|
||
/* Extract checkpoint_lsn from bytes 8-15 (little-endian on this arch).
|
||
* Held LOCALLY -- not yet written to p-> -- until the single commit
|
||
* point below (legacy short-circuit, or after every entity restores). */
|
||
uint64_t stored_lsn;
|
||
memcpy(&stored_lsn, header + 8, 8);
|
||
|
||
/* Optional committed-config reference (ha-term-safety task 4.2 r1):
|
||
* present only when the sentinel carries the 41-byte tail. Also held
|
||
* locally until the same commit point. */
|
||
int cfgref_present = 0;
|
||
uint64_t cfgref_tag = 0;
|
||
uint8_t cfgref_sha[32];
|
||
memset(cfgref_sha, 0, sizeof(cfgref_sha));
|
||
if (n >= 16 + 1 + 8 + 32 && header[16] != 0) {
|
||
cfgref_present = 1;
|
||
memcpy(&cfgref_tag, header + 17, 8);
|
||
memcpy(cfgref_sha, header + 25, 32);
|
||
}
|
||
|
||
off_t consumed = (off_t)n;
|
||
|
||
/* Legacy-format detection (Requirement 2.4, 5.1, 5.2): "does more data
|
||
* exist" is the discriminator (design.md), not a flag bit -- a file
|
||
* ending exactly at the legacy header is unambiguously a pre-feature
|
||
* checkpoint. This is an expected rollout shape, not corruption: commit
|
||
* the position it records (existing, preserved legacy behavior) and
|
||
* report entities_restored=false so the caller falls back to normal
|
||
* catch-up rather than treating this checkpoint as fully complete. */
|
||
if (st.st_size <= consumed) {
|
||
close(fd);
|
||
p->checkpoint_lsn = stored_lsn;
|
||
p->current_lsn = stored_lsn;
|
||
if (cfgref_present) {
|
||
p->cfgref_present = 1;
|
||
p->cfgref_tag = cfgref_tag;
|
||
memcpy(p->cfgref_sha, cfgref_sha, sizeof(p->cfgref_sha));
|
||
}
|
||
if (out_entities_restored)
|
||
*out_entities_restored = false;
|
||
return 0;
|
||
}
|
||
|
||
/* Entity-bearing checkpoint: format_version, then job frames, then queue
|
||
* frames (design.md "Checkpoint File Layout"). */
|
||
uint32_t fmt = 0;
|
||
if (read(fd, &fmt, sizeof(fmt)) != (ssize_t)sizeof(fmt)) {
|
||
close(fd);
|
||
return -EIO;
|
||
}
|
||
consumed += (off_t)sizeof(fmt);
|
||
|
||
/* An unrecognized format_version is corruption or a future layout this
|
||
* binary cannot read -- fail loud rather than silently treating it as
|
||
* legacy (task 5.1 review note: a version mismatch must never be masked
|
||
* as an expected legacy shape, or real corruption goes undetected). */
|
||
if (fmt != SNAPSHOT_FORMAT_VERSION) {
|
||
close(fd);
|
||
return -EINVAL;
|
||
}
|
||
|
||
int rc = load_snapshot_entities(fd, st.st_size, consumed, p, stored_lsn);
|
||
close(fd);
|
||
if (rc != 0)
|
||
return rc; /* Requirement 2.3: p->*_lsn / p->cfgref_* untouched */
|
||
|
||
/* Single commit point (Requirement 2.1, 2.2): reached ONLY after every
|
||
* job and queue frame restored successfully above. */
|
||
p->checkpoint_lsn = stored_lsn;
|
||
p->current_lsn = stored_lsn;
|
||
if (cfgref_present) {
|
||
p->cfgref_present = 1;
|
||
p->cfgref_tag = cfgref_tag;
|
||
memcpy(p->cfgref_sha, cfgref_sha, sizeof(p->cfgref_sha));
|
||
}
|
||
if (out_entities_restored)
|
||
*out_entities_restored = true;
|
||
return 0;
|
||
}
|
||
|
||
/* Public wrapper for load_snapshot — exposed via persist.h for ha.c (Task
|
||
* 3.5c). load_snapshot() above now owns the full restore-before-advance
|
||
* implementation (ha-entity-snapshots task 5.2): this wrapper is a thin
|
||
* pass-through, since load_snapshot() already gates *out_entities_restored
|
||
* and the p->checkpoint_lsn/current_lsn commit on entity-restore success
|
||
* (Requirements 2.1-2.4, 5.1, 5.2). */
|
||
int persist_load_snapshot(persist_t *p, const char *path, uint64_t lsn,
|
||
bool *out_entities_restored)
|
||
{
|
||
return load_snapshot(p, path, lsn, out_entities_restored);
|
||
}
|
||
|
||
/* P2-2: Standby WAL replay — apply a single replicated entry to in-memory state.
|
||
*
|
||
* No hooks fire on standbys; the hook system is empty until promotion.
|
||
*
|
||
* Returns 0 on success (including skip for checkpoint / unknown types),
|
||
* negative errno on error (NULL args, oversized payload). */
|
||
int apply_wal_entry(persist_t* p, uint32_t type, uint64_t lsn,
|
||
const void* payload, uint32_t payload_len)
|
||
{
|
||
if (!p)
|
||
return -EINVAL;
|
||
|
||
/* Reject oversized payloads BEFORE any allocation */
|
||
if (payload_len > MAX_WAL_PAYLOAD_BYTES) {
|
||
ERROR("apply_wal_entry: payload_len %u exceeds MAX_WAL_PAYLOAD_BYTES %u",
|
||
payload_len, MAX_WAL_PAYLOAD_BYTES);
|
||
return -EMSGSIZE;
|
||
}
|
||
|
||
/* LOGICAL entity restore ONLY. The on-disk WAL header, the CRC chain
|
||
* (p->last_crc), and current_lsn are owned exclusively by the physical
|
||
* layer — persist_wal_write_replicated on the live replicated path, and
|
||
* replay_wal/persist_recover on recovery. This function MUST NOT touch
|
||
* them. (bug-778: it previously set p->last_crc from a synthetic crc=0
|
||
* header, breaking the on-disk CRC chain and cold-restart recovery.) */
|
||
|
||
/* WAL_CHECKPOINT (100) is a local-only marker — standbys never receive it
|
||
* via replication. Skip silently. */
|
||
if (type == WAL_CHECKPOINT)
|
||
return 0;
|
||
|
||
/* Unknown types above WAL_ENTRY_TYPE_MAX: log WARN and skip for forward
|
||
* compatibility — an older controller can boot on WAL written by a newer
|
||
* one without hard-failing on unrecognised entry types. */
|
||
if (type > WAL_ENTRY_TYPE_MAX) {
|
||
WARN("apply_wal_entry: unknown type %u > WAL_ENTRY_TYPE_MAX %u"
|
||
" (lsn=%" PRIu64 ") — skipping for forward compatibility",
|
||
type, WAL_ENTRY_TYPE_MAX, lsn);
|
||
return 0;
|
||
}
|
||
|
||
/* Advance the current_lsn high-water (monotonic, idempotent). This is a
|
||
* write-cursor high-water that is safe to bump from multiple paths. The
|
||
* CRC chain link (p->last_crc) is deliberately NOT touched here — it has a
|
||
* single owner: the physical writer (persist_wal_write_replicated) and
|
||
* replay (persist_recover). (bug-778) */
|
||
pthread_mutex_lock(&p->lock);
|
||
if (lsn + 1 > p->current_lsn)
|
||
p->current_lsn = lsn + 1;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
if (!p->cluster)
|
||
return 0; /* unit tests without a full cluster — skip entity restore */
|
||
|
||
switch (type) {
|
||
case WAL_JOB_SUBMIT:
|
||
/* Payload: Cap'n Proto serialized xce_job_t. Thread the entry LSN
|
||
* through so the restored job is tagged for the egress watermark
|
||
* (ha-convergence-recovery 3.3). */
|
||
restore_job(p->cluster, 0, payload, payload_len, lsn);
|
||
break;
|
||
|
||
case WAL_JOB_ARRAY_SUBMIT:
|
||
/* job-array-single-wal-record task 3.3 (design.md "Array Apply
|
||
* Expansion"): the array-record analog of WAL_JOB_SUBMIT's
|
||
* restore_job() call directly above, dispatched from this SEPARATE,
|
||
* still-live standby catch-up / replicated-apply seam so it can
|
||
* never diverge from apply_committed_entry()'s WAL_JOB_ARRAY_SUBMIT
|
||
* case (Req 3.1, 3.2, 4.1). Mirrors the single-job case's exact
|
||
* minimalism: no xlist_t, no hook fire — this dispatcher is
|
||
* deliberately hook-free (standby catch-up does not notify the
|
||
* local scheduler/API server; that is the primary's job, done via
|
||
* apply_committed_entry()). out_new_jobs is not needed here, so
|
||
* NULL is passed (restore_job_array()'s out_new_jobs parameter is
|
||
* optional — see test_rja_out_new_jobs_is_optional,
|
||
* test_job_array_apply.c). The return value is intentionally
|
||
* discarded, matching every other case in this switch today (task
|
||
* 4.1 — Live Apply Resource Fence — is what later makes this
|
||
* function's callers respect a negative rc; not this task's
|
||
* boundary). */
|
||
restore_job_array(p->cluster, payload, payload_len, lsn, NULL);
|
||
break;
|
||
|
||
case WAL_JOB_START:
|
||
case WAL_JOB_FINISH:
|
||
case WAL_JOB_FAIL:
|
||
case WAL_JOB_STOP:
|
||
case WAL_JOB_RESUME:
|
||
case WAL_JOB_UPDATE:
|
||
case WAL_JOB_CKPT:
|
||
case WAL_JOB_PREASON:
|
||
case WAL_JOB_RUSAGE:
|
||
/* Payload: wal_jobstate_t {jobid, state} */
|
||
if (payload_len >= sizeof(wal_jobstate_t)) {
|
||
const wal_jobstate_t* js = (const wal_jobstate_t*)payload;
|
||
restore_job_state(p->cluster, js->jobid, js->state);
|
||
}
|
||
break;
|
||
|
||
case WAL_WORKER_CTRL:
|
||
/* Payload: [uint32_t detail][capnp worker] */
|
||
if (payload_len > WORKER_WAL_HDR_SIZE)
|
||
worker_from_capnp(p->cluster, payload, payload_len);
|
||
break;
|
||
|
||
case WAL_QUEUE_CTRL:
|
||
/* Payload: [wal_queuehdr_t][name NUL-terminated] */
|
||
if (payload_len > sizeof(wal_queuehdr_t)) {
|
||
const wal_queuehdr_t* qh = (const wal_queuehdr_t*)payload;
|
||
const char* name = (const char*)(payload + sizeof(wal_queuehdr_t));
|
||
restore_queue_state(p->cluster, name, qh->status);
|
||
}
|
||
break;
|
||
|
||
case WAL_NOOP:
|
||
/* Internal promotion no-op: empty payload, no entity restore, no
|
||
* hooks. current_lsn is advanced by the physical layer (writer /
|
||
* replay), not here — recognised explicitly rather than via default. */
|
||
break;
|
||
|
||
case WAL_CONFIG_MARKER:
|
||
/* Config-change marker (ha-term-safety task 4.2): a {version_tag,
|
||
* sha256} reference, NEVER the config bytes (R7.4). It carries NO
|
||
* entity state, so replay performs no restore here — the committed
|
||
* configuration is DERIVED from the latest committed marker on demand
|
||
* (config_committed_marker), and the bytes are obtained + hash-verified
|
||
* out-of-band before activation (task 4.3). Recognised explicitly so
|
||
* the per-LSN term index (built above this switch's caller) records its
|
||
* term and on_get_entry_term / (term,lsn) reconcile treat it as an
|
||
* ordinary log entry. */
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
/* raft-apply-decoupling task 2.2 — see persist.h for the full contract doc
|
||
* (routing table, the job-state-family hook-mapping deviation, and what this
|
||
* function deliberately does NOT own). Not yet wired into any live call
|
||
* site — replay (2.3) and the live apply pump (6.1) will call this later. */
|
||
/* raft-apply-decoupling task 6.1 (Hazard 1 fix): thread-local "this call
|
||
* stack is inside apply_committed_entry's own post-mutation notification"
|
||
* guard. persist_job_hook_fun / persist_queue_hook_fun / persist_worker_hook_fun
|
||
* are AFTER_EVENT subscribers that (for RETAIN/fact-shaped entry types)
|
||
* durably append a NEW WAL entry every time they fire — the correct behavior
|
||
* for their ORIGINAL, still-live purpose (job_ctrl's mark_job_update/
|
||
* mark_job_fail synchronous command path; the legacy queue/worker control
|
||
* command handlers) which mutate live state and rely on this exact hook
|
||
* firing to durably record that mutation.
|
||
*
|
||
* apply_committed_entry() now ALSO fires these same AFTER_EVENT hooks (job
|
||
* facts always as the generic HOOK_DETAIL_JOB_UPDATE, per task 2.2's
|
||
* documented wire-format deviation; queue/worker control at their native
|
||
* detail) to deliver its own post-mutation NOTIFICATION — but that entry is
|
||
* by definition already durably committed (it just got applied FROM the
|
||
* WAL), so re-appending it would silently duplicate the entry on every live
|
||
* apply. This is fixable, correctly, ONLY by provenance (which caller fired
|
||
* the hook), NOT by hook `detail` value: HOOK_DETAIL_JOB_UPDATE in
|
||
* particular is genuinely dual-origin (job_ctrl's SIGCONT/SIGSTOP path uses
|
||
* the identical detail for its own, still-legitimate live append) — an
|
||
* unconditional detail-based filter would silently break job_ctrl's suspend/
|
||
* resume durability instead of fixing anything. A shared persist_t-wide flag
|
||
* would ALSO be wrong: it would race across threads (e.g. job_ctrl mutating
|
||
* a DIFFERENT job on the iomgr thread while this thread's apply walk is
|
||
* mid-hook for an unrelated fact) and could wrongly suppress or wrongly
|
||
* permit an append depending on scheduling. A thread-local flag, scoped
|
||
* narrowly around each xce_run_hooks() call this function makes, is race-free
|
||
* by construction: it is only ever observed by hook subscribers invoked
|
||
* SYNCHRONOUSLY on this exact call stack, from this exact function. */
|
||
static _Thread_local bool t_apply_seam_notifying = false;
|
||
|
||
int apply_committed_entry(persist_t* p, uint32_t wal_type, uint64_t lsn,
|
||
const void* payload, uint32_t len)
|
||
{
|
||
if (!p)
|
||
return -EINVAL;
|
||
|
||
if (!p->cluster)
|
||
return 0; /* unit tests without a full cluster — nothing to dispatch into */
|
||
|
||
cluster_t* c = p->cluster;
|
||
|
||
switch (wal_type) {
|
||
case WAL_JOB_SUBMIT: {
|
||
/* Return value distinguishes a genuine creation from an idempotent
|
||
* no-op (bug-1419): 1 = restore_job() actually constructed a new
|
||
* job, 0 = the jobid already existed (duplicate WAL entry — see
|
||
* below), negative = a real error. replay_wal()'s new-job tally
|
||
* relies on this distinction; the live apply pump discards it.
|
||
*
|
||
* A duplicate WAL_JOB_SUBMIT for the same jobid is an EXPECTED,
|
||
* harmless occurrence, not a bug: a client's local commit-timeout
|
||
* retry can append a second entry for the same jobid while the
|
||
* first is still in flight during a quorum-loss window; the WAL
|
||
* entry that timed out is never retracted, so if it later achieves
|
||
* majority commit anyway, both entries end up committed. restore_job
|
||
* ()'s own -EEXIST here is what makes that safe to replay twice. */
|
||
int rc = restore_job(c, 0, payload, len, lsn);
|
||
if (rc == -EEXIST)
|
||
return 0; /* idempotent re-apply (replay / resync) — no hook */
|
||
if (rc < 0)
|
||
return rc;
|
||
|
||
struct capn jc_ctx;
|
||
capn_init_mem(&jc_ctx, (const uint8_t*)payload, len, 0);
|
||
Job_ptr jp;
|
||
jp.p = capn_getp(capn_root(&jc_ctx), 0, 1);
|
||
struct Job cj;
|
||
read_Job(&cj, jp);
|
||
uint64_t jobid = cj.jobid;
|
||
capn_free(&jc_ctx);
|
||
|
||
job_t* j = NULL;
|
||
lock(c->jobmgr, RTTI_LOCK_READ);
|
||
key_find(c->jobmgr, (void*)jobid, (void**)&j);
|
||
unlock(c->jobmgr);
|
||
if (!j)
|
||
return 1; /* defensive: should not happen after a successful restore */
|
||
|
||
void* jptr = j;
|
||
xlist_t jlist = { .count = 1, .caps = 1, .delta = 1, .data = &jptr };
|
||
t_apply_seam_notifying = true;
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
|
||
HOOK_DETAIL_JOB_NEW, c, &jlist);
|
||
t_apply_seam_notifying = false;
|
||
|
||
return 1;
|
||
}
|
||
|
||
case WAL_JOB_ARRAY_SUBMIT: {
|
||
/* job-array-single-wal-record task 3.2 (design.md "Array Apply
|
||
* Expansion"): the array-record analog of WAL_JOB_SUBMIT's
|
||
* restore_job() above, dispatched from this SAME shared seam so live
|
||
* apply, boot replay (both callers of this function), and (task
|
||
* 3.3) standby catch-up can never diverge in how they expand a
|
||
* committed array record (Req 3.1, 3.2).
|
||
*
|
||
* restore_job_array()'s return generalizes restore_job()'s
|
||
* three-way contract to N elements: a positive count = the number
|
||
* of genuinely NEW elements this call constructed (this is exactly,
|
||
* and only, what the hook fire below carries — Req 3.4); -EEXIST =
|
||
* every index in the record already existed (idempotent
|
||
* whole-record no-op, Req 3.3) — no hook; a negative errno = a
|
||
* genuine construction/resource-exhaustion failure (Req 9.4) — no
|
||
* hook, propagated so persist_apply_pump_run() (task 4.1) can halt
|
||
* and fence on it rather than silently skipping it. */
|
||
xlist_t out_new_jobs;
|
||
memset(&out_new_jobs, 0, sizeof(out_new_jobs));
|
||
int rc = restore_job_array(c, payload, len, lsn, &out_new_jobs);
|
||
if (rc == -EEXIST)
|
||
return 0; /* idempotent re-apply (replay / resync) — no hook */
|
||
if (rc < 0)
|
||
return rc;
|
||
|
||
/* Exactly ONE fire carrying every newly created element in one
|
||
* list — never once per element (Req 3.4). out_new_jobs is already
|
||
* the precise newly-created set restore_job_array() built; no
|
||
* re-derivation via key_find needed. */
|
||
t_apply_seam_notifying = true;
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
|
||
HOOK_DETAIL_JOB_NEW, c, &out_new_jobs);
|
||
t_apply_seam_notifying = false;
|
||
|
||
/* Only the list's own backing array is released here — ownership of
|
||
* every job_t* it holds already transferred to c->jobmgr inside
|
||
* restore_job_array()'s bulk-insert pass, mirroring
|
||
* test_job_array_apply.c's own xlist_fini(&out) usage after a
|
||
* successful call. */
|
||
xlist_fini(&out_new_jobs);
|
||
|
||
return rc;
|
||
}
|
||
|
||
case WAL_JOB_START:
|
||
case WAL_JOB_FINISH:
|
||
case WAL_JOB_STOP:
|
||
case WAL_JOB_RESUME:
|
||
case WAL_JOB_RUSAGE:
|
||
case WAL_JOB_CKPT:
|
||
case WAL_JOB_PREASON: {
|
||
/* Every job-state fact type in this group shares one wire shape —
|
||
* wal_jobstate_t {jobid, state} — and therefore one guarded core.
|
||
* Their payloads are deterministic/state-independent (a START is
|
||
* always JOB_STAT_RUN, a FINISH's outcome depends only on
|
||
* exit_code), so an absolute `state` baked at receipt time never
|
||
* goes stale — unlike WAL_JOB_FAIL/WAL_JOB_UPDATE below, which have
|
||
* their own dedicated cases. See persist.h's doc comment on
|
||
* apply_committed_entry for the full routing table and why this
|
||
* does not dispatch to the richer type-specific apply_job_{start,
|
||
* finish}_transition() cores (their parameter structs are not
|
||
* present on this wire format). */
|
||
if (len < sizeof(wal_jobstate_t))
|
||
return 0; /* undersized payload — skip, mirrors apply_wal_entry */
|
||
|
||
const wal_jobstate_t* js = (const wal_jobstate_t*)payload;
|
||
job_t* j = NULL;
|
||
lock(c->jobmgr, RTTI_LOCK_READ);
|
||
key_find(c->jobmgr, (void*)js->jobid, (void**)&j);
|
||
unlock(c->jobmgr);
|
||
if (!j)
|
||
return -ENOENT;
|
||
|
||
lock(j, RTTI_LOCK_WRITE);
|
||
job_transition_result_t result =
|
||
apply_job_update_transition(j, (int)js->state);
|
||
unlock(j);
|
||
|
||
if (result != JOB_TRANSITION_APPLIED)
|
||
return 0; /* rejected-backward / no-op-duplicate — no hook */
|
||
|
||
/* Hazard 1 guard (see t_apply_seam_notifying's doc comment above):
|
||
* HOOK_DETAIL_JOB_UPDATE is dual-origin — job_ctrl's mark_job_update
|
||
* (SIGCONT/SIGSTOP) fires the identical detail for its own live,
|
||
* still-legitimate append via persist_job_hook_fun. Without this
|
||
* guard, EVERY fact this apply pump applies would re-append itself
|
||
* as a duplicate, mis-typed WAL_JOB_UPDATE entry the moment the live
|
||
* pump (task 6.1) starts calling this function. */
|
||
t_apply_seam_notifying = true;
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
|
||
HOOK_DETAIL_JOB_UPDATE, c, j);
|
||
t_apply_seam_notifying = false;
|
||
return 0;
|
||
}
|
||
|
||
case WAL_JOB_FAIL: {
|
||
/* task 6.1 round 2 (Req 3.3/7.1-7.3, 11.3) fix for a job-state
|
||
* corruption bug: unlike the deterministic group above, a FAIL's
|
||
* resulting state depends on the job's CURRENT state (PERR merges
|
||
* onto whatever terminal state already holds). Dispatch on _pad
|
||
* (see wal_types.h's doc comment on wal_jobstate_t._pad):
|
||
* - WAL_JOB_FAIL_STAGE_FLAG set: a deferred fail-fact
|
||
* (record_job_fail_fact, job.c) — reconstruct a minimal
|
||
* job_failinfo_t from {jobid, stage} and call the REAL
|
||
* apply_job_fail_transition() against the job's ACTUAL CURRENT
|
||
* state at apply time, never a receipt-time snapshot.
|
||
* - _pad == 0: the legacy live path (job_ctrl's mark_job_fail) —
|
||
* `state` is already the correct, current, absolute value,
|
||
* computed synchronously under the same lock as the mutation
|
||
* (no staleness window) — an absolute overwrite is safe and
|
||
* unchanged. */
|
||
if (len < sizeof(wal_jobstate_t))
|
||
return 0; /* undersized payload — skip, mirrors apply_wal_entry */
|
||
|
||
const wal_jobstate_t* js = (const wal_jobstate_t*)payload;
|
||
job_t* j = NULL;
|
||
lock(c->jobmgr, RTTI_LOCK_READ);
|
||
key_find(c->jobmgr, (void*)js->jobid, (void**)&j);
|
||
unlock(c->jobmgr);
|
||
if (!j)
|
||
return -ENOENT;
|
||
|
||
lock(j, RTTI_LOCK_WRITE);
|
||
job_transition_result_t result;
|
||
if (js->_pad & WAL_JOB_FAIL_STAGE_FLAG) {
|
||
job_failinfo_t failinfo = {
|
||
.jobid = js->jobid,
|
||
.stage = js->_pad & ~WAL_JOB_FAIL_STAGE_FLAG,
|
||
.code = 0,
|
||
.detail = NULL,
|
||
};
|
||
result = apply_job_fail_transition(j, &failinfo);
|
||
} else {
|
||
result = apply_job_update_transition(j, (int)js->state);
|
||
}
|
||
unlock(j);
|
||
|
||
if (result != JOB_TRANSITION_APPLIED)
|
||
return 0; /* rejected-backward / no-op-duplicate — no hook */
|
||
|
||
/* Hazard 1 guard — see the identical guard above. */
|
||
t_apply_seam_notifying = true;
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
|
||
HOOK_DETAIL_JOB_UPDATE, c, j);
|
||
t_apply_seam_notifying = false;
|
||
return 0;
|
||
}
|
||
|
||
case WAL_JOB_UPDATE: {
|
||
/* task 6.1 round 2 (Req 3.3/7.1-7.3, 11.3): same class of fix as
|
||
* WAL_JOB_FAIL above. Dispatch on _pad:
|
||
* - WAL_JOB_UPDATE_POSTDONE_MARK: a deferred postdone fact
|
||
* (record_job_postdone_fact, job.c) — call
|
||
* apply_job_postdone_transition(), which re-derives the
|
||
* postdone merge (conditional EXIT backstop + OR JOB_STAT_PDONE)
|
||
* fresh against the job's ACTUAL CURRENT state at apply time.
|
||
* - _pad == 0: the legacy live path (job_ctrl's mark_job_update,
|
||
* SIGCONT/SIGSTOP) — `state` is already correct/absolute,
|
||
* computed synchronously under the same lock as the mutation —
|
||
* an absolute overwrite is safe and unchanged. */
|
||
if (len < sizeof(wal_jobstate_t))
|
||
return 0; /* undersized payload — skip, mirrors apply_wal_entry */
|
||
|
||
const wal_jobstate_t* js = (const wal_jobstate_t*)payload;
|
||
job_t* j = NULL;
|
||
lock(c->jobmgr, RTTI_LOCK_READ);
|
||
key_find(c->jobmgr, (void*)js->jobid, (void**)&j);
|
||
unlock(c->jobmgr);
|
||
if (!j)
|
||
return -ENOENT;
|
||
|
||
lock(j, RTTI_LOCK_WRITE);
|
||
job_transition_result_t result =
|
||
(js->_pad == WAL_JOB_UPDATE_POSTDONE_MARK)
|
||
? apply_job_postdone_transition(j)
|
||
: apply_job_update_transition(j, (int)js->state);
|
||
unlock(j);
|
||
|
||
if (result != JOB_TRANSITION_APPLIED)
|
||
return 0; /* rejected-backward / no-op-duplicate — no hook */
|
||
|
||
/* Hazard 1 guard — see the identical guard above. */
|
||
t_apply_seam_notifying = true;
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
|
||
HOOK_DETAIL_JOB_UPDATE, c, j);
|
||
t_apply_seam_notifying = false;
|
||
return 0;
|
||
}
|
||
|
||
case WAL_QUEUE_CTRL: {
|
||
if (len <= sizeof(wal_queuehdr_t))
|
||
return 0; /* undersized payload — skip, mirrors apply_wal_entry */
|
||
|
||
const wal_queuehdr_t* qh = (const wal_queuehdr_t*)payload;
|
||
const char* name =
|
||
(const char*)((const uint8_t*)payload + sizeof(wal_queuehdr_t));
|
||
|
||
/* restore_queue_state's -ENOENT ("queue not yet built from config")
|
||
* and -EINVAL (bad name) are both benign skips here, matching
|
||
* apply_wal_entry's existing behavior of never fencing on this
|
||
* entry type's restore outcome. */
|
||
if (restore_queue_state(c, name, qh->status) < 0)
|
||
return 0;
|
||
|
||
queue_t* q = NULL;
|
||
lock(c->qmgr, RTTI_LOCK_READ);
|
||
key_find(c->qmgr, (void*)name, (void**)&q);
|
||
unlock(c->qmgr);
|
||
if (!q)
|
||
return 0;
|
||
|
||
/* Same double-append class as the job-fact guard above: the legacy
|
||
* queue-control command handler still mutates then relies on this
|
||
* exact AFTER_EVENT firing to durably append via
|
||
* persist_queue_hook_fun (WAL_QUEUE_CTRL is unconditionally
|
||
* appended on AFTER_EVENT there, not gated on WAL_MODE_WAIT like the
|
||
* job-submit case is) — guard so this live-pump re-notify does not
|
||
* re-append an already-committed entry. */
|
||
t_apply_seam_notifying = true;
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_QUEUE, HOOK_AFTER_EVENT,
|
||
HOOK_DETAIL_QUEUE_UPDATE, c, q);
|
||
t_apply_seam_notifying = false;
|
||
return 0;
|
||
}
|
||
|
||
case WAL_WORKER_CTRL: {
|
||
if (len <= WORKER_WAL_HDR_SIZE)
|
||
return 0; /* undersized payload — skip, mirrors apply_wal_entry */
|
||
|
||
uint32_t hook_detail;
|
||
memcpy(&hook_detail, payload, sizeof(hook_detail));
|
||
|
||
int rc = worker_from_capnp(c, payload, len);
|
||
if (rc == -EEXIST)
|
||
return 0; /* idempotent re-apply (replay / resync) — no hook */
|
||
if (rc < 0)
|
||
return rc;
|
||
|
||
/* Recover the worker's name via a cheap raw capnp field read
|
||
* (read_Worker's capn_get_text is a borrowed, NUL-terminated pointer
|
||
* into the decode buffer — no allocation, unlike decode_Worker) so
|
||
* the just-restored worker can be looked up for the notification
|
||
* hook without changing worker_from_capnp's own contract. */
|
||
const uint8_t* wpayload = (const uint8_t*)payload + WORKER_WAL_HDR_SIZE;
|
||
size_t wlen = len - WORKER_WAL_HDR_SIZE;
|
||
struct capn wc_ctx;
|
||
capn_init_mem(&wc_ctx, wpayload, wlen, 0);
|
||
Worker_ptr wp;
|
||
wp.p = capn_getp(capn_root(&wc_ctx), 0, 1);
|
||
struct Worker cw;
|
||
read_Worker(&cw, wp);
|
||
|
||
worker_t* w = NULL;
|
||
lock(c->workermgr, RTTI_LOCK_READ);
|
||
key_find(c->workermgr, (void*)cw.name.str, (void**)&w);
|
||
unlock(c->workermgr);
|
||
capn_free(&wc_ctx);
|
||
if (!w)
|
||
return 0; /* defensive: should not happen after a successful restore */
|
||
|
||
/* Same double-append class as the job-fact/queue guards above:
|
||
* persist_worker_hook_fun unconditionally appends on AFTER_EVENT for
|
||
* every worker detail (no WAL_MODE_WAIT gate), and the legacy worker
|
||
* join/update/dead command paths still rely on that firing for their
|
||
* own live durability — guard the whole re-notify switch so this
|
||
* live-pump call never re-appends an already-committed entry. */
|
||
t_apply_seam_notifying = true;
|
||
switch (hook_detail) {
|
||
case HOOK_DETAIL_WORKER_JOIN:
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_WORKER,
|
||
HOOK_AFTER_EVENT, HOOK_DETAIL_WORKER_JOIN, c,
|
||
w->host.name, w);
|
||
break;
|
||
case HOOK_DETAIL_WORKER_UPDATE:
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_WORKER,
|
||
HOOK_AFTER_EVENT, HOOK_DETAIL_WORKER_UPDATE, c, w);
|
||
break;
|
||
case HOOK_DETAIL_WORKER_DEAD:
|
||
xce_run_hooks(&c->hooksys, XCE_CONTROLLER_HOOK_WORKER,
|
||
HOOK_AFTER_EVENT, HOOK_DETAIL_WORKER_DEAD, c, w);
|
||
break;
|
||
default:
|
||
break; /* unrecognized discriminator — mutation already applied,
|
||
* notification skipped rather than guessed at */
|
||
}
|
||
t_apply_seam_notifying = false;
|
||
return 0;
|
||
}
|
||
|
||
default:
|
||
/* WAL_NOOP / WAL_CONFIG_MARKER / WAL_CHECKPOINT / unknown — no
|
||
* entity effect at this seam, no hook (unchanged from
|
||
* apply_wal_entry's existing treatment of these types). */
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* Ordered apply pump (raft-apply-decoupling task 6.1)
|
||
* ============================================================ */
|
||
|
||
/* Locate and read back the WAL entry at exactly `lsn` from the on-disk
|
||
* segment files, for the live apply pump to hand to apply_committed_entry()
|
||
* (mirrors read_entry_meta_locked's segment-scan idiom above, but returns
|
||
* the entry's type and a malloc'd copy of its payload instead of just
|
||
* metadata, and re-verifies the entry CRC — this is live-path plumbing, not
|
||
* a metadata probe).
|
||
*
|
||
* Does not take p->lock: every LSN this is ever called with is already
|
||
* majority-committed (durable and replicated), strictly below the physical
|
||
* append tip a concurrent persist_wal_async could be extending — so this
|
||
* never races the single-writer append path for the bytes it actually
|
||
* reads.
|
||
*
|
||
* Returns 0 and populates *out_type/*out_payload (malloc'd if payload_len >
|
||
* 0, caller frees; NULL when payload_len is 0)/*out_len on success.
|
||
* Returns -ENOENT if no segment contains this LSN (should not happen for a
|
||
* genuinely committed entry) or a negative errno on a read/CRC failure. */
|
||
static int apply_pump_read_entry(persist_t* p, uint64_t lsn,
|
||
uint32_t* out_type, uint8_t** out_payload,
|
||
uint32_t* out_len)
|
||
{
|
||
if (!p || lsn == 0 || !out_type || !out_payload || !out_len)
|
||
return -EINVAL;
|
||
|
||
/* Fast path (raft-apply-decoupling task 7.4 perf-fix): the sole caller
|
||
* (persist_apply_pump_run) calls this once per LSN in strict ascending
|
||
* order with no gaps in the common case, and holds apply_pump_lock
|
||
* across the entire walk -- so the cache fields below need no lock of
|
||
* their own here (see persist.h doc comment). A cache hit reads the next
|
||
* header directly at the remembered offset on the already-open fd,
|
||
* giving amortized O(1) per entry instead of a fresh
|
||
* opendir/readdir/segment-sort plus a scan-from-zero on every single
|
||
* call (the O(n^2) regression this fixes). ANY miss below just drops the
|
||
* cache and falls through to the unchanged slow path -- correctness
|
||
* never depends on the cache being right, only performance does. */
|
||
if (p->apply_pump_cache_fd >= 0 && p->apply_pump_cache_next_lsn == lsn) {
|
||
wal_entry_hdr_t hdr;
|
||
ssize_t hn = pread(p->apply_pump_cache_fd, &hdr, sizeof(hdr),
|
||
(off_t)p->apply_pump_cache_off);
|
||
bool hit = (hn == (ssize_t)sizeof(hdr)) && (hdr.lsn == lsn);
|
||
uint8_t* payload = NULL;
|
||
|
||
if (hit && hdr.payload_len > 0) {
|
||
payload = malloc(hdr.payload_len);
|
||
if (!payload) {
|
||
hit = false;
|
||
} else {
|
||
ssize_t pn = pread(p->apply_pump_cache_fd, payload,
|
||
hdr.payload_len,
|
||
(off_t)(p->apply_pump_cache_off +
|
||
sizeof(hdr)));
|
||
if (pn != (ssize_t)hdr.payload_len) {
|
||
free(payload);
|
||
payload = NULL;
|
||
hit = false;
|
||
}
|
||
}
|
||
}
|
||
if (hit) {
|
||
uint32_t expected_crc =
|
||
crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||
if (hdr.payload_len > 0)
|
||
expected_crc = crc32(expected_crc, (const Bytef*)payload,
|
||
hdr.payload_len);
|
||
if (hdr.crc != expected_crc) {
|
||
free(payload);
|
||
payload = NULL;
|
||
hit = false;
|
||
}
|
||
}
|
||
|
||
if (hit) {
|
||
*out_type = hdr.type;
|
||
*out_payload = payload;
|
||
*out_len = hdr.payload_len;
|
||
p->apply_pump_cache_off +=
|
||
(uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
p->apply_pump_cache_next_lsn = lsn + 1;
|
||
return 0;
|
||
}
|
||
|
||
/* Miss (short read/EOF meaning the entry rolled into the next
|
||
* segment, header mismatch, CRC failure, whatever): don't try to be
|
||
* clever about advancing to the next segment inline. Close and fall
|
||
* through to the EXISTING slow path unchanged -- it already
|
||
* correctly finds the right segment from scratch. */
|
||
close(p->apply_pump_cache_fd);
|
||
p->apply_pump_cache_fd = -1;
|
||
}
|
||
|
||
DIR* d = opendir(p->wal_path);
|
||
if (!d)
|
||
return -ENOENT;
|
||
|
||
uint64_t seg_lsns[4096];
|
||
int nseg = 0;
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL &&
|
||
nseg < (int)(sizeof(seg_lsns) / sizeof(seg_lsns[0]))) {
|
||
if (strncmp(e->d_name, "seg-", 4) != 0)
|
||
continue;
|
||
uint64_t sl = 0;
|
||
if (sscanf(e->d_name, "seg-%" SCNx64 ".wal", &sl) != 1)
|
||
continue;
|
||
seg_lsns[nseg++] = sl;
|
||
}
|
||
closedir(d);
|
||
if (nseg == 0)
|
||
return -ENOENT;
|
||
|
||
/* Ascending sort by start LSN (segment count is tiny in practice). */
|
||
for (int i = 1; i < nseg; i++) {
|
||
uint64_t key = seg_lsns[i];
|
||
int j = i - 1;
|
||
while (j >= 0 && seg_lsns[j] > key) {
|
||
seg_lsns[j + 1] = seg_lsns[j];
|
||
j--;
|
||
}
|
||
seg_lsns[j + 1] = key;
|
||
}
|
||
|
||
int rc = -ENOENT;
|
||
for (int si = 0; si < nseg; si++) {
|
||
/* Segments are LSN-ordered; one whose start LSN already exceeds the
|
||
* target cannot contain it, nor can any later one. */
|
||
if (seg_lsns[si] > lsn)
|
||
break;
|
||
|
||
char seg_path[4096];
|
||
snprintf(seg_path, sizeof(seg_path), WAL_SEG_FMT, p->wal_path,
|
||
seg_lsns[si]);
|
||
int fd = open(seg_path, O_RDONLY);
|
||
if (fd < 0)
|
||
continue;
|
||
|
||
uint32_t off = 0;
|
||
for (;;) {
|
||
wal_entry_hdr_t hdr;
|
||
ssize_t hn = pread(fd, &hdr, sizeof(hdr), (off_t)off);
|
||
if (hn < (ssize_t)sizeof(hdr))
|
||
break; /* short read / EOF / pre-allocated zero tail */
|
||
if (hdr.lsn == 0 && hdr.type == 0 && hdr.payload_len == 0)
|
||
break;
|
||
|
||
if (hdr.lsn == lsn) {
|
||
uint8_t* payload = NULL;
|
||
uint32_t expected_crc =
|
||
crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||
|
||
if (hdr.payload_len > 0) {
|
||
payload = malloc(hdr.payload_len);
|
||
if (!payload) {
|
||
rc = -ENOMEM;
|
||
break;
|
||
}
|
||
ssize_t pn = pread(fd, payload, hdr.payload_len,
|
||
(off_t)(off + sizeof(hdr)));
|
||
if (pn != (ssize_t)hdr.payload_len) {
|
||
free(payload);
|
||
rc = -EIO;
|
||
break;
|
||
}
|
||
expected_crc = crc32(expected_crc, (const Bytef*)payload,
|
||
hdr.payload_len);
|
||
}
|
||
|
||
if (hdr.crc != expected_crc) {
|
||
free(payload);
|
||
rc = -EIO;
|
||
break;
|
||
}
|
||
|
||
*out_type = hdr.type;
|
||
*out_payload = payload;
|
||
*out_len = hdr.payload_len;
|
||
rc = 0;
|
||
|
||
/* raft-apply-decoupling task 7.4 perf-fix: populate the
|
||
* segment-lookup cache with this fd positioned just past the
|
||
* entry we just returned, BEFORE closing it below, so the
|
||
* next expected LSN (lsn+1) hits the fast path above instead
|
||
* of re-scanning from segment start next time. Every OTHER
|
||
* branch (error paths, not-found-in-this-segment
|
||
* continuation) closes fd exactly as before -- caching only
|
||
* ever happens on this success branch. */
|
||
p->apply_pump_cache_fd = fd;
|
||
p->apply_pump_cache_seg_lsn = seg_lsns[si];
|
||
p->apply_pump_cache_off =
|
||
off + (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
p->apply_pump_cache_next_lsn = lsn + 1;
|
||
fd = -1; /* ownership transferred to the cache -- the
|
||
* close(fd) below becomes a guarded no-op. */
|
||
break;
|
||
}
|
||
|
||
off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
}
|
||
if (fd >= 0)
|
||
close(fd);
|
||
|
||
if (rc == 0 || rc == -ENOMEM || rc == -EIO)
|
||
break; /* found it, or hit a genuine error inside the segment
|
||
* that should contain it */
|
||
}
|
||
|
||
return rc;
|
||
}
|
||
|
||
int persist_apply_pump_run(persist_t* p)
|
||
{
|
||
if (!p)
|
||
return -EINVAL;
|
||
|
||
pthread_mutex_lock(&p->apply_pump_lock);
|
||
|
||
int applied = 0;
|
||
for (;;) {
|
||
uint64_t committed = atomic_load_explicit(&p->committed_lsn,
|
||
memory_order_acquire);
|
||
uint64_t next = atomic_load_explicit(&p->last_applied_lsn,
|
||
memory_order_relaxed) + 1;
|
||
if (next > committed)
|
||
break; /* caught up as of this instant */
|
||
|
||
uint32_t wal_type = 0;
|
||
uint8_t* payload = NULL;
|
||
uint32_t len = 0;
|
||
int rc = apply_pump_read_entry(p, next, &wal_type, &payload, &len);
|
||
if (rc < 0) {
|
||
ERROR("persist_apply_pump_run: failed to read committed entry "
|
||
"lsn=%" PRIu64 " (rc=%d) -- stopping this pass; the entry "
|
||
"stays committed and will be retried, from the same "
|
||
"position, on the next kick",
|
||
next, rc);
|
||
pthread_mutex_unlock(&p->apply_pump_lock);
|
||
return rc;
|
||
}
|
||
|
||
/* Strict order, no skipping or reordering: this is the single
|
||
* dispatch call every newly committed entry passes through here,
|
||
* regardless of its commit mode (WAIT command or RETAIN fact) — the
|
||
* loop only ever advances last_applied_lsn one entry at a time, in
|
||
* ascending order, so a burst of commands and facts that committed
|
||
* out of their original submission order still applies in strict
|
||
* commit (LSN) order (Req 3.3, 7.3). */
|
||
int apply_rc = apply_committed_entry(p, wal_type, next, payload, len);
|
||
if (apply_rc < 0) {
|
||
/* job-array-single-wal-record task 4.1 (design.md "Live Apply
|
||
* Resource Fence"): this return value was previously discarded
|
||
* -- (void)apply_committed_entry(...) -- with last_applied_lsn
|
||
* advancing unconditionally, silently and permanently losing
|
||
* any apply failure. A negative rc here is never a content-
|
||
* derived failure (Req 9.1-9.3's pre-commit validation already
|
||
* made those impossible post-commit); it is a node-local
|
||
* resource-exhaustion failure (e.g. OOM mid array expansion,
|
||
* Req 9.4) that must not be skipped. Mirror the
|
||
* apply_pump_read_entry failure branch immediately above: stop
|
||
* the walk exactly at this LSN, do not advance
|
||
* last_applied_lsn, and do not take this LSN's pending-apply
|
||
* hand-off (persist_take_pending_apply/on_applied) -- the entry
|
||
* never actually applied, so that hand-off must not fire for
|
||
* it. The entry stays committed and will be retried, from the
|
||
* same position, on the next kick. This is a GENERAL fix: every
|
||
* WAL type's apply outcome is now respected, not just
|
||
* WAL_JOB_ARRAY_SUBMIT's. Under NORMAL operation every other
|
||
* existing type's apply case returns 0 or positive, so this
|
||
* branch is a behavior no-op for them in practice; the
|
||
* fact-type cases' pre-existing "-ENOENT: referenced job not
|
||
* found" branch (persist.h's dispatch table) is one other real,
|
||
* pre-existing source of a negative return here, and now also
|
||
* correctly halts the walk here instead of being silently
|
||
* discarded, exactly as this fix intends. */
|
||
ERROR("persist_apply_pump_run: apply_committed_entry failed for "
|
||
"committed entry lsn=%" PRIu64 " wal_type=%" PRIu32
|
||
" (rc=%d) -- stopping this pass at this position; the "
|
||
"entry stays committed and will be retried, from the same "
|
||
"position, on the next kick; Live Apply Resource Fence "
|
||
"ENGAGED (job-array-single-wal-record Req 9.4)",
|
||
next, wal_type, apply_rc);
|
||
/* job-array-single-wal-record task 4.2 (design.md "Live Apply
|
||
* Resource Fence"): engage the NEW, live, self-healing fence --
|
||
* distinct from recovery_unrecoverable -- so the promotion gate
|
||
* (task 4.3) can refuse this node while it stays alive and
|
||
* keeps retrying. Cleared below, from inside this same walk,
|
||
* the instant a retry of this exact LSN succeeds (Req 9.5). */
|
||
persist_mark_apply_resource_fenced(p, next);
|
||
free(payload);
|
||
pthread_mutex_unlock(&p->apply_pump_lock);
|
||
return apply_rc;
|
||
}
|
||
free(payload);
|
||
|
||
atomic_store_explicit(&p->last_applied_lsn, next, memory_order_relaxed);
|
||
applied++;
|
||
|
||
/* job-array-single-wal-record task 4.2 (design.md "Live Apply
|
||
* Resource Fence", Req 9.5): self-heal. Called unconditionally on
|
||
* EVERY successful apply, not just after a known-fenced LSN --
|
||
* persist_clear_apply_resource_fence() is a cheap no-op when nothing
|
||
* is fenced (the overwhelming common case). This is correct and
|
||
* sufficient because the walk above is strictly sequential and
|
||
* returns immediately on any failure (see the halt branch just
|
||
* above): it can never advance PAST a fenced LSN without that exact
|
||
* LSN applying successfully first, so "the next entry to apply
|
||
* successfully after a fence engages" and "a retry of the SAME
|
||
* stuck LSN succeeding" are the same event by construction. No
|
||
* election, promotion check, or external/admin action is involved --
|
||
* the fence clears purely because this walk itself made progress. */
|
||
persist_clear_apply_resource_fence(p);
|
||
|
||
/* raft-apply-decoupling task 6.2 (Req 1.1, 2.1-2.3): consume this
|
||
* LSN's command hand-off, if any (task 3.2's
|
||
* persist_register_pending_apply(), e.g. handle_jobsubmit's
|
||
* commit-outcome continuation) -- strictly AFTER the mutation above
|
||
* and after last_applied_lsn already reflects it, never before or
|
||
* during. A fact entry never registers a hand-off, so
|
||
* persist_take_pending_apply() simply returns 0 for it and this is a
|
||
* correct no-op with no per-entry-type branching needed here.
|
||
* persist_take_pending_apply() unlinks the registration before
|
||
* returning it, so re-applying the same lsn (a future retry/replay
|
||
* landing on this exact walk position again) can never fire the
|
||
* continuation a second time. */
|
||
pending_apply_notify_fn on_applied = NULL;
|
||
void* apply_ctx = NULL;
|
||
|
||
if (persist_take_pending_apply(p, next, &on_applied, &apply_ctx))
|
||
on_applied(apply_ctx, next);
|
||
}
|
||
|
||
pthread_mutex_unlock(&p->apply_pump_lock);
|
||
return applied;
|
||
}
|
||
|
||
/* Heap payload for one dispatched apply-pump walk invocation; frees itself
|
||
* after use, mirroring commit.c's commit_resolve_dispatch_t convention. */
|
||
typedef struct {
|
||
persist_t* p;
|
||
} apply_pump_dispatch_t;
|
||
|
||
/* Run walk passes until genuinely caught up, using the same recheck-then-
|
||
* clear idiom commit.c's sweep thread uses (task 1.3) to avoid a lost-wakeup
|
||
* race between "persist_apply_pump_run just decided it is caught up" and "a
|
||
* new commit lands right after". Runs on a pool worker thread (or inline as
|
||
* persist_apply_pump_kick's documented fallback) — never on the calling
|
||
* (potentially replication-ACK/iomgr) thread when dispatched normally.
|
||
*
|
||
* Does NOT immediately retry a failed pass: persist_apply_pump_run() already
|
||
* logs loudly and leaves last_applied_lsn unadvanced past a read failure, so
|
||
* an immediate self-retry here would busy-loop forever on the exact same
|
||
* unresolved head-of-line entry (a real, reproduced hazard — a persist_t
|
||
* whose committed_lsn is ever advanced without a corresponding real WAL
|
||
* entry on disk, e.g. a test fixture exercising commit-engine resolve logic
|
||
* in isolation, spun this into a multi-million-iteration tight loop before
|
||
* this guard existed). A future, genuinely NEW kick — triggered by a later
|
||
* real commit-advance event — retries then, at that event's natural cadence,
|
||
* never in a busy spin. */
|
||
static void apply_pump_drain(persist_t* p)
|
||
{
|
||
for (;;) {
|
||
int rc = persist_apply_pump_run(p);
|
||
|
||
atomic_store_explicit(&p->apply_pump_scheduled, 0, memory_order_release);
|
||
|
||
if (rc < 0)
|
||
return; /* already logged loudly inside persist_apply_pump_run;
|
||
* do not busy-retry the same unresolved entry */
|
||
|
||
uint64_t committed = atomic_load_explicit(&p->committed_lsn,
|
||
memory_order_acquire);
|
||
uint64_t next = atomic_load_explicit(&p->last_applied_lsn,
|
||
memory_order_relaxed) + 1;
|
||
if (next > committed)
|
||
return; /* genuinely caught up: no race window survived open */
|
||
|
||
int expected = 0;
|
||
if (!atomic_compare_exchange_strong_explicit(
|
||
&p->apply_pump_scheduled, &expected, 1, memory_order_acq_rel,
|
||
memory_order_relaxed))
|
||
return; /* a concurrent kick already reclaimed the flag; it will
|
||
* run its own pass and pick up this same new work */
|
||
/* else: we reclaimed the flag ourselves -- loop and run another pass */
|
||
}
|
||
}
|
||
|
||
static int apply_pump_vtask_entry(vtask_t* task)
|
||
{
|
||
apply_pump_dispatch_t* d = (apply_pump_dispatch_t*)task->data->u.generic;
|
||
|
||
apply_pump_drain(d->p);
|
||
free(d);
|
||
return 0;
|
||
}
|
||
|
||
void persist_apply_pump_kick(persist_t* p, void* pool_arg)
|
||
{
|
||
if (!p || !p->cluster)
|
||
return;
|
||
|
||
/* bug-1416 (raft-apply-decoupling task 7.2 round 2): defer while this
|
||
* node's own boot-time persist_recover() replay+verification pass is
|
||
* still running -- last_applied_lsn is being written UNLOCKED by
|
||
* replay_wal() during that window (single-threaded-recovery
|
||
* assumption), and persist_verify_apply_correctness() is about to
|
||
* compare a live re-read of it (and of the live job table) against a
|
||
* WAL-scan-derived expectation. Letting this walk run concurrently
|
||
* would race BOTH: it could advance last_applied_lsn out from under
|
||
* replay_wal's own plain (non-CAS) store, and it mutates the same job
|
||
* table persist_verify_apply_correctness re-reads as "actual" --
|
||
* producing a false APPLY_DIVERGENT verdict on a perfectly healthy
|
||
* boot (confirmed live). No work is lost: persist_recover() sets this
|
||
* flag back to true and issues exactly one catch-up kick right before
|
||
* it returns, which will walk from last_applied_lsn+1 up through
|
||
* whatever committed_lsn has grown to by then -- a full, ordered
|
||
* drain, not a partial one. Intentionally checked with NO lock: a
|
||
* stale `true` read racing the flag's transition to `false` is
|
||
* harmless (the walk itself still only advances one entry at a time
|
||
* under apply_pump_lock, so at worst this call proceeds a few
|
||
* instructions into a window persist_recover() is about to close, and
|
||
* the deferred re-kick after recover() still runs); a stale `false`
|
||
* merely defers one no-op-cost kick to the next real trigger. */
|
||
if (!atomic_load_explicit(&p->recovery_complete, memory_order_acquire))
|
||
return;
|
||
|
||
int expected = 0;
|
||
if (!atomic_compare_exchange_strong_explicit(
|
||
&p->apply_pump_scheduled, &expected, 1, memory_order_acq_rel,
|
||
memory_order_relaxed))
|
||
return; /* already scheduled/running -- it will pick up any new work */
|
||
|
||
vtaskmgr_t* pool = (vtaskmgr_t*)pool_arg;
|
||
vtask_data_t* data = calloc(1, sizeof(*data));
|
||
vtask_t* task = vtask_alloc();
|
||
apply_pump_dispatch_t* d = calloc(1, sizeof(*d));
|
||
|
||
/* Mirrors commit.c's dispatch_continuation fallback exactly: never leave
|
||
* a scheduled walk silently unrun. pool == NULL is the expected shape of
|
||
* a commit-only / bare-persist-cluster unit fixture that never calls
|
||
* commit_set_pool — a real cluster always wires the pool before any
|
||
* commit activity (cluster_init_phase2), so this fallback path is the
|
||
* normal, deterministic behavior for that class of test, not a rare
|
||
* production condition. */
|
||
if (pool == NULL || data == NULL || task == NULL || d == NULL) {
|
||
free(data);
|
||
free(task);
|
||
free(d);
|
||
apply_pump_drain(p);
|
||
return;
|
||
}
|
||
|
||
d->p = p;
|
||
data->vtype = VTASK_GENERIC;
|
||
data->u.generic = d;
|
||
vtask_init(task, (vtask_func_t)apply_pump_vtask_entry, data);
|
||
|
||
/* VTASK_LIVE_BLOCK is never admission-gated (same delivery guarantee as
|
||
* commit.c's own continuation dispatch) — this cannot fast-reject the
|
||
* way VTASK_REQ_BLOCK could. */
|
||
if (vtask_enq_classified(pool, task, VTASK_LIVE_BLOCK) < 0) {
|
||
WARN("persist_apply_pump_kick: vtask enqueue failed -- running "
|
||
"inline (stay alive, never leave a scheduled walk unrun)");
|
||
free(data);
|
||
free(task);
|
||
free(d);
|
||
apply_pump_drain(p);
|
||
}
|
||
}
|
||
|
||
/* persist_apply_pump_run_sync (2026-07-10, chaotic-soak regression fix,
|
||
* ROUND 2): synchronously drain the apply pump on THIS thread, GUARANTEEING
|
||
* last_applied_lsn reaches committed_lsn (as observed at entry) before
|
||
* returning -- regardless of what any concurrent caller is doing.
|
||
*
|
||
* Round 1 of this fix CAS-claimed apply_pump_scheduled and, on losing that
|
||
* claim to a concurrent caller (e.g. a heartbeat-driven kick that fired
|
||
* moments before promotion), just returned -- betting the OTHER caller's
|
||
* async vtask-dispatched drain would finish in time. A second live chaotic
|
||
* soak proved that bet wrong: job loss persisted even with the environmental
|
||
* fdatasync noise (bug-924) independently confirmed and eliminated, so this
|
||
* was a genuine, separate bug in the fix itself, not noise. The caller here
|
||
* (promotion) cannot outsource its guarantee to a drain it doesn't control
|
||
* the completion of.
|
||
*
|
||
* Fix: do NOT use the CAS-coalescing flag as a "someone else has it, I'm
|
||
* done" signal -- call persist_apply_pump_run() directly in a loop until
|
||
* THIS call observes last_applied_lsn caught up to committed_lsn. Multiple
|
||
* concurrent callers (this one and any vtask-dispatched persist_apply_pump_
|
||
* kick) safely interleave via persist_apply_pump_run's own internal
|
||
* apply_pump_lock and apply_committed_entry's per-LSN idempotency -- at
|
||
* worst a redundant no-op pass, never lost or duplicated work. Stops (does
|
||
* not busy-loop forever) on a genuine read failure, exactly mirroring
|
||
* apply_pump_drain's own error handling: persist_apply_pump_run() already
|
||
* logs loudly and leaves last_applied_lsn unadvanced on that path. */
|
||
void persist_apply_pump_run_sync(persist_t* p)
|
||
{
|
||
if (!p || !p->cluster)
|
||
return;
|
||
if (!atomic_load_explicit(&p->recovery_complete, memory_order_acquire))
|
||
return;
|
||
|
||
for (;;) {
|
||
uint64_t committed = atomic_load_explicit(&p->committed_lsn,
|
||
memory_order_acquire);
|
||
uint64_t applied = atomic_load_explicit(&p->last_applied_lsn,
|
||
memory_order_relaxed);
|
||
if (applied >= committed)
|
||
return; /* caught up -- by us or a concurrent drain */
|
||
|
||
int rc = persist_apply_pump_run(p);
|
||
if (rc < 0)
|
||
return; /* already logged loudly; do not busy-loop on the same
|
||
* unresolved entry (mirrors apply_pump_drain) */
|
||
/* Loop: re-check in case committed_lsn advanced further mid-walk, or
|
||
* a concurrent drain made partial progress this pass didn't see. */
|
||
}
|
||
}
|
||
|
||
/* Read WAL segment files in LSN order; replay entries with lsn > from_lsn.
|
||
*
|
||
* Segment filename format: seg-<start_lsn_hex16>.wal
|
||
* Entry format: wal_entry_hdr_t (40 bytes) + payload (hdr.payload_len bytes).
|
||
*
|
||
* CRC chain: hdr.prev_crc must match last_crc (WAL_CRC_NONE for first entry
|
||
* in a segment). If prev_crc mismatches, stop at last good entry and log WARN.
|
||
*
|
||
* Recovery-integrity verdict (ha-commit-durability task 3.1, Req 2.1):
|
||
* *verdict_out (when non-NULL) is set to RECOVER_TRUNCATED iff replay stopped
|
||
* on a STRUCTURAL corruption — a CRC chain break, a CRC mismatch, or a
|
||
* short/partial entry read — and RECOVER_CLEAN otherwise (clean EOF,
|
||
* pre-allocated-zero tail, empty WAL, or all entries replayed). The verdict is
|
||
* about the STRUCTURE of the local log, not about being behind the cluster: a
|
||
* clean log that simply ends below the cluster high-water is still CLEAN.
|
||
*
|
||
* new_job_submits_out (raft-apply-decoupling task 5.1, when non-NULL) is set
|
||
* to the number of WAL_JOB_SUBMIT entries this call actually dispatched via
|
||
* apply_committed_entry() that resulted in a GENUINE new job (that function's
|
||
* return value == 1) — not merely the number of WAL_JOB_SUBMIT entries above
|
||
* `from_lsn` present in the log. This is the log-derived half of the
|
||
* post-replay apply-correctness expectation (see apply_expectation_t).
|
||
*
|
||
* A WAL_JOB_SUBMIT entry can legitimately be duplicated for the same jobid
|
||
* (bug-1419): a client's local commit-timeout retry appends a second entry
|
||
* while the first is still in flight during a quorum-loss window, and the
|
||
* timed-out entry is never retracted, so both can end up committed if the
|
||
* first later achieves majority commit anyway. apply_committed_entry()
|
||
* dedupes this via restore_job()'s -EEXIST and returns 0 (no-op) rather than
|
||
* 1 (created) for the duplicate — counting raw WAL_JOB_SUBMIT occurrences
|
||
* instead would tally that duplicate as a second job and spuriously fail the
|
||
* post-replay comparison against jobmgr's actual growth (APPLY_DIVERGENT).
|
||
*
|
||
* Returns number of entries replayed (>= 0) or negative errno on I/O error. */
|
||
static int replay_wal(persist_t* p, uint64_t from_lsn,
|
||
recover_verdict_t* verdict_out,
|
||
uint64_t* new_job_submits_out)
|
||
{
|
||
/* Default to CLEAN; only a structural corruption flips it to TRUNCATED. */
|
||
if (verdict_out)
|
||
*verdict_out = RECOVER_CLEAN;
|
||
if (new_job_submits_out)
|
||
*new_job_submits_out = 0;
|
||
|
||
/* --- Collect and sort segment filenames by start LSN --- */
|
||
DIR* d = opendir(p->wal_path);
|
||
if (!d)
|
||
return 0; /* no WAL dir → first boot, nothing to replay (CLEAN) */
|
||
|
||
/* Collect up to 4096 segment names */
|
||
uint64_t seg_lsns[4096];
|
||
int nseg = 0;
|
||
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL && nseg < (int)(sizeof(seg_lsns)/sizeof(seg_lsns[0]))) {
|
||
if (strncmp(e->d_name, "seg-", 4) != 0) continue;
|
||
uint64_t seg_start = 0;
|
||
if (sscanf(e->d_name, "seg-%" SCNx64 ".wal", &seg_start) != 1) continue;
|
||
seg_lsns[nseg++] = seg_start;
|
||
}
|
||
closedir(d);
|
||
|
||
if (nseg == 0)
|
||
return 0;
|
||
|
||
/* Simple insertion sort — segment count is tiny in practice */
|
||
for (int i = 1; i < nseg; i++) {
|
||
uint64_t key = seg_lsns[i];
|
||
int j = i - 1;
|
||
while (j >= 0 && seg_lsns[j] > key) {
|
||
seg_lsns[j + 1] = seg_lsns[j];
|
||
j--;
|
||
}
|
||
seg_lsns[j + 1] = key;
|
||
}
|
||
|
||
/* --- Replay entries from each segment --- */
|
||
int replayed = 0;
|
||
uint32_t last_crc = WAL_CRC_NONE;
|
||
|
||
for (int si = 0; si < nseg; si++) {
|
||
char seg_path[4096];
|
||
snprintf(seg_path, sizeof(seg_path),
|
||
WAL_SEG_FMT, p->wal_path, seg_lsns[si]);
|
||
|
||
int fd = open(seg_path, O_RDONLY);
|
||
if (fd < 0) {
|
||
WARN("persist_recover: cannot open segment %s: %s",
|
||
seg_path, strerror(errno));
|
||
continue;
|
||
}
|
||
|
||
uint32_t seg_off = 0;
|
||
|
||
for (;;) {
|
||
/* Read header */
|
||
wal_entry_hdr_t hdr;
|
||
ssize_t hn = pread(fd, &hdr, sizeof(hdr), (off_t)seg_off);
|
||
if (hn == 0)
|
||
break; /* clean end of segment (CLEAN) */
|
||
if (hn < (ssize_t)sizeof(hdr)) {
|
||
/* A partial header (or read error) is a SHORT READ — the log
|
||
* stops mid-entry, so it cannot be reconstructed contiguously
|
||
* (RECOVER_TRUNCATED, Req 2.1). Distinct from clean EOF above. */
|
||
if (hn < 0)
|
||
WARN("persist_recover: read error in %s at offset %u: %s",
|
||
seg_path, seg_off, strerror(errno));
|
||
else
|
||
WARN("persist_recover: short header read in %s at offset %u "
|
||
"(got %zd of %zu bytes); stopping replay",
|
||
seg_path, seg_off, hn, sizeof(hdr));
|
||
if (verdict_out)
|
||
*verdict_out = RECOVER_TRUNCATED;
|
||
break;
|
||
}
|
||
|
||
/* Detect pre-allocated zero region (no valid entry starts with
|
||
* all-zero header: lsn == 0 && type == 0 && payload_len == 0).
|
||
* This is the normal fallocate'd tail after the last valid entry —
|
||
* a clean stop, NOT corruption (stays RECOVER_CLEAN). */
|
||
if (hdr.lsn == 0 && hdr.type == 0 && hdr.payload_len == 0)
|
||
break;
|
||
|
||
/* CRC chain validation: prev_crc must match last_crc in segment */
|
||
if (seg_off == 0) {
|
||
/* First entry in this segment: prev_crc must be WAL_CRC_NONE
|
||
* (or we accept any value if last_crc is also WAL_CRC_NONE,
|
||
* e.g. across a segment boundary after checkpoint trim). */
|
||
if (hdr.prev_crc != WAL_CRC_NONE && last_crc != WAL_CRC_NONE &&
|
||
hdr.prev_crc != last_crc) {
|
||
WARN("persist_recover: CRC chain break at start of %s "
|
||
"(prev_crc=%08x expected=%08x); stopping replay",
|
||
seg_path, hdr.prev_crc, last_crc);
|
||
if (verdict_out)
|
||
*verdict_out = RECOVER_TRUNCATED;
|
||
break;
|
||
}
|
||
} else {
|
||
if (hdr.prev_crc != last_crc) {
|
||
WARN("persist_recover: CRC chain break in %s at offset %u "
|
||
"(prev_crc=%08x expected=%08x); stopping replay at last good entry",
|
||
seg_path, seg_off, hdr.prev_crc, last_crc);
|
||
if (verdict_out)
|
||
*verdict_out = RECOVER_TRUNCATED;
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* Verify entry CRC */
|
||
uint32_t expected_crc = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||
|
||
uint8_t* payload = NULL;
|
||
if (hdr.payload_len > 0) {
|
||
payload = malloc(hdr.payload_len);
|
||
if (!payload) {
|
||
close(fd);
|
||
return -ENOMEM;
|
||
}
|
||
ssize_t pn = pread(fd, payload, hdr.payload_len,
|
||
(off_t)(seg_off + sizeof(hdr)));
|
||
if (pn != (ssize_t)hdr.payload_len) {
|
||
WARN("persist_recover: short payload read in %s at offset %u",
|
||
seg_path, seg_off);
|
||
if (verdict_out)
|
||
*verdict_out = RECOVER_TRUNCATED;
|
||
free(payload);
|
||
break;
|
||
}
|
||
expected_crc = crc32(expected_crc, (const Bytef*)payload, hdr.payload_len);
|
||
}
|
||
|
||
if (hdr.crc != expected_crc) {
|
||
WARN("persist_recover: CRC mismatch in %s at offset %u "
|
||
"(got=%08x expected=%08x); stopping replay",
|
||
seg_path, seg_off, hdr.crc, expected_crc);
|
||
if (verdict_out)
|
||
*verdict_out = RECOVER_TRUNCATED;
|
||
free(payload);
|
||
break;
|
||
}
|
||
|
||
/* Advance segment-local CRC */
|
||
last_crc = hdr.crc;
|
||
|
||
/* Record this entry's term at its LSN for recovery read-back
|
||
* (ha-term-safety 1.3, R1.5). Record EVERY CRC-validated logical
|
||
* entry — including snapshot-covered ones below from_lsn — so the
|
||
* index spans the full LSN range the reconcile/promotion logic may
|
||
* query. EXCLUDE WAL_CHECKPOINT markers (type == WAL_CHECKPOINT) and
|
||
* unknown future types (type > WAL_CHECKPOINT): they are skipped
|
||
* below and are never logical Raft entries, so the live append path
|
||
* does not record them either (kept symmetric). The raw hdr.term is
|
||
* stored verbatim (legacy 0 included); the legacy-0 -> WAL_LEGACY_TERM
|
||
* mapping is applied only at read-back. */
|
||
if (hdr.type < WAL_CHECKPOINT)
|
||
record_entry_term(p, hdr.lsn, hdr.term);
|
||
|
||
/* Skip entries already covered by the snapshot */
|
||
if (hdr.lsn <= from_lsn) {
|
||
free(payload);
|
||
seg_off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
continue;
|
||
}
|
||
|
||
/* Skip WAL_CHECKPOINT markers (local bookkeeping only) */
|
||
if (hdr.type == WAL_CHECKPOINT) {
|
||
free(payload);
|
||
seg_off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
/* Advance current_lsn past the marker */
|
||
if (hdr.lsn + 1 > p->current_lsn)
|
||
p->current_lsn = hdr.lsn + 1;
|
||
continue;
|
||
}
|
||
|
||
/* Unknown future entry types: log and skip */
|
||
if (hdr.type > WAL_CHECKPOINT) {
|
||
WARN("persist_recover: unknown WAL entry type %u at LSN %lu; skipping",
|
||
hdr.type, (unsigned long)hdr.lsn);
|
||
free(payload);
|
||
seg_off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
continue;
|
||
}
|
||
|
||
/* Logical entity restore via the shared apply seam
|
||
* (raft-apply-decoupling task 2.3, design.md "Apply Seam"): the
|
||
* SAME apply_committed_entry() dispatch the live commit-advance
|
||
* apply pump will use (task 6.1), so a node's materialized state
|
||
* is the deterministic result of its committed log regardless of
|
||
* whether it got there via restart replay or live apply
|
||
* (Req 3.1-3.4). apply_wal_entry() (this replay loop's previous
|
||
* per-entry dispatch) is NOT retired by this change — it remains
|
||
* the live standby catch-up/replicated-apply dispatch used
|
||
* elsewhere (ha.c); it is simply no longer this loop's call. The
|
||
* CRC chain + current_lsn remain owned by this replay loop
|
||
* (current_lsn advanced below; last_crc at line ~1601) — neither
|
||
* dispatch function ever touched them (bug-778).
|
||
*
|
||
* KNOWN HAZARD (tracked in tasks.md Implementation Notes, task
|
||
* 2.3): this replays the FULL post-checkpoint WAL range on every
|
||
* restart (last_applied_lsn is not persisted, deliberately
|
||
* recomputed here), so apply_committed_entry()'s AFTER_EVENT
|
||
* hooks fire again for entries already applied+notified in a
|
||
* prior process life. Confirmed inert today: scheduler hooks
|
||
* aren't registered until a post-boot network JOIN
|
||
* (on_scheduler_join, sched.c), and apiserver hooks — though
|
||
* registered before this runs — gate their notify on a
|
||
* connected endpoint that is empty at first-boot replay time.
|
||
* MUST be re-verified before tasks 4.1/6.1/6.2/6.3 make this
|
||
* path broadly live. */
|
||
int apply_rc = apply_committed_entry(p, hdr.type, hdr.lsn, payload,
|
||
hdr.payload_len);
|
||
free(payload);
|
||
|
||
/* raft-apply-decoupling task 5.1: track the replay-derived
|
||
* LOGICAL apply cursor separately from current_lsn (the physical
|
||
* WAL position owned by this loop, bug-778) — last_applied_lsn is
|
||
* the "highest entry actually dispatched via
|
||
* apply_committed_entry()" position the post-replay verification
|
||
* compares against. Segments are scanned in ascending LSN order
|
||
* (sorted above) and entries within a segment are sequential, so
|
||
* a plain store (no monotonic max needed) is correct here. */
|
||
atomic_store_explicit(&p->last_applied_lsn, hdr.lsn,
|
||
memory_order_relaxed);
|
||
|
||
/* Log-derived half of the post-replay apply-correctness
|
||
* expectation (Req 8.1): every WAL_JOB_SUBMIT that reaches this
|
||
* point is, by construction, above the loaded snapshot's
|
||
* checkpoint_lsn (the from_lsn skip above already filtered out
|
||
* anything the snapshot covers) — so it names a job that must now
|
||
* exist in the live job table that did not exist before this
|
||
* replay pass began. A duplicate WAL_JOB_SUBMIT for an
|
||
* already-existing jobid (bug-1419: timeout-then-late-commit
|
||
* retry) is a legitimate no-op — apply_committed_entry() returns
|
||
* 0 for it, not 1 — so it must NOT be tallied here or the
|
||
* post-replay comparison against jobmgr's real growth spuriously
|
||
* fails (APPLY_DIVERGENT). */
|
||
if (hdr.type == WAL_JOB_SUBMIT && new_job_submits_out &&
|
||
apply_rc == 1)
|
||
(*new_job_submits_out)++;
|
||
|
||
/* job-array-single-wal-record task 6.0a: the array analog of the
|
||
* WAL_JOB_SUBMIT tally immediately above. apply_committed_entry()'s
|
||
* WAL_JOB_ARRAY_SUBMIT case (restore_job_array()'s return,
|
||
* generalized to N elements) returns apply_rc == the genuine
|
||
* count of NEWLY created elements (1..array_size) on a real
|
||
* apply, or 0 for an idempotent whole-record re-apply (every
|
||
* index already existed -- restore_job_array() returned
|
||
* -EEXIST, which this same dispatch maps to 0, mirroring
|
||
* WAL_JOB_SUBMIT's own dedup return). Tallying apply_rc (not a
|
||
* flat +1) is required here, unlike the single-job case above,
|
||
* because one WAL_JOB_ARRAY_SUBMIT entry can materialize many
|
||
* jobmgr entries at once; guarding on apply_rc > 0 excludes the
|
||
* idempotent 0 case from double-counting a re-apply, exactly as
|
||
* apply_rc == 1 excludes WAL_JOB_SUBMIT's duplicate-jobid case
|
||
* (bug-1419) above. Without this branch, replay_wal() silently
|
||
* undercounts every array record's real N-element jobmgr growth
|
||
* to 0, and persist_verify_apply_correctness() then reports a
|
||
* false APPLY_DIVERGENT verdict that engages the PERMANENT,
|
||
* non-self-healing persist_recover_loud_fence_if_unrecoverable()
|
||
* fence (live-reproduced task 6.1 soak, 2026-07-15). */
|
||
if (hdr.type == WAL_JOB_ARRAY_SUBMIT && new_job_submits_out &&
|
||
apply_rc > 0)
|
||
(*new_job_submits_out) += (uint64_t)apply_rc;
|
||
|
||
/* Advance current_lsn to one past this entry */
|
||
if (hdr.lsn + 1 > p->current_lsn)
|
||
p->current_lsn = hdr.lsn + 1;
|
||
|
||
replayed++;
|
||
seg_off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
}
|
||
|
||
close(fd);
|
||
}
|
||
|
||
/* Update last_crc so the next WAL write chains correctly */
|
||
pthread_mutex_lock(&p->lock);
|
||
p->last_crc = last_crc;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
return replayed;
|
||
}
|
||
|
||
/* raft-apply-decoupling task 5.1: cheap read-lock count of c->jobmgr's live
|
||
* entries, mirroring collect_job_frames' iteration idiom (this file, above)
|
||
* without collecting or filtering anything. Used only to derive the
|
||
* post-replay materialized-entity expectation (never on a hot path). Safe on
|
||
* a NULL cluster or jobmgr (a bare unit-test persist_t) — returns 0. */
|
||
static uint64_t count_jobmgr_entries(cluster_t* c)
|
||
{
|
||
if (!c || !c->jobmgr)
|
||
return 0;
|
||
|
||
lock(c->jobmgr, RTTI_LOCK_READ);
|
||
|
||
iterator_t it;
|
||
int irc = iter_make(&it, c->jobmgr);
|
||
if (irc < 0) {
|
||
unlock(c->jobmgr);
|
||
return 0; /* empty table */
|
||
}
|
||
|
||
uint64_t n = 0;
|
||
for (;;) {
|
||
job_t* j = NULL;
|
||
int nrc = iter_next(&it, (void**)&j);
|
||
if (nrc < 0 || nrc == RTTI_ITER_NO_DATA)
|
||
break;
|
||
if (j == NULL)
|
||
continue;
|
||
n++;
|
||
}
|
||
|
||
iter_destroy(&it);
|
||
unlock(c->jobmgr);
|
||
return n;
|
||
}
|
||
|
||
/* persist_verify_apply_correctness — see persist.h for the full contract
|
||
* doc (raft-apply-decoupling task 5.1, Req 8.1/8.2). Pure comparison: no
|
||
* logging, no fence side effects. */
|
||
apply_verdict_t persist_verify_apply_correctness(
|
||
persist_t* p, const apply_expectation_t* exp,
|
||
uint64_t jobmgr_count_before_replay)
|
||
{
|
||
if (!p || !exp)
|
||
return APPLY_CLEAN; /* defensive: nothing to verify */
|
||
|
||
uint64_t actual_last_applied =
|
||
atomic_load_explicit(&p->last_applied_lsn, memory_order_acquire);
|
||
if (actual_last_applied != exp->expected_last_applied_lsn)
|
||
return APPLY_DIVERGENT;
|
||
|
||
/* Entity-materialization half of the check: skip gracefully when there
|
||
* is no live cluster to query — mirrors apply_committed_entry()'s own
|
||
* "nothing to dispatch into" convention for a bare unit-test persist_t.
|
||
* Only the position check above applies in that case. */
|
||
if (!p->cluster || !p->cluster->jobmgr)
|
||
return APPLY_CLEAN;
|
||
|
||
uint64_t jobmgr_count_after = count_jobmgr_entries(p->cluster);
|
||
if (jobmgr_count_after < jobmgr_count_before_replay)
|
||
return APPLY_DIVERGENT; /* table shrank — impossible on a clean replay */
|
||
|
||
uint64_t actual_new_jobs = jobmgr_count_after - jobmgr_count_before_replay;
|
||
if (actual_new_jobs != exp->expected_new_job_count)
|
||
return APPLY_DIVERGENT;
|
||
|
||
return APPLY_CLEAN;
|
||
}
|
||
|
||
apply_verdict_t persist_apply_verdict(persist_t* p)
|
||
{
|
||
return p ? p->apply_verdict : APPLY_CLEAN;
|
||
}
|
||
|
||
uint64_t persist_last_applied_lsn(persist_t* p)
|
||
{
|
||
return p ? atomic_load_explicit(&p->last_applied_lsn, memory_order_acquire)
|
||
: 0;
|
||
}
|
||
|
||
/* bug-1416 (raft-apply-decoupling task 7.2 round 2) -- see persist.h's
|
||
* doc comment on the recovery_complete field for the full contract. */
|
||
bool persist_recovery_complete(persist_t* p)
|
||
{
|
||
return !p || atomic_load_explicit(&p->recovery_complete,
|
||
memory_order_acquire);
|
||
}
|
||
|
||
/* bug-1416 round 2 -- see persist.h's doc comment on this function for the
|
||
* full rationale (closes the gap between persist_init() and the FIRST
|
||
* persist_recover() call, which persist_recover()'s own false/true bracket
|
||
* cannot see). */
|
||
void persist_mark_recovery_pending(persist_t* p)
|
||
{
|
||
if (!p)
|
||
return;
|
||
atomic_store_explicit(&p->recovery_complete, false, memory_order_release);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Public: persist_recover */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
int persist_recover(persist_t* p)
|
||
{
|
||
/* bug-1416 (raft-apply-decoupling task 7.2 round 2): close the boot-time
|
||
* race window for the FULL duration of this call, not just the position
|
||
* check inside persist_verify_apply_correctness -- see the
|
||
* recovery_complete field's doc comment (persist.h) for the complete
|
||
* rationale. Held false until the natural (success) return at the
|
||
* bottom of this function, which restores it and issues one deliberate
|
||
* catch-up kick. The two hard-I/O-error early returns below
|
||
* (load_snapshot / replay_wal failure) deliberately do NOT restore it:
|
||
* those are pre-existing, rare disk-I/O-level failures the caller
|
||
* already treats as fatal to this boot attempt (cluster_init_phase2
|
||
* tears down persist_t on a nonzero return) -- leaving the gate closed
|
||
* is the conservative, fail-static choice (never let the live apply
|
||
* pump or live-replication path mutate state on top of a persist_t
|
||
* whose own recovery never finished), not a new hazard. */
|
||
atomic_store_explicit(&p->recovery_complete, false, memory_order_release);
|
||
|
||
/* raft-apply-decoupling task 7.4 perf-fix (round 2): invalidate the apply
|
||
* pump's segment-lookup cache before replay -- the cached fd/offset are
|
||
* only valid relative to the pre-recovery on-disk state, and replay below
|
||
* can change checkpoint_lsn/current_lsn. Doing this first covers every
|
||
* return path below (including the early I/O-failure returns), so the
|
||
* first post-recovery pump kick always falls back to full segment
|
||
* rediscovery from the correct, recovered position -- never trusting a
|
||
* stale pre-recovery cursor. Wrapped in apply_pump_lock (matching
|
||
* persist_truncate_wal_above's identical invalidation site below) so the
|
||
* cache fields' documented invariant -- "guarded ENTIRELY by
|
||
* apply_pump_lock, never p->lock" (persist.h) -- holds unconditionally
|
||
* here too, rather than resting on the implicit fact that no live pump
|
||
* walk can be racing this boot-time call. No caller of persist_recover()
|
||
* holds apply_pump_lock (its sole production call site, cluster.c boot
|
||
* sequence, runs before the pump is ever kicked), so this cannot
|
||
* deadlock. */
|
||
pthread_mutex_lock(&p->apply_pump_lock);
|
||
if (p->apply_pump_cache_fd >= 0) {
|
||
close(p->apply_pump_cache_fd);
|
||
p->apply_pump_cache_fd = -1;
|
||
}
|
||
pthread_mutex_unlock(&p->apply_pump_lock);
|
||
|
||
/* --- Step 1: find latest snapshot (highest LSN filename) --- */
|
||
char snap_file[4096] = "";
|
||
uint64_t snap_lsn = 0;
|
||
|
||
DIR* d = opendir(p->snap_path);
|
||
if (d) {
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL) {
|
||
size_t len = strlen(e->d_name);
|
||
if (len < 6 || strcmp(e->d_name + len - 6, ".capnp") != 0)
|
||
continue;
|
||
uint64_t lsn = 0;
|
||
if (sscanf(e->d_name, "snap-%" SCNx64 ".capnp", &lsn) != 1)
|
||
continue;
|
||
if (lsn > snap_lsn) {
|
||
snap_lsn = lsn;
|
||
snprintf(snap_file, sizeof(snap_file),
|
||
"%s/%s", p->snap_path, e->d_name);
|
||
}
|
||
}
|
||
closedir(d);
|
||
}
|
||
|
||
/* --- Step 2: load snapshot (if any) --- */
|
||
if (snap_lsn > 0 && snap_file[0] != '\0') {
|
||
/* Local recovery doesn't need the entities-restored indicator to
|
||
* decide behavior: the WAL replay in Step 3 applies everything
|
||
* above checkpoint_lsn regardless, unchanged by ha-entity-snapshots.
|
||
* It IS consumed for observability parity with the wire receive
|
||
* path (ha_handle_snapshot_chunk, task 6.1) — a rolling-upgrade
|
||
* restart against a pre-feature (legacy, position-only) local
|
||
* checkpoint is an expected rollout state, not an error. */
|
||
bool entities_restored = false;
|
||
int rc = load_snapshot(p, snap_file, snap_lsn, &entities_restored);
|
||
if (rc != 0) {
|
||
ERROR("persist_recover: failed to load snapshot %s: %s",
|
||
snap_file, strerror(-rc));
|
||
return rc;
|
||
}
|
||
/* checkpoint_lsn and current_lsn set by load_snapshot */
|
||
if (entities_restored) {
|
||
INFO("persist_recover: snapshot %s loaded lsn=%" PRIu64
|
||
" with full job/queue entity restoration", snap_file,
|
||
p->checkpoint_lsn);
|
||
} else {
|
||
INFO("persist_recover: snapshot %s lsn=%" PRIu64 " is a legacy "
|
||
"position-only checkpoint (no entity content) — continuing "
|
||
"via normal WAL replay", snap_file, p->checkpoint_lsn);
|
||
}
|
||
}
|
||
|
||
/* raft-apply-decoupling task 5.1 (Req 8.1): seed the replay-derived
|
||
* apply cursor from the loaded baseline (checkpoint_lsn stays 0 when no
|
||
* snapshot was found) so that, on a CLEAN replay with no new entries
|
||
* beyond the snapshot, the cursor already sits at the recovered tip —
|
||
* mirroring how current_lsn is seeded by load_snapshot. Step 3's
|
||
* replay_wal advances it further for every entry actually dispatched
|
||
* above this baseline. Also capture the live job table's size BEFORE
|
||
* replay runs: the post-replay verification below needs this baseline to
|
||
* isolate the jobs replay itself materializes from anything the snapshot
|
||
* already restored. */
|
||
atomic_store_explicit(&p->last_applied_lsn, p->checkpoint_lsn,
|
||
memory_order_release);
|
||
uint64_t jobmgr_count_before_replay = count_jobmgr_entries(p->cluster);
|
||
|
||
/* --- Step 3: replay WAL entries after checkpoint_lsn --- */
|
||
/* Recovery-integrity verdict (ha-commit-durability task 3.1, Req 2.1):
|
||
* replay distinguishes a clean log end from a corruption-truncated end.
|
||
* We CLASSIFY + record the verdict here; we do NOT FATAL (task 3.3) and do
|
||
* NOT fence promotion (task 4.3 consumes p->recovery_verdict). A clean
|
||
* replay over a loaded snapshot-sentinel (no entries below the sentinel) is
|
||
* CLEAN — the sentinel established the committed position; replay only adds
|
||
* entries above it. */
|
||
recover_verdict_t verdict = RECOVER_CLEAN;
|
||
uint64_t new_job_submits = 0;
|
||
int replayed = replay_wal(p, p->checkpoint_lsn, &verdict, &new_job_submits);
|
||
if (replayed < 0)
|
||
return replayed;
|
||
|
||
p->recovery_verdict = verdict;
|
||
if (verdict == RECOVER_TRUNCATED)
|
||
WARN("persist_recover: RECOVER_TRUNCATED — local WAL is structurally "
|
||
"corrupt (CRC break / mismatch / short read); member marked "
|
||
"recovery-truncated (non-promotable until caught up)");
|
||
|
||
/* --- Step 3b: re-derive the durable floor from the recovered WAL tip
|
||
* (ha-commit-durability task 3.4, Req 2.3) ---
|
||
*
|
||
* Publish the member's durable floor (the durable last-log index consumed
|
||
* by the Raft freshness gate, task 4.2) as the highest INTACT replayed LSN.
|
||
* replay_wal advanced current_lsn to one past the last intact entry — incl.
|
||
* stopping at a corruption boundary on TRUNCATED — so current_lsn - 1 is
|
||
* exactly that intact WAL tip, and a snapshot-sentinel load leaves it at the
|
||
* sentinel/replayed tip. We store it into durable_lsn because the recovered
|
||
* on-disk entries are GENUINELY durable (each was CRC-validated and read
|
||
* from stable storage by replay_wal), so the freshness advertisement is now
|
||
* backed by durable state rather than a volatile in-memory position.
|
||
*
|
||
* Crux (Req 2.3 — a truncated member cannot over-advertise): this is an
|
||
* ASSIGN to the recovered tip, NOT a max() with whatever durable_lsn held.
|
||
* A fresh recover starts from durable_lsn=0 (persist_init), so on a
|
||
* truncating restart the floor lands at the LOWER intact tip — never a
|
||
* stale higher in-memory value and never the commit position
|
||
* (committed_lsn). The recovered tip is, by construction, <= current_lsn-1,
|
||
* preserving the durable_lsn <= current_lsn-1 invariant. The post-election
|
||
* safe-commit point continues to come from voter replies, not this floor
|
||
* (CLAUDE.md). */
|
||
uint64_t recovered_tip = (p->current_lsn > 0) ? p->current_lsn - 1 : 0;
|
||
atomic_store_explicit(&p->durable_lsn, recovered_tip, memory_order_release);
|
||
|
||
/* raft-apply-decoupling task 5.1 (Req 8.1/8.2): post-replay apply-
|
||
* correctness verification, reported ALONGSIDE the structural
|
||
* recovery_verdict set above — neither implies the other. See
|
||
* persist_verify_apply_correctness's doc comment for exactly what is
|
||
* compared. Routing an APPLY_DIVERGENT verdict into the existing
|
||
* non-promotable loud-fence mechanism is task 5.2; this only classifies
|
||
* and logs.
|
||
*
|
||
* Round-2 fix: the expected position MUST be the LOGICAL apply cursor
|
||
* replay_wal() already maintains in p->last_applied_lsn — NOT
|
||
* recovered_tip (current_lsn - 1, the PHYSICAL WAL-scan tip used above
|
||
* for durable_lsn). current_lsn advances for every WAL entry replay_wal
|
||
* scans, including WAL_CHECKPOINT markers and unknown future types,
|
||
* neither of which is ever routed through apply_committed_entry() (see
|
||
* the skip branches in replay_wal). last_applied_lsn only advances when
|
||
* a real dispatch happens. If the highest-LSN entry above checkpoint_lsn
|
||
* is a trailing WAL_CHECKPOINT marker — the exact state left behind by a
|
||
* crash between persist_write_checkpoint_marker() (step 1, durably
|
||
* fsynced) and write_snapshot() (step 3b, persists the advanced
|
||
* checkpoint_lsn) — recovered_tip over-counts past it while
|
||
* last_applied_lsn correctly does not, and comparing against
|
||
* recovered_tip previously produced a false APPLY_DIVERGENT for a
|
||
* perfectly clean recovery (violates Req 8.2). */
|
||
uint64_t apply_cursor =
|
||
atomic_load_explicit(&p->last_applied_lsn, memory_order_acquire);
|
||
apply_expectation_t apply_exp = {
|
||
.expected_last_applied_lsn = apply_cursor,
|
||
.expected_new_job_count = new_job_submits,
|
||
};
|
||
p->apply_verdict = persist_verify_apply_correctness(
|
||
p, &apply_exp, jobmgr_count_before_replay);
|
||
if (p->apply_verdict == APPLY_DIVERGENT) {
|
||
ERROR("persist_recover: APPLY_DIVERGENT — materialized state does "
|
||
"not match the committed log tip (expected last_applied_lsn=%"
|
||
PRIu64 " new_job_count=%" PRIu64 "); a hidden apply defect is "
|
||
"present (ha-commit-durability recover_verdict=%s)",
|
||
apply_exp.expected_last_applied_lsn,
|
||
apply_exp.expected_new_job_count,
|
||
(verdict == RECOVER_TRUNCATED) ? "RECOVER_TRUNCATED"
|
||
: "RECOVER_CLEAN");
|
||
|
||
/* raft-apply-decoupling task 5.2 (Req 8.3/8.4): route the DIVERGENT
|
||
* verdict through the EXISTING non-promotable loud-fence mechanism
|
||
* (persist_recover_loud_fence_if_unrecoverable + the sticky
|
||
* recovery_unrecoverable flag) instead of a new fence path.
|
||
* apply_divergent=true fences UNCONDITIONALLY (see
|
||
* persist_recover_decision), so the peer/HWM fields below are
|
||
* inert placeholders here -- they are not yet known at this
|
||
* boot-time replay call site (unlike the structural verdict's own
|
||
* fence check, which stays deferred to promotion time in ha.c,
|
||
* where live peer/HWM context exists). verdict/recovered_tip are
|
||
* passed through only for accurate, consistent logging. The member
|
||
* stays ALIVE, non-promotable, and keeps retrying catch-up -- it
|
||
* never exits (controller-never-self-terminate invariant,
|
||
* CLAUDE.md). */
|
||
recover_policy_inputs_t apply_fence_in = {
|
||
.verdict = verdict,
|
||
.durable_below_hwm = false,
|
||
.healthy_peer_reachable = false,
|
||
.catch_up_succeeded = false,
|
||
.durable_floor_lsn = recovered_tip,
|
||
.cluster_hwm_lsn = 0,
|
||
.apply_divergent = true,
|
||
};
|
||
persist_recover_loud_fence_if_unrecoverable(p, &apply_fence_in);
|
||
} else
|
||
INFO("persist_recover: APPLY_CLEAN — materialized state matches the "
|
||
"committed log tip (last_applied_lsn=%" PRIu64 ")",
|
||
apply_cursor);
|
||
|
||
/* --- Step 4: retention pass (TTL check) — no-op in P0 (no entities) --- */
|
||
|
||
/* bug-1416: replay + verification is fully complete -- safe to let the
|
||
* live apply pump and the standby live-replication apply path resume
|
||
* mutating state (see the recovery_complete field's doc comment).
|
||
* Restored unconditionally here regardless of the verdict computed
|
||
* above: a genuine APPLY_DIVERGENT already fenced this member via
|
||
* persist_recover_loud_fence_if_unrecoverable (non-promotable, stays
|
||
* alive, keeps retrying) -- that sticky fence and its clearing logic
|
||
* (task 5.3) are untouched by this flag, which governs ONLY whether
|
||
* in-memory apply may proceed, not whether this member may lead. Issue
|
||
* one deliberate catch-up kick for anything that committed locally (or
|
||
* arrived via live replication and was durably persisted but had its
|
||
* entity-apply deferred, see ha_apply_single_entry / ha_apply_term_
|
||
* entry_via_library) while this pass ran -- persist_apply_pump_run's
|
||
* own walk drains fully from last_applied_lsn+1 through the current
|
||
* committed_lsn, so this is a complete, ordered catch-up, not a partial
|
||
* one; a harmless no-op if nothing queued. */
|
||
atomic_store_explicit(&p->recovery_complete, true, memory_order_release);
|
||
/* n_vcpus==0 is this codebase's own established "not yet initialized"
|
||
* sentinel for vtaskmgr_t (mirrors cluster_init_phase2's own
|
||
* `if (c->vtaskmgr.n_vcpus == 0) vtaskmgr_init(...)` guard) -- a real
|
||
* cluster always has n_vcpus > 0 by the time persist_recover() runs
|
||
* (vtaskmgr_init happens earlier in myapp_run()/phase2), while a bare
|
||
* recovery-focused unit-test cluster_t (none of which call
|
||
* vtaskmgr_init, confirmed by inspection) leaves it zeroed. Passing a
|
||
* pointer to an uninitialized vtaskmgr_t into vtask_enq_classified
|
||
* would be unsafe, so fall back to persist_apply_pump_kick's own
|
||
* existing, already-exercised NULL-pool inline path in that case --
|
||
* identical to how every apply-pump test fixture in this spec already
|
||
* exercises the pump. */
|
||
void* pool = (p->cluster && p->cluster->vtaskmgr.n_vcpus > 0)
|
||
? &p->cluster->vtaskmgr
|
||
: NULL;
|
||
persist_apply_pump_kick(p, pool);
|
||
|
||
return 0;
|
||
}
|
||
|
||
/* Recovery-integrity accessors (ha-commit-durability task 3.1, Req 2.1). */
|
||
recover_verdict_t persist_recovery_verdict(persist_t* p)
|
||
{
|
||
return p ? p->recovery_verdict : RECOVER_CLEAN;
|
||
}
|
||
|
||
bool persist_recovery_truncated(persist_t* p)
|
||
{
|
||
return p && p->recovery_verdict == RECOVER_TRUNCATED;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Boot-policy loud-fence decision (ha-commit-durability task 3.3, Req 2.4;
|
||
* never-self-exit rework 2026-06-18).
|
||
*
|
||
* Implements the FENCE_UNRECOVERABLE terminal of the Boot Policy Decision Table
|
||
* (design.md). The decision is a PURE function of its inputs; the action (loud
|
||
* operator-visible log + set the sticky non-promotable flag) is separated into
|
||
* persist_recover_loud_fence_if_unrecoverable so the decision can be unit-tested
|
||
* in isolation. A reachable healthy peer NEVER yields FENCE_UNRECOVERABLE
|
||
* (rows 3, 5); it is reserved for a member that cannot recover locally AND
|
||
* cannot catch up (no healthy peer, rows 4, 4b).
|
||
*
|
||
* CRITICAL: this terminal is a LOUD, PERMANENT, NON-promotable fence — the
|
||
* process STAYS ALIVE and keeps retrying catch-up. There is NO abort()/exit():
|
||
* a controller MUST NEVER self-terminate as a designed response to an
|
||
* operational condition (user invariant, 2026-06-18). A self-exited controller
|
||
* needs manual admin recovery and degrades quorum meanwhile; one that stays
|
||
* alive and keeps retrying self-heals when a peer returns (Req 2.6).
|
||
* ------------------------------------------------------------------ */
|
||
|
||
recover_decision_t persist_recover_decision(const recover_policy_inputs_t* in)
|
||
{
|
||
if (in == NULL)
|
||
return RECOVER_DECISION_ELIGIBLE; /* defensive: nothing to fence */
|
||
|
||
/* raft-apply-decoupling task 5.2 (Req 8.3/8.4): an APPLY_DIVERGENT
|
||
* post-replay verdict is a NEW INPUT to this same decision table, not a
|
||
* new fence mechanism (design.md "Restart Replay & Verification"). It
|
||
* fences UNCONDITIONALLY -- every other field below is irrelevant once
|
||
* this is true. Unlike a structurally truncated/behind log (rows 2/3/5),
|
||
* where a healthy peer's missing entries can repair the gap and legitimately
|
||
* downgrade the outcome to a plain FENCE-then-catch-up, an apply-time
|
||
* defect means the LOCAL apply path itself produced the wrong
|
||
* materialized state from bytes it already had -- streaming more entries
|
||
* from a peer does not prove that path is now trustworthy, so this is
|
||
* never downgraded by peer reachability or catch-up success. */
|
||
if (in->apply_divergent)
|
||
return RECOVER_DECISION_FENCE_UNRECOVERABLE;
|
||
|
||
bool truncated = (in->verdict == RECOVER_TRUNCATED);
|
||
|
||
/* Rows 1 & 6: a structurally CLEAN log that is NOT behind the cluster
|
||
* high-water mark is a normal promotable member — no fence. */
|
||
if (!truncated && !in->durable_below_hwm)
|
||
return RECOVER_DECISION_ELIGIBLE;
|
||
|
||
/* From here the member is truncated OR behind the HWM — it must NOT lead
|
||
* as-is. A truncated log is by definition not provably at/above the HWM
|
||
* (design.md note), so it is treated as behind for fence purposes. */
|
||
|
||
/* Rows 2, 3, 5: a healthy peer is reachable. Catch up from it and regain
|
||
* eligibility (task 4.3). A reachable peer NEVER yields the unrecoverable
|
||
* fence — even for a truncated log or a legitimate snapshot-sentinel. If
|
||
* catch-up has already succeeded the member is eligible; otherwise it stays
|
||
* fenced (4.3 drives the catch-up + release). */
|
||
if (in->healthy_peer_reachable) {
|
||
if (in->catch_up_succeeded)
|
||
return RECOVER_DECISION_ELIGIBLE;
|
||
return RECOVER_DECISION_FENCE;
|
||
}
|
||
|
||
/* No healthy peer. If catch-up did not reach a committed-consistent state,
|
||
* the member can neither recover locally nor catch up right now — rows 4 &
|
||
* 4b: a LOUD, PERMANENT, NON-promotable fence (NOT process exit). It stays
|
||
* alive and keeps retrying; the fence releases when a peer returns
|
||
* (Req 2.4/2.6). */
|
||
if (!in->catch_up_succeeded)
|
||
return RECOVER_DECISION_FENCE_UNRECOVERABLE;
|
||
|
||
/* No peer but catch-up is reported succeeded (e.g. already at/above after a
|
||
* local catch-up): eligible. */
|
||
return RECOVER_DECISION_ELIGIBLE;
|
||
}
|
||
|
||
bool persist_recovery_unrecoverable(persist_t* p)
|
||
{
|
||
return p && atomic_load_explicit(&p->recovery_unrecoverable,
|
||
memory_order_acquire);
|
||
}
|
||
|
||
void persist_clear_recovery_unrecoverable(persist_t* p)
|
||
{
|
||
if (!p)
|
||
return;
|
||
/* Clear only on a genuine transition so the loud "fence released" log fires
|
||
* once, not on every catch-up tick. */
|
||
if (atomic_exchange_explicit(&p->recovery_unrecoverable, false,
|
||
memory_order_acq_rel)) {
|
||
INFO("persist_recover: unrecoverable fence RELEASED — member reached a "
|
||
"committed-consistent state via catch-up; promotable again "
|
||
"(ha-commit-durability Req 2.6)");
|
||
}
|
||
}
|
||
|
||
/* job-array-single-wal-record task 4.2 (design.md "Live Apply Resource
|
||
* Fence", Req 9.4/9.5). See the apply_resource_fenced_lsn field's doc
|
||
* comment (persist.h) and this trio's own doc comment (persist.h) for the
|
||
* full rationale and the deliberate distinction from
|
||
* persist_recovery_unrecoverable/persist_clear_recovery_unrecoverable just
|
||
* above -- that boot-only mechanism is NOT reused here on purpose. */
|
||
bool persist_apply_resource_fenced(persist_t* p)
|
||
{
|
||
return p && atomic_load_explicit(&p->apply_resource_fenced_lsn,
|
||
memory_order_acquire) != 0;
|
||
}
|
||
|
||
void persist_mark_apply_resource_fenced(persist_t* p, uint64_t lsn)
|
||
{
|
||
if (!p)
|
||
return;
|
||
atomic_store_explicit(&p->apply_resource_fenced_lsn, lsn,
|
||
memory_order_release);
|
||
}
|
||
|
||
void persist_clear_apply_resource_fence(persist_t* p)
|
||
{
|
||
if (!p)
|
||
return;
|
||
/* Clear only on a genuine transition so the self-heal log fires once per
|
||
* episode, not on every ordinary successful apply (the overwhelming
|
||
* common case: nothing was fenced, so this is a cheap no-op). */
|
||
uint64_t prev = atomic_exchange_explicit(&p->apply_resource_fenced_lsn, 0,
|
||
memory_order_acq_rel);
|
||
if (prev != 0) {
|
||
INFO("persist_apply_pump_run: Live Apply Resource Fence RELEASED — "
|
||
"lsn=%" PRIu64 " applied successfully on retry; promotable "
|
||
"again (job-array-single-wal-record Req 9.5)",
|
||
prev);
|
||
}
|
||
}
|
||
|
||
recover_decision_t
|
||
persist_recover_loud_fence_if_unrecoverable(persist_t* p,
|
||
const recover_policy_inputs_t* in)
|
||
{
|
||
recover_decision_t decision = persist_recover_decision(in);
|
||
|
||
if (decision != RECOVER_DECISION_FENCE_UNRECOVERABLE)
|
||
return decision;
|
||
|
||
/* Loud, operator-visible alert. The two triggers reaching this terminal
|
||
* are NOT logged with the same clause (round-2 fix, raft-apply-
|
||
* decoupling task 5.2 remediation):
|
||
*
|
||
* - apply_divergent: fences UNCONDITIONALLY (persist_recover_decision),
|
||
* evaluated at boot-replay time BEFORE peer/HWM context exists (see
|
||
* the call site in persist_recover()). `healthy_peer_reachable` and
|
||
* `cluster_hwm_lsn` are inert placeholders there, not real facts —
|
||
* asserting "no healthy peer reachable" or a durable_floor_lsn < hwm
|
||
* comparison for this trigger would report unevaluated-or-false
|
||
* information to the operator (round-1 review: the placeholders can
|
||
* make the comparison numerically impossible, e.g. "2 < 0"). This
|
||
* branch names the real reason (apply-correctness divergence) only.
|
||
* - structural (truncated / behind-HWM): reaches this terminal only
|
||
* via the pre-existing "no healthy peer reachable" branch in
|
||
* persist_recover_decision, so the peer-reachability and
|
||
* durable_floor_lsn/hwm comparison ARE guaranteed true here — that
|
||
* clause is unchanged from before this task.
|
||
*/
|
||
if (in->apply_divergent) {
|
||
ERROR("persist_recover: UNRECOVERABLE — post-replay apply-correctness "
|
||
"verification is DIVERGENT (materialized state does not match "
|
||
"the committed log tip, raft-apply-decoupling Req 8.3); a local "
|
||
"apply defect cannot be proven fixed by peer reachability or "
|
||
"catch-up alone. Staying ALIVE and PERMANENTLY non-promotable "
|
||
"(refusing to serve divergent state) and RETRYING catch-up "
|
||
"indefinitely — operator attention is needed to diagnose the "
|
||
"apply path (raft-apply-decoupling Req 8.3/8.4)");
|
||
} else {
|
||
const char* why = (in->verdict == RECOVER_TRUNCATED)
|
||
? "recovery-truncated log (structural corruption)"
|
||
: "log behind the cluster committed high-water mark";
|
||
ERROR("persist_recover: UNRECOVERABLE — %s and no healthy peer reachable to "
|
||
"catch up; truncation boundary durable_floor_lsn=%" PRIu64
|
||
" < cluster committed high-water hwm=%" PRIu64 ". Staying ALIVE and "
|
||
"PERMANENTLY non-promotable (refusing to serve divergent state) and "
|
||
"RETRYING catch-up indefinitely — will self-heal when a healthy peer "
|
||
"returns; operator attention may be needed "
|
||
"(ha-commit-durability Req 2.4)",
|
||
why, in->durable_floor_lsn, in->cluster_hwm_lsn);
|
||
}
|
||
|
||
/* Set the sticky non-promotable flag so the promotion gate refuses
|
||
* leadership as belt-and-suspenders until catch-up clears it (Req 2.4/2.6).
|
||
* p may be NULL for pure-classification callers that only want the loud log. */
|
||
if (p != NULL)
|
||
atomic_store_explicit(&p->recovery_unrecoverable, true,
|
||
memory_order_release);
|
||
|
||
return RECOVER_DECISION_FENCE_UNRECOVERABLE;
|
||
}
|
||
|
||
/* Advance committed_lsn to `written_lsn` on a majority ACK — but ONLY if that
|
||
* entry is already durable on local stable storage (ha-commit-durability task
|
||
* 2.4, Req 1.1/1.2). This is the legacy (engine-less / LSN-0) sibling of the
|
||
* commit engine's durable self-cap (commit.c majority_candidate_locked): a
|
||
* majority ACK no longer implies the local copy is fsync'd, because group-
|
||
* commit (task 2.1) deferred the append fsync to the demand-driven flusher. So
|
||
* advancing committed_lsn here on the ACK alone would reintroduce the bug-661
|
||
* ACK-without-durability hazard — committed could be durable on a single
|
||
* member and lost across failover. We gate the store on written_lsn <=
|
||
* durable_lsn so committed_lsn ⇒ durable-on-this-member on every path.
|
||
* committed_lsn is monotonic, so a max() store; the durable cap can only delay
|
||
* the advance until the flusher syncs the entry (the durable-advance commit
|
||
* wake re-drives the engine path; the legacy path is re-evaluated on the next
|
||
* sync_append). */
|
||
static void sync_append_legacy_commit_store(persist_t* p, uint64_t written_lsn)
|
||
{
|
||
uint64_t durable = atomic_load_explicit(&p->durable_lsn,
|
||
memory_order_acquire);
|
||
/* DURABILITY PRECONDITION: never advance committed past what is durable
|
||
* locally. The LSN-0 first-entry edge stays valid (0 <= 0). */
|
||
if (written_lsn > durable)
|
||
return;
|
||
uint64_t cur = atomic_load_explicit(&p->committed_lsn,
|
||
memory_order_acquire);
|
||
if (written_lsn <= cur)
|
||
return; /* monotonic: committed_lsn never regresses */
|
||
atomic_store_explicit(&p->committed_lsn, written_lsn, memory_order_release);
|
||
#ifdef XCE_TESTING
|
||
/* INVARIANT (debug guard): committed_lsn must never exceed durable_lsn on
|
||
* any commit advance. A future caller reintroducing ACK-without-durability
|
||
* trips this. */
|
||
assert(atomic_load_explicit(&p->committed_lsn, memory_order_acquire)
|
||
<= atomic_load_explicit(&p->durable_lsn, memory_order_acquire));
|
||
#endif
|
||
}
|
||
|
||
int persist_wal_sync_append(persist_t* p, uint32_t type,
|
||
const void* payload, uint32_t len,
|
||
int* sync_rc_out)
|
||
{
|
||
/* ha-term-safety task 3.1: the primary's WAL_JOB_SUBMIT append is routed
|
||
* through the raft library (ha_primary_log_append → raft_log_append) so the
|
||
* library stamps the term + assigns the LSN (firing on_persist_entry → the
|
||
* WAL write), keeping p->current_lsn co-tracked with s->log_len; the existing
|
||
* majority-commit barrier below (repl_wait_majority_ack) is UNCHANGED and runs
|
||
* after the append. Non-raft road / engine-less tests (library not LEADER)
|
||
* fall back inside ha_primary_log_append to persist_wal_async. Standalone
|
||
* persist (p->cluster == NULL, lib-level tests) keeps the direct append. */
|
||
int rc;
|
||
if (p->cluster != NULL)
|
||
rc = ha_primary_log_append(p->cluster, type, payload, len, NULL);
|
||
else
|
||
rc = persist_wal_async(p, type, payload, len);
|
||
if (rc != 0) {
|
||
if (sync_rc_out)
|
||
*sync_rc_out = rc;
|
||
return rc;
|
||
}
|
||
|
||
/* Majority-commit replication (Req 5.1–5.3; ha-commit-engine Req 1.1–1.3).
|
||
* persist_wal_async appends to the page cache and signals the demand-driven
|
||
* flusher; it no longer fsyncs inline (group-commit, task 2.1). Durability
|
||
* is therefore established by the flusher advancing durable_lsn, and a
|
||
* commit position advances only once the entry is durable locally — the
|
||
* engine path via the durable self-cap (commit.c), the legacy path below
|
||
* via sync_append_legacy_commit_store's written_lsn <= durable_lsn gate.
|
||
* We use current_lsn - 1 because persist_wal_async already incremented it. */
|
||
uint64_t written_lsn;
|
||
pthread_mutex_lock(&p->lock);
|
||
written_lsn = p->current_lsn - 1;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
int sync_err;
|
||
/* Engine-wired path (ha-commit-engine task 5.3): delegate the commit
|
||
* decision to the commit engine through the walAck adapter. The engine
|
||
* is the single committed_lsn writer (no store here), and its
|
||
* n_peers<=1 self-quorum replaces the old has_standbys → -ENODEV
|
||
* degraded special case (Req 6.3).
|
||
*
|
||
* LSN-0 edge (ACCEPTED interim behavior — spec Implementation Notes):
|
||
* written_lsn is passed unconditionally, but the FIRST-EVER WAL entry
|
||
* of a fresh cluster is LSN 0 and commit_submit rejects lsn==0, so the
|
||
* adapter's entry_lsn != 0 guard routes that one entry to the legacy
|
||
* branch below. This happens once per cluster lifetime;
|
||
* ha-single-leader-role's promotion no-op entry eliminates it in
|
||
* practice. */
|
||
if (p->repl.ce != NULL && written_lsn != 0) {
|
||
sync_err = repl_wait_majority_ack(&p->repl, written_lsn,
|
||
p->config.sync_timeout_ms,
|
||
(uint8_t)OPCode_walAck);
|
||
if (sync_err != 0)
|
||
CTRL_METRIC_INC(ctrl_repl_degraded_count);
|
||
} else {
|
||
/* Residual non-engine branch: engine-less (ce == NULL) unit-test
|
||
* contexts plus the LSN-0 first entry. The legacy connectivity-
|
||
* based counting was retired by ha-commit-barrier-rollout task 2.2:
|
||
* repl_wait_majority_ack now confirms LSN 0 immediately (documented
|
||
* once-per-lifetime edge) and surfaces engine-less walAck waits as
|
||
* -EINVAL via commit_submit, so the committed_lsn store below only
|
||
* fires for the LSN-0 case (a no-op store of 0). */
|
||
mylock_lock(&p->repl.lock);
|
||
int has_standbys = (p->repl.nstandbys > 0);
|
||
mylock_unlock(&p->repl.lock);
|
||
|
||
if (!has_standbys) {
|
||
/* No standbys registered → degraded mode (no quorum to wait for) */
|
||
sync_err = -ENODEV;
|
||
} else {
|
||
sync_err = repl_wait_majority_ack(&p->repl, written_lsn,
|
||
p->config.sync_timeout_ms,
|
||
(uint8_t)OPCode_walAck);
|
||
if (sync_err == 0)
|
||
/* Durable-gated commit advance (task 2.4): advance committed_lsn
|
||
* only if written_lsn is durable locally — never on the ACK
|
||
* alone (group-commit removed the inline append fsync). */
|
||
sync_append_legacy_commit_store(p, written_lsn);
|
||
else
|
||
CTRL_METRIC_INC(ctrl_repl_degraded_count);
|
||
}
|
||
}
|
||
if (sync_rc_out)
|
||
*sync_rc_out = sync_err;
|
||
/* sync_err < 0 — replication failed.
|
||
* -ENODEV: legacy degraded mode (no standbys, engine-less) — acceptable
|
||
* for forward progress.
|
||
* -ETIMEDOUT: no per-entry majority within the deadline — the write
|
||
* must be rejected to preserve the majority-consistency
|
||
* invariant.
|
||
* -XCE_ERR_NOT_READY: engine admission cap exhausted — retriable
|
||
* rejection.
|
||
* Timeout and admission rejection propagate to the job-submit error
|
||
* path (ha-commit-engine Req 1.1, 4.4). */
|
||
if (sync_err == -ETIMEDOUT || sync_err == -XCE_ERR_NOT_READY)
|
||
return sync_err;
|
||
return 0;
|
||
}
|
||
|
||
#ifdef XCE_TESTING
|
||
/* Test seam (task 2.4): invoke the EXACT durable-gated commit store that the
|
||
* legacy sync_append branch uses on a majority ACK, so a test can prove the
|
||
* ACK-without-durability hazard is closed without standing up the replication
|
||
* wire. Pre-fix this branch stored committed_lsn = written_lsn unconditionally
|
||
* (over-advancing past durable_lsn). */
|
||
void persist_test_sync_append_legacy_commit_store(persist_t* p,
|
||
uint64_t written_lsn)
|
||
{
|
||
sync_append_legacy_commit_store(p, written_lsn);
|
||
}
|
||
#endif /* XCE_TESTING */
|
||
|
||
void persist_log_entry_metadata(const wal_entry_hdr_t* hdr)
|
||
{
|
||
TRACE("WAL entry: type=%u lsn=%lu term=%lu payload_len=%u crc=%08x",
|
||
hdr->type,
|
||
(unsigned long)hdr->lsn,
|
||
(unsigned long)hdr->term,
|
||
hdr->payload_len,
|
||
hdr->crc);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Hook functions — AFTER_EVENT handlers that write WAL entries */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/* Map HOOK_DETAIL_* values to WAL entry types.
|
||
* Returns 0 for details that have no WAL representation (skip). */
|
||
static uint32_t detail_to_wal_type(int detail)
|
||
{
|
||
switch (detail) {
|
||
case HOOK_DETAIL_JOB_NEW: return WAL_JOB_SUBMIT;
|
||
case HOOK_DETAIL_JOB_START: return WAL_JOB_START;
|
||
case HOOK_DETAIL_JOB_EXIT: return WAL_JOB_FINISH;
|
||
case HOOK_DETAIL_JOB_FAIL: return WAL_JOB_FAIL;
|
||
case HOOK_DETAIL_JOB_STOP: return WAL_JOB_STOP;
|
||
case HOOK_DETAIL_JOB_RESUME: return WAL_JOB_RESUME;
|
||
case HOOK_DETAIL_JOB_UPDATE: return WAL_JOB_UPDATE;
|
||
case HOOK_DETAIL_JOB_CKPT: return WAL_JOB_CKPT;
|
||
case HOOK_DETAIL_JOB_RUSAGE_UPDATE: return WAL_JOB_RUSAGE;
|
||
case HOOK_DETAIL_SETPREASON: return WAL_JOB_PREASON;
|
||
case HOOK_DETAIL_CLRPREASON: return WAL_JOB_PREASON;
|
||
case HOOK_DETAIL_QUEUE_UPDATE: return WAL_QUEUE_CTRL;
|
||
case HOOK_DETAIL_WORKER_JOIN: return WAL_WORKER_CTRL;
|
||
case HOOK_DETAIL_WORKER_UPDATE: return WAL_WORKER_CTRL;
|
||
case HOOK_DETAIL_WORKER_DEAD: return WAL_WORKER_CTRL;
|
||
default: return 0; /* unknown — skip */
|
||
}
|
||
}
|
||
|
||
/* Write-path commit classification (ha-commit-barrier-rollout task 1.2).
|
||
* Pure lookup over the VERIFIED table (design.md §Write-Path
|
||
* Classification); `detail` disambiguates WAL_WORKER_CTRL only.
|
||
* Unclassified types fail fast under XCE_TESTING (abort) and fall back to
|
||
* RETAIN in production — never dropped, never falsely confirmed (Req 2.2). */
|
||
wal_commit_mode_t wal_classify(uint32_t wal_type, int detail)
|
||
{
|
||
switch (wal_type) {
|
||
case WAL_JOB_SUBMIT:
|
||
/* client job submit — request-shaped; keeps the in-hook sync path
|
||
* (reference implementation, handle_jobsubmit) */
|
||
return WAL_MODE_WAIT;
|
||
case WAL_JOB_ARRAY_SUBMIT:
|
||
/* client job-array submit (job-array-single-wal-record task 1.1) —
|
||
* request-shaped exactly like WAL_JOB_SUBMIT: one requester awaits
|
||
* the single commit barrier before reply, regardless of array
|
||
* size. */
|
||
return WAL_MODE_WAIT;
|
||
case WAL_QUEUE_CTRL:
|
||
/* client queue control — request-shaped; the handler barrier owes
|
||
* commit completion before the requester reply (tasks 2-3) */
|
||
return WAL_MODE_WAIT;
|
||
case WAL_WORKER_CTRL:
|
||
/* the only detail-disambiguated type: JOIN/UPDATE have a requester
|
||
* awaiting confirmation (WAIT); DEAD is a controller-detected fact.
|
||
* Unexpected details take the fact-safe RETAIN side. */
|
||
if (detail == HOOK_DETAIL_WORKER_JOIN ||
|
||
detail == HOOK_DETAIL_WORKER_UPDATE)
|
||
return WAL_MODE_WAIT;
|
||
return WAL_MODE_RETAIN;
|
||
case WAL_JOB_START:
|
||
case WAL_JOB_FINISH:
|
||
case WAL_JOB_FAIL:
|
||
case WAL_JOB_STOP:
|
||
case WAL_JOB_RESUME:
|
||
case WAL_JOB_UPDATE:
|
||
case WAL_JOB_RUSAGE:
|
||
case WAL_JOB_CKPT:
|
||
case WAL_JOB_PREASON:
|
||
/* worker/scheduler-reported facts — retained until committed.
|
||
* FAIL/UPDATE are dual-origin; their request-shaped side is
|
||
* realized by the handler barrier in do_jobcontrol_op
|
||
* (research.md §2 call-site verification). */
|
||
return WAL_MODE_RETAIN;
|
||
case WAL_NOOP:
|
||
/* internal promotion no-op — replicated like any committed entry,
|
||
* retained until committed (ha-commit-barrier-rollout pre-declared
|
||
* row: promotion | internal | RETAIN; guard forces classification) */
|
||
return WAL_MODE_RETAIN;
|
||
case WAL_CONFIG_MARKER:
|
||
/* config-change marker (ha-term-safety task 4.2): a committable
|
||
* (term,lsn) log entry like a job-state fact — replicated and retained
|
||
* until committed; it commits through the SAME barrier as job state
|
||
* (R7.1/R7.11), so it is RETAIN, NOT a no-op or a LOCAL marker. The
|
||
* requester barrier on a config upgrade is owned by the upgrade path
|
||
* (do_upgrade_config_with_bytes), not the per-entry WAIT hook. */
|
||
return WAL_MODE_RETAIN;
|
||
case WAL_CHECKPOINT:
|
||
/* local segment marker — never replicated, by design */
|
||
return WAL_MODE_LOCAL;
|
||
default:
|
||
ERROR("wal_classify: unclassified WAL type %u (detail %d) — "
|
||
"RETAIN fallback; add it to the classification table",
|
||
wal_type, detail);
|
||
#ifdef XCE_TESTING
|
||
assert(0 && "unclassified WAL type — add it to wal_classify");
|
||
#endif
|
||
return WAL_MODE_RETAIN;
|
||
}
|
||
}
|
||
|
||
/* Commit barrier helper (ha-commit-barrier-rollout task 2.2, design.md
|
||
* §Commit Barrier Helper; Req 1.1, 1.2, 1.3).
|
||
*
|
||
* Handler-callable WAIT completion on the appended watermark: snapshot the
|
||
* last-appended LSN, return 0 when the commit index already covers it,
|
||
* else block in the commit engine until coverage or deadline. The engine
|
||
* result is returned UNMODIFIED (0 / -ETIMEDOUT / -XCE_ERR_NOT_READY /
|
||
* -EINVAL) so callers map it to their own error replies.
|
||
*
|
||
* Lock discipline: p->lock is held only for the current_lsn snapshot and
|
||
* is released BEFORE commit_submit(WAIT) — the engine blocks on ce->lock
|
||
* + condvar, and p->lock must never be held across it (engine
|
||
* lock-ordering rule). */
|
||
int persist_commit_barrier(persist_t* p, uint32_t timeout_ms)
|
||
{
|
||
uint64_t watermark;
|
||
|
||
if (p == NULL)
|
||
return -EINVAL;
|
||
|
||
/* Snapshot the last-appended watermark under p->lock. current_lsn is
|
||
* the NEXT LSN to assign (persist.h invariant: committed_lsn <=
|
||
* current_lsn - 1), so the highest appended entry is current_lsn - 1
|
||
* (resolved contract, spec Implementation Notes 2.1). current_lsn ==
|
||
* 0 means nothing was ever appended — vacuously satisfied. */
|
||
pthread_mutex_lock(&p->lock);
|
||
if (p->current_lsn == 0) {
|
||
pthread_mutex_unlock(&p->lock);
|
||
return 0;
|
||
}
|
||
watermark = p->current_lsn - 1;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
/* Already covered — committed_lsn is monotone, so coverage observed
|
||
* here can never regress. This check also absorbs the LSN-0 first
|
||
* entry (watermark 0 vs committed_lsn 0): that once-per-lifetime entry
|
||
* has no quorum contract (ha-commit-engine Implementation Notes) and
|
||
* commit_submit would reject lsn == 0 anyway. */
|
||
if (atomic_load_explicit(&p->committed_lsn, memory_order_acquire) >=
|
||
watermark)
|
||
return 0;
|
||
|
||
/* Engine not wired: legacy/bootstrap contexts have no engine commit
|
||
* contract — there is nothing to wait on, so the barrier is a no-op.
|
||
* Production always wires the engine (cluster_init_phase2). */
|
||
if (p->repl.ce == NULL)
|
||
return 0;
|
||
|
||
/* timeout_ms == 0 borrows the configured sync deadline (Req 1.2). */
|
||
return commit_submit(p->repl.ce, watermark, COMMIT_MODE_WAIT,
|
||
timeout_ms != 0 ? timeout_ms
|
||
: p->config.sync_timeout_ms);
|
||
}
|
||
|
||
/* Classified append at the hook seam (barrier-rollout task 1.2).
|
||
*
|
||
* WAIT (queue/worker control): the append stays async here — the handler
|
||
* barrier (tasks 2-3) owes commit completion before the requester reply.
|
||
* WAL_JOB_SUBMIT never reaches this helper (it keeps the in-hook sync
|
||
* path in persist_job_hook_fun).
|
||
*
|
||
* RETAIN (facts): append + broadcast, then register the appended LSN with
|
||
* the commit engine. commit_submit(COMMIT_MODE_RETAIN) is registration-
|
||
* free bookkeeping (commit.c — returns 0 immediately; commit state is
|
||
* derived from registry watermarks at ACK time), so the call is contract
|
||
* documentation: the entry stays pending purely by lsn > committed_lsn
|
||
* and commits later via covering ACKs. Commit-progress results are NEVER
|
||
* propagated to hook callers — only the local append rc is (Req 3.1).
|
||
*
|
||
* Lock context: persist_wal_async takes/releases p->lock internally; the
|
||
* appended-LSN read below re-takes p->lock briefly (same discipline as
|
||
* persist_wal_sync_append) and releases it BEFORE commit_submit, so no
|
||
* p->lock is ever held across ce->lock (engine lock-ordering rule). */
|
||
static int wal_hook_append_classified(persist_t* p, uint32_t wal_type,
|
||
wal_commit_mode_t mode,
|
||
const void* payload, uint32_t len)
|
||
{
|
||
uint64_t appended_lsn;
|
||
int rc;
|
||
|
||
/* ha-term-safety task 3.1: route the primary's local append through the raft
|
||
* library (ha_primary_log_append → raft_log_append) so the LIBRARY stamps the
|
||
* live election term and assigns the LSN (firing on_persist_entry → the WAL
|
||
* write), keeping p->current_lsn co-tracked with s->log_len. The library is
|
||
* the SINGLE primary append entry — no append bypasses it on the raft road.
|
||
* On the non-raft road (library not LEADER) ha_primary_log_append falls back
|
||
* to persist_wal_async, so engine-less / init-time contexts are unchanged.
|
||
* When p->cluster is NULL (persist used standalone in lib tests) keep the
|
||
* direct append (no cluster → no raft state to drive). */
|
||
if (p->cluster != NULL)
|
||
rc = ha_primary_log_append(p->cluster, wal_type, payload, len, NULL);
|
||
else
|
||
rc = persist_wal_async(p, wal_type, payload, len);
|
||
if (rc != 0 || mode != WAL_MODE_RETAIN)
|
||
return rc;
|
||
|
||
if (p->repl.ce == NULL)
|
||
return rc; /* engine not wired (bootstrap / engine-less contexts) */
|
||
|
||
/* current_lsn - 1 is the LSN persist_wal_async just assigned (it
|
||
* increments under p->lock). A concurrent append may bump it further;
|
||
* that is benign — contiguous advancement means the higher watermark
|
||
* still covers this entry. LSN-0 edge: commit_submit rejects lsn == 0
|
||
* (first-ever entry of a fresh cluster — accepted interim behavior,
|
||
* spec Implementation Notes). */
|
||
pthread_mutex_lock(&p->lock);
|
||
appended_lsn = p->current_lsn - 1;
|
||
pthread_mutex_unlock(&p->lock);
|
||
if (appended_lsn != 0)
|
||
(void)commit_submit(p->repl.ce, appended_lsn, COMMIT_MODE_RETAIN, 0);
|
||
|
||
return rc; /* local append rc only — facts have no commit timeout */
|
||
}
|
||
|
||
/* Shared step for a single WAL_JOB_SUBMIT entry (raft-apply-decoupling task
|
||
* 3.1): encode `job`, append, and block on the majority-commit barrier.
|
||
* Returns 0 when the entry committed on a majority; a negative rc otherwise
|
||
* (-ETIMEDOUT: no majority within the deadline; -XCE_ERR_NOT_READY: admission
|
||
* cap; -ENOMEM: encode failure; or a local-write error). Whenever the LOCAL
|
||
* WAL write succeeded (rc == 0, -ETIMEDOUT, or -XCE_ERR_NOT_READY — the entry
|
||
* was appended locally and the LSN advanced even though replication is
|
||
* degraded), *out_lsn is set to the entry's assigned LSN so a caller can tag
|
||
* it for egress-watermark purposes even on a rejected submission. */
|
||
static int submit_job_wal_entry_and_commit(persist_t* p, const xce_job_t* job,
|
||
uint64_t* out_lsn)
|
||
{
|
||
uint8_t* buf = NULL;
|
||
size_t sz = 0;
|
||
int rc;
|
||
|
||
if (capnp_encode_job(job, &buf, &sz) != 0)
|
||
return -ENOMEM;
|
||
|
||
/* ha-convergence-recovery (3.3): snapshot the LSN this submit entry
|
||
* will receive BEFORE the append. persist_wal_async assigns
|
||
* hdr.lsn = current_lsn then increments, so the pre-append current_lsn
|
||
* is exactly the written entry's LSN. */
|
||
pthread_mutex_lock(&p->lock);
|
||
uint64_t submit_lsn = p->current_lsn;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
int sync_rc = 0;
|
||
rc = persist_wal_sync_append(p, WAL_JOB_SUBMIT, buf, (uint32_t)sz,
|
||
&sync_rc);
|
||
free(buf);
|
||
|
||
if (rc == 0 || rc == -ETIMEDOUT || rc == -XCE_ERR_NOT_READY)
|
||
*out_lsn = submit_lsn;
|
||
|
||
return rc;
|
||
}
|
||
|
||
/* Explicit controller-side raft step for a job submission (raft-apply-decoupling
|
||
* item #1 / bug-1306): WAL-append each job in `jlist` and block on the
|
||
* majority-commit barrier, returning the commit verdict DIRECTLY to the caller
|
||
* (handle_jobsubmit) instead of through the xce_run_hooks fan-out (which returns
|
||
* only the last hook's rc and so clobbered this -ETIMEDOUT with a later notify
|
||
* hook's 0 — the committed-job-loss root cause).
|
||
*
|
||
* Returns 0 when every job's entry committed on a majority; the FIRST negative
|
||
* rc otherwise (-ETIMEDOUT: no majority within the deadline; -XCE_ERR_NOT_READY:
|
||
* admission cap; -ENOMEM: encode failure; or a local-write error). The caller
|
||
* MUST reject the submission on a negative rc (never reply success) to preserve
|
||
* the Commit Durability invariant. On -ETIMEDOUT / -XCE_ERR_NOT_READY the entry
|
||
* WAS appended locally and the LSN advanced, so j->submit_lsn is still tagged —
|
||
* the entry remains a pending, uncommitted tail a new-term leader may discard. */
|
||
int persist_submit_wal_and_commit(persist_t* p, xlist_t* jlist)
|
||
{
|
||
int rc = 0;
|
||
|
||
if (p == NULL || jlist == NULL)
|
||
return -EINVAL;
|
||
|
||
for (int i = 0; i < jlist->count && rc == 0; i++) {
|
||
job_t* j = (job_t*)xlist_get(jlist, i);
|
||
uint64_t lsn = 0;
|
||
|
||
rc = submit_job_wal_entry_and_commit(p, &j->job, &lsn);
|
||
if (rc == 0 || rc == -ETIMEDOUT || rc == -XCE_ERR_NOT_READY)
|
||
j->submit_lsn = lsn;
|
||
}
|
||
|
||
return rc;
|
||
}
|
||
|
||
/* Sibling of persist_submit_wal_and_commit for a job submission whose job
|
||
* object(s) do not exist yet (raft-apply-decoupling task 3.1 / Req 2.1, 2.3):
|
||
* `payloads` is an xlist_t of xce_job_t* that have never been registered in
|
||
* any job table. On full success (return 0), out_lsns[i] holds payloads[i]'s
|
||
* committed LSN — the caller constructs and tags the job object AFTER this
|
||
* call returns, so a failed/timed-out commit never leaves a job object
|
||
* behind (there is nothing to construct until this succeeds). Returns the
|
||
* first negative rc on failure. */
|
||
int persist_submit_job_payloads_and_commit(persist_t* p, xlist_t* payloads,
|
||
uint64_t* out_lsns)
|
||
{
|
||
int rc = 0;
|
||
|
||
if (p == NULL || payloads == NULL || out_lsns == NULL)
|
||
return -EINVAL;
|
||
|
||
for (int i = 0; i < payloads->count && rc == 0; i++) {
|
||
xce_job_t* j = (xce_job_t*)xlist_get(payloads, i);
|
||
|
||
rc = submit_job_wal_entry_and_commit(p, j, &out_lsns[i]);
|
||
}
|
||
|
||
return rc;
|
||
}
|
||
|
||
/* raft-apply-decoupling task 3.2: append-only counterpart of
|
||
* submit_job_wal_entry_and_commit — writes the entry via the SAME
|
||
* primary-append primitive wal_hook_append_classified already uses for
|
||
* RETAIN-mode facts (ha_primary_log_append / persist_wal_async), WITHOUT
|
||
* waiting on the majority-commit barrier. Deliberately does NOT reuse
|
||
* submit_job_wal_entry_and_commit itself: that helper is shared with
|
||
* persist_submit_wal_and_commit's still-synchronous, still-blocking
|
||
* contract (relied on directly by test_egress_filter.c / test_e2e_hooks.c /
|
||
* test_persist_hooks.c), which this task must not disturb. */
|
||
static int append_job_wal_entry_local(persist_t* p, const xce_job_t* job,
|
||
uint64_t* out_lsn)
|
||
{
|
||
uint8_t* buf = NULL;
|
||
size_t sz = 0;
|
||
int rc;
|
||
uint64_t lsn;
|
||
|
||
if (capnp_encode_job(job, &buf, &sz) != 0)
|
||
return -ENOMEM;
|
||
|
||
/* Snapshot the LSN this entry will receive BEFORE the append (same
|
||
* convention as submit_job_wal_entry_and_commit / wal_hook_append_
|
||
* classified: persist_wal_async / ha_primary_log_append assigns
|
||
* hdr.lsn = current_lsn then increments). */
|
||
pthread_mutex_lock(&p->lock);
|
||
lsn = p->current_lsn;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
if (p->cluster != NULL)
|
||
rc = ha_primary_log_append(p->cluster, WAL_JOB_SUBMIT, buf,
|
||
(uint32_t)sz, NULL);
|
||
else
|
||
rc = persist_wal_async(p, WAL_JOB_SUBMIT, buf, (uint32_t)sz);
|
||
free(buf);
|
||
|
||
if (rc == 0)
|
||
*out_lsn = lsn;
|
||
return rc;
|
||
}
|
||
|
||
/* raft-apply-decoupling task 3.2: bridges commit_submit_async's
|
||
* commit_continuation_fn shape (commit_outcome_t + rc) to
|
||
* persist_job_commit_notify_fn's shape (a single rc) so persist.h's public
|
||
* declaration never needs commit.h's commit_outcome_t (see persist.h's
|
||
* doc comment on persist_job_commit_notify_fn). One adapter is heap-
|
||
* allocated per registered lsn and frees itself after its single
|
||
* invocation — safe because commit_resolve guarantees exactly-once
|
||
* delivery. */
|
||
typedef struct {
|
||
persist_job_commit_notify_fn fn;
|
||
void* ctx;
|
||
} job_commit_adapter_ctx_t;
|
||
|
||
static void job_commit_resolve_adapter(void* adapter_ctx, uint64_t lsn,
|
||
commit_outcome_t outcome, int rc)
|
||
{
|
||
job_commit_adapter_ctx_t* a = (job_commit_adapter_ctx_t*)adapter_ctx;
|
||
int final_rc = (outcome == COMMIT_OUTCOME_COMMITTED) ? 0 : rc;
|
||
|
||
a->fn(a->ctx, lsn, final_rc);
|
||
free(a);
|
||
}
|
||
|
||
int persist_submit_job_payloads_async(persist_t* p, xlist_t* payloads,
|
||
uint64_t* out_lsns,
|
||
persist_job_commit_notify_fn fn,
|
||
void* ctx)
|
||
{
|
||
int first_rc = 0;
|
||
|
||
if (p == NULL || payloads == NULL || out_lsns == NULL || fn == NULL)
|
||
return -EINVAL;
|
||
|
||
for (int i = 0; i < payloads->count; i++) {
|
||
xce_job_t* j = (xce_job_t*)xlist_get(payloads, i);
|
||
uint64_t lsn = 0;
|
||
int rc;
|
||
|
||
if (first_rc != 0) {
|
||
/* A prior payload in this batch already failed locally: every
|
||
* remaining payload resolves with that SAME failure now,
|
||
* synchronously — so `fn` is invoked exactly once per payload
|
||
* regardless of where the failure occurred, and the caller
|
||
* never has to separately track "never going to be called"
|
||
* entries. */
|
||
fn(ctx, 0, first_rc);
|
||
continue;
|
||
}
|
||
|
||
rc = append_job_wal_entry_local(p, j, &lsn);
|
||
if (rc != 0) {
|
||
first_rc = rc;
|
||
fn(ctx, 0, rc);
|
||
continue;
|
||
}
|
||
out_lsns[i] = lsn;
|
||
|
||
/* LSN-0 edge (documented once-per-cluster-lifetime interim
|
||
* behavior — ha-commit-engine Implementation Notes, mirrored by
|
||
* repl_wait_majority_ack's walAck branch): the first-ever WAL
|
||
* entry of a fresh cluster is structurally un-commitable and
|
||
* commit_submit_async rejects lsn == 0, so resolve it inline.
|
||
* Engine-not-wired (bootstrap / engine-less contexts) mirrors
|
||
* persist_commit_barrier's own "no engine -> no-op success"
|
||
* convention — there is no async completion authority to
|
||
* register with. */
|
||
if (lsn == 0 || p->repl.ce == NULL) {
|
||
fn(ctx, lsn, 0);
|
||
continue;
|
||
}
|
||
|
||
job_commit_adapter_ctx_t* a = calloc(1, sizeof(*a));
|
||
if (a == NULL) {
|
||
first_rc = -ENOMEM;
|
||
fn(ctx, lsn, -ENOMEM);
|
||
continue;
|
||
}
|
||
a->fn = fn;
|
||
a->ctx = ctx;
|
||
|
||
int reg_rc = commit_submit_async(p->repl.ce, lsn,
|
||
job_commit_resolve_adapter, a);
|
||
if (reg_rc != 0) {
|
||
free(a);
|
||
first_rc = reg_rc;
|
||
ERROR("persist_submit_job_payloads_async: lsn=%lu appended but "
|
||
"commit_submit_async registration failed (rc=%d) -- the "
|
||
"entry remains durable and will still be caught up "
|
||
"eventually via replay, but this caller will not be told "
|
||
"its outcome via this path",
|
||
(unsigned long)lsn, reg_rc);
|
||
fn(ctx, lsn, reg_rc);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return first_rc;
|
||
}
|
||
|
||
/* job-array-single-wal-record task 2.1: serialize a job-array submission
|
||
* (base jobid + array size + one shared job spec) to a malloc'd Cap'n Proto
|
||
* buffer, reusing the SAME capnp struct (JobSubmit) the client's own submit
|
||
* request uses -- mirrors capnp_encode_job above, which mirrors encode_Job/
|
||
* new_Job/write_Job for a single job, except here via encode_JobSubmit/
|
||
* new_JobSubmit/write_JobSubmit. The encoded payload is O(1) in array_size:
|
||
* arraySize is a fixed-width uint64 field, not a per-element list -- so this
|
||
* function's output size never grows with array_size (Req 7.1). Caller must
|
||
* free(*out) on success. */
|
||
static int capnp_encode_job_array(const xce_job_submit_t* s, uint8_t** out,
|
||
size_t* out_len)
|
||
{
|
||
struct capn c;
|
||
struct capn_segment* cs;
|
||
struct JobSubmit cjs;
|
||
JobSubmit_ptr jp;
|
||
|
||
capn_init_malloc(&c);
|
||
cs = capn_root(&c).seg;
|
||
encode_JobSubmit(cs, &cjs, (xce_job_submit_t*)s);
|
||
jp = new_JobSubmit(cs);
|
||
write_JobSubmit(&cjs, jp);
|
||
capn_setp(capn_root(&c), 0, jp.p);
|
||
|
||
*out_len = capn_size(&c);
|
||
*out = malloc(*out_len);
|
||
if (!*out) { capn_free(&c); return -ENOMEM; }
|
||
capn_write_mem(&c, *out, *out_len, 0);
|
||
capn_free(&c);
|
||
return 0;
|
||
}
|
||
|
||
/* job-array-single-wal-record task 2.1: append-only, single-record
|
||
* counterpart of append_job_wal_entry_local for a WHOLE job array. Encodes
|
||
* ONE JobSubmit-shaped payload (base_jobid, array_size, base_spec->submit)
|
||
* and appends it as exactly one WAL_JOB_ARRAY_SUBMIT entry via the SAME
|
||
* primary-append primitive append_job_wal_entry_local already uses
|
||
* (ha_primary_log_append / persist_wal_async) -- never one entry per array
|
||
* element, regardless of array_size (Req 1.1, 1.2, 1.3). base_jobid/
|
||
* array_size are taken as explicit parameters rather than read from
|
||
* base_spec->jobid/base_spec->array_size, so the caller's own
|
||
* already-range-claimed values are always what gets committed, independent
|
||
* of whatever same-shaped fields the client's own wire request happened to
|
||
* carry. */
|
||
static int append_job_array_wal_entry_local(persist_t* p, uint64_t base_jobid,
|
||
uint64_t array_size,
|
||
const xce_job_submit_t* base_spec,
|
||
uint64_t* out_lsn)
|
||
{
|
||
xce_job_submit_t wire = { .jobid = base_jobid,
|
||
.array_size = array_size,
|
||
.submit = base_spec->submit };
|
||
uint8_t* buf = NULL;
|
||
size_t sz = 0;
|
||
int rc;
|
||
uint64_t lsn;
|
||
|
||
if (capnp_encode_job_array(&wire, &buf, &sz) != 0)
|
||
return -ENOMEM;
|
||
|
||
/* Snapshot the LSN this entry will receive BEFORE the append (same
|
||
* convention as append_job_wal_entry_local / submit_job_wal_entry_and_
|
||
* commit: persist_wal_async / ha_primary_log_append assigns
|
||
* hdr.lsn = current_lsn then increments). */
|
||
pthread_mutex_lock(&p->lock);
|
||
lsn = p->current_lsn;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
if (p->cluster != NULL)
|
||
rc = ha_primary_log_append(p->cluster, WAL_JOB_ARRAY_SUBMIT, buf,
|
||
(uint32_t)sz, NULL);
|
||
else
|
||
rc = persist_wal_async(p, WAL_JOB_ARRAY_SUBMIT, buf, (uint32_t)sz);
|
||
free(buf);
|
||
|
||
if (rc == 0)
|
||
*out_lsn = lsn;
|
||
return rc;
|
||
}
|
||
|
||
/* job-array-single-wal-record task 2.1 (design.md §WAL Array Submission,
|
||
* Service Interface): encodes and appends ONE array-shaped WAL entry
|
||
* (WAL_JOB_ARRAY_SUBMIT) and registers ONE commit continuation for it,
|
||
* regardless of array_size -- the single-record sibling of
|
||
* persist_submit_job_payloads_async's per-payload loop, specialized to
|
||
* exactly one payload. No job_t/xce_job_t object is constructed here or
|
||
* anywhere on this call path -- array expansion into individual job
|
||
* entries happens later, in the shared apply seam (task 3.x's
|
||
* restore_job_array), never here.
|
||
*
|
||
* Mirrors persist_submit_job_payloads_async's contract exactly:
|
||
* - a local encode/append failure resolves `fn` synchronously with that
|
||
* failure and returns the same negative rc;
|
||
* - the LSN-0 / engine-not-wired edge cases (documented on
|
||
* persist_submit_job_payloads_async above) resolve `fn` inline with
|
||
* rc=0;
|
||
* - otherwise `fn` is registered via commit_submit_async and invoked
|
||
* exactly once, asynchronously, once the entry's commit outcome is
|
||
* known. */
|
||
int persist_submit_job_array_async(persist_t* p, uint64_t base_jobid,
|
||
uint64_t array_size,
|
||
const xce_job_submit_t* base_spec,
|
||
uint64_t* out_lsn,
|
||
persist_job_commit_notify_fn fn, void* ctx)
|
||
{
|
||
uint64_t lsn = 0;
|
||
int rc;
|
||
|
||
if (p == NULL || base_spec == NULL || base_spec->submit == NULL ||
|
||
out_lsn == NULL || fn == NULL || array_size <= 1)
|
||
return -EINVAL;
|
||
|
||
rc = append_job_array_wal_entry_local(p, base_jobid, array_size,
|
||
base_spec, &lsn);
|
||
if (rc != 0) {
|
||
fn(ctx, 0, rc);
|
||
return rc;
|
||
}
|
||
*out_lsn = lsn;
|
||
|
||
/* LSN-0 / engine-not-wired edge (see persist_submit_job_payloads_async's
|
||
* doc comment above for the full rationale -- identical here,
|
||
* specialized to one entry instead of a loop). */
|
||
if (lsn == 0 || p->repl.ce == NULL) {
|
||
fn(ctx, lsn, 0);
|
||
return 0;
|
||
}
|
||
|
||
job_commit_adapter_ctx_t* a = calloc(1, sizeof(*a));
|
||
if (a == NULL) {
|
||
fn(ctx, lsn, -ENOMEM);
|
||
return -ENOMEM;
|
||
}
|
||
a->fn = fn;
|
||
a->ctx = ctx;
|
||
|
||
int reg_rc = commit_submit_async(p->repl.ce, lsn,
|
||
job_commit_resolve_adapter, a);
|
||
if (reg_rc != 0) {
|
||
free(a);
|
||
ERROR("persist_submit_job_array_async: lsn=%lu appended but "
|
||
"commit_submit_async registration failed (rc=%d) -- the "
|
||
"entry remains durable and will still be caught up "
|
||
"eventually via replay, but this caller will not be told "
|
||
"its outcome via this path",
|
||
(unsigned long)lsn, reg_rc);
|
||
fn(ctx, lsn, reg_rc);
|
||
return reg_rc;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
void persist_register_pending_apply(persist_t* p, uint64_t lsn,
|
||
pending_apply_notify_fn on_applied,
|
||
void* ctx)
|
||
{
|
||
pending_command_apply_t* node;
|
||
|
||
if (p == NULL || on_applied == NULL)
|
||
return;
|
||
|
||
/* bug-1415 self-healing (round 2): commit.c's continuation dispatch and
|
||
* the apply pump's walk (persist_apply_pump_run) are two independent
|
||
* vtasks with no completion-order guarantee -- see this function's doc
|
||
* comment in persist.h for the full race. If the walk has already
|
||
* applied `lsn` (last_applied_lsn already at or past it) by the time we
|
||
* get here, linking this registration onto pending_applies would orphan
|
||
* it forever: the walk only ever visits each lsn once, and it already
|
||
* called persist_take_pending_apply() for this lsn and found nothing.
|
||
* Fire the hand-off immediately, synchronously, on THIS thread instead
|
||
* -- never leave a continuation unresolved (controllers never
|
||
* self-terminate; the same "fail loud and complete the work" posture
|
||
* commit.c's dispatch_continuation fallback uses). */
|
||
if (lsn <= persist_last_applied_lsn(p)) {
|
||
on_applied(ctx, lsn);
|
||
return;
|
||
}
|
||
|
||
node = calloc(1, sizeof(*node));
|
||
if (node == NULL) {
|
||
ERROR("persist_register_pending_apply: OOM registering the apply "
|
||
"hand-off for lsn=%lu -- the entry is still durably committed "
|
||
"and will still be applied by a future restart replay, but no "
|
||
"continuation will fire for it via this path",
|
||
(unsigned long)lsn);
|
||
return;
|
||
}
|
||
node->lsn = lsn;
|
||
node->on_applied = on_applied;
|
||
node->ctx = ctx;
|
||
|
||
pthread_mutex_lock(&p->pending_applies_lock);
|
||
node->next = (pending_command_apply_t*)p->pending_applies;
|
||
p->pending_applies = node;
|
||
pthread_mutex_unlock(&p->pending_applies_lock);
|
||
}
|
||
|
||
int persist_has_pending_apply(persist_t* p, uint64_t lsn)
|
||
{
|
||
pending_command_apply_t* n;
|
||
int found = 0;
|
||
|
||
if (p == NULL)
|
||
return 0;
|
||
|
||
pthread_mutex_lock(&p->pending_applies_lock);
|
||
for (n = (pending_command_apply_t*)p->pending_applies; n != NULL;
|
||
n = n->next) {
|
||
if (n->lsn == lsn) {
|
||
found = 1;
|
||
break;
|
||
}
|
||
}
|
||
pthread_mutex_unlock(&p->pending_applies_lock);
|
||
|
||
return found;
|
||
}
|
||
|
||
int persist_take_pending_apply(persist_t* p, uint64_t lsn,
|
||
pending_apply_notify_fn* out_fn, void** out_ctx)
|
||
{
|
||
pending_command_apply_t* prev = NULL;
|
||
pending_command_apply_t* n;
|
||
int found = 0;
|
||
|
||
if (p == NULL)
|
||
return 0;
|
||
|
||
pthread_mutex_lock(&p->pending_applies_lock);
|
||
for (n = (pending_command_apply_t*)p->pending_applies; n != NULL;
|
||
n = n->next) {
|
||
if (n->lsn == lsn) {
|
||
if (prev == NULL)
|
||
p->pending_applies = n->next;
|
||
else
|
||
prev->next = n->next;
|
||
if (out_fn)
|
||
*out_fn = n->on_applied;
|
||
if (out_ctx)
|
||
*out_ctx = n->ctx;
|
||
found = 1;
|
||
break;
|
||
}
|
||
prev = n;
|
||
}
|
||
pthread_mutex_unlock(&p->pending_applies_lock);
|
||
|
||
free(n); /* NULL when not found -- free(NULL) is a no-op */
|
||
|
||
return found;
|
||
}
|
||
|
||
/* raft-apply-decoupling task 4.1 (Req 5.1): the receipt-time durable-append
|
||
* primitive for a worker-reported job-state fact. Unlike persist_job_hook_fun
|
||
* below (an AFTER_EVENT hook that fires only once a mutation has already
|
||
* happened), this is a direct, hook-fan-out-free call: job.c's fact receipt
|
||
* handlers (record_job_start_fact/record_job_finish_fact/record_job_fail_fact/
|
||
* record_job_postdone_fact) call this (or persist_submit_job_fact_ex below)
|
||
* INSTEAD of mutating and firing AFTER_EVENT, so no notification (scheduler
|
||
* dispatch, slot release, apiserver push) can ever fire for an uncommitted
|
||
* fact (Req 5.3). `state` is always WAL_MODE_RETAIN for every job-state fact
|
||
* type (verified against wal_classify's table) — hardcoded here rather than
|
||
* routed through wal_classify since none of these types are detail-
|
||
* disambiguated the way WAL_WORKER_CTRL is.
|
||
*
|
||
* Thin wrapper over persist_submit_job_fact_ex() with pad = 0 — the "legacy
|
||
* absolute state" discriminator (see wal_types.h's doc comment on
|
||
* wal_jobstate_t._pad). Unchanged contract/signature for every existing
|
||
* caller. */
|
||
int persist_submit_job_fact(persist_t* p, uint32_t wal_type, uint64_t jobid,
|
||
int state)
|
||
{
|
||
return persist_submit_job_fact_ex(p, wal_type, jobid, state, 0);
|
||
}
|
||
|
||
/* raft-apply-decoupling task 6.1 round 2 (Req 3.3/7.1-7.3): full-control
|
||
* sibling of persist_submit_job_fact() above, letting the caller set
|
||
* wal_jobstate_t's `_pad` discriminator explicitly. Used by
|
||
* record_job_fail_fact (WAL_JOB_FAIL_STAGE_FLAG | stage) and
|
||
* record_job_postdone_fact (WAL_JOB_UPDATE_POSTDONE_MARK) to mark a fact
|
||
* whose real mutation must be re-derived at apply time against the job's
|
||
* CURRENT state, rather than trusting a receipt-time snapshot baked into
|
||
* `state` — see wal_types.h's doc comment on wal_jobstate_t._pad for the
|
||
* full rationale. */
|
||
int persist_submit_job_fact_ex(persist_t* p, uint32_t wal_type,
|
||
uint64_t jobid, int state, uint32_t pad)
|
||
{
|
||
wal_jobstate_t js = { .jobid = jobid, .state = (uint32_t)state,
|
||
._pad = pad };
|
||
|
||
return wal_hook_append_classified(p, wal_type, WAL_MODE_RETAIN, &js,
|
||
sizeof(js));
|
||
}
|
||
|
||
/* P0 payload: just the detail value (4 bytes).
|
||
* Full Cap'n Proto serialization is wired in Task 15. */
|
||
|
||
int persist_job_hook_fun(int type, int situation, int detail,
|
||
void* cls, va_list ap)
|
||
{
|
||
(void)type;
|
||
/* Write to WAL only after the state change is complete.
|
||
* BEFORE_EVENT has a different va_list layout and must be ignored. */
|
||
if (situation != HOOK_AFTER_EVENT)
|
||
return 0;
|
||
|
||
/* raft-apply-decoupling task 6.1 (Hazard 1): this AFTER_EVENT firing
|
||
* originated from apply_committed_entry()'s own post-mutation notify —
|
||
* the entry is already durably committed (that is why it is being
|
||
* applied at all), so appending it again here would duplicate it. See
|
||
* t_apply_seam_notifying's doc comment (above apply_committed_entry) for
|
||
* why this MUST be a call-provenance guard, not a `detail` filter:
|
||
* job_ctrl's mark_job_update/mark_job_fail still fire this exact same
|
||
* hook for their own live, still-legitimate append. */
|
||
if (t_apply_seam_notifying)
|
||
return 0;
|
||
|
||
persist_t* p = cls;
|
||
uint32_t wal_type = detail_to_wal_type(detail);
|
||
wal_commit_mode_t mode;
|
||
|
||
if (wal_type == 0)
|
||
return 0;
|
||
mode = wal_classify(wal_type, detail);
|
||
|
||
if (mode == WAL_MODE_WAIT) {
|
||
/* WAL_JOB_SUBMIT durability (WAL append + majority-commit barrier) is
|
||
* an explicit step invoked directly by handle_jobsubmit —
|
||
* persist_submit_job_payloads_and_commit(), called strictly before
|
||
* any job object is constructed (raft-apply-decoupling task 3.1 /
|
||
* bug-1306) — never from this hook. This hook fires only after that
|
||
* commit has already succeeded, purely as a notification, so there
|
||
* is nothing left for it to do here. */
|
||
return 0;
|
||
}
|
||
|
||
/* All other job events: payload = wal_jobstate_t {jobid, state}.
|
||
* State is read from the job_t after the AFTER_EVENT hook updates it. */
|
||
wal_jobstate_t js = {0};
|
||
switch (detail) {
|
||
case HOOK_DETAIL_JOB_START: {
|
||
/* args: cluster_t*, job_pidinfo_t*, job_t* */
|
||
(void)va_arg(ap, cluster_t*);
|
||
(void)va_arg(ap, void*);
|
||
job_t* j = va_arg(ap, job_t*);
|
||
js.jobid = j->job.jobid; js.state = (uint32_t)j->job.state;
|
||
break;
|
||
}
|
||
case HOOK_DETAIL_JOB_EXIT: {
|
||
/* args: cluster_t*, xce_jobfinish_t*, job_t* */
|
||
(void)va_arg(ap, cluster_t*);
|
||
(void)va_arg(ap, void*);
|
||
job_t* j = va_arg(ap, job_t*);
|
||
js.jobid = j->job.jobid; js.state = (uint32_t)j->job.state;
|
||
break;
|
||
}
|
||
case HOOK_DETAIL_JOB_FAIL: {
|
||
/* args: cluster_t*, job_failinfo_t*, job_t* */
|
||
(void)va_arg(ap, cluster_t*);
|
||
(void)va_arg(ap, void*);
|
||
job_t* j = va_arg(ap, job_t*);
|
||
js.jobid = j->job.jobid; js.state = (uint32_t)j->job.state;
|
||
break;
|
||
}
|
||
default: {
|
||
/* STOP, RESUME, UPDATE, CKPT, PREASON, RUSAGE — args: cluster_t*, job_t* */
|
||
(void)va_arg(ap, cluster_t*);
|
||
job_t* j = va_arg(ap, job_t*);
|
||
js.jobid = j->job.jobid; js.state = (uint32_t)j->job.state;
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* mode == WAL_MODE_RETAIN — fact-shaped job state transition */
|
||
return wal_hook_append_classified(p, wal_type, mode, &js, sizeof(js));
|
||
}
|
||
|
||
int persist_queue_hook_fun(int type, int situation, int detail,
|
||
void* cls, va_list ap)
|
||
{
|
||
(void)type;
|
||
if (situation != HOOK_AFTER_EVENT)
|
||
return 0;
|
||
|
||
/* raft-apply-decoupling task 6.1 (Hazard 1, queue analog): see
|
||
* persist_job_hook_fun's identical guard above for the full rationale —
|
||
* apply_committed_entry()'s WAL_QUEUE_CTRL branch fires this same
|
||
* AFTER_EVENT to notify, and this hook appends unconditionally
|
||
* (WAL_QUEUE_CTRL is WAL_MODE_WAIT with no early-return here), so
|
||
* without this guard a live-applied queue entry would re-append and
|
||
* re-trigger a nested majority-commit wait every time the pump walks
|
||
* it. */
|
||
if (t_apply_seam_notifying)
|
||
return 0;
|
||
|
||
persist_t* p = cls;
|
||
uint32_t wal_type = detail_to_wal_type(detail);
|
||
wal_commit_mode_t mode;
|
||
|
||
if (wal_type == 0)
|
||
return 0;
|
||
mode = wal_classify(wal_type, detail);
|
||
|
||
/* args: cluster_t*, queue_t* */
|
||
(void)va_arg(ap, cluster_t*);
|
||
queue_t* q = va_arg(ap, queue_t*);
|
||
|
||
/* Payload: [wal_queuehdr_t][queue name NUL-terminated] */
|
||
const char* name = q->queue.queue ? q->queue.queue : "";
|
||
size_t name_len = strlen(name) + 1;
|
||
size_t total = sizeof(wal_queuehdr_t) + name_len;
|
||
uint8_t* pkt = malloc(total);
|
||
if (!pkt) return -ENOMEM;
|
||
|
||
wal_queuehdr_t hdr = { .status = q->queue.status };
|
||
memcpy(pkt, &hdr, sizeof(hdr));
|
||
memcpy(pkt + sizeof(hdr), name, name_len);
|
||
|
||
/* mode == WAL_MODE_WAIT — the append stays async; the handler barrier
|
||
* owes commit completion before the requester reply (task 3) */
|
||
int rc = wal_hook_append_classified(p, wal_type, mode, pkt,
|
||
(uint32_t)total);
|
||
free(pkt);
|
||
return rc;
|
||
}
|
||
|
||
int persist_worker_hook_fun(int type, int situation, int detail,
|
||
void* cls, va_list ap)
|
||
{
|
||
(void)type;
|
||
if (situation != HOOK_AFTER_EVENT)
|
||
return 0;
|
||
|
||
/* raft-apply-decoupling task 6.1 (Hazard 1, worker analog): see
|
||
* persist_job_hook_fun's identical guard above for the full rationale. */
|
||
if (t_apply_seam_notifying)
|
||
return 0;
|
||
|
||
persist_t* p = cls;
|
||
uint32_t wal_type = detail_to_wal_type(detail);
|
||
wal_commit_mode_t mode;
|
||
|
||
if (wal_type == 0)
|
||
return 0;
|
||
mode = wal_classify(wal_type, detail);
|
||
|
||
/* WAL_WORKER_CTRL payload layout: [uint32_t detail][capnp worker OR name] */
|
||
uint32_t disc = (uint32_t)detail;
|
||
uint8_t* buf = NULL;
|
||
size_t capnp_sz = 0;
|
||
int rc = 0;
|
||
|
||
/* All worker detail types: args are cluster_t* then worker_t*.
|
||
* JOIN uses a unified signature with an extra char* name between them;
|
||
* DEAD and UPDATE use (cluster_t*, worker_t*) directly. */
|
||
(void)va_arg(ap, cluster_t*);
|
||
if (detail == HOOK_DETAIL_WORKER_JOIN)
|
||
(void)va_arg(ap, char*); /* skip name — present only for JOIN */
|
||
worker_t* w = va_arg(ap, worker_t*);
|
||
if (!w) return 0; /* guard: failed join produced no worker slot */
|
||
rc = capnp_encode_worker(&w->host, &buf, &capnp_sz);
|
||
if (rc != 0) return rc;
|
||
|
||
/* Build [disc | capnp bytes] in one allocation */
|
||
size_t total = WORKER_WAL_HDR_SIZE + capnp_sz;
|
||
uint8_t* pkt = malloc(total);
|
||
if (!pkt) { free(buf); return -ENOMEM; }
|
||
memcpy(pkt, &disc, WORKER_WAL_HDR_SIZE);
|
||
memcpy(pkt + WORKER_WAL_HDR_SIZE, buf, capnp_sz);
|
||
free(buf);
|
||
|
||
/* JOIN/UPDATE → WAIT (handler barrier owes completion, task 3);
|
||
* DEAD → RETAIN (controller-detected fact, registered with the engine) */
|
||
rc = wal_hook_append_classified(p, wal_type, mode, pkt, (uint32_t)total);
|
||
free(pkt);
|
||
return rc;
|
||
}
|
||
|
||
/* ============================================================
|
||
* GC retention predicates (P0-5a / P0-8 / P0-13)
|
||
*
|
||
* Placed in persist.c so the unit test binary (which links only
|
||
* persist.c from the controller sources) can access them without
|
||
* pulling in RTTI-dependent job.c / worker.c.
|
||
* ============================================================ */
|
||
|
||
bool job_is_terminal(const job_t* j)
|
||
{
|
||
return (j->job.state & (JOB_STAT_EXIT | JOB_STAT_DONE | JOB_STAT_PERR)) != 0;
|
||
}
|
||
|
||
bool job_should_retain(const job_t* j, const persist_config_t* cfg)
|
||
{
|
||
time_t now = time(NULL);
|
||
|
||
/* TRANSIENT jobs: auto-terminate when TTL elapses after failover.
|
||
* transient_since == 0 means the timestamp was never set — do not GC. */
|
||
if (j->job.state & JOB_STAT_TRANSIENT) {
|
||
if (j->job.transient_since > 0 &&
|
||
now > (time_t)j->job.transient_since + (time_t)cfg->transient_job_timeout_s) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* Non-terminal jobs are always retained */
|
||
if (!job_is_terminal(j)) return true;
|
||
|
||
/* Terminal jobs: retain until finished_retain_s expires */
|
||
return (j->job.finish_time + (time_t)cfg->finished_retain_s >= now);
|
||
}
|
||
|
||
bool worker_should_retain(const xce_worker_t* w, const persist_config_t* cfg)
|
||
{
|
||
time_t now = time(NULL);
|
||
|
||
/* HIDDEN workers: admin explicitly removed — clean up at next checkpoint */
|
||
if (w->status & XCE_WORKER_HIDDEN) return false;
|
||
|
||
/* UNAVAILABLE workers: retain until worker_retain_s expires.
|
||
* dead_since == 0 means not yet set — do not GC. */
|
||
if (w->status & XCE_WORKER_UNAVAIL) {
|
||
if (w->dead_since > 0 &&
|
||
now > w->dead_since + (time_t)cfg->worker_retain_s) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/* ============================================================
|
||
* WAL truncation (task 7.5)
|
||
*
|
||
* Remove all WAL entries with LSN > cutoff_lsn.
|
||
* Idempotent: calling twice with same cutoff_lsn returns 0 both times.
|
||
* ============================================================ */
|
||
|
||
/* Clamp durable_lsn down to `tip` when the log tip recedes below it (truncate
|
||
* / divergent-suffix discard). Without this, durable_lsn could exceed
|
||
* current_lsn - 1 after a truncate, violating its invariant and letting the
|
||
* commit majority count self at an LSN whose bytes were just removed. MUST be
|
||
* called with p->lock held (mirrors the committed_lsn clamp in the truncate
|
||
* paths). durable_lsn only ever decreases here — the sole exception to its
|
||
* monotonic-non-decreasing rule (ha-commit-durability 2.1 State Management). */
|
||
static void clamp_durable_lsn_locked(persist_t* p, uint64_t tip)
|
||
{
|
||
uint64_t dur = atomic_load_explicit(&p->durable_lsn, memory_order_acquire);
|
||
if (dur > tip)
|
||
atomic_store_explicit(&p->durable_lsn, tip, memory_order_release);
|
||
}
|
||
|
||
int persist_truncate_wal_above(persist_t* p, uint64_t cutoff_lsn)
|
||
{
|
||
pthread_mutex_lock(&p->lock);
|
||
uint64_t before = p->current_lsn;
|
||
int rc = truncate_wal_above_locked(p, cutoff_lsn);
|
||
uint64_t after = p->current_lsn;
|
||
pthread_mutex_unlock(&p->lock);
|
||
|
||
/* raft-apply-decoupling task 1.4: fire the truncation wake only when a
|
||
* truncation actually removed entries above cutoff_lsn (current_lsn
|
||
* strictly decreased -- both truncate_wal_above_locked branches set
|
||
* current_lsn = cutoff_lsn + 1 on a real truncation) -- mirrors the
|
||
* durable-advance wake's own before/after guard (see fsync_worker
|
||
* above) so a no-op truncate (nothing above cutoff_lsn, or an I/O
|
||
* error before current_lsn is touched) never spuriously fails a
|
||
* continuation whose entry is still safely in the log. Called AFTER
|
||
* releasing p->lock: the callback (the commit engine) takes only
|
||
* ce->lock, preserving the cycle-free p->lock -> ce->lock edge already
|
||
* established by the durable-advance wake. */
|
||
if (rc == 0 && after < before) {
|
||
/* raft-apply-decoupling task 7.4 perf-fix: a real truncation may
|
||
* ftruncate()/unlink() segment files the apply-pump's segment-lookup
|
||
* cache still points into -- invalidate it so a subsequent
|
||
* apply_pump_read_entry can never read truncated-away data. p->lock
|
||
* is already released above and apply_pump_lock is never held by
|
||
* this call path (persist_apply_pump_run is the only place that
|
||
* holds it, during its own walk), so no lock-order hazard here. */
|
||
pthread_mutex_lock(&p->apply_pump_lock);
|
||
if (p->apply_pump_cache_fd >= 0) {
|
||
close(p->apply_pump_cache_fd);
|
||
p->apply_pump_cache_fd = -1;
|
||
}
|
||
p->apply_pump_cache_next_lsn = 0;
|
||
pthread_mutex_unlock(&p->apply_pump_lock);
|
||
|
||
if (p->on_truncate != NULL) {
|
||
p->on_truncate(p->truncate_cb_ctx, cutoff_lsn);
|
||
}
|
||
}
|
||
return rc;
|
||
}
|
||
|
||
/* persist_committed_lsn_advance_atomic (2026-07-10, controller-invariant-audit
|
||
* finding): race-safe monotonic-max advance of p->committed_lsn via a CAS
|
||
* retry loop. Replaces the naive "load; if target>cur store" pattern, which
|
||
* can lose an update if two writers race: persist_promotion_advance_committed
|
||
* (below) and ha_standby_advance_committed_and_apply (ha.c) can both run
|
||
* during the promotion window before role flips to CTRL_PRIMARY (a
|
||
* still-in-flight WAL catch-up entry processed on another thread at the same
|
||
* instant as this node's own election completing). No lock required -- CAS
|
||
* only succeeds if the value hasn't changed since the read, so a losing
|
||
* racer retries with the freshly-observed value and can never clobber a
|
||
* larger value with a smaller one. */
|
||
uint64_t persist_committed_lsn_advance_atomic(persist_t* p, uint64_t target)
|
||
{
|
||
uint64_t cur = atomic_load_explicit(&p->committed_lsn, memory_order_acquire);
|
||
while (target > cur) {
|
||
if (atomic_compare_exchange_weak_explicit(&p->committed_lsn, &cur, target,
|
||
memory_order_release,
|
||
memory_order_acquire))
|
||
return target;
|
||
/* cur was refreshed to the current value by the failed CAS; retry. */
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
/* persist_promotion_advance_committed (2026-07-10, promotion apply-lag fix):
|
||
* unconditionally advance p->committed_lsn toward `safe_lsn` at promotion
|
||
* time. persist_truncate_wal_above only sets committed_lsn on the branches
|
||
* of truncate_wal_above_locked that perform a REAL truncation (partial
|
||
* truncate, or the full-segment-purge demotion-convergence case); its no-op
|
||
* early return (nothing above cutoff_lsn to remove -- the common case for a
|
||
* newly-elected leader whose own log already covers safe_lsn) leaves
|
||
* committed_lsn untouched at its pre-election value. Without this call,
|
||
* committed_lsn -- and therefore apply, which is commit-gated
|
||
* (raft-apply-decoupling) -- stalls until the normal per-entry ack-driven
|
||
* commit path organically catches it up, which can take seconds while
|
||
* surviving standbys reconnect and re-ACK. During that gap a job already
|
||
* committed on a majority is invisible via expect-job/joblist on the very
|
||
* primary that is authoritative for it (bug-828 class).
|
||
*
|
||
* DESIGN NOTE -- why this function never forces a synchronous flush (round 3,
|
||
* 2026-07-10, controller-invariant-audit): an earlier round of this fix
|
||
* called flusher_flush_once(p) here when durable_lsn lagged safe_lsn, to
|
||
* force this node's own fsync to catch up immediately rather than waiting on
|
||
* fsync_worker's cadence. That was a real bug-1198-CLASS thread-role
|
||
* violation: the real multi-node winning-vote-reply path reaches
|
||
* raft_cb_become_leader (and so this function) via peer_ctrl_classify's
|
||
* VTASK_LIVENESS dispatch (ha.c ~5935-5940) -- a task class the design
|
||
* DOCUMENTS as "never admission-gated" / never expected to block (see the
|
||
* bug-1198 hazard box a few hundred lines above peer_ctrl_classify's
|
||
* definition, which closed the EXACT same class of bug for
|
||
* HA_REPL_SEND_SNAPSHOT's persist_checkpoint). A blocking fdatasync here
|
||
* would reintroduce that closed bug class on the pool's liveness-reserved
|
||
* workers. The visibility gap the force-flush was trying to close is instead
|
||
* closed non-blockingly by ha_start_primary_heartbeat sending its first
|
||
* heartbeat synchronously at promotion (bug-1432) -- that lets a reconnecting
|
||
* standby's real position get learned, and the entry gets committed via the
|
||
* normal ack-driven commit_advance_locked path, within one network RTT
|
||
* instead of waiting a full heartbeat interval. A live chaotic soak (10
|
||
* rounds) confirmed durable-capped-only + immediate-heartbeat together reach
|
||
* committed_job_loss=0 with zero blocking calls added to this function.
|
||
*
|
||
* Durable-capped (never advances committed_lsn past this node's own
|
||
* durable_lsn -- see persistence.md's "acknowledged implies durable on a
|
||
* majority" core invariant) and monotonic + race-safe via
|
||
* persist_committed_lsn_advance_atomic. */
|
||
void persist_promotion_advance_committed(persist_t* p, uint64_t safe_lsn)
|
||
{
|
||
if (!p)
|
||
return;
|
||
uint64_t durable = atomic_load_explicit(&p->durable_lsn,
|
||
memory_order_acquire);
|
||
uint64_t target = safe_lsn < durable ? safe_lsn : durable;
|
||
persist_committed_lsn_advance_atomic(p, target);
|
||
}
|
||
|
||
/* Lock-held core of persist_truncate_wal_above (see the forward declaration and
|
||
* the conflict-guard reuse near persist_wal_write_replicated). Caller MUST hold
|
||
* p->lock; this function never touches the lock so it is reusable from another
|
||
* already-locked path. */
|
||
static int truncate_wal_above_locked(persist_t* p, uint64_t cutoff_lsn)
|
||
{
|
||
/* No-op: nothing written yet, or cutoff covers all written entries */
|
||
if (p->current_lsn == 0 || cutoff_lsn + 1 >= p->current_lsn) {
|
||
return 0;
|
||
}
|
||
|
||
/* --- Collect segment filenames and sort by start LSN --- */
|
||
uint64_t seg_lsns[4096];
|
||
int nseg = 0;
|
||
|
||
DIR* d = opendir(p->wal_path);
|
||
if (d) {
|
||
struct dirent* e;
|
||
while ((e = readdir(d)) != NULL &&
|
||
nseg < (int)(sizeof(seg_lsns) / sizeof(seg_lsns[0]))) {
|
||
if (strncmp(e->d_name, "seg-", 4) != 0)
|
||
continue;
|
||
uint64_t sl = 0;
|
||
if (sscanf(e->d_name, "seg-%" SCNx64 ".wal", &sl) != 1)
|
||
continue;
|
||
seg_lsns[nseg++] = sl;
|
||
}
|
||
closedir(d);
|
||
}
|
||
|
||
if (nseg == 0) {
|
||
return 0;
|
||
}
|
||
|
||
/* Insertion sort ascending by start LSN */
|
||
for (int i = 1; i < nseg; i++) {
|
||
uint64_t key = seg_lsns[i];
|
||
int j = i - 1;
|
||
while (j >= 0 && seg_lsns[j] > key) {
|
||
seg_lsns[j + 1] = seg_lsns[j];
|
||
j--;
|
||
}
|
||
seg_lsns[j + 1] = key;
|
||
}
|
||
|
||
/* --- Find segment containing cutoff_lsn --- */
|
||
int cutoff_seg_idx = -1;
|
||
for (int i = 0; i < nseg; i++) {
|
||
if (seg_lsns[i] <= cutoff_lsn)
|
||
cutoff_seg_idx = i;
|
||
else
|
||
break;
|
||
}
|
||
|
||
if (cutoff_seg_idx < 0) {
|
||
/* cutoff_lsn is below the oldest retained segment's start LSN, so EVERY
|
||
* on-disk entry has LSN > cutoff_lsn and must be removed (the function's
|
||
* contract: "remove all entries with LSN > cutoff_lsn"). This is the
|
||
* demotion-convergence case (ha-convergence-recovery Req 1.2): a demoted
|
||
* ex-primary whose committed boundary sits below its entire retained log
|
||
* must discard all of it and collapse the tip to cutoff_lsn + 1. Delete
|
||
* every segment, close the active fd, and reset the in-memory tip. */
|
||
for (int i = 0; i < nseg; i++) {
|
||
char del_path[4096];
|
||
snprintf(del_path, sizeof(del_path),
|
||
WAL_SEG_FMT, p->wal_path, seg_lsns[i]);
|
||
unlink(del_path);
|
||
}
|
||
if (p->wal_fd >= 0) {
|
||
fdatasync(p->wal_fd);
|
||
close(p->wal_fd);
|
||
p->wal_fd = -1;
|
||
}
|
||
p->seg_offset = 0;
|
||
p->seg_path[0] = '\0';
|
||
p->current_lsn = cutoff_lsn + 1;
|
||
p->last_crc = WAL_CRC_NONE;
|
||
atomic_store_explicit(&p->committed_lsn, cutoff_lsn,
|
||
memory_order_release);
|
||
clamp_durable_lsn_locked(p, cutoff_lsn);
|
||
/* Keep the per-LSN term index consistent: drop terms for removed LSNs. */
|
||
truncate_term_index_locked(p, cutoff_lsn);
|
||
return 0;
|
||
}
|
||
|
||
/* --- Scan segment to find byte offset of first entry with lsn > cutoff --- */
|
||
char cutoff_seg_path[4096];
|
||
snprintf(cutoff_seg_path, sizeof(cutoff_seg_path),
|
||
WAL_SEG_FMT, p->wal_path, seg_lsns[cutoff_seg_idx]);
|
||
|
||
int scan_fd = open(cutoff_seg_path, O_RDONLY);
|
||
if (scan_fd < 0) {
|
||
return -errno;
|
||
}
|
||
|
||
uint32_t scan_off = 0;
|
||
uint32_t trunc_off = 0;
|
||
uint32_t new_last_crc = WAL_CRC_NONE;
|
||
bool found_cut = false;
|
||
|
||
for (;;) {
|
||
wal_entry_hdr_t hdr;
|
||
ssize_t n = pread(scan_fd, &hdr, sizeof(hdr), (off_t)scan_off);
|
||
if (n < (ssize_t)sizeof(hdr))
|
||
break; /* short read or EOF */
|
||
/* Pre-allocated zero region: type==0 is not a valid WAL type (min=1) */
|
||
if (hdr.lsn == 0 && hdr.type == 0 && hdr.payload_len == 0)
|
||
break;
|
||
if (hdr.lsn > cutoff_lsn) {
|
||
trunc_off = scan_off;
|
||
found_cut = true;
|
||
break;
|
||
}
|
||
new_last_crc = hdr.crc;
|
||
scan_off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||
}
|
||
close(scan_fd);
|
||
|
||
if (!found_cut)
|
||
trunc_off = scan_off; /* end of valid data in segment */
|
||
|
||
/* --- Close the current wal_fd --- */
|
||
if (p->wal_fd >= 0) {
|
||
fdatasync(p->wal_fd);
|
||
close(p->wal_fd);
|
||
p->wal_fd = -1;
|
||
}
|
||
|
||
/* --- Truncate the cutoff segment at the computed offset --- */
|
||
int new_fd = open(cutoff_seg_path, O_RDWR);
|
||
if (new_fd < 0) {
|
||
return -errno;
|
||
}
|
||
if (ftruncate(new_fd, (off_t)trunc_off) != 0) {
|
||
int saved = errno;
|
||
close(new_fd);
|
||
return -saved;
|
||
}
|
||
fdatasync(new_fd);
|
||
|
||
/* --- Delete all segments after the cutoff segment --- */
|
||
for (int i = cutoff_seg_idx + 1; i < nseg; i++) {
|
||
char del_path[4096];
|
||
snprintf(del_path, sizeof(del_path),
|
||
WAL_SEG_FMT, p->wal_path, seg_lsns[i]);
|
||
unlink(del_path);
|
||
}
|
||
|
||
/* --- Update persist_t state under lock --- */
|
||
p->wal_fd = new_fd;
|
||
p->seg_offset = trunc_off;
|
||
snprintf(p->seg_path, sizeof(p->seg_path),
|
||
WAL_SEG_FMT, p->wal_path, seg_lsns[cutoff_seg_idx]);
|
||
p->current_lsn = cutoff_lsn + 1;
|
||
p->last_crc = new_last_crc;
|
||
atomic_store_explicit(&p->committed_lsn, cutoff_lsn, memory_order_release);
|
||
clamp_durable_lsn_locked(p, cutoff_lsn);
|
||
/* Keep the per-LSN term index consistent: drop terms for removed LSNs. */
|
||
truncate_term_index_locked(p, cutoff_lsn);
|
||
|
||
return 0;
|
||
}
|