use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{debug, info};
use crate::control::state::SharedState;
const POLL_INTERVAL: Duration = Duration::from_millis(50);
pub const DATA_GROUP_RECOVERY_TIMEOUT: Duration = Duration::from_secs(60);
fn is_data_group(group_id: u64) -> bool {
group_id != nodedb_cluster::METADATA_GROUP_ID
&& group_id != nodedb_cluster::calvin::SEQUENCER_GROUP_ID
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupWait {
NoLeader,
LogUncommitted {
commit_index: u64,
last_log_index: u64,
},
ReplayLagging {
commit_index: u64,
last_applied: u64,
},
}
fn group_wait(
leader_id: u64,
commit_index: u64,
last_log_index: u64,
last_applied: u64,
) -> Option<GroupWait> {
if leader_id == 0 {
return Some(GroupWait::NoLeader);
}
if last_log_index == 0 {
return None;
}
if commit_index < last_log_index {
return Some(GroupWait::LogUncommitted {
commit_index,
last_log_index,
});
}
if last_applied >= commit_index {
return None;
}
Some(GroupWait::ReplayLagging {
commit_index,
last_applied,
})
}
#[derive(Debug, Clone, Copy)]
struct PendingGroup {
group_id: u64,
wait: GroupWait,
}
impl std::fmt::Display for PendingGroup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.wait {
GroupWait::NoLeader => write!(f, "group {} has no leader", self.group_id),
GroupWait::LogUncommitted {
commit_index,
last_log_index,
} => write!(
f,
"group {} committed {commit_index} of {last_log_index} retained entries",
self.group_id
),
GroupWait::ReplayLagging {
commit_index,
last_applied,
} => write!(
f,
"group {} applied {last_applied} of {commit_index} committed entries",
self.group_id
),
}
}
}
fn pending_groups(statuses: Vec<nodedb_cluster::GroupStatus>) -> Vec<PendingGroup> {
statuses
.into_iter()
.filter(|s| is_data_group(s.group_id))
.filter_map(|s| {
group_wait(
s.leader_id,
s.commit_index,
s.last_log_index,
s.last_applied,
)
.map(|wait| PendingGroup {
group_id: s.group_id,
wait,
})
})
.collect()
}
pub async fn await_data_group_recovery(shared: &Arc<SharedState>) -> anyhow::Result<()> {
let Some(status_fn) = shared.raft_status_fn.get() else {
return Ok(());
};
let status_fn = Arc::clone(status_fn);
let deadline = Instant::now() + DATA_GROUP_RECOVERY_TIMEOUT;
loop {
let pending = pending_groups(status_fn());
if pending.is_empty() {
info!("all local data raft groups replayed — opening client gateway");
return Ok(());
}
if Instant::now() >= deadline {
let detail = pending
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join("; ");
return Err(anyhow::anyhow!(
"data raft group recovery timeout after {DATA_GROUP_RECOVERY_TIMEOUT:?}: {detail}"
));
}
debug!(
pending = pending.len(),
"waiting for data raft group replay"
);
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_and_sequencer_groups_are_not_data_groups() {
assert!(!is_data_group(nodedb_cluster::METADATA_GROUP_ID));
assert!(!is_data_group(nodedb_cluster::calvin::SEQUENCER_GROUP_ID));
}
#[test]
fn ordinary_group_ids_are_data_groups() {
assert!(is_data_group(1));
assert!(is_data_group(4_294_967_295));
}
#[test]
fn leaderless_group_waits_for_election() {
assert_eq!(group_wait(0, 0, 0, 0), Some(GroupWait::NoLeader));
assert_eq!(group_wait(0, 9, 9, 9), Some(GroupWait::NoLeader));
}
#[test]
fn empty_log_is_recovered_once_leader_exists() {
assert_eq!(group_wait(1, 0, 0, 0), None);
}
#[test]
fn fresh_leader_with_uncommitted_retained_log_waits() {
assert_eq!(
group_wait(1, 0, 12, 0),
Some(GroupWait::LogUncommitted {
commit_index: 0,
last_log_index: 12,
})
);
}
#[test]
fn partially_committed_retained_log_waits() {
assert_eq!(
group_wait(1, 5, 12, 5),
Some(GroupWait::LogUncommitted {
commit_index: 5,
last_log_index: 12,
})
);
}
#[test]
fn committed_but_unapplied_log_waits() {
assert_eq!(
group_wait(1, 12, 12, 4),
Some(GroupWait::ReplayLagging {
commit_index: 12,
last_applied: 4,
})
);
}
#[test]
fn election_noop_only_log_converges() {
assert_eq!(group_wait(1, 1, 1, 1), None);
}
#[test]
fn caught_up_group_is_recovered() {
assert_eq!(group_wait(1, 12, 12, 12), None);
assert_eq!(group_wait(1, 12, 12, 13), None);
}
}