feat(raft-commit-safety): (lastLogTerm,lastLogIndex) vote+prevote freshness (tasks 1.3, 4.1-4.2, 5.1-5.2, 6.2)

logOk (raft.tla:285, closes G6): local_last_log_term = log_len>0 ? on_get_entry_term(log_len):0;
logOk = cand_last_log_term > local || (== && cand_last_log_index >= log_len). Lexicographic:
term dominates, tie decided by index. Shared raft_log_ok helper.

Vote (4.2): logOk deny inserted after stale-term/UpdateTerm/role-gate/one-vote-per-term and
BEFORE on_vote_eligible (denies even when the app gate would grant); persist-before-grant intact.
PreVote (5.2): identical logOk, fully side-effect-free (no term/vote/role/commit mutation, no
on_persist). Signatures extended (1.3) on raft_on_vote_request/pre_vote_request; all 39 lib call
sites + sim routing updated; sim captures the candidate's send-time last-log tuple (raft_sim_rpc_t).
Empty-log call sites pass (0,0) → logOk true → existing outcomes preserved. 9 freshness tests.

NOTE: controller cluster.c vote callers still use the old signatures and will NOT compile — an
accepted, design-documented revalidation trigger; the controller adapter plumbing is owned by the
downstream ha-term-safety spec (to be implemented next). Library suite ASAN green (239 tests).
This commit is contained in:
Rongsong Shen 2026-06-22 00:14:26 +00:00
parent 195f660f9a
commit 56485e2fa3
5 changed files with 449 additions and 62 deletions

View file

