use crate::auth_catalog::AuthCatalog;
use crate::config::{ExecutionSpec, OnError};
use crate::error::{CliError, CliResult};
use crate::expand::{ExpandedNode, NodeRole};
use crate::interpolate::interpolate_record;
use crate::registry::{build_sink, build_source};
use crate::state::build_state_store;
use crate::transforms::compile_transforms;
use async_trait::async_trait;
use chrono::{DateTime, FixedOffset};
use faucet_core::observability::Labels;
use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Mutex, Semaphore};
type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
use tokio_util::sync::CancellationToken;
pub struct ExecuteOptions {
pub pipeline_name: String,
pub execution: Option<ExecutionSpec>,
pub dry_run: bool,
pub limit: Option<usize>,
pub state_path_override: Option<PathBuf>,
pub auth: AuthCatalog,
pub clock: DateTime<FixedOffset>,
pub cancel: Option<CancellationToken>,
}
const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
#[derive(Debug)]
pub struct InvocationOutcome {
pub row_id: String,
pub parent_record_key: Option<String>,
pub records_written: usize,
pub error: Option<String>,
}
#[derive(Debug)]
pub struct RunSummary {
pub invocations: Vec<InvocationOutcome>,
}
impl RunSummary {
pub fn failure_count(&self) -> usize {
self.invocations
.iter()
.filter(|i| i.error.is_some())
.count()
}
pub fn had_failures(&self) -> bool {
self.failure_count() > 0
}
}
fn default_concurrency() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.clamp(1, 8)
}
pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
let on_error = opts
.execution
.as_ref()
.map(|e| e.on_error)
.unwrap_or_default();
let max_concurrent = opts
.execution
.as_ref()
.and_then(|e| e.max_concurrent)
.unwrap_or_else(default_concurrency)
.max(1);
let semaphore = Arc::new(Semaphore::new(max_concurrent));
let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
for n in nodes.iter() {
if let NodeRole::Child { parent_id, .. } = &n.role {
children_of
.entry(parent_id.clone())
.or_default()
.push(n.id.clone());
}
}
let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
let nodes_with_descendants: HashSet<String> = children_of.keys().cloned().collect();
let mut outcomes: Vec<InvocationOutcome> = Vec::new();
let mut skipped_subtrees: HashSet<String> = HashSet::new();
let cancel = opts.cancel.clone().unwrap_or_default();
let opts = Arc::new(opts);
let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
let mut completed: HashSet<String> = HashSet::new();
let nodes_by_id: HashMap<String, ExpandedNode> =
nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
let bfs_order: Vec<String> = {
let mut ids: Vec<(usize, String)> = nodes_by_id
.values()
.map(|n| (n.row_index, n.id.clone()))
.collect();
ids.sort_by_key(|(i, _)| *i);
ids.into_iter().map(|(_, id)| id).collect()
};
while !remaining.is_empty() {
let ready: Vec<String> = bfs_order
.iter()
.filter(|id| remaining.contains(*id))
.filter(|id| match &nodes_by_id[*id].role {
NodeRole::Root => true,
NodeRole::Child { parent_id, .. } => {
completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
}
})
.cloned()
.collect();
if ready.is_empty() {
let mut stuck: Vec<String> = remaining.iter().cloned().collect();
stuck.sort();
return Err(CliError::Internal(format!(
"executor deadlock: {} node(s) never became ready (no completed/skipped parent): {}",
stuck.len(),
stuck.join(", ")
)));
}
let mut units: Vec<Unit> = Vec::new();
let captured_snapshot = captured.lock().await.clone();
for id in &ready {
let node = &nodes_by_id[id];
if let NodeRole::Child { parent_id, .. } = &node.role
&& skipped_subtrees.contains(parent_id)
{
skipped_subtrees.insert(id.clone());
tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
continue;
}
match &node.role {
NodeRole::Root => {
let uses_state = node.state.is_some() || opts.state_path_override.is_some();
let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
validate_unit_state_key(&node.id, uses_state, &state_key)?;
units.push(Unit {
node: node.clone(),
parent_record: None,
state_key,
parent_record_key: None,
});
}
NodeRole::Child {
parent_id,
parent_key,
} => {
let parent_records = captured_snapshot
.get(parent_id)
.cloned()
.unwrap_or_default();
if parent_records.is_empty() {
tracing::info!(
row = %id, parent = %parent_id,
"parent produced no records — child skipped"
);
continue;
}
let uses_state = node.state.is_some() || opts.state_path_override.is_some();
let mut seen_keys: HashSet<String> = HashSet::new();
for record in &parent_records {
let pk_value = resolve_parent_key(record, parent_key);
let pk_string = pk_value
.as_ref()
.map(value_to_string_brief)
.unwrap_or_else(|| "(missing)".to_string());
let state_key =
build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
validate_unit_state_key(&node.id, uses_state, &state_key)?;
if !seen_keys.insert(state_key.clone()) {
return Err(CliError::DuplicateStateKey {
id: node.id.clone(),
state_key,
});
}
units.push(Unit {
node: node.clone(),
parent_record: Some(record.clone()),
state_key,
parent_record_key: Some(pk_string),
});
}
}
}
}
drop(captured_snapshot);
let mut had_level_failure = false;
let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
let level_cancel = cancel.child_token();
let mut joinset = tokio::task::JoinSet::new();
let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
for unit in units {
let sem = Arc::clone(&semaphore);
let opts2 = Arc::clone(&opts);
let captured = Arc::clone(&captured);
let needs_capture = nodes_with_descendants.contains(&unit.node.id);
let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
let unit_cancel = level_cancel.clone();
let handle = joinset.spawn(async move {
let _permit = sem.acquire().await.expect("semaphore not closed");
run_unit(&unit, needs_capture, &captured, &opts2, unit_cancel).await
});
task_meta.insert(handle.id(), meta);
}
let mut stop_triggered = false;
let mut aborted = false;
let mut stop_deadline: Option<tokio::time::Instant> = None;
loop {
let joined = match stop_deadline {
Some(deadline) if !aborted => {
match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
Ok(j) => j,
Err(_) => {
tracing::warn!(
"on_error: stop — flush grace elapsed; aborting remaining \
in-flight invocations"
);
joinset.abort_all();
aborted = true;
continue;
}
}
}
_ => joinset.join_next_with_id().await,
};
let Some(joined) = joined else { break };
let outcome = match joined {
Ok((_id, outcome)) => outcome,
Err(e) if e.is_cancelled() => {
continue;
}
Err(e) => {
let (row_id, parent_record_key) = task_meta
.get(&e.id())
.cloned()
.unwrap_or_else(|| ("<unknown>".to_string(), None));
InvocationOutcome {
row_id,
parent_record_key,
records_written: 0,
error: Some(format!("pipeline invocation task panicked: {e}")),
}
}
};
if let Some(err) = &outcome.error {
tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
had_level_failure = true;
nodes_with_any_failure.insert(outcome.row_id.clone());
if matches!(on_error, OnError::Stop) && !stop_triggered {
stop_triggered = true;
tracing::error!(
"on_error: stop — cancelling in-flight invocations (cooperative \
flush), then aborting any that don't stop within the grace window"
);
level_cancel.cancel();
stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
}
} else {
tracing::info!(
row = %outcome.row_id,
records_written = outcome.records_written,
"pipeline invocation completed"
);
}
outcomes.push(outcome);
}
for id in ready {
remaining.remove(&id);
if nodes_with_any_failure.contains(&id) {
skipped_subtrees.insert(id.clone());
if let Some(children) = children_of.get(&id) {
for cid in children {
skipped_subtrees.insert(cid.clone());
}
}
} else {
completed.insert(id);
}
}
if had_level_failure && matches!(on_error, OnError::Stop) {
tracing::error!("on_error: stop — aborting after first failure");
break;
}
}
Ok(RunSummary {
invocations: outcomes,
})
}
struct Unit {
node: ExpandedNode,
parent_record: Option<Arc<Value>>,
state_key: String,
parent_record_key: Option<String>,
}
async fn run_unit(
unit: &Unit,
needs_capture: bool,
captured: &CapturedRecords,
opts: &ExecuteOptions,
cancel: CancellationToken,
) -> InvocationOutcome {
let result = run_one_invocation(
&unit.node,
unit.parent_record.as_deref(),
&unit.state_key,
needs_capture,
opts,
cancel,
)
.await;
let row_id = unit.node.id.clone();
let parent_record_key = unit.parent_record_key.clone();
match result {
Ok((records, written)) => {
if needs_capture {
captured
.lock()
.await
.entry(row_id.clone())
.or_default()
.extend(records.into_iter().map(Arc::new));
}
InvocationOutcome {
row_id,
parent_record_key,
records_written: written,
error: None,
}
}
Err(e) => InvocationOutcome {
row_id,
parent_record_key,
records_written: 0,
error: Some(e.to_string()),
},
}
}
fn build_state_key(pipeline_name: &str, row_id: &str, parent_key: Option<&str>) -> String {
match parent_key {
None => format!("{pipeline_name}::{row_id}"),
Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
}
}
fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
if uses_state {
faucet_core::state::validate_state_key(state_key).map_err(|e| {
CliError::InvalidStateKey {
id: node_id.to_owned(),
state_key: state_key.to_owned(),
reason: e.to_string(),
}
})?;
}
Ok(())
}
fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
let mut cur = record;
for segment in parent_key.split('.') {
cur = match cur {
Value::Object(m) => m.get(segment)?,
Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(cur.clone())
}
async fn run_one_invocation(
node: &ExpandedNode,
parent_record: Option<&Value>,
state_key: &str,
needs_capture: bool,
opts: &ExecuteOptions,
cancel: CancellationToken,
) -> CliResult<(Vec<Value>, usize)> {
let run_id = uuid::Uuid::now_v7().to_string();
let pipeline_name = opts.pipeline_name.clone();
let row_id = node.id.clone();
let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
let mut source_cfg = node.source.config.clone();
let mut sink_cfg = node.sink.config.clone();
resolve_now_inplace(&mut source_cfg, opts.clock)?;
resolve_now_inplace(&mut sink_cfg, opts.clock)?;
if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
resolve_inplace(&mut source_cfg, &ctx)?;
resolve_inplace(&mut sink_cfg, &ctx)?;
}
let source = build_source(&node.source.kind, source_cfg, &opts.auth).await?;
let raw_sink: Box<dyn Sink> = if opts.dry_run {
Box::new(CountingSink::new())
} else {
build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
};
let raw_sink: Box<dyn Sink> = match opts.limit {
Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
None => raw_sink,
};
let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink: Box<dyn Sink> = if needs_capture {
Box::new(CapturingSink::wrap(raw_sink, Arc::clone(&captured)))
} else {
raw_sink
};
let stages = compile_transforms(&node.transforms)?;
let source: Box<dyn Source> = if stages.is_empty() {
source
} else {
Box::new(faucet_core::TransformingSource::new(
source,
stages,
obs_labels.clone(),
)?)
};
let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
Box::new(StateKeyOverride {
inner: source,
key: state_key.to_owned(),
})
} else {
source
};
let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
.with_name(pipeline_name)
.with_row(row_id)
.with_run_id(run_id);
let pipeline = match state {
Some(store) => pipeline.with_state_store(store),
None => pipeline,
};
let pipeline = if let Some(ref dlq_spec) = node.dlq {
let dlq_cfg = build_dlq_config(dlq_spec).await?;
pipeline.with_dlq(dlq_cfg)
} else {
pipeline
};
let pipeline = pipeline.with_cancel(cancel);
#[cfg(feature = "quality")]
let pipeline = if let Some(ref quality_spec) = node.quality {
let compiled = Arc::new(
faucet_core::CompiledQuality::compile(quality_spec)
.map_err(|e| CliError::Config(format!("quality: {e}")))?,
);
pipeline.with_quality(compiled)
} else {
pipeline
};
let pipeline = if let Some(ab) = opts
.execution
.as_ref()
.and_then(|e| e.adaptive_batch_size.clone())
{
ab.validate()
.map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
pipeline.with_adaptive(ab)
} else {
pipeline
};
let result = pipeline.run().await?;
sink.flush().await?;
let captured = if needs_capture {
std::mem::take(&mut *captured.lock().await)
} else {
Vec::new()
};
Ok((captured, result.records_written))
}
async fn build_state_for_node(
node: &ExpandedNode,
state_path_override: Option<&Path>,
) -> CliResult<Option<Arc<dyn StateStore>>> {
match (&node.state, state_path_override) {
(Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
(None, Some(path)) => Ok(Some(state_from_override(path))),
(Some(spec), Some(path)) => {
if spec.kind == "file" {
Ok(Some(state_from_override(path)))
} else {
tracing::warn!(
state = %spec.kind,
"--state-path is only meaningful for the 'file' backend; ignoring override"
);
Ok(Some(build_state_store(spec).await?))
}
}
(None, None) => Ok(None),
}
}
fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
}
pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
let sink = build_sink(
&spec.sink.kind,
spec.sink.config.clone(),
&AuthCatalog::new(),
)
.await?;
Ok(DlqConfig {
sink: Arc::from(sink),
on_batch_error: match spec.on_batch_error {
crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
},
max_failures_per_page: spec.max_failures_per_page,
max_failures_total: spec.max_failures_total,
include_original_payload: spec.include_original_payload,
})
}
fn resolve_now_inplace(value: &mut Value, clock: DateTime<FixedOffset>) -> CliResult<()> {
match value {
Value::String(s) => {
*s = crate::interpolate::resolve_now(s, clock)?;
Ok(())
}
Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
Value::Object(m) => m
.values_mut()
.try_for_each(|v| resolve_now_inplace(v, clock)),
_ => Ok(()),
}
}
fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
match value {
Value::String(s) => {
let resolved = interpolate_record(s, ctx)?;
*s = resolved;
Ok(())
}
Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
_ => Ok(()),
}
}
struct StateKeyOverride {
inner: Box<dyn Source>,
key: String,
}
#[async_trait]
impl Source for StateKeyOverride {
async fn fetch_with_context(
&self,
ctx: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
self.inner.fetch_with_context(ctx).await
}
async fn fetch_with_context_incremental(
&self,
ctx: &HashMap<String, Value>,
) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
self.inner.fetch_with_context_incremental(ctx).await
}
fn connector_name(&self) -> &'static str {
self.inner.connector_name()
}
fn state_key(&self) -> Option<String> {
Some(self.key.clone())
}
async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
self.inner.apply_start_bookmark(bookmark).await
}
}
struct CapturingSink {
inner: Box<dyn Sink>,
captured: Arc<Mutex<Vec<Value>>>,
}
impl CapturingSink {
fn wrap(inner: Box<dyn Sink>, captured: Arc<Mutex<Vec<Value>>>) -> Self {
Self { inner, captured }
}
}
#[async_trait]
impl Sink for CapturingSink {
fn connector_name(&self) -> &'static str {
self.inner.connector_name()
}
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
let written = self.inner.write_batch(records).await?;
let n = written.min(records.len());
let mut buf = self.captured.lock().await;
buf.extend(records.iter().take(n).cloned());
Ok(written)
}
async fn flush(&self) -> Result<(), FaucetError> {
self.inner.flush().await
}
}
struct LimitedSink {
inner: Box<dyn Sink>,
remaining: AtomicUsize,
}
impl LimitedSink {
fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
Self {
inner,
remaining: AtomicUsize::new(cap),
}
}
}
#[async_trait]
impl Sink for LimitedSink {
fn connector_name(&self) -> &'static str {
self.inner.connector_name()
}
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
let remaining = self.remaining.load(Ordering::Relaxed);
if remaining == 0 {
return Ok(0);
}
let take = remaining.min(records.len());
let slice = &records[..take];
let written = self.inner.write_batch(slice).await?;
self.remaining
.fetch_sub(written.min(remaining), Ordering::Relaxed);
Ok(written)
}
async fn flush(&self) -> Result<(), FaucetError> {
self.inner.flush().await
}
}
struct CountingSink {
seen: AtomicUsize,
}
impl CountingSink {
fn new() -> Self {
Self {
seen: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl Sink for CountingSink {
fn connector_name(&self) -> &'static str {
"dry-run"
}
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.seen.fetch_add(records.len(), Ordering::Relaxed);
Ok(records.len())
}
}
fn value_to_string_brief(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
use crate::expand::expand;
use serde_json::json;
fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
PipelineConfig {
version: 1,
name: Some("test".into()),
vars: None,
auth: None,
pipeline: PipelineSpec {
source: Some(ConnectorSpec {
kind: "csv".into(),
config: json!({"path": input.to_str().unwrap()}),
transforms: None,
inherit_transforms: true,
}),
sink: Some(ConnectorSpec {
kind: "jsonl".into(),
config: json!({"path": output.to_str().unwrap()}),
transforms: None,
inherit_transforms: true,
}),
sources: Default::default(),
sinks: Default::default(),
transforms: Vec::new(),
state: None,
dlq: None,
#[cfg(feature = "quality")]
quality: None,
},
matrix: Vec::new(),
execution: None,
observability: None,
#[cfg(feature = "schedule")]
schedule: None,
}
}
#[tokio::test]
async fn empty_matrix_runs_pipeline_once() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.csv");
let output = dir.path().join("out.jsonl");
std::fs::write(&input, "name\nalice\nbob\n").unwrap();
let cfg = cfg_csv_to_jsonl(&input, &output);
let nodes = expand(&cfg).unwrap();
let summary = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "t".into(),
execution: None,
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.unwrap();
assert_eq!(summary.invocations.len(), 1);
assert_eq!(summary.invocations[0].records_written, 2);
assert!(!summary.had_failures());
let body = std::fs::read_to_string(&output).unwrap();
assert_eq!(body.lines().count(), 2);
}
#[tokio::test]
async fn matrix_two_independent_roots_both_run() {
let dir = tempfile::tempdir().unwrap();
let csv_a = dir.path().join("a.csv");
let csv_b = dir.path().join("b.csv");
let out_a = dir.path().join("a.jsonl");
let out_b = dir.path().join("b.jsonl");
std::fs::write(&csv_a, "name\nalice\n").unwrap();
std::fs::write(&csv_b, "name\nbob\n").unwrap();
let yaml = format!(
r#"version: 1
pipeline:
source: {{ type: csv, config: {{ path: {a} }} }}
sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
matrix:
- id: rowA
- id: rowB
source: {{ config: {{ path: {b} }} }}
sink: {{ config: {{ path: {out_b} }} }}
"#,
a = csv_a.display(),
b = csv_b.display(),
out_a = out_a.display(),
out_b = out_b.display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let summary = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "matrix".into(),
execution: None,
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.unwrap();
assert_eq!(summary.invocations.len(), 2);
assert!(out_a.exists());
assert!(out_b.exists());
}
#[tokio::test]
async fn dag_child_fans_out_per_parent_record() {
let dir = tempfile::tempdir().unwrap();
let parent_csv = dir.path().join("parents.csv");
let child_csv = dir.path().join("child.csv");
std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
let parent_out = dir.path().join("parents.jsonl");
let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
let yaml = format!(
r#"version: 1
pipeline:
source: {{ type: csv, config: {{ path: {parent} }} }}
sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
matrix:
- id: parents
- id: child
parent: parents
source: {{ config: {{ path: {child} }} }}
sink: {{ config: {{ path: "{child_out}" }} }}
"#,
parent = parent_csv.display(),
parent_out = parent_out.display(),
child = child_csv.display(),
child_out = child_out_pattern.display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let summary = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "dagtest".into(),
execution: None,
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.unwrap();
assert_eq!(summary.invocations.len(), 3);
assert!(!summary.had_failures(), "{:?}", summary);
assert!(dir.path().join("child-1.jsonl").exists());
assert!(dir.path().join("child-2.jsonl").exists());
}
#[tokio::test]
async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
let dir = tempfile::tempdir().unwrap();
let good_csv = dir.path().join("good.csv");
std::fs::write(&good_csv, "x\n1\n").unwrap();
let good_out = dir.path().join("good.jsonl");
let bad_sink_dir = dir.path().to_path_buf();
let yaml = format!(
r#"version: 1
pipeline:
source: {{ type: csv, config: {{ path: {good_csv} }} }}
sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
matrix:
- id: bad
sink: {{ config: {{ path: {bad_dir} }} }}
- id: good
execution:
max_concurrent: 1
on_error: stop
"#,
good_csv = good_csv.display(),
good_out = good_out.display(),
bad_dir = bad_sink_dir.display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let summary = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "stoptest".into(),
execution: cfg.execution.clone(),
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.unwrap();
assert!(summary.had_failures(), "the failing root must be reported");
let bad: Vec<_> = summary
.invocations
.iter()
.filter(|o| o.row_id == "bad")
.collect();
assert_eq!(bad.len(), 1, "bad must run exactly once");
assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
assert!(
summary.invocations.len() <= 2,
"at most the two roots may run, got {:?}",
summary.invocations
);
let good_wrote = summary
.invocations
.iter()
.find(|o| o.row_id == "good" && o.error.is_none())
.map(|o| o.records_written)
.unwrap_or(0);
if good_wrote > 0 {
assert!(
good_out.exists(),
"a good that wrote records must have produced its output file"
);
}
}
#[tokio::test]
async fn invalid_pipeline_name_with_state_errors_up_front() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.csv");
let output = dir.path().join("out.jsonl");
std::fs::write(&input, "name\nalice\n").unwrap();
let yaml = format!(
r#"version: 1
pipeline:
source: {{ type: csv, config: {{ path: {input} }} }}
sink: {{ type: jsonl, config: {{ path: {output} }} }}
state: {{ type: memory }}
"#,
input = input.display(),
output = output.display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let err = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "bad name".into(), execution: None,
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.expect_err("an invalid pipeline name must be rejected up front when state is configured");
assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
}
#[tokio::test]
async fn invalid_parent_key_value_with_state_errors_up_front() {
let dir = tempfile::tempdir().unwrap();
let parent_csv = dir.path().join("parents.csv");
let child_csv = dir.path().join("child.csv");
std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
std::fs::write(&child_csv, "x\nA\n").unwrap();
let parent_out = dir.path().join("parents.jsonl");
let child_out = dir.path().join("child.jsonl");
let yaml = format!(
r#"version: 1
pipeline:
source: {{ type: csv, config: {{ path: {parent} }} }}
sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
state: {{ type: memory }}
matrix:
- id: parents
- id: child
parent: parents
source: {{ config: {{ path: {child} }} }}
sink: {{ config: {{ path: {child_out} }} }}
"#,
parent = parent_csv.display(),
parent_out = parent_out.display(),
child = child_csv.display(),
child_out = child_out.display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let err = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "ok".into(),
execution: None,
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.expect_err(
"an illegal parent-key value must be rejected up front when state is configured",
);
assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
}
#[tokio::test]
async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
let dir = tempfile::tempdir().unwrap();
let bad_sink_dir = dir.path().to_path_buf();
let good_csv = dir.path().join("good.csv");
std::fs::write(&good_csv, "x\n1\n").unwrap();
let yaml = format!(
r#"version: 1
pipeline:
source: {{ type: csv, config: {{ path: {good_csv} }} }}
sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
matrix:
- id: bad
- id: good_a
- id: good_b
execution:
max_concurrent: 3
on_error: stop
"#,
good_csv = good_csv.display(),
bad_dir = bad_sink_dir.display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let summary = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "stop_parallel".into(),
execution: cfg.execution.clone(),
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.unwrap();
assert!(
summary.had_failures(),
"summary should record at least one failure: {summary:?}"
);
assert!(
summary.invocations[0].error.is_some(),
"first outcome must be the failure that triggered stop: {summary:?}"
);
for inv in &summary.invocations {
assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
}
}
#[tokio::test]
async fn on_error_continue_skips_failed_subtree_only() {
let dir = tempfile::tempdir().unwrap();
let good_csv = dir.path().join("good.csv");
std::fs::write(&good_csv, "x\n1\n").unwrap();
let good_out = dir.path().join("good.jsonl");
let yaml = format!(
r#"version: 1
pipeline:
source: {{ type: csv, config: {{ path: {good_csv} }} }}
sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
matrix:
- id: bad
sink: {{ config: {{ path: {bad_dir} }} }}
- id: good
"#,
good_csv = good_csv.display(),
good_out = good_out.display(),
bad_dir = dir.path().display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let summary = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "continuetest".into(),
execution: None,
dry_run: false,
limit: None,
state_path_override: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
},
)
.await
.unwrap();
assert_eq!(summary.invocations.len(), 2);
assert_eq!(summary.failure_count(), 1);
let good_outcome = summary
.invocations
.iter()
.find(|i| i.row_id == "good")
.unwrap();
assert!(good_outcome.error.is_none());
}
}