use super::transfer::ABSENT_MS;
use super::*;
use fusee::{Ctx, Engine, Error};
use std::sync::atomic::AtomicU32;
use tokio::time::{sleep, timeout};
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"fusee-{name}-{}-{}",
std::process::id(),
fusee::local::unix_ms()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn free_ports(n: usize) -> Vec<String> {
let ls: Vec<std::net::TcpListener> = (0..n)
.map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap())
.collect();
ls.iter()
.map(|l| l.local_addr().unwrap().to_string())
.collect()
}
async fn member(id: u8, peers: &[String], root: &Path, election_ms: u64) -> ReplicaNode {
ReplicaNode::start(ReplicaConfig {
id,
peers: peers.to_vec(),
join: false,
dir: root.join(format!("r{id}")),
local: LocalOptions {
flush: FlushMethod::Fsync,
..Default::default()
},
timing: Timing {
heartbeat_ms: 20,
election_ms,
},
security: Default::default(),
})
.await
.unwrap()
}
async fn ready_one(nodes: &[Option<ReplicaNode>]) -> usize {
let deadline = Instant::now() + Duration::from_secs(20);
let mut stable = None;
loop {
let mut ready = nodes.iter().enumerate().filter_map(|(i, n)| {
let status = n.as_ref()?.status();
status.ready.then_some((i, status.view))
});
match (ready.next(), ready.next()) {
(Some(leader), None) => match stable {
Some((prior, since))
if prior == leader && Instant::now() - since > Duration::from_millis(300) =>
{
return leader.0;
}
Some((prior, _)) if prior == leader => {}
_ => stable = Some((leader, Instant::now())),
},
_ => stable = None,
}
assert!(Instant::now() < deadline, "no stable primary");
sleep(Duration::from_millis(10)).await;
}
}
async fn until(
what: &str,
deadline: Instant,
nodes: &[Option<ReplicaNode>],
mut done: impl FnMut(&[Option<ReplicaNode>]) -> bool,
) {
while !done(nodes) {
let statuses: Vec<_> = nodes
.iter()
.map(|n| n.as_ref().map(|n| n.status()))
.collect();
assert!(
Instant::now() < deadline,
"timed out waiting: {what}: {statuses:#?}"
);
sleep(Duration::from_millis(15)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_burst_beyond_the_proposal_bound_commits_without_waking_status_waiters() {
let root = scratch("proposal-burst");
let peers = free_ports(3);
let mut nodes = Vec::new();
for id in 0..3 {
nodes.push(Some(member(id, &peers, &root, 1_000).await));
}
let p = ready_one(&nodes).await;
let node = nodes[p].as_ref().unwrap();
let mut watcher = node.subscribe();
let before = *watcher.borrow_and_update();
let limits = node.engine_config().limits.unwrap();
const THREADS: usize = 8;
const EACH: usize = PROPOSAL_QUEUE / 2;
const PROBES: usize = 256;
let spec = fusee::limits::LimitSpec {
concurrent: 1,
per_second: 0.0,
};
let producers: Vec<_> = (0..THREADS)
.map(|t| {
let limits = limits.clone();
std::thread::spawn(move || {
for _ in 0..EACH {
limits.define(&format!("burst-{t}"), spec);
}
})
})
.collect();
let storage = ReplicaStorage::new(node);
let mut probes = tokio::task::JoinSet::new();
for _ in 0..PROBES {
let storage = storage.clone();
probes.spawn(async move { storage.probe().await });
}
while let Some(probe) = probes.join_next().await {
probe.unwrap().unwrap();
}
for producer in producers {
producer.join().unwrap();
}
let target = before.commit + (THREADS * EACH + PROBES) as u64;
until(
"every proposal of the burst commits",
Instant::now() + Duration::from_secs(60),
&nodes,
|_| node.status().applied >= target,
)
.await;
let after = node.status();
assert!(after.op >= target && after.commit >= target, "{after:?}");
assert_eq!((after.view, after.ready), (before.view, before.ready));
assert!(
!watcher.has_changed().unwrap(),
"commits woke status waiters"
);
drop(nodes);
std::fs::remove_dir_all(root).unwrap();
}
fn register(engine: &Engine, calls: Arc<AtomicU32>) -> fusee::WorkflowRef<String, usize> {
engine.register("absent-job", move |ctx: Ctx, data: String| {
let calls = calls.clone();
async move {
let n = ctx
.step("size", || async {
calls.fetch_add(1, Ordering::SeqCst);
Ok::<_, Error>(data.len())
})
.await?;
let delta: Option<usize> = ctx.wait_for("go").await?;
Ok(n + delta.unwrap_or(0))
}
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn compaction_waits_only_for_reachable_members_and_a_late_member_catches_up() {
let root = scratch("absent-member");
let peers = free_ports(3);
let silent = tokio::net::TcpListener::bind(&peers[0]).await.unwrap();
let mute = tokio::spawn(async move {
let mut readers = tokio::task::JoinSet::new();
loop {
let Ok((mut stream, _)) = silent.accept().await else {
continue;
};
readers.spawn(async move {
let mut buf = vec![0; 1 << 16];
while matches!(stream.read(&mut buf).await, Ok(n) if n > 0) {}
});
}
});
let election = 1_000;
let mut nodes = vec![
None,
Some(member(1, &peers, &root, election).await),
Some(member(2, &peers, &root, election).await),
];
let p = ready_one(&nodes).await;
let b = 3 - p;
let setup = |node: &ReplicaNode| {
node.engine_config()
.checkpoint_interval(Duration::from_millis(50))
.park_after(Duration::ZERO)
};
let engine = Engine::open(setup(nodes[p].as_ref().unwrap()))
.await
.unwrap();
let calls = Arc::new(AtomicU32::new(0));
let wf = register(&engine, calls.clone());
let payload = "x".repeat(4096);
engine.start(&wf, "kept", &payload).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(60);
while engine.metrics().await.unwrap().runs_parked == 0 {
assert!(Instant::now() < deadline, "workflow failed to park");
sleep(Duration::from_millis(15)).await;
}
let hold = Instant::now() + Duration::from_millis(2 * ABSENT_MS);
while Instant::now() < hold {
let status = nodes[p].as_ref().unwrap().status();
assert!(status.ready, "primary changed while holding: {status:?}");
assert_eq!(
status.checkpoint, 0,
"compacted past a reachable member without checkpoint support"
);
sleep(Duration::from_millis(25)).await;
}
mute.abort();
let _ = mute.await;
let gone = Instant::now();
until("the primary compacts", deadline, &nodes, |nodes| {
nodes[p].as_ref().unwrap().status().checkpoint > 0
})
.await;
assert!(
gone.elapsed() >= Duration::from_millis(ABSENT_MS - 600),
"compacted {:?} after the member left",
gone.elapsed()
);
let base = nodes[p].as_ref().unwrap().status().checkpoint;
assert!(!root.join(format!("r{p}/vsr/log")).exists());
until(
"the other backup installs the image",
deadline,
&nodes,
|nodes| nodes[b].as_ref().unwrap().status().checkpoint >= base,
)
.await;
nodes[0] = Some(member(0, &peers, &root, election).await);
let committed = nodes[p].as_ref().unwrap().status().commit;
until("the late member catches up", deadline, &nodes, |nodes| {
let s = nodes[0].as_ref().unwrap().status();
s.checkpoint >= base && s.commit >= committed && !s.primary
})
.await;
nodes[b].take().unwrap().kill();
let second = engine
.start(&wf, "second", &"yy".to_string())
.await
.unwrap();
second.signal("go", &1usize).await.unwrap();
let result = timeout(Duration::from_secs(20), second.result())
.await
.expect("no quorum with the late member")
.unwrap();
assert_eq!(result, 3);
engine.shutdown().await.unwrap();
drop(second);
drop(wf);
drop(engine);
nodes[p].take().unwrap().kill();
nodes[b] = Some(member(b as u8, &peers, &root, election).await);
let q = ready_one(&nodes).await;
let engine = Engine::open(setup(nodes[q].as_ref().unwrap()))
.await
.unwrap();
let _wf = register(&engine, calls.clone());
assert_eq!(
timeout(
Duration::from_secs(20),
engine.run::<usize>("second").result()
)
.await
.unwrap()
.unwrap(),
3
);
let kept = engine.run::<usize>("kept");
kept.signal("go", &3usize).await.unwrap();
let result = timeout(Duration::from_secs(20), kept.result())
.await
.unwrap()
.unwrap();
assert_eq!(result, payload.len() + 3);
assert_eq!(calls.load(Ordering::SeqCst), 2, "a recorded step ran again");
engine.shutdown().await.unwrap();
drop(engine);
drop(nodes);
std::fs::remove_dir_all(root).unwrap();
}