pub(super) mod fixtures;
use std::error::Error;
use std::sync::Arc;
use std::time::Duration;
use beamr::process::ExitReason;
use super::{DispatchConversation, DispatchError, select_first};
use crate::conversation::ParticipantPid;
use crate::routing::function::loader::{ModuleLoader, RoutingModule};
use crate::routing::group::{ConsumerGroup, ConsumerRegistration};
use crate::routing::{
ConsumerId, ConsumerStateView, RoutingDecision, RoutingFunction, RoutingMessage,
};
use fixtures::ControlledConsumer;
fn select_by_target() -> RoutingFunction {
ModuleLoader::new().load(RoutingModule::new(
b"dispatch-by-target",
|message, consumers| {
let Some(crate::routing::FieldValue::Text(target)) = message.get("target") else {
return RoutingDecision::none();
};
consumers
.iter()
.find(|state| state.consumer.as_str() == target)
.map_or_else(RoutingDecision::none, |state| {
RoutingDecision::select(state.consumer.clone())
})
},
))
}
fn registration(id: &str, pid: u64) -> ConsumerRegistration {
ConsumerRegistration::new(
ParticipantPid::new(pid),
ConsumerStateView::new(ConsumerId::new(id), 0, 4, 0, Vec::new()),
)
}
fn has_link_to(scheduler: &beamr::scheduler::Scheduler, consumer_pid: u64) -> bool {
(consumer_pid.saturating_sub(256)..consumer_pid + 256)
.filter(|candidate| *candidate != consumer_pid && *candidate != 0)
.any(|candidate| scheduler.is_linked(candidate, consumer_pid))
}
struct LinkReady {
hook: beamr::hook::Hook,
ready: std::sync::mpsc::Receiver<()>,
}
impl LinkReady {
fn wait(&self) -> Result<(), String> {
self.ready
.recv()
.map_err(|error| format!("dispatch link-ready signal disconnected: {error}"))
}
}
impl Drop for LinkReady {
fn drop(&mut self) {
self.hook.unregister();
}
}
fn arm_link_signal(scheduler: &Arc<beamr::scheduler::Scheduler>, consumer_pid: u64) -> LinkReady {
let (ready_tx, ready) = std::sync::mpsc::channel();
let hook = scheduler.hook().clone();
let scheduler = Arc::clone(scheduler);
let fired = std::sync::atomic::AtomicBool::new(false);
hook.register(move |event| {
if scheduler.is_linked(event.pid, consumer_pid)
&& !fired.swap(true, std::sync::atomic::Ordering::AcqRel)
{
if let Err(error) = ready_tx.send(()) {
tracing::error!(%error, "dispatch link-ready signal disconnected");
}
}
beamr::hook::HookDecision::Continue
});
LinkReady { hook, ready }
}
#[test]
fn dispatch_selects_consumer_via_routing_function() -> Result<(), Box<dyn Error>> {
let dispatch = DispatchConversation::new(ConsumerGroup::new(select_by_target()))?;
let a_fixture = ControlledConsumer::new(dispatch.supervisor())?;
a_fixture.permit.release()?;
let a = a_fixture.pid;
let b_fixture = ControlledConsumer::new(dispatch.supervisor())?;
b_fixture.permit.release()?;
let b = b_fixture.pid;
let c_fixture = ControlledConsumer::new(dispatch.supervisor())?;
c_fixture.permit.release()?;
let c = c_fixture.pid;
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
a,
ConsumerStateView::new(ConsumerId::new("A"), 0, 4, 0, Vec::new()),
));
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
b,
ConsumerStateView::new(ConsumerId::new("B"), 0, 4, 0, Vec::new()),
));
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
c,
ConsumerStateView::new(ConsumerId::new("C"), 0, 4, 0, Vec::new()),
));
let message =
RoutingMessage::new().with("target", crate::routing::FieldValue::Text("B".to_owned()));
let outcome = dispatch.dispatch(&message)?;
assert_eq!(outcome.delivered_to(), &ConsumerId::new("B"));
assert!(outcome.delivered_first_try());
dispatch.supervisor().shutdown();
Ok(())
}
#[test]
fn dispatch_links_to_selected_consumer_before_completion() -> Result<(), Box<dyn Error>> {
let dispatch = DispatchConversation::new(ConsumerGroup::new(select_first()))?;
let scheduler = dispatch.supervisor().scheduler();
let consumer_fixture = ControlledConsumer::new(dispatch.supervisor())?;
let consumer = consumer_fixture.pid;
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
consumer,
ConsumerStateView::new(ConsumerId::new("solo"), 0, 4, 0, Vec::new()),
));
let dispatch_for_thread = dispatch.clone();
let link_ready = arm_link_signal(&scheduler, consumer.get());
let worker = std::thread::spawn(move || dispatch_for_thread.dispatch(&RoutingMessage::new()));
link_ready.wait()?;
let linked = has_link_to(&scheduler, consumer.get());
assert!(linked, "dispatch must link to the selected consumer");
consumer_fixture.permit.release()?;
let outcome = worker.join().map_err(|_| "dispatch thread panicked")??;
assert_eq!(outcome.delivered_to(), &ConsumerId::new("solo"));
dispatch.supervisor().shutdown();
Ok(())
}
#[test]
fn consumer_crash_reroutes_to_backup_with_real_sub_millisecond_latency()
-> Result<(), Box<dyn Error>> {
let mut dispatch = DispatchConversation::new(ConsumerGroup::new(select_first()))?;
let scheduler = dispatch.supervisor().scheduler();
let primary_fixture = ControlledConsumer::new(dispatch.supervisor())?;
let primary = primary_fixture.pid;
let backup_fixture = ControlledConsumer::new(dispatch.supervisor())?;
backup_fixture.permit.release()?;
let backup = backup_fixture.pid;
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
primary,
ConsumerStateView::new(ConsumerId::new("a-primary"), 0, 4, 0, Vec::new()),
));
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
backup,
ConsumerStateView::new(ConsumerId::new("b-backup"), 0, 4, 0, Vec::new()),
));
let link_ready = super::acceptance_tests::arm_waiter_signal(&mut dispatch, primary);
let crasher = std::thread::spawn(move || -> Result<(), String> {
link_ready.wait()?;
scheduler.terminate_process(primary.get(), ExitReason::Error);
Ok(())
});
let outcome = dispatch.dispatch(&RoutingMessage::new())?;
crasher.join().map_err(|_| "crasher thread panicked")??;
assert_eq!(primary_fixture.permit.messages_taken(), 0);
assert_eq!(outcome.delivered_to(), &ConsumerId::new("b-backup"));
assert_eq!(outcome.rerouted_from(), &[ConsumerId::new("a-primary")]);
let timings = outcome.reroute_timings();
assert_eq!(timings.len(), 1, "exactly one reroute timing expected");
let latency = timings[0].detection_to_reroute();
eprintln!(
"quiet crash detection_to_reroute_us={}",
latency.as_micros()
);
assert!(
latency < Duration::from_millis(1),
"crash-to-reroute latency {latency:?} exceeded one millisecond"
);
assert!(
timings[0].crash_observed() <= std::time::Instant::now(),
"crash instant must lie in the past"
);
dispatch.supervisor().shutdown();
Ok(())
}
#[test]
fn reroute_timing_spans_real_exit_to_reroute_via_event_path() -> Result<(), Box<dyn Error>> {
let mut dispatch = DispatchConversation::new(ConsumerGroup::new(select_first()))?;
let scheduler = dispatch.supervisor().scheduler();
let primary_fixture = ControlledConsumer::new(dispatch.supervisor())?;
let primary = primary_fixture.pid;
let backup_fixture = ControlledConsumer::new(dispatch.supervisor())?;
backup_fixture.permit.release()?;
let backup = backup_fixture.pid;
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
primary,
ConsumerStateView::new(ConsumerId::new("a-primary"), 0, 4, 0, Vec::new()),
));
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
backup,
ConsumerStateView::new(ConsumerId::new("b-backup"), 0, 4, 0, Vec::new()),
));
let before_dispatch = std::time::Instant::now();
let link_ready = super::acceptance_tests::arm_waiter_signal(&mut dispatch, primary);
let crasher = std::thread::spawn(move || -> Result<(), String> {
link_ready.wait()?;
scheduler.terminate_process(primary.get(), ExitReason::Error);
Ok(())
});
let outcome = dispatch.dispatch(&RoutingMessage::new())?;
crasher.join().map_err(|_| "crasher thread panicked")??;
assert_eq!(primary_fixture.permit.messages_taken(), 0);
let timing = outcome
.reroute_timings()
.first()
.ok_or("a reroute timing must be recorded after a crash")?;
assert!(
timing.crash_observed() >= before_dispatch,
"EXIT instant must fall after dispatch started"
);
eprintln!(
"quiet EXIT detection_to_reroute_us={}",
timing.detection_to_reroute().as_micros()
);
assert!(timing.detection_to_reroute() < Duration::from_millis(1));
dispatch.supervisor().shutdown();
Ok(())
}
#[test]
fn no_remaining_consumer_after_crash_is_an_error() -> Result<(), Box<dyn Error>> {
let mut dispatch = DispatchConversation::new(ConsumerGroup::new(select_first()))?;
let scheduler = dispatch.supervisor().scheduler();
let only_fixture = ControlledConsumer::new(dispatch.supervisor())?;
let only = only_fixture.pid;
let _ = dispatch.group().add_consumer(ConsumerRegistration::new(
only,
ConsumerStateView::new(ConsumerId::new("only"), 0, 4, 0, Vec::new()),
));
let link_ready = super::acceptance_tests::arm_waiter_signal(&mut dispatch, only);
let crasher = std::thread::spawn(move || -> Result<(), String> {
link_ready.wait()?;
scheduler.terminate_process(only.get(), ExitReason::Error);
Ok(())
});
let result = dispatch.dispatch(&RoutingMessage::new());
crasher.join().map_err(|_| "crasher thread panicked")??;
assert_eq!(only_fixture.permit.messages_taken(), 0);
assert_eq!(result, Err(DispatchError::NoConsumerAvailable));
dispatch.supervisor().shutdown();
Ok(())
}
#[test]
fn empty_group_yields_no_consumer_available() -> Result<(), Box<dyn Error>> {
let dispatch = DispatchConversation::new(ConsumerGroup::new(select_first()))?;
let result = dispatch.dispatch(&RoutingMessage::new());
assert_eq!(result, Err(DispatchError::NoConsumerAvailable));
dispatch.supervisor().shutdown();
Ok(())
}
#[test]
fn registration_helper_constructs_consumer_state() {
let registration = registration("helper", 1);
assert_eq!(registration.consumer(), &ConsumerId::new("helper"));
}
#[test]
fn delayed_exit_observation_records_scheduling_without_refusing_reroute()
-> Result<(), Box<dyn Error>> {
let mut dispatch = DispatchConversation::new(ConsumerGroup::new(select_first()))?;
let primary = ControlledConsumer::new(dispatch.supervisor())?;
let backup = ControlledConsumer::new(dispatch.supervisor())?;
backup.permit.release()?;
let _ = dispatch
.group()
.add_consumer(registration("a-primary", primary.pid.get()));
let _ = dispatch
.group()
.add_consumer(registration("b-backup", backup.pid.get()));
let (probe, armed) = super::acceptance_tests::AttemptProbe::new();
let (observe, permission) = std::sync::mpsc::channel();
*probe
.observe_permission
.lock()
.map_err(|error| error.to_string())? = Some(permission);
dispatch.probe = Some(Arc::clone(&probe));
let worker_dispatch = dispatch.clone();
let worker = std::thread::spawn(move || worker_dispatch.dispatch(&RoutingMessage::new()));
assert_eq!(armed.recv()?, primary.pid);
dispatch
.supervisor()
.scheduler()
.terminate_process(primary.pid.get(), ExitReason::Error);
let captured = probe
.captured_exit
.lock()
.map_err(|error| error.to_string())?
.take()
.ok_or("EXIT capture missing")?;
let exited = captured.recv()?;
let checksum = (0..1_000_000_u64).fold(0_u64, |sum, value| {
sum.wrapping_add(std::hint::black_box(value).rotate_left(13))
});
assert_eq!(checksum, 499_999_500_000_u64 << 13);
let held = exited.elapsed();
eprintln!(
"delayed observer detection_to_release_us={}",
held.as_micros()
);
assert!(
held >= Duration::from_millis(1),
"independent work did not exercise delayed observation: {held:?}"
);
observe.send(())?;
let result = worker
.join()
.map_err(|_| "delayed observer dispatch worker panicked")?;
dispatch.supervisor().shutdown();
let outcome = result?;
assert_eq!(outcome.delivered_to(), &ConsumerId::new("b-backup"));
assert_eq!(outcome.rerouted_from(), &[ConsumerId::new("a-primary")]);
assert_eq!(outcome.reroute_timings().len(), 1);
let timing = outcome.reroute_timings()[0];
assert_eq!(timing.crash_observed(), exited);
assert!(timing.detection_to_reroute() >= held);
eprintln!(
"delayed observer detection_to_reroute_us={}",
timing.detection_to_reroute().as_micros()
);
assert_eq!(primary.permit.messages_taken(), 0);
assert_eq!(backup.permit.messages_taken(), 1);
assert_eq!(probe.reroutes.load(std::sync::atomic::Ordering::Acquire), 1);
Ok(())
}