diff --git a/tidal-server/src/cluster/node.rs b/tidal-server/src/cluster/node.rs index e10f68e..ac992c5 100644 --- a/tidal-server/src/cluster/node.rs +++ b/tidal-server/src/cluster/node.rs @@ -3379,6 +3379,34 @@ impl ShardReplica { .lag_gauge() .update_leader_seqno_for(leader_shard, leader_last_seq); let applied = self.applied_for_leader_shard(leader_shard); + // m12 seed-join promotion fix: report this follower's caught-up frontier + // BACK to the leader on the heartbeat path. The leader's durable per-peer + // `learner_mark` advances ONLY from a follower frontier-report, which the + // receiver emits after APPLYING a streamed event. A node that converged via + // a SNAPSHOT INSTALL (frontier seeded at boot, the leader's WAL already + // covering it) has nothing to stream-apply, so it never tells the leader it + // is caught up — and the auto-promotion gate (`flushed - learner_mark <= + // learner_promote_lag`) strands a seed-joined Learner forever (regression: + // `mp_seed_join_snapshot_catchup`; before Fix 1's frontier-seed the joiner + // re-pulled from seqno 1 and THOSE applies emitted the reports that promoted + // it). The heartbeat flows on an IDLE cluster and carries the CURRENT term, + // so this report is both recurring (survives the join/registration race) and + // term-correct (the term-checked `update_peer_for_term` fold accepts it, + // unlike a boot-time report stamped term 0). `notify_applied` dedups per + // shard (only an ADVANCED frontier pushes), so a steady follower never spams. + // Safe: it reports this node's TRUE applied frontier (never above what it + // durably holds, so it cannot over-credit a not-caught-up node), and learner + // marks never feed the quorum commit index — they gate promotion only. + // SCOPED to a LEARNER: a Voter's frontier already reaches the leader via + // the ship-ack path, and a voter mark DOES feed `compute_commit`, so folding + // one off the heartbeat could perturb the same-term commit gate (Raft fig-8); + // a learner mark never feeds `compute_commit`, so this is provably + // commit-safe and is exactly the signal the auto-promotion gate consumes. + if applied > 0 + && self.membership.self_role() == Some(tidaldb::wal::format::MemberRole::Learner) + { + self.transport.notify_applied(leader_shard, applied); + } if (self.install_boot || self.seed_joiner) && !self.converged.load(Ordering::Acquire) { self.note_lag_for_readiness(leader_last_seq.saturating_sub(applied)); }