use std::{
collections::{BTreeSet, HashMap},
fmt,
};
use crate::common::resolve_dataflow_identifier_interactive;
use crate::ws_client::WsSession;
use dora_core::{
config::{Input, InputMapping},
descriptor::{Descriptor, Node, SINGLE_OPERATOR_DEFAULT_ID},
};
use dora_message::{
DataflowId,
cli_to_coordinator::ControlRequest,
coordinator_to_cli::ControlRequestReply,
id::{DataId, NodeId, OperatorId},
};
use eyre::{Context, ContextCompat, bail};
use uuid::Uuid;
#[derive(Debug, clap::Args)]
pub struct DataflowSelector {
#[clap(long, short, value_name = "UUID_OR_NAME")]
pub dataflow: Option<String>,
}
impl DataflowSelector {
pub fn resolve(&self, session: &WsSession) -> eyre::Result<(Uuid, Descriptor)> {
let dataflow_id =
resolve_dataflow_identifier_interactive(session, self.dataflow.as_deref())?;
let reply_raw = session
.request(
&serde_json::to_vec(&ControlRequest::Info {
dataflow_uuid: dataflow_id,
})
.unwrap(),
)
.wrap_err("failed to send message")?;
let reply: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
match reply {
ControlRequestReply::DataflowInfo { descriptor, .. } => Ok((dataflow_id, descriptor)),
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected list dataflow reply: {other:?}"),
}
}
}
#[derive(Debug, clap::Args)]
pub struct TopicSelector {
#[clap(flatten)]
pub dataflow: DataflowSelector,
#[clap(value_name = "DATA")]
pub data: Vec<String>,
}
#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq)]
pub struct TopicIdentifier {
pub node_id: NodeId,
pub data_id: DataId,
}
impl fmt::Display for TopicIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.node_id, self.data_id)
}
}
pub(crate) fn public_topic_output_id(node: &Node, wire_output: &DataId) -> DataId {
let Some(operator) = &node.operator else {
return wire_output.clone();
};
let default_id = OperatorId::from(SINGLE_OPERATOR_DEFAULT_ID.to_string());
let operator_id = operator.id.as_ref().unwrap_or(&default_id);
match wire_output.strip_prefix(&format!("{operator_id}/")) {
Some(bare) => DataId::from(bare.to_string()),
None => wire_output.clone(),
}
}
pub(crate) fn node_topic_inputs(node: &Node) -> Vec<(&DataId, &Input)> {
let mut inputs: Vec<_> = node.inputs.iter().collect();
if let Some(operator) = &node.operator {
inputs.extend(operator.config.inputs.iter());
}
if let Some(runtime) = &node.operators {
for operator in &runtime.operators {
inputs.extend(operator.config.inputs.iter());
}
}
inputs
}
pub(crate) fn wire_topic_output_id(node: &Node, public_output: &DataId) -> DataId {
let Some(operator) = &node.operator else {
return public_output.clone();
};
let default_id = OperatorId::from(SINGLE_OPERATOR_DEFAULT_ID.to_string());
let operator_id = operator.id.as_ref().unwrap_or(&default_id);
DataId::from(format!("{operator_id}/{public_output}"))
}
pub(crate) fn node_topic_outputs(node: &Node) -> BTreeSet<DataId> {
let mut outputs = node.outputs.clone();
if let Some(operator) = &node.operator {
outputs.extend(operator.config.outputs.iter().cloned());
}
if let Some(runtime) = &node.operators {
for operator in &runtime.operators {
outputs.extend(
operator
.config
.outputs
.iter()
.map(|output| DataId::from(format!("{}/{}", operator.id, output))),
);
}
}
outputs
}
impl TopicSelector {
pub fn resolve_with_descriptor(
&self,
session: &WsSession,
) -> eyre::Result<(DataflowId, BTreeSet<TopicIdentifier>, Descriptor)> {
let (dataflow_id, dataflow_descriptor) = self.dataflow.resolve(session)?;
let data = resolve_topics(&self.data, &dataflow_descriptor)?;
Ok((dataflow_id, data, dataflow_descriptor))
}
}
fn resolve_topics(
data: &[String],
descriptor: &Descriptor,
) -> eyre::Result<BTreeSet<TopicIdentifier>> {
let node_map = descriptor
.nodes
.iter()
.map(|node| (&node.id, node))
.collect::<HashMap<_, _>>();
let unknown_node_err = |node: &NodeId| {
format!(
"unknown node `{}`\n\n \
hint: available nodes: {}",
node,
node_map
.keys()
.map(|k| k.to_string())
.collect::<Vec<_>>()
.join(", ")
)
};
let mut topics = BTreeSet::new();
if data.is_empty() {
topics.extend(descriptor.nodes.iter().flat_map(|node| {
node_topic_outputs(node)
.into_iter()
.map(|output| TopicIdentifier {
node_id: node.id.clone(),
data_id: output,
})
}));
return Ok(topics);
}
for s in data {
if !s.contains('/') {
let node_id: NodeId = s
.parse()
.wrap_err_with(|| format!("invalid node id `{s}`"))?;
let node = *node_map
.get(&node_id)
.with_context(|| unknown_node_err(&node_id))?;
topics.extend(
node_topic_outputs(node)
.into_iter()
.map(|output| TopicIdentifier {
node_id: node_id.clone(),
data_id: output,
}),
);
continue;
}
match s.parse() {
Ok(InputMapping::User(user)) => {
let node = *node_map
.get(&user.source)
.with_context(|| unknown_node_err(&user.source))?;
let outputs = node_topic_outputs(node);
if outputs.contains(&user.output) {
topics.insert(TopicIdentifier {
node_id: user.source,
data_id: user.output,
});
} else {
bail!(
"node `{}` does not have output `{}`\n\n \
hint: available outputs: {}",
user.source,
user.output,
outputs
.iter()
.map(|o| o.to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
}
Ok(_) => {
bail!("Reserved input mapping cannot be inspected")
}
Err(e) => bail!("Invalid output id `{s}`: {e}"),
}
}
if topics.is_empty() {
bail!(
"no outputs found in this dataflow\n\n \
hint: ensure nodes in the dataflow declare `outputs` in their YAML definition"
);
}
Ok(topics)
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor() -> Descriptor {
serde_yaml::from_str(
"\
nodes:
- id: camera
path: camera
outputs:
- frame
- status
- id: sink
path: sink
inputs:
image: camera/frame
",
)
.expect("parse descriptor")
}
fn topic(node: &str, output: &str) -> TopicIdentifier {
TopicIdentifier {
node_id: node.to_string().into(),
data_id: output.to_string().into(),
}
}
#[test]
fn bare_node_id_expands_to_all_outputs() {
let topics = resolve_topics(&["camera".to_string()], &descriptor()).unwrap();
assert_eq!(
topics,
BTreeSet::from([topic("camera", "frame"), topic("camera", "status")])
);
}
#[test]
fn qualified_output_selects_exactly_one() {
let topics = resolve_topics(&["camera/frame".to_string()], &descriptor()).unwrap();
assert_eq!(topics, BTreeSet::from([topic("camera", "frame")]));
}
#[test]
fn empty_selector_returns_every_output() {
let topics = resolve_topics(&[], &descriptor()).unwrap();
assert_eq!(
topics,
BTreeSet::from([topic("camera", "frame"), topic("camera", "status")])
);
}
#[test]
fn unknown_bare_node_reports_hint() {
let err = resolve_topics(&["ghost".to_string()], &descriptor()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("unknown node `ghost`"), "unexpected: {msg}");
assert!(msg.contains("available nodes"), "unexpected: {msg}");
}
#[test]
fn unknown_output_reports_hint() {
let err = resolve_topics(&["camera/nope".to_string()], &descriptor()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("does not have output `nope`"),
"unexpected: {msg}"
);
}
#[test]
fn node_topic_outputs_include_all_descriptor_node_kinds() {
let descriptor: Descriptor = serde_yaml::from_str(
"\
nodes:
- id: standard
path: ./source
outputs:
- status
- id: single
operator:
python: single.py
outputs:
- image
- id: runtime
operators:
- id: op
python: runtime.py
outputs:
- status
",
)
.expect("valid descriptor");
let topics: Vec<String> = descriptor
.nodes
.iter()
.flat_map(|node| {
node_topic_outputs(node)
.into_iter()
.map(|output| format!("{}/{}", node.id, output))
})
.collect();
assert_eq!(
topics,
vec!["standard/status", "single/image", "runtime/op/status"]
);
}
#[test]
fn public_and_wire_output_ids_round_trip() {
let descriptor: Descriptor = serde_yaml::from_str(
"\
nodes:
- id: standard
path: standard
outputs:
- status
- id: single
operator:
python: single.py
outputs:
- image
- id: named
operator:
id: myop
python: named.py
outputs:
- image
- id: runtime
operators:
- id: op
python: runtime.py
outputs:
- status
",
)
.expect("parse descriptor");
let node = |id: &str| {
descriptor
.nodes
.iter()
.find(|n| n.id.to_string() == id)
.expect("node in fixture")
.clone()
};
let d = |s: &str| DataId::from(s.to_string());
assert_eq!(
wire_topic_output_id(&node("single"), &d("image")),
d("op/image")
);
assert_eq!(
public_topic_output_id(&node("single"), &d("op/image")),
d("image")
);
assert_eq!(
wire_topic_output_id(&node("named"), &d("image")),
d("myop/image")
);
assert_eq!(
public_topic_output_id(&node("named"), &d("image")),
d("image")
);
assert_eq!(
public_topic_output_id(&node("named"), &d("myop/image")),
d("image")
);
for (id, output) in [("standard", "status"), ("runtime", "op/status")] {
assert_eq!(wire_topic_output_id(&node(id), &d(output)), d(output));
assert_eq!(public_topic_output_id(&node(id), &d(output)), d(output));
}
for id in ["standard", "single", "named", "runtime"] {
let n = node(id);
for public in node_topic_outputs(&n) {
let wire = wire_topic_output_id(&n, &public);
assert_eq!(
public_topic_output_id(&n, &wire),
public,
"round-trip failed for {id}/{public}"
);
}
}
}
#[test]
fn node_topic_inputs_include_operator_backed_consumers() {
let descriptor: Descriptor = serde_yaml::from_str(
"\
nodes:
- id: camera
path: camera
outputs:
- frame
- id: detect
operator:
python: detect.py
inputs:
image: camera/frame
- id: plot
operators:
- id: op
python: plot.py
inputs:
image: camera/frame
- id: log
path: log
inputs:
image: camera/frame
",
)
.expect("parse descriptor");
for id in ["detect", "plot", "log"] {
let node = descriptor
.nodes
.iter()
.find(|n| n.id.to_string() == id)
.expect("node in fixture");
let inputs = node_topic_inputs(node);
assert_eq!(inputs.len(), 1, "{id} should have one input");
assert_eq!(inputs[0].0.to_string(), "image", "{id}");
}
}
}