use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::time::{Duration, Instant};
use melin_transport_core::health::RaftStatus;
use tracing::{info, warn};
use crate::durability_policy::DurabilityMode;
use crate::replication::ReplicaControlPlane;
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const PRIMARY_DOWN_GRACE: Duration = Duration::from_secs(3);
fn effective_acking_mode(observed: u8, local_fallback: u8) -> Option<DurabilityMode> {
let byte = if observed == crate::durability_policy::ACKING_MODE_UNKNOWN {
local_fallback
} else {
observed
};
DurabilityMode::from_u8(byte)
}
struct AutoPromotionInputs {
tip_ready: bool,
fenced: bool,
durability_mode: Option<DurabilityMode>,
primary_link_up: bool,
primary_link_down_for: Duration,
primary_observed: bool,
journal_empty: bool,
term: u64,
fence_epoch: u64,
}
fn auto_promotion_decision(inputs: &AutoPromotionInputs) -> Result<(), &'static str> {
if !inputs.tip_ready {
return Err("journal recovery has not seeded this node's tip yet");
}
if inputs.fenced {
return Err("node is fenced (superseded by a newer primary)");
}
if inputs.primary_link_up {
return Err("replication link to the primary is up — refusing to depose a live primary");
}
if inputs.primary_link_down_for < PRIMARY_DOWN_GRACE {
return Err(
"the primary link dropped only moments ago — waiting out the grace period to tell \
a transient blip from a real failure before failover",
);
}
if !inputs.primary_observed && inputs.journal_empty {
return Err(
"no primary has been observed since boot and the local journal is empty — a blank \
genesis node must not depose a primary that may still be starting; bring the \
primary up first, or promote manually",
);
}
match inputs.durability_mode {
Some(DurabilityMode::Local) => {
return Err(
"the primary acks under `local` durability — an election win cannot prove \
this node holds every acked order; promote manually if the lag is acceptable",
);
}
None => return Err("acking durability mode is unrecognised"),
Some(DurabilityMode::Hybrid | DurabilityMode::DurablyReplicated) => {}
}
if inputs.term <= inputs.fence_epoch {
return Err(
"election term is not above the fencing epoch (manual promotions outran raft \
terms) — promote manually; the alignment heals as terms advance",
);
}
Ok(())
}
fn consider_auto_promotion(
status: &RaftStatus,
control: &ReplicaControlPlane,
fence_state: &melin_transport_core::fence::FenceState,
durability_mode: &AtomicU8,
primary_link_down_for: Duration,
last_refused_term: &mut u64,
) {
if !status.running.load(Ordering::Relaxed)
|| status.role.load(Ordering::Relaxed) != RaftStatus::ROLE_LEADER
|| control.promote.is_requested()
{
return;
}
let term = status.term.load(Ordering::Relaxed);
let observed_mode = control.primary_acking_mode.load(Ordering::Acquire);
let inputs = AutoPromotionInputs {
tip_ready: control.tip_ready.load(Ordering::Acquire),
fenced: fence_state.is_fenced(),
durability_mode: effective_acking_mode(
observed_mode,
durability_mode.load(Ordering::Relaxed),
),
primary_link_up: control.primary_link_up.load(Ordering::Acquire),
primary_link_down_for,
primary_observed: observed_mode != crate::durability_policy::ACKING_MODE_UNKNOWN,
journal_empty: control.journal_tip.load().get() == 0,
term,
fence_epoch: fence_state.epoch(),
};
match auto_promotion_decision(&inputs) {
Ok(()) => {
if control.promote.request(term) {
info!(
node_id = status.node_id,
term, "elected leader — auto-promoting this replica"
);
}
}
Err(reason) => {
if *last_refused_term != term {
*last_refused_term = term;
warn!(
node_id = status.node_id,
term, reason, "elected leader but refusing auto-promotion"
);
}
}
}
}
pub(crate) fn spawn_auto_promotion(
status: Arc<RaftStatus>,
control: ReplicaControlPlane,
fence_state: Arc<melin_transport_core::fence::FenceState>,
durability_mode: Arc<AtomicU8>,
shutdown: Arc<AtomicBool>,
) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("raft-promotion".into())
.spawn(move || {
let mut last_refused_term = 0u64;
let mut primary_link_down_since: Option<Instant> = None;
while !shutdown.load(Ordering::Relaxed) {
if control.primary_link_up.load(Ordering::Acquire) {
primary_link_down_since = None;
} else if primary_link_down_since.is_none() {
primary_link_down_since = Some(Instant::now());
}
let primary_link_down_for = primary_link_down_since
.map(|since| since.elapsed())
.unwrap_or(Duration::ZERO);
consider_auto_promotion(
&status,
&control,
&fence_state,
&durability_mode,
primary_link_down_for,
&mut last_refused_term,
);
if control.promote.is_requested() {
return;
}
std::thread::sleep(POLL_INTERVAL);
}
})
.expect("failed to spawn raft-promotion thread")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::durability_policy::ACKING_MODE_UNKNOWN;
fn ok_inputs() -> AutoPromotionInputs {
AutoPromotionInputs {
tip_ready: true,
fenced: false,
durability_mode: Some(DurabilityMode::Hybrid),
primary_link_up: false,
primary_link_down_for: PRIMARY_DOWN_GRACE * 10,
primary_observed: true,
journal_empty: false,
term: 5,
fence_epoch: 3,
}
}
#[test]
fn healthy_leader_promotes() {
assert!(auto_promotion_decision(&ok_inputs()).is_ok());
}
#[test]
fn refuses_before_tip_ready() {
let inputs = AutoPromotionInputs {
tip_ready: false,
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("tip")
);
}
#[test]
fn refuses_when_fenced() {
let inputs = AutoPromotionInputs {
fenced: true,
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("fenced")
);
}
#[test]
fn refuses_while_primary_link_is_up() {
let inputs = AutoPromotionInputs {
primary_link_up: true,
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("live primary")
);
}
#[test]
fn refuses_on_a_transient_primary_link_blip() {
let inputs = AutoPromotionInputs {
primary_link_up: false,
primary_link_down_for: PRIMARY_DOWN_GRACE - Duration::from_millis(1),
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("moments ago"),
"a sub-grace outage must be refused as a possible blip"
);
let inputs = AutoPromotionInputs {
primary_link_up: false,
primary_link_down_for: PRIMARY_DOWN_GRACE,
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs).is_ok(),
"a sustained outage past the grace must promote"
);
}
#[test]
fn refuses_a_blank_genesis_node_but_not_a_restarted_replica() {
let inputs = AutoPromotionInputs {
primary_observed: false,
journal_empty: true,
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("journal is empty")
);
let inputs = AutoPromotionInputs {
primary_observed: false,
journal_empty: false,
..ok_inputs()
};
assert!(auto_promotion_decision(&inputs).is_ok());
let inputs = AutoPromotionInputs {
primary_observed: true,
journal_empty: true,
..ok_inputs()
};
assert!(auto_promotion_decision(&inputs).is_ok());
}
#[test]
fn refuses_local_durability_and_unknown_modes() {
let inputs = AutoPromotionInputs {
durability_mode: Some(DurabilityMode::Local),
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("local")
);
let inputs = AutoPromotionInputs {
durability_mode: None,
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("unrecognised")
);
}
#[test]
fn refuses_when_epochs_outran_terms() {
for (term, fence_epoch) in [(3, 3), (2, 3)] {
let inputs = AutoPromotionInputs {
term,
fence_epoch,
..ok_inputs()
};
assert!(
auto_promotion_decision(&inputs)
.unwrap_err()
.contains("term"),
"term {term} epoch {fence_epoch}"
);
}
let inputs = AutoPromotionInputs {
term: 4,
fence_epoch: 3,
..ok_inputs()
};
assert!(auto_promotion_decision(&inputs).is_ok());
}
#[test]
fn effective_acking_mode_prefers_the_observed_primary_mode() {
assert_eq!(
effective_acking_mode(
DurabilityMode::Local.as_u8(),
DurabilityMode::Hybrid.as_u8()
),
Some(DurabilityMode::Local)
);
assert_eq!(
effective_acking_mode(ACKING_MODE_UNKNOWN, DurabilityMode::Hybrid.as_u8()),
Some(DurabilityMode::Hybrid)
);
assert_eq!(
effective_acking_mode(0x7F, DurabilityMode::Hybrid.as_u8()),
None
);
}
}