use std::collections::HashMap;
use std::time::Duration;
use tokio::time::Instant;
use ursula_raft::RaftGroupHandleRegistry;
use ursula_shard::RaftGroupId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CommitStallAction {
pub group_id: u32,
pub targets: Vec<u64>,
pub stalled_for: Duration,
pub last_log: Option<u64>,
pub committed: Option<u64>,
}
#[derive(Default)]
pub(crate) struct CommitStallTracker {
baseline: HashMap<u32, (Option<u64>, Option<u64>, Instant)>,
}
impl CommitStallTracker {
pub fn evaluate(
&mut self,
snaps: &[ursula_raft::RaftGroupMetricsSnapshot],
my_id: u64,
now: Instant,
threshold: Duration,
) -> Vec<CommitStallAction> {
self.baseline.retain(|gid, _| {
snaps
.iter()
.any(|s| s.raft_group_id == *gid && s.current_leader == Some(my_id))
});
let mut leader_count: HashMap<u64, usize> = HashMap::new();
for snap in snaps {
if let Some(leader) = snap.current_leader {
*leader_count.entry(leader).or_insert(0) += 1;
}
}
let mut actions = Vec::new();
for snap in snaps {
if snap.current_leader != Some(my_id) {
continue;
}
let last_log = snap.last_log_index;
let committed = snap.committed.map(|c| c.index);
let has_gap = match (last_log, committed) {
(Some(ll), Some(c)) => ll > c,
(Some(_), None) => true,
_ => false,
};
if !has_gap {
self.baseline.remove(&snap.raft_group_id);
continue;
}
let entry = self
.baseline
.entry(snap.raft_group_id)
.or_insert((last_log, committed, now));
if entry.0 != last_log || entry.1 != committed {
*entry = (last_log, committed, now);
continue;
}
let stalled_for = now.duration_since(entry.2);
if stalled_for < threshold {
continue;
}
let mut targets: Vec<u64> = snap
.voter_ids
.iter()
.copied()
.filter(|v| *v != my_id)
.collect();
targets.sort_by_key(|v| (leader_count.get(v).copied().unwrap_or(0), *v));
if targets.is_empty() {
continue;
}
actions.push(CommitStallAction {
group_id: snap.raft_group_id,
targets,
stalled_for,
last_log,
committed,
});
self.baseline.remove(&snap.raft_group_id);
}
actions
}
}
pub fn spawn_commit_stall_watchdog(
registry: &RaftGroupHandleRegistry,
cs_cfg: &ursula_config::CommitStallConfig,
) {
let interval_ms = cs_cfg.interval.as_duration().as_millis() as usize;
if interval_ms == 0 {
return;
}
let threshold_ms = cs_cfg.threshold.as_duration().as_millis() as usize;
let registry = registry.clone();
tokio::spawn(async move {
let interval = Duration::from_millis(u64::try_from(interval_ms).unwrap_or(2_000));
let threshold = Duration::from_millis(u64::try_from(threshold_ms).unwrap_or(15_000));
let mut tracker = CommitStallTracker::default();
loop {
tokio::time::sleep(interval).await;
let snaps = registry.metrics_snapshot();
if snaps.is_empty() {
continue;
}
let my_id = snaps[0].node_id;
let actions = tracker.evaluate(&snaps, my_id, Instant::now(), threshold);
for action in actions {
let Some(raft) = registry.get(RaftGroupId(action.group_id)) else {
continue;
};
tracing::warn!(
"commit-stall: node {my_id} group {} stalled {:.1}s (last_log={:?} committed={:?}); trying targets {:?}",
action.group_id,
action.stalled_for.as_secs_f64(),
action.last_log,
action.committed,
action.targets,
);
let mut handed_off = false;
for target in &action.targets {
match raft.trigger().transfer_leader(*target).await {
Ok(()) => {
tracing::warn!(
"commit-stall: group {} handed off -> {}",
action.group_id,
target
);
handed_off = true;
break;
}
Err(err) => {
tracing::error!(
"commit-stall: transfer_leader group {} -> {} failed: {err}; trying next target",
action.group_id,
target
);
}
}
}
if !handed_off {
tracing::error!(
"commit-stall: group {} no target accepted transfer (all {} candidates failed); will retry after threshold",
action.group_id,
action.targets.len(),
);
}
}
}
});
}