use std::collections::HashMap;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use super::{
BspContext,
condition::Condition,
error::BspError,
graph::{BspGraph, Edge, InputPin, NodeId},
node::{Activation, BspNode, NodeOutcome},
recovery::{Recovery, RecoveryPolicy, RetryPolicy},
registry::NodeRegistry,
run::RunContext,
value::BspValue,
};
pub(crate) fn value_to_json(value: &BspValue) -> Option<serde_json::Value> {
if let Some(v) = value.downcast_ref::<serde_json::Value>() {
Some(v.clone())
} else if let Some(b) = value.downcast_ref::<bool>() {
Some(serde_json::Value::from(*b))
} else if let Some(i) = value.downcast_ref::<i64>() {
Some(serde_json::Value::from(*i))
} else if let Some(u) = value.downcast_ref::<u64>() {
Some(serde_json::Value::from(*u))
} else if let Some(f) = value.downcast_ref::<f64>() {
serde_json::Number::from_f64(*f).map(serde_json::Value::Number)
} else {
value
.downcast_ref::<String>()
.map(|s| serde_json::Value::from(s.clone()))
}
}
pub const GRAPH_DEF_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StaticInputDef {
pub node: String,
pub port: usize,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphDef {
pub version: u32,
pub nodes: Vec<NodeDef>,
pub edges: Vec<EdgeDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entry: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub static_inputs: Vec<StaticInputDef>,
}
impl GraphDef {
pub fn from_json(json: &str) -> Result<Self, BspError> {
serde_json::from_str(json).map_err(|e| BspError::DefError(e.to_string()))
}
pub fn to_json(&self) -> Result<String, BspError> {
serde_json::to_string_pretty(self).map_err(|e| BspError::DefError(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NodeDef {
pub id: String,
pub type_name: String,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub config: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation: Option<ActivationDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recovery: Option<RecoveryDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merger: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_executions: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<(f64, f64)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActivationDef {
Any,
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryDef {
FailFast,
Retry {
#[serde(default = "default_max_retries")]
max_retries: u32,
#[serde(default = "default_base_delay_ms")]
base_delay_ms: u64,
#[serde(default = "default_max_delay_ms")]
max_delay_ms: u64,
#[serde(default = "default_jitter")]
jitter: f64,
},
ErrorEdge,
}
const fn default_max_retries() -> u32 {
3
}
const fn default_base_delay_ms() -> u64 {
500
}
const fn default_max_delay_ms() -> u64 {
8_000
}
const fn default_jitter() -> f64 {
0.25
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConditionDef {
#[default]
Always,
Never,
OnSkip,
OnError,
Named(String),
And(Vec<ConditionDef>),
Or(Vec<ConditionDef>),
Not(Box<ConditionDef>),
Custom {
label: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EdgeDef {
pub from: String,
pub to: String,
#[serde(default)]
pub condition: ConditionDef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_iterations: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_port: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_port: Option<usize>,
}
impl ConditionDef {
fn to_condition<S>(
&self,
registry: &NodeRegistry<S>,
unknown: &mut Vec<String>,
) -> Condition<S> {
match self {
Self::Always => Condition::Always,
Self::Never => Condition::Never,
Self::OnSkip => Condition::OnSkip,
Self::OnError => Condition::OnError,
Self::Named(name) => {
if !registry.conditions.contains_key(name) {
unknown.push(format!("condition '{name}'"));
}
Condition::Named(name.clone().into())
}
Self::And(conds) => Condition::And(
conds
.iter()
.map(|c| c.to_condition(registry, unknown))
.collect(),
),
Self::Or(conds) => Condition::Or(
conds
.iter()
.map(|c| c.to_condition(registry, unknown))
.collect(),
),
Self::Not(cond) => Condition::Not(Box::new(cond.to_condition(registry, unknown))),
Self::Custom { label } => match registry.conditions.get(label) {
Some(f) => Condition::Custom {
label: label.clone(),
f: f.clone(),
},
None => {
unknown.push(format!(
"custom condition '{label}' (re-register it to instantiate)"
));
Condition::Never
}
},
}
}
fn from_condition<S>(condition: &Condition<S>) -> Self {
match condition {
Condition::Always => Self::Always,
Condition::Never => Self::Never,
Condition::OnSkip => Self::OnSkip,
Condition::OnError => Self::OnError,
Condition::Named(id) => Self::Named(id.0.clone()),
Condition::And(conds) => Self::And(conds.iter().map(Self::from_condition).collect()),
Condition::Or(conds) => Self::Or(conds.iter().map(Self::from_condition).collect()),
Condition::Not(cond) => Self::Not(Box::new(Self::from_condition(cond))),
Condition::Custom { label, .. } => Self::Custom {
label: label.clone(),
},
}
}
}
struct DefNode<S> {
inner: Box<dyn BspNode<S>>,
type_name: String,
activation: Option<Activation>,
recovery: Option<RecoveryPolicy>,
merger: Option<String>,
max_executions: Option<u64>,
}
#[async_trait]
impl<S: Send + Sync> BspNode<S> for DefNode<S> {
fn name(&self) -> &str {
self.inner.name()
}
fn type_name(&self) -> &str {
&self.type_name
}
fn input_ports(&self) -> Vec<super::graph::PortIndex> {
self.inner.input_ports()
}
fn output_ports(&self) -> Vec<super::graph::PortIndex> {
self.inner.output_ports()
}
fn is_dynamic_output(&self) -> bool {
self.inner.is_dynamic_output()
}
fn activation(&self) -> Activation {
self.activation.unwrap_or_else(|| self.inner.activation())
}
fn recovery(&self) -> RecoveryPolicy {
match &self.recovery {
Some(policy) => policy.clone(),
None => self.inner.recovery(),
}
}
fn merger(&self) -> Option<&str> {
self.merger.as_deref().or_else(|| self.inner.merger())
}
fn max_executions(&self) -> Option<u64> {
self.max_executions.or_else(|| self.inner.max_executions())
}
async fn execute(&self, run: &mut RunContext<'_, S>) -> Result<NodeOutcome, BspError> {
self.inner.execute(run).await
}
}
impl From<ActivationDef> for Activation {
fn from(def: ActivationDef) -> Self {
match def {
ActivationDef::Any => Activation::Any,
ActivationDef::All => Activation::All,
}
}
}
impl From<Activation> for ActivationDef {
fn from(activation: Activation) -> Self {
match activation {
Activation::Any => ActivationDef::Any,
Activation::All => ActivationDef::All,
}
}
}
impl RecoveryDef {
fn to_retry_policy(self) -> Option<RetryPolicy> {
match self {
Self::Retry {
max_retries,
base_delay_ms,
max_delay_ms,
jitter,
} => Some(RetryPolicy {
max_retries,
base_delay: std::time::Duration::from_millis(base_delay_ms),
max_delay: std::time::Duration::from_millis(max_delay_ms),
jitter,
}),
_ => None,
}
}
}
impl<S> BspGraph<S> {
pub fn from_def(
def: &GraphDef,
registry: &NodeRegistry<S>,
ctx: &BspContext,
) -> Result<Self, BspError>
where
S: Send + Sync + 'static,
{
if def.version != GRAPH_DEF_VERSION {
return Err(BspError::DefError(format!(
"unsupported graph definition version {} (this crate understands {GRAPH_DEF_VERSION})",
def.version
)));
}
let mut unknown: Vec<String> = Vec::new();
for node_def in &def.nodes {
if !registry.factories.contains_key(&node_def.type_name) {
unknown.push(format!("node type '{}'", node_def.type_name));
}
if let Some(merger) = &node_def.merger
&& !registry.mergers.contains_key(merger)
{
unknown.push(format!("merger '{merger}'"));
}
}
let mut id_map: HashMap<&str, NodeId> = HashMap::new();
for (idx, node_def) in def.nodes.iter().enumerate() {
let id = idx + 1;
if id_map.insert(node_def.id.as_str(), id).is_some() {
return Err(BspError::ValidationError(format!(
"duplicate node id '{}' in graph definition",
node_def.id
)));
}
}
let mut edges: Vec<Edge<S>> = Vec::with_capacity(def.edges.len());
for edge_def in &def.edges {
let source = id_map.get(edge_def.from.as_str()).copied().ok_or_else(|| {
BspError::ValidationError(format!(
"edge references unknown source node '{}'",
edge_def.from
))
})?;
let target = id_map.get(edge_def.to.as_str()).copied().ok_or_else(|| {
BspError::ValidationError(format!(
"edge references unknown target node '{}'",
edge_def.to
))
})?;
edges.push(Edge {
source,
target,
condition: edge_def.condition.to_condition(registry, &mut unknown),
max_iterations: edge_def.max_iterations,
source_port: edge_def.source_port,
target_port: edge_def.target_port,
});
}
if !unknown.is_empty() {
return Err(BspError::ValidationError(format!(
"graph definition references unregistered names: {}",
unknown.join(", ")
)));
}
let mut graph = BspGraph::new();
graph.conditions = registry.conditions.clone();
graph.mergers = registry.mergers.clone();
for (idx, node_def) in def.nodes.iter().enumerate() {
let id = idx + 1;
let factory = registry
.factories
.get(&node_def.type_name)
.expect("completeness checked above");
let inner = factory(node_def, ctx)?;
let recovery = match node_def.recovery {
None => None,
Some(RecoveryDef::FailFast) => Some(RecoveryPolicy::FailFast),
Some(RecoveryDef::ErrorEdge) => Some(RecoveryPolicy::RouteToErrorEdge),
Some(def @ RecoveryDef::Retry { .. }) => {
let policy = def.to_retry_policy().expect("retry variant");
let classify = match inner.recovery() {
RecoveryPolicy::Retry { classify, .. } => classify,
_ => std::sync::Arc::new(|_: &BspError| Recovery::Retry { after: None }),
};
Some(RecoveryPolicy::Retry { policy, classify })
}
};
graph.add_node_with_id(
id,
Box::new(DefNode {
inner,
type_name: node_def.type_name.clone(),
activation: node_def.activation.map(Activation::from),
recovery,
merger: node_def.merger.clone(),
max_executions: node_def.max_executions,
}),
);
graph.labels.insert(id, node_def.id.clone());
graph.node_defs.insert(id, node_def.clone());
}
for edge in edges {
graph.push_edge(edge);
}
for entry in &def.entry {
let id = id_map.get(entry.as_str()).copied().ok_or_else(|| {
BspError::ValidationError(format!("entry references unknown node '{entry}'"))
})?;
graph.add_entry(id);
}
for static_input in &def.static_inputs {
let id = id_map
.get(static_input.node.as_str())
.copied()
.ok_or_else(|| {
BspError::ValidationError(format!(
"static input references unknown node '{}'",
static_input.node
))
})?;
graph.static_inputs.insert(
InputPin {
node_id: id,
port: static_input.port,
},
(registry.value_factory)(&static_input.value),
);
}
Ok(graph)
}
#[must_use]
pub fn to_def(&self) -> GraphDef {
let nodes = self
.nodes
.iter()
.map(|(id, node)| {
if let Some(retained) = self.node_defs.get(id) {
return retained.clone();
}
NodeDef {
id: self
.labels
.get(id)
.cloned()
.unwrap_or_else(|| id.to_string()),
type_name: node.type_name().to_string(),
config: serde_json::Value::Null,
activation: Some(node.activation().into()),
recovery: Some(match node.recovery() {
RecoveryPolicy::FailFast => RecoveryDef::FailFast,
RecoveryPolicy::RouteToErrorEdge => RecoveryDef::ErrorEdge,
RecoveryPolicy::Retry { policy, .. } => RecoveryDef::Retry {
max_retries: policy.max_retries,
base_delay_ms: u64::try_from(policy.base_delay.as_millis())
.unwrap_or(u64::MAX),
max_delay_ms: u64::try_from(policy.max_delay.as_millis())
.unwrap_or(u64::MAX),
jitter: policy.jitter,
},
}),
merger: node.merger().map(str::to_string),
max_executions: node.max_executions(),
position: None,
}
})
.collect();
let edges = self
.edges
.iter()
.map(|edge| EdgeDef {
from: self
.labels
.get(&edge.source)
.cloned()
.unwrap_or_else(|| edge.source.to_string()),
to: self
.labels
.get(&edge.target)
.cloned()
.unwrap_or_else(|| edge.target.to_string()),
condition: ConditionDef::from_condition(&edge.condition),
max_iterations: edge.max_iterations,
source_port: edge.source_port,
target_port: edge.target_port,
})
.collect();
let entry = self
.entry
.iter()
.map(|id| {
self.labels
.get(id)
.cloned()
.unwrap_or_else(|| id.to_string())
})
.collect();
let static_inputs = self
.static_inputs
.iter()
.filter_map(|(pin, value)| {
value_to_json(value).map(|json| StaticInputDef {
node: self
.labels
.get(&pin.node_id)
.cloned()
.unwrap_or_else(|| pin.node_id.to_string()),
port: pin.port,
value: json,
})
})
.collect();
GraphDef {
version: GRAPH_DEF_VERSION,
nodes,
edges,
entry,
static_inputs,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_condition_def_json_shapes() {
assert_eq!(
serde_json::to_string(&ConditionDef::Always).unwrap(),
"\"always\""
);
assert_eq!(
serde_json::to_string(&ConditionDef::Named("has_more".to_string())).unwrap(),
"{\"named\":\"has_more\"}"
);
assert_eq!(
serde_json::to_string(&ConditionDef::Not(Box::new(ConditionDef::OnError))).unwrap(),
"{\"not\":\"on_error\"}"
);
assert_eq!(
serde_json::to_string(&ConditionDef::Custom {
label: "hand_rolled".to_string()
})
.unwrap(),
"{\"custom\":{\"label\":\"hand_rolled\"}}"
);
}
#[test]
fn test_activation_and_recovery_def_json_shapes() {
assert_eq!(
serde_json::to_string(&ActivationDef::All).unwrap(),
"\"all\""
);
assert_eq!(
serde_json::to_string(&RecoveryDef::FailFast).unwrap(),
"\"fail_fast\""
);
let retry = RecoveryDef::Retry {
max_retries: 2,
base_delay_ms: 100,
max_delay_ms: 1_000,
jitter: 0.1,
};
let json = serde_json::to_string(&retry).unwrap();
assert!(json.starts_with("{\"retry\":{"));
let parsed: RecoveryDef =
serde_json::from_str("{\"retry\": {\"max_retries\": 1}}").unwrap();
assert_eq!(
parsed.to_retry_policy().unwrap().base_delay,
std::time::Duration::from_millis(500)
);
}
#[test]
fn test_graph_def_version_roundtrip_and_mismatch() {
let json = r#"{"version": 1, "nodes": [], "edges": []}"#;
let def = GraphDef::from_json(json).unwrap();
assert_eq!(def.version, GRAPH_DEF_VERSION);
assert!(def.entry.is_empty());
let registry: NodeRegistry<()> = NodeRegistry::new();
let ctx = BspContext::new();
let result = BspGraph::from_def(
&GraphDef {
version: 99,
nodes: Vec::new(),
edges: Vec::new(),
entry: Vec::new(),
static_inputs: Vec::new(),
},
®istry,
&ctx,
);
assert!(matches!(result, Err(BspError::DefError(_))));
}
}