use serde::Deserialize;
use crate::status::{Error, Result};
fn default_max_queue_size() -> usize {
100
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecutorConfig {
#[serde(default)]
pub name: String,
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub num_threads: usize,
#[serde(default)]
pub affinity: Vec<usize>,
#[serde(default)]
pub priority: i32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InputPolicyConfig {
#[serde(default = "default_policy_type")]
pub r#type: String,
#[serde(default)]
pub capacity: usize,
#[serde(default)]
pub sets: Vec<Vec<String>>,
}
fn default_policy_type() -> String {
"sync".to_string()
}
impl Default for InputPolicyConfig {
fn default() -> Self {
Self {
r#type: default_policy_type(),
capacity: 0,
sets: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeConfig {
#[serde(default)]
pub name: String,
#[serde(default)]
pub kernel: String,
#[serde(default)]
pub input_ports: Vec<String>,
#[serde(default)]
pub output_ports: Vec<String>,
#[serde(default)]
pub executor: String,
#[serde(default)]
pub max_in_flight: usize,
#[serde(default)]
pub options: serde_yaml::Value,
#[serde(default)]
pub input_policy: InputPolicyConfig,
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub back_edges: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GraphConfig {
#[serde(default)]
pub executors: Vec<ExecutorConfig>,
#[serde(default)]
pub nodes: Vec<NodeConfig>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub subgraphs: std::collections::BTreeMap<String, SubgraphConfig>,
#[serde(default)]
pub input_ports: Vec<String>,
#[serde(default)]
pub output_ports: Vec<String>,
#[serde(default = "default_max_queue_size")]
pub max_queue_size: usize,
#[serde(default)]
pub max_queued_packets: usize,
#[serde(default)]
pub max_queued_bytes: u64,
#[serde(default)]
pub watchdog_ms: u64,
#[serde(default = "default_true")]
pub stats_timing: bool,
}
fn default_true() -> bool {
true
}
impl Default for GraphConfig {
fn default() -> Self {
Self {
executors: Vec::new(),
nodes: Vec::new(),
include: Vec::new(),
subgraphs: std::collections::BTreeMap::new(),
input_ports: Vec::new(),
output_ports: Vec::new(),
max_queue_size: default_max_queue_size(),
max_queued_packets: 0,
max_queued_bytes: 0,
watchdog_ms: 0,
stats_timing: default_true(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubgraphConfig {
#[serde(default)]
pub nodes: Vec<NodeConfig>,
#[serde(default)]
pub input_ports: Vec<String>,
#[serde(default)]
pub output_ports: Vec<String>,
}
impl GraphConfig {
pub fn parse(text: &str) -> Result<Self> {
serde_yaml::from_str(text).map_err(|e| Error::InvalidArg(format!("YAML parse failed: {e}")))
}
pub fn from_yaml(text: &str) -> Result<Self> {
let cfg = Self::parse(text)?;
if !cfg.include.is_empty() {
return Err(Error::InvalidArg(
"`include` is only supported when loading from a file (from_yaml_file); \
relative paths cannot be resolved from a text string"
.into(),
));
}
let flat = crate::expand::expand(cfg)?;
flat.check_supported()?;
Ok(flat)
}
pub fn from_yaml_file(path: &str) -> Result<Self> {
let text = std::fs::read_to_string(path)
.map_err(|e| Error::InvalidArg(format!("failed to read `{path}`: {e}")))?;
let cfg = Self::parse(&text)?;
let merged = crate::expand::resolve_includes(cfg, std::path::Path::new(path))?;
let flat = crate::expand::expand(merged)?;
flat.check_supported()?;
Ok(flat)
}
fn check_supported(&self) -> Result<()> {
for n in &self.nodes {
let who = if n.name.is_empty() {
n.kernel.clone()
} else {
n.name.clone()
};
if n.max_in_flight > 1 && n.executor.is_empty() {
return Err(Error::InvalidArg(format!(
"node `{who}`: max_in_flight={} requires an executor (thread pool) as well -- \
the default executor is the host main thread, so there is no parallelism",
n.max_in_flight
)));
}
if n.input_ports.is_empty() && n.executor.is_empty() {
return Err(Error::InvalidArg(format!(
"node `{who}`: a source node (no input ports) requires an executor (thread pool) -- \
it would otherwise block the host main thread and stall the whole graph"
)));
}
match n.input_policy.r#type.as_str() {
"sync" | "immediate" => {}
"fixed_size" => {
if n.input_policy.capacity == 0 {
return Err(Error::InvalidArg(format!(
"node `{who}`: fixed_size policy capacity must be >= 1"
)));
}
}
"batch" => {
if n.input_policy.capacity == 0 {
return Err(Error::InvalidArg(format!(
"node `{who}`: batch policy capacity (the batch size) must be >= 1"
)));
}
if n.input_ports.len() != 1 {
return Err(Error::InvalidArg(format!(
"node `{who}`: batch policy requires exactly one input port (multi-input batching is not supported yet), got {}",
n.input_ports.len()
)));
}
}
"sync_set" => {
if n.input_policy.sets.is_empty() {
return Err(Error::InvalidArg(format!(
"node `{who}`: sync_set policy must provide sets (input port groups)"
)));
}
}
other => {
return Err(Error::InvalidArg(format!(
"node `{who}`: unknown input_policy `{other}` (valid: sync / immediate / fixed_size / sync_set / batch)"
)))
}
}
if !n.back_edges.is_empty() {
let port_names: Vec<String> = n
.input_ports
.iter()
.map(|d| parse_port_spec(d).map(|s| s.name))
.collect::<Result<_>>()?;
for be in &n.back_edges {
if !port_names.contains(be) {
return Err(Error::InvalidArg(format!(
"node `{who}`: back_edge `{be}` is not one of this node's input ports"
)));
}
}
let forward = port_names
.iter()
.filter(|p| !n.back_edges.contains(p))
.count();
if forward == 0 {
return Err(Error::InvalidArg(format!(
"node `{who}`: every input port is a back_edge -- a node needs at least one forward input to ever fire"
)));
}
if n.input_policy.r#type == "sync_set" {
for set in &n.input_policy.sets {
if let Some(name) = set.iter().find(|p| n.back_edges.contains(p)) {
return Err(Error::InvalidArg(format!(
"node `{who}`: back_edge `{name}` must not appear in a sync_set group"
)));
}
}
}
}
}
for e in &self.executors {
if e.r#type != "ThreadPoolExecutor" && !e.r#type.is_empty() {
return Err(Error::InvalidArg(format!(
"unknown executor type `{}` (only ThreadPoolExecutor is currently supported)",
e.r#type
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortSpec {
pub tag: String,
pub index: Option<usize>,
pub name: String,
}
pub fn parse_port_spec(decl: &str) -> Result<PortSpec> {
let parts: Vec<&str> = decl.split(':').collect();
let spec = match parts.len() {
1 => PortSpec {
tag: String::new(),
index: None,
name: parts[0].to_string(),
},
2 => PortSpec {
tag: parts[0].to_string(),
index: None,
name: parts[1].to_string(),
},
3 => {
let idx: usize = parts[1].parse().map_err(|_| {
Error::InvalidArg(format!(
"port declaration `{decl}`: index `{}` is not a non-negative integer",
parts[1]
))
})?;
PortSpec {
tag: parts[0].to_string(),
index: Some(idx),
name: parts[2].to_string(),
}
}
_ => {
return Err(Error::InvalidArg(format!(
"port declaration `{decl}` has an invalid number of segments (expected name / TAG:name / TAG:index:name)"
)))
}
};
if spec.name.is_empty() {
return Err(Error::InvalidArg(format!(
"port declaration `{decl}` has an empty name"
)));
}
Ok(spec)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_graph() {
let cfg = GraphConfig::from_yaml(
r#"
nodes:
- name: "n1"
kernel: "PassThroughKernel"
input_ports: ["a"]
output_ports: ["b"]
input_ports: ["a"]
output_ports: ["b"]
"#,
)
.unwrap();
assert_eq!(cfg.nodes.len(), 1);
assert_eq!(cfg.nodes[0].kernel, "PassThroughKernel");
assert_eq!(cfg.input_ports, vec!["a"]);
assert_eq!(cfg.max_queue_size, 100, "default queue limit");
}
#[test]
fn parses_options_and_executors() {
let cfg = GraphConfig::from_yaml(
r#"
executors:
- name: "cpu"
type: "ThreadPoolExecutor"
num_threads: 4
nodes:
- name: "s"
kernel: "ScaleKernel"
executor: "cpu"
input_ports: ["a"]
output_ports: ["b"]
options:
factor: 3
mean: [0.1, 0.2]
roi: { x: 5 }
watchdog_ms: 5000
max_queued_packets: 500
"#,
)
.unwrap();
assert_eq!(cfg.executors[0].num_threads, 4);
assert_eq!(cfg.nodes[0].executor, "cpu");
assert_eq!(cfg.watchdog_ms, 5000);
assert_eq!(cfg.max_queued_packets, 500);
assert!(cfg.nodes[0].options.get("factor").is_some());
}
#[test]
fn parses_executor_affinity() {
let cfg = GraphConfig::from_yaml(
r#"
executors:
- { name: "rt", type: "ThreadPoolExecutor", num_threads: 2, affinity: [2, 3], priority: 10 }
nodes:
- { name: "n", kernel: "K", input_ports: ["a"], output_ports: ["b"] }
"#,
)
.unwrap();
assert_eq!(
cfg.executors[0].affinity,
vec![2, 3],
"affinity list should be parsed"
);
assert_eq!(
cfg.executors[0].priority, 10,
"realtime priority should be parsed"
);
let cfg2 =
GraphConfig::from_yaml("executors:\n - { name: c, type: ThreadPoolExecutor }\n")
.unwrap();
assert!(
cfg2.executors[0].affinity.is_empty(),
"no CPU pinning by default"
);
assert_eq!(
cfg2.executors[0].priority, 0,
"ordinary time-sharing by default"
);
}
#[test]
fn rejects_unsupported_features_loudly() {
let err = GraphConfig::from_yaml(
r#"
nodes:
- name: "n"
kernel: "K"
max_in_flight: 4
"#,
)
.unwrap_err();
assert_eq!(err.code(), crate::status::code::INVALID_ARG);
assert!(err.to_string().contains("max_in_flight"), "{err}");
}
#[test]
fn rejects_unknown_fields_and_bad_policy() {
assert!(
GraphConfig::from_yaml("nodes:\n - kernel: K\n typo_field: 1\n").is_err(),
"a misspelled field name must error rather than be silently ignored"
);
let err =
GraphConfig::from_yaml("nodes:\n - kernel: K\n input_policy: { type: nonsense }\n")
.unwrap_err();
assert_eq!(err.code(), crate::status::code::INVALID_ARG);
}
#[test]
fn port_spec_three_forms() {
assert_eq!(
parse_port_spec("frames").unwrap(),
PortSpec {
tag: String::new(),
index: None,
name: "frames".into()
}
);
assert_eq!(
parse_port_spec("VIDEO:cam0").unwrap(),
PortSpec {
tag: "VIDEO".into(),
index: None,
name: "cam0".into()
}
);
assert_eq!(
parse_port_spec("MASK:1:m1").unwrap(),
PortSpec {
tag: "MASK".into(),
index: Some(1),
name: "m1".into()
}
);
}
#[test]
fn port_spec_rejects_malformed() {
assert!(parse_port_spec("a:b:c:d").is_err(), "too many segments");
assert!(
parse_port_spec("TAG:x:name").is_err(),
"index is not a number"
);
assert!(parse_port_spec("").is_err(), "empty name");
assert!(parse_port_spec("TAG:").is_err(), "empty name");
}
}