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 shard: Option<faucet_core::ShardSpec>,
65 pub auth: AuthCatalog,
69 pub clock: DateTime<FixedOffset>,
73 pub cancel: Option<CancellationToken>,
79 pub resilience: Option<faucet_core::ResiliencePolicy>,
84 #[cfg(feature = "lineage")]
87 pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
88 #[cfg(feature = "lineage")]
92 pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
93}
94
95const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
100
101#[derive(Debug)]
103pub struct InvocationOutcome {
104 pub row_id: String,
105 pub parent_record_key: Option<String>,
108 pub records_written: usize,
109 pub error: Option<String>,
110}
111
112#[derive(Debug)]
114pub struct RunSummary {
115 pub invocations: Vec<InvocationOutcome>,
116}
117
118impl RunSummary {
119 pub fn failure_count(&self) -> usize {
120 self.invocations
121 .iter()
122 .filter(|i| i.error.is_some())
123 .count()
124 }
125 pub fn had_failures(&self) -> bool {
126 self.failure_count() > 0
127 }
128}
129
130fn default_concurrency() -> usize {
141 std::thread::available_parallelism()
142 .map(|n| n.get())
143 .unwrap_or(4)
144 .clamp(1, 8)
145}
146
147pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
150 let on_error = opts
151 .execution
152 .as_ref()
153 .map(|e| e.on_error)
154 .unwrap_or_default();
155 let max_concurrent = opts
156 .execution
157 .as_ref()
158 .and_then(|e| e.max_concurrent)
159 .unwrap_or_else(default_concurrency)
160 .max(1);
161 let semaphore = Arc::new(Semaphore::new(max_concurrent));
162
163 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
168 for n in nodes.iter() {
169 if let NodeRole::Child { parent_id, .. } = &n.role {
170 children_of
171 .entry(parent_id.clone())
172 .or_default()
173 .push(n.id.clone());
174 }
175 }
176
177 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
182
183 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
184 let mut skipped_subtrees: HashSet<String> = HashSet::new();
185
186 let cancel = opts.cancel.clone().unwrap_or_default();
191 let opts = Arc::new(opts);
192
193 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
197 let mut completed: HashSet<String> = HashSet::new();
198 let nodes_by_id: HashMap<String, ExpandedNode> =
199 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
200
201 let projections = build_projections(&nodes_by_id, &children_of);
204
205 let bfs_order: Vec<String> = {
209 let mut ids: Vec<(usize, String)> = nodes_by_id
210 .values()
211 .map(|n| (n.row_index, n.id.clone()))
212 .collect();
213 ids.sort_by_key(|(i, _)| *i);
214 ids.into_iter().map(|(_, id)| id).collect()
215 };
216
217 while !remaining.is_empty() {
218 let ready: Vec<String> = bfs_order
221 .iter()
222 .filter(|id| remaining.contains(*id))
223 .filter(|id| match &nodes_by_id[*id].role {
224 NodeRole::Root => true,
225 NodeRole::Child { parent_id, .. } => {
226 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
227 }
228 })
229 .cloned()
230 .collect();
231
232 if ready.is_empty() {
233 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
238 stuck.sort();
239 return Err(CliError::Internal(format!(
240 "executor deadlock: {} node(s) never became ready (no completed/skipped parent): {}",
241 stuck.len(),
242 stuck.join(", ")
243 )));
244 }
245
246 let mut units: Vec<Unit> = Vec::new();
249 let level_records: HashMap<String, Vec<Arc<Value>>> = {
256 let consumed_parents: HashSet<&str> = ready
257 .iter()
258 .filter_map(|id| match &nodes_by_id[id].role {
259 NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
260 NodeRole::Root => None,
261 })
262 .collect();
263 let mut cap = captured.lock().await;
264 consumed_parents
265 .iter()
266 .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
267 .collect()
268 };
269 for id in &ready {
270 let node = &nodes_by_id[id];
271 if let NodeRole::Child { parent_id, .. } = &node.role
274 && skipped_subtrees.contains(parent_id)
275 {
276 skipped_subtrees.insert(id.clone());
277 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
278 continue;
279 }
280 match &node.role {
281 NodeRole::Root => {
282 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
283 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
284 validate_unit_state_key(&node.id, uses_state, &state_key)?;
285 units.push(Unit {
286 node: node.clone(),
287 parent_record: None,
288 state_key,
289 parent_record_key: None,
290 });
291 }
292 NodeRole::Child {
293 parent_id,
294 parent_key,
295 } => {
296 let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
297 if parent_records.is_empty() {
298 tracing::info!(
299 row = %id, parent = %parent_id,
300 "parent produced no records — child skipped"
301 );
302 continue;
303 }
304 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
306 let mut seen_keys: HashSet<String> = HashSet::new();
307 for record in &parent_records {
308 let pk_value = resolve_parent_key(record, parent_key);
309 let pk_string = pk_value
310 .as_ref()
311 .map(value_to_string_brief)
312 .unwrap_or_else(|| "(missing)".to_string());
313 let state_key =
314 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
315 validate_unit_state_key(&node.id, uses_state, &state_key)?;
316 if !seen_keys.insert(state_key.clone()) {
317 return Err(CliError::DuplicateStateKey {
318 id: node.id.clone(),
319 state_key,
320 });
321 }
322 units.push(Unit {
323 node: node.clone(),
324 parent_record: Some(record.clone()),
325 state_key,
326 parent_record_key: Some(pk_string),
327 });
328 }
329 }
330 }
331 }
332 drop(level_records);
333
334 let mut had_level_failure = false;
335 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
336
337 let level_cancel = cancel.child_token();
349 let mut joinset = tokio::task::JoinSet::new();
350 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
354 for unit in units {
355 let sem = Arc::clone(&semaphore);
356 let opts2 = Arc::clone(&opts);
357 let captured = Arc::clone(&captured);
358 let capture = projections.get(&unit.node.id).cloned();
359 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
360 let unit_cancel = level_cancel.clone();
361 let handle = joinset.spawn(async move {
362 let _permit = sem.acquire().await.expect("semaphore not closed");
363 run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
364 });
365 task_meta.insert(handle.id(), meta);
366 }
367
368 let mut stop_triggered = false;
369 let mut aborted = false;
370 let mut stop_deadline: Option<tokio::time::Instant> = None;
371 loop {
372 let joined = match stop_deadline {
377 Some(deadline) if !aborted => {
378 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
379 Ok(j) => j,
380 Err(_) => {
381 tracing::warn!(
382 "on_error: stop — flush grace elapsed; aborting remaining \
383 in-flight invocations"
384 );
385 joinset.abort_all();
386 aborted = true;
387 continue;
388 }
389 }
390 }
391 _ => joinset.join_next_with_id().await,
392 };
393 let Some(joined) = joined else { break };
394 let outcome = match joined {
398 Ok((_id, outcome)) => outcome,
399 Err(e) if e.is_cancelled() => {
400 continue;
403 }
404 Err(e) => {
405 let (row_id, parent_record_key) = task_meta
406 .get(&e.id())
407 .cloned()
408 .unwrap_or_else(|| ("<unknown>".to_string(), None));
409 InvocationOutcome {
410 row_id,
411 parent_record_key,
412 records_written: 0,
413 error: Some(format!("pipeline invocation task panicked: {e}")),
414 }
415 }
416 };
417
418 if let Some(err) = &outcome.error {
419 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
420 had_level_failure = true;
421 nodes_with_any_failure.insert(outcome.row_id.clone());
422 if matches!(on_error, OnError::Stop) && !stop_triggered {
423 stop_triggered = true;
424 tracing::error!(
425 "on_error: stop — cancelling in-flight invocations (cooperative \
426 flush), then aborting any that don't stop within the grace window"
427 );
428 level_cancel.cancel();
432 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
433 }
434 } else {
435 tracing::info!(
436 row = %outcome.row_id,
437 records_written = outcome.records_written,
438 "pipeline invocation completed"
439 );
440 }
441 outcomes.push(outcome);
442 }
443
444 for id in ready {
448 remaining.remove(&id);
449 if nodes_with_any_failure.contains(&id) {
450 skipped_subtrees.insert(id.clone());
451 if let Some(children) = children_of.get(&id) {
453 for cid in children {
454 skipped_subtrees.insert(cid.clone());
455 }
456 }
457 } else {
458 completed.insert(id);
459 }
460 }
461
462 if had_level_failure && matches!(on_error, OnError::Stop) {
463 tracing::error!("on_error: stop — aborting after first failure");
464 break;
466 }
467 }
468
469 Ok(RunSummary {
470 invocations: outcomes,
471 })
472}
473
474struct Unit {
477 node: ExpandedNode,
478 parent_record: Option<Arc<Value>>,
479 state_key: String,
480 parent_record_key: Option<String>,
481}
482
483async fn run_unit(
484 unit: &Unit,
485 capture: Option<Arc<Projection>>,
486 captured: &CapturedRecords,
487 opts: &ExecuteOptions,
488 cancel: CancellationToken,
489) -> InvocationOutcome {
490 let needs_capture = capture.is_some();
491 let result = run_one_invocation(
492 &unit.node,
493 unit.parent_record.as_deref(),
494 &unit.state_key,
495 capture,
496 opts,
497 cancel,
498 )
499 .await;
500 let row_id = unit.node.id.clone();
501 let parent_record_key = unit.parent_record_key.clone();
502 match result {
503 Ok((records, written)) => {
504 if needs_capture {
505 captured
506 .lock()
507 .await
508 .entry(row_id.clone())
509 .or_default()
510 .extend(records.into_iter().map(Arc::new));
513 }
514 InvocationOutcome {
515 row_id,
516 parent_record_key,
517 records_written: written,
518 error: None,
519 }
520 }
521 Err(e) => InvocationOutcome {
522 row_id,
523 parent_record_key,
524 records_written: 0,
525 error: Some(e.to_string()),
526 },
527 }
528}
529
530pub(crate) fn build_state_key(
532 pipeline_name: &str,
533 row_id: &str,
534 parent_key: Option<&str>,
535) -> String {
536 match parent_key {
537 None => format!("{pipeline_name}::{row_id}"),
538 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
539 }
540}
541
542fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
547 if uses_state {
548 faucet_core::state::validate_state_key(state_key).map_err(|e| {
549 CliError::InvalidStateKey {
550 id: node_id.to_owned(),
551 state_key: state_key.to_owned(),
552 reason: e.to_string(),
553 }
554 })?;
555 }
556 Ok(())
557}
558
559fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
561 let mut cur = record;
562 for segment in parent_key.split('.') {
563 cur = match cur {
564 Value::Object(m) => m.get(segment)?,
565 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
566 _ => return None,
567 };
568 }
569 Some(cur.clone())
570}
571
572#[derive(Debug, Clone)]
576enum Projection {
577 Full,
580 Paths(Vec<Vec<String>>),
582}
583
584fn split_path(path: &str) -> Vec<String> {
586 path.split('.').map(|s| s.to_string()).collect()
587}
588
589fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
593 paths.sort();
594 paths.dedup();
595 let mut kept: Vec<Vec<String>> = Vec::new();
596 for p in paths {
597 let covered = kept
598 .iter()
599 .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
600 if !covered {
601 kept.push(p);
602 }
603 }
604 kept
605}
606
607fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
610 let mut cur = record;
611 for seg in segments {
612 cur = match cur {
613 Value::Object(m) => m.get(seg)?,
614 Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
615 _ => return None,
616 };
617 }
618 Some(cur.clone())
619}
620
621fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
626 if segments.is_empty() {
627 return;
628 }
629 let mut cur = out;
630 for seg in &segments[..segments.len() - 1] {
631 let map = match cur {
632 Value::Object(m) => m,
633 _ => return,
634 };
635 cur = map
636 .entry(seg.clone())
637 .or_insert_with(|| Value::Object(serde_json::Map::new()));
638 }
639 if let Value::Object(m) = cur {
640 m.insert(segments[segments.len() - 1].clone(), leaf);
641 }
642}
643
644fn project_record(record: &Value, projection: &Projection) -> Value {
649 match projection {
650 Projection::Full => record.clone(),
651 Projection::Paths(paths) => {
652 let mut out = Value::Object(serde_json::Map::new());
653 for segs in paths {
654 if let Some(v) = walk_value(record, segs) {
655 graft_object(&mut out, segs, v);
656 }
657 }
658 out
659 }
660 }
661}
662
663fn build_projections(
668 nodes_by_id: &HashMap<String, ExpandedNode>,
669 children_of: &HashMap<String, Vec<String>>,
670) -> HashMap<String, Arc<Projection>> {
671 let mut out = HashMap::new();
672 for (parent_id, child_ids) in children_of {
673 let mut raw: Vec<Vec<String>> = Vec::new();
674 let mut full = false;
675 for cid in child_ids {
676 let child = &nodes_by_id[cid];
677 if let NodeRole::Child { parent_key, .. } = &child.role {
678 if parent_key.is_empty() {
679 full = true;
680 } else {
681 raw.push(split_path(parent_key));
682 }
683 }
684 for dref in &child.deferred_refs {
685 if dref.referenced_id == *parent_id {
686 if dref.dotted_path.is_empty() {
687 full = true; } else {
689 raw.push(split_path(&dref.dotted_path));
690 }
691 }
692 }
693 }
694 let projection = if full || raw.is_empty() {
699 Projection::Full
700 } else {
701 Projection::Paths(minimal_paths(raw))
702 };
703 out.insert(parent_id.clone(), Arc::new(projection));
704 }
705 out
706}
707
708async fn run_one_invocation(
710 node: &ExpandedNode,
711 parent_record: Option<&Value>,
712 state_key: &str,
713 capture: Option<Arc<Projection>>,
714 opts: &ExecuteOptions,
715 cancel: CancellationToken,
716) -> CliResult<(Vec<Value>, usize)> {
717 let run_id = uuid::Uuid::now_v7().to_string();
720 let pipeline_name = opts.pipeline_name.clone();
721 let row_id = node.id.clone();
722 #[cfg(feature = "lineage")]
723 let lineage = opts.lineage.clone();
724 #[cfg(feature = "lineage")]
725 let lineage_cfg = opts.lineage_cfg.clone();
726 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
727 let mut source_cfg = node.source.config.clone();
729 let mut sink_cfg = node.sink.config.clone();
730
731 resolve_now_inplace(&mut source_cfg, opts.clock)?;
734 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
735
736 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
737 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
738 resolve_inplace(&mut source_cfg, &ctx)?;
739 resolve_inplace(&mut sink_cfg, &ctx)?;
740 }
741
742 let source = build_source(
744 &node.source.kind,
745 source_cfg,
746 &opts.auth,
747 opts.resilience.as_ref().map(|r| &r.retry),
748 )
749 .await?;
750
751 if let Some(shard) = &opts.shard {
755 source
756 .apply_shard(shard)
757 .await
758 .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
759 }
760 let raw_sink: Box<dyn Sink> = if opts.dry_run {
761 Box::new(CountingSink::new())
762 } else {
763 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
764 };
765 let raw_sink: Box<dyn Sink> = match opts.limit {
766 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
767 None => raw_sink,
768 };
769 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
770 let sink: Box<dyn Sink> = match &capture {
771 Some(projection) => Box::new(CapturingSink::wrap(
772 raw_sink,
773 Arc::clone(&captured),
774 Arc::clone(projection),
775 )),
776 None => raw_sink,
777 };
778
779 #[cfg(feature = "lineage")]
785 let (in_sample, out_sample) = {
786 use std::sync::Arc as StdArc;
787 match (&lineage, &lineage_cfg) {
788 (Some(_), Some(lc)) => {
789 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
790 let cap = if want_schema { lc.sample_records } else { 0 };
791 let need_counter = lc.emit_on.running;
792 if want_schema || need_counter {
793 (
794 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
795 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
796 )
797 } else {
798 (None, None)
799 }
800 }
801 _ => (None, None),
802 }
803 };
804
805 #[cfg(feature = "lineage")]
808 let source: Box<dyn Source> = match &in_sample {
809 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
810 source,
811 std::sync::Arc::clone(state),
812 )),
813 None => source,
814 };
815
816 let stages = if node.transforms.is_empty() {
821 compile_transforms(&node.transforms)?
822 } else {
823 let mut transforms = node.transforms.clone();
824 for t in &mut transforms {
825 resolve_now_inplace(&mut t.config, opts.clock)?;
826 }
827 compile_transforms(&transforms)?
828 };
829 let source: Box<dyn Source> = if stages.is_empty() {
830 source
831 } else {
832 Box::new(faucet_core::TransformingSource::new(
833 source,
834 stages,
835 obs_labels.clone(),
836 )?)
837 };
838
839 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
843 let effective_state_key = match &opts.shard {
846 Some(shard) => format!("{state_key}::{}", shard.id),
847 None => state_key.to_owned(),
848 };
849 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
850 Box::new(StateKeyOverride {
851 inner: source,
852 key: effective_state_key,
853 })
854 } else {
855 source
856 };
857
858 #[cfg(feature = "lineage")]
861 let sink: Box<dyn Sink> = match &out_sample {
862 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
863 sink,
864 std::sync::Arc::clone(state),
865 )),
866 None => sink,
867 };
868
869 #[cfg(feature = "lineage")]
874 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
875 .with_name(pipeline_name.clone())
876 .with_row(row_id.clone())
877 .with_run_id(run_id.clone());
878 #[cfg(not(feature = "lineage"))]
879 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
880 .with_name(pipeline_name)
881 .with_row(row_id)
882 .with_run_id(run_id);
883 let pipeline = match state {
884 Some(store) => pipeline.with_state_store(store),
885 None => pipeline,
886 };
887 let pipeline = if let Some(ref dlq_spec) = node.dlq {
888 let dlq_cfg = build_dlq_config(dlq_spec).await?;
889 pipeline.with_dlq(dlq_cfg)
890 } else {
891 pipeline
892 };
893 #[cfg(feature = "lineage")]
898 let pipeline = pipeline.with_cancel(cancel.clone());
899 #[cfg(not(feature = "lineage"))]
900 let pipeline = pipeline.with_cancel(cancel);
901 #[cfg(feature = "quality")]
905 let pipeline = if let Some(ref quality_spec) = node.quality {
906 let compiled = Arc::new(
907 faucet_core::CompiledQuality::compile(quality_spec)
908 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
909 );
910 pipeline.with_quality(compiled)
911 } else {
912 pipeline
913 };
914 let pipeline = if let Some(ref sd) = node.schema {
916 pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd))
917 } else {
918 pipeline
919 };
920 let pipeline = if let Some(ab) = opts
922 .execution
923 .as_ref()
924 .and_then(|e| e.adaptive_batch_size.clone())
925 {
926 ab.validate()
927 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
928 pipeline.with_adaptive(ab)
929 } else {
930 pipeline
931 };
932 let pipeline = if let Some(policy) = opts.resilience.clone() {
935 pipeline.with_resilience(policy)
936 } else {
937 pipeline
938 };
939 let pipeline = pipeline.with_delivery(node.delivery);
942 #[cfg(feature = "lineage")]
944 let lineage_ctx = match (&lineage, &lineage_cfg) {
945 (Some(em), Some(lc)) => {
946 let job_name =
947 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
948 let mut ctx = faucet_lineage::RunLifecycle {
949 job_namespace: lc.namespace.clone(),
950 job_name,
951 run_id: run_id.clone(),
952 parent: lc.parent_job.clone(),
953 input: faucet_lineage::DatasetRef {
954 namespace: lc.namespace.clone(),
955 name: source.dataset_uri(),
956 },
957 output: faucet_lineage::DatasetRef {
958 namespace: lc.namespace.clone(),
959 name: sink.dataset_uri(),
960 },
961 started_at: chrono::Utc::now(),
962 finished_at: None,
963 records: 0,
964 error: None,
965 input_schema: None,
966 output_schema: None,
967 column_lineage: None,
968 source_code: None,
969 };
970 em.emit(faucet_lineage::EventType::Start, &ctx).await;
971 let hb_handle = if lc.emit_on.running {
974 let em2 = std::sync::Arc::clone(em);
975 let interval = lc.heartbeat_interval;
976 let mut beat_ctx = ctx.clone();
977 let counter = out_sample.clone();
978 Some(tokio::spawn(async move {
979 let mut tick = tokio::time::interval(interval);
980 tick.tick().await; loop {
982 tick.tick().await;
983 if let Some(c) = &counter {
984 beat_ctx.records = c.count();
985 }
986 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
987 .await;
988 }
989 }))
990 } else {
991 None
992 };
993 ctx.source_code = if lc.include_source_code_facet {
994 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
995 } else {
996 None
997 };
998 Some((std::sync::Arc::clone(em), ctx, hb_handle))
999 }
1000 _ => None,
1001 };
1002
1003 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1010 Ok(r) => sink.flush().await.map(|_| r),
1011 Err(e) => Err(e),
1012 };
1013
1014 #[cfg(feature = "lineage")]
1015 if let Some((em, mut ctx, hb)) = lineage_ctx {
1016 if let Some(h) = hb {
1017 h.abort();
1018 }
1019 ctx.finished_at = Some(chrono::Utc::now());
1020 if let Some(state) = &out_sample {
1021 ctx.records = state.count();
1022 if lineage_cfg
1023 .as_ref()
1024 .map(|l| l.include_schema_facet)
1025 .unwrap_or(false)
1026 {
1027 ctx.output_schema = Some(state.inferred_schema());
1028 }
1029 }
1030 if let Some(state) = &in_sample
1031 && lineage_cfg
1032 .as_ref()
1033 .map(|l| l.include_schema_facet || l.include_column_lineage)
1034 .unwrap_or(false)
1035 {
1036 let in_schema = state.inferred_schema();
1037 if lineage_cfg
1038 .as_ref()
1039 .map(|l| l.include_column_lineage)
1040 .unwrap_or(false)
1041 {
1042 let input_fields: Vec<String> =
1043 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1044 let ops = crate::lineage_glue::column_ops(&node.transforms);
1045 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1046 }
1047 if lineage_cfg
1048 .as_ref()
1049 .map(|l| l.include_schema_facet)
1050 .unwrap_or(false)
1051 {
1052 ctx.input_schema = Some(in_schema);
1053 }
1054 }
1055 let ev = match &result {
1056 Err(e) => {
1057 ctx.error = Some(e.to_string());
1058 faucet_lineage::EventType::Fail
1059 }
1060 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1061 Ok(_) => faucet_lineage::EventType::Complete,
1062 };
1063 em.emit(ev, &ctx).await;
1064 }
1065
1066 let result = result?;
1067
1068 let captured = if capture.is_some() {
1069 std::mem::take(&mut *captured.lock().await)
1070 } else {
1071 Vec::new()
1072 };
1073 Ok((captured, result.records_written))
1074}
1075
1076async fn build_state_for_node(
1077 node: &ExpandedNode,
1078 state_path_override: Option<&Path>,
1079) -> CliResult<Option<Arc<dyn StateStore>>> {
1080 match (&node.state, state_path_override) {
1081 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1082 (None, Some(path)) => Ok(Some(state_from_override(path))),
1083 (Some(spec), Some(path)) => {
1084 if spec.kind == "file" {
1085 Ok(Some(state_from_override(path)))
1086 } else {
1087 tracing::warn!(
1088 state = %spec.kind,
1089 "--state-path is only meaningful for the 'file' backend; ignoring override"
1090 );
1091 Ok(Some(build_state_store(spec).await?))
1092 }
1093 }
1094 (None, None) => Ok(None),
1095 }
1096}
1097
1098fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1099 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1100}
1101
1102pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1105 let sink = build_sink(
1108 &spec.sink.kind,
1109 spec.sink.config.clone(),
1110 &AuthCatalog::new(),
1111 )
1112 .await?;
1113 Ok(DlqConfig {
1114 sink: Arc::from(sink),
1115 on_batch_error: match spec.on_batch_error {
1116 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1117 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1118 },
1119 max_failures_per_page: spec.max_failures_per_page,
1120 max_failures_total: spec.max_failures_total,
1121 include_original_payload: spec.include_original_payload,
1122 })
1123}
1124
1125fn resolve_now_inplace(value: &mut Value, clock: DateTime<FixedOffset>) -> CliResult<()> {
1128 match value {
1129 Value::String(s) => {
1130 *s = crate::interpolate::resolve_now(s, clock)?;
1131 Ok(())
1132 }
1133 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1134 Value::Object(m) => m
1135 .values_mut()
1136 .try_for_each(|v| resolve_now_inplace(v, clock)),
1137 _ => Ok(()),
1138 }
1139}
1140
1141fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1145 match value {
1146 Value::String(s) => {
1147 let resolved = interpolate_record(s, ctx)?;
1148 *s = resolved;
1149 Ok(())
1150 }
1151 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1152 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1153 _ => Ok(()),
1154 }
1155}
1156
1157struct StateKeyOverride {
1163 inner: Box<dyn Source>,
1164 key: String,
1165}
1166
1167#[async_trait]
1168impl Source for StateKeyOverride {
1169 async fn fetch_with_context(
1170 &self,
1171 ctx: &HashMap<String, Value>,
1172 ) -> Result<Vec<Value>, FaucetError> {
1173 self.inner.fetch_with_context(ctx).await
1174 }
1175 async fn fetch_with_context_incremental(
1176 &self,
1177 ctx: &HashMap<String, Value>,
1178 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1179 self.inner.fetch_with_context_incremental(ctx).await
1180 }
1181 fn connector_name(&self) -> &'static str {
1182 self.inner.connector_name()
1183 }
1184 fn state_key(&self) -> Option<String> {
1185 Some(self.key.clone())
1186 }
1187 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1188 self.inner.apply_start_bookmark(bookmark).await
1189 }
1190}
1191
1192struct CapturingSink {
1196 inner: Box<dyn Sink>,
1197 captured: Arc<Mutex<Vec<Value>>>,
1198 projection: Arc<Projection>,
1199}
1200
1201impl CapturingSink {
1202 fn wrap(
1203 inner: Box<dyn Sink>,
1204 captured: Arc<Mutex<Vec<Value>>>,
1205 projection: Arc<Projection>,
1206 ) -> Self {
1207 Self {
1208 inner,
1209 captured,
1210 projection,
1211 }
1212 }
1213}
1214
1215#[async_trait]
1216impl Sink for CapturingSink {
1217 fn connector_name(&self) -> &'static str {
1218 self.inner.connector_name()
1219 }
1220 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1221 let written = self.inner.write_batch(records).await?;
1222 let n = written.min(records.len());
1225 let mut buf = self.captured.lock().await;
1226 buf.extend(
1227 records
1228 .iter()
1229 .take(n)
1230 .map(|r| project_record(r, &self.projection)),
1231 );
1232 Ok(written)
1233 }
1234 async fn flush(&self) -> Result<(), FaucetError> {
1235 self.inner.flush().await
1236 }
1237}
1238
1239struct LimitedSink {
1242 inner: Box<dyn Sink>,
1243 remaining: AtomicUsize,
1244}
1245
1246impl LimitedSink {
1247 fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1248 Self {
1249 inner,
1250 remaining: AtomicUsize::new(cap),
1251 }
1252 }
1253}
1254
1255#[async_trait]
1256impl Sink for LimitedSink {
1257 fn connector_name(&self) -> &'static str {
1258 self.inner.connector_name()
1259 }
1260 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1261 let remaining = self.remaining.load(Ordering::Relaxed);
1262 if remaining == 0 {
1263 return Ok(0);
1264 }
1265 let take = remaining.min(records.len());
1266 let slice = &records[..take];
1267 let written = self.inner.write_batch(slice).await?;
1268 self.remaining
1269 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1270 Ok(written)
1271 }
1272 async fn flush(&self) -> Result<(), FaucetError> {
1273 self.inner.flush().await
1274 }
1275}
1276
1277struct CountingSink {
1280 seen: AtomicUsize,
1281}
1282
1283impl CountingSink {
1284 fn new() -> Self {
1285 Self {
1286 seen: AtomicUsize::new(0),
1287 }
1288 }
1289}
1290
1291#[async_trait]
1292impl Sink for CountingSink {
1293 fn connector_name(&self) -> &'static str {
1294 "dry-run"
1295 }
1296 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1297 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1298 Ok(records.len())
1299 }
1300}
1301
1302fn value_to_string_brief(v: &Value) -> String {
1305 match v {
1306 Value::String(s) => s.clone(),
1307 other => other.to_string(),
1308 }
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313 use super::*;
1314 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1315 use crate::expand::expand;
1316 use serde_json::json;
1317
1318 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1319 PipelineConfig {
1320 version: 1,
1321 name: Some("test".into()),
1322 vars: None,
1323 auth: None,
1324 pipeline: PipelineSpec {
1325 source: Some(ConnectorSpec {
1326 kind: "csv".into(),
1327 config: json!({"path": input.to_str().unwrap()}),
1328 transforms: None,
1329 inherit_transforms: true,
1330 }),
1331 sink: Some(ConnectorSpec {
1332 kind: "jsonl".into(),
1333 config: json!({"path": output.to_str().unwrap()}),
1334 transforms: None,
1335 inherit_transforms: true,
1336 }),
1337 sources: Default::default(),
1338 sinks: Default::default(),
1339 transforms: Vec::new(),
1340 state: None,
1341 dlq: None,
1342 #[cfg(feature = "quality")]
1343 quality: None,
1344 schema: None,
1345 },
1346 matrix: Vec::new(),
1347 execution: None,
1348 observability: None,
1349 delivery: faucet_core::DeliveryMode::default(),
1350 resilience: None,
1351 shard: None,
1352 replication: None,
1353 #[cfg(feature = "schedule")]
1354 schedule: None,
1355 #[cfg(feature = "lineage")]
1356 lineage: None,
1357 }
1358 }
1359
1360 #[tokio::test]
1361 async fn empty_matrix_runs_pipeline_once() {
1362 let dir = tempfile::tempdir().unwrap();
1363 let input = dir.path().join("in.csv");
1364 let output = dir.path().join("out.jsonl");
1365 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1366 let cfg = cfg_csv_to_jsonl(&input, &output);
1367 let nodes = expand(&cfg).unwrap();
1368 let summary = run_expanded(
1369 nodes,
1370 ExecuteOptions {
1371 pipeline_name: "t".into(),
1372 execution: None,
1373 dry_run: false,
1374 limit: None,
1375 state_path_override: None,
1376 shard: None,
1377 auth: Default::default(),
1378 clock: chrono::Utc::now().fixed_offset(),
1379 cancel: None,
1380 resilience: None,
1381 #[cfg(feature = "lineage")]
1382 lineage: None,
1383 #[cfg(feature = "lineage")]
1384 lineage_cfg: None,
1385 },
1386 )
1387 .await
1388 .unwrap();
1389 assert_eq!(summary.invocations.len(), 1);
1390 assert_eq!(summary.invocations[0].records_written, 2);
1391 assert!(!summary.had_failures());
1392 let body = std::fs::read_to_string(&output).unwrap();
1393 assert_eq!(body.lines().count(), 2);
1394 }
1395
1396 #[tokio::test]
1397 async fn matrix_two_independent_roots_both_run() {
1398 let dir = tempfile::tempdir().unwrap();
1400 let csv_a = dir.path().join("a.csv");
1401 let csv_b = dir.path().join("b.csv");
1402 let out_a = dir.path().join("a.jsonl");
1403 let out_b = dir.path().join("b.jsonl");
1404 std::fs::write(&csv_a, "name\nalice\n").unwrap();
1405 std::fs::write(&csv_b, "name\nbob\n").unwrap();
1406
1407 let yaml = format!(
1408 r#"version: 1
1409pipeline:
1410 source: {{ type: csv, config: {{ path: {a} }} }}
1411 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
1412matrix:
1413 - id: rowA
1414 - id: rowB
1415 source: {{ config: {{ path: {b} }} }}
1416 sink: {{ config: {{ path: {out_b} }} }}
1417"#,
1418 a = csv_a.display(),
1419 b = csv_b.display(),
1420 out_a = out_a.display(),
1421 out_b = out_b.display(),
1422 );
1423 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1424 let nodes = expand(&cfg).unwrap();
1425 let summary = run_expanded(
1426 nodes,
1427 ExecuteOptions {
1428 pipeline_name: "matrix".into(),
1429 execution: None,
1430 dry_run: false,
1431 limit: None,
1432 state_path_override: None,
1433 shard: None,
1434 auth: Default::default(),
1435 clock: chrono::Utc::now().fixed_offset(),
1436 cancel: None,
1437 resilience: None,
1438 #[cfg(feature = "lineage")]
1439 lineage: None,
1440 #[cfg(feature = "lineage")]
1441 lineage_cfg: None,
1442 },
1443 )
1444 .await
1445 .unwrap();
1446 assert_eq!(summary.invocations.len(), 2);
1447 assert!(out_a.exists());
1448 assert!(out_b.exists());
1449 }
1450
1451 #[tokio::test]
1452 async fn dag_child_fans_out_per_parent_record() {
1453 let dir = tempfile::tempdir().unwrap();
1456 let parent_csv = dir.path().join("parents.csv");
1457 let child_csv = dir.path().join("child.csv");
1458 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
1459 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
1460 let parent_out = dir.path().join("parents.jsonl");
1461 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
1462
1463 let yaml = format!(
1464 r#"version: 1
1465pipeline:
1466 source: {{ type: csv, config: {{ path: {parent} }} }}
1467 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
1468matrix:
1469 - id: parents
1470 - id: child
1471 parent: parents
1472 source: {{ config: {{ path: {child} }} }}
1473 sink: {{ config: {{ path: "{child_out}" }} }}
1474"#,
1475 parent = parent_csv.display(),
1476 parent_out = parent_out.display(),
1477 child = child_csv.display(),
1478 child_out = child_out_pattern.display(),
1479 );
1480 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1481 let nodes = expand(&cfg).unwrap();
1482 let summary = run_expanded(
1483 nodes,
1484 ExecuteOptions {
1485 pipeline_name: "dagtest".into(),
1486 execution: None,
1487 dry_run: false,
1488 limit: None,
1489 state_path_override: None,
1490 shard: None,
1491 auth: Default::default(),
1492 clock: chrono::Utc::now().fixed_offset(),
1493 cancel: None,
1494 resilience: None,
1495 #[cfg(feature = "lineage")]
1496 lineage: None,
1497 #[cfg(feature = "lineage")]
1498 lineage_cfg: None,
1499 },
1500 )
1501 .await
1502 .unwrap();
1503
1504 assert_eq!(summary.invocations.len(), 3);
1506 assert!(!summary.had_failures(), "{:?}", summary);
1507 assert!(dir.path().join("child-1.jsonl").exists());
1508 assert!(dir.path().join("child-2.jsonl").exists());
1509 }
1510
1511 #[tokio::test]
1512 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
1513 let dir = tempfile::tempdir().unwrap();
1525 let good_csv = dir.path().join("good.csv");
1526 std::fs::write(&good_csv, "x\n1\n").unwrap();
1527 let good_out = dir.path().join("good.jsonl");
1528 let bad_sink_dir = dir.path().to_path_buf();
1529
1530 let yaml = format!(
1531 r#"version: 1
1532pipeline:
1533 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1534 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
1535matrix:
1536 - id: bad
1537 sink: {{ config: {{ path: {bad_dir} }} }}
1538 - id: good
1539execution:
1540 max_concurrent: 1
1541 on_error: stop
1542"#,
1543 good_csv = good_csv.display(),
1544 good_out = good_out.display(),
1545 bad_dir = bad_sink_dir.display(),
1546 );
1547 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1548 let nodes = expand(&cfg).unwrap();
1549 let summary = run_expanded(
1550 nodes,
1551 ExecuteOptions {
1552 pipeline_name: "stoptest".into(),
1553 execution: cfg.execution.clone(),
1554 dry_run: false,
1555 limit: None,
1556 state_path_override: None,
1557 shard: None,
1558 auth: Default::default(),
1559 clock: chrono::Utc::now().fixed_offset(),
1560 cancel: None,
1561 resilience: None,
1562 #[cfg(feature = "lineage")]
1563 lineage: None,
1564 #[cfg(feature = "lineage")]
1565 lineage_cfg: None,
1566 },
1567 )
1568 .await
1569 .unwrap();
1570
1571 assert!(summary.had_failures(), "the failing root must be reported");
1573
1574 let bad: Vec<_> = summary
1576 .invocations
1577 .iter()
1578 .filter(|o| o.row_id == "bad")
1579 .collect();
1580 assert_eq!(bad.len(), 1, "bad must run exactly once");
1581 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
1582
1583 assert!(
1585 summary.invocations.len() <= 2,
1586 "at most the two roots may run, got {:?}",
1587 summary.invocations
1588 );
1589
1590 let good_wrote = summary
1597 .invocations
1598 .iter()
1599 .find(|o| o.row_id == "good" && o.error.is_none())
1600 .map(|o| o.records_written)
1601 .unwrap_or(0);
1602 if good_wrote > 0 {
1603 assert!(
1604 good_out.exists(),
1605 "a good that wrote records must have produced its output file"
1606 );
1607 }
1608 }
1609
1610 #[tokio::test]
1611 async fn invalid_pipeline_name_with_state_errors_up_front() {
1612 let dir = tempfile::tempdir().unwrap();
1616 let input = dir.path().join("in.csv");
1617 let output = dir.path().join("out.jsonl");
1618 std::fs::write(&input, "name\nalice\n").unwrap();
1619 let yaml = format!(
1620 r#"version: 1
1621pipeline:
1622 source: {{ type: csv, config: {{ path: {input} }} }}
1623 sink: {{ type: jsonl, config: {{ path: {output} }} }}
1624 state: {{ type: memory }}
1625"#,
1626 input = input.display(),
1627 output = output.display(),
1628 );
1629 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1630 let nodes = expand(&cfg).unwrap();
1631 let err = run_expanded(
1632 nodes,
1633 ExecuteOptions {
1634 pipeline_name: "bad name".into(), execution: None,
1636 dry_run: false,
1637 limit: None,
1638 state_path_override: None,
1639 shard: None,
1640 auth: Default::default(),
1641 clock: chrono::Utc::now().fixed_offset(),
1642 cancel: None,
1643 resilience: None,
1644 #[cfg(feature = "lineage")]
1645 lineage: None,
1646 #[cfg(feature = "lineage")]
1647 lineage_cfg: None,
1648 },
1649 )
1650 .await
1651 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
1652 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1653 }
1654
1655 #[tokio::test]
1656 async fn invalid_parent_key_value_with_state_errors_up_front() {
1657 let dir = tempfile::tempdir().unwrap();
1660 let parent_csv = dir.path().join("parents.csv");
1661 let child_csv = dir.path().join("child.csv");
1662 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
1664 std::fs::write(&child_csv, "x\nA\n").unwrap();
1665 let parent_out = dir.path().join("parents.jsonl");
1666 let child_out = dir.path().join("child.jsonl");
1667 let yaml = format!(
1668 r#"version: 1
1669pipeline:
1670 source: {{ type: csv, config: {{ path: {parent} }} }}
1671 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
1672 state: {{ type: memory }}
1673matrix:
1674 - id: parents
1675 - id: child
1676 parent: parents
1677 source: {{ config: {{ path: {child} }} }}
1678 sink: {{ config: {{ path: {child_out} }} }}
1679"#,
1680 parent = parent_csv.display(),
1681 parent_out = parent_out.display(),
1682 child = child_csv.display(),
1683 child_out = child_out.display(),
1684 );
1685 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1686 let nodes = expand(&cfg).unwrap();
1687 let err = run_expanded(
1688 nodes,
1689 ExecuteOptions {
1690 pipeline_name: "ok".into(),
1691 execution: None,
1692 dry_run: false,
1693 limit: None,
1694 state_path_override: None,
1695 shard: None,
1696 auth: Default::default(),
1697 clock: chrono::Utc::now().fixed_offset(),
1698 cancel: None,
1699 resilience: None,
1700 #[cfg(feature = "lineage")]
1701 lineage: None,
1702 #[cfg(feature = "lineage")]
1703 lineage_cfg: None,
1704 },
1705 )
1706 .await
1707 .expect_err(
1708 "an illegal parent-key value must be rejected up front when state is configured",
1709 );
1710 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1711 }
1712
1713 #[tokio::test]
1714 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
1715 let dir = tempfile::tempdir().unwrap();
1724 let bad_sink_dir = dir.path().to_path_buf();
1725 let good_csv = dir.path().join("good.csv");
1728 std::fs::write(&good_csv, "x\n1\n").unwrap();
1729 let yaml = format!(
1735 r#"version: 1
1736pipeline:
1737 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1738 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
1739matrix:
1740 - id: bad
1741 - id: good_a
1742 - id: good_b
1743execution:
1744 max_concurrent: 3
1745 on_error: stop
1746"#,
1747 good_csv = good_csv.display(),
1748 bad_dir = bad_sink_dir.display(),
1749 );
1750 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1751 let nodes = expand(&cfg).unwrap();
1752 let summary = run_expanded(
1753 nodes,
1754 ExecuteOptions {
1755 pipeline_name: "stop_parallel".into(),
1756 execution: cfg.execution.clone(),
1757 dry_run: false,
1758 limit: None,
1759 state_path_override: None,
1760 shard: None,
1761 auth: Default::default(),
1762 clock: chrono::Utc::now().fixed_offset(),
1763 cancel: None,
1764 resilience: None,
1765 #[cfg(feature = "lineage")]
1766 lineage: None,
1767 #[cfg(feature = "lineage")]
1768 lineage_cfg: None,
1769 },
1770 )
1771 .await
1772 .unwrap();
1773
1774 assert!(
1779 summary.had_failures(),
1780 "summary should record at least one failure: {summary:?}"
1781 );
1782 assert!(
1783 summary.invocations[0].error.is_some(),
1784 "first outcome must be the failure that triggered stop: {summary:?}"
1785 );
1786 for inv in &summary.invocations {
1790 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
1791 }
1792 }
1793
1794 #[tokio::test]
1795 async fn on_error_continue_skips_failed_subtree_only() {
1796 let dir = tempfile::tempdir().unwrap();
1798 let good_csv = dir.path().join("good.csv");
1799 std::fs::write(&good_csv, "x\n1\n").unwrap();
1800 let good_out = dir.path().join("good.jsonl");
1801
1802 let yaml = format!(
1803 r#"version: 1
1804pipeline:
1805 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1806 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
1807matrix:
1808 - id: bad
1809 sink: {{ config: {{ path: {bad_dir} }} }}
1810 - id: good
1811"#,
1812 good_csv = good_csv.display(),
1813 good_out = good_out.display(),
1814 bad_dir = dir.path().display(),
1815 );
1816 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1817 let nodes = expand(&cfg).unwrap();
1818 let summary = run_expanded(
1819 nodes,
1820 ExecuteOptions {
1821 pipeline_name: "continuetest".into(),
1822 execution: None,
1823 dry_run: false,
1824 limit: None,
1825 state_path_override: None,
1826 shard: None,
1827 auth: Default::default(),
1828 clock: chrono::Utc::now().fixed_offset(),
1829 cancel: None,
1830 resilience: None,
1831 #[cfg(feature = "lineage")]
1832 lineage: None,
1833 #[cfg(feature = "lineage")]
1834 lineage_cfg: None,
1835 },
1836 )
1837 .await
1838 .unwrap();
1839 assert_eq!(summary.invocations.len(), 2);
1840 assert_eq!(summary.failure_count(), 1);
1841 let good_outcome = summary
1842 .invocations
1843 .iter()
1844 .find(|i| i.row_id == "good")
1845 .unwrap();
1846 assert!(good_outcome.error.is_none());
1847 }
1848
1849 #[test]
1852 fn split_path_splits_on_dots() {
1853 assert_eq!(split_path("id"), vec!["id".to_string()]);
1854 assert_eq!(
1855 split_path("user.name"),
1856 vec!["user".to_string(), "name".to_string()]
1857 );
1858 }
1859
1860 #[test]
1861 fn minimal_paths_drops_descendants_of_kept_ancestors() {
1862 let paths = vec![
1863 vec!["user".into(), "name".into()],
1864 vec!["user".into()],
1865 vec!["id".into()],
1866 vec!["id".into()],
1867 ];
1868 let min = minimal_paths(paths);
1869 assert!(min.contains(&vec!["user".to_string()]));
1870 assert!(min.contains(&vec!["id".to_string()]));
1871 assert!(
1872 !min.contains(&vec!["user".to_string(), "name".to_string()]),
1873 "user.name must be dropped — covered by user"
1874 );
1875 assert_eq!(min.len(), 2);
1876 }
1877
1878 #[test]
1879 fn project_full_clones_whole_record() {
1880 let r = json!({"a": 1, "b": {"c": 2}});
1881 assert_eq!(project_record(&r, &Projection::Full), r);
1882 }
1883
1884 #[test]
1885 fn project_keeps_only_referenced_paths() {
1886 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
1887 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
1888 let got = project_record(&r, &p);
1889 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
1890 assert!(got.get("blob").is_none());
1891 assert!(got["user"].get("age").is_none());
1892 }
1893
1894 #[test]
1895 fn project_array_index_path_resolves_same_as_original() {
1896 let r = json!({"tags": ["x", "y", "z"]});
1897 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
1898 let got = project_record(&r, &p);
1899 assert_eq!(got, json!({"tags": {"0": "x"}}));
1900 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
1901 assert_eq!(
1902 resolve_parent_key(&got, "tags.0"),
1903 resolve_parent_key(&r, "tags.0"),
1904 "reduced tree must resolve the same value as the original"
1905 );
1906 }
1907
1908 #[test]
1909 fn project_numeric_object_key_resolves_same_as_original() {
1910 let r = json!({"data": {"0": "x", "1": "y"}});
1915 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
1916 let got = project_record(&r, &p);
1917 assert_eq!(got, json!({"data": {"0": "x"}}));
1918 assert_eq!(
1919 resolve_parent_key(&got, "data.0"),
1920 resolve_parent_key(&r, "data.0"),
1921 "numeric object-key path must resolve identically on the reduced tree"
1922 );
1923 }
1924
1925 #[test]
1926 fn project_missing_path_is_omitted() {
1927 let r = json!({"id": 1});
1928 let p = Projection::Paths(vec![vec!["nope".into()]]);
1929 assert_eq!(project_record(&r, &p), json!({}));
1930 }
1931
1932 #[test]
1933 fn build_projections_unions_parent_key_and_refs() {
1934 use crate::config::ConnectorSpec;
1935 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
1936
1937 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
1938 ExpandedNode {
1939 id: id.into(),
1940 row_index: 0,
1941 role: NodeRole::Child {
1942 parent_id: parent.into(),
1943 parent_key: parent_key.into(),
1944 },
1945 source: ConnectorSpec {
1946 kind: "csv".into(),
1947 config: json!({}),
1948 transforms: None,
1949 inherit_transforms: true,
1950 },
1951 sink: ConnectorSpec {
1952 kind: "jsonl".into(),
1953 config: json!({}),
1954 transforms: None,
1955 inherit_transforms: true,
1956 },
1957 transforms: Vec::new(),
1958 state: None,
1959 dlq: None,
1960 delivery: faucet_core::DeliveryMode::AtLeastOnce,
1961 #[cfg(feature = "quality")]
1962 quality: None,
1963 schema: None,
1964 deferred_refs: refs
1965 .iter()
1966 .map(|(rid, p)| DeferredRef {
1967 referenced_id: (*rid).into(),
1968 dotted_path: (*p).into(),
1969 token: format!("${{{rid}.{p}}}"),
1970 })
1971 .collect(),
1972 }
1973 }
1974
1975 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
1976 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
1977 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
1978 let children_of =
1979 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
1980
1981 let projs = build_projections(&nodes_by_id, &children_of);
1982 let p = projs.get("p").expect("projection for p");
1983 match &**p {
1984 Projection::Paths(paths) => {
1985 assert!(paths.contains(&vec!["id".to_string()]));
1986 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
1987 assert!(paths.contains(&vec!["email".to_string()]));
1988 assert!(
1989 !paths.iter().any(|p| p == &vec!["x".to_string()]),
1990 "a ref to a different parent must not be captured under p"
1991 );
1992 }
1993 Projection::Full => panic!("expected Paths, got Full"),
1994 }
1995 }
1996
1997 #[test]
1998 fn build_projections_whole_record_ref_is_full() {
1999 use crate::config::ConnectorSpec;
2000 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2001 let c = ExpandedNode {
2002 id: "c".into(),
2003 row_index: 0,
2004 role: NodeRole::Child {
2005 parent_id: "p".into(),
2006 parent_key: "id".into(),
2007 },
2008 source: ConnectorSpec {
2009 kind: "csv".into(),
2010 config: json!({}),
2011 transforms: None,
2012 inherit_transforms: true,
2013 },
2014 sink: ConnectorSpec {
2015 kind: "jsonl".into(),
2016 config: json!({}),
2017 transforms: None,
2018 inherit_transforms: true,
2019 },
2020 transforms: Vec::new(),
2021 state: None,
2022 dlq: None,
2023 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2024 #[cfg(feature = "quality")]
2025 quality: None,
2026 schema: None,
2027 deferred_refs: vec![DeferredRef {
2028 referenced_id: "p".into(),
2029 dotted_path: "".into(),
2030 token: "${p}".into(),
2031 }],
2032 };
2033 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
2034 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
2035 let projs = build_projections(&nodes_by_id, &children_of);
2036 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
2037 }
2038
2039 fn opts(name: &str) -> ExecuteOptions {
2041 ExecuteOptions {
2042 pipeline_name: name.into(),
2043 execution: None,
2044 dry_run: false,
2045 limit: None,
2046 state_path_override: None,
2047 shard: None,
2048 auth: Default::default(),
2049 clock: chrono::Utc::now().fixed_offset(),
2050 cancel: None,
2051 resilience: None,
2052 #[cfg(feature = "lineage")]
2053 lineage: None,
2054 #[cfg(feature = "lineage")]
2055 lineage_cfg: None,
2056 }
2057 }
2058
2059 #[tokio::test]
2060 async fn dry_run_counts_records_without_writing_sink_file() {
2061 let dir = tempfile::tempdir().unwrap();
2064 let input = dir.path().join("in.csv");
2065 let output = dir.path().join("out.jsonl");
2066 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
2067 let cfg = cfg_csv_to_jsonl(&input, &output);
2068 let nodes = expand(&cfg).unwrap();
2069 let mut o = opts("dry");
2070 o.dry_run = true;
2071 let summary = run_expanded(nodes, o).await.unwrap();
2072 assert_eq!(summary.invocations.len(), 1);
2073 assert_eq!(summary.invocations[0].records_written, 3);
2074 assert!(!summary.had_failures());
2075 assert!(
2076 !output.exists(),
2077 "dry-run must not create the real sink file"
2078 );
2079 }
2080
2081 #[tokio::test]
2082 async fn limit_caps_records_written_across_the_run() {
2083 let dir = tempfile::tempdir().unwrap();
2085 let input = dir.path().join("in.csv");
2086 let output = dir.path().join("out.jsonl");
2087 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
2088 let cfg = cfg_csv_to_jsonl(&input, &output);
2089 let nodes = expand(&cfg).unwrap();
2090 let mut o = opts("lim");
2091 o.limit = Some(2);
2092 let summary = run_expanded(nodes, o).await.unwrap();
2093 assert_eq!(summary.invocations[0].records_written, 2);
2094 let body = std::fs::read_to_string(&output).unwrap();
2095 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
2096 }
2097
2098 #[tokio::test]
2099 async fn duplicate_state_key_among_siblings_is_rejected() {
2100 let dir = tempfile::tempdir().unwrap();
2104 let parent_csv = dir.path().join("parents.csv");
2105 let child_csv = dir.path().join("child.csv");
2106 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
2108 std::fs::write(&child_csv, "x\nA\n").unwrap();
2109 let parent_out = dir.path().join("parents.jsonl");
2110 let child_out = dir.path().join("child.jsonl");
2111 let yaml = format!(
2112 r#"version: 1
2113pipeline:
2114 source: {{ type: csv, config: {{ path: {parent} }} }}
2115 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2116 state: {{ type: memory }}
2117matrix:
2118 - id: parents
2119 - id: child
2120 parent: parents
2121 source: {{ config: {{ path: {child} }} }}
2122 sink: {{ config: {{ path: {child_out} }} }}
2123"#,
2124 parent = parent_csv.display(),
2125 parent_out = parent_out.display(),
2126 child = child_csv.display(),
2127 child_out = child_out.display(),
2128 );
2129 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2130 let nodes = expand(&cfg).unwrap();
2131 let err = run_expanded(nodes, opts("dupkey"))
2132 .await
2133 .expect_err("colliding sibling state keys must be rejected");
2134 match err {
2135 CliError::DuplicateStateKey { id, state_key } => {
2136 assert_eq!(id, "child");
2137 assert_eq!(state_key, "dupkey::child::dup");
2138 }
2139 other => panic!("expected DuplicateStateKey, got {other:?}"),
2140 }
2141 }
2142
2143 #[tokio::test]
2144 async fn state_path_override_writes_bookmark_file() {
2145 let dir = tempfile::tempdir().unwrap();
2150 let input = dir.path().join("in.csv");
2151 let output = dir.path().join("out.jsonl");
2152 let state_dir = dir.path().join("state");
2153 std::fs::write(&input, "name\nalice\n").unwrap();
2154 let cfg = cfg_csv_to_jsonl(&input, &output);
2155 let nodes = expand(&cfg).unwrap();
2156 let mut o = opts("statepath");
2157 o.state_path_override = Some(state_dir.clone());
2158 let summary = run_expanded(nodes, o).await.unwrap();
2159 assert!(!summary.had_failures());
2160 assert_eq!(summary.invocations[0].records_written, 1);
2164 }
2165
2166 #[tokio::test]
2167 async fn build_dlq_config_maps_spec_fields() {
2168 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
2169 let dir = tempfile::tempdir().unwrap();
2170 let dlq_out = dir.path().join("dlq.jsonl");
2171 let spec = DlqSpec {
2172 sink: ConnectorSpec {
2173 kind: "jsonl".into(),
2174 config: json!({ "path": dlq_out.to_str().unwrap() }),
2175 transforms: None,
2176 inherit_transforms: true,
2177 },
2178 on_batch_error: OnBatchErrorSpec::DlqAll,
2179 max_failures_per_page: Some(7),
2180 max_failures_total: Some(42),
2181 include_original_payload: false,
2182 };
2183 let cfg = build_dlq_config(&spec).await.unwrap();
2184 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
2185 assert_eq!(cfg.max_failures_per_page, Some(7));
2186 assert_eq!(cfg.max_failures_total, Some(42));
2187 assert!(!cfg.include_original_payload);
2188 }
2189
2190 #[tokio::test]
2191 async fn build_state_for_node_arms() {
2192 let dir = tempfile::tempdir().unwrap();
2193
2194 let node = stub_node(None);
2196 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
2197
2198 let p = dir.path().join("s1");
2200 assert!(
2201 build_state_for_node(&node, Some(&p))
2202 .await
2203 .unwrap()
2204 .is_some()
2205 );
2206
2207 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
2209 kind: "memory".into(),
2210 config: json!({}),
2211 }));
2212 assert!(
2213 build_state_for_node(&node_mem, None)
2214 .await
2215 .unwrap()
2216 .is_some()
2217 );
2218
2219 let node_file = stub_node(Some(crate::config::StateStoreSpec {
2221 kind: "file".into(),
2222 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
2223 }));
2224 let p2 = dir.path().join("override2");
2225 assert!(
2226 build_state_for_node(&node_file, Some(&p2))
2227 .await
2228 .unwrap()
2229 .is_some()
2230 );
2231
2232 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
2235 kind: "memory".into(),
2236 config: json!({}),
2237 }));
2238 let p3 = dir.path().join("override3");
2239 assert!(
2240 build_state_for_node(&node_mem2, Some(&p3))
2241 .await
2242 .unwrap()
2243 .is_some()
2244 );
2245 }
2246
2247 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
2249 use crate::config::ConnectorSpec;
2250 ExpandedNode {
2251 id: "n".into(),
2252 row_index: 0,
2253 role: NodeRole::Root,
2254 source: ConnectorSpec {
2255 kind: "csv".into(),
2256 config: json!({}),
2257 transforms: None,
2258 inherit_transforms: true,
2259 },
2260 sink: ConnectorSpec {
2261 kind: "jsonl".into(),
2262 config: json!({}),
2263 transforms: None,
2264 inherit_transforms: true,
2265 },
2266 transforms: Vec::new(),
2267 state,
2268 dlq: None,
2269 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2270 #[cfg(feature = "quality")]
2271 quality: None,
2272 schema: None,
2273 deferred_refs: Vec::new(),
2274 }
2275 }
2276
2277 #[tokio::test]
2278 async fn state_key_override_delegates_and_overrides_key() {
2279 let dir = tempfile::tempdir().unwrap();
2281 let input = dir.path().join("in.csv");
2282 std::fs::write(&input, "name\nz\n").unwrap();
2283 let inner = build_source(
2284 "csv",
2285 json!({"path": input.to_str().unwrap()}),
2286 &AuthCatalog::new(),
2287 None,
2288 )
2289 .await
2290 .unwrap();
2291 let inner_name = inner.connector_name();
2293 let ov = StateKeyOverride {
2294 inner,
2295 key: "my::custom::key".into(),
2296 };
2297 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
2298 assert_eq!(ov.connector_name(), inner_name);
2299 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
2300 assert_eq!(rows.len(), 1);
2301 ov.apply_start_bookmark(json!({"any": "bookmark"}))
2303 .await
2304 .unwrap();
2305 }
2306
2307 #[tokio::test]
2308 async fn orphaned_child_surfaces_executor_deadlock() {
2309 use crate::config::ConnectorSpec;
2313 let orphan = ExpandedNode {
2314 id: "orphan".into(),
2315 row_index: 0,
2316 role: NodeRole::Child {
2317 parent_id: "missing-parent".into(),
2318 parent_key: "id".into(),
2319 },
2320 source: ConnectorSpec {
2321 kind: "csv".into(),
2322 config: json!({}),
2323 transforms: None,
2324 inherit_transforms: true,
2325 },
2326 sink: ConnectorSpec {
2327 kind: "jsonl".into(),
2328 config: json!({}),
2329 transforms: None,
2330 inherit_transforms: true,
2331 },
2332 transforms: Vec::new(),
2333 state: None,
2334 dlq: None,
2335 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2336 #[cfg(feature = "quality")]
2337 quality: None,
2338 schema: None,
2339 deferred_refs: Vec::new(),
2340 };
2341 let err = run_expanded(vec![orphan], opts("deadlock"))
2342 .await
2343 .expect_err("an orphaned child must surface as an executor deadlock");
2344 match err {
2345 CliError::Internal(msg) => {
2346 assert!(msg.contains("executor deadlock"), "{msg}");
2347 assert!(msg.contains("orphan"), "{msg}");
2348 }
2349 other => panic!("expected Internal deadlock error, got {other:?}"),
2350 }
2351 }
2352
2353 #[test]
2354 fn value_to_string_brief_unquotes_strings_only() {
2355 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
2356 assert_eq!(value_to_string_brief(&json!(42)), "42");
2357 assert_eq!(value_to_string_brief(&json!(true)), "true");
2358 assert_eq!(value_to_string_brief(&json!(null)), "null");
2359 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
2360 }
2361
2362 #[test]
2363 fn build_state_key_with_and_without_parent() {
2364 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
2365 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
2366 }
2367
2368 #[test]
2369 fn resolve_parent_key_walks_objects_arrays_and_misses() {
2370 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
2371 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
2372 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
2373 assert_eq!(resolve_parent_key(&r, "user.age"), None);
2375 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
2377 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
2379 }
2380
2381 #[tokio::test]
2382 async fn cooperative_cancel_returns_partial_ok() {
2383 let dir = tempfile::tempdir().unwrap();
2387 let input = dir.path().join("in.csv");
2388 let output = dir.path().join("out.jsonl");
2389 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2390 let cfg = cfg_csv_to_jsonl(&input, &output);
2391 let nodes = expand(&cfg).unwrap();
2392 let token = CancellationToken::new();
2393 token.cancel(); let mut o = opts("cancel");
2395 o.cancel = Some(token);
2396 let summary = run_expanded(nodes, o).await.unwrap();
2397 assert_eq!(summary.invocations.len(), 1);
2400 assert!(
2401 !summary.had_failures(),
2402 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
2403 );
2404 }
2405
2406 #[tokio::test]
2407 async fn fanout_projects_away_unreferenced_parent_fields() {
2408 let dir = tempfile::tempdir().unwrap();
2412 let parent_csv = dir.path().join("parents.csv");
2413 let child_csv = dir.path().join("child.csv");
2414 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
2415 std::fs::write(&child_csv, "x\nA\n").unwrap();
2416 let parent_out = dir.path().join("parents.jsonl");
2417 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2418
2419 let yaml = format!(
2420 r#"version: 1
2421pipeline:
2422 source: {{ type: csv, config: {{ path: {parent} }} }}
2423 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2424matrix:
2425 - id: parents
2426 - id: child
2427 parent: parents
2428 source: {{ config: {{ path: {child} }} }}
2429 sink: {{ config: {{ path: "{child_out}" }} }}
2430"#,
2431 parent = parent_csv.display(),
2432 parent_out = parent_out.display(),
2433 child = child_csv.display(),
2434 child_out = child_out_pattern.display(),
2435 );
2436 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2437 let nodes = expand(&cfg).unwrap();
2438 let summary = run_expanded(
2439 nodes,
2440 ExecuteOptions {
2441 pipeline_name: "projtest".into(),
2442 execution: None,
2443 dry_run: false,
2444 limit: None,
2445 state_path_override: None,
2446 shard: None,
2447 auth: Default::default(),
2448 clock: chrono::Utc::now().fixed_offset(),
2449 cancel: None,
2450 resilience: None,
2451 #[cfg(feature = "lineage")]
2452 lineage: None,
2453 #[cfg(feature = "lineage")]
2454 lineage_cfg: None,
2455 },
2456 )
2457 .await
2458 .unwrap();
2459
2460 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2462 assert!(!summary.had_failures(), "{summary:?}");
2463 assert!(dir.path().join("child-1.jsonl").exists());
2465 assert!(dir.path().join("child-2.jsonl").exists());
2466 }
2467}