use std::{
collections::{BTreeMap, HashMap, VecDeque},
path::PathBuf,
pin::pin,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::{Duration, Instant},
};
use dora_message::{
DataflowId,
daemon_to_node::{DaemonCommunication, DaemonReply, DataMessage, NodeEvent},
id::DataId,
node_to_daemon::{DaemonRequest, Timestamped},
};
pub use event::{Event, StopCause};
use futures::{
FutureExt, Stream,
future::{Either, select},
};
use futures_timer::Delay;
use scheduler::{NON_INPUT_EVENT, NON_INPUT_EVENT_QUEUE_SIZE, Scheduler};
use self::thread::{EventItem, EventStreamThreadHandle};
use crate::{
DaemonCommunicationWrapper, PatternError,
daemon_connection::{
DaemonChannel,
node_integration_testing::{convert_arrow_input_to_json, convert_output_to_json},
},
event_stream::data_conversion::RawData,
node::{ZENOH_TEARDOWN_TIMEOUT, teardown_with_timeout},
};
use dora_arrow_convert::IntoArrow;
use dora_core::{
config::{Input, NodeId},
uhlc,
};
use eyre::{Context, eyre};
pub use scheduler::Scheduler as EventScheduler;
mod data_conversion;
mod event;
pub mod extensions;
pub mod input_tracker;
pub mod merged;
mod scheduler;
mod thread;
fn send_or_count_ingress_drop(
tx: &tokio::sync::mpsc::Sender<EventItem>,
item: EventItem,
input_id: &DataId,
dropped: &AtomicU64,
) {
use tokio::sync::mpsc::error::TrySendError;
match tx.try_send(item) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
let count = dropped.fetch_add(1, Ordering::Relaxed) + 1;
if should_warn_ingress_drop(count) {
tracing::warn!(
input = %input_id,
drops = count,
"event channel full; dropping zenoh input. Raise this input's \
queue_size — the ingress channel is sized from the sum of the \
node's input queue_sizes."
);
}
}
Err(TrySendError::Closed(_)) => {
}
}
}
fn should_warn_ingress_drop(count: u64) -> bool {
count.is_power_of_two()
}
pub struct EventStream {
node_id: NodeId,
receiver: tokio::sync::mpsc::Receiver<EventItem>,
_thread_handle: EventStreamThreadHandle,
_zenoh_subscribers: Vec<zenoh::pubsub::Subscriber<()>>,
_zenoh_schema_subscribers: Vec<zenoh_ext::AdvancedSubscriber<()>>,
startup_acker: Option<std::thread::JoinHandle<()>>,
close_channel: DaemonChannel,
clock: Arc<uhlc::HLC>,
scheduler: Scheduler,
ingress_drops: HashMap<DataId, Arc<AtomicU64>>,
write_events_to: Option<WriteEventsTo>,
start_timestamp: uhlc::Timestamp,
use_scheduler: bool,
scheduler_maybe_nonempty: bool,
input_type_checks: HashMap<DataId, arrow_schema::DataType>,
pending_passthrough: std::collections::VecDeque<Event>,
stop_received: bool,
testing_shutdown: Option<Arc<AtomicBool>>,
}
fn spawn_startup_acker(
node_id: NodeId,
ack_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>>,
mut ack_rx: tokio::sync::mpsc::Receiver<DataId>,
clock: Arc<uhlc::HLC>,
) -> Option<std::thread::JoinHandle<()>> {
use dora_message::metadata::Metadata;
use zenoh::Wait;
if ack_publishers.is_empty() {
return None;
}
let handle = std::thread::Builder::new()
.name("dora-startup-acker".into())
.spawn(move || {
while let Some(input_id) = ack_rx.blocking_recv() {
let Some(publisher) = ack_publishers.get(&input_id) else {
continue;
};
let metadata = Metadata::startup_ack(
clock.new_timestamp(),
node_id.as_ref(),
input_id.as_ref(),
);
let attachment = match dora_message::encode(&metadata) {
Ok(bytes) => bytes,
Err(e) => {
tracing::debug!(input = %input_id, "failed to serialize startup ack ({e})");
continue;
}
};
if let Err(e) = publisher.put(&[][..]).attachment(&attachment[..]).wait() {
tracing::trace!(input = %input_id, "startup ack put failed ({e})");
}
}
});
match handle {
Ok(handle) => Some(handle),
Err(e) => {
tracing::warn!(
"failed to spawn startup-acker thread ({e}); \
producers keep this node's inputs on the daemon path"
);
None
}
}
}
fn connect_daemon_channel(
daemon_communication: &DaemonCommunicationWrapper,
node_id: &NodeId,
purpose: &str,
) -> eyre::Result<DaemonChannel> {
let channel = match daemon_communication {
DaemonCommunicationWrapper::Standard(daemon_communication) => match daemon_communication {
DaemonCommunication::Tcp { socket_addr } => DaemonChannel::new_tcp(*socket_addr)
.wrap_err_with(|| format!("failed to connect {purpose} for node `{node_id}`"))?,
DaemonCommunication::Interactive => DaemonChannel::Interactive(Default::default()),
},
DaemonCommunicationWrapper::Testing { channel, .. } => {
DaemonChannel::IntegrationTestChannel(channel.clone())
}
};
Ok(channel)
}
fn ingress_channel_capacity<'a>(inputs: impl Iterator<Item = &'a Input>) -> usize {
const MAX_CHANNEL_CAPACITY: usize = usize::MAX >> 3;
inputs
.map(|c| {
c.queue_size
.unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE)
})
.fold(0, usize::saturating_add)
.clamp(64, MAX_CHANNEL_CAPACITY)
}
impl EventStream {
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(level = "trace", skip(clock, zenoh_session))]
pub(crate) fn init(
dataflow_id: DataflowId,
node_id: &NodeId,
daemon_communication: &DaemonCommunicationWrapper,
input_config: BTreeMap<DataId, Input>,
input_types: &BTreeMap<DataId, String>,
clock: Arc<uhlc::HLC>,
write_events_to: Option<PathBuf>,
zenoh_session: Option<&zenoh::Session>,
) -> eyre::Result<Self> {
let channel = connect_daemon_channel(daemon_communication, node_id, "event stream")?;
let testing_shutdown = match daemon_communication {
DaemonCommunicationWrapper::Testing { shutdown, .. } => Some(shutdown.clone()),
_ => None,
};
let close_channel =
connect_daemon_channel(daemon_communication, node_id, "event close channel")?;
let mut queue_size_limit: HashMap<DataId, (usize, VecDeque<EventItem>)> = input_config
.iter()
.map(|(input, config)| {
(
input.clone(),
(
config
.queue_size
.unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE),
VecDeque::new(),
),
)
})
.collect();
queue_size_limit.insert(
DataId::from(NON_INPUT_EVENT.to_string()),
(NON_INPUT_EVENT_QUEUE_SIZE, VecDeque::new()),
);
let queue_policies: HashMap<DataId, dora_message::config::QueuePolicy> = input_config
.iter()
.filter_map(|(input, config)| config.queue_policy.map(|p| (input.clone(), p)))
.collect();
let scheduler = Scheduler::with_policies(queue_size_limit, queue_policies);
let total_queue_capacity = ingress_channel_capacity(input_config.values());
let write_events_to = match write_events_to {
Some(path) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).wrap_err_with(|| {
format!(
"failed to create parent directories for event output file `{}` for node `{}`",
path.display(),
node_id
)
})?;
}
let file = std::fs::File::create(&path).wrap_err_with(|| {
format!(
"failed to create event output file `{}` for node `{}`",
path.display(),
node_id
)
})?;
Some(WriteEventsTo {
node_id: node_id.clone(),
file,
events_buffer: Vec::new(),
poisoned: None,
})
}
None => None,
};
let mut input_type_checks = HashMap::new();
{
let registry = dora_core::types::TypeRegistry::new();
for (input_id, type_urn) in input_types {
match registry.resolve_arrow_type(type_urn) {
Some(dt) => {
input_type_checks.insert(input_id.clone(), dt);
}
None => {
if registry.resolve(type_urn).is_some() {
tracing::debug!(
input = %input_id,
"skipping type check for complex type \"{type_urn}\""
);
} else {
tracing::warn!(
input = %input_id,
"unknown input type URN \"{type_urn}\" — skipping type check"
);
}
}
}
}
}
Self::init_on_channel(
dataflow_id,
node_id,
channel,
close_channel,
clock,
scheduler,
write_events_to,
input_type_checks,
total_queue_capacity,
zenoh_session,
&input_config,
testing_shutdown,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn init_on_channel(
dataflow_id: DataflowId,
node_id: &NodeId,
mut channel: DaemonChannel,
mut close_channel: DaemonChannel,
clock: Arc<uhlc::HLC>,
scheduler: Scheduler,
write_events_to: Option<WriteEventsTo>,
input_type_checks: HashMap<DataId, arrow_schema::DataType>,
channel_capacity: usize,
zenoh_session: Option<&zenoh::Session>,
input_config: &BTreeMap<DataId, Input>,
testing_shutdown: Option<Arc<AtomicBool>>,
) -> eyre::Result<Self> {
channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
let (tx, rx) = tokio::sync::mpsc::channel(channel_capacity);
let use_scheduler = match &channel {
DaemonChannel::IntegrationTestChannel(_) => {
false
}
_ => true,
};
let mut zenoh_subscribers = Vec::new();
let mut zenoh_schema_subscribers = Vec::new();
let (ack_tx, ack_rx) = tokio::sync::mpsc::channel::<DataId>(256);
let mut ack_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>> = HashMap::new();
let mut ingress_drops: HashMap<DataId, Arc<AtomicU64>> = HashMap::new();
if let Some(session) = zenoh_session {
use zenoh::Wait;
use zenoh::qos::CongestionControl;
for (input_id, input) in input_config {
let mapping = &input.mapping;
if let dora_message::config::InputMapping::User(user_mapping) = mapping {
let source_node = &user_mapping.source;
let source_output = &user_mapping.output;
let topic = dora_core::topics::zenoh_output_publish_topic(
dataflow_id,
source_node,
source_output,
);
let key_expr = match zenoh::key_expr::KeyExpr::new(topic.clone()) {
Ok(k) => k.into_owned(),
Err(e) => {
tracing::warn!(input = %input_id, "invalid zenoh key ({e}), using daemon path");
continue;
}
};
let ack_topic = dora_core::topics::zenoh_output_ack_topic(
dataflow_id,
source_node,
source_output,
);
match session
.declare_publisher(ack_topic)
.congestion_control(CongestionControl::Drop)
.express(true)
.wait()
{
Ok(publisher) => {
ack_publishers.insert(input_id.clone(), publisher);
}
Err(e) => {
tracing::warn!(
input = %input_id,
"failed to declare startup-ack publisher ({e}); \
the producer keeps this input on the daemon path"
);
}
}
let decoder = std::sync::Arc::new(std::sync::Mutex::new(
crate::arrow_utils::ipc_encode::InputDecoder::new(),
));
let schema_plane_failed =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let first_undecodable =
std::sync::Arc::new(std::sync::Mutex::new(Option::<Instant>::None));
declare_schema_subscriber(
session,
dataflow_id,
source_node,
source_output,
input_id,
decoder.clone(),
tx.clone(),
schema_plane_failed.clone(),
&mut zenoh_schema_subscribers,
);
let ack_tx_cb = ack_tx.clone();
let tx_cb = tx.clone();
let input_id_cb = input_id.clone();
let ingress_drops_cb =
ingress_drops.entry(input_id.clone()).or_default().clone();
let decoder = decoder.clone();
let first_undecodable_cb = first_undecodable.clone();
let subscriber = session
.declare_subscriber(key_expr)
.callback(move |sample| {
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
use dora_message::metadata::Metadata;
let metadata = match sample.attachment() {
Some(att) => {
match dora_message::decode::<Metadata>(&att.to_bytes())
{
Ok(m)
if m.metadata_version()
!= Metadata::CURRENT_VERSION =>
{
tracing::warn!(
"dropping zenoh sample: incompatible \
metadata wire version {} (this node \
speaks {})",
m.metadata_version(),
Metadata::CURRENT_VERSION
);
return;
}
Ok(m) => m,
Err(e) => {
tracing::warn!(
"zenoh metadata deserialization failed \
(possibly a peer using an incompatible \
wire format/version): {e}"
);
return;
}
}
}
None => {
tracing::warn!(
"zenoh sample missing metadata attachment"
);
return;
}
};
if metadata.is_startup_marker() {
let _ = ack_tx_cb.try_send(input_id_cb.clone());
return;
}
let payload = sample.payload().clone();
let mut decoder = decoder.lock().unwrap_or_else(|poison| {
let mut guard = poison.into_inner();
guard.reset();
guard
});
let data =
match decode_zenoh_sample(&mut decoder, &metadata, payload)
{
Ok(Some(data)) => data,
Ok(None) => {
if schema_plane_failed
.load(std::sync::atomic::Ordering::Relaxed)
{
let mut first = first_undecodable_cb
.lock()
.unwrap_or_else(|p| p.into_inner());
if first.is_none() {
tracing::warn!(
input = %input_id_cb,
"schema-once batch arrived unprimed \
while the `@schema` subscriber is \
not declared; dropping, waiting up \
to {}s for the producer's in-band \
full-stream refresh",
SCHEMA_PLANE_FATAL_GRACE.as_secs()
);
}
if schema_plane_fatal_due(
&mut first,
Instant::now(),
) && tx_cb
.try_send(EventItem::FatalError(eyre!(
"input `{input_id_cb}`: the `@schema` \
subscriber failed to declare (a \
degraded zenoh session) and the \
input stayed undecodable for {}s \
despite the producer's periodic \
full-stream refresh — messages on \
this input are being dropped",
SCHEMA_PLANE_FATAL_GRACE.as_secs()
)))
.is_ok()
{
schema_plane_failed.store(
false,
std::sync::atomic::Ordering::Relaxed,
);
}
}
return;
}
Err(e) => {
tracing::warn!(
input = %input_id_cb,
"zenoh payload decode failed: {e}"
);
return;
}
};
drop(decoder);
if schema_plane_failed
.load(std::sync::atomic::Ordering::Relaxed)
{
*first_undecodable_cb
.lock()
.unwrap_or_else(|p| p.into_inner()) = None;
}
send_or_count_ingress_drop(
&tx_cb,
EventItem::ZenohInput {
id: input_id_cb.clone(),
metadata: std::sync::Arc::new(metadata),
data,
},
&input_id_cb,
&ingress_drops_cb,
);
}));
if result.is_err() {
tracing::error!(
input = %input_id_cb,
"zenoh subscriber callback panicked"
);
let _ = tx_cb.try_send(EventItem::FatalError(eyre!(
"zenoh subscriber callback for input `{input_id_cb}` panicked"
)));
}
})
.wait();
match subscriber {
Ok(s) => {
tracing::debug!(input = %input_id, %topic, "zenoh subscriber declared (callback)");
zenoh_subscribers.push(s);
}
Err(e) => {
tracing::warn!(
input = %input_id,
"failed to declare zenoh subscriber ({e}), using daemon path"
);
}
}
}
}
}
drop(ack_tx); let startup_acker =
spawn_startup_acker(node_id.clone(), ack_publishers, ack_rx, clock.clone());
let reply = channel
.request(&Timestamped {
inner: DaemonRequest::Subscribe,
timestamp: clock.new_timestamp(),
})
.map_err(|e| eyre!(e))
.wrap_err("failed to create subscription with dora-daemon")?;
match reply {
DaemonReply::Result(Ok(())) => {}
DaemonReply::Result(Err(err)) => {
eyre::bail!("subscribe failed: {err}")
}
other => eyre::bail!("unexpected subscribe reply: {other:?}"),
}
close_channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
let thread_handle = thread::init(node_id.clone(), tx, channel, clock.clone())?;
Ok(EventStream {
node_id: node_id.clone(),
receiver: rx,
_thread_handle: thread_handle,
_zenoh_subscribers: zenoh_subscribers,
_zenoh_schema_subscribers: zenoh_schema_subscribers,
startup_acker,
close_channel,
start_timestamp: clock.new_timestamp(),
clock,
scheduler,
ingress_drops,
write_events_to,
use_scheduler,
scheduler_maybe_nonempty: false,
input_type_checks,
pending_passthrough: std::collections::VecDeque::new(),
stop_received: false,
testing_shutdown,
})
}
pub fn recv(&mut self) -> Option<Event> {
futures::executor::block_on(self.recv_async())
}
pub fn recv_timeout(&mut self, dur: Duration) -> Option<Event> {
futures::executor::block_on(self.recv_async_timeout(dur))
}
pub async fn recv_async(&mut self) -> Option<Event> {
if let Some(event) = self.pending_passthrough.pop_front() {
return Some(event);
}
self.recv_from_stream().await
}
async fn recv_from_stream(&mut self) -> Option<Event> {
if self.stop_received {
return self.pop_scheduled(true);
}
let event = if !self.use_scheduler {
self.receiver.recv().await.map(Self::convert_event_item)
} else {
while self.scheduler.is_empty() {
match self.receiver.recv().await {
Some(event) => self.add_event(event),
None => break,
}
}
while let Ok(event) = self.receiver.try_recv() {
self.add_event(event);
}
self.scheduler.next().map(Self::convert_event_item)
};
if let Some(ref event) = event {
self.note_produced_event(event);
}
event
}
fn pop_scheduled(&mut self, inputs_only: bool) -> Option<Event> {
if !self.use_scheduler {
return None;
}
while let Some(item) = self.scheduler.next() {
if inputs_only
&& !matches!(
&item,
EventItem::NodeEvent {
event: NodeEvent::Input { .. },
..
} | EventItem::ZenohInput { .. }
)
{
continue;
}
let event = Self::convert_event_item(item);
self.note_produced_event(&event);
return Some(event);
}
None
}
fn note_produced_event(&mut self, event: &Event) {
if let Event::Input { id, metadata, data } = event
&& self.input_type_checks.contains_key(id)
&& !crate::node::carries_pattern_correlation(&metadata.parameters)
{
let raw = dora_arrow_convert::internal::array_ref(data);
let actual = raw.data_type();
if *actual != arrow_schema::DataType::Null
&& let Some(expected) = self.input_type_checks.remove(id)
&& *actual != expected
{
tracing::warn!(
input = %id,
expected = ?expected,
actual = ?actual,
"input type mismatch on first message"
);
}
}
if matches!(event, Event::Stop(_)) {
self.stop_received = true;
}
}
pub fn is_empty(&self) -> bool {
self.pending_passthrough.is_empty() && self.scheduler.is_empty() && self.receiver.is_empty()
}
pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
let mut counts = self.scheduler.drain_drop_counts();
for (id, dropped) in &self.ingress_drops {
if dropped.load(Ordering::Relaxed) > 0 {
let n = dropped.swap(0, Ordering::Relaxed);
*counts.entry(id.clone()).or_insert(0) += n;
}
}
counts
}
fn add_event(&mut self, event: EventItem) {
if let Err(err) = self.record_event(&event) {
tracing::warn!(
node = %self.node_id,
"failed to record event to write_events_to log: {err:?}"
);
if let Some(write_events_to) = self.write_events_to.as_mut() {
let time_offset_secs = self
.clock
.new_timestamp()
.get_diff_duration(&self.start_timestamp)
.as_secs_f64();
write_events_to.mark_poisoned(&err, time_offset_secs);
}
}
self.scheduler.add_event(event);
self.scheduler_maybe_nonempty = true;
}
fn record_event(&mut self, event: &EventItem) -> eyre::Result<()> {
if let Some(write_events_to) = &mut self.write_events_to {
let event_json = match event {
EventItem::NodeEvent { event, .. } => match event {
NodeEvent::Stop => Some(control_event_json(
&self.clock,
&self.start_timestamp,
"Stop",
None,
)),
NodeEvent::Reload { .. } => None,
NodeEvent::Input { id, metadata, data } => {
let mut event_json = convert_output_to_json(
id,
metadata,
data,
self.start_timestamp,
false,
)?;
event_json.insert("type".into(), "Input".into());
Some(event_json.into())
}
NodeEvent::InputClosed { id } => Some(control_event_json(
&self.clock,
&self.start_timestamp,
"InputClosed",
Some(id.to_string()),
)),
NodeEvent::InputRecovered { id } => Some(control_event_json(
&self.clock,
&self.start_timestamp,
"InputRecovered",
Some(id.to_string()),
)),
NodeEvent::NodeRestarted { id } => Some(control_event_json(
&self.clock,
&self.start_timestamp,
"NodeRestarted",
Some(id.to_string()),
)),
NodeEvent::AllInputsClosed => Some(control_event_json(
&self.clock,
&self.start_timestamp,
"AllInputsClosed",
None,
)),
_ => None,
},
EventItem::ZenohInput { id, metadata, data } => {
let array = arrow::array::make_array(data.clone());
let mut event_json = convert_arrow_input_to_json(
id,
metadata,
array,
self.start_timestamp,
false,
)?;
event_json.insert("type".into(), "Input".into());
Some(event_json.into())
}
_ => None,
};
if let Some(event_json) = event_json {
write_events_to.events_buffer.push(event_json);
}
}
Ok(())
}
pub fn try_recv(&mut self) -> Result<Event, TryRecvError> {
match self.recv_async().now_or_never() {
Some(Some(event)) => Ok(event),
Some(None) => Err(TryRecvError::Closed),
None => Err(TryRecvError::Empty),
}
}
pub fn drain(&mut self) -> Option<Vec<Event>> {
let mut events = Vec::new();
loop {
match self.try_recv() {
Ok(event) => events.push(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Closed) => {
if events.is_empty() {
return None;
} else {
break;
}
}
}
}
Some(events)
}
pub async fn recv_async_timeout(&mut self, dur: Duration) -> Option<Event> {
match select(Delay::new(dur), pin!(self.recv_async())).await {
Either::Left((_elapsed, _)) => Some(Self::convert_event_item(EventItem::TimeoutError(
eyre!("Receiver timed out"),
))),
Either::Right((event, _)) => event,
}
}
pub async fn recv_service_response(
&mut self,
request_id: &str,
expected_server: &NodeId,
timeout: Duration,
) -> Result<Event, PatternError> {
self.wait_for_correlation(
timeout,
expected_server,
|event, request_id| match event {
Event::Input { metadata, .. } => {
dora_message::metadata::get_string_param(
&metadata.parameters,
dora_message::metadata::REQUEST_ID,
) == Some(request_id)
}
_ => false,
},
request_id,
)
.await
}
pub async fn recv_action_result(
&mut self,
goal_id: &str,
expected_server: &NodeId,
timeout: Duration,
) -> Result<Event, PatternError> {
self.wait_for_correlation(
timeout,
expected_server,
|event, goal_id| match event {
Event::Input { metadata, .. } => {
let matches_goal = dora_message::metadata::get_string_param(
&metadata.parameters,
dora_message::metadata::GOAL_ID,
) == Some(goal_id);
if !matches_goal {
return false;
}
matches!(
dora_message::metadata::get_string_param(
&metadata.parameters,
dora_message::metadata::GOAL_STATUS,
),
Some(dora_message::metadata::GOAL_STATUS_SUCCEEDED)
| Some(dora_message::metadata::GOAL_STATUS_ABORTED)
| Some(dora_message::metadata::GOAL_STATUS_CANCELED)
)
}
_ => false,
},
goal_id,
)
.await
}
fn buffer_passthrough(&mut self, event: Event) {
let (bucket, cap) = match &event {
Event::Input { id, .. } => {
let id = id.clone();
let cap = self.scheduler.effective_cap_for(&id);
(PassthroughBucket::Input(id), cap)
}
_ => (PassthroughBucket::NonInput, NON_INPUT_EVENT_QUEUE_SIZE),
};
self.pending_passthrough.push_back(event);
self.enforce_passthrough_bound(&bucket, cap);
}
fn enforce_passthrough_bound(&mut self, bucket: &PassthroughBucket, cap: usize) {
use dora_message::metadata::carries_pattern_correlation;
let in_bucket = |event: &Event| match (bucket, event) {
(PassthroughBucket::Input(id), Event::Input { id: eid, .. }) => eid == id,
(PassthroughBucket::Input(_), _) => false,
(PassthroughBucket::NonInput, event) => !matches!(event, Event::Input { .. }),
};
let correlated = |event: &Event| {
matches!(event, Event::Input { metadata, .. }
if carries_pattern_correlation(&metadata.parameters))
};
let droppable = |event: &Event| in_bucket(event) && !matches!(event, Event::Stop(_));
if self
.pending_passthrough
.iter()
.filter(|e| in_bucket(e))
.count()
<= cap
{
return;
}
let evict_at = self
.pending_passthrough
.iter()
.position(|e| droppable(e) && !correlated(e))
.or_else(|| self.pending_passthrough.iter().position(droppable));
let Some(evict_at) = evict_at else {
return;
};
match self.pending_passthrough.get(evict_at) {
Some(Event::Input { id, metadata, .. })
if carries_pattern_correlation(&metadata.parameters) =>
{
scheduler::log_correlation_drop_params(id, &metadata.parameters);
}
Some(Event::Input { id, .. }) => {
tracing::warn!(input = %id, "discarding buffered input due to queue size limit");
}
_ => {
tracing::warn!("discarding buffered control event due to passthrough buffer limit");
}
}
self.pending_passthrough.remove(evict_at);
match bucket {
PassthroughBucket::Input(id) => self.scheduler.record_drop(id),
PassthroughBucket::NonInput => self.scheduler.record_non_input_drop(),
}
}
async fn wait_for_correlation<F>(
&mut self,
timeout: Duration,
expected_server: &NodeId,
is_match: F,
needle: &str,
) -> Result<Event, PatternError>
where
F: Fn(&Event, &str) -> bool,
{
if let Some(pos) = self
.pending_passthrough
.iter()
.position(|event| is_match(event, needle))
&& let Some(event) = self.pending_passthrough.remove(pos)
{
return Ok(event);
}
let deadline = representable_wait_deadline(timeout);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(PatternError::Timeout);
}
let event = match select(Delay::new(remaining), pin!(self.recv_from_stream())).await {
Either::Left((_elapsed, _)) => return Err(PatternError::Timeout),
Either::Right((None, _)) => return Err(PatternError::StreamEnded),
Either::Right((Some(e), _)) => e,
};
match classify_correlation_event(&event, expected_server, |e| is_match(e, needle)) {
CorrelationOutcome::Match => return Ok(event),
CorrelationOutcome::ServerRestarted => {
self.buffer_passthrough(event);
return Err(PatternError::ServerRestarted(expected_server.to_string()));
}
CorrelationOutcome::StreamEnded => {
self.buffer_passthrough(event);
return Err(PatternError::StreamEnded);
}
CorrelationOutcome::StreamError => {
if let Event::Error(err) = event {
return Err(PatternError::StreamError(err));
}
unreachable!("StreamError only returned for Event::Error");
}
CorrelationOutcome::Passthrough => {
self.buffer_passthrough(event);
}
}
}
}
}
fn representable_wait_deadline(timeout: Duration) -> Instant {
let now = Instant::now();
let mut timeout = timeout;
while !timeout.is_zero() && now.checked_add(timeout).is_none() {
timeout /= 2;
}
now + timeout
}
fn control_event_json(
clock: &uhlc::HLC,
start_timestamp: &uhlc::Timestamp,
ty: &str,
id: Option<String>,
) -> serde_json::Value {
let time_offset = clock.new_timestamp().get_diff_duration(start_timestamp);
let mut event_json = serde_json::Map::new();
event_json.insert("type".to_owned(), ty.into());
if let Some(id) = id {
event_json.insert("id".to_owned(), serde_json::Value::String(id));
}
event_json.insert(
"time_offset_secs".to_owned(),
time_offset.as_secs_f64().into(),
);
serde_json::Value::Object(event_json)
}
#[derive(Debug, PartialEq, Eq)]
enum CorrelationOutcome {
Match,
ServerRestarted,
StreamEnded,
StreamError,
Passthrough,
}
fn classify_correlation_event<F>(
event: &Event,
expected_server: &NodeId,
is_match: F,
) -> CorrelationOutcome
where
F: Fn(&Event) -> bool,
{
if is_match(event) {
return CorrelationOutcome::Match;
}
match event {
Event::NodeRestarted { id } if id == expected_server => CorrelationOutcome::ServerRestarted,
Event::Stop(_) => CorrelationOutcome::StreamEnded,
Event::Error(_) => CorrelationOutcome::StreamError,
_ => CorrelationOutcome::Passthrough,
}
}
enum PassthroughBucket {
Input(DataId),
NonInput,
}
impl EventStream {
fn convert_event_item(item: EventItem) -> Event {
match item {
EventItem::NodeEvent { event } => match event {
NodeEvent::Stop => Event::Stop(event::StopCause::Manual),
NodeEvent::Reload { operator_id } => Event::Reload { operator_id },
NodeEvent::InputClosed { id } => Event::InputClosed { id },
NodeEvent::InputRecovered { id } => Event::InputRecovered { id },
NodeEvent::NodeRestarted { id } => Event::NodeRestarted { id },
NodeEvent::Input { id, metadata, data } => {
let data_inner = data.map(Arc::unwrap_or_clone);
let result = data_to_arrow_array(data_inner);
match result {
Ok(data) => {
let mut metadata = Arc::unwrap_or_clone(metadata);
dora_message::metadata::strip_internal_parameters(
&mut metadata.parameters,
);
Event::Input {
id,
metadata,
data: dora_arrow_convert::internal::from_array_ref(data),
}
}
Err(err) => Event::Error(format!("{err:?}")),
}
}
NodeEvent::AllInputsClosed => Event::Stop(event::StopCause::AllInputsClosed),
NodeEvent::ParamUpdate { key, value_json } => {
match serde_json::from_slice(&value_json) {
Ok(value) => Event::ParamUpdate { key, value },
Err(err) => Event::Error(format!(
"failed to deserialize ParamUpdate value for `{key}`: {err}"
)),
}
}
NodeEvent::ParamDeleted { key } => Event::ParamDeleted { key },
NodeEvent::NodeFailed {
affected_input_ids,
error,
source_node_id,
} => Event::NodeFailed {
affected_input_ids,
error,
source_node_id,
},
other => {
tracing::warn!("ignoring unrecognized NodeEvent variant: {other:?}");
Event::Error(format!("unrecognized node event: {other:?}"))
}
},
EventItem::ZenohInput { id, metadata, data } => {
let mut metadata = Arc::unwrap_or_clone(metadata);
dora_message::metadata::strip_internal_parameters(&mut metadata.parameters);
Event::Input {
id,
metadata,
data: dora_arrow_convert::internal::from_array_data(data),
}
}
EventItem::FatalError(err) => {
Event::Error(format!("fatal event stream error: {err:?}"))
}
EventItem::TimeoutError(err) => {
Event::Error(format!("Timeout event stream error: {err:?}"))
}
}
}
}
#[derive(Debug)]
pub enum TryRecvError {
Empty,
Closed,
}
#[allow(dead_code)] struct ZBytesAllocation(zenoh::bytes::ZBytes);
unsafe impl Sync for ZBytesAllocation {}
unsafe impl Send for ZBytesAllocation {}
impl std::panic::RefUnwindSafe for ZBytesAllocation {}
fn zenoh_payload_to_buffer(payload: zenoh::bytes::ZBytes) -> arrow::buffer::Buffer {
use std::ptr::NonNull;
match payload.to_bytes() {
std::borrow::Cow::Borrowed(slice) => {
let ptr =
NonNull::new(slice.as_ptr() as *mut u8).expect("zenoh SHM payload ptr is null");
let len = slice.len();
unsafe {
arrow::buffer::Buffer::from_custom_allocation(
ptr,
len,
Arc::new(ZBytesAllocation(payload)),
)
}
}
std::borrow::Cow::Owned(vec) => arrow::buffer::Buffer::from_vec(vec),
}
}
#[allow(clippy::too_many_arguments)]
fn declare_schema_subscriber(
session: &zenoh::Session,
dataflow_id: DataflowId,
source_node: &NodeId,
source_output: &DataId,
input_id: &DataId,
decoder: Arc<std::sync::Mutex<crate::arrow_utils::ipc_encode::InputDecoder>>,
tx: tokio::sync::mpsc::Sender<EventItem>,
schema_plane_failed: Arc<std::sync::atomic::AtomicBool>,
out: &mut Vec<zenoh_ext::AdvancedSubscriber<()>>,
) {
use zenoh::Wait;
use zenoh_ext::{AdvancedSubscriberBuilderExt, HistoryConfig};
let topic =
dora_core::topics::zenoh_output_schema_topic(dataflow_id, source_node, source_output);
let key = match zenoh::key_expr::KeyExpr::new(topic) {
Ok(k) => k.into_owned(),
Err(e) => {
tracing::warn!(input = %input_id, "invalid @schema zenoh key ({e}); schema-once disabled for this input");
schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
return;
}
};
let input_id_cb = input_id.clone();
let sub = session
.declare_subscriber(key)
.history(HistoryConfig::default().detect_late_publishers())
.callback(move |sample| {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let buffer = zenoh_payload_to_buffer(sample.payload().clone());
let hash = crate::node::fnv1a(buffer.as_slice());
let mut decoder = decoder.lock().unwrap_or_else(|poison| {
let mut guard = poison.into_inner();
guard.reset();
guard
});
if let Err(e) = decoder.set_schema_raw(hash, buffer) {
tracing::warn!(input = %input_id_cb, "failed to prime decoder from @schema sample: {e}");
}
}));
if result.is_err() {
tracing::error!(input = %input_id_cb, "zenoh @schema subscriber callback panicked");
let _ = tx.try_send(EventItem::FatalError(eyre!(
"zenoh @schema subscriber for input `{input_id_cb}` panicked"
)));
}
})
.wait();
match sub {
Ok(s) => out.push(s),
Err(e) => {
tracing::warn!(input = %input_id, "failed to declare @schema subscriber ({e}); schema-once disabled for this input");
schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
const SCHEMA_PLANE_FATAL_GRACE: Duration =
crate::node::SCHEMA_ONCE_REFRESH_INTERVAL.saturating_mul(3);
fn schema_plane_fatal_due(first_undecodable: &mut Option<Instant>, now: Instant) -> bool {
let start = *first_undecodable.get_or_insert(now);
now.duration_since(start) >= SCHEMA_PLANE_FATAL_GRACE
}
fn decode_zenoh_sample(
decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
metadata: &dora_message::metadata::Metadata,
payload: zenoh::bytes::ZBytes,
) -> eyre::Result<Option<arrow::array::ArrayData>> {
use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
use dora_message::metadata::{SCHEMA_HASH, get_integer_param};
if payload.is_empty() {
return Ok(Some(
dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
));
}
let buffer = zenoh_payload_to_buffer(payload);
match get_integer_param(&metadata.parameters, SCHEMA_HASH) {
Some(hash) => {
tracing::debug!("received schema-less batch with SCHEMA_HASH={}", hash);
decoder.decode_batch_raw(buffer, hash as u64)
}
None => {
tracing::debug!("received full IPC stream (no SCHEMA_HASH)");
if !crate::node::carries_pattern_correlation(&metadata.parameters) {
prime_in_band(decoder, &buffer);
}
decode_arrow_ipc_zero_copy_raw(buffer).map(Some)
}
}
}
fn prime_in_band(
decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
buffer: &arrow::buffer::Buffer,
) {
let Some((hash, schema)) =
crate::arrow_utils::ipc_encode::schema_block_and_hash(buffer.as_slice())
else {
return;
};
if decoder.knows_schema(hash) {
return;
}
let schema = arrow::buffer::Buffer::from(schema);
if let Err(e) = decoder.set_schema_raw(hash, schema) {
tracing::debug!("in-band schema priming failed: {e}");
}
}
pub fn data_to_arrow_array(
data: Option<DataMessage>,
) -> eyre::Result<Arc<dyn arrow::array::Array>> {
let raw_data = data.map_or(RawData::Empty, |DataMessage::Vec(v)| RawData::Vec(v));
raw_data.into_arrow_array().map(arrow::array::make_array)
}
impl Stream for EventStream {
type Item = Event;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
if let Some(event) = self.pending_passthrough.pop_front() {
return std::task::Poll::Ready(Some(event));
}
if self.stop_received {
return std::task::Poll::Ready(self.pop_scheduled(true));
}
if self.scheduler_maybe_nonempty {
if let Some(event) = self.pop_scheduled(false) {
return std::task::Poll::Ready(Some(event));
}
self.scheduler_maybe_nonempty = false;
}
let poll = self
.receiver
.poll_recv(cx)
.map(|item| item.map(Self::convert_event_item));
if let std::task::Poll::Ready(Some(ref event)) = poll {
self.note_produced_event(event);
}
poll
}
}
impl Drop for EventStream {
fn drop(&mut self) {
let subscribers = std::mem::take(&mut self._zenoh_subscribers);
let schema_subscribers = std::mem::take(&mut self._zenoh_schema_subscribers);
let startup_acker = self.startup_acker.take();
if !subscribers.is_empty() || !schema_subscribers.is_empty() || startup_acker.is_some() {
let completed =
teardown_with_timeout("zenoh-subscribers", ZENOH_TEARDOWN_TIMEOUT, move || {
drop(subscribers);
drop(schema_subscribers);
if let Some(handle) = startup_acker {
let _ = handle.join();
}
});
if !completed {
tracing::warn!(
"zenoh subscriber teardown timed out after {}s; continuing node shutdown",
ZENOH_TEARDOWN_TIMEOUT.as_secs()
);
}
}
let request = Timestamped {
inner: DaemonRequest::EventStreamDropped,
timestamp: self.clock.new_timestamp(),
};
if let Some(shutdown) = &self.testing_shutdown {
shutdown.store(true, Ordering::Relaxed);
}
let result = self
.close_channel
.request(&request)
.map_err(|e| eyre!(e))
.wrap_err("failed to signal event stream closure to dora-daemon")
.and_then(|r| match r {
DaemonReply::Result(Ok(())) => Ok(()),
DaemonReply::Result(Err(err)) => Err(eyre!("EventStreamClosed failed: {err}")),
other => Err(eyre!("unexpected EventStreamClosed reply: {other:?}")),
});
if let Err(err) = result {
tracing::warn!("{err:?}")
}
if let Some(write_events_to) = self.write_events_to.take()
&& let Err(err) = write_events_to.write_out()
{
tracing::warn!(
"failed to write out events for node {}: {err:?}",
self.node_id
);
}
}
}
pub(crate) struct WriteEventsTo {
node_id: NodeId,
file: std::fs::File,
events_buffer: Vec<serde_json::Value>,
poisoned: Option<PoisonInfo>,
}
#[derive(Debug)]
pub(crate) struct PoisonInfo {
first_failure_event_index: usize,
first_failure_time_offset_secs: f64,
first_failure_error: String,
additional_failures: u64,
}
impl WriteEventsTo {
fn mark_poisoned(&mut self, err: &eyre::Report, time_offset_secs: f64) {
match &mut self.poisoned {
None => {
self.poisoned = Some(PoisonInfo {
first_failure_event_index: self.events_buffer.len(),
first_failure_time_offset_secs: time_offset_secs,
first_failure_error: format!("{err:?}"),
additional_failures: 0,
});
}
Some(info) => {
info.additional_failures += 1;
}
}
}
fn write_out(self) -> eyre::Result<()> {
use dora_message::integration_testing_format::RecordingStatus;
let Self {
node_id,
file,
events_buffer,
poisoned,
} = self;
let mut inputs_file = serde_json::Map::new();
inputs_file.insert("id".into(), node_id.to_string().into());
let recording_status = match poisoned {
None => RecordingStatus::Clean,
Some(info) => RecordingStatus::Poisoned {
first_failure_event_index: info.first_failure_event_index,
first_failure_time_offset_secs: info.first_failure_time_offset_secs,
first_failure_error: info.first_failure_error,
additional_failures: info.additional_failures,
},
};
inputs_file.insert(
"recording_status".into(),
serde_json::to_value(&recording_status)
.context("failed to serialize recording_status")?,
);
inputs_file.insert("events".into(), events_buffer.into());
serde_json::to_writer_pretty(file, &inputs_file)
.context("failed to write events to file")?;
Ok(())
}
}
#[cfg(test)]
impl EventStream {
fn push_passthrough_for_testing(&mut self, event: Event) {
self.pending_passthrough.push_back(event);
}
fn push_scheduler_input_for_testing(&mut self, id: &str) {
use crate::event_stream::thread::EventItem;
use dora_message::{daemon_to_node::NodeEvent, metadata::Metadata};
self.use_scheduler = true;
let meta = Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
self.add_event(EventItem::NodeEvent {
event: NodeEvent::Input {
id: id.into(),
metadata: std::sync::Arc::new(meta),
data: None,
},
});
}
fn push_scheduler_stop_for_testing(&mut self) {
use crate::event_stream::thread::EventItem;
use dora_message::daemon_to_node::NodeEvent;
self.use_scheduler = true;
self.add_event(EventItem::NodeEvent {
event: NodeEvent::Stop,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn input_with_queue_size(queue_size: usize) -> Input {
serde_yaml::from_str(&format!("source: a/out\nqueue_size: {queue_size}\n"))
.expect("parse input")
}
#[test]
fn ingress_channel_capacity_saturates_for_huge_queue_sizes() {
for sizes in [
vec![usize::MAX],
vec![usize::MAX / 2 + 1, usize::MAX / 2 + 1],
] {
let inputs: Vec<Input> = sizes.into_iter().map(input_with_queue_size).collect();
let capacity = ingress_channel_capacity(inputs.iter());
assert_eq!(capacity, usize::MAX >> 3);
let _ = tokio::sync::mpsc::channel::<()>(capacity);
}
}
#[test]
fn ingress_channel_capacity_sums_queue_sizes_with_a_floor() {
let inputs = [input_with_queue_size(50), input_with_queue_size(70)];
assert_eq!(ingress_channel_capacity(inputs.iter()), 120);
let small = [input_with_queue_size(1)];
assert_eq!(ingress_channel_capacity(small.iter()), 64);
assert_eq!(ingress_channel_capacity(std::iter::empty()), 64);
}
#[test]
fn representable_wait_deadline_clamps_overflow_without_panic() {
let before = Instant::now();
let deadline = representable_wait_deadline(Duration::from_secs(5));
assert!(
deadline > before,
"a real timeout must produce a future deadline"
);
assert!(
deadline <= Instant::now() + Duration::from_secs(6),
"a small timeout must not be clamped to a huge one"
);
let deadline_max = representable_wait_deadline(Duration::MAX);
assert!(
deadline_max > Instant::now(),
"an overflowing timeout must still produce a future deadline"
);
}
#[test]
fn control_event_json_shape_and_key_order() {
let clock = uhlc::HLC::default();
let start = clock.new_timestamp();
let with_id = control_event_json(&clock, &start, "InputClosed", Some("cam".to_owned()));
let obj = with_id.as_object().expect("object");
assert_eq!(
obj.keys().collect::<Vec<_>>(),
vec!["type", "id", "time_offset_secs"]
);
assert_eq!(obj["type"], serde_json::json!("InputClosed"));
assert_eq!(obj["id"], serde_json::json!("cam"));
assert!(obj["time_offset_secs"].is_f64());
let without_id = control_event_json(&clock, &start, "AllInputsClosed", None);
let obj = without_id.as_object().expect("object");
assert_eq!(
obj.keys().collect::<Vec<_>>(),
vec!["type", "time_offset_secs"]
);
assert_eq!(obj["type"], serde_json::json!("AllInputsClosed"));
}
#[test]
fn convert_param_update() {
let item = EventItem::NodeEvent {
event: NodeEvent::ParamUpdate {
key: "fps".into(),
value_json: serde_json::to_vec(&serde_json::json!(60)).unwrap(),
},
};
let event = EventStream::convert_event_item(item);
match event {
Event::ParamUpdate { key, value } => {
assert_eq!(key, "fps");
assert_eq!(value, serde_json::json!(60));
}
other => panic!("expected ParamUpdate, got {other:?}"),
}
}
#[test]
fn node_event_param_update_round_trips_through_postcard() {
let cases = [
serde_json::json!(42),
serde_json::json!(1.5),
serde_json::json!("hello"),
serde_json::json!(null),
serde_json::json!([1, 2, 3]),
serde_json::json!({"nested": {"array": [true, false]}}),
];
for value in cases {
let event = NodeEvent::ParamUpdate {
key: "rate".into(),
value_json: serde_json::to_vec(&value).unwrap(),
};
let bytes = dora_message::encode(&event).expect("serialize");
let back: NodeEvent = dora_message::decode(&bytes).expect("deserialize");
match back {
NodeEvent::ParamUpdate { key, value_json } => {
assert_eq!(key, "rate");
let decoded: serde_json::Value =
serde_json::from_slice(&value_json).expect("value_json is JSON");
assert_eq!(decoded, value);
}
other => panic!("expected ParamUpdate, got {other:?}"),
}
}
}
fn write_events_to_with_tempfile() -> (WriteEventsTo, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!(
"dora-write-events-test-{}.json",
uuid::Uuid::new_v4()
));
let file = std::fs::File::create(&path).expect("create tempfile");
let w = WriteEventsTo {
node_id: "test-node".parse().unwrap(),
file,
events_buffer: Vec::new(),
poisoned: None,
};
(w, path)
}
fn read_back(path: &std::path::Path) -> serde_json::Value {
let s = std::fs::read_to_string(path).expect("read back tempfile");
std::fs::remove_file(path).ok();
serde_json::from_str(&s).expect("output is valid JSON")
}
#[test]
fn write_events_clean_recording_emits_state_clean() {
let (mut w, path) = write_events_to_with_tempfile();
w.events_buffer.push(serde_json::json!({"type": "Stop"}));
w.write_out().expect("write_out clean recording");
let v = read_back(&path);
assert_eq!(v["recording_status"]["state"], "clean");
assert_eq!(v["events"].as_array().unwrap().len(), 1);
assert_eq!(v["id"], "test-node");
}
#[test]
fn write_events_poisoned_recording_emits_state_poisoned_with_first_failure() {
let (mut w, path) = write_events_to_with_tempfile();
w.events_buffer.push(serde_json::json!({"type": "Input"}));
w.events_buffer.push(serde_json::json!({"type": "Input"}));
w.mark_poisoned(&eyre!("arrow conversion failed: bad type"), 1.5);
w.events_buffer.push(serde_json::json!({"type": "Stop"}));
w.write_out().expect("write_out poisoned recording");
let v = read_back(&path);
let status = &v["recording_status"];
assert_eq!(status["state"], "poisoned");
assert_eq!(status["first_failure_event_index"], 2);
assert_eq!(status["first_failure_time_offset_secs"], 1.5);
assert!(
status["first_failure_error"]
.as_str()
.unwrap()
.contains("arrow conversion failed: bad type")
);
assert_eq!(status["additional_failures"], 0);
assert_eq!(v["events"].as_array().unwrap().len(), 3);
}
#[test]
fn write_events_multiple_failures_keep_first_and_count_rest() {
let (mut w, path) = write_events_to_with_tempfile();
w.mark_poisoned(&eyre!("first error"), 0.5);
w.mark_poisoned(&eyre!("second error"), 1.0);
w.mark_poisoned(&eyre!("third error"), 1.5);
w.write_out().expect("write_out with multiple failures");
let v = read_back(&path);
let status = &v["recording_status"];
assert_eq!(status["state"], "poisoned");
assert_eq!(status["first_failure_event_index"], 0);
assert_eq!(status["first_failure_time_offset_secs"], 0.5);
assert!(
status["first_failure_error"]
.as_str()
.unwrap()
.contains("first error")
);
assert_eq!(status["additional_failures"], 2);
}
#[test]
fn convert_param_deleted() {
let item = EventItem::NodeEvent {
event: NodeEvent::ParamDeleted { key: "fps".into() },
};
let event = EventStream::convert_event_item(item);
match event {
Event::ParamDeleted { key } => {
assert_eq!(key, "fps");
}
other => panic!("expected ParamDeleted, got {other:?}"),
}
}
#[test]
fn convert_stop_event() {
let item = EventItem::NodeEvent {
event: NodeEvent::Stop,
};
let event = EventStream::convert_event_item(item);
assert!(matches!(event, Event::Stop(StopCause::Manual)));
}
#[test]
fn convert_all_inputs_closed() {
let item = EventItem::NodeEvent {
event: NodeEvent::AllInputsClosed,
};
let event = EventStream::convert_event_item(item);
assert!(matches!(event, Event::Stop(StopCause::AllInputsClosed)));
}
#[test]
fn convert_input_closed() {
let item = EventItem::NodeEvent {
event: NodeEvent::InputClosed {
id: "input_1".to_string().into(),
},
};
let event = EventStream::convert_event_item(item);
match event {
Event::InputClosed { id } => assert_eq!(AsRef::<str>::as_ref(&id), "input_1"),
other => panic!("expected InputClosed, got {other:?}"),
}
}
#[test]
fn convert_node_restarted() {
let item = EventItem::NodeEvent {
event: NodeEvent::NodeRestarted {
id: "upstream".to_string().into(),
},
};
let event = EventStream::convert_event_item(item);
match event {
Event::NodeRestarted { id } => assert_eq!(AsRef::<str>::as_ref(&id), "upstream"),
other => panic!("expected NodeRestarted, got {other:?}"),
}
}
use arrow::array::new_empty_array;
use arrow::datatypes::DataType as ArrowDataType;
use dora_arrow_convert::internal::from_array_ref;
use dora_message::metadata::{
GOAL_ID, GOAL_STATUS, GOAL_STATUS_ABORTED, GOAL_STATUS_SUCCEEDED, Metadata,
MetadataParameters, Parameter, REQUEST_ID,
};
fn make_metadata(params: MetadataParameters) -> Metadata {
Metadata::from_parameters(dora_core::uhlc::HLC::default().new_timestamp(), params)
}
fn make_input_event(id: &str, params: MetadataParameters) -> Event {
Event::Input {
id: id.into(),
metadata: make_metadata(params),
data: from_array_ref(new_empty_array(&ArrowDataType::Null)),
}
}
fn request_id_params(id: &str) -> MetadataParameters {
let mut p = MetadataParameters::new();
p.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
p
}
fn goal_params(goal_id: &str, status: Option<&str>) -> MetadataParameters {
let mut p = MetadataParameters::new();
p.insert(GOAL_ID.into(), Parameter::String(goal_id.to_string()));
if let Some(s) = status {
p.insert(GOAL_STATUS.into(), Parameter::String(s.to_string()));
}
p
}
fn is_request_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
move |event: &Event| match event {
Event::Input { metadata, .. } => {
dora_message::metadata::get_string_param(&metadata.parameters, REQUEST_ID)
== Some(needle)
}
_ => false,
}
}
fn is_action_result_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
move |event: &Event| match event {
Event::Input { metadata, .. } => {
let p = &metadata.parameters;
dora_message::metadata::get_string_param(p, GOAL_ID) == Some(needle)
&& matches!(
dora_message::metadata::get_string_param(p, GOAL_STATUS),
Some(GOAL_STATUS_SUCCEEDED)
| Some(GOAL_STATUS_ABORTED)
| Some(dora_message::metadata::GOAL_STATUS_CANCELED)
)
}
_ => false,
}
}
#[test]
fn classify_matching_request_id_returns_match() {
let server = NodeId::from("calc".to_string());
let event = make_input_event("response", request_id_params("req-42"));
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::Match
);
}
#[test]
fn classify_different_request_id_is_passthrough() {
let server = NodeId::from("calc".to_string());
let event = make_input_event("response", request_id_params("req-99"));
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::Passthrough
);
}
#[test]
fn classify_expected_server_restart_returns_server_restarted() {
let server = NodeId::from("calc".to_string());
let event = Event::NodeRestarted { id: server.clone() };
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::ServerRestarted
);
}
#[test]
fn classify_unrelated_node_restart_is_passthrough() {
let server = NodeId::from("calc".to_string());
let event = Event::NodeRestarted {
id: NodeId::from("other".to_string()),
};
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::Passthrough
);
}
#[test]
fn classify_stop_returns_stream_ended() {
let server = NodeId::from("calc".to_string());
let event = Event::Stop(StopCause::Manual);
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::StreamEnded
);
}
#[test]
fn classify_error_returns_stream_error() {
let server = NodeId::from("calc".to_string());
let event = Event::Error("boom".to_string());
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::StreamError
);
}
#[test]
fn classify_unrelated_input_is_passthrough() {
let server = NodeId::from("calc".to_string());
let event = make_input_event("sensor", MetadataParameters::new());
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::Passthrough
);
}
#[test]
fn classify_param_update_is_passthrough() {
let server = NodeId::from("calc".to_string());
let event = Event::ParamUpdate {
key: "threshold".to_string(),
value: serde_json::json!(0.85),
};
assert_eq!(
classify_correlation_event(&event, &server, is_request_match("req-42")),
CorrelationOutcome::Passthrough
);
}
#[test]
fn classify_action_result_terminal_succeeded_matches() {
let server = NodeId::from("nav".to_string());
let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_SUCCEEDED)));
assert_eq!(
classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
CorrelationOutcome::Match
);
}
#[test]
fn classify_action_result_terminal_aborted_matches() {
let server = NodeId::from("nav".to_string());
let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_ABORTED)));
assert_eq!(
classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
CorrelationOutcome::Match
);
}
#[test]
fn classify_action_feedback_without_terminal_status_is_passthrough() {
let server = NodeId::from("nav".to_string());
let event = make_input_event("feedback", goal_params("goal-1", None));
assert_eq!(
classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
CorrelationOutcome::Passthrough
);
}
#[test]
fn classify_action_result_for_different_goal_is_passthrough() {
let server = NodeId::from("nav".to_string());
let event = make_input_event("result", goal_params("goal-2", Some(GOAL_STATUS_SUCCEEDED)));
assert_eq!(
classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
CorrelationOutcome::Passthrough
);
}
use crate::integration_testing::{
IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
integration_testing_format::{IncomingEvent, TimedIncomingEvent},
};
fn test_event_stream() -> (crate::DoraNode, EventStream) {
let events = vec![TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Stop,
}];
let inputs = TestingInput::Input(IntegrationTestInput::new(
"test-node".parse().unwrap(),
events,
));
let (tx, _rx) = crate::integration_testing::output_channel();
let outputs = TestingOutput::ToChannel(tx);
let options = TestingOptions {
skip_output_time_offsets: true,
};
crate::DoraNode::init_testing(inputs, outputs, options).unwrap()
}
#[test]
fn to_channel_delivers_outputs_in_order() {
use arrow::array::Int32Array;
let events = vec![TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Stop,
}];
let inputs = TestingInput::Input(IntegrationTestInput::new(
"test-node".parse().unwrap(),
events,
));
let (tx, mut rx) = crate::integration_testing::output_channel();
let outputs = TestingOutput::ToChannel(tx);
let options = TestingOptions {
skip_output_time_offsets: true,
};
let (mut node, _events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
for i in 0..3 {
node.send_output(
"out".parse().unwrap(),
Default::default(),
dora_arrow_convert::internal::from_array_ref(std::sync::Arc::new(
Int32Array::from(vec![i]),
)),
)
.unwrap();
}
let received = crate::integration_testing::drain_outputs(&mut rx);
assert_eq!(received.len(), 3, "every sent output should be delivered");
for (i, output) in received.iter().enumerate() {
assert_eq!(output.get("id").and_then(|v| v.as_str()), Some("out"));
assert_eq!(
output.get("data"),
Some(&serde_json::json!([i as i32])),
"outputs should arrive in send order"
);
}
}
#[test]
fn null_first_message_does_not_consume_type_check() {
use arrow::array::{Int32Array, NullArray};
use dora_message::metadata::Metadata;
let (_node, mut events) = test_event_stream();
let id = DataId::from("cam".to_string());
events
.input_type_checks
.insert(id.clone(), arrow_schema::DataType::Int32);
let clock = dora_core::uhlc::HLC::default();
let input = |arr: std::sync::Arc<dyn arrow::array::Array>| Event::Input {
id: id.clone(),
metadata: Metadata::new(clock.new_timestamp()),
data: dora_arrow_convert::internal::from_array_ref(arr),
};
events.note_produced_event(&input(std::sync::Arc::new(NullArray::new(1))));
assert!(
events.input_type_checks.contains_key(&id),
"a Null first message must not consume the type check"
);
events.note_produced_event(&input(std::sync::Arc::new(Int32Array::from(vec![1]))));
assert!(
!events.input_type_checks.contains_key(&id),
"the first non-Null message must consume the one-shot type check"
);
}
#[test]
fn is_empty_reflects_pending_passthrough() {
let (_node, mut events) = test_event_stream();
let _ = events.recv();
assert!(events.is_empty(), "should be empty after draining");
events.push_passthrough_for_testing(Event::ParamDeleted {
key: "k".to_string(),
});
assert!(
!events.is_empty(),
"should not be empty with pending passthrough"
);
}
#[test]
fn stream_poll_next_drains_pending_passthrough() {
use futures::StreamExt;
let (_node, mut events) = test_event_stream();
let _ = events.recv();
events.push_passthrough_for_testing(Event::ParamUpdate {
key: "threshold".to_string(),
value: serde_json::json!(42),
});
let next = futures::executor::block_on(events.next());
match next {
Some(Event::ParamUpdate { key, value }) => {
assert_eq!(key, "threshold");
assert_eq!(value, serde_json::json!(42));
}
other => panic!("expected ParamUpdate from passthrough, got {other:?}"),
}
}
#[test]
fn recv_async_drains_pending_passthrough_before_receiver() {
let (_node, mut events) = test_event_stream();
events.push_passthrough_for_testing(Event::ParamDeleted {
key: "x".to_string(),
});
let first = events.recv();
assert!(
matches!(first, Some(Event::ParamDeleted { .. })),
"expected passthrough ParamDeleted first, got {first:?}"
);
let second = events.recv();
assert!(
matches!(second, Some(Event::Stop(_))),
"expected Stop second, got {second:?}"
);
}
fn passthrough_input(id: &DataId, request_id: Option<&str>) -> Event {
use dora_message::metadata::{Metadata, MetadataParameters, Parameter, REQUEST_ID};
let mut params = MetadataParameters::new();
if let Some(request_id) = request_id {
params.insert(REQUEST_ID.into(), Parameter::String(request_id.to_string()));
}
let ts = dora_core::uhlc::HLC::default().new_timestamp();
Event::Input {
id: id.clone(),
metadata: Metadata::from_parameters(ts, params),
data: dora_arrow_convert::internal::from_array_ref(std::sync::Arc::new(
arrow::array::NullArray::new(1),
)),
}
}
fn count_buffered_for(events: &EventStream, id: &DataId) -> usize {
events
.pending_passthrough
.iter()
.filter(|e| matches!(e, Event::Input { id: eid, .. } if eid == id))
.count()
}
#[test]
fn buffer_passthrough_bounds_ordinary_inputs_per_queue_size() {
let (_node, mut events) = test_event_stream();
let _ = events.recv();
let id = DataId::from("sensor".to_string());
let cap = 10; for _ in 0..(cap + 5) {
events.buffer_passthrough(passthrough_input(&id, None));
}
assert_eq!(
count_buffered_for(&events, &id),
cap,
"passthrough retention must be capped at the input's queue_size"
);
assert_eq!(
events.drain_drop_counts().get(&id),
Some(&5),
"over-cap passthrough events must be counted as drops for the input"
);
}
#[test]
fn buffer_passthrough_preserves_correlated_over_ordinary() {
use dora_message::metadata::{REQUEST_ID, get_string_param};
let (_node, mut events) = test_event_stream();
let _ = events.recv();
let id = DataId::from("svc".to_string());
let cap = 10;
for i in 0..cap {
events.buffer_passthrough(passthrough_input(&id, Some(&format!("req-{i}"))));
}
for _ in 0..20 {
events.buffer_passthrough(passthrough_input(&id, None));
}
let correlated = events
.pending_passthrough
.iter()
.filter(|e| {
matches!(e, Event::Input { metadata, .. }
if get_string_param(&metadata.parameters, REQUEST_ID).is_some())
})
.count();
assert_eq!(
correlated, cap,
"every correlated response must be preserved"
);
assert_eq!(
events.pending_passthrough.len(),
cap,
"ordinary floods must not push the buffer past the cap"
);
}
#[test]
fn buffer_passthrough_never_drops_stop() {
let (_node, mut events) = test_event_stream();
let _ = events.recv();
events.buffer_passthrough(Event::Stop(StopCause::Manual));
let id = DataId::from("flood".to_string());
for _ in 0..30 {
events.buffer_passthrough(passthrough_input(&id, None));
}
assert!(
events
.pending_passthrough
.iter()
.any(|e| matches!(e, Event::Stop(_))),
"a buffered Stop must survive an input flood"
);
assert_eq!(
count_buffered_for(&events, &id),
10,
"the flooding input is still capped at its queue_size"
);
}
#[test]
fn buffer_passthrough_bounds_control_events() {
let (_node, mut events) = test_event_stream();
let _ = events.recv();
events.buffer_passthrough(Event::Stop(StopCause::Manual));
let pushed_reloads = NON_INPUT_EVENT_QUEUE_SIZE + 25;
for _ in 0..pushed_reloads {
events.buffer_passthrough(Event::Reload { operator_id: None });
}
let non_input = events
.pending_passthrough
.iter()
.filter(|e| !matches!(e, Event::Input { .. }))
.count();
assert_eq!(
non_input, NON_INPUT_EVENT_QUEUE_SIZE,
"buffered control events must be capped at the non-input limit"
);
assert!(
events
.pending_passthrough
.iter()
.any(|e| matches!(e, Event::Stop(_))),
"Stop must survive a control-event flood"
);
let expected_drops = (pushed_reloads + 1 - NON_INPUT_EVENT_QUEUE_SIZE) as u64;
assert_eq!(
events
.drain_drop_counts()
.get(&DataId::from(NON_INPUT_EVENT.to_string())),
Some(&expected_drops),
"control-event overflow must be accounted under the non-input key"
);
}
#[test]
fn recv_service_response_matches_after_non_matching_event() {
let events = vec![
TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Input {
id: "sensor".parse().unwrap(),
metadata: None,
data: None,
},
},
TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Input {
id: "response".parse().unwrap(),
metadata: Some(request_id_params("req-1")),
data: None,
},
},
TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Stop,
},
];
let inputs = TestingInput::Input(IntegrationTestInput::new(
"test-node".parse().unwrap(),
events,
));
let (tx, _rx) = crate::integration_testing::output_channel();
let outputs = TestingOutput::ToChannel(tx);
let options = TestingOptions {
skip_output_time_offsets: true,
};
let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
let server = NodeId::from("calc".to_string());
let response = futures::executor::block_on(events.recv_service_response(
"req-1",
&server,
Duration::from_secs(5),
));
match response {
Ok(Event::Input { id, .. }) => assert_eq!(id.as_str(), "response"),
other => panic!("expected the correlated response Input, got {other:?}"),
}
let buffered = events.recv();
assert!(
matches!(&buffered, Some(Event::Input { id, .. }) if id.as_str() == "sensor"),
"expected the buffered non-matching 'sensor' input, got {buffered:?}"
);
}
#[test]
fn recv_service_response_matches_buffered_response_from_prior_wait() {
let events = vec![
TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Input {
id: "response".parse().unwrap(),
metadata: Some(request_id_params("req-2")),
data: None,
},
},
TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Input {
id: "response".parse().unwrap(),
metadata: Some(request_id_params("req-1")),
data: None,
},
},
TimedIncomingEvent {
time_offset_secs: 0.0,
event: IncomingEvent::Stop,
},
];
let inputs = TestingInput::Input(IntegrationTestInput::new(
"test-node".parse().unwrap(),
events,
));
let (tx, _rx) = crate::integration_testing::output_channel();
let outputs = TestingOutput::ToChannel(tx);
let options = TestingOptions {
skip_output_time_offsets: true,
};
let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
let server = NodeId::from("calc".to_string());
let request_id_of = |event: &Event| match event {
Event::Input { metadata, .. } => dora_message::metadata::get_string_param(
&metadata.parameters,
dora_message::metadata::REQUEST_ID,
)
.map(str::to_owned),
_ => None,
};
let first = futures::executor::block_on(events.recv_service_response(
"req-1",
&server,
Duration::from_secs(5),
));
match &first {
Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-1")),
other => panic!("expected the req-1 response, got {other:?}"),
}
let second = futures::executor::block_on(events.recv_service_response(
"req-2",
&server,
Duration::from_secs(5),
));
match &second {
Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-2")),
other => panic!("expected the buffered req-2 response, got {other:?}"),
}
}
#[test]
fn recv_returns_none_after_stop() {
let (_node, mut events) = test_event_stream();
let first = events.recv();
assert!(matches!(first, Some(Event::Stop(_))));
let second = events.recv();
assert!(
second.is_none(),
"recv must return None after Stop, got {second:?}"
);
}
#[test]
fn recv_drains_buffered_scheduler_inputs_after_stop() {
let (_node, mut events) = test_event_stream();
assert!(matches!(events.recv(), Some(Event::Stop(_))));
events.push_scheduler_input_for_testing("cam");
let drained = events.recv();
assert!(
matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
"buffered input must be drained after Stop, got {drained:?}"
);
assert!(
events.recv().is_none(),
"stream must close after draining buffered inputs"
);
}
#[test]
fn stream_next_delivers_inputs_buffered_in_scheduler() {
use futures::{FutureExt, StreamExt};
let (_node, mut events) = test_event_stream();
events.push_scheduler_input_for_testing("cam");
let next = events.next().now_or_never();
assert!(
matches!(&next, Some(Some(Event::Input { id, .. })) if id.as_str() == "cam"),
"buffered input must be delivered by the Stream path, got {next:?}"
);
}
#[test]
fn stream_next_drains_buffered_inputs_after_stop() {
use futures::{FutureExt, StreamExt};
let (_node, mut events) = test_event_stream();
assert!(matches!(events.recv(), Some(Event::Stop(_))));
events.push_scheduler_stop_for_testing();
events.push_scheduler_input_for_testing("cam");
let drained = events.next().now_or_never();
assert!(
matches!(&drained, Some(Some(Event::Input { id, .. })) if id.as_str() == "cam"),
"buffered input must be drained after Stop, got {drained:?}"
);
assert!(
matches!(events.next().now_or_never(), Some(None)),
"stream must close after draining buffered inputs"
);
}
#[test]
fn recv_after_stop_skips_trailing_control_events() {
let (_node, mut events) = test_event_stream();
assert!(matches!(events.recv(), Some(Event::Stop(_))));
events.push_scheduler_stop_for_testing();
events.push_scheduler_input_for_testing("cam");
let drained = events.recv();
assert!(
matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
"expected the buffered input, not a re-delivered Stop, got {drained:?}"
);
assert!(events.recv().is_none(), "stream must close after the input");
}
#[test]
fn post_stop_drain_runs_first_message_type_check() {
use crate::event_stream::thread::EventItem;
use dora_message::metadata::Metadata;
let (_node, mut events) = test_event_stream();
let id = DataId::from("cam".to_string());
events
.input_type_checks
.insert(id.clone(), arrow_schema::DataType::Int32);
assert!(matches!(events.recv(), Some(Event::Stop(_))));
events.use_scheduler = true;
events.scheduler.add_event(EventItem::ZenohInput {
id: id.clone(),
metadata: std::sync::Arc::new(Metadata::new(
dora_core::uhlc::HLC::default().new_timestamp(),
)),
data: {
use arrow::array::Array;
arrow::array::Int32Array::from(vec![1]).into_data()
},
});
let drained = events.recv();
assert!(
matches!(&drained, Some(Event::Input { id: got, .. }) if got == &id),
"buffered input must be drained after Stop, got {drained:?}"
);
assert!(
!events.input_type_checks.contains_key(&id),
"the post-Stop drain must consume the one-shot type check, \
staying in lockstep with the normal receive path"
);
}
#[test]
fn zenoh_payload_ipc_roundtrip_and_empty_is_unit() {
use crate::arrow_utils::ipc_encode::{
InputDecoder, encode_ipc_into_data, ipc_fast_path_len_data,
};
use arrow::array::{Array, Int32Array};
let metadata =
dora_message::metadata::Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
let mut decoder = InputDecoder::new();
let unit = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::new())
.unwrap()
.unwrap();
assert_eq!(
unit.data_type(),
&arrow_schema::DataType::Null,
"empty payload maps to the unit array"
);
let data = Int32Array::from(vec![10, 20, 30]).into_data();
let len = ipc_fast_path_len_data(&data).expect("primitive is fast-path eligible");
let mut buf = vec![0u8; len];
encode_ipc_into_data(&data, &mut buf).unwrap();
let decoded = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::from(buf))
.unwrap()
.unwrap();
assert_eq!(decoded.data_type(), &arrow_schema::DataType::Int32);
assert_eq!(&decoded, &data);
}
#[test]
fn full_stream_primes_decoder_in_band_for_schema_less_batches() {
use crate::arrow_utils::ipc_encode::{
InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
ipc_fast_path_len_data, schema_block_len,
};
use arrow::array::{Array, Int32Array};
use dora_message::metadata::{Metadata, Parameter, SCHEMA_HASH};
let hlc = dora_core::uhlc::HLC::default();
let mut decoder = InputDecoder::new();
let first = Int32Array::from(vec![1, 2]).into_data();
let mut full = vec![0u8; ipc_fast_path_len_data(&first).unwrap()];
encode_ipc_into_data(&first, &mut full).unwrap();
let block = schema_block_len(&full).unwrap();
let hash = dora_message::metadata::fnv1a(&full[..block]);
let plain = Metadata::new(hlc.new_timestamp());
let got = decode_zenoh_sample(&mut decoder, &plain, zenoh::bytes::ZBytes::from(full))
.unwrap()
.unwrap();
assert_eq!(&got, &first);
let second = Int32Array::from(vec![3]).into_data();
let mut batch = vec![0u8; batch_fast_path_len_data(&second).unwrap()];
encode_batch_into_data(&second, &mut batch).unwrap();
let mut tagged = Metadata::new(hlc.new_timestamp());
tagged
.parameters
.insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
let got = decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
.unwrap()
.expect("schema-less batch must decode against the in-band-primed decoder");
assert_eq!(&got, &second);
}
#[test]
fn pattern_correlated_full_stream_does_not_prime_in_band() {
use crate::arrow_utils::ipc_encode::{
InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
ipc_fast_path_len_data, schema_block_len,
};
use arrow::array::{Array, Int32Array};
use dora_message::metadata::{Metadata, Parameter, REQUEST_ID, SCHEMA_HASH};
let hlc = dora_core::uhlc::HLC::default();
let mut decoder = InputDecoder::new();
let reply = Int32Array::from(vec![7]).into_data();
let mut full = vec![0u8; ipc_fast_path_len_data(&reply).unwrap()];
encode_ipc_into_data(&reply, &mut full).unwrap();
let block = schema_block_len(&full).unwrap();
let hash = dora_message::metadata::fnv1a(&full[..block]);
let mut service = Metadata::new(hlc.new_timestamp());
service
.parameters
.insert(REQUEST_ID.to_string(), Parameter::String("req-1".into()));
let got = decode_zenoh_sample(&mut decoder, &service, zenoh::bytes::ZBytes::from(full))
.unwrap()
.unwrap();
assert_eq!(&got, &reply);
let batch_array = Int32Array::from(vec![8]).into_data();
let mut batch = vec![0u8; batch_fast_path_len_data(&batch_array).unwrap()];
encode_batch_into_data(&batch_array, &mut batch).unwrap();
let mut tagged = Metadata::new(hlc.new_timestamp());
tagged
.parameters
.insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
assert!(
decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
.unwrap()
.is_none(),
"a pattern-correlated stream must not prime the schema-once decoder"
);
}
#[test]
fn internal_wire_keys_are_stripped_from_user_visible_metadata() {
use dora_message::metadata::{
FRAMING, FRAMING_ARROW_IPC, Metadata, Parameter, SCHEMA_HASH,
};
let hlc = dora_core::uhlc::HLC::default();
let mut metadata = Metadata::new(hlc.new_timestamp());
metadata
.parameters
.insert(SCHEMA_HASH.to_string(), Parameter::Integer(42));
metadata.parameters.insert(
FRAMING.to_string(),
Parameter::String(FRAMING_ARROW_IPC.to_string()),
);
metadata
.parameters
.insert("user_key".to_string(), Parameter::Integer(7));
let zenoh_item = EventItem::ZenohInput {
id: DataId::from("in".to_string()),
metadata: Arc::new(metadata.clone()),
data: {
use arrow::array::Array;
arrow::array::Int32Array::from(vec![1]).into_data()
},
};
let Event::Input {
metadata: user_metadata,
..
} = EventStream::convert_event_item(zenoh_item)
else {
panic!("expected an input event");
};
assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
assert!(!user_metadata.parameters.contains_key(FRAMING));
assert_eq!(
user_metadata.parameters.get("user_key"),
Some(&Parameter::Integer(7)),
"user-provided keys must survive the strip"
);
let daemon_item = EventItem::NodeEvent {
event: dora_message::daemon_to_node::NodeEvent::Input {
id: DataId::from("in".to_string()),
metadata: Arc::new(metadata),
data: None,
},
};
let Event::Input {
metadata: user_metadata,
..
} = EventStream::convert_event_item(daemon_item)
else {
panic!("expected an input event");
};
assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
assert!(!user_metadata.parameters.contains_key(FRAMING));
}
#[test]
fn zenoh_input_serializes_into_recording_json() {
use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
let hlc = dora_core::uhlc::HLC::default();
let start = hlc.new_timestamp();
let metadata = Metadata::new(hlc.new_timestamp());
let array: arrow::array::ArrayRef =
std::sync::Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3]));
let json = convert_arrow_input_to_json(
&DataId::from("in".to_string()),
&metadata,
array,
start,
true,
)
.expect("zenoh input must serialize");
assert_eq!(json["id"], "in");
assert!(json.contains_key("data"), "recorded event must carry data");
assert!(
json.contains_key("data_type"),
"recorded event must carry data_type"
);
assert_eq!(
json["data"].as_array().map(|a| a.len()),
Some(3),
"all array elements must be recorded"
);
}
#[test]
fn zenoh_input_with_earlier_timestamp_does_not_underflow() {
use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
let hlc = dora_core::uhlc::HLC::default();
let input_ts = hlc.new_timestamp();
let start = hlc.new_timestamp();
let metadata = Metadata::new(input_ts);
let array: arrow::array::ArrayRef =
std::sync::Arc::new(arrow::array::Int32Array::from(vec![1]));
let json = convert_arrow_input_to_json(
&DataId::from("in".to_string()),
&metadata,
array,
start,
false,
)
.expect("recording an earlier-timestamped input must not fail");
assert_eq!(
json["time_offset_secs"], 0.0,
"an input predating start must clamp to a zero offset"
);
}
#[test]
fn schema_plane_fatal_waits_out_the_grace_window() {
let start = Instant::now();
let mut first = None;
assert!(!schema_plane_fatal_due(&mut first, start));
assert_eq!(first, Some(start));
assert!(!schema_plane_fatal_due(
&mut first,
start + SCHEMA_PLANE_FATAL_GRACE / 2
));
assert!(schema_plane_fatal_due(
&mut first,
start + SCHEMA_PLANE_FATAL_GRACE
));
let mut first = None;
let later = start + SCHEMA_PLANE_FATAL_GRACE * 2;
assert!(!schema_plane_fatal_due(&mut first, later));
assert_eq!(first, Some(later));
}
#[test]
fn stream_returns_none_after_stop() {
use futures::StreamExt;
let (_node, mut events) = test_event_stream();
let first = futures::executor::block_on(events.next());
assert!(matches!(first, Some(Event::Stop(_))));
let second = futures::executor::block_on(events.next());
assert!(
second.is_none(),
"Stream::next must yield None after Stop, got {second:?}"
);
}
fn zenoh_item(id: &str) -> EventItem {
use dora_arrow_convert::IntoArrow;
let ts = uhlc::HLC::default().new_timestamp();
EventItem::ZenohInput {
id: DataId::from(id.to_string()),
metadata: std::sync::Arc::new(Metadata::new(ts)),
data: dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
}
}
#[test]
fn ingress_overflow_is_counted() {
let (tx, _rx) = tokio::sync::mpsc::channel::<EventItem>(1);
let id = DataId::from("camera".to_string());
let drops = std::sync::Arc::new(AtomicU64::new(0));
send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
assert_eq!(drops.load(Ordering::Relaxed), 0);
send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
assert_eq!(drops.load(Ordering::Relaxed), 2);
}
#[test]
fn ingress_drop_warning_recurs_without_draining() {
assert!(should_warn_ingress_drop(1));
assert!(should_warn_ingress_drop(2));
assert!(should_warn_ingress_drop(4));
assert!(should_warn_ingress_drop(1024));
assert!(!should_warn_ingress_drop(3));
assert!(!should_warn_ingress_drop(5));
assert!(!should_warn_ingress_drop(1000));
}
#[test]
fn ingress_send_to_a_closed_channel_is_not_a_drop() {
let (tx, rx) = tokio::sync::mpsc::channel::<EventItem>(1);
drop(rx);
let id = DataId::from("camera".to_string());
let drops = std::sync::Arc::new(AtomicU64::new(0));
send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
assert_eq!(drops.load(Ordering::Relaxed), 0);
}
#[test]
fn drain_drop_counts_merges_ingress_and_scheduler_drops() {
let (_node, mut events) = test_event_stream();
let id = DataId::from("camera".to_string());
events.scheduler.record_drop(&id);
events
.ingress_drops
.entry(id.clone())
.or_default()
.fetch_add(2, Ordering::Relaxed);
let counts = events.drain_drop_counts();
assert_eq!(counts.get(&id), Some(&3));
assert!(
events.drain_drop_counts().is_empty(),
"drain must reset both sources"
);
}
}