use crate::{
DaemonCommunicationWrapper, EventStream, NodeError, NodeResult,
daemon_connection::{DaemonChannel, IntegrationTestingEvents},
integration_testing::{
TestingCommunication, TestingInput, TestingOptions, TestingOutput,
take_testing_communication,
},
};
use self::{arrow_utils::ipc_encode, control_channel::ControlChannel};
use aligned_vec::{AVec, ConstAlign};
use arrow::array::{Array, ArrayData};
use colored::Colorize;
use dora_arrow_convert::{DoraArray, IntoArrow};
use dora_core::{
config::{DataId, NodeId, NodeRunConfig},
descriptor::Descriptor,
topics::{DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT, DORA_DAEMON_LOCAL_LISTEN_PORT_ENV, LOCALHOST},
types::TypeRegistry,
uhlc,
};
use dora_message::{
DataflowId,
daemon_to_node::{DaemonCommunication, DaemonReply, NodeConfig, OutputRouting},
metadata::{
FIN, FLUSH, FRAMING, FRAMING_ARROW_IPC, Metadata, MetadataParameters, Parameter,
SCHEMA_HASH, SEGMENT_ID, SEQ, SESSION_ID,
},
node_to_daemon::{DaemonRequest, DataMessage, Timestamped},
};
use eyre::WrapErr;
use is_terminal::IsTerminal;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
path::PathBuf,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
#[cfg(feature = "tracing")]
use tokio::runtime::Handle;
#[cfg(feature = "tracing")]
use dora_tracing::{OtelGuard, TracingBuilder};
use tracing::{debug, error, info, warn};
pub mod arrow_utils;
mod control_channel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeTypeCheck {
Off,
Warn,
Error,
}
impl RuntimeTypeCheck {
fn from_env() -> Self {
Self::from_value(std::env::var("DORA_RUNTIME_TYPE_CHECK").ok().as_deref())
}
fn from_value(value: Option<&str>) -> Self {
match value {
Some("error") => Self::Error,
Some("1" | "warn" | "true" | "on") => Self::Warn,
Some("" | "0" | "false" | "off") | None => Self::Off,
Some(other) => {
tracing::warn!(
"unknown DORA_RUNTIME_TYPE_CHECK value \"{other}\", \
expected \"warn\" or \"error\"; disabling runtime type check"
);
Self::Off
}
}
}
}
#[cfg(test)]
mod runtime_type_check_tests {
use super::RuntimeTypeCheck;
#[test]
fn parses_enable_spellings() {
for v in ["1", "warn", "true", "on"] {
assert_eq!(
RuntimeTypeCheck::from_value(Some(v)),
RuntimeTypeCheck::Warn
);
}
assert_eq!(
RuntimeTypeCheck::from_value(Some("error")),
RuntimeTypeCheck::Error
);
}
#[test]
fn disable_spellings_and_unset_are_off() {
for v in ["", "0", "false", "off"] {
assert_eq!(RuntimeTypeCheck::from_value(Some(v)), RuntimeTypeCheck::Off);
}
assert_eq!(RuntimeTypeCheck::from_value(None), RuntimeTypeCheck::Off);
}
#[test]
fn unknown_value_falls_back_to_off() {
assert_eq!(
RuntimeTypeCheck::from_value(Some("maybe")),
RuntimeTypeCheck::Off
);
}
}
pub const ZERO_COPY_THRESHOLD: usize = 4096;
const LARGE_SEND_DIAG_LIMIT: u32 = 3;
const ZENOH_STARTUP_MARKER_INTERVAL: Duration = Duration::from_millis(5);
const ZENOH_STARTUP_GRACE: Duration = Duration::from_millis(500);
const ZENOH_STARTUP_GRACE_POLL_INTERVAL: Duration = Duration::from_millis(2);
struct DirectOutput {
publisher: zenoh::pubsub::Publisher<'static>,
ready: Arc<AtomicBool>,
}
type ZenohPublishers = HashMap<DataId, DirectOutput>;
fn declare_output_publishers(
session: &zenoh::Session,
dataflow_id: DataflowId,
node_id: &NodeId,
outputs: &BTreeSet<DataId>,
routing: &BTreeMap<DataId, OutputRouting>,
) -> (ZenohPublishers, Vec<Arc<AckState>>) {
use zenoh::Wait;
use zenoh::qos::{CongestionControl, Priority};
let mut publishers = HashMap::new();
let mut ack_states = Vec::new();
for output_id in outputs {
let Some(output_routing) = routing.get(output_id) else {
warn!(output = %output_id, "no routing entry for output; staying on the daemon path");
continue;
};
if output_routing.daemon_only {
debug!(
output = %output_id,
"output pinned to the daemon path (a consumer is reachable only by \
inter-daemon forwarding)"
);
continue;
}
let topic = dora_core::topics::zenoh_output_publish_topic(dataflow_id, node_id, output_id);
let key_expr = match zenoh::key_expr::KeyExpr::new(topic) {
Ok(key) => key.into_owned(),
Err(e) => {
warn!(output = %output_id, "invalid zenoh key ({e}); falling back to daemon path");
continue;
}
};
match session
.declare_publisher(key_expr)
.congestion_control(CongestionControl::Drop)
.express(true)
.priority(Priority::RealTime)
.wait()
{
Ok(publisher) => {
let ready = Arc::new(AtomicBool::new(output_routing.required_ackers.is_empty()));
if !output_routing.required_ackers.is_empty() {
ack_states.push(Arc::new(AckState::new(
output_id.clone(),
&output_routing.required_ackers,
ready.clone(),
)));
}
publishers.insert(output_id.clone(), DirectOutput { publisher, ready });
}
Err(e) => {
warn!(output = %output_id, "failed to declare zenoh publisher ({e}); falling back to daemon path");
}
}
}
(publishers, ack_states)
}
struct AckState {
output_id: DataId,
required: BTreeSet<(String, String)>,
received: Mutex<BTreeSet<(String, String)>>,
ready: Arc<AtomicBool>,
frozen: AtomicBool,
}
impl AckState {
fn new(
output_id: DataId,
required: &BTreeSet<dora_message::daemon_to_node::RequiredAcker>,
ready: Arc<AtomicBool>,
) -> Self {
Self {
output_id,
required: required
.iter()
.map(|acker| (acker.node_id.to_string(), acker.input_id.to_string()))
.collect(),
received: Mutex::new(BTreeSet::new()),
ready,
frozen: AtomicBool::new(false),
}
}
fn received(&self) -> std::sync::MutexGuard<'_, BTreeSet<(String, String)>> {
self.received
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn record(&self, consumer_node: &str, input_id: &str) {
let identity = (consumer_node.to_owned(), input_id.to_owned());
if !self.required.contains(&identity) {
return;
}
let mut received = self.received();
if self.frozen.load(Ordering::Relaxed) {
return;
}
received.insert(identity);
if received.len() == self.required.len() {
self.ready.store(true, Ordering::Relaxed);
}
}
fn freeze(&self) -> bool {
let _guard = self.received();
if self.ready.load(Ordering::Relaxed) {
return false;
}
self.frozen.store(true, Ordering::Relaxed);
true
}
fn is_frozen(&self) -> bool {
self.frozen.load(Ordering::Relaxed)
}
fn missing(&self) -> Vec<String> {
let received = self.received();
self.required
.difference(&received)
.map(|(node, input)| format!("{node}/{input}"))
.collect()
}
}
fn declare_ack_subscribers(
session: &zenoh::Session,
dataflow_id: DataflowId,
node_id: &NodeId,
ack_states: &mut Vec<Arc<AckState>>,
) -> Vec<zenoh::pubsub::Subscriber<()>> {
use zenoh::Wait;
let mut subscribers = Vec::new();
let mut awaited = Vec::new();
for state in ack_states.drain(..) {
let topic =
dora_core::topics::zenoh_output_ack_topic(dataflow_id, node_id, &state.output_id);
let state_cb = state.clone();
let subscriber = session
.declare_subscriber(topic)
.callback(move |sample| {
let Some(attachment) = sample.attachment() else {
return;
};
let Ok(metadata) = dora_message::decode::<Metadata>(&attachment.to_bytes()) else {
return;
};
if metadata.metadata_version() != Metadata::CURRENT_VERSION {
return;
}
if let Some((consumer, input)) = metadata.startup_ack_identity() {
state_cb.record(consumer, input);
}
})
.wait();
match subscriber {
Ok(subscriber) => {
subscribers.push(subscriber);
awaited.push(state);
}
Err(e) => {
warn!(
output = %state.output_id,
"failed to declare startup-ack subscriber ({e}); output stays on the daemon path"
);
}
}
}
*ack_states = awaited;
subscribers
}
struct StartupHandshake {
stop: Arc<AtomicBool>,
ack_states: Vec<Arc<AckState>>,
handle: Option<std::thread::JoinHandle<()>>,
ack_subscribers: Vec<zenoh::pubsub::Subscriber<()>>,
}
impl StartupHandshake {
fn start(
session: &zenoh::Session,
dataflow_id: DataflowId,
node_id: &NodeId,
publishers: &Arc<ZenohPublishers>,
mut ack_states: Vec<Arc<AckState>>,
clock: Arc<uhlc::HLC>,
) -> Self {
use zenoh::Wait;
let stop = Arc::new(AtomicBool::new(false));
let ack_subscribers =
declare_ack_subscribers(session, dataflow_id, node_id, &mut ack_states);
if ack_states.is_empty() {
return Self {
stop,
ack_states,
handle: None,
ack_subscribers,
};
}
let thread_stop = stop.clone();
let thread_states = ack_states.clone();
let thread_publishers = publishers.clone();
let handle = std::thread::Builder::new()
.name("dora-startup-handshake".into())
.spawn(move || {
loop {
if thread_stop.load(Ordering::Relaxed) {
return;
}
let mut awaiting = false;
for state in &thread_states {
if state.ready.load(Ordering::Relaxed) || state.is_frozen() {
continue;
}
awaiting = true;
let Some(output) = thread_publishers.get(&state.output_id) else {
continue;
};
let metadata = Metadata::startup_marker(clock.new_timestamp());
let attachment = match dora_message::encode(&metadata) {
Ok(bytes) => bytes,
Err(e) => {
debug!(output = %state.output_id, "failed to serialize startup marker ({e})");
continue;
}
};
if let Err(e) = output
.publisher
.put(&[][..])
.attachment(&attachment[..])
.wait()
{
tracing::trace!(output = %state.output_id, "startup marker put failed ({e})");
}
}
if !awaiting {
return;
}
std::thread::sleep(ZENOH_STARTUP_MARKER_INTERVAL);
}
});
match handle {
Ok(handle) => Self {
stop,
ack_states,
handle: Some(handle),
ack_subscribers,
},
Err(e) => {
error!(
"failed to spawn startup-handshake thread ({e}); outputs stay on the daemon path"
);
Self {
stop,
ack_states,
handle: None,
ack_subscribers,
}
}
}
}
fn settle(&self, grace: Duration) {
wait_for_grace(&self.ack_states, grace);
}
fn shutdown(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for StartupHandshake {
fn drop(&mut self) {
self.shutdown();
}
}
fn wait_for_grace(ack_states: &[Arc<AckState>], grace: Duration) {
let grace_deadline = Instant::now() + grace;
loop {
if ack_states
.iter()
.all(|state| state.ready.load(Ordering::Relaxed))
{
break;
}
if Instant::now() >= grace_deadline {
break;
}
std::thread::sleep(ZENOH_STARTUP_GRACE_POLL_INTERVAL);
}
for state in ack_states {
if state.freeze() {
warn!(
output = %state.output_id,
missing = ?state.missing(),
"startup handshake incomplete after {}ms; output stays on the \
reliable daemon path for the rest of the run",
grace.as_millis()
);
} else {
debug!(
output = %state.output_id,
"startup handshake complete; output takes the direct zenoh path"
);
}
}
}
fn normalize_output_routing(
routing: Option<BTreeMap<DataId, OutputRouting>>,
outputs: &BTreeSet<DataId>,
) -> BTreeMap<DataId, OutputRouting> {
match routing {
Some(routing) => routing,
None => {
if !outputs.is_empty() {
warn!(
"node config carries no output routing (spawned by an older daemon?); \
all outputs stay on the reliable daemon path"
);
}
outputs
.iter()
.map(|output_id| {
(
output_id.clone(),
OutputRouting {
daemon_only: true,
required_ackers: Default::default(),
},
)
})
.collect()
}
}
}
pub(crate) const ZENOH_TEARDOWN_TIMEOUT: Duration = Duration::from_secs(3);
const TESTING_DAEMON_CHANNEL_CAPACITY: usize = 256;
pub struct DoraNode {
id: NodeId,
dataflow_id: DataflowId,
node_config: NodeRunConfig,
control_channel: ControlChannel,
clock: Arc<uhlc::HLC>,
zenoh_session: Option<zenoh::Session>,
sample_allocator: SampleAllocator,
zenoh_publishers: Arc<ZenohPublishers>,
startup_handshake: Option<StartupHandshake>,
zenoh_schema_publishers: HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
zenoh_schema_state: HashMap<DataId, SchemaOnceState>,
large_send_diag_count: u32,
dataflow_descriptor: serde_yaml::Result<Descriptor>,
warned_unknown_output: BTreeSet<DataId>,
interactive: bool,
restart_count: u32,
runtime_type_checks: Option<(RuntimeTypeCheck, HashMap<DataId, arrow_schema::DataType>)>,
_owned_runtime: Option<tokio::runtime::Runtime>,
testing_daemon: Option<std::thread::JoinHandle<()>>,
testing_shutdown: Option<Arc<AtomicBool>>,
}
impl DoraNode {
pub fn init_from_env() -> NodeResult<(Self, EventStream)> {
Self::init_from_env_inner(true)
}
pub fn init_from_env_force() -> NodeResult<(Self, EventStream)> {
Self::init_from_env_inner(false)
}
fn init_from_env_inner(fallback_to_interactive: bool) -> NodeResult<(Self, EventStream)> {
if let Some(testing_comm) = take_testing_communication() {
let TestingCommunication {
input,
output,
options,
} = *testing_comm;
return Self::init_testing(input, output, options);
}
match std::env::var("DORA_NODE_CONFIG") {
Ok(raw) => {
let node_config: NodeConfig =
serde_yaml::from_str(&raw).context("failed to deserialize node config")?;
return Self::init(node_config);
}
Err(std::env::VarError::NotUnicode(_)) => {
return Err(NodeError::Init(
"DORA_NODE_CONFIG env variable is not valid unicode".into(),
));
}
Err(std::env::VarError::NotPresent) => {} };
match std::env::var("DORA_TEST_WITH_INPUTS") {
Ok(raw) => {
let input_file = PathBuf::from(raw);
let output_file = match std::env::var("DORA_TEST_WRITE_OUTPUTS_TO") {
Ok(raw) => PathBuf::from(raw),
Err(std::env::VarError::NotUnicode(_)) => {
return Err(NodeError::Init(
"DORA_TEST_WRITE_OUTPUTS_TO env variable is not valid unicode".into(),
));
}
Err(std::env::VarError::NotPresent) => {
input_file.with_file_name("outputs.jsonl")
}
};
let skip_output_time_offsets =
std::env::var_os("DORA_TEST_NO_OUTPUT_TIME_OFFSET").is_some();
let input = TestingInput::FromJsonFile(input_file);
let output = TestingOutput::ToFile(output_file);
let options = TestingOptions {
skip_output_time_offsets,
};
return Self::init_testing(input, output, options);
}
Err(std::env::VarError::NotUnicode(_)) => {
return Err(NodeError::Init(
"DORA_TEST_WITH_INPUTS env variable is not valid unicode".into(),
));
}
Err(std::env::VarError::NotPresent) => {} }
if fallback_to_interactive && std::io::stdin().is_terminal() {
println!(
"{}",
"Starting node in interactive mode as DORA_NODE_CONFIG env variable is not set"
.green()
);
return Self::init_interactive();
}
Err(NodeError::Init(
"DORA_NODE_CONFIG env variable is not set".into(),
))
}
pub fn builder() -> DoraNodeBuilder {
DoraNodeBuilder::default()
}
pub fn init_from_node_id(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
Self::builder().node_id(node_id).build()
}
pub fn init_flexible(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
if std::env::var("DORA_NODE_CONFIG").is_ok() {
info!(
"Skipping {node_id} specified within the node initialization in favor of `DORA_NODE_CONFIG` specified by `dora start`"
);
Self::init_from_env()
} else {
Self::init_from_node_id(node_id)
}
}
pub fn init_interactive() -> NodeResult<(Self, EventStream)> {
#[cfg(feature = "tracing")]
{
TracingBuilder::new("node")
.with_stdout("debug", false)
.build()
.wrap_err("failed to set up tracing subscriber")?;
}
let node_config = NodeConfig {
dataflow_id: DataflowId::new_v4(),
node_id: "test-node"
.parse()
.map_err(|e| NodeError::Init(format!("{e}")))?,
run_config: NodeRunConfig::default(),
daemon_communication: Some(DaemonCommunication::Interactive),
dataflow_descriptor: serde_yaml::Value::Null,
dynamic: false,
write_events_to: None,
restart_count: 0,
output_routing: None,
};
let (mut node, events) = Self::init(node_config)?;
node.interactive = true;
Ok((node, events))
}
pub fn init_testing(
input: TestingInput,
output: TestingOutput,
options: TestingOptions,
) -> NodeResult<(Self, EventStream)> {
let node_config = NodeConfig {
dataflow_id: DataflowId::new_v4(),
node_id: "test-node"
.parse()
.map_err(|e| NodeError::Init(format!("{e}")))?,
run_config: NodeRunConfig::default(),
daemon_communication: None,
dataflow_descriptor: serde_yaml::Value::Null,
dynamic: false,
write_events_to: None,
restart_count: 0,
output_routing: None,
};
let testing_comm = TestingCommunication {
input,
output,
options,
};
let (mut node, events) = Self::init_with_options(node_config, Some(testing_comm))?;
node.interactive = true;
Ok((node, events))
}
#[doc(hidden)]
#[tracing::instrument]
pub fn init(node_config: NodeConfig) -> NodeResult<(Self, EventStream)> {
Self::init_with_options(node_config, None)
}
#[tracing::instrument(skip(testing_communication))]
fn init_with_options(
node_config: NodeConfig,
testing_communication: Option<TestingCommunication>,
) -> NodeResult<(Self, EventStream)> {
crate::orphan_guard::arm_if_run_child();
let NodeConfig {
dataflow_id,
node_id,
run_config,
daemon_communication,
dataflow_descriptor,
dynamic,
write_events_to,
restart_count,
output_routing,
} = node_config;
let clock = Arc::new(uhlc::HLC::default());
let input_config = run_config.inputs.clone();
let (daemon_communication, testing_daemon, testing_shutdown) = match daemon_communication {
Some(comm) => (comm.into(), None, None),
None => match testing_communication {
Some(comm) => {
let TestingCommunication {
input,
output,
options,
} = comm;
let (sender, mut receiver) =
tokio::sync::mpsc::channel(TESTING_DAEMON_CHANNEL_CAPACITY);
let shutdown = Arc::new(AtomicBool::new(false));
let new_communication = DaemonCommunicationWrapper::Testing {
channel: sender,
shutdown: shutdown.clone(),
};
let mut events =
IntegrationTestingEvents::new(input, output, options, shutdown.clone())?;
let shutdown_for_loop = shutdown.clone();
let handle = std::thread::Builder::new()
.name("dora-testing-daemon".into())
.spawn(move || {
while let Some((request, reply_sender)) = receiver.blocking_recv() {
let outputs_done =
matches!(request.inner, DaemonRequest::OutputsDone);
let reply = events.request(&request);
if reply_sender
.send(reply.unwrap_or_else(|err| {
DaemonReply::Result(Err(format!("{err:?}")))
}))
.is_err()
{
eprintln!("failed to send reply");
}
if outputs_done && shutdown_for_loop.load(Ordering::Relaxed) {
break;
}
}
})
.map_err(|e| {
NodeError::Init(format!("failed to spawn testing daemon thread: {e}"))
})?;
(new_communication, Some(handle), Some(shutdown))
}
None => {
return Err(NodeError::Init(
"no daemon communication method specified".into(),
));
}
},
};
let is_standard_mode = matches!(
daemon_communication,
DaemonCommunicationWrapper::Standard(_)
);
let shm_pool_size = run_config
.shared_memory_pool_size
.map(|bs| bs.as_bytes())
.or_else(|| {
std::env::var("DORA_NODE_SHM_POOL_SIZE")
.ok()
.and_then(|s| s.parse::<usize>().ok())
})
.unwrap_or(8 * 1024 * 1024);
let zenoh_zero_copy_threshold = std::env::var("DORA_ZERO_COPY_THRESHOLD")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(ZERO_COPY_THRESHOLD);
let (zenoh_session, zenoh_shm_provider, owned_runtime) = if !is_standard_mode {
(None, None, None)
} else {
let (handle, owned_runtime) = match tokio::runtime::Handle::try_current() {
Ok(handle) => (handle, None),
Err(_) => {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("dora-node-runtime")
.build()
.map_err(|e| {
NodeError::Init(format!("failed to create owned tokio runtime: {e}"))
})?;
let handle = rt.handle().clone();
(handle, Some(rt))
}
};
let session = std::thread::scope(|s| {
match s
.spawn(|| handle.block_on(dora_core::topics::open_zenoh_session(None)))
.join()
{
Ok(Ok(session)) => Ok(session),
Ok(Err(e)) => Err(NodeError::Init(format!(
"failed to open zenoh session: {e:?}"
))),
Err(_panic) => Err(NodeError::Init("zenoh session init panicked".into())),
}
})?;
let provider = {
use zenoh::Wait;
use zenoh::shm::{AllocAlignment, MemoryLayout, ShmProviderBuilder};
let alignment =
AllocAlignment::new(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT_EXPONENT)
.expect("ARROW_BUFFER_ALIGNMENT is a valid power-of-two alignment");
let layout = shm_pool_size
.checked_next_multiple_of(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT)
.and_then(|aligned| MemoryLayout::new(aligned, alignment).ok());
match layout {
Some(layout) => match ShmProviderBuilder::default_backend(layout).wait() {
Ok(provider) => Some(Arc::new(provider)),
Err(e) => {
warn!(
"failed to create zenoh SHM provider ({e}); \
falling back to heap-buffered publishes"
);
None
}
},
None => {
warn!(
"invalid zenoh SHM pool size ({shm_pool_size}); \
falling back to heap-buffered publishes"
);
None
}
}
};
(Some(session), provider, owned_runtime)
};
let (zenoh_publishers, startup_handshake) = match zenoh_session.as_ref() {
Some(session) => {
let routing = normalize_output_routing(output_routing, &run_config.outputs);
let (publishers, ack_states) = declare_output_publishers(
session,
dataflow_id,
&node_id,
&run_config.outputs,
&routing,
);
let publishers = Arc::new(publishers);
let handshake = StartupHandshake::start(
session,
dataflow_id,
&node_id,
&publishers,
ack_states,
clock.clone(),
);
(publishers, Some(handshake))
}
None => (Arc::new(HashMap::new()), None),
};
let event_stream = EventStream::init(
dataflow_id,
&node_id,
&daemon_communication,
input_config,
&run_config.input_types,
clock.clone(),
write_events_to,
zenoh_session.as_ref(),
)
.wrap_err("failed to init event stream")?;
if let Some(handshake) = &startup_handshake {
handshake.settle(ZENOH_STARTUP_GRACE);
}
let control_channel =
ControlChannel::init(dataflow_id, &node_id, &daemon_communication, clock.clone())
.wrap_err("failed to init control channel")?;
let runtime_type_checks = match RuntimeTypeCheck::from_env() {
RuntimeTypeCheck::Off => None,
mode => {
let registry = TypeRegistry::new();
let mut checks = HashMap::new();
for (id, urn) in &run_config.output_types {
match registry.resolve_arrow_type(urn) {
Some(dt) => {
checks.insert(id.clone(), dt);
}
None => {
if registry.resolve(urn).is_some() {
info!(
"runtime type check: skipping complex type \"{urn}\" on output \"{id}\""
);
} else {
warn!(
"runtime type check: unknown type URN \"{urn}\" on output \"{id}\""
);
}
}
}
}
Some((mode, checks))
}
};
let node = Self {
id: node_id,
dataflow_id,
node_config: run_config.clone(),
control_channel,
clock,
zenoh_session,
zenoh_publishers,
startup_handshake,
zenoh_schema_publishers: HashMap::new(),
zenoh_schema_state: HashMap::new(),
sample_allocator: SampleAllocator {
shm_provider: zenoh_shm_provider,
zero_copy_threshold: zenoh_zero_copy_threshold,
},
large_send_diag_count: 0,
dataflow_descriptor: serde_yaml::from_value(dataflow_descriptor),
warned_unknown_output: BTreeSet::new(),
interactive: false,
restart_count,
runtime_type_checks,
_owned_runtime: owned_runtime,
testing_daemon,
testing_shutdown,
};
if dynamic {
if let Ok(descriptor) = &node.dataflow_descriptor
&& let Some(env_vars) = descriptor
.nodes
.iter()
.find(|n| n.id == node.id)
.and_then(|n| n.env.as_ref())
{
for key in env_vars.keys() {
if std::env::var(key).is_err() {
warn!(
"env var `{key}` declared in dataflow descriptor is not set; \
it should have been injected by the daemon at spawn time"
);
}
}
}
}
Ok((node, event_stream))
}
pub fn validate_output(&mut self, output_id: &DataId) -> bool {
if !self.node_config.outputs.contains(output_id) && !self.interactive {
if !self.warned_unknown_output.contains(output_id) {
warn!("Ignoring output `{output_id}` not in node's output list.");
self.warned_unknown_output.insert(output_id.clone());
}
false
} else {
true
}
}
pub fn send_output_raw<F>(
&mut self,
output_id: DataId,
parameters: MetadataParameters,
data_len: usize,
data: F,
) -> NodeResult<()>
where
F: FnOnce(&mut [u8]),
{
if !self.validate_output(&output_id) {
return Ok(());
};
let prepared = ipc_encode::PreparedUint8Ipc::new(data_len)
.map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
let mut sample = self.allocate_data_sample(prepared.byte_len())?;
let offset = prepared
.encode_header_into(&mut sample)
.map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
data(&mut sample[offset..offset + data_len]);
let mut parameters = parameters;
parameters.insert(
FRAMING.to_string(),
Parameter::String(FRAMING_ARROW_IPC.to_string()),
);
self.send_output_sample(output_id, parameters, Some(sample))
}
pub fn send_output(
&mut self,
output_id: DataId,
parameters: MetadataParameters,
data: impl IntoArrow,
) -> NodeResult<()> {
if !self.validate_output(&output_id) {
return Ok(());
};
let data = data.into_arrow();
let arrow_array = dora_arrow_convert::internal::array_ref(&data).to_data();
self.check_output_type(&output_id, arrow_array.data_type(), ¶meters)?;
let encoded = self.sample_allocator.encode_arrow_data(&arrow_array)?;
self.send_encoded_unchecked(output_id, parameters, encoded.sample)
}
pub fn send_output_encoded(
&mut self,
output_id: DataId,
parameters: MetadataParameters,
encoded: EncodedSample,
) -> NodeResult<()> {
if !self.validate_output(&output_id) {
return Ok(());
};
self.check_output_type(&output_id, &encoded.data_type, ¶meters)?;
self.send_encoded_unchecked(output_id, parameters, encoded.sample)
}
fn send_encoded_unchecked(
&mut self,
output_id: DataId,
mut parameters: MetadataParameters,
sample: DataSample,
) -> NodeResult<()> {
parameters.insert(
FRAMING.to_string(),
Parameter::String(FRAMING_ARROW_IPC.to_string()),
);
self.send_output_sample(output_id, parameters, Some(sample))
.wrap_err("failed to send output")?;
Ok(())
}
fn check_output_type(
&self,
output_id: &DataId,
actual: &arrow_schema::DataType,
parameters: &MetadataParameters,
) -> NodeResult<()> {
if let Some((mode, checks)) = &self.runtime_type_checks
&& let Some(expected) = checks.get(output_id)
&& !carries_pattern_correlation(parameters)
&& actual != expected
{
let msg =
format!("output \"{output_id}\": expected Arrow type {expected:?}, got {actual:?}");
match mode {
RuntimeTypeCheck::Error => {
return Err(NodeError::Output(msg));
}
RuntimeTypeCheck::Warn => {
warn!("type mismatch: {msg}");
}
RuntimeTypeCheck::Off => unreachable!(),
}
}
Ok(())
}
pub fn send_output_bytes(
&mut self,
output_id: DataId,
parameters: MetadataParameters,
data_len: usize,
data: &[u8],
) -> NodeResult<()> {
if !self.validate_output(&output_id) {
return Ok(());
};
if data.len() != data_len {
return Err(NodeError::Output(format!(
"send_output_bytes: data_len ({data_len}) does not match data.len() ({})",
data.len()
)));
}
self.send_output_raw(output_id, parameters, data_len, |sample| {
sample.copy_from_slice(data)
})
}
pub fn send_output_sample(
&mut self,
output_id: DataId,
mut parameters: MetadataParameters,
sample: Option<DataSample>,
) -> NodeResult<()> {
parameters.remove(SCHEMA_HASH);
#[cfg(feature = "tracing")]
if !parameters.contains_key(crate::OPEN_TELEMETRY_CONTEXT) {
let cx = opentelemetry::Context::current();
let serialized = dora_tracing::telemetry::serialize_context(&cx);
if !serialized.is_empty() {
parameters.insert(
crate::OPEN_TELEMETRY_CONTEXT.to_string(),
crate::Parameter::String(serialized),
);
}
}
let metadata = Metadata::from_parameters(self.clock.new_timestamp(), parameters);
let finalized = sample.map(|sample| sample.finalize());
let diag_bytes = finalized.as_ref().map_or(0, |f| f.byte_len());
let diag = diag_bytes >= self.sample_allocator.zero_copy_threshold
&& self.large_send_diag_count < LARGE_SEND_DIAG_LIMIT;
if diag {
self.large_send_diag_count += 1;
}
enum Delivery {
Zenoh,
Daemon(Option<DataMessage>),
}
let delivery = match finalized {
Some(finalized) if self.output_direct_ready(&output_id) => {
tracing::trace!(
output = %output_id,
size = finalized.byte_len(),
"publishing via zenoh"
);
if diag {
warn!(
"output `{output_id}`: {diag_bytes} B -> zenoh direct path \
(dora-rs/dora#2742 diagnostic)"
);
}
match self.zenoh_publish(&output_id, &metadata, finalized, diag) {
Ok(PublishOutcome::Published) => Delivery::Zenoh,
Ok(PublishOutcome::NotPublished(sample)) => {
Delivery::Daemon(Some(sample.into_data_message()))
}
Err(e) => {
tracing::warn!(
"zenoh publish failed ({e}); message dropped \
(SHM payload consumed, no daemon fallback)"
);
Delivery::Zenoh
}
}
}
Some(finalized) => {
if diag {
warn!(
"output `{output_id}`: {diag_bytes} B -> daemon path \
(dora-rs/dora#2742 diagnostic)"
);
}
Delivery::Daemon(Some(finalized.into_data_message()))
}
None => Delivery::Daemon(None),
};
match delivery {
Delivery::Zenoh => {
if diag {
warn!(
"output `{output_id}`: entering report_output_sent \
(dora-rs/dora#2742 diagnostic)"
);
}
self.control_channel
.report_output_sent(output_id.clone(), metadata)
.wrap_err_with(|| format!("failed to report output {output_id}"))?;
}
Delivery::Daemon(data) => {
if let Some(DataMessage::Vec(v)) = &data
&& v.len() > dora_message::MAX_MESSAGE_BYTES
{
return Err(NodeError::Output(format!(
"output \"{output_id}\": IPC-encoded message is {} bytes, exceeding \
the {}-byte daemon transport limit (the output is on the daemon \
path: pinned for a consumer only forwarding can reach, its \
startup handshake did not complete, or no zenoh route is \
available)",
v.len(),
dora_message::MAX_MESSAGE_BYTES,
)));
}
if diag {
warn!(
"output `{output_id}`: entering control_channel.send_message \
(dora-rs/dora#2742 diagnostic)"
);
}
self.control_channel
.send_message(output_id.clone(), metadata, data)
.wrap_err_with(|| format!("failed to send output {output_id}"))?;
}
}
Ok(())
}
pub fn close_outputs(&mut self, outputs_ids: Vec<DataId>) -> NodeResult<()> {
for output_id in &outputs_ids {
if !self.node_config.outputs.contains(output_id) {
return Err(NodeError::Output(format!("unknown output {output_id}")));
}
}
for output_id in &outputs_ids {
self.node_config.outputs.remove(output_id);
}
self.control_channel
.report_closed_outputs(outputs_ids)
.wrap_err("failed to report closed outputs to daemon")?;
Ok(())
}
fn output_direct_ready(&self, output_id: &DataId) -> bool {
self.zenoh_publishers
.get(output_id)
.is_some_and(|output| output.ready.load(Ordering::Relaxed))
}
fn zenoh_publish(
&mut self,
output_id: &DataId,
metadata: &Metadata,
finalized: FinalizedSample,
diag: bool,
) -> eyre::Result<PublishOutcome> {
use zenoh::Wait;
let Some(DirectOutput { publisher, .. }) = self.zenoh_publishers.get(output_id) else {
return Ok(PublishOutcome::NotPublished(finalized));
};
let session = self
.zenoh_session
.as_ref()
.expect("a declared publisher implies a zenoh session");
let metadata_bytes = match dora_message::encode(metadata) {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(output = %output_id, "failed to serialize metadata ({e}); falling back to daemon path");
return Ok(PublishOutcome::NotPublished(finalized));
}
};
match finalized {
FinalizedSample::Shm(sbuf) => {
if diag {
tracing::warn!(
"output `{output_id}`: entering zenoh put of an SHM buffer \
(dora-rs/dora#2742 diagnostic)"
);
}
publisher
.put(sbuf)
.attachment(&metadata_bytes[..])
.wait()
.map_err(|e| eyre::eyre!("zenoh SHM publish failed: {e}"))?;
Ok(PublishOutcome::Published)
}
FinalizedSample::Vec(avec) => {
if avec.len() >= self.sample_allocator.zero_copy_threshold
&& let Some(provider) = &self.sample_allocator.shm_provider
{
use zenoh::shm::GarbageCollect;
if diag {
tracing::warn!(
"output `{output_id}`: entering SHM alloc of {} B \
(dora-rs/dora#2742 diagnostic)",
avec.len()
);
}
match provider
.alloc(avec.len())
.with_policy::<GarbageCollect>()
.wait()
{
Ok(mut sbuf) => {
if sbuf.as_mut().len() == avec.len() {
sbuf.as_mut().copy_from_slice(&avec);
if diag {
tracing::warn!(
"output `{output_id}`: entering zenoh put of a \
copied SHM buffer (dora-rs/dora#2742 diagnostic)"
);
}
return match publisher
.put(sbuf)
.attachment(&metadata_bytes[..])
.wait()
{
Ok(()) => Ok(PublishOutcome::Published),
Err(e) => {
tracing::warn!(
"zenoh SHM publish failed ({e}); \
falling back to daemon path"
);
Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
}
};
}
tracing::debug!(
"zenoh SHM alloc returned {} bytes for a {}-byte \
request; using daemon path",
sbuf.as_ref().len(),
avec.len()
);
}
Err(e) => {
tracing::debug!("SHM alloc failed ({e}), using heap buffer");
}
}
}
if avec.len() >= self.sample_allocator.zero_copy_threshold {
return Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)));
}
let schema_once = if schema_once_eligible(
avec.len(),
self.sample_allocator.zero_copy_threshold,
&metadata.parameters,
) {
publish_schema_once(
&mut self.zenoh_schema_publishers,
&mut self.zenoh_schema_state,
session,
self.dataflow_id,
&self.id,
output_id,
&avec,
metadata,
)
} else {
None
};
let (payload, attachment): (&[u8], &[u8]) = match schema_once.as_ref() {
Some(att) => match arrow_utils::ipc_encode::batch_slice(&avec) {
Some(slice) => (slice, att.as_slice()),
None => (&avec[..], &metadata_bytes[..]),
},
None => (&avec[..], &metadata_bytes[..]),
};
match publisher.put(payload).attachment(attachment).wait() {
Ok(()) => Ok(PublishOutcome::Published),
Err(e) => {
tracing::warn!("zenoh publish failed ({e}); falling back to daemon path");
self.zenoh_schema_state.remove(output_id);
Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
}
}
}
}
}
pub fn id(&self) -> &NodeId {
&self.id
}
pub fn dataflow_id(&self) -> &DataflowId {
&self.dataflow_id
}
pub fn node_config(&self) -> &NodeRunConfig {
&self.node_config
}
pub fn zero_copy_threshold(&self) -> usize {
self.sample_allocator.zero_copy_threshold
}
pub fn is_restart(&self) -> bool {
self.restart_count > 0
}
pub fn restart_count(&self) -> u32 {
self.restart_count
}
pub fn timestamp(&self) -> uhlc::Timestamp {
self.clock.new_timestamp()
}
pub fn log(&self, level: &str, message: &str, target: Option<&str>) {
self.log_with_fields(level, message, target, None);
}
const MAX_LOG_FIELDS_BYTES: usize = 60 * 1024;
pub fn log_with_fields(
&self,
level: &str,
message: &str,
target: Option<&str>,
fields: Option<&std::collections::BTreeMap<String, String>>,
) {
let level_str = match level.to_lowercase().as_str() {
"error" => "error",
"warn" | "warning" => "warn",
"info" => "info",
"debug" => "debug",
"trace" => "trace",
_ => "info",
};
let timestamp = chrono::Utc::now().to_rfc3339();
let mut entry = serde_json::json!({
"timestamp": timestamp,
"level": level_str,
"node_id": self.id.to_string(),
"message": message,
});
if let Some(target) = target {
entry["target"] = serde_json::Value::String(target.to_string());
}
if let Some(fields) = fields {
match log_fields_within_budget(fields, Self::MAX_LOG_FIELDS_BYTES) {
Some(value) => entry["fields"] = value,
None => {
eprintln!("dora log: fields too large, dropping fields");
entry["fields_dropped"] = serde_json::Value::Bool(true);
}
}
}
match serde_json::to_string(&entry) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("dora log serialization error: {e}"),
}
}
pub fn log_error(&self, message: &str) {
self.log("error", message, None);
}
pub fn log_warn(&self, message: &str) {
self.log("warn", message, None);
}
pub fn log_info(&self, message: &str) {
self.log("info", message, None);
}
pub fn log_debug(&self, message: &str) {
self.log("debug", message, None);
}
pub fn log_trace(&self, message: &str) {
self.log("trace", message, None);
}
pub fn new_request_id() -> String {
thread_local! {
static CTX: uuid::ContextV7 = const { uuid::ContextV7::new() };
}
CTX.with(|ctx| uuid::Uuid::new_v7(uuid::Timestamp::now(ctx)).to_string())
}
pub fn new_goal_id() -> String {
Self::new_request_id()
}
pub fn send_service_request(
&mut self,
output_id: DataId,
mut parameters: MetadataParameters,
data: impl IntoArrow,
) -> NodeResult<String> {
if parameters.contains_key(dora_message::metadata::REQUEST_ID) {
tracing::warn!("send_service_request: caller-provided request_id will be overwritten");
}
let request_id = Self::new_request_id();
parameters.insert(
dora_message::metadata::REQUEST_ID.to_string(),
dora_message::metadata::Parameter::String(request_id.clone()),
);
self.send_output(output_id, parameters, data)?;
Ok(request_id)
}
pub fn send_service_response(
&mut self,
output_id: DataId,
parameters: MetadataParameters,
data: impl IntoArrow,
) -> NodeResult<()> {
self.send_output(output_id, parameters, data)
}
pub fn send_stream_chunk(
&mut self,
output_id: DataId,
segment: &mut StreamSegment,
fin: bool,
data: impl IntoArrow,
) -> NodeResult<()> {
self.send_output(output_id, segment.chunk(fin), data)
}
pub fn allocate_data_sample(&mut self, data_len: usize) -> NodeResult<DataSample> {
self.sample_allocator.allocate(data_len)
}
pub fn sample_allocator(&self) -> SampleAllocator {
self.sample_allocator.clone()
}
pub fn dataflow_descriptor(&self) -> NodeResult<&Descriptor> {
match &self.dataflow_descriptor {
Ok(d) => Ok(d),
Err(err) => Err(NodeError::Data(format!(
"failed to parse dataflow descriptor: {err}\n\n\
This might be caused by mismatched version numbers of dora \
daemon and the dora node API"
))),
}
}
pub fn extension_store(
&mut self,
namespace: impl Into<String>,
key: impl Into<String>,
value: Vec<u8>,
) -> Result<(), eyre::Error> {
self.control_channel
.extension_store(namespace.into(), key.into(), value)
}
pub fn extension_load(
&mut self,
namespace: impl Into<String>,
key: impl Into<String>,
remove: bool,
) -> Result<Option<Vec<u8>>, eyre::Error> {
self.control_channel
.extension_load(namespace.into(), key.into(), remove)
}
pub fn extension_drop(
&mut self,
namespace: impl Into<String>,
key: impl Into<String>,
) -> Result<(), eyre::Error> {
self.control_channel
.extension_drop(namespace.into(), key.into())
}
pub fn extension_request(
&mut self,
namespace: impl Into<String>,
payload: Vec<u8>,
) -> Result<Vec<u8>, eyre::Error> {
self.control_channel
.extension_request(namespace.into(), payload)
}
}
fn log_fields_within_budget(
fields: &std::collections::BTreeMap<String, String>,
limit: usize,
) -> Option<serde_json::Value> {
struct ByteCounter(usize);
impl std::io::Write for ByteCounter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut counter = ByteCounter(0);
serde_json::to_writer(&mut counter, fields).ok()?;
(counter.0 <= limit).then(|| serde_json::json!(fields))
}
#[derive(Default)]
pub struct DoraNodeBuilder {
node_id: Option<NodeId>,
daemon_port: Option<u16>,
}
impl DoraNodeBuilder {
pub fn node_id(mut self, node_id: NodeId) -> Self {
self.node_id = Some(node_id);
self
}
#[inline]
pub fn dynamic(self) -> Self {
self
}
pub fn daemon_port(mut self, port: u16) -> Self {
self.daemon_port = Some(port);
self
}
pub fn build(self) -> NodeResult<(DoraNode, EventStream)> {
let Some(node_id) = self.node_id else {
return DoraNode::init_from_env();
};
let port = self.daemon_port.unwrap_or_else(|| {
match std::env::var(DORA_DAEMON_LOCAL_LISTEN_PORT_ENV) {
Ok(p) => p.parse().unwrap_or_else(|e| {
tracing::warn!(
"invalid {DORA_DAEMON_LOCAL_LISTEN_PORT_ENV}={p:?}: {e}, using default port"
);
DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT
}),
Err(_) => DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT,
}
});
let daemon_address = (LOCALHOST, port).into();
let mut channel =
DaemonChannel::new_tcp(daemon_address).context("Could not connect to the daemon")?;
let clock = Arc::new(uhlc::HLC::default());
let reply = channel
.request(&Timestamped {
inner: DaemonRequest::NodeConfig { node_id },
timestamp: clock.new_timestamp(),
})
.wrap_err("failed to request node config from daemon")?;
match reply {
DaemonReply::NodeConfig {
result: Ok(node_config),
} => DoraNode::init(node_config),
DaemonReply::NodeConfig { result: Err(error) } => {
let capped: String = error.chars().take(512).collect();
Err(NodeError::Init(format!(
"failed to get node config from daemon: {capped}"
)))
}
_ => Err(NodeError::Init("unexpected reply from daemon".into())),
}
}
}
pub(crate) fn teardown_with_timeout(
label: &str,
timeout: Duration,
teardown: impl FnOnce() + Send + 'static,
) -> bool {
let (work_tx, work_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let thread = std::thread::Builder::new()
.name(format!("dora-teardown-{label}"))
.spawn(move || {
if let Ok(work) = work_rx.recv() {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
}
let _ = done_tx.send(());
});
match thread {
Ok(_) => {
let _ = work_tx.send(teardown);
done_rx.recv_timeout(timeout).is_ok()
}
Err(err) => {
warn!("failed to spawn {label} teardown thread ({err}); running it inline");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(teardown));
true
}
}
}
impl Drop for DoraNode {
fn drop(&mut self) {
let startup_handshake = self.startup_handshake.take();
let publishers = std::mem::take(&mut self.zenoh_publishers);
let schema_publishers = std::mem::take(&mut self.zenoh_schema_publishers);
let shm_provider = self.sample_allocator.shm_provider.take();
let session = self.zenoh_session.take();
let runtime = self._owned_runtime.take();
if session.is_none() && shm_provider.is_none() && publishers.is_empty() {
drop(startup_handshake);
drop(runtime);
} else {
let completed = teardown_with_timeout("zenoh", ZENOH_TEARDOWN_TIMEOUT, move || {
if let Some(mut handshake) = startup_handshake {
handshake.shutdown();
drop(std::mem::take(&mut handshake.ack_subscribers));
}
drop(publishers);
drop(schema_publishers);
drop(shm_provider);
drop(session);
drop(runtime);
});
if !completed {
warn!(
"zenoh teardown timed out after {}s; continuing node shutdown",
ZENOH_TEARDOWN_TIMEOUT.as_secs()
);
}
}
if let Some(shutdown) = &self.testing_shutdown {
shutdown.store(true, Ordering::Relaxed);
}
if let Err(err) = self
.control_channel
.report_closed_outputs(
std::mem::take(&mut self.node_config.outputs)
.into_iter()
.collect(),
)
.context("failed to close outputs on drop")
{
tracing::warn!("{err:?}")
}
if let Err(err) = self.control_channel.report_outputs_done() {
tracing::warn!("{err:?}")
}
if let Some(handle) = self.testing_daemon.take() {
self.control_channel.close_channel();
if handle.join().is_err() {
tracing::warn!("testing daemon thread panicked");
}
}
self.testing_shutdown = None;
}
}
#[derive(Debug)]
pub struct EncodedSample {
sample: DataSample,
data_type: arrow_schema::DataType,
}
impl EncodedSample {
pub fn type_name(&self) -> String {
format!("{:?}", self.data_type)
}
#[cfg(feature = "arrow-v59")]
pub fn data_type(&self) -> &arrow_schema::DataType {
&self.data_type
}
pub fn as_bytes(&self) -> &[u8] {
&self.sample
}
}
#[derive(Clone)]
pub struct SampleAllocator {
shm_provider: Option<Arc<zenoh::shm::ShmProvider<zenoh::shm::PosixShmProviderBackend>>>,
zero_copy_threshold: usize,
}
impl SampleAllocator {
pub fn allocate(&self, data_len: usize) -> NodeResult<DataSample> {
if data_len >= self.zero_copy_threshold
&& let Some(provider) = &self.shm_provider
{
use zenoh::Wait;
use zenoh::shm::GarbageCollect;
match provider
.alloc(data_len)
.with_policy::<GarbageCollect>()
.wait()
{
Ok(sbuf) => {
if sbuf.as_ref().len() == data_len {
return Ok(DataSample {
storage: SampleStorage::Shm(sbuf),
});
}
tracing::debug!(
"zenoh SHM alloc returned {} bytes for a {data_len}-byte \
request; using heap",
sbuf.as_ref().len()
);
}
Err(e) => {
tracing::debug!("SHM alloc failed ({e}), using heap buffer");
}
}
}
let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, data_len);
Ok(avec.into())
}
pub fn encode_arrow(&self, array: &DoraArray) -> NodeResult<EncodedSample> {
self.encode_arrow_data(&dora_arrow_convert::internal::array_ref(array).to_data())
}
pub(crate) fn encode_arrow_data(&self, array: &ArrayData) -> NodeResult<EncodedSample> {
let sample = match ipc_encode::PreparedIpc::from_data(array) {
Some(prepared) => {
let mut sample = self.allocate(prepared.byte_len())?;
prepared
.encode_into(&mut sample)
.map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
sample
}
None => {
let bytes = ipc_encode::encode_ipc_to_vec_data(array)
.map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
let mut sample = self.allocate(bytes.len())?;
sample.copy_from_slice(&bytes);
sample
}
};
Ok(EncodedSample {
sample,
data_type: array.data_type().clone(),
})
}
pub fn heap() -> Self {
Self {
shm_provider: None,
zero_copy_threshold: ZERO_COPY_THRESHOLD,
}
}
}
impl std::fmt::Debug for SampleAllocator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SampleAllocator")
.field("shm", &self.shm_provider.is_some())
.field("zero_copy_threshold", &self.zero_copy_threshold)
.finish()
}
}
pub struct DataSample {
storage: SampleStorage,
}
enum SampleStorage {
Heap(AVec<u8, ConstAlign<128>>),
Shm(zenoh::shm::ZShmMut),
}
impl DataSample {
fn finalize(self) -> FinalizedSample {
match self.storage {
SampleStorage::Heap(buffer) => FinalizedSample::Vec(buffer),
SampleStorage::Shm(sbuf) => FinalizedSample::Shm(sbuf),
}
}
}
impl std::ops::Deref for DataSample {
type Target = [u8];
fn deref(&self) -> &Self::Target {
match &self.storage {
SampleStorage::Heap(buffer) => buffer,
SampleStorage::Shm(sbuf) => sbuf.as_ref(),
}
}
}
impl std::ops::DerefMut for DataSample {
fn deref_mut(&mut self) -> &mut Self::Target {
match &mut self.storage {
SampleStorage::Heap(buffer) => buffer,
SampleStorage::Shm(sbuf) => sbuf.as_mut(),
}
}
}
impl From<AVec<u8, ConstAlign<128>>> for DataSample {
fn from(value: AVec<u8, ConstAlign<128>>) -> Self {
Self {
storage: SampleStorage::Heap(value),
}
}
}
impl std::fmt::Debug for DataSample {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DataSample")
.field("len", &self.len())
.finish_non_exhaustive()
}
}
enum FinalizedSample {
Vec(AVec<u8, ConstAlign<128>>),
Shm(zenoh::shm::ZShmMut),
}
impl FinalizedSample {
fn byte_len(&self) -> usize {
match self {
FinalizedSample::Vec(v) => v.len(),
FinalizedSample::Shm(sbuf) => sbuf.as_ref().len(),
}
}
fn into_data_message(self) -> DataMessage {
match self {
FinalizedSample::Vec(v) => DataMessage::Vec(v),
FinalizedSample::Shm(sbuf) => {
let bytes = sbuf.as_ref();
let mut avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
avec.copy_from_slice(bytes);
DataMessage::Vec(avec)
}
}
}
}
enum PublishOutcome {
Published,
NotPublished(FinalizedSample),
}
pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
dora_message::metadata::fnv1a(bytes)
}
pub(crate) const SCHEMA_ONCE_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
struct SchemaOnceState {
published_hash: u64,
last_full_stream: Instant,
}
#[derive(Debug)]
enum SchemaOnceDecision {
PublishSchemaAndSendFullStream,
SendFullStreamRefresh,
SendSchemaLessBatch,
}
fn schema_once_decision(
state: Option<&SchemaOnceState>,
hash: u64,
now: Instant,
) -> SchemaOnceDecision {
match state {
Some(state) if state.published_hash == hash => {
if now.duration_since(state.last_full_stream) >= SCHEMA_ONCE_REFRESH_INTERVAL {
SchemaOnceDecision::SendFullStreamRefresh
} else {
SchemaOnceDecision::SendSchemaLessBatch
}
}
_ => SchemaOnceDecision::PublishSchemaAndSendFullStream,
}
}
#[allow(clippy::too_many_arguments)]
fn publish_schema_once(
schema_publishers: &mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
schema_state: &mut HashMap<DataId, SchemaOnceState>,
session: &zenoh::Session,
dataflow_id: DataflowId,
node_id: &NodeId,
output_id: &DataId,
full_stream: &[u8],
base_metadata: &Metadata,
) -> Option<Vec<u8>> {
let (hash, schema_bytes) = arrow_utils::ipc_encode::schema_block_and_hash(full_stream)?;
let now = Instant::now();
let decision = schema_once_decision(schema_state.get(output_id), hash, now);
tracing::debug!(output = %output_id, decision = ?decision, "schema-once decision");
match decision {
SchemaOnceDecision::PublishSchemaAndSendFullStream => {
if let Some(publisher) =
schema_publisher(schema_publishers, session, dataflow_id, node_id, output_id)
{
use zenoh::Wait;
match publisher.put(schema_bytes).wait() {
Ok(()) => {
tracing::debug!(output = %output_id, hash, "schema published on @schema subtopic");
schema_state.insert(
output_id.clone(),
SchemaOnceState {
published_hash: hash,
last_full_stream: now,
},
);
}
Err(e) => {
tracing::warn!(output = %output_id, "failed to publish schema on @schema subtopic ({e})");
}
}
}
None
}
SchemaOnceDecision::SendFullStreamRefresh => {
tracing::debug!(output = %output_id, hash, "sending full-stream refresh");
if let Some(state) = schema_state.get_mut(output_id) {
state.last_full_stream = now;
}
None
}
SchemaOnceDecision::SendSchemaLessBatch => {
tracing::debug!(output = %output_id, hash, "sending schema-less batch with SCHEMA_HASH");
let mut metadata = base_metadata.clone();
metadata
.parameters
.insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
dora_message::encode(&metadata).ok()
}
}
}
fn schema_publisher<'a>(
schema_publishers: &'a mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
session: &zenoh::Session,
dataflow_id: DataflowId,
node_id: &NodeId,
output_id: &DataId,
) -> Option<&'a zenoh_ext::AdvancedPublisher<'static>> {
if !schema_publishers.contains_key(output_id) {
use zenoh::Wait;
use zenoh::qos::CongestionControl;
use zenoh_ext::{AdvancedPublisherBuilderExt, CacheConfig, MissDetectionConfig};
let topic = dora_core::topics::zenoh_output_schema_topic(dataflow_id, node_id, output_id);
let key = zenoh::key_expr::KeyExpr::new(topic).ok()?.into_owned();
let publisher = match session
.declare_publisher(key)
.congestion_control(CongestionControl::Block)
.sample_miss_detection(MissDetectionConfig::default())
.cache(CacheConfig::default())
.publisher_detection()
.wait()
{
Ok(p) => p,
Err(e) => {
tracing::warn!(output = %output_id, "failed to declare schema publisher ({e})");
return None;
}
};
schema_publishers.insert(output_id.clone(), publisher);
}
schema_publishers.get(output_id)
}
pub(crate) use dora_message::metadata::carries_pattern_correlation;
fn schema_once_eligible(
payload_len: usize,
zero_copy_threshold: usize,
params: &MetadataParameters,
) -> bool {
payload_len < zero_copy_threshold && !carries_pattern_correlation(params)
}
#[cfg(feature = "tracing")]
pub fn init_tracing(
node_id: &NodeId,
dataflow_id: &DataflowId,
) -> NodeResult<Arc<Mutex<Option<OtelGuard>>>> {
let node_id_str = node_id.to_string();
let guard: Arc<Mutex<Option<OtelGuard>>> = Arc::new(Mutex::new(None));
let clone = guard.clone();
let tracing_monitor = async move {
let mut builder = TracingBuilder::new(node_id_str.clone());
if std::env::var("DORA_OTLP_ENDPOINT").is_ok()
|| std::env::var("DORA_JAEGER_TRACING").is_ok()
{
match builder.with_otlp_tracing() {
Ok(b) => {
builder = b.with_stdout("info", true);
if let Ok(mut guard) = clone.lock() {
*guard = builder.guard.take();
}
}
Err(e) => {
eprintln!("warning: failed to set up OTLP tracing: {e:?}");
builder = TracingBuilder::new(node_id_str).with_stdout("info", true);
}
}
} else {
builder = builder.with_stdout("info", true);
}
if let Err(e) = builder.build() {
eprintln!("warning: failed to set up tracing subscriber: {e:?}");
}
};
let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
rt.spawn(tracing_monitor);
let _ = &dataflow_id;
#[cfg(feature = "metrics")]
if let Ok(endpoint) = std::env::var("DORA_OTLP_ENDPOINT") {
let id = format!("{dataflow_id}/{node_id}");
let monitor_task = async move {
use dora_metrics::run_metrics_monitor;
if let Err(e) = run_metrics_monitor(id.clone(), &endpoint)
.await
.wrap_err("metrics monitor exited unexpectedly")
{
warn!("metrics monitor failed: {:#?}", e);
}
};
let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
rt.spawn(monitor_task);
}
Ok(guard)
}
pub struct StreamSegment {
session_id: String,
segment_id: i64,
seq: i64,
}
impl StreamSegment {
pub fn new() -> Self {
Self {
session_id: DoraNode::new_request_id(),
segment_id: 0,
seq: 0,
}
}
pub fn with_session_id(session_id: String) -> Self {
Self {
session_id,
segment_id: 0,
seq: 0,
}
}
pub fn next_segment(&mut self) -> i64 {
self.segment_id += 1;
self.seq = 0;
self.segment_id
}
pub fn chunk(&mut self, fin: bool) -> MetadataParameters {
let mut params = MetadataParameters::new();
params.insert(
SESSION_ID.into(),
Parameter::String(self.session_id.clone()),
);
params.insert(SEGMENT_ID.into(), Parameter::Integer(self.segment_id));
params.insert(SEQ.into(), Parameter::Integer(self.seq));
params.insert(FIN.into(), Parameter::Bool(fin));
self.seq += 1;
params
}
pub fn flush(&mut self) -> MetadataParameters {
self.next_segment();
let mut params = self.chunk(false);
params.insert(FLUSH.into(), Parameter::Bool(true));
params
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn segment_id(&self) -> i64 {
self.segment_id
}
pub fn seq(&self) -> i64 {
self.seq
}
}
impl Default for StreamSegment {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::integration_testing::{
IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
integration_testing_format::{IncomingEvent, TimedIncomingEvent},
};
fn required_acker(node: &str, input: &str) -> dora_message::daemon_to_node::RequiredAcker {
dora_message::daemon_to_node::RequiredAcker {
node_id: NodeId::from(node.to_string()),
input_id: DataId::from(input.to_string()),
}
}
fn test_ack_state(output: &str, acker: (&str, &str)) -> Arc<AckState> {
Arc::new(AckState::new(
DataId::from(output.to_string()),
&BTreeSet::from([required_acker(acker.0, acker.1)]),
Arc::new(AtomicBool::new(false)),
))
}
#[test]
fn log_fields_budget_measures_serialized_json_not_raw_bytes() {
use std::collections::BTreeMap;
let limit = DoraNode::MAX_LOG_FIELDS_BYTES;
let mut small = BTreeMap::new();
small.insert("k".to_string(), "v".to_string());
assert!(log_fields_within_budget(&small, limit).is_some());
let mut big = BTreeMap::new();
big.insert("k".to_string(), "\u{1}".repeat(20 * 1024));
assert!(big.values().map(String::len).sum::<usize>() < limit);
assert!(log_fields_within_budget(&big, limit).is_none());
}
#[test]
fn ack_state_completes_only_when_required_set_is_covered() {
let ready = Arc::new(AtomicBool::new(false));
let required = BTreeSet::from([
required_acker("sink-a", "camera"),
required_acker("sink-b", "cam"),
]);
let state = AckState::new(DataId::from("image".to_string()), &required, ready.clone());
state.record("stranger", "camera");
assert!(!ready.load(Ordering::Relaxed));
state.record("sink-a", "camera");
state.record("sink-a", "camera");
assert!(!ready.load(Ordering::Relaxed));
assert_eq!(state.missing(), vec!["sink-b/cam".to_string()]);
state.record("sink-b", "cam");
assert!(ready.load(Ordering::Relaxed));
assert!(state.missing().is_empty());
}
#[test]
fn ack_state_requires_exact_identity_match() {
let ready = Arc::new(AtomicBool::new(false));
let required = BTreeSet::from([required_acker("sink", "camera")]);
let state = AckState::new(DataId::from("image".to_string()), &required, ready.clone());
state.record("sink", "other-input");
state.record("other-node", "camera");
assert!(!ready.load(Ordering::Relaxed));
state.record("sink", "camera");
assert!(ready.load(Ordering::Relaxed));
}
#[test]
fn ack_state_freeze_blocks_a_late_upgrade() {
let state = test_ack_state("image", ("sink", "camera"));
assert!(state.freeze(), "an un-acked output is frozen");
assert!(state.is_frozen());
state.record("sink", "camera");
assert!(
!state.ready.load(Ordering::Relaxed),
"a frozen output must stay on the daemon path for the rest of the run"
);
assert_eq!(state.missing(), vec!["sink/camera".to_string()]);
}
#[test]
fn ack_state_freeze_spares_an_output_that_acked_in_time() {
let state = test_ack_state("image", ("sink", "camera"));
state.record("sink", "camera");
assert!(!state.freeze(), "a ready output is not frozen");
assert!(!state.is_frozen());
assert!(
state.ready.load(Ordering::Relaxed),
"an output that proved its routes keeps the direct zenoh path"
);
}
#[test]
fn grace_boundary_freezes_unacked_outputs_only() {
let acked = test_ack_state("image", ("sink", "camera"));
let unacked = test_ack_state("status", ("sink", "state"));
acked.record("sink", "camera");
wait_for_grace(&[acked.clone(), unacked.clone()], Duration::from_millis(20));
assert!(acked.ready.load(Ordering::Relaxed));
assert!(!acked.is_frozen());
assert!(unacked.is_frozen());
unacked.record("sink", "state");
assert!(!unacked.ready.load(Ordering::Relaxed));
}
#[test]
fn grace_returns_early_once_every_output_is_acked() {
let state = test_ack_state("image", ("sink", "camera"));
state.record("sink", "camera");
let start = Instant::now();
wait_for_grace(std::slice::from_ref(&state), Duration::from_secs(30));
wait_for_grace(&[], Duration::from_secs(30));
assert!(!state.is_frozen());
assert!(state.ready.load(Ordering::Relaxed));
assert!(
start.elapsed() < Duration::from_secs(5),
"a completed handshake must not wait out the grace, took {:?}",
start.elapsed()
);
}
#[test]
fn missing_output_routing_pins_every_output_to_the_daemon_path() {
let outputs = BTreeSet::from([
DataId::from("image".to_string()),
DataId::from("status".to_string()),
]);
let routing = normalize_output_routing(None, &outputs);
assert_eq!(routing.len(), 2);
for output_id in &outputs {
let entry = routing.get(output_id).expect("entry per output");
assert!(entry.daemon_only);
assert!(entry.required_ackers.is_empty());
}
assert!(normalize_output_routing(None, &BTreeSet::new()).is_empty());
}
#[test]
fn provided_output_routing_is_passed_through() {
let outputs = BTreeSet::from([DataId::from("image".to_string())]);
let provided = BTreeMap::from([(
DataId::from("image".to_string()),
OutputRouting {
daemon_only: false,
required_ackers: BTreeSet::from([required_acker("sink", "camera")]),
},
)]);
let routing = normalize_output_routing(Some(provided.clone()), &outputs);
assert_eq!(routing, provided);
}
#[test]
fn new_request_id_returns_valid_uuid() {
let id = DoraNode::new_request_id();
uuid::Uuid::parse_str(&id).expect("should be valid UUID");
}
#[test]
fn new_request_id_is_unique() {
let ids: Vec<String> = (0..100).map(|_| DoraNode::new_request_id()).collect();
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(ids.len(), unique.len(), "all IDs should be unique");
}
#[test]
fn new_goal_id_returns_valid_uuid() {
let id = DoraNode::new_goal_id();
uuid::Uuid::parse_str(&id).expect("should be valid UUID");
}
#[test]
fn timestamp_uses_node_clock_and_is_monotonic() {
let (node, events, _rx) = test_node();
let t1 = node.timestamp();
let t2 = node.timestamp();
assert_eq!(
t1.get_id(),
t2.get_id(),
"two timestamp() calls must come from the same HLC instance",
);
assert!(
t2 > t1,
"HLC timestamps must be strictly monotonic: {t1:?} >= {t2:?}"
);
drop(node);
drop(events);
}
use crate::integration_testing::{OutputReceiver, drain_outputs};
fn test_node() -> (DoraNode, crate::EventStream, OutputReceiver) {
let events = vec![TimedIncomingEvent {
time_offset_secs: 0.1,
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, event_stream) = DoraNode::init_testing(inputs, outputs, options).unwrap();
(node, event_stream, rx)
}
const INIT_TESTING_DROP_BUDGET: Duration = Duration::from_millis(500);
fn init_testing_node_mid_scheduled_wait() -> (DoraNode, crate::EventStream) {
let events = vec![TimedIncomingEvent {
time_offset_secs: 30.0,
event: IncomingEvent::Stop,
}];
let inputs = TestingInput::Input(IntegrationTestInput::new(
"drop-hang-node".parse().unwrap(),
events,
));
let (tx, _rx) = crate::integration_testing::output_channel();
let outputs = TestingOutput::ToChannel(tx);
let (node, event_stream) =
DoraNode::init_testing(inputs, outputs, TestingOptions::default()).unwrap();
std::thread::sleep(Duration::from_millis(50));
(node, event_stream)
}
#[test]
fn init_testing_drop_events_then_node_during_scheduled_wait_does_not_hang() {
let (node, event_stream) = init_testing_node_mid_scheduled_wait();
let start = Instant::now();
drop(event_stream);
drop(node);
let elapsed = start.elapsed();
assert!(
elapsed < INIT_TESTING_DROP_BUDGET,
"events-then-node Drop hung for {elapsed:?}; expected interruptible testing-daemon shutdown"
);
}
#[test]
fn init_testing_drop_node_then_events_during_scheduled_wait_does_not_hang() {
let (node, event_stream) = init_testing_node_mid_scheduled_wait();
let start = Instant::now();
drop(node);
drop(event_stream);
let elapsed = start.elapsed();
assert!(
elapsed < INIT_TESTING_DROP_BUDGET,
"node-then-events Drop hung for {elapsed:?}; expected OutputsDone under shutdown to exit the testing daemon"
);
}
#[test]
fn send_service_request_returns_valid_id_and_sends_output() {
let (mut node, events, mut rx) = test_node();
let request_id = node
.send_service_request("request".into(), Default::default(), ())
.unwrap();
uuid::Uuid::parse_str(&request_id).expect("returned request_id should be valid UUID");
drop(node);
drop(events);
let outputs = drain_outputs(&mut rx);
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0]["id"], "request");
}
#[test]
fn send_service_request_returns_unique_ids() {
let (mut node, events, _rx) = test_node();
let id1 = node
.send_service_request("out".into(), Default::default(), ())
.unwrap();
let id2 = node
.send_service_request("out".into(), Default::default(), ())
.unwrap();
assert_ne!(id1, id2, "successive request IDs should differ");
drop(node);
drop(events);
}
#[test]
fn send_service_response_sends_output() {
let (mut node, events, mut rx) = test_node();
let mut params = MetadataParameters::default();
params.insert(
dora_message::metadata::REQUEST_ID.to_string(),
dora_message::metadata::Parameter::String("test-req-id".into()),
);
node.send_service_response("response".into(), params, ())
.unwrap();
drop(node);
drop(events);
let outputs = drain_outputs(&mut rx);
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0]["id"], "response");
}
#[test]
fn send_output_bytes_rejects_len_mismatch() {
let (mut node, events, _rx) = test_node();
let result = node.send_output_bytes("out".into(), Default::default(), 8, &[1, 2, 3, 4]);
let err = result.expect_err("mismatched data_len must error, not panic");
assert!(
err.to_string().contains("does not match"),
"unexpected error message: {err}"
);
drop(node);
drop(events);
}
#[test]
fn data_sample_heap_roundtrip() {
let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, 8);
let mut sample: DataSample = avec.into();
sample.copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(&sample[..], &[1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(sample.len(), 8);
match sample.finalize().into_data_message() {
DataMessage::Vec(v) => assert_eq!(v.as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]),
}
}
#[test]
fn send_output_ipc_roundtrip() {
use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
use crate::arrow_utils::ipc_encode::{encode_ipc_into_data, ipc_fast_path_len_data};
use arrow::array::{ArrayRef, Float32Array, StringArray, StructArray, UInt64Array};
use arrow_schema::{DataType, Field};
use std::ptr::NonNull;
fn roundtrip(data: ArrayData) {
let len = ipc_fast_path_len_data(&data).expect("array should be fast-path eligible");
let mut buf: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, len);
encode_ipc_into_data(&data, &mut buf).expect("fast-path IPC encode");
let ptr = NonNull::new(buf.as_ptr() as *mut u8).unwrap();
let blen = buf.len();
let buffer =
unsafe { arrow::buffer::Buffer::from_custom_allocation(ptr, blen, Arc::new(buf)) };
let decoded = decode_arrow_ipc_zero_copy_raw(buffer).expect("zero-copy IPC decode");
assert_eq!(
data, decoded,
"IPC send->receive round-trip must preserve the array"
);
}
roundtrip(Float32Array::from(vec![1.0, 2.5, -3.0, 4.0]).into_data());
roundtrip(UInt64Array::from(vec![Some(1), None, Some(3)]).into_data());
roundtrip(StringArray::from(vec![Some("hello"), None, Some("world")]).into_data());
roundtrip(
StructArray::from(vec![
(
Arc::new(Field::new("v", DataType::UInt64, true)),
Arc::new(UInt64Array::from(vec![Some(1), None, Some(3)])) as ArrayRef,
),
(
Arc::new(Field::new("s", DataType::Utf8, true)),
Arc::new(StringArray::from(vec![Some("a"), Some("bb"), None])) as ArrayRef,
),
])
.into_data(),
);
}
#[test]
fn close_outputs_is_atomic_on_unknown_id() {
let (mut node, events, _rx) = test_node();
let valid: DataId = "valid".into();
node.node_config.outputs.insert(valid.clone());
let result = node.close_outputs(vec![valid.clone(), "unknown".into()]);
assert!(
result.is_err(),
"closing a batch containing an unknown output must fail"
);
assert!(
node.node_config.outputs.contains(&valid),
"a failed close_outputs must not remove the valid output from local state"
);
drop(node);
drop(events);
}
#[test]
fn carries_pattern_correlation_detects_request_id() {
let mut params = MetadataParameters::default();
params.insert(
dora_message::metadata::REQUEST_ID.to_string(),
dora_message::metadata::Parameter::String("req-1".into()),
);
assert!(carries_pattern_correlation(¶ms));
}
#[test]
fn carries_pattern_correlation_detects_goal_id() {
let mut params = MetadataParameters::default();
params.insert(
dora_message::metadata::GOAL_ID.to_string(),
dora_message::metadata::Parameter::String("goal-1".into()),
);
assert!(carries_pattern_correlation(¶ms));
}
#[test]
fn carries_pattern_correlation_detects_goal_status() {
let mut params = MetadataParameters::default();
params.insert(
dora_message::metadata::GOAL_STATUS.to_string(),
dora_message::metadata::Parameter::String("succeeded".into()),
);
assert!(carries_pattern_correlation(¶ms));
}
#[test]
fn carries_pattern_correlation_empty_is_not_a_pattern() {
let params = MetadataParameters::default();
assert!(!carries_pattern_correlation(¶ms));
}
#[test]
fn carries_pattern_correlation_ignores_non_pattern_keys() {
let mut params = MetadataParameters::default();
params.insert(
"custom_key".to_string(),
dora_message::metadata::Parameter::String("value".into()),
);
assert!(!carries_pattern_correlation(¶ms));
}
#[test]
fn schema_once_excludes_pattern_correlation_outputs() {
const THRESHOLD: usize = 4096;
let plain = MetadataParameters::default();
assert!(
schema_once_eligible(100, THRESHOLD, &plain),
"small message on a stable-schema output is eligible"
);
assert!(
!schema_once_eligible(THRESHOLD, THRESHOLD, &plain),
"a message at/above the threshold is not eligible (goes via SHM/full stream)"
);
for key in [
dora_message::metadata::REQUEST_ID,
dora_message::metadata::GOAL_ID,
dora_message::metadata::GOAL_STATUS,
] {
let mut params = MetadataParameters::default();
params.insert(
key.to_string(),
dora_message::metadata::Parameter::String("x".into()),
);
assert!(
!schema_once_eligible(100, THRESHOLD, ¶ms),
"small pattern-correlation message ({key}) must bypass schema-once"
);
}
let mut stream = MetadataParameters::default();
stream.insert(
dora_message::metadata::SESSION_ID.to_string(),
dora_message::metadata::Parameter::String("s1".into()),
);
stream.insert(
dora_message::metadata::SEGMENT_ID.to_string(),
dora_message::metadata::Parameter::Integer(0),
);
assert!(
schema_once_eligible(100, THRESHOLD, &stream),
"small streaming chunk (stable schema) stays eligible for schema-once"
);
}
#[test]
fn schema_once_decision_covers_publish_refresh_and_schema_less() {
let start = Instant::now();
let later = start + SCHEMA_ONCE_REFRESH_INTERVAL;
let state = SchemaOnceState {
published_hash: 7,
last_full_stream: start,
};
assert!(matches!(
schema_once_decision(None, 7, start),
SchemaOnceDecision::PublishSchemaAndSendFullStream
));
assert!(matches!(
schema_once_decision(Some(&state), 8, start),
SchemaOnceDecision::PublishSchemaAndSendFullStream
));
assert!(matches!(
schema_once_decision(Some(&state), 7, start),
SchemaOnceDecision::SendSchemaLessBatch
));
assert!(matches!(
schema_once_decision(Some(&state), 7, later),
SchemaOnceDecision::SendFullStreamRefresh
));
}
#[test]
fn stream_segment_new_generates_valid_session_id() {
let seg = StreamSegment::new();
uuid::Uuid::parse_str(seg.session_id()).expect("session_id should be valid UUID");
assert_eq!(seg.segment_id(), 0);
}
#[test]
fn stream_segment_with_session_id() {
let seg = StreamSegment::with_session_id("my-session".into());
assert_eq!(seg.session_id(), "my-session");
assert_eq!(seg.segment_id(), 0);
assert_eq!(seg.seq(), 0);
}
#[test]
fn stream_segment_seq_accessor_tracks_next_seq() {
let mut seg = StreamSegment::with_session_id("s1".into());
assert_eq!(seg.seq(), 0);
seg.chunk(false);
assert_eq!(seg.seq(), 1);
seg.chunk(false);
assert_eq!(seg.seq(), 2);
seg.next_segment();
assert_eq!(seg.seq(), 0);
}
#[test]
fn stream_segment_chunk_auto_increments_seq() {
let mut seg = StreamSegment::with_session_id("s1".into());
let p0 = seg.chunk(false);
let p1 = seg.chunk(false);
let p2 = seg.chunk(true);
assert_eq!(p0.get(SEQ), Some(&Parameter::Integer(0)));
assert_eq!(p1.get(SEQ), Some(&Parameter::Integer(1)));
assert_eq!(p2.get(SEQ), Some(&Parameter::Integer(2)));
assert_eq!(p0.get(FIN), Some(&Parameter::Bool(false)));
assert_eq!(p2.get(FIN), Some(&Parameter::Bool(true)));
assert_eq!(p0.get(SESSION_ID), Some(&Parameter::String("s1".into())));
assert_eq!(p0.get(SEGMENT_ID), Some(&Parameter::Integer(0)));
}
#[test]
fn stream_segment_next_segment_resets_seq() {
let mut seg = StreamSegment::with_session_id("s1".into());
seg.chunk(false); seg.chunk(false); let new_id = seg.next_segment();
assert_eq!(new_id, 1);
assert_eq!(seg.segment_id(), 1);
let p = seg.chunk(false);
assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
}
#[test]
fn stream_segment_flush_advances_segment_and_sets_flush() {
let mut seg = StreamSegment::with_session_id("s1".into());
seg.chunk(false);
let p = seg.flush();
assert_eq!(seg.segment_id(), 1);
assert_eq!(p.get(FLUSH), Some(&Parameter::Bool(true)));
assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
}
#[test]
fn send_stream_chunk_sends_output() {
let (mut node, events, mut rx) = test_node();
let mut seg = StreamSegment::with_session_id("s1".into());
node.send_stream_chunk("audio".into(), &mut seg, false, ())
.unwrap();
drop(node);
drop(events);
let outputs = drain_outputs(&mut rx);
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0]["id"], "audio");
}
#[test]
fn teardown_with_timeout_completes_fast_closure() {
let start = Instant::now();
let completed = teardown_with_timeout("fast", Duration::from_secs(5), || {});
assert!(completed, "fast teardown should report completion");
assert!(
start.elapsed() < Duration::from_secs(5),
"fast teardown should not wait for the full timeout"
);
}
#[test]
fn teardown_with_timeout_gives_up_on_wedged_closure() {
let start = Instant::now();
let completed = teardown_with_timeout("wedged", Duration::from_millis(300), || {
std::thread::sleep(Duration::from_secs(60))
});
let elapsed = start.elapsed();
assert!(!completed, "wedged teardown should report a timeout");
assert!(
elapsed >= Duration::from_millis(300),
"should wait the full deadline, returned after {elapsed:?}"
);
assert!(
elapsed < Duration::from_secs(5),
"should give up shortly after the deadline, took {elapsed:?}"
);
}
#[test]
fn teardown_with_timeout_contains_panics() {
let completed = teardown_with_timeout("panicking", Duration::from_secs(5), || {
panic!("teardown panicked")
});
assert!(completed, "panicking teardown still counts as completed");
}
#[test]
fn zenoh_teardown_fits_within_daemon_force_kill_grace() {
const DAEMON_FORCE_KILL_GRACE: Duration = Duration::from_secs(15);
const TEARDOWN_PHASES: u32 = 3;
let worst_case = ZENOH_TEARDOWN_TIMEOUT * TEARDOWN_PHASES;
assert!(
worst_case < DAEMON_FORCE_KILL_GRACE,
"worst-case zenoh teardown ({worst_case:?}) must stay under the daemon \
force-kill grace ({DAEMON_FORCE_KILL_GRACE:?}); raising ZENOH_TEARDOWN_TIMEOUT \
reintroduces dora-rs/dora#2742"
);
}
}
#[cfg(test)]
mod operator_boundary_tests {
use super::*;
use arrow::buffer::Buffer;
use std::ptr::NonNull;
use std::sync::atomic::{AtomicBool, Ordering};
struct ForeignOwner {
released: Arc<AtomicBool>,
_backing: Vec<u8>,
}
impl Drop for ForeignOwner {
fn drop(&mut self) {
self.released.store(true, Ordering::SeqCst);
}
}
fn foreign_owned_array(len: usize) -> (ArrayData, Arc<AtomicBool>) {
let backing = vec![0xABu8; len];
let ptr = NonNull::new(backing.as_ptr() as *mut u8).expect("non-null");
let released = Arc::new(AtomicBool::new(false));
let owner = Arc::new(ForeignOwner {
released: released.clone(),
_backing: backing,
});
let buffer = unsafe { Buffer::from_custom_allocation(ptr, len, owner) };
let array = ArrayData::builder(arrow::datatypes::DataType::UInt8)
.len(len)
.add_buffer(buffer)
.build()
.expect("valid UInt8 array");
(array, released)
}
#[test]
fn encoded_sample_does_not_retain_the_source_payload() {
let allocator = SampleAllocator::heap();
let (array, released) = foreign_owned_array(8192);
let sample = allocator
.encode_arrow_data(&array)
.expect("encoding a UInt8 array must succeed");
assert!(
!released.load(Ordering::SeqCst),
"sanity: the source buffer is still alive while the array is"
);
drop(array);
assert!(
released.load(Ordering::SeqCst),
"the encoded sample must not keep the operator's payload alive; \
otherwise the runtime frees foreign memory (dora-rs/dora#2742)"
);
drop(sample);
}
#[test]
fn encoded_sample_round_trips_to_the_source_array() {
let allocator = SampleAllocator::heap();
let (array, _released) = foreign_owned_array(1024);
let encoded = allocator.encode_arrow_data(&array).expect("encode");
assert_eq!(encoded.type_name(), format!("{:?}", array.data_type()));
let decoded = crate::node::arrow_utils::decode_arrow_ipc_data(encoded.as_bytes())
.expect("the sample must be a well-formed Arrow IPC stream");
assert_eq!(decoded, array);
}
#[test]
fn allocator_and_sample_cross_thread_boundaries() {
const fn assert_send<T: Send>() {}
const fn assert_send_sync_clone<T: Send + Sync + Clone>() {}
assert_send::<DataSample>();
assert_send::<EncodedSample>();
assert_send_sync_clone::<SampleAllocator>();
}
}