use std::sync::Arc;
use async_trait::async_trait;
use dataflow_rs::engine::error::DataflowError;
use dataflow_rs::engine::functions::AsyncFunctionHandler;
use dataflow_rs::engine::task_context::TaskContext;
use dataflow_rs::engine::task_outcome::TaskOutcome;
use dataflow_rs::{Template, TemplateCompiler};
use serde::Deserialize;
use serde_json::Value;
use super::schema::{FieldKind, FieldSchema};
const META_CALL_DEPTH: &str = "_orion_call_depth";
const META_CALL_CHAIN: &str = "_orion_call_chain";
#[derive(Debug, Deserialize)]
pub struct ChannelCallInput {
#[serde(default, alias = "channel_logic")]
pub channel: Option<Template>,
#[serde(default, alias = "response_path")]
pub output: Option<Template>,
#[serde(default, alias = "data_logic")]
pub data: Option<Template>,
#[serde(default)]
pub timeout_ms: Option<Template>,
}
pub struct ChannelCallHandler {
pub engine: Arc<crate::engine::EngineHandle>,
pub channel_registry: Arc<crate::channel::ChannelRegistry>,
pub max_call_depth: u32,
pub default_timeout_ms: u64,
}
#[async_trait]
impl AsyncFunctionHandler for ChannelCallHandler {
type Input = ChannelCallInput;
fn compile_input(input: &mut Self::Input, c: &TemplateCompiler) -> dataflow_rs::Result<()> {
if let Some(t) = input.channel.as_mut() {
check_channel_shape(t)?;
t.compile(c, "channel_call.channel")?;
}
if let Some(t) = input.data.as_mut() {
t.compile(c, "channel_call.data")?;
}
if let Some(t) = input.timeout_ms.as_mut() {
t.compile(c, "channel_call.timeout_ms")?;
}
if let Some(t) = input.output.as_mut() {
t.compile(c, "channel_call.output")?;
}
Ok(())
}
async fn execute(
&self,
ctx: &mut TaskContext<'_>,
input: &ChannelCallInput,
) -> dataflow_rs::Result<TaskOutcome> {
let target_channel = resolve_target(ctx, input)?;
let label = target_channel.clone();
crate::engine::profile::record("channel_call", Some(&label), async move {
let parent_depth = ctx
.message()
.metadata()
.get(META_CALL_DEPTH)
.and_then(|v| v.as_i64())
.map(|n| n as u64)
.unwrap_or(0);
let parent_chain: Vec<String> = ctx
.message()
.metadata()
.get(META_CALL_CHAIN)
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if parent_depth >= self.max_call_depth as u64 {
return Err(DataflowError::Validation(format!(
"channel_call: max call depth {} exceeded (chain: {})",
self.max_call_depth,
format_chain(&parent_chain, &target_channel),
)));
}
if parent_chain.contains(&target_channel) {
return Err(DataflowError::Validation(format!(
"channel_call: cycle detected: {}",
format_chain(&parent_chain, &target_channel),
)));
}
let call_data: Value = if let Some(ref data) = input.data {
input_json(data, ctx)?
} else {
(&*ctx.message().payload_arc().clone()).into()
};
let child_depth = parent_depth + 1;
let mut child_chain = parent_chain;
child_chain.push(target_channel.clone());
let mut child_meta: Value = ctx.message().metadata().into();
if !child_meta.is_object() {
child_meta = serde_json::json!({});
}
crate::engine::clear_error_context(&mut child_meta);
let calling_channel = child_meta
.get("channel")
.and_then(Value::as_str)
.unwrap_or("channel_call")
.to_string();
child_meta["channel"] = Value::String(target_channel.clone());
child_meta[META_CALL_DEPTH] = serde_json::json!(child_depth);
child_meta[META_CALL_CHAIN] = serde_json::json!(child_chain);
let target_runtime = self
.channel_registry
.require_serviceable(&target_channel)
.map_err(|e| {
DataflowError::function_execution(
format!("channel_call to '{target_channel}': {e}"),
None,
)
})?;
if target_runtime.is_none() {
return Err(DataflowError::function_execution(
format!("channel_call to '{target_channel}': channel not found or not active"),
None,
));
}
let header_lookup = |name: &str| {
child_meta
.get("headers")
.and_then(|h| h.get(name))
.and_then(Value::as_str)
.map(str::to_string)
};
let admission = crate::channel::guards::admit(crate::channel::guards::GuardRequest {
transport: crate::channel::guards::Transport::ChannelCall,
channel: &target_channel,
runtime: &target_runtime,
data: &call_data,
metadata: &child_meta,
datalogic: ctx.datalogic(),
origin: None,
caller_identity: &calling_channel,
header: &header_lookup,
auth_backoff: None,
raw_body: None,
dedup_key_fallback: None,
dedup_owner: None,
default_timeout_ms: Some(self.default_timeout_ms),
max_timeout_ms: None,
})
.await
.map_err(|e| guard_refusal(&target_channel, e))?;
let _backpressure_permit = admission.backpressure_permit;
let task_timeout_ms = match input.timeout_ms.as_ref() {
Some(t) => Some(t.resolve_u64(ctx, "channel_call 'timeout_ms'")?),
None => None,
};
let timeout_ms = task_timeout_ms
.or(admission.timeout_ms)
.unwrap_or(self.default_timeout_ms);
let child = crate::engine::execute_admitted(
&self.engine,
&target_channel,
&call_data,
&child_meta,
crate::engine::ExecOpts {
timeout_ms: Some(timeout_ms),
..Default::default()
},
)
.await;
match child.outcome {
crate::engine::RunOutcome::Ok => {}
crate::engine::RunOutcome::WorkflowErrors(summary) => {
return Err(DataflowError::function_execution(
format!("channel_call to '{target_channel}' failed: {summary}"),
None,
));
}
crate::engine::RunOutcome::EngineError(e) => {
return Err(DataflowError::function_execution(
format!("channel_call to '{target_channel}' failed: {e}"),
None,
));
}
crate::engine::RunOutcome::Timeout(ms) => {
return Err(DataflowError::Timeout(format!(
"channel_call to '{target_channel}' timed out after {ms}ms"
)));
}
}
let result_data_json: Value = child.message.data().into();
let output = match input.output.as_ref() {
Some(t) => t.resolve_string(ctx)?,
None => "data".to_string(),
};
ctx.set_json(&output, &result_data_json);
Ok(TaskOutcome::Success)
})
.await
}
}
pub(super) fn resolve_target(
ctx: &TaskContext<'_>,
input: &ChannelCallInput,
) -> dataflow_rs::Result<String> {
let Some(channel) = input.channel.as_ref() else {
return Err(DataflowError::Validation(
"channel_call requires 'channel'".into(),
));
};
let result: Value = input_json(channel, ctx)?;
let target = result.as_str().ok_or_else(|| {
DataflowError::Validation("channel_call 'channel' must evaluate to a string".to_string())
})?;
if target.is_empty() {
return Err(DataflowError::Validation(
"channel_call: target channel name must not be empty".into(),
));
}
Ok(target.to_string())
}
fn check_channel_shape(channel: &Template) -> dataflow_rs::Result<()> {
match channel.as_json() {
Value::Object(_) | Value::Array(_) | Value::String(_) => Ok(()),
other => Err(DataflowError::Validation(format!(
"channel_call 'channel' must be a channel name or an expression \
producing one, not {other}"
))),
}
}
fn input_json(template: &Template, ctx: &TaskContext<'_>) -> dataflow_rs::Result<Value> {
Ok((&template.resolve(ctx)?).into())
}
fn guard_refusal(target: &str, e: crate::errors::OrionError) -> DataflowError {
let (status, _code, detail) = e.response_parts();
let message = format!("channel_call to '{target}': {detail}");
if status == axum::http::StatusCode::BAD_REQUEST {
DataflowError::Validation(message)
} else {
crate::errors::channel_refused_dataflow_error(status, message)
}
}
fn format_chain(chain: &[String], target: &str) -> String {
let mut parts: Vec<&str> = chain.iter().map(|s| s.as_str()).collect();
parts.push(target);
parts.join(" -> ")
}
pub(super) const CHANNEL_CALL_FIELDS: &[FieldSchema] = &[
FieldSchema {
name: "channel",
description: "Target channel to invoke (JSONLogic), so one task can route by \
message content. (Was `channel_logic`; still accepted, but not \
alongside `channel`.)",
kind: FieldKind::String,
required: true,
template_at: &[""],
alias: Some("channel_logic"),
..FieldSchema::DEFAULT
},
FieldSchema {
name: "data",
description: "Payload to pass to the target channel (JSONLogic). Omit to forward \
the caller's own payload. (Was `data_logic`; still accepted, but not \
alongside `data`.)",
kind: FieldKind::Any,
template_at: &[""],
alias: Some("data_logic"),
..FieldSchema::DEFAULT
},
FieldSchema {
name: "output",
description: "Dotted path where the called channel's response is stored. Defaults to \"data\". (Was `response_path` before 1.0; still accepted.)",
kind: FieldKind::String,
template_at: &[""],
..FieldSchema::DEFAULT
},
FieldSchema {
name: "timeout_ms",
description: "Per-call timeout in milliseconds.",
kind: FieldKind::Number,
template_at: &[""],
..FieldSchema::DEFAULT
},
];