@ -148,7 +148,25 @@ int raft_start_election(raft_state_t *s)
return RAFT_OK;
}
/* (lastLogTerm, lastLogIndex) freshness predicate (raft.tla
* HandleRequestVoteRequest logOk, line 285, closes G6). The voter's own last-log
* tuple is (on_get_entry_term(log_len), log_len) — term 0 when the log is empty,
* matching the on_get_entry_term 0-means-absent sentinel. The library owns this
* decision (design "Option B: library owns logOk") and applies it identically on
* the binding vote and the PreVote eligibility paths. */
static int raft_log_ok(const raft_state_t *s, uint64_t cand_last_log_term,
uint64_t cand_last_log_index)
{
uint64_t local_last_log_term =
(s->log_len > 0) ? s->cbs->on_get_entry_term(s->ctx, s->log_len) : 0;
return (cand_last_log_term > local_last_log_term) ||
(cand_last_log_term == local_last_log_term &&
cand_last_log_index >= s->log_len);
}
int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
uint64_t cand_last_log_term,
uint64_t cand_last_log_index,
uint64_t *reply_term)
{
/* Step 1: stale term — deny immediately, no state change */
@ -208,7 +226,18 @@ int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
/* same candidate — fall through to re-persist and re-grant */
}
/* Step 5: application-level policy gate (freshness, log lsn, etc.) */
/* Step 5: (lastLogTerm, lastLogIndex) freshness gate (raft.tla
* HandleRequestVoteRequest logOk, 285, closes G6). This precedes the
* application gate: a !logOk denies regardless of on_vote_eligible (Req 3.4),
* without weakening the stale-term / one-vote-per-term / persist-before-grant
* invariants above (Req 3.5). */
if (!raft_log_ok(s, cand_last_log_term, cand_last_log_index)) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_denied_log_stale", NULL);
return RAFT_DENY;
}
/* Step 6: application-level policy gate (freshness, log lsn, etc.) */
if (s->cbs->on_vote_eligible != NULL) {
if (s->cbs->on_vote_eligible(s->ctx, from_idx, term) == 0) {
if (s->cbs->on_trace)
@ -217,7 +246,7 @@ int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
}
}
/* Step 6: persist before granting — durability guarantee */
/* Step 7: persist before granting — durability guarantee */
if (s->cbs->on_persist(s->ctx, term, from_idx) != 0) {
/* Persist failed — deny without updating voted_for */
return RAFT_DENY;
@ -231,15 +260,18 @@ int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
return RAFT_GRANT;
}
int raft_on_pre_vote_request(raft_state_t *s, int from_idx, uint64_t cand_term)
int raft_on_pre_vote_request(raft_state_t *s, int from_idx, uint64_t cand_term,
uint64_t cand_last_log_term,
uint64_t cand_last_log_index)
{
/* PreVote is a side-effect-free straw poll: it answers "would you vote for
* me in cand_term?" WITHOUT changing any persistent state. Unlike
* raft_on_vote_request, this function never adopts cand_term (even when
* higher — that adoption is the very thing PreVote exists to prevent), never
* touches term / voted_for / voted_for_term / role, and never calls
* on_persist. The dispatcher applies the freshness floor before this call,
* exactly as on the real-vote path. */
* on_persist. The library applies the SAME (lastLogTerm, lastLogIndex)
* logOk freshness gate as the real-vote path (via raft_log_ok), so a stale
* candidate is rejected here exactly as it would be in a binding vote. */
/* Step 1: stale term — deny (mirrors raft_on_vote_request Step 1). A
* candidate proposing a term we already passed could never win a real
@ -251,10 +283,21 @@ int raft_on_pre_vote_request(raft_state_t *s, int from_idx, uint64_t cand_term)
return RAFT_DENY;
}
/* Step 2: application-level eligibility gate — reuse the EXISTING
/* Step 2: (lastLogTerm, lastLogIndex) freshness gate — the IDENTICAL logOk
* as the binding vote path (raft.tla 285, closes G6 for PreVote, Req 4.1).
* The library owns this predicate (the dispatcher no longer applies freshness
* before this call). Side-effect-free: a deny mutates nothing and never
* persists (Req 4.4). */
if (!raft_log_ok(s, cand_last_log_term, cand_last_log_index)) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "pre_vote_denied_log_stale", NULL);
return RAFT_DENY;
}
/* Step 3: application-level eligibility gate — reuse the EXISTING
* on_vote_eligible callback (primary-deny / fresh-heartbeat stickiness /
* catch-up deny), identical to raft_on_vote_request Step 5. If NULL, every
* Raft-eligible candidate is granted. */
* catch-up deny), identical to raft_on_vote_request's application gate. If
* NULL, every Raft-eligible candidate is granted. */
if (s->cbs->on_vote_eligible != NULL) {
if (s->cbs->on_vote_eligible(s->ctx, from_idx, cand_term) == 0) {
if (s->cbs->on_trace)

View file

@ -244,12 +244,29 @@ int raft_start_election(raft_state_t *s);
/*
* raft_on_vote_request: process an incoming RequestVote RPC.
* Applies Raft invariants (term comparison, one-vote-per-term).
* If invariants pass, calls on_vote_eligible to allow application policy.
* Applies Raft invariants (term comparison, one-vote-per-term), then the
* (lastLogTerm, lastLogIndex) freshness gate (raft.tla HandleRequestVoteRequest
* logOk, line 285, closes G6), then the application policy gate.
*
* The library owns the logOk decision (design "Option B: library owns logOk"):
* local_last_log_term = (log_len > 0) ? on_get_entry_term(log_len) : 0
* logOk = (cand_last_log_term > local_last_log_term)
* || (cand_last_log_term == local_last_log_term
* && cand_last_log_index >= log_len)
* The candidate advertises (cand_last_log_term, cand_last_log_index) in its wire
* RequestVote; the caller decodes and passes them here.
*
* Step order: stale-term deny → UpdateTerm/step-down → role gate →
* one-vote-per-term → logOk gate (NEW; precedes the application gate, so a
* !logOk denies regardless of on_vote_eligible) → on_vote_eligible →
* persist-before-grant.
*
* Fires on_persist before granting.
* Returns RAFT_GRANT (1) or RAFT_DENY (0); sets *reply_term to current term.
*/
int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
uint64_t cand_last_log_term,
uint64_t cand_last_log_index,
uint64_t *reply_term);
/*
@ -301,13 +318,17 @@ int raft_begin_pre_vote(raft_state_t *s);
/*
* raft_on_pre_vote_request: answer an incoming PreVote probe.
* Denies if cand_term < s->term; otherwise returns on_vote_eligible(ctx,
* from_idx, cand_term) (granting when the gate is NULL). Never touches term,
* voted_for, role, or fires on_persist. Freshness is applied by the
* dispatcher before this call (same contract as the real vote path).
* Denies if cand_term < s->term; then applies the IDENTICAL (lastLogTerm,
* lastLogIndex) logOk as raft_on_vote_request (the library owns the predicate —
* it is NO LONGER applied by the dispatcher before this call), then consults
* on_vote_eligible (granting when the gate is NULL). Mutates NOTHING — never
* touches term, voted_for, voted_for_term, role, or commit_index, and never
* fires on_persist — preserving the PreVote side-effect-free invariant.
* Returns 1 = grant, 0 = deny.
*/
int raft_on_pre_vote_request(raft_state_t *s, int from_idx, uint64_t cand_term);
int raft_on_pre_vote_request(raft_state_t *s, int from_idx, uint64_t cand_term,
uint64_t cand_last_log_term,
uint64_t cand_last_log_index);
/*
* raft_on_pre_vote_reply: tally a reply to our PreVote.
@ -423,11 +444,17 @@ void raft_snapshot(const raft_state_t *s, raft_snapshot_t *snap);
/* Upper bound on buffered AppendEntries awaiting delivery (mirrors pending[]). */
#define RAFT_SIM_MAX_AE 64
/* Buffered RPC entry: one pending vote-request to route */
/* Buffered RPC entry: one pending vote-request to route.
* last_log_term/last_log_index capture the CANDIDATE's advertised last-log tuple
* at SEND time (mirroring raft_sim_ae_t.leader_term) so the (lastLogTerm,
* lastLogIndex) freshness gate (raft-commit-safety) is evaluated against the
* candidate's send-epoch log, not a value re-read at delivery. */
typedef struct {
int src;
int dst;
uint64_t term;
uint64_t last_log_term; /* candidate's last-log term at send (0 if empty) */
uint64_t last_log_index; /* candidate's last-log index at send (== log_count) */
} raft_sim_rpc_t;
/* One in-memory log entry: term stamp + fixed-size payload bytes.

View file

@ -53,6 +53,14 @@ static int sim_send_vote_request(void *ctx, int peer_idx, uint64_t term)
rpc->src = node->idx;
rpc->dst = peer_idx;
rpc->term = term;
/* Capture the CANDIDATE's last-log tuple at SEND time from its own in-memory
* log (the library holds no entry bytes): last_log_index == log_count,
* last_log_term == term of the tail entry (0 when the log is empty). This
* feeds the (lastLogTerm, lastLogIndex) freshness gate at delivery. */
rpc->last_log_index = (uint64_t)node->log_count;
rpc->last_log_term = (node->log_count > 0)
? node->log[node->log_count - 1].term
: 0;
return 0;
}
@ -319,9 +327,12 @@ void raft_sim_deliver_pending(raft_sim_cluster_t *c)
if (c->nodes[src].partition || c->nodes[dst].partition)
continue;
/* Deliver vote request to dst */
/* Deliver vote request to dst, passing the candidate's send-time last-log
* tuple so dst evaluates the (lastLogTerm, lastLogIndex) freshness gate. */
uint64_t reply_term = 0;
int granted = raft_on_vote_request(&c->nodes[dst].state, src, term,
rpc->last_log_term,
rpc->last_log_index,
&reply_term);
/* Route reply back to src */