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_core::{
config::{DataId, NodeId, NodeRunConfig},
descriptor::{Descriptor, DescriptorExt},
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},
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;
#[cfg(feature = "tracing")]
use std::sync::Mutex;
use std::{
collections::{BTreeSet, HashMap},
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
#[cfg(feature = "tracing")]
use tokio::runtime::Handle;
#[cfg(feature = "tracing")]
use dora_tracing::{OtelGuard, TracingBuilder};
use tracing::{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 {
match std::env::var("DORA_RUNTIME_TYPE_CHECK").as_deref() {
Ok("error") => Self::Error,
Ok("1" | "warn" | "true") => Self::Warn,
Ok("") | Err(_) => Self::Off,
Ok(other) => {
tracing::warn!(
"unknown DORA_RUNTIME_TYPE_CHECK value \"{other}\", \
expected \"warn\" or \"error\"; disabling runtime type check"
);
Self::Off
}
}
}
}
pub const ZERO_COPY_THRESHOLD: usize = 4096;
struct ZenohOutputReadiness {
expected: usize,
ready: bool,
alive: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
_liveliness_sub: zenoh::pubsub::Subscriber<()>,
}
const ZENOH_OUTPUT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const ZENOH_OUTPUT_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(5);
pub(crate) const ZENOH_TEARDOWN_TIMEOUT: Duration = Duration::from_secs(15);
pub struct DoraNode {
id: NodeId,
dataflow_id: DataflowId,
node_config: NodeRunConfig,
control_channel: ControlChannel,
clock: Arc<uhlc::HLC>,
zenoh_session: Option<zenoh::Session>,
zenoh_shm_provider: Option<zenoh::shm::ShmProvider<zenoh::shm::PosixShmProviderBackend>>,
zenoh_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>>,
zenoh_schema_publishers: HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
zenoh_schema_state: HashMap<DataId, SchemaOnceState>,
zenoh_zero_copy_threshold: usize,
zenoh_output_readiness: HashMap<DataId, ZenohOutputReadiness>,
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>,
}
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 {
inputs: Default::default(),
outputs: Default::default(),
output_types: Default::default(),
output_framing: Default::default(),
input_types: Default::default(),
shared_memory_pool_size: None,
},
daemon_communication: Some(DaemonCommunication::Interactive),
dataflow_descriptor: serde_yaml::Value::Null,
dynamic: false,
write_events_to: None,
restart_count: 0,
};
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 {
inputs: Default::default(),
outputs: Default::default(),
output_types: Default::default(),
output_framing: Default::default(),
input_types: Default::default(),
shared_memory_pool_size: None,
},
daemon_communication: None,
dataflow_descriptor: serde_yaml::Value::Null,
dynamic: false,
write_events_to: None,
restart_count: 0,
};
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)> {
let NodeConfig {
dataflow_id,
node_id,
run_config,
daemon_communication,
dataflow_descriptor,
dynamic,
write_events_to,
restart_count,
} = node_config;
let clock = Arc::new(uhlc::HLC::default());
let input_config = run_config.inputs.clone();
let daemon_communication = match daemon_communication {
Some(comm) => comm.into(),
None => match testing_communication {
Some(comm) => {
let TestingCommunication {
input,
output,
options,
} = comm;
let (sender, mut receiver) = tokio::sync::mpsc::channel(5);
let new_communication = DaemonCommunicationWrapper::Testing { channel: sender };
let mut events = IntegrationTestingEvents::new(input, output, options)?;
std::thread::spawn(move || {
while let Some((request, reply_sender)) = receiver.blocking_recv() {
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");
}
}
});
new_communication
}
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(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 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(),
dynamic,
)
.wrap_err("failed to init event stream")?;
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 mut node = Self {
id: node_id,
dataflow_id,
node_config: run_config.clone(),
control_channel,
clock,
zenoh_session,
zenoh_shm_provider,
zenoh_publishers: HashMap::new(),
zenoh_schema_publishers: HashMap::new(),
zenoh_schema_state: HashMap::new(),
zenoh_zero_copy_threshold,
zenoh_output_readiness: HashMap::new(),
dataflow_descriptor: serde_yaml::from_value(dataflow_descriptor),
warned_unknown_output: BTreeSet::new(),
interactive: false,
restart_count,
runtime_type_checks,
_owned_runtime: owned_runtime,
};
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"
);
}
}
}
}
node.warm_up_direct_outputs();
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 total = ipc_encode::uint8_ipc_len(data_len)
.map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
let mut sample = self.allocate_data_sample(total)?;
let offset = ipc_encode::encode_uint8_ipc_header(&mut sample, data_len)
.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 Array,
) -> NodeResult<()> {
if !self.validate_output(&output_id) {
return Ok(());
};
let arrow_array = data.to_data();
if let Some((mode, checks)) = &self.runtime_type_checks
&& let Some(expected) = checks.get(&output_id)
&& !carries_pattern_correlation(¶meters)
{
let actual = arrow_array.data_type();
if 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!(),
}
}
}
self.send_output_array(output_id, parameters, arrow_array)
}
fn send_output_array(
&mut self,
output_id: DataId,
mut parameters: MetadataParameters,
arrow_array: ArrayData,
) -> NodeResult<()> {
parameters.insert(
FRAMING.to_string(),
Parameter::String(FRAMING_ARROW_IPC.to_string()),
);
let sample = match ipc_encode::ipc_fast_path_len(&arrow_array) {
Some(len) => {
let mut s = self.allocate_data_sample(len)?;
ipc_encode::encode_ipc_into(&arrow_array, &mut s)
.map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
s
}
None => {
let bytes = ipc_encode::encode_ipc_to_vec(&arrow_array)
.map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
let mut s = self.allocate_data_sample(bytes.len())?;
s.copy_from_slice(&bytes);
s
}
};
self.send_output_sample(output_id, parameters, Some(sample))
.wrap_err("failed to send output")?;
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("open_telemetry_context") {
let cx = opentelemetry::Context::current();
let serialized = dora_tracing::telemetry::serialize_context(&cx);
if !serialized.is_empty() {
parameters.insert(
"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());
enum Delivery {
Zenoh,
Daemon(Option<DataMessage>),
}
let delivery = match finalized {
Some(finalized) if self.zenoh_session.is_some() => {
tracing::trace!(
output = %output_id,
size = finalized.byte_len(),
"publishing via zenoh"
);
match self.zenoh_publish(&output_id, &metadata, finalized) {
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) => Delivery::Daemon(Some(finalized.into_data_message())),
None => Delivery::Daemon(None),
};
match delivery {
Delivery::Zenoh => {
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 (no matching zenoh subscriber to \
take the large payload)",
v.len(),
dora_message::MAX_MESSAGE_BYTES,
)));
}
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 ensure_output_ready(&mut self, output_id: &DataId) -> bool {
use zenoh::Wait;
if self
.zenoh_output_readiness
.get(output_id)
.is_some_and(|r| r.ready)
{
return true;
}
if !self.zenoh_output_readiness.contains_key(output_id) {
match self.declare_output_readiness(output_id) {
Some(readiness) => {
self.zenoh_output_readiness
.insert(output_id.clone(), readiness);
}
None => return true,
}
}
let (expected, count) = {
let readiness = self
.zenoh_output_readiness
.get(output_id)
.expect("readiness entry just ensured");
let count = readiness.alive.lock().map(|alive| alive.len()).unwrap_or(0);
(readiness.expected, count)
};
if expected == 0 {
self.mark_output_ready(output_id);
return true;
}
if count < expected {
return false;
}
let matched = self
.zenoh_publishers
.get(output_id)
.and_then(|publisher| publisher.matching_status().wait().ok())
.is_some_and(|status| status.matching());
if matched {
self.mark_output_ready(output_id);
return true;
}
false
}
fn mark_output_ready(&mut self, output_id: &DataId) {
if let Some(readiness) = self.zenoh_output_readiness.get_mut(output_id) {
if !readiness.ready {
tracing::debug!(
output = %output_id,
subscribers = readiness.expected,
"all subscribers wired; switching output to direct zenoh data plane"
);
}
readiness.ready = true;
}
}
fn declare_output_readiness(&self, output_id: &DataId) -> Option<ZenohOutputReadiness> {
use zenoh::Wait;
let session = self.zenoh_session.as_ref()?;
let expected = match &self.dataflow_descriptor {
Ok(descriptor) => descriptor
.output_subscriber_count(&self.id, output_id)
.unwrap_or_else(|e| {
tracing::warn!(
output = %output_id,
"failed to count subscribers ({e}); not gating the zenoh data plane"
);
0
}),
Err(_) => 0,
};
let prefix = dora_core::topics::zenoh_output_ready_liveliness_prefix(
self.dataflow_id,
&self.id,
output_id,
);
let key_expr = match zenoh::key_expr::KeyExpr::new(prefix) {
Ok(key) => key.into_owned(),
Err(e) => {
tracing::warn!(output = %output_id, "invalid readiness key ({e}); not gating the zenoh data plane");
return None;
}
};
let alive = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
let alive_cb = alive.clone();
let subscriber = session
.liveliness()
.declare_subscriber(key_expr)
.history(true)
.callback(move |sample| {
let key = sample.key_expr().as_str().to_string();
if let Ok(mut alive) = alive_cb.lock() {
match sample.kind() {
zenoh::sample::SampleKind::Put => {
alive.insert(key);
}
zenoh::sample::SampleKind::Delete => {
alive.remove(&key);
}
}
}
})
.wait();
match subscriber {
Ok(subscriber) => Some(ZenohOutputReadiness {
expected,
ready: false,
alive,
_liveliness_sub: subscriber,
}),
Err(e) => {
tracing::warn!(output = %output_id, "failed to declare readiness subscriber ({e}); not gating the zenoh data plane");
None
}
}
}
fn ensure_zenoh_publisher(&mut self, output_id: &DataId) -> bool {
use zenoh::Wait;
use zenoh::qos::{CongestionControl, Priority};
if self.zenoh_publishers.contains_key(output_id) {
return true;
}
let Some(session) = self.zenoh_session.as_ref() else {
return false;
};
let topic =
dora_core::topics::zenoh_output_publish_topic(self.dataflow_id, &self.id, output_id);
let key_expr = match zenoh::key_expr::KeyExpr::new(topic) {
Ok(key) => key.into_owned(),
Err(e) => {
tracing::warn!(output = %output_id, "invalid zenoh key ({e}); falling back to daemon path");
return false;
}
};
let publisher = match session
.declare_publisher(key_expr)
.congestion_control(CongestionControl::Drop)
.express(true)
.priority(Priority::RealTime)
.wait()
{
Ok(publisher) => publisher,
Err(e) => {
tracing::warn!(output = %output_id, "failed to declare zenoh publisher ({e}); falling back to daemon path");
return false;
}
};
self.zenoh_publishers.insert(output_id.clone(), publisher);
true
}
fn warm_up_direct_outputs(&mut self) {
if self.zenoh_session.is_none() {
return;
}
let outputs: Vec<DataId> = self.node_config.outputs.iter().cloned().collect();
if outputs.is_empty() {
return;
}
for output_id in &outputs {
self.ensure_zenoh_publisher(output_id);
}
let deadline = Instant::now() + ZENOH_OUTPUT_CONNECT_TIMEOUT;
loop {
let all_ready = outputs
.iter()
.all(|output_id| self.ensure_output_ready(output_id));
if all_ready {
break;
}
if Instant::now() >= deadline {
let pending: Vec<&str> = outputs
.iter()
.filter(|o| !self.ensure_output_ready(o))
.map(|o| o.as_str())
.collect();
tracing::debug!(
outputs = ?pending,
"direct-zenoh routes not confirmed within {}s; these outputs start on the daemon path and upgrade when their subscribers connect",
ZENOH_OUTPUT_CONNECT_TIMEOUT.as_secs()
);
break;
}
std::thread::sleep(ZENOH_OUTPUT_CONNECT_POLL_INTERVAL);
}
}
fn zenoh_publish(
&mut self,
output_id: &DataId,
metadata: &Metadata,
finalized: FinalizedSample,
) -> eyre::Result<PublishOutcome> {
use zenoh::Wait;
if !self.ensure_zenoh_publisher(output_id) {
return Ok(PublishOutcome::NotPublished(finalized));
}
if !self.ensure_output_ready(output_id) {
return Ok(PublishOutcome::NotPublished(finalized));
}
let Some(publisher) = self.zenoh_publishers.get(output_id) else {
tracing::warn!(output = %output_id, "zenoh publisher missing; falling back to daemon path");
return Ok(PublishOutcome::NotPublished(finalized));
};
let session = self
.zenoh_session
.as_ref()
.expect("zenoh session presence checked above");
let metadata_bytes = match bincode::serialize(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) => {
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.zenoh_zero_copy_threshold
&& let Some(provider) = &self.zenoh_shm_provider
{
use zenoh::shm::GarbageCollect;
match provider
.alloc(avec.len())
.with_policy::<GarbageCollect>()
.wait()
{
Ok(mut sbuf) => {
sbuf.as_mut().copy_from_slice(&avec);
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)))
}
};
}
Err(e) => {
tracing::debug!("SHM alloc failed ({e}), using heap buffer");
}
}
}
if avec.len() >= self.zenoh_zero_copy_threshold {
return Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)));
}
let schema_once = if schema_once_eligible(
avec.len(),
self.zenoh_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.zenoh_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 {
let total: usize = fields.iter().map(|(k, v)| k.len() + v.len()).sum();
if total <= Self::MAX_LOG_FIELDS_BYTES {
entry["fields"] = serde_json::json!(fields);
} else {
eprintln!("dora log: fields too large ({total} bytes), 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 Array,
) -> 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 Array,
) -> 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 Array,
) -> NodeResult<()> {
self.send_output(output_id, segment.chunk(fin), data)
}
pub fn allocate_data_sample(&mut self, data_len: usize) -> NodeResult<DataSample> {
if data_len >= self.zenoh_zero_copy_threshold
&& let Some(provider) = &self.zenoh_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 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 register_pinned_memory(
&mut self,
shared_memory_id: String,
metadata: Metadata,
) -> Result<(), eyre::Error> {
self.control_channel
.register_pinned_memory(shared_memory_id, metadata)
}
pub fn read_pinned_memory(
&mut self,
shared_memory_id: String,
free: bool,
) -> Result<Metadata, eyre::Error> {
self.control_channel
.read_pinned_memory(shared_memory_id, free)
}
pub fn free_pinned_memory(&mut self, shared_memory_id: String) -> Result<(), eyre::Error> {
self.control_channel.free_pinned_memory(shared_memory_id)
}
}
#[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 publishers = std::mem::take(&mut self.zenoh_publishers);
let schema_publishers = std::mem::take(&mut self.zenoh_schema_publishers);
let readiness = std::mem::take(&mut self.zenoh_output_readiness);
let shm_provider = self.zenoh_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(runtime);
} else {
let completed = teardown_with_timeout("zenoh", ZENOH_TEARDOWN_TIMEOUT, move || {
drop(publishers);
drop(schema_publishers);
drop(readiness);
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 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:?}")
}
}
}
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));
bincode::serialize(&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 std::env::var("DORA_OTLP_ENDPOINT").is_ok() {
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())
.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},
};
use arrow::array::NullArray;
#[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);
}
fn test_node() -> (
DoraNode,
crate::EventStream,
flume::Receiver<serde_json::Map<String, serde_json::Value>>,
) {
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) = flume::unbounded();
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)
}
#[test]
fn send_service_request_returns_valid_id_and_sends_output() {
let (mut node, events, rx) = test_node();
let request_id = node
.send_service_request("request".into(), Default::default(), NullArray::new(0))
.unwrap();
uuid::Uuid::parse_str(&request_id).expect("returned request_id should be valid UUID");
drop(node);
drop(events);
let outputs: Vec<_> = rx.try_iter().collect();
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(), NullArray::new(0))
.unwrap();
let id2 = node
.send_service_request("out".into(), Default::default(), NullArray::new(0))
.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, 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, NullArray::new(0))
.unwrap();
drop(node);
drop(events);
let outputs: Vec<_> = rx.try_iter().collect();
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;
use crate::arrow_utils::ipc_encode::{encode_ipc_into, ipc_fast_path_len};
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).expect("array should be fast-path eligible");
let mut buf: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, len);
encode_ipc_into(&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(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, rx) = test_node();
let mut seg = StreamSegment::with_session_id("s1".into());
node.send_stream_chunk("audio".into(), &mut seg, false, NullArray::new(0))
.unwrap();
drop(node);
drop(events);
let outputs: Vec<_> = rx.try_iter().collect();
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");
}
}