use std::io;
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread::JoinHandle;
use melin_transport_core::replication::protocol::Handshake;
use melin_transport_core::replication::validate::HandshakeValidation;
struct ValidationJob {
handshake: Handshake,
verdict_tx: Sender<io::Result<HandshakeValidation>>,
}
pub(crate) struct ValidationWorker {
jobs: Option<Sender<ValidationJob>>,
handle: Option<JoinHandle<()>>,
}
impl ValidationWorker {
pub(crate) fn spawn<F>(name: String, journal_path: PathBuf, validate: F) -> io::Result<Self>
where
F: Fn(&std::path::Path, &Handshake) -> io::Result<HandshakeValidation> + Send + 'static,
{
let (jobs_tx, jobs_rx) = channel::<ValidationJob>();
let handle = spawn_unpinned(name, move || worker_loop(journal_path, jobs_rx, validate))?;
Ok(ValidationWorker {
jobs: Some(jobs_tx),
handle: Some(handle),
})
}
pub(crate) fn submit(
&self,
handshake: Handshake,
) -> Result<Receiver<io::Result<HandshakeValidation>>, WorkerGone> {
let (verdict_tx, verdict_rx) = channel();
let jobs = self.jobs.as_ref().ok_or(WorkerGone)?;
jobs.send(ValidationJob {
handshake,
verdict_tx,
})
.map_err(|_| WorkerGone)?;
Ok(verdict_rx)
}
}
impl Drop for ValidationWorker {
fn drop(&mut self) {
self.jobs = None;
if let Some(handle) = self.handle.take()
&& handle.join().is_err()
{
tracing::error!("handshake validation worker panicked");
}
}
}
pub(super) fn spawn_unpinned(
name: String,
body: impl FnOnce() + Send + 'static,
) -> io::Result<JoinHandle<()>> {
melin_app::affinity::with_child_context(&name, 0, || {
std::thread::Builder::new().name(name.clone()).spawn(body)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct WorkerGone;
impl std::fmt::Display for WorkerGone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("replication worker is no longer running")
}
}
fn worker_loop<F>(journal_path: PathBuf, jobs: Receiver<ValidationJob>, validate: F)
where
F: Fn(&std::path::Path, &Handshake) -> io::Result<HandshakeValidation>,
{
for job in jobs {
let verdict = validate(&journal_path, &job.handshake);
let _ = job.verdict_tx.send(verdict);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::TryRecvError;
fn handshake(last_sequence: u64) -> Handshake {
Handshake {
last_sequence,
chain_hash: [0u8; 32],
epoch: 1,
}
}
#[test]
fn a_submitted_handshake_gets_its_verdict() {
let worker = ValidationWorker::spawn("test-validate".into(), PathBuf::from("/journal"), {
|path: &std::path::Path, hs: &Handshake| {
assert_eq!(path, std::path::Path::new("/journal"));
assert_eq!(hs.last_sequence, 42);
Ok(HandshakeValidation::Ok)
}
})
.unwrap();
let rx = worker.submit(handshake(42)).unwrap();
assert_eq!(
rx.recv().unwrap().unwrap(),
HandshakeValidation::Ok,
"the verdict should reach the submitter"
);
}
#[test]
fn successive_handshakes_run_on_the_same_thread() {
let ids = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = Arc::clone(&ids);
let worker = ValidationWorker::spawn(
"test-validate".into(),
PathBuf::from("/journal"),
move |_p: &std::path::Path, _h: &Handshake| {
seen.lock()
.expect("test mutex poisoned")
.push(std::thread::current().id());
Ok(HandshakeValidation::Ok)
},
)
.unwrap();
for seq in 0..4u64 {
let rx = worker.submit(handshake(seq)).unwrap();
assert_eq!(rx.recv().unwrap().unwrap(), HandshakeValidation::Ok);
}
let ids = ids.lock().expect("test mutex poisoned");
assert_eq!(ids.len(), 4);
assert!(
ids.windows(2).all(|w| w[0] == w[1]),
"every validation must run on the one long-lived worker thread, saw {ids:?}"
);
}
#[test]
fn a_dropped_verdict_receiver_does_not_kill_the_worker() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let worker = ValidationWorker::spawn(
"test-validate".into(),
PathBuf::from("/journal"),
move |_p: &std::path::Path, _h: &Handshake| {
counter.fetch_add(1, Ordering::Relaxed);
Ok(HandshakeValidation::Ok)
},
)
.unwrap();
drop(worker.submit(handshake(1)).unwrap());
let rx = worker.submit(handshake(2)).unwrap();
assert_eq!(rx.recv().unwrap().unwrap(), HandshakeValidation::Ok);
assert_eq!(
calls.load(Ordering::Relaxed),
2,
"the abandoned job must still have been serviced"
);
}
#[test]
fn an_io_error_is_delivered_as_a_verdict() {
let worker = ValidationWorker::spawn(
"test-validate".into(),
PathBuf::from("/journal"),
|_p: &std::path::Path, _h: &Handshake| {
Err(io::Error::new(io::ErrorKind::NotFound, "no segment"))
},
)
.unwrap();
let rx = worker.submit(handshake(7)).unwrap();
assert_eq!(
rx.recv().unwrap().unwrap_err().kind(),
io::ErrorKind::NotFound
);
}
#[test]
fn submission_does_not_wait_for_the_verdict() {
let (release_tx, release_rx) = channel::<()>();
let gate = std::sync::Mutex::new(release_rx);
let worker = ValidationWorker::spawn(
"test-validate".into(),
PathBuf::from("/journal"),
move |_p: &std::path::Path, _h: &Handshake| {
gate.lock()
.expect("test mutex poisoned")
.recv()
.expect("release channel closed");
Ok(HandshakeValidation::Ok)
},
)
.unwrap();
let rx = worker.submit(handshake(1)).unwrap();
assert!(
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
"the verdict must not be ready while the worker is still blocked"
);
release_tx.send(()).unwrap();
assert_eq!(rx.recv().unwrap().unwrap(), HandshakeValidation::Ok);
}
#[test]
fn the_worker_does_not_inherit_a_pinned_parents_affinity() {
let total = cpu_count_in_mask();
if total < 2 {
return;
}
let core = melin_app::affinity::first_allowed_cpu().expect("at least one CPU in the mask");
let observed = Arc::new(AtomicUsize::new(0));
let verdict = {
let sink = Arc::clone(&observed);
std::thread::spawn(move || {
melin_app::affinity::pin_to_core(core).expect("pin the parent thread");
let worker = ValidationWorker::spawn(
"test-validate".into(),
PathBuf::from("/journal"),
move |_p: &std::path::Path, _h: &Handshake| {
sink.store(cpu_count_in_mask(), Ordering::Relaxed);
Ok(HandshakeValidation::Ok)
},
)
.unwrap();
let rx = worker.submit(handshake(1)).unwrap();
rx.recv().unwrap().unwrap()
})
.join()
.expect("parent thread")
};
assert_eq!(verdict, HandshakeValidation::Ok);
assert_eq!(
observed.load(Ordering::Relaxed),
total,
"the worker must run on the full CPU mask, not the parent's single core"
);
}
fn cpu_count_in_mask() -> usize {
unsafe {
let mut set: libc::cpu_set_t = std::mem::zeroed();
if libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut set) != 0 {
return 0;
}
(0..libc::CPU_SETSIZE as usize)
.filter(|&i| libc::CPU_ISSET(i, &set))
.count()
}
}
}