1use crate::auth_catalog::AuthCatalog;
21use crate::config::{ExecutionSpec, OnError};
22use crate::error::{CliError, CliResult};
23use crate::expand::{ExpandedNode, NodeRole};
24use crate::interpolate::interpolate_record;
25use crate::registry::{build_sink, build_source};
26use crate::state::build_state_store;
27use crate::transforms::compile_transforms;
28use async_trait::async_trait;
29use chrono::{DateTime, FixedOffset};
30use faucet_core::observability::Labels;
31use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
32use serde_json::Value;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36use std::sync::atomic::{AtomicUsize, Ordering};
37use std::time::Duration;
38use tokio::sync::{Mutex, Semaphore};
39
40type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
44use tokio_util::sync::CancellationToken;
45
46pub struct ExecuteOptions {
48 pub pipeline_name: String,
51 pub execution: Option<ExecutionSpec>,
54 pub dry_run: bool,
56 pub limit: Option<usize>,
58 pub state_path_override: Option<PathBuf>,
60 pub auth: AuthCatalog,
64 pub clock: DateTime<FixedOffset>,
68 pub cancel: Option<CancellationToken>,
74}
75
76const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
81
82#[derive(Debug)]
84pub struct InvocationOutcome {
85 pub row_id: String,
86 pub parent_record_key: Option<String>,
89 pub records_written: usize,
90 pub error: Option<String>,
91}
92
93#[derive(Debug)]
95pub struct RunSummary {
96 pub invocations: Vec<InvocationOutcome>,
97}
98
99impl RunSummary {
100 pub fn failure_count(&self) -> usize {
101 self.invocations
102 .iter()
103 .filter(|i| i.error.is_some())
104 .count()
105 }
106 pub fn had_failures(&self) -> bool {
107 self.failure_count() > 0
108 }
109}
110
111fn default_concurrency() -> usize {
122 std::thread::available_parallelism()
123 .map(|n| n.get())
124 .unwrap_or(4)
125 .clamp(1, 8)
126}
127
128pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
131 let on_error = opts
132 .execution
133 .as_ref()
134 .map(|e| e.on_error)
135 .unwrap_or_default();
136 let max_concurrent = opts
137 .execution
138 .as_ref()
139 .and_then(|e| e.max_concurrent)
140 .unwrap_or_else(default_concurrency)
141 .max(1);
142 let semaphore = Arc::new(Semaphore::new(max_concurrent));
143
144 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
149 for n in nodes.iter() {
150 if let NodeRole::Child { parent_id, .. } = &n.role {
151 children_of
152 .entry(parent_id.clone())
153 .or_default()
154 .push(n.id.clone());
155 }
156 }
157
158 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
163 let nodes_with_descendants: HashSet<String> = children_of.keys().cloned().collect();
164
165 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
166 let mut skipped_subtrees: HashSet<String> = HashSet::new();
167
168 let cancel = opts.cancel.clone().unwrap_or_default();
173 let opts = Arc::new(opts);
174
175 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
179 let mut completed: HashSet<String> = HashSet::new();
180 let nodes_by_id: HashMap<String, ExpandedNode> =
181 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
182
183 let bfs_order: Vec<String> = {
187 let mut ids: Vec<(usize, String)> = nodes_by_id
188 .values()
189 .map(|n| (n.row_index, n.id.clone()))
190 .collect();
191 ids.sort_by_key(|(i, _)| *i);
192 ids.into_iter().map(|(_, id)| id).collect()
193 };
194
195 while !remaining.is_empty() {
196 let ready: Vec<String> = bfs_order
199 .iter()
200 .filter(|id| remaining.contains(*id))
201 .filter(|id| match &nodes_by_id[*id].role {
202 NodeRole::Root => true,
203 NodeRole::Child { parent_id, .. } => {
204 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
205 }
206 })
207 .cloned()
208 .collect();
209
210 if ready.is_empty() {
211 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
216 stuck.sort();
217 return Err(CliError::Internal(format!(
218 "executor deadlock: {} node(s) never became ready (no completed/skipped parent): {}",
219 stuck.len(),
220 stuck.join(", ")
221 )));
222 }
223
224 let mut units: Vec<Unit> = Vec::new();
227 let captured_snapshot = captured.lock().await.clone();
228 for id in &ready {
229 let node = &nodes_by_id[id];
230 if let NodeRole::Child { parent_id, .. } = &node.role
233 && skipped_subtrees.contains(parent_id)
234 {
235 skipped_subtrees.insert(id.clone());
236 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
237 continue;
238 }
239 match &node.role {
240 NodeRole::Root => {
241 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
242 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
243 validate_unit_state_key(&node.id, uses_state, &state_key)?;
244 units.push(Unit {
245 node: node.clone(),
246 parent_record: None,
247 state_key,
248 parent_record_key: None,
249 });
250 }
251 NodeRole::Child {
252 parent_id,
253 parent_key,
254 } => {
255 let parent_records = captured_snapshot
256 .get(parent_id)
257 .cloned()
258 .unwrap_or_default();
259 if parent_records.is_empty() {
260 tracing::info!(
261 row = %id, parent = %parent_id,
262 "parent produced no records — child skipped"
263 );
264 continue;
265 }
266 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
268 let mut seen_keys: HashSet<String> = HashSet::new();
269 for record in &parent_records {
270 let pk_value = resolve_parent_key(record, parent_key);
271 let pk_string = pk_value
272 .as_ref()
273 .map(value_to_string_brief)
274 .unwrap_or_else(|| "(missing)".to_string());
275 let state_key =
276 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
277 validate_unit_state_key(&node.id, uses_state, &state_key)?;
278 if !seen_keys.insert(state_key.clone()) {
279 return Err(CliError::DuplicateStateKey {
280 id: node.id.clone(),
281 state_key,
282 });
283 }
284 units.push(Unit {
285 node: node.clone(),
286 parent_record: Some(record.clone()),
287 state_key,
288 parent_record_key: Some(pk_string),
289 });
290 }
291 }
292 }
293 }
294 drop(captured_snapshot);
295
296 let mut had_level_failure = false;
297 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
298
299 let level_cancel = cancel.child_token();
311 let mut joinset = tokio::task::JoinSet::new();
312 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
316 for unit in units {
317 let sem = Arc::clone(&semaphore);
318 let opts2 = Arc::clone(&opts);
319 let captured = Arc::clone(&captured);
320 let needs_capture = nodes_with_descendants.contains(&unit.node.id);
321 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
322 let unit_cancel = level_cancel.clone();
323 let handle = joinset.spawn(async move {
324 let _permit = sem.acquire().await.expect("semaphore not closed");
325 run_unit(&unit, needs_capture, &captured, &opts2, unit_cancel).await
326 });
327 task_meta.insert(handle.id(), meta);
328 }
329
330 let mut stop_triggered = false;
331 let mut aborted = false;
332 let mut stop_deadline: Option<tokio::time::Instant> = None;
333 loop {
334 let joined = match stop_deadline {
339 Some(deadline) if !aborted => {
340 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
341 Ok(j) => j,
342 Err(_) => {
343 tracing::warn!(
344 "on_error: stop — flush grace elapsed; aborting remaining \
345 in-flight invocations"
346 );
347 joinset.abort_all();
348 aborted = true;
349 continue;
350 }
351 }
352 }
353 _ => joinset.join_next_with_id().await,
354 };
355 let Some(joined) = joined else { break };
356 let outcome = match joined {
360 Ok((_id, outcome)) => outcome,
361 Err(e) if e.is_cancelled() => {
362 continue;
365 }
366 Err(e) => {
367 let (row_id, parent_record_key) = task_meta
368 .get(&e.id())
369 .cloned()
370 .unwrap_or_else(|| ("<unknown>".to_string(), None));
371 InvocationOutcome {
372 row_id,
373 parent_record_key,
374 records_written: 0,
375 error: Some(format!("pipeline invocation task panicked: {e}")),
376 }
377 }
378 };
379
380 if let Some(err) = &outcome.error {
381 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
382 had_level_failure = true;
383 nodes_with_any_failure.insert(outcome.row_id.clone());
384 if matches!(on_error, OnError::Stop) && !stop_triggered {
385 stop_triggered = true;
386 tracing::error!(
387 "on_error: stop — cancelling in-flight invocations (cooperative \
388 flush), then aborting any that don't stop within the grace window"
389 );
390 level_cancel.cancel();
394 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
395 }
396 } else {
397 tracing::info!(
398 row = %outcome.row_id,
399 records_written = outcome.records_written,
400 "pipeline invocation completed"
401 );
402 }
403 outcomes.push(outcome);
404 }
405
406 for id in ready {
410 remaining.remove(&id);
411 if nodes_with_any_failure.contains(&id) {
412 skipped_subtrees.insert(id.clone());
413 if let Some(children) = children_of.get(&id) {
415 for cid in children {
416 skipped_subtrees.insert(cid.clone());
417 }
418 }
419 } else {
420 completed.insert(id);
421 }
422 }
423
424 if had_level_failure && matches!(on_error, OnError::Stop) {
425 tracing::error!("on_error: stop — aborting after first failure");
426 break;
428 }
429 }
430
431 Ok(RunSummary {
432 invocations: outcomes,
433 })
434}
435
436struct Unit {
439 node: ExpandedNode,
440 parent_record: Option<Arc<Value>>,
441 state_key: String,
442 parent_record_key: Option<String>,
443}
444
445async fn run_unit(
446 unit: &Unit,
447 needs_capture: bool,
448 captured: &CapturedRecords,
449 opts: &ExecuteOptions,
450 cancel: CancellationToken,
451) -> InvocationOutcome {
452 let result = run_one_invocation(
453 &unit.node,
454 unit.parent_record.as_deref(),
455 &unit.state_key,
456 needs_capture,
457 opts,
458 cancel,
459 )
460 .await;
461 let row_id = unit.node.id.clone();
462 let parent_record_key = unit.parent_record_key.clone();
463 match result {
464 Ok((records, written)) => {
465 if needs_capture {
466 captured
467 .lock()
468 .await
469 .entry(row_id.clone())
470 .or_default()
471 .extend(records.into_iter().map(Arc::new));
474 }
475 InvocationOutcome {
476 row_id,
477 parent_record_key,
478 records_written: written,
479 error: None,
480 }
481 }
482 Err(e) => InvocationOutcome {
483 row_id,
484 parent_record_key,
485 records_written: 0,
486 error: Some(e.to_string()),
487 },
488 }
489}
490
491fn build_state_key(pipeline_name: &str, row_id: &str, parent_key: Option<&str>) -> String {
493 match parent_key {
494 None => format!("{pipeline_name}::{row_id}"),
495 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
496 }
497}
498
499fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
504 if uses_state {
505 faucet_core::state::validate_state_key(state_key).map_err(|e| {
506 CliError::InvalidStateKey {
507 id: node_id.to_owned(),
508 state_key: state_key.to_owned(),
509 reason: e.to_string(),
510 }
511 })?;
512 }
513 Ok(())
514}
515
516fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
518 let mut cur = record;
519 for segment in parent_key.split('.') {
520 cur = match cur {
521 Value::Object(m) => m.get(segment)?,
522 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
523 _ => return None,
524 };
525 }
526 Some(cur.clone())
527}
528
529async fn run_one_invocation(
531 node: &ExpandedNode,
532 parent_record: Option<&Value>,
533 state_key: &str,
534 needs_capture: bool,
535 opts: &ExecuteOptions,
536 cancel: CancellationToken,
537) -> CliResult<(Vec<Value>, usize)> {
538 let run_id = uuid::Uuid::now_v7().to_string();
541 let pipeline_name = opts.pipeline_name.clone();
542 let row_id = node.id.clone();
543 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
544 let mut source_cfg = node.source.config.clone();
546 let mut sink_cfg = node.sink.config.clone();
547
548 resolve_now_inplace(&mut source_cfg, opts.clock)?;
551 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
552
553 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
554 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
555 resolve_inplace(&mut source_cfg, &ctx)?;
556 resolve_inplace(&mut sink_cfg, &ctx)?;
557 }
558
559 let source = build_source(&node.source.kind, source_cfg, &opts.auth).await?;
561 let raw_sink: Box<dyn Sink> = if opts.dry_run {
562 Box::new(CountingSink::new())
563 } else {
564 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
565 };
566 let raw_sink: Box<dyn Sink> = match opts.limit {
567 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
568 None => raw_sink,
569 };
570 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
571 let sink: Box<dyn Sink> = if needs_capture {
572 Box::new(CapturingSink::wrap(raw_sink, Arc::clone(&captured)))
573 } else {
574 raw_sink
575 };
576
577 let stages = compile_transforms(&node.transforms)?;
579 let source: Box<dyn Source> = if stages.is_empty() {
580 source
581 } else {
582 Box::new(faucet_core::TransformingSource::new(
583 source,
584 stages,
585 obs_labels.clone(),
586 )?)
587 };
588
589 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
593 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
594 Box::new(StateKeyOverride {
595 inner: source,
596 key: state_key.to_owned(),
597 })
598 } else {
599 source
600 };
601
602 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
604 .with_name(pipeline_name)
605 .with_row(row_id)
606 .with_run_id(run_id);
607 let pipeline = match state {
608 Some(store) => pipeline.with_state_store(store),
609 None => pipeline,
610 };
611 let pipeline = if let Some(ref dlq_spec) = node.dlq {
612 let dlq_cfg = build_dlq_config(dlq_spec).await?;
613 pipeline.with_dlq(dlq_cfg)
614 } else {
615 pipeline
616 };
617 let pipeline = pipeline.with_cancel(cancel);
620 #[cfg(feature = "quality")]
624 let pipeline = if let Some(ref quality_spec) = node.quality {
625 let compiled = Arc::new(
626 faucet_core::CompiledQuality::compile(quality_spec)
627 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
628 );
629 pipeline.with_quality(compiled)
630 } else {
631 pipeline
632 };
633 let pipeline = if let Some(ab) = opts
635 .execution
636 .as_ref()
637 .and_then(|e| e.adaptive_batch_size.clone())
638 {
639 ab.validate()
640 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
641 pipeline.with_adaptive(ab)
642 } else {
643 pipeline
644 };
645 let result = pipeline.run().await?;
646 sink.flush().await?;
647
648 let captured = if needs_capture {
649 std::mem::take(&mut *captured.lock().await)
650 } else {
651 Vec::new()
652 };
653 Ok((captured, result.records_written))
654}
655
656async fn build_state_for_node(
657 node: &ExpandedNode,
658 state_path_override: Option<&Path>,
659) -> CliResult<Option<Arc<dyn StateStore>>> {
660 match (&node.state, state_path_override) {
661 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
662 (None, Some(path)) => Ok(Some(state_from_override(path))),
663 (Some(spec), Some(path)) => {
664 if spec.kind == "file" {
665 Ok(Some(state_from_override(path)))
666 } else {
667 tracing::warn!(
668 state = %spec.kind,
669 "--state-path is only meaningful for the 'file' backend; ignoring override"
670 );
671 Ok(Some(build_state_store(spec).await?))
672 }
673 }
674 (None, None) => Ok(None),
675 }
676}
677
678fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
679 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
680}
681
682pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
685 let sink = build_sink(
688 &spec.sink.kind,
689 spec.sink.config.clone(),
690 &AuthCatalog::new(),
691 )
692 .await?;
693 Ok(DlqConfig {
694 sink: Arc::from(sink),
695 on_batch_error: match spec.on_batch_error {
696 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
697 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
698 },
699 max_failures_per_page: spec.max_failures_per_page,
700 max_failures_total: spec.max_failures_total,
701 include_original_payload: spec.include_original_payload,
702 })
703}
704
705fn resolve_now_inplace(value: &mut Value, clock: DateTime<FixedOffset>) -> CliResult<()> {
708 match value {
709 Value::String(s) => {
710 *s = crate::interpolate::resolve_now(s, clock)?;
711 Ok(())
712 }
713 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
714 Value::Object(m) => m
715 .values_mut()
716 .try_for_each(|v| resolve_now_inplace(v, clock)),
717 _ => Ok(()),
718 }
719}
720
721fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
725 match value {
726 Value::String(s) => {
727 let resolved = interpolate_record(s, ctx)?;
728 *s = resolved;
729 Ok(())
730 }
731 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
732 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
733 _ => Ok(()),
734 }
735}
736
737struct StateKeyOverride {
743 inner: Box<dyn Source>,
744 key: String,
745}
746
747#[async_trait]
748impl Source for StateKeyOverride {
749 async fn fetch_with_context(
750 &self,
751 ctx: &HashMap<String, Value>,
752 ) -> Result<Vec<Value>, FaucetError> {
753 self.inner.fetch_with_context(ctx).await
754 }
755 async fn fetch_with_context_incremental(
756 &self,
757 ctx: &HashMap<String, Value>,
758 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
759 self.inner.fetch_with_context_incremental(ctx).await
760 }
761 fn connector_name(&self) -> &'static str {
762 self.inner.connector_name()
763 }
764 fn state_key(&self) -> Option<String> {
765 Some(self.key.clone())
766 }
767 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
768 self.inner.apply_start_bookmark(bookmark).await
769 }
770}
771
772struct CapturingSink {
775 inner: Box<dyn Sink>,
776 captured: Arc<Mutex<Vec<Value>>>,
777}
778
779impl CapturingSink {
780 fn wrap(inner: Box<dyn Sink>, captured: Arc<Mutex<Vec<Value>>>) -> Self {
781 Self { inner, captured }
782 }
783}
784
785#[async_trait]
786impl Sink for CapturingSink {
787 fn connector_name(&self) -> &'static str {
788 self.inner.connector_name()
789 }
790 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
791 let written = self.inner.write_batch(records).await?;
792 let n = written.min(records.len());
794 let mut buf = self.captured.lock().await;
795 buf.extend(records.iter().take(n).cloned());
796 Ok(written)
797 }
798 async fn flush(&self) -> Result<(), FaucetError> {
799 self.inner.flush().await
800 }
801}
802
803struct LimitedSink {
806 inner: Box<dyn Sink>,
807 remaining: AtomicUsize,
808}
809
810impl LimitedSink {
811 fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
812 Self {
813 inner,
814 remaining: AtomicUsize::new(cap),
815 }
816 }
817}
818
819#[async_trait]
820impl Sink for LimitedSink {
821 fn connector_name(&self) -> &'static str {
822 self.inner.connector_name()
823 }
824 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
825 let remaining = self.remaining.load(Ordering::Relaxed);
826 if remaining == 0 {
827 return Ok(0);
828 }
829 let take = remaining.min(records.len());
830 let slice = &records[..take];
831 let written = self.inner.write_batch(slice).await?;
832 self.remaining
833 .fetch_sub(written.min(remaining), Ordering::Relaxed);
834 Ok(written)
835 }
836 async fn flush(&self) -> Result<(), FaucetError> {
837 self.inner.flush().await
838 }
839}
840
841struct CountingSink {
844 seen: AtomicUsize,
845}
846
847impl CountingSink {
848 fn new() -> Self {
849 Self {
850 seen: AtomicUsize::new(0),
851 }
852 }
853}
854
855#[async_trait]
856impl Sink for CountingSink {
857 fn connector_name(&self) -> &'static str {
858 "dry-run"
859 }
860 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
861 self.seen.fetch_add(records.len(), Ordering::Relaxed);
862 Ok(records.len())
863 }
864}
865
866fn value_to_string_brief(v: &Value) -> String {
869 match v {
870 Value::String(s) => s.clone(),
871 other => other.to_string(),
872 }
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
879 use crate::expand::expand;
880 use serde_json::json;
881
882 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
883 PipelineConfig {
884 version: 1,
885 name: Some("test".into()),
886 vars: None,
887 auth: None,
888 pipeline: PipelineSpec {
889 source: Some(ConnectorSpec {
890 kind: "csv".into(),
891 config: json!({"path": input.to_str().unwrap()}),
892 transforms: None,
893 inherit_transforms: true,
894 }),
895 sink: Some(ConnectorSpec {
896 kind: "jsonl".into(),
897 config: json!({"path": output.to_str().unwrap()}),
898 transforms: None,
899 inherit_transforms: true,
900 }),
901 sources: Default::default(),
902 sinks: Default::default(),
903 transforms: Vec::new(),
904 state: None,
905 dlq: None,
906 #[cfg(feature = "quality")]
907 quality: None,
908 },
909 matrix: Vec::new(),
910 execution: None,
911 observability: None,
912 #[cfg(feature = "schedule")]
913 schedule: None,
914 }
915 }
916
917 #[tokio::test]
918 async fn empty_matrix_runs_pipeline_once() {
919 let dir = tempfile::tempdir().unwrap();
920 let input = dir.path().join("in.csv");
921 let output = dir.path().join("out.jsonl");
922 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
923 let cfg = cfg_csv_to_jsonl(&input, &output);
924 let nodes = expand(&cfg).unwrap();
925 let summary = run_expanded(
926 nodes,
927 ExecuteOptions {
928 pipeline_name: "t".into(),
929 execution: None,
930 dry_run: false,
931 limit: None,
932 state_path_override: None,
933 auth: Default::default(),
934 clock: chrono::Utc::now().fixed_offset(),
935 cancel: None,
936 },
937 )
938 .await
939 .unwrap();
940 assert_eq!(summary.invocations.len(), 1);
941 assert_eq!(summary.invocations[0].records_written, 2);
942 assert!(!summary.had_failures());
943 let body = std::fs::read_to_string(&output).unwrap();
944 assert_eq!(body.lines().count(), 2);
945 }
946
947 #[tokio::test]
948 async fn matrix_two_independent_roots_both_run() {
949 let dir = tempfile::tempdir().unwrap();
951 let csv_a = dir.path().join("a.csv");
952 let csv_b = dir.path().join("b.csv");
953 let out_a = dir.path().join("a.jsonl");
954 let out_b = dir.path().join("b.jsonl");
955 std::fs::write(&csv_a, "name\nalice\n").unwrap();
956 std::fs::write(&csv_b, "name\nbob\n").unwrap();
957
958 let yaml = format!(
959 r#"version: 1
960pipeline:
961 source: {{ type: csv, config: {{ path: {a} }} }}
962 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
963matrix:
964 - id: rowA
965 - id: rowB
966 source: {{ config: {{ path: {b} }} }}
967 sink: {{ config: {{ path: {out_b} }} }}
968"#,
969 a = csv_a.display(),
970 b = csv_b.display(),
971 out_a = out_a.display(),
972 out_b = out_b.display(),
973 );
974 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
975 let nodes = expand(&cfg).unwrap();
976 let summary = run_expanded(
977 nodes,
978 ExecuteOptions {
979 pipeline_name: "matrix".into(),
980 execution: None,
981 dry_run: false,
982 limit: None,
983 state_path_override: None,
984 auth: Default::default(),
985 clock: chrono::Utc::now().fixed_offset(),
986 cancel: None,
987 },
988 )
989 .await
990 .unwrap();
991 assert_eq!(summary.invocations.len(), 2);
992 assert!(out_a.exists());
993 assert!(out_b.exists());
994 }
995
996 #[tokio::test]
997 async fn dag_child_fans_out_per_parent_record() {
998 let dir = tempfile::tempdir().unwrap();
1001 let parent_csv = dir.path().join("parents.csv");
1002 let child_csv = dir.path().join("child.csv");
1003 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
1004 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
1005 let parent_out = dir.path().join("parents.jsonl");
1006 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
1007
1008 let yaml = format!(
1009 r#"version: 1
1010pipeline:
1011 source: {{ type: csv, config: {{ path: {parent} }} }}
1012 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
1013matrix:
1014 - id: parents
1015 - id: child
1016 parent: parents
1017 source: {{ config: {{ path: {child} }} }}
1018 sink: {{ config: {{ path: "{child_out}" }} }}
1019"#,
1020 parent = parent_csv.display(),
1021 parent_out = parent_out.display(),
1022 child = child_csv.display(),
1023 child_out = child_out_pattern.display(),
1024 );
1025 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1026 let nodes = expand(&cfg).unwrap();
1027 let summary = run_expanded(
1028 nodes,
1029 ExecuteOptions {
1030 pipeline_name: "dagtest".into(),
1031 execution: None,
1032 dry_run: false,
1033 limit: None,
1034 state_path_override: None,
1035 auth: Default::default(),
1036 clock: chrono::Utc::now().fixed_offset(),
1037 cancel: None,
1038 },
1039 )
1040 .await
1041 .unwrap();
1042
1043 assert_eq!(summary.invocations.len(), 3);
1045 assert!(!summary.had_failures(), "{:?}", summary);
1046 assert!(dir.path().join("child-1.jsonl").exists());
1047 assert!(dir.path().join("child-2.jsonl").exists());
1048 }
1049
1050 #[tokio::test]
1051 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
1052 let dir = tempfile::tempdir().unwrap();
1064 let good_csv = dir.path().join("good.csv");
1065 std::fs::write(&good_csv, "x\n1\n").unwrap();
1066 let good_out = dir.path().join("good.jsonl");
1067 let bad_sink_dir = dir.path().to_path_buf();
1068
1069 let yaml = format!(
1070 r#"version: 1
1071pipeline:
1072 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1073 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
1074matrix:
1075 - id: bad
1076 sink: {{ config: {{ path: {bad_dir} }} }}
1077 - id: good
1078execution:
1079 max_concurrent: 1
1080 on_error: stop
1081"#,
1082 good_csv = good_csv.display(),
1083 good_out = good_out.display(),
1084 bad_dir = bad_sink_dir.display(),
1085 );
1086 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1087 let nodes = expand(&cfg).unwrap();
1088 let summary = run_expanded(
1089 nodes,
1090 ExecuteOptions {
1091 pipeline_name: "stoptest".into(),
1092 execution: cfg.execution.clone(),
1093 dry_run: false,
1094 limit: None,
1095 state_path_override: None,
1096 auth: Default::default(),
1097 clock: chrono::Utc::now().fixed_offset(),
1098 cancel: None,
1099 },
1100 )
1101 .await
1102 .unwrap();
1103
1104 assert!(summary.had_failures(), "the failing root must be reported");
1106
1107 let bad: Vec<_> = summary
1109 .invocations
1110 .iter()
1111 .filter(|o| o.row_id == "bad")
1112 .collect();
1113 assert_eq!(bad.len(), 1, "bad must run exactly once");
1114 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
1115
1116 assert!(
1118 summary.invocations.len() <= 2,
1119 "at most the two roots may run, got {:?}",
1120 summary.invocations
1121 );
1122
1123 let good_wrote = summary
1130 .invocations
1131 .iter()
1132 .find(|o| o.row_id == "good" && o.error.is_none())
1133 .map(|o| o.records_written)
1134 .unwrap_or(0);
1135 if good_wrote > 0 {
1136 assert!(
1137 good_out.exists(),
1138 "a good that wrote records must have produced its output file"
1139 );
1140 }
1141 }
1142
1143 #[tokio::test]
1144 async fn invalid_pipeline_name_with_state_errors_up_front() {
1145 let dir = tempfile::tempdir().unwrap();
1149 let input = dir.path().join("in.csv");
1150 let output = dir.path().join("out.jsonl");
1151 std::fs::write(&input, "name\nalice\n").unwrap();
1152 let yaml = format!(
1153 r#"version: 1
1154pipeline:
1155 source: {{ type: csv, config: {{ path: {input} }} }}
1156 sink: {{ type: jsonl, config: {{ path: {output} }} }}
1157 state: {{ type: memory }}
1158"#,
1159 input = input.display(),
1160 output = output.display(),
1161 );
1162 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1163 let nodes = expand(&cfg).unwrap();
1164 let err = run_expanded(
1165 nodes,
1166 ExecuteOptions {
1167 pipeline_name: "bad name".into(), execution: None,
1169 dry_run: false,
1170 limit: None,
1171 state_path_override: None,
1172 auth: Default::default(),
1173 clock: chrono::Utc::now().fixed_offset(),
1174 cancel: None,
1175 },
1176 )
1177 .await
1178 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
1179 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1180 }
1181
1182 #[tokio::test]
1183 async fn invalid_parent_key_value_with_state_errors_up_front() {
1184 let dir = tempfile::tempdir().unwrap();
1187 let parent_csv = dir.path().join("parents.csv");
1188 let child_csv = dir.path().join("child.csv");
1189 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
1191 std::fs::write(&child_csv, "x\nA\n").unwrap();
1192 let parent_out = dir.path().join("parents.jsonl");
1193 let child_out = dir.path().join("child.jsonl");
1194 let yaml = format!(
1195 r#"version: 1
1196pipeline:
1197 source: {{ type: csv, config: {{ path: {parent} }} }}
1198 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
1199 state: {{ type: memory }}
1200matrix:
1201 - id: parents
1202 - id: child
1203 parent: parents
1204 source: {{ config: {{ path: {child} }} }}
1205 sink: {{ config: {{ path: {child_out} }} }}
1206"#,
1207 parent = parent_csv.display(),
1208 parent_out = parent_out.display(),
1209 child = child_csv.display(),
1210 child_out = child_out.display(),
1211 );
1212 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1213 let nodes = expand(&cfg).unwrap();
1214 let err = run_expanded(
1215 nodes,
1216 ExecuteOptions {
1217 pipeline_name: "ok".into(),
1218 execution: None,
1219 dry_run: false,
1220 limit: None,
1221 state_path_override: None,
1222 auth: Default::default(),
1223 clock: chrono::Utc::now().fixed_offset(),
1224 cancel: None,
1225 },
1226 )
1227 .await
1228 .expect_err(
1229 "an illegal parent-key value must be rejected up front when state is configured",
1230 );
1231 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1232 }
1233
1234 #[tokio::test]
1235 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
1236 let dir = tempfile::tempdir().unwrap();
1245 let bad_sink_dir = dir.path().to_path_buf();
1246 let good_csv = dir.path().join("good.csv");
1249 std::fs::write(&good_csv, "x\n1\n").unwrap();
1250 let yaml = format!(
1256 r#"version: 1
1257pipeline:
1258 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1259 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
1260matrix:
1261 - id: bad
1262 - id: good_a
1263 - id: good_b
1264execution:
1265 max_concurrent: 3
1266 on_error: stop
1267"#,
1268 good_csv = good_csv.display(),
1269 bad_dir = bad_sink_dir.display(),
1270 );
1271 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1272 let nodes = expand(&cfg).unwrap();
1273 let summary = run_expanded(
1274 nodes,
1275 ExecuteOptions {
1276 pipeline_name: "stop_parallel".into(),
1277 execution: cfg.execution.clone(),
1278 dry_run: false,
1279 limit: None,
1280 state_path_override: None,
1281 auth: Default::default(),
1282 clock: chrono::Utc::now().fixed_offset(),
1283 cancel: None,
1284 },
1285 )
1286 .await
1287 .unwrap();
1288
1289 assert!(
1294 summary.had_failures(),
1295 "summary should record at least one failure: {summary:?}"
1296 );
1297 assert!(
1298 summary.invocations[0].error.is_some(),
1299 "first outcome must be the failure that triggered stop: {summary:?}"
1300 );
1301 for inv in &summary.invocations {
1305 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
1306 }
1307 }
1308
1309 #[tokio::test]
1310 async fn on_error_continue_skips_failed_subtree_only() {
1311 let dir = tempfile::tempdir().unwrap();
1313 let good_csv = dir.path().join("good.csv");
1314 std::fs::write(&good_csv, "x\n1\n").unwrap();
1315 let good_out = dir.path().join("good.jsonl");
1316
1317 let yaml = format!(
1318 r#"version: 1
1319pipeline:
1320 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1321 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
1322matrix:
1323 - id: bad
1324 sink: {{ config: {{ path: {bad_dir} }} }}
1325 - id: good
1326"#,
1327 good_csv = good_csv.display(),
1328 good_out = good_out.display(),
1329 bad_dir = dir.path().display(),
1330 );
1331 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1332 let nodes = expand(&cfg).unwrap();
1333 let summary = run_expanded(
1334 nodes,
1335 ExecuteOptions {
1336 pipeline_name: "continuetest".into(),
1337 execution: None,
1338 dry_run: false,
1339 limit: None,
1340 state_path_override: None,
1341 auth: Default::default(),
1342 clock: chrono::Utc::now().fixed_offset(),
1343 cancel: None,
1344 },
1345 )
1346 .await
1347 .unwrap();
1348 assert_eq!(summary.invocations.len(), 2);
1349 assert_eq!(summary.failure_count(), 1);
1350 let good_outcome = summary
1351 .invocations
1352 .iter()
1353 .find(|i| i.row_id == "good")
1354 .unwrap();
1355 assert!(good_outcome.error.is_none());
1356 }
1357}