use std::{
collections::{BTreeMap, HashMap, VecDeque},
path::PathBuf,
pin::pin,
sync::{
Arc,
atomic::{AtomicBool, 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, 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;
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,
write_events_to: Option<WriteEventsTo>,
start_timestamp: uhlc::Timestamp,
use_scheduler: 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
}
}
}
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 = 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 event stream for node `{node_id}`")
})?
}
DaemonCommunication::Interactive => {
DaemonChannel::Interactive(Default::default())
}
}
}
DaemonCommunicationWrapper::Testing { channel, .. } => {
DaemonChannel::IntegrationTestChannel(channel.clone())
}
};
let testing_shutdown = match daemon_communication {
DaemonCommunicationWrapper::Testing { shutdown, .. } => Some(shutdown.clone()),
_ => None,
};
let close_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 event close channel for node `{node_id}`")
})?
}
DaemonCommunication::Interactive => {
DaemonChannel::Interactive(Default::default())
}
}
}
DaemonCommunicationWrapper::Testing { channel, .. } => {
DaemonChannel::IntegrationTestChannel(channel.clone())
}
};
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()),
(1_000, 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: usize = input_config
.values()
.map(|c| {
c.queue_size
.unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE)
})
.sum::<usize>()
.max(64);
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();
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 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;
}
if let Err(e) = tx_cb.try_send(EventItem::ZenohInput {
id: input_id_cb.clone(),
metadata: std::sync::Arc::new(metadata),
data,
}) {
use tokio::sync::mpsc::error::TrySendError;
match e {
TrySendError::Full(_) => {
tracing::warn!(
"event channel full; dropping zenoh input"
);
}
TrySendError::Closed(_) => {
}
}
}
}));
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,
write_events_to,
use_scheduler,
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 {
if self.use_scheduler {
while let Some(item) = self.scheduler.next() {
if matches!(
&item,
EventItem::NodeEvent {
event: NodeEvent::Input { .. },
..
} | EventItem::ZenohInput { .. }
) {
return Some(Self::convert_event_item(item));
}
}
}
return None;
}
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 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 Some(expected) = self.input_type_checks.remove(id)
{
let raw = dora_arrow_convert::internal::array_ref(data);
let actual = raw.data_type();
if *actual != arrow_schema::DataType::Null && *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> {
self.scheduler.drain_drop_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);
}
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
}
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 = std::time::Instant::now() + 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.pending_passthrough.push_back(event);
return Err(PatternError::ServerRestarted(expected_server.to_string()));
}
CorrelationOutcome::StreamEnded => {
self.pending_passthrough.push_back(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.pending_passthrough.push_back(event);
}
}
}
}
}
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,
}
}
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 data: eyre::Result<Option<RawData>> = match data {
None => Ok(None),
Some(DataMessage::Vec(v)) => Ok(Some(RawData::Vec(v))),
};
data.and_then(|data| {
let raw_data = data.unwrap_or(RawData::Empty);
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(None);
}
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.scheduler.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.scheduler.add_event(EventItem::NodeEvent {
event: NodeEvent::Stop,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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 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:?}"
);
}
#[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 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 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:?}"
);
}
}