1use crate::auth_catalog::AuthCatalog;
25use crate::config::{ExecutionSpec, OnError};
26use crate::error::{CliError, CliResult};
27use crate::expand::{ExpandedNode, NodeRole};
28use crate::interpolate::interpolate_record;
29use crate::registry::{build_sink, build_source};
30use crate::state::build_state_store;
31use crate::transforms::compile_transforms;
32use async_trait::async_trait;
33use chrono::{DateTime, FixedOffset};
34use faucet_core::observability::Labels;
35use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
36use serde_json::Value;
37use std::collections::{HashMap, HashSet};
38use std::path::{Path, PathBuf};
39use std::sync::Arc;
40use std::sync::atomic::{AtomicUsize, Ordering};
41use std::time::Duration;
42use tokio::sync::{Mutex, Semaphore};
43
44type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
48use tokio_util::sync::CancellationToken;
49
50pub struct ExecuteOptions {
52 pub pipeline_name: String,
55 pub execution: Option<ExecutionSpec>,
58 pub dry_run: bool,
60 pub limit: Option<usize>,
62 pub state_path_override: Option<PathBuf>,
64 pub shard: Option<faucet_core::ShardSpec>,
69 pub auth: AuthCatalog,
73 pub clock: DateTime<FixedOffset>,
77 pub cancel: Option<CancellationToken>,
83 pub resilience: Option<faucet_core::ResiliencePolicy>,
88 pub sla: Option<crate::sla::SlaSpec>,
93 #[cfg(feature = "lineage")]
96 pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
97 #[cfg(feature = "lineage")]
101 pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
102 #[cfg(feature = "notify")]
108 pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
109 #[cfg(feature = "catalog")]
116 pub catalog: Option<crate::catalog::CatalogHandle>,
117}
118
119const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
124
125#[derive(Debug)]
127pub struct InvocationOutcome {
128 pub row_id: String,
129 pub parent_record_key: Option<String>,
132 pub records_written: usize,
133 pub error: Option<String>,
134}
135
136#[derive(Debug)]
138pub struct RunSummary {
139 pub invocations: Vec<InvocationOutcome>,
140}
141
142impl RunSummary {
143 pub fn failure_count(&self) -> usize {
144 self.invocations
145 .iter()
146 .filter(|i| i.error.is_some())
147 .count()
148 }
149 pub fn had_failures(&self) -> bool {
150 self.failure_count() > 0
151 }
152}
153
154fn default_concurrency() -> usize {
165 std::thread::available_parallelism()
166 .map(|n| n.get())
167 .unwrap_or(4)
168 .clamp(1, 8)
169}
170
171pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
174 let on_error = opts
175 .execution
176 .as_ref()
177 .map(|e| e.on_error)
178 .unwrap_or_default();
179 let max_concurrent = opts
180 .execution
181 .as_ref()
182 .and_then(|e| e.max_concurrent)
183 .unwrap_or_else(default_concurrency)
184 .max(1);
185 let semaphore = Arc::new(Semaphore::new(max_concurrent));
186
187 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
192 for n in nodes.iter() {
193 if let NodeRole::Child { parent_id, .. } = &n.role {
194 children_of
195 .entry(parent_id.clone())
196 .or_default()
197 .push(n.id.clone());
198 }
199 }
200
201 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
206
207 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
208 let mut skipped_subtrees: HashSet<String> = HashSet::new();
209
210 let cancel = opts.cancel.clone().unwrap_or_default();
215 let opts = Arc::new(opts);
216
217 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
221 let mut completed: HashSet<String> = HashSet::new();
222 let nodes_by_id: HashMap<String, ExpandedNode> =
223 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
224
225 let projections = build_projections(&nodes_by_id, &children_of);
228
229 let bfs_order: Vec<String> = {
233 let mut ids: Vec<(usize, String)> = nodes_by_id
234 .values()
235 .map(|n| (n.row_index, n.id.clone()))
236 .collect();
237 ids.sort_by_key(|(i, _)| *i);
238 ids.into_iter().map(|(_, id)| id).collect()
239 };
240
241 while !remaining.is_empty() {
242 let ready: Vec<String> = bfs_order
248 .iter()
249 .filter(|id| remaining.contains(*id))
250 .filter(|id| {
251 let node = &nodes_by_id[*id];
252 let parent_done = match &node.role {
253 NodeRole::Root => true,
254 NodeRole::Child { parent_id, .. } => {
255 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
256 }
257 };
258 parent_done
259 && node
260 .depends_on
261 .iter()
262 .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
263 })
264 .cloned()
265 .collect();
266
267 if ready.is_empty() {
268 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
273 stuck.sort();
274 return Err(CliError::Internal(format!(
275 "executor deadlock: {} node(s) never became ready (no completed/skipped \
276 parent or dependency): {}",
277 stuck.len(),
278 stuck.join(", ")
279 )));
280 }
281
282 let mut units: Vec<Unit> = Vec::new();
285 let level_records: HashMap<String, Vec<Arc<Value>>> = {
292 let consumed_parents: HashSet<&str> = ready
293 .iter()
294 .filter_map(|id| match &nodes_by_id[id].role {
295 NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
296 NodeRole::Root => None,
297 })
298 .collect();
299 let mut cap = captured.lock().await;
300 consumed_parents
301 .iter()
302 .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
303 .collect()
304 };
305 for id in &ready {
306 let node = &nodes_by_id[id];
307 if let NodeRole::Child { parent_id, .. } = &node.role
310 && skipped_subtrees.contains(parent_id)
311 {
312 skipped_subtrees.insert(id.clone());
313 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
314 continue;
315 }
316 if let Some(dep) = node
321 .depends_on
322 .iter()
323 .find(|d| skipped_subtrees.contains(d.as_str()))
324 {
325 skipped_subtrees.insert(id.clone());
326 tracing::warn!(
327 row = %id, dependency = %dep,
328 "skipping row: a depends_on row failed or was skipped"
329 );
330 continue;
331 }
332 match &node.role {
333 NodeRole::Root => {
334 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
335 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
336 validate_unit_state_key(&node.id, uses_state, &state_key)?;
337 units.push(Unit {
338 node: node.clone(),
339 parent_record: None,
340 state_key,
341 parent_record_key: None,
342 });
343 }
344 NodeRole::Child {
345 parent_id,
346 parent_key,
347 } => {
348 let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
349 if parent_records.is_empty() {
350 tracing::info!(
351 row = %id, parent = %parent_id,
352 "parent produced no records — child skipped"
353 );
354 continue;
355 }
356 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
358 let mut seen_keys: HashSet<String> = HashSet::new();
359 for record in &parent_records {
360 let pk_value = resolve_parent_key(record, parent_key);
361 let pk_string = pk_value
362 .as_ref()
363 .map(value_to_string_brief)
364 .unwrap_or_else(|| "(missing)".to_string());
365 let state_key =
366 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
367 validate_unit_state_key(&node.id, uses_state, &state_key)?;
368 if !seen_keys.insert(state_key.clone()) {
369 return Err(CliError::DuplicateStateKey {
370 id: node.id.clone(),
371 state_key,
372 });
373 }
374 units.push(Unit {
375 node: node.clone(),
376 parent_record: Some(record.clone()),
377 state_key,
378 parent_record_key: Some(pk_string),
379 });
380 }
381 }
382 }
383 }
384 drop(level_records);
385
386 let mut had_level_failure = false;
387 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
388
389 let level_cancel = cancel.child_token();
401 let mut joinset = tokio::task::JoinSet::new();
402 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
406 for unit in units {
407 let sem = Arc::clone(&semaphore);
408 let opts2 = Arc::clone(&opts);
409 let captured = Arc::clone(&captured);
410 let capture = projections.get(&unit.node.id).cloned();
411 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
412 let unit_cancel = level_cancel.clone();
413 let handle = joinset.spawn(async move {
414 let _permit = sem.acquire().await.expect("semaphore not closed");
415 run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
416 });
417 task_meta.insert(handle.id(), meta);
418 }
419
420 let mut stop_triggered = false;
421 let mut aborted = false;
422 let mut stop_deadline: Option<tokio::time::Instant> = None;
423 loop {
424 let joined = match stop_deadline {
429 Some(deadline) if !aborted => {
430 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
431 Ok(j) => j,
432 Err(_) => {
433 tracing::warn!(
434 "on_error: stop — flush grace elapsed; aborting remaining \
435 in-flight invocations"
436 );
437 joinset.abort_all();
438 aborted = true;
439 continue;
440 }
441 }
442 }
443 _ => joinset.join_next_with_id().await,
444 };
445 let Some(joined) = joined else { break };
446 let outcome = match joined {
450 Ok((_id, outcome)) => outcome,
451 Err(e) if e.is_cancelled() => {
452 continue;
455 }
456 Err(e) => {
457 let (row_id, parent_record_key) = task_meta
458 .get(&e.id())
459 .cloned()
460 .unwrap_or_else(|| ("<unknown>".to_string(), None));
461 InvocationOutcome {
462 row_id,
463 parent_record_key,
464 records_written: 0,
465 error: Some(format!("pipeline invocation task panicked: {e}")),
466 }
467 }
468 };
469
470 if let Some(err) = &outcome.error {
471 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
472 had_level_failure = true;
473 nodes_with_any_failure.insert(outcome.row_id.clone());
474 if matches!(on_error, OnError::Stop) && !stop_triggered {
475 stop_triggered = true;
476 tracing::error!(
477 "on_error: stop — cancelling in-flight invocations (cooperative \
478 flush), then aborting any that don't stop within the grace window"
479 );
480 level_cancel.cancel();
484 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
485 }
486 } else {
487 tracing::info!(
488 row = %outcome.row_id,
489 records_written = outcome.records_written,
490 "pipeline invocation completed"
491 );
492 }
493 outcomes.push(outcome);
494 }
495
496 for id in ready {
500 remaining.remove(&id);
501 if nodes_with_any_failure.contains(&id) {
502 skipped_subtrees.insert(id.clone());
503 if let Some(children) = children_of.get(&id) {
505 for cid in children {
506 skipped_subtrees.insert(cid.clone());
507 }
508 }
509 } else {
510 completed.insert(id);
511 }
512 }
513
514 if had_level_failure && matches!(on_error, OnError::Stop) {
515 tracing::error!("on_error: stop — aborting after first failure");
516 break;
518 }
519 }
520
521 Ok(RunSummary {
522 invocations: outcomes,
523 })
524}
525
526struct Unit {
529 node: ExpandedNode,
530 parent_record: Option<Arc<Value>>,
531 state_key: String,
532 parent_record_key: Option<String>,
533}
534
535async fn run_unit(
536 unit: &Unit,
537 capture: Option<Arc<Projection>>,
538 captured: &CapturedRecords,
539 opts: &ExecuteOptions,
540 cancel: CancellationToken,
541) -> InvocationOutcome {
542 let needs_capture = capture.is_some();
543 let result = run_one_invocation(
544 &unit.node,
545 unit.parent_record.as_deref(),
546 &unit.state_key,
547 capture,
548 opts,
549 cancel,
550 )
551 .await;
552 let row_id = unit.node.id.clone();
553 let parent_record_key = unit.parent_record_key.clone();
554 match result {
555 Ok((records, written)) => {
556 if needs_capture {
557 captured
558 .lock()
559 .await
560 .entry(row_id.clone())
561 .or_default()
562 .extend(records.into_iter().map(Arc::new));
565 }
566 InvocationOutcome {
567 row_id,
568 parent_record_key,
569 records_written: written,
570 error: None,
571 }
572 }
573 Err(e) => InvocationOutcome {
574 row_id,
575 parent_record_key,
576 records_written: 0,
577 error: Some(e.to_string()),
578 },
579 }
580}
581
582pub(crate) fn build_state_key(
584 pipeline_name: &str,
585 row_id: &str,
586 parent_key: Option<&str>,
587) -> String {
588 match parent_key {
589 None => format!("{pipeline_name}::{row_id}"),
590 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
591 }
592}
593
594fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
599 if uses_state {
600 faucet_core::state::validate_state_key(state_key).map_err(|e| {
601 CliError::InvalidStateKey {
602 id: node_id.to_owned(),
603 state_key: state_key.to_owned(),
604 reason: e.to_string(),
605 }
606 })?;
607 }
608 Ok(())
609}
610
611fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
613 let mut cur = record;
614 for segment in parent_key.split('.') {
615 cur = match cur {
616 Value::Object(m) => m.get(segment)?,
617 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
618 _ => return None,
619 };
620 }
621 Some(cur.clone())
622}
623
624#[derive(Debug, Clone)]
628enum Projection {
629 Full,
632 Paths(Vec<Vec<String>>),
634}
635
636fn split_path(path: &str) -> Vec<String> {
638 path.split('.').map(|s| s.to_string()).collect()
639}
640
641fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
645 paths.sort();
646 paths.dedup();
647 let mut kept: Vec<Vec<String>> = Vec::new();
648 for p in paths {
649 let covered = kept
650 .iter()
651 .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
652 if !covered {
653 kept.push(p);
654 }
655 }
656 kept
657}
658
659fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
662 let mut cur = record;
663 for seg in segments {
664 cur = match cur {
665 Value::Object(m) => m.get(seg)?,
666 Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
667 _ => return None,
668 };
669 }
670 Some(cur.clone())
671}
672
673fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
678 if segments.is_empty() {
679 return;
680 }
681 let mut cur = out;
682 for seg in &segments[..segments.len() - 1] {
683 let map = match cur {
684 Value::Object(m) => m,
685 _ => return,
686 };
687 cur = map
688 .entry(seg.clone())
689 .or_insert_with(|| Value::Object(serde_json::Map::new()));
690 }
691 if let Value::Object(m) = cur {
692 m.insert(segments[segments.len() - 1].clone(), leaf);
693 }
694}
695
696fn project_record(record: &Value, projection: &Projection) -> Value {
701 match projection {
702 Projection::Full => record.clone(),
703 Projection::Paths(paths) => {
704 let mut out = Value::Object(serde_json::Map::new());
705 for segs in paths {
706 if let Some(v) = walk_value(record, segs) {
707 graft_object(&mut out, segs, v);
708 }
709 }
710 out
711 }
712 }
713}
714
715fn build_projections(
720 nodes_by_id: &HashMap<String, ExpandedNode>,
721 children_of: &HashMap<String, Vec<String>>,
722) -> HashMap<String, Arc<Projection>> {
723 let mut out = HashMap::new();
724 for (parent_id, child_ids) in children_of {
725 let mut raw: Vec<Vec<String>> = Vec::new();
726 let mut full = false;
727 for cid in child_ids {
728 let child = &nodes_by_id[cid];
729 if let NodeRole::Child { parent_key, .. } = &child.role {
730 if parent_key.is_empty() {
731 full = true;
732 } else {
733 raw.push(split_path(parent_key));
734 }
735 }
736 for dref in &child.deferred_refs {
737 if dref.referenced_id == *parent_id {
738 if dref.dotted_path.is_empty() {
739 full = true; } else {
741 raw.push(split_path(&dref.dotted_path));
742 }
743 }
744 }
745 }
746 let projection = if full || raw.is_empty() {
751 Projection::Full
752 } else {
753 Projection::Paths(minimal_paths(raw))
754 };
755 out.insert(parent_id.clone(), Arc::new(projection));
756 }
757 out
758}
759
760async fn run_one_invocation(
762 node: &ExpandedNode,
763 parent_record: Option<&Value>,
764 state_key: &str,
765 capture: Option<Arc<Projection>>,
766 opts: &ExecuteOptions,
767 cancel: CancellationToken,
768) -> CliResult<(Vec<Value>, usize)> {
769 let run_id = uuid::Uuid::now_v7().to_string();
772 let pipeline_name = opts.pipeline_name.clone();
773 let row_id = node.id.clone();
774 #[cfg(feature = "lineage")]
775 let lineage = opts.lineage.clone();
776 #[cfg(feature = "lineage")]
777 let lineage_cfg = opts.lineage_cfg.clone();
778 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
779 #[cfg(feature = "catalog")]
783 let catalog_active = opts.catalog.is_some()
784 && matches!(node.role, NodeRole::Root)
785 && !opts.dry_run
786 && opts.limit.is_none()
787 && opts.shard.is_none();
788 let mut source_cfg = node.source.config.clone();
790 let mut sink_cfg = node.sink.config.clone();
791
792 resolve_now_inplace(&mut source_cfg, opts.clock)?;
795 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
796
797 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
798 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
799 resolve_inplace(&mut source_cfg, &ctx)?;
800 resolve_inplace(&mut sink_cfg, &ctx)?;
801 }
802
803 let source = match node.source_override.as_ref().and_then(|o| o.take()) {
808 Some(prebuilt) => prebuilt,
809 None => {
810 build_source(
811 &node.source.kind,
812 source_cfg,
813 &opts.auth,
814 opts.resilience.as_ref().map(|r| &r.retry),
815 )
816 .await?
817 }
818 };
819
820 #[cfg(feature = "catalog")]
823 let source_dataset_uri = source.dataset_uri();
824
825 if let Some(shard) = &opts.shard {
829 source
830 .apply_shard(shard)
831 .await
832 .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
833 }
834 let raw_sink: Box<dyn Sink> = if opts.dry_run {
835 Box::new(CountingSink::new())
836 } else {
837 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
838 };
839 #[cfg(feature = "catalog")]
840 let sink_dataset_uri = raw_sink.dataset_uri();
841 let raw_sink: Box<dyn Sink> = match opts.limit {
842 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
843 None => raw_sink,
844 };
845 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
846 let sink: Box<dyn Sink> = match &capture {
847 Some(projection) => Box::new(CapturingSink::wrap(
848 raw_sink,
849 Arc::clone(&captured),
850 Arc::clone(projection),
851 )),
852 None => raw_sink,
853 };
854
855 #[cfg(feature = "lineage")]
861 let (in_sample, out_sample) = {
862 use std::sync::Arc as StdArc;
863 let mut want = false;
864 let mut cap = 0usize;
865 if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
866 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
867 if want_schema {
868 cap = cap.max(lc.sample_records);
869 }
870 want = want_schema || lc.emit_on.running;
871 }
872 #[cfg(feature = "catalog")]
876 if catalog_active {
877 want = true;
878 cap = cap.max(
879 opts.catalog
880 .as_ref()
881 .map(|h| h.sample_records)
882 .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
883 );
884 }
885 if want {
886 (
887 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
888 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
889 )
890 } else {
891 (None, None)
892 }
893 };
894
895 #[cfg(feature = "lineage")]
898 let source: Box<dyn Source> = match &in_sample {
899 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
900 source,
901 std::sync::Arc::clone(state),
902 )),
903 None => source,
904 };
905
906 let stages = if node.transforms.is_empty() {
911 compile_transforms(&node.transforms)?
912 } else {
913 let mut transforms = node.transforms.clone();
914 for t in &mut transforms {
915 resolve_now_inplace(&mut t.config, opts.clock)?;
916 }
917 compile_transforms(&transforms)?
918 };
919 let source: Box<dyn Source> = if stages.is_empty() {
920 source
921 } else {
922 Box::new(faucet_core::TransformingSource::new(
923 source,
924 stages,
925 obs_labels.clone(),
926 )?)
927 };
928
929 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
933 let sla_store = state.clone();
936 let effective_state_key = match &opts.shard {
939 Some(shard) => format!("{state_key}::{}", shard.id),
940 None => state_key.to_owned(),
941 };
942 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
943 Box::new(StateKeyOverride {
944 inner: source,
945 key: effective_state_key,
946 })
947 } else {
948 source
949 };
950
951 #[cfg(feature = "lineage")]
954 let sink: Box<dyn Sink> = match &out_sample {
955 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
956 sink,
957 std::sync::Arc::clone(state),
958 )),
959 None => sink,
960 };
961
962 #[cfg(feature = "lineage")]
967 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
968 .with_name(pipeline_name.clone())
969 .with_row(row_id.clone())
970 .with_run_id(run_id.clone());
971 #[cfg(not(feature = "lineage"))]
972 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
973 .with_name(pipeline_name)
974 .with_row(row_id)
975 .with_run_id(run_id);
976 let pipeline = match state {
977 Some(store) => pipeline.with_state_store(store),
978 None => pipeline,
979 };
980 let pipeline = if let Some(ref dlq_spec) = node.dlq {
981 let dlq_cfg = build_dlq_config(dlq_spec).await?;
982 pipeline.with_dlq(dlq_cfg)
983 } else {
984 pipeline
985 };
986 let pipeline = pipeline.with_cancel(cancel.clone());
992 #[cfg(feature = "quality")]
996 let pipeline = if let Some(ref quality_spec) = node.quality {
997 let compiled = Arc::new(
998 faucet_core::CompiledQuality::compile(quality_spec)
999 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
1000 );
1001 pipeline.with_quality(compiled)
1002 } else {
1003 pipeline
1004 };
1005 #[cfg(feature = "contract")]
1009 let pipeline = if let Some(ref contract_spec) = node.contract {
1010 let compiled = Arc::new(
1011 faucet_core::CompiledContract::compile(contract_spec)
1012 .map_err(|e| CliError::Config(format!("contract: {e}")))?,
1013 );
1014 pipeline.with_contract(compiled)
1015 } else {
1016 pipeline
1017 };
1018 #[cfg(feature = "masking")]
1024 let pipeline = if let Some(ref masking_spec) = node.masking {
1025 let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
1026 let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
1027 .map_err(|e| CliError::Config(format!("masking: {e}")))?;
1028 if compiled.is_empty() {
1029 pipeline
1030 } else {
1031 pipeline.with_masking(Arc::new(compiled))
1032 }
1033 } else {
1034 pipeline
1035 };
1036 let pipeline = if let Some(ref sd) = node.schema {
1038 pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd))
1039 } else {
1040 pipeline
1041 };
1042 let pipeline = if let Some(ab) = opts
1044 .execution
1045 .as_ref()
1046 .and_then(|e| e.adaptive_batch_size.clone())
1047 {
1048 ab.validate()
1049 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
1050 pipeline.with_adaptive(ab)
1051 } else {
1052 pipeline
1053 };
1054 let pipeline = if let Some(policy) = opts.resilience.clone() {
1057 pipeline.with_resilience(policy)
1058 } else {
1059 pipeline
1060 };
1061 let pipeline = pipeline.with_delivery(node.delivery);
1064 #[cfg(feature = "lineage")]
1066 let lineage_ctx = match (&lineage, &lineage_cfg) {
1067 (Some(em), Some(lc)) => {
1068 let job_name =
1069 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1070 let mut ctx = faucet_lineage::RunLifecycle {
1071 job_namespace: lc.namespace.clone(),
1072 job_name,
1073 run_id: run_id.clone(),
1074 parent: lc.parent_job.clone(),
1075 input: faucet_lineage::DatasetRef {
1076 namespace: lc.namespace.clone(),
1077 name: source.dataset_uri(),
1078 },
1079 output: faucet_lineage::DatasetRef {
1080 namespace: lc.namespace.clone(),
1081 name: sink.dataset_uri(),
1082 },
1083 started_at: chrono::Utc::now(),
1084 finished_at: None,
1085 records: 0,
1086 error: None,
1087 input_schema: None,
1088 output_schema: None,
1089 column_lineage: None,
1090 source_code: None,
1091 };
1092 em.emit(faucet_lineage::EventType::Start, &ctx).await;
1093 let hb_handle = if lc.emit_on.running {
1096 let em2 = std::sync::Arc::clone(em);
1097 let interval = lc.heartbeat_interval;
1098 let mut beat_ctx = ctx.clone();
1099 let counter = out_sample.clone();
1100 Some(tokio::spawn(async move {
1101 let mut tick = tokio::time::interval(interval);
1102 tick.tick().await; loop {
1104 tick.tick().await;
1105 if let Some(c) = &counter {
1106 beat_ctx.records = c.count();
1107 }
1108 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1109 .await;
1110 }
1111 }))
1112 } else {
1113 None
1114 };
1115 ctx.source_code = if lc.include_source_code_facet {
1116 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1117 } else {
1118 None
1119 };
1120 Some((std::sync::Arc::clone(em), ctx, hb_handle))
1121 }
1122 _ => None,
1123 };
1124
1125 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1132 Ok(r) => sink.flush().await.map(|_| r),
1133 Err(e) => Err(e),
1134 };
1135
1136 #[cfg(feature = "lineage")]
1137 if let Some((em, mut ctx, hb)) = lineage_ctx {
1138 if let Some(h) = hb {
1139 h.abort();
1140 }
1141 ctx.finished_at = Some(chrono::Utc::now());
1142 if let Some(state) = &out_sample {
1143 ctx.records = state.count();
1144 if lineage_cfg
1145 .as_ref()
1146 .map(|l| l.include_schema_facet)
1147 .unwrap_or(false)
1148 {
1149 ctx.output_schema = Some(state.inferred_schema());
1150 }
1151 }
1152 if let Some(state) = &in_sample
1153 && lineage_cfg
1154 .as_ref()
1155 .map(|l| l.include_schema_facet || l.include_column_lineage)
1156 .unwrap_or(false)
1157 {
1158 let in_schema = state.inferred_schema();
1159 if lineage_cfg
1160 .as_ref()
1161 .map(|l| l.include_column_lineage)
1162 .unwrap_or(false)
1163 {
1164 let input_fields: Vec<String> =
1165 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1166 #[cfg(feature = "masking")]
1167 let has_masking = node.masking.is_some();
1168 #[cfg(not(feature = "masking"))]
1169 let has_masking = false;
1170 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1171 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1172 }
1173 if lineage_cfg
1174 .as_ref()
1175 .map(|l| l.include_schema_facet)
1176 .unwrap_or(false)
1177 {
1178 ctx.input_schema = Some(in_schema);
1179 }
1180 }
1181 let ev = match &result {
1182 Err(e) => {
1183 ctx.error = Some(e.to_string());
1184 faucet_lineage::EventType::Fail
1185 }
1186 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1187 Ok(_) => faucet_lineage::EventType::Complete,
1188 };
1189 em.emit(ev, &ctx).await;
1190 }
1191
1192 let is_notifiable_root = matches!(node.role, NodeRole::Root)
1202 && !opts.dry_run
1203 && opts.limit.is_none()
1204 && opts.shard.is_none()
1205 && !cancel.is_cancelled();
1206
1207 #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1208 let sla_violations = if let Some(spec) = &opts.sla
1209 && is_notifiable_root
1210 {
1211 let outcome = match &result {
1212 Ok(r) => crate::sla::RunOutcome::Success {
1213 rows: r.records_written as u64,
1214 },
1215 Err(_) => crate::sla::RunOutcome::Failure,
1216 };
1217 crate::sla::evaluate_post_run(
1218 spec,
1219 sla_store.as_ref(),
1220 state_key,
1221 &obs_labels.pipeline,
1222 &obs_labels.row,
1223 outcome,
1224 chrono::Utc::now().timestamp(),
1225 )
1226 .await
1227 } else {
1228 Vec::new()
1229 };
1230
1231 #[cfg(feature = "notify")]
1236 if let Some(notifier) = &opts.notifier
1237 && is_notifiable_root
1238 {
1239 use crate::notify::NotifyEvent;
1240 let pipeline = obs_labels.pipeline.to_string();
1241 let row = obs_labels.row.to_string();
1242 match &result {
1243 Ok(r) => {
1244 notifier
1245 .emit(NotifyEvent::run_success(
1246 pipeline.clone(),
1247 row.clone(),
1248 r.records_written as u64,
1249 ))
1250 .await;
1251 if let Some(dlq) = &r.dlq
1252 && dlq.records_dlq > 0
1253 {
1254 notifier
1255 .emit(NotifyEvent::dlq_threshold(
1256 pipeline.clone(),
1257 row.clone(),
1258 dlq.records_dlq as u64,
1259 ))
1260 .await;
1261 }
1262 }
1263 Err(e) => {
1264 notifier.emit(error_event(&pipeline, &row, e)).await;
1265 }
1266 }
1267 for v in &sla_violations {
1268 notifier
1269 .emit(NotifyEvent::sla_breach(
1270 pipeline.clone(),
1271 row.clone(),
1272 v.kind(),
1273 v.to_string(),
1274 ))
1275 .await;
1276 }
1277 }
1278
1279 #[cfg(feature = "catalog")]
1284 if let Some(handle) = &opts.catalog
1285 && catalog_active
1286 && !cancel.is_cancelled()
1287 && let Ok(pipeline_result) = &result
1288 {
1289 use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1290 use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1291
1292 let records_written = pipeline_result.records_written as u64;
1293 let source_schema = in_sample
1294 .as_ref()
1295 .and_then(|s| schema_from_samples(&s.samples()));
1296 let sink_schema = out_sample
1297 .as_ref()
1298 .and_then(|s| schema_from_samples(&s.samples()));
1299 let records_read = in_sample
1302 .as_ref()
1303 .map(|s| s.count())
1304 .unwrap_or(records_written);
1305 let records_out = out_sample
1306 .as_ref()
1307 .map(|s| s.count())
1308 .unwrap_or(records_written);
1309
1310 let column_lineage = in_sample.as_ref().and_then(|s| {
1313 let input_fields: Vec<String> = s
1314 .inferred_schema()
1315 .fields
1316 .iter()
1317 .map(|(n, _)| n.clone())
1318 .collect();
1319 #[cfg(feature = "masking")]
1320 let has_masking = node.masking.is_some();
1321 #[cfg(not(feature = "masking"))]
1322 let has_masking = false;
1323 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1324 faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1325 let fields: serde_json::Map<String, Value> = cl
1328 .edges
1329 .iter()
1330 .map(|(out, ins)| {
1331 (
1332 out.clone(),
1333 Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1334 )
1335 })
1336 .collect();
1337 serde_json::json!({ "fields": fields })
1338 })
1339 });
1340
1341 let update = CatalogUpdate {
1342 run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1343 pipeline: obs_labels.pipeline.to_string(),
1344 row: obs_labels.row.to_string(),
1345 recorded_at: chrono::Utc::now(),
1346 source: DatasetObservation {
1347 uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1348 kind: node.source.kind.clone(),
1349 role: DatasetRole::Source,
1350 schema: source_schema,
1351 records: records_read,
1352 },
1353 sink: DatasetObservation {
1354 uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1355 kind: node.sink.kind.clone(),
1356 role: DatasetRole::Sink,
1357 schema: sink_schema,
1358 records: records_out,
1359 },
1360 column_lineage,
1361 };
1362 crate::catalog::record(handle, &update).await;
1363 }
1364
1365 let result = result?;
1366
1367 let captured = if capture.is_some() {
1368 std::mem::take(&mut *captured.lock().await)
1369 } else {
1370 Vec::new()
1371 };
1372 Ok((captured, result.records_written))
1373}
1374
1375async fn build_state_for_node(
1376 node: &ExpandedNode,
1377 state_path_override: Option<&Path>,
1378) -> CliResult<Option<Arc<dyn StateStore>>> {
1379 match (&node.state, state_path_override) {
1380 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1381 (None, Some(path)) => Ok(Some(state_from_override(path))),
1382 (Some(spec), Some(path)) => {
1383 if spec.kind == "file" {
1384 Ok(Some(state_from_override(path)))
1385 } else {
1386 tracing::warn!(
1387 state = %spec.kind,
1388 "--state-path is only meaningful for the 'file' backend; ignoring override"
1389 );
1390 Ok(Some(build_state_store(spec).await?))
1391 }
1392 }
1393 (None, None) => Ok(None),
1394 }
1395}
1396
1397fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1398 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1399}
1400
1401pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1404 let sink = build_sink(
1407 &spec.sink.kind,
1408 spec.sink.config.clone(),
1409 &AuthCatalog::new(),
1410 )
1411 .await?;
1412 Ok(DlqConfig {
1413 sink: Arc::from(sink),
1414 on_batch_error: match spec.on_batch_error {
1415 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1416 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1417 },
1418 max_failures_per_page: spec.max_failures_per_page,
1419 max_failures_total: spec.max_failures_total,
1420 include_original_payload: spec.include_original_payload,
1421 })
1422}
1423
1424#[cfg(feature = "notify")]
1428fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1429 use crate::notify::NotifyEvent;
1430 match err {
1431 FaucetError::CircuitOpen { failures, cooldown } => {
1432 NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1433 }
1434 FaucetError::ContractViolation { message, .. } => {
1435 NotifyEvent::contract_abort(pipeline, row, message.clone())
1436 }
1437 other => {
1438 NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1439 }
1440 }
1441}
1442
1443#[cfg(feature = "notify")]
1447fn faucet_error_kind(err: &FaucetError) -> &'static str {
1448 match err {
1449 FaucetError::Config(_) => "config",
1450 FaucetError::Source(_) => "source",
1451 FaucetError::Sink(_) => "sink",
1452 FaucetError::State(_) => "state",
1453 FaucetError::QualityFailure { .. } => "quality",
1454 FaucetError::SchemaDrift { .. } => "schema_drift",
1455 _ => "error",
1456 }
1457}
1458
1459pub(crate) fn resolve_now_inplace(
1464 value: &mut Value,
1465 clock: DateTime<FixedOffset>,
1466) -> CliResult<()> {
1467 match value {
1468 Value::String(s) => {
1469 *s = crate::interpolate::resolve_now(s, clock)?;
1470 Ok(())
1471 }
1472 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1473 Value::Object(m) => m
1474 .values_mut()
1475 .try_for_each(|v| resolve_now_inplace(v, clock)),
1476 _ => Ok(()),
1477 }
1478}
1479
1480fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1484 match value {
1485 Value::String(s) => {
1486 let resolved = interpolate_record(s, ctx)?;
1487 *s = resolved;
1488 Ok(())
1489 }
1490 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1491 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1492 _ => Ok(()),
1493 }
1494}
1495
1496struct StateKeyOverride {
1502 inner: Box<dyn Source>,
1503 key: String,
1504}
1505
1506#[async_trait]
1507impl Source for StateKeyOverride {
1508 async fn fetch_with_context(
1509 &self,
1510 ctx: &HashMap<String, Value>,
1511 ) -> Result<Vec<Value>, FaucetError> {
1512 self.inner.fetch_with_context(ctx).await
1513 }
1514 async fn fetch_with_context_incremental(
1515 &self,
1516 ctx: &HashMap<String, Value>,
1517 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1518 self.inner.fetch_with_context_incremental(ctx).await
1519 }
1520 fn connector_name(&self) -> &'static str {
1521 self.inner.connector_name()
1522 }
1523 fn dataset_uri(&self) -> String {
1524 self.inner.dataset_uri()
1525 }
1526 fn state_key(&self) -> Option<String> {
1527 Some(self.key.clone())
1528 }
1529 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1530 self.inner.apply_start_bookmark(bookmark).await
1531 }
1532}
1533
1534struct CapturingSink {
1538 inner: Box<dyn Sink>,
1539 captured: Arc<Mutex<Vec<Value>>>,
1540 projection: Arc<Projection>,
1541}
1542
1543impl CapturingSink {
1544 fn wrap(
1545 inner: Box<dyn Sink>,
1546 captured: Arc<Mutex<Vec<Value>>>,
1547 projection: Arc<Projection>,
1548 ) -> Self {
1549 Self {
1550 inner,
1551 captured,
1552 projection,
1553 }
1554 }
1555}
1556
1557#[async_trait]
1558impl Sink for CapturingSink {
1559 fn connector_name(&self) -> &'static str {
1560 self.inner.connector_name()
1561 }
1562 fn dataset_uri(&self) -> String {
1563 self.inner.dataset_uri()
1564 }
1565 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1566 let written = self.inner.write_batch(records).await?;
1567 let n = written.min(records.len());
1570 let mut buf = self.captured.lock().await;
1571 buf.extend(
1572 records
1573 .iter()
1574 .take(n)
1575 .map(|r| project_record(r, &self.projection)),
1576 );
1577 Ok(written)
1578 }
1579 async fn flush(&self) -> Result<(), FaucetError> {
1580 self.inner.flush().await
1581 }
1582}
1583
1584struct LimitedSink {
1587 inner: Box<dyn Sink>,
1588 remaining: AtomicUsize,
1589}
1590
1591impl LimitedSink {
1592 fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1593 Self {
1594 inner,
1595 remaining: AtomicUsize::new(cap),
1596 }
1597 }
1598}
1599
1600#[async_trait]
1601impl Sink for LimitedSink {
1602 fn connector_name(&self) -> &'static str {
1603 self.inner.connector_name()
1604 }
1605 fn dataset_uri(&self) -> String {
1606 self.inner.dataset_uri()
1607 }
1608 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1609 let remaining = self.remaining.load(Ordering::Relaxed);
1610 if remaining == 0 {
1611 return Ok(0);
1612 }
1613 let take = remaining.min(records.len());
1614 let slice = &records[..take];
1615 let written = self.inner.write_batch(slice).await?;
1616 self.remaining
1617 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1618 Ok(written)
1619 }
1620 async fn flush(&self) -> Result<(), FaucetError> {
1621 self.inner.flush().await
1622 }
1623}
1624
1625struct CountingSink {
1628 seen: AtomicUsize,
1629}
1630
1631impl CountingSink {
1632 fn new() -> Self {
1633 Self {
1634 seen: AtomicUsize::new(0),
1635 }
1636 }
1637}
1638
1639#[async_trait]
1640impl Sink for CountingSink {
1641 fn connector_name(&self) -> &'static str {
1642 "dry-run"
1643 }
1644 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1645 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1646 Ok(records.len())
1647 }
1648}
1649
1650fn value_to_string_brief(v: &Value) -> String {
1653 match v {
1654 Value::String(s) => s.clone(),
1655 other => other.to_string(),
1656 }
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661 use super::*;
1662 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1663 use crate::expand::expand;
1664 use serde_json::json;
1665
1666 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1667 PipelineConfig {
1668 version: 1,
1669 name: Some("test".into()),
1670 vars: None,
1671 auth: None,
1672 pipeline: PipelineSpec {
1673 source: Some(ConnectorSpec {
1674 kind: "csv".into(),
1675 config: json!({"path": input.to_str().unwrap()}),
1676 transforms: None,
1677 inherit_transforms: true,
1678 }),
1679 sink: Some(ConnectorSpec {
1680 kind: "jsonl".into(),
1681 config: json!({"path": output.to_str().unwrap()}),
1682 transforms: None,
1683 inherit_transforms: true,
1684 }),
1685 sources: Default::default(),
1686 sinks: Default::default(),
1687 transforms: Vec::new(),
1688 state: None,
1689 dlq: None,
1690 #[cfg(feature = "quality")]
1691 quality: None,
1692 #[cfg(feature = "contract")]
1693 contract: None,
1694 #[cfg(feature = "masking")]
1695 masking: None,
1696 schema: None,
1697 },
1698 matrix: Vec::new(),
1699 execution: None,
1700 observability: None,
1701 delivery: faucet_core::DeliveryMode::default(),
1702 resilience: None,
1703 sla: None,
1704 shard: None,
1705 replication: None,
1706 #[cfg(feature = "schedule")]
1707 schedule: None,
1708 #[cfg(feature = "lineage")]
1709 lineage: None,
1710 #[cfg(feature = "catalog")]
1711 catalog: None,
1712 #[cfg(feature = "notify")]
1713 notifications: Vec::new(),
1714 }
1715 }
1716
1717 #[tokio::test]
1718 async fn empty_matrix_runs_pipeline_once() {
1719 let dir = tempfile::tempdir().unwrap();
1720 let input = dir.path().join("in.csv");
1721 let output = dir.path().join("out.jsonl");
1722 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1723 let cfg = cfg_csv_to_jsonl(&input, &output);
1724 let nodes = expand(&cfg).unwrap();
1725 let summary = run_expanded(
1726 nodes,
1727 ExecuteOptions {
1728 pipeline_name: "t".into(),
1729 execution: None,
1730 dry_run: false,
1731 limit: None,
1732 state_path_override: None,
1733 shard: None,
1734 auth: Default::default(),
1735 clock: chrono::Utc::now().fixed_offset(),
1736 cancel: None,
1737 resilience: None,
1738 sla: None,
1739 #[cfg(feature = "lineage")]
1740 lineage: None,
1741 #[cfg(feature = "lineage")]
1742 lineage_cfg: None,
1743 #[cfg(feature = "notify")]
1744 notifier: None,
1745 #[cfg(feature = "catalog")]
1746 catalog: None,
1747 },
1748 )
1749 .await
1750 .unwrap();
1751 assert_eq!(summary.invocations.len(), 1);
1752 assert_eq!(summary.invocations[0].records_written, 2);
1753 assert!(!summary.had_failures());
1754 let body = std::fs::read_to_string(&output).unwrap();
1755 assert_eq!(body.lines().count(), 2);
1756 }
1757
1758 #[cfg(feature = "catalog")]
1760 fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
1761 let mut o = opts(name);
1762 o.catalog = Some(handle);
1763 o
1764 }
1765
1766 #[cfg(feature = "catalog")]
1767 #[tokio::test]
1768 async fn catalog_records_schema_timeline_across_two_runs() {
1769 use crate::catalog::CatalogHandle;
1773 use crate::serve::history::RunHistory as _;
1774 use crate::serve::history::catalog::{self, CatalogListFilter};
1775 use crate::serve::history::memory::MemoryHistory;
1776
1777 let dir = tempfile::tempdir().unwrap();
1778 let input = dir.path().join("in.csv");
1779 let output = dir.path().join("out.jsonl");
1780 let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
1781 let handle = CatalogHandle {
1782 store: store.clone(),
1783 run_id: None,
1784 sample_records: 10,
1785 };
1786
1787 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
1788 let cfg = cfg_csv_to_jsonl(&input, &output);
1789 let nodes = expand(&cfg).unwrap();
1790 let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
1791 .await
1792 .unwrap();
1793 assert!(!summary.had_failures());
1794
1795 std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
1797 let nodes = expand(&cfg).unwrap();
1798 let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
1799 .await
1800 .unwrap();
1801 assert!(!summary.had_failures());
1802
1803 let page = store
1805 .catalog_list_datasets(&CatalogListFilter {
1806 limit: 10,
1807 ..Default::default()
1808 })
1809 .await
1810 .unwrap();
1811 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
1812 for ds in &page.datasets {
1813 let detail = store
1814 .catalog_get_dataset(&ds.id)
1815 .await
1816 .unwrap()
1817 .expect("dataset detail");
1818 assert_eq!(detail.dataset.runs, 2);
1819 assert_eq!(
1820 detail.schema_timeline.len(),
1821 2,
1822 "exactly two timeline entries for {}",
1823 ds.uri
1824 );
1825 assert!(detail.schema_timeline[0].diff.is_none());
1826 let diff = detail.schema_timeline[1]
1827 .diff
1828 .as_ref()
1829 .expect("second version carries a diff");
1830 assert!(
1831 diff["added"]
1832 .as_array()
1833 .unwrap()
1834 .iter()
1835 .any(|c| c["column"] == "email"),
1836 "diff must show the added email column: {diff}"
1837 );
1838 assert_eq!(detail.stats.len(), 2, "one volume point per run");
1839 }
1840 let edges = store.catalog_lineage(None, 5).await.unwrap();
1842 assert_eq!(edges.len(), 1);
1843 assert_eq!(edges[0].runs, 2);
1844 assert_eq!(edges[0].last_records, 2);
1845 assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
1846 }
1847
1848 #[cfg(feature = "catalog")]
1851 struct FailingCatalogStore;
1852
1853 #[cfg(feature = "catalog")]
1854 #[async_trait]
1855 impl crate::serve::history::RunHistory for FailingCatalogStore {
1856 async fn claim_idempotency(
1857 &self,
1858 _: &str,
1859 _: &str,
1860 _: &str,
1861 _: std::time::Duration,
1862 ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
1863 Err(crate::serve::history::HistoryError::Backend("down".into()))
1864 }
1865 async fn upsert(
1866 &self,
1867 _: &crate::serve::history::RunRecord,
1868 ) -> Result<(), crate::serve::history::HistoryError> {
1869 Err(crate::serve::history::HistoryError::Backend("down".into()))
1870 }
1871 async fn get(
1872 &self,
1873 _: &str,
1874 ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
1875 {
1876 Err(crate::serve::history::HistoryError::Backend("down".into()))
1877 }
1878 async fn list(
1879 &self,
1880 _: &crate::serve::history::ListFilter,
1881 ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
1882 Err(crate::serve::history::HistoryError::Backend("down".into()))
1883 }
1884 async fn delete(
1885 &self,
1886 _: &str,
1887 ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
1888 {
1889 Err(crate::serve::history::HistoryError::Backend("down".into()))
1890 }
1891 async fn purge_expired(
1892 &self,
1893 _: std::time::Duration,
1894 ) -> Result<usize, crate::serve::history::HistoryError> {
1895 Err(crate::serve::history::HistoryError::Backend("down".into()))
1896 }
1897 async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
1898 Err(crate::serve::history::HistoryError::Backend("down".into()))
1899 }
1900 async fn catalog_record(
1901 &self,
1902 _: &crate::serve::history::catalog::CatalogUpdate,
1903 ) -> Result<(), crate::serve::history::HistoryError> {
1904 Err(crate::serve::history::HistoryError::Backend(
1905 "catalog write refused".into(),
1906 ))
1907 }
1908 fn degraded(&self) -> bool {
1909 false
1910 }
1911 }
1912
1913 #[cfg(feature = "catalog")]
1914 #[tokio::test]
1915 async fn catalog_write_failure_never_fails_the_run() {
1916 use crate::catalog::CatalogHandle;
1919 let dir = tempfile::tempdir().unwrap();
1920 let input = dir.path().join("in.csv");
1921 let output = dir.path().join("out.jsonl");
1922 std::fs::write(&input, "name\nalice\n").unwrap();
1923 let cfg = cfg_csv_to_jsonl(&input, &output);
1924 let nodes = expand(&cfg).unwrap();
1925 let handle = CatalogHandle {
1926 store: Arc::new(FailingCatalogStore),
1927 run_id: None,
1928 sample_records: 10,
1929 };
1930 let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
1931 .await
1932 .unwrap();
1933 assert!(
1934 !summary.had_failures(),
1935 "catalog failure must not fail the run"
1936 );
1937 assert_eq!(summary.invocations[0].records_written, 1);
1938 assert_eq!(
1939 std::fs::read_to_string(&output).unwrap().lines().count(),
1940 1,
1941 "sink output written despite the catalog error"
1942 );
1943 }
1944
1945 #[tokio::test]
1946 async fn matrix_two_independent_roots_both_run() {
1947 let dir = tempfile::tempdir().unwrap();
1949 let csv_a = dir.path().join("a.csv");
1950 let csv_b = dir.path().join("b.csv");
1951 let out_a = dir.path().join("a.jsonl");
1952 let out_b = dir.path().join("b.jsonl");
1953 std::fs::write(&csv_a, "name\nalice\n").unwrap();
1954 std::fs::write(&csv_b, "name\nbob\n").unwrap();
1955
1956 let yaml = format!(
1957 r#"version: 1
1958pipeline:
1959 source: {{ type: csv, config: {{ path: {a} }} }}
1960 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
1961matrix:
1962 - id: rowA
1963 - id: rowB
1964 source: {{ config: {{ path: {b} }} }}
1965 sink: {{ config: {{ path: {out_b} }} }}
1966"#,
1967 a = csv_a.display(),
1968 b = csv_b.display(),
1969 out_a = out_a.display(),
1970 out_b = out_b.display(),
1971 );
1972 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1973 let nodes = expand(&cfg).unwrap();
1974 let summary = run_expanded(
1975 nodes,
1976 ExecuteOptions {
1977 pipeline_name: "matrix".into(),
1978 execution: None,
1979 dry_run: false,
1980 limit: None,
1981 state_path_override: None,
1982 shard: None,
1983 auth: Default::default(),
1984 clock: chrono::Utc::now().fixed_offset(),
1985 cancel: None,
1986 resilience: None,
1987 sla: None,
1988 #[cfg(feature = "lineage")]
1989 lineage: None,
1990 #[cfg(feature = "lineage")]
1991 lineage_cfg: None,
1992 #[cfg(feature = "notify")]
1993 notifier: None,
1994 #[cfg(feature = "catalog")]
1995 catalog: None,
1996 },
1997 )
1998 .await
1999 .unwrap();
2000 assert_eq!(summary.invocations.len(), 2);
2001 assert!(out_a.exists());
2002 assert!(out_b.exists());
2003 }
2004
2005 #[tokio::test]
2006 async fn dag_child_fans_out_per_parent_record() {
2007 let dir = tempfile::tempdir().unwrap();
2010 let parent_csv = dir.path().join("parents.csv");
2011 let child_csv = dir.path().join("child.csv");
2012 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2013 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2014 let parent_out = dir.path().join("parents.jsonl");
2015 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2016
2017 let yaml = format!(
2018 r#"version: 1
2019pipeline:
2020 source: {{ type: csv, config: {{ path: {parent} }} }}
2021 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2022matrix:
2023 - id: parents
2024 - id: child
2025 parent: parents
2026 source: {{ config: {{ path: {child} }} }}
2027 sink: {{ config: {{ path: "{child_out}" }} }}
2028"#,
2029 parent = parent_csv.display(),
2030 parent_out = parent_out.display(),
2031 child = child_csv.display(),
2032 child_out = child_out_pattern.display(),
2033 );
2034 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2035 let nodes = expand(&cfg).unwrap();
2036 let summary = run_expanded(
2037 nodes,
2038 ExecuteOptions {
2039 pipeline_name: "dagtest".into(),
2040 execution: None,
2041 dry_run: false,
2042 limit: None,
2043 state_path_override: None,
2044 shard: None,
2045 auth: Default::default(),
2046 clock: chrono::Utc::now().fixed_offset(),
2047 cancel: None,
2048 resilience: None,
2049 sla: None,
2050 #[cfg(feature = "lineage")]
2051 lineage: None,
2052 #[cfg(feature = "lineage")]
2053 lineage_cfg: None,
2054 #[cfg(feature = "notify")]
2055 notifier: None,
2056 #[cfg(feature = "catalog")]
2057 catalog: None,
2058 },
2059 )
2060 .await
2061 .unwrap();
2062
2063 assert_eq!(summary.invocations.len(), 3);
2065 assert!(!summary.had_failures(), "{:?}", summary);
2066 assert!(dir.path().join("child-1.jsonl").exists());
2067 assert!(dir.path().join("child-2.jsonl").exists());
2068 }
2069
2070 #[tokio::test]
2071 async fn depends_on_root_runs_after_dependency() {
2072 let dir = tempfile::tempdir().unwrap();
2076 let input = dir.path().join("in.csv");
2077 let mid = dir.path().join("mid.csv");
2078 let out = dir.path().join("out.jsonl");
2079 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2080
2081 let yaml = format!(
2082 r#"version: 1
2083pipeline:
2084 source: {{ type: csv, config: {{ path: {input} }} }}
2085 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2086matrix:
2087 - id: stage
2088 sink: {{ type: csv, config: {{ path: {mid} }} }}
2089 - id: load
2090 depends_on: [stage]
2091 source: {{ config: {{ path: {mid} }} }}
2092"#,
2093 input = input.display(),
2094 mid = mid.display(),
2095 out = out.display(),
2096 );
2097 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2098 let nodes = expand(&cfg).unwrap();
2099 let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2100 assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2101 assert!(!summary.had_failures(), "{summary:?}");
2102 let load = summary
2103 .invocations
2104 .iter()
2105 .find(|i| i.row_id == "load")
2106 .unwrap();
2107 assert_eq!(load.records_written, 2);
2108 let written = std::fs::read_to_string(&out).unwrap();
2109 assert_eq!(written.lines().count(), 2);
2110 }
2111
2112 #[tokio::test]
2113 async fn diamond_dependency_waits_for_all_prerequisites() {
2114 let dir = tempfile::tempdir().unwrap();
2117 let input = dir.path().join("in.csv");
2118 let mid_a = dir.path().join("mid_a.csv");
2119 let mid_b = dir.path().join("mid_b.csv");
2120 let out = dir.path().join("out.jsonl");
2121 std::fs::write(&input, "name\nalice\n").unwrap();
2122
2123 let yaml = format!(
2124 r#"version: 1
2125pipeline:
2126 source: {{ type: csv, config: {{ path: {input} }} }}
2127 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2128matrix:
2129 - id: a
2130 sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2131 - id: b
2132 sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2133 - id: c
2134 depends_on: [a, b]
2135 source: {{ config: {{ path: {mid_a} }} }}
2136"#,
2137 input = input.display(),
2138 mid_a = mid_a.display(),
2139 mid_b = mid_b.display(),
2140 out = out.display(),
2141 );
2142 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2143 let nodes = expand(&cfg).unwrap();
2144 let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2145 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2146 assert!(!summary.had_failures(), "{summary:?}");
2147 assert!(mid_b.exists(), "b must have run before c became ready");
2148 assert!(out.exists());
2149 }
2150
2151 #[tokio::test]
2152 async fn failed_dependency_skips_dependent() {
2153 let dir = tempfile::tempdir().unwrap();
2156 let good_input = dir.path().join("good.csv");
2157 let out = dir.path().join("out.jsonl");
2158 std::fs::write(&good_input, "name\nalice\n").unwrap();
2159
2160 let yaml = format!(
2161 r#"version: 1
2162pipeline:
2163 source: {{ type: csv, config: {{ path: {good} }} }}
2164 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2165matrix:
2166 - id: stage
2167 source: {{ config: {{ path: {missing} }} }}
2168 - id: load
2169 depends_on: [stage]
2170"#,
2171 good = good_input.display(),
2172 missing = dir.path().join("nonexistent.csv").display(),
2173 out = out.display(),
2174 );
2175 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2176 let nodes = expand(&cfg).unwrap();
2177 let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2178 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2179 assert_eq!(summary.invocations[0].row_id, "stage");
2180 assert!(summary.invocations[0].error.is_some());
2181 assert!(
2182 !out.exists(),
2183 "dependent row must not run after its dependency failed"
2184 );
2185 }
2186
2187 #[tokio::test]
2188 async fn dependency_on_skipped_row_cascades() {
2189 let dir = tempfile::tempdir().unwrap();
2192 let good_input = dir.path().join("good.csv");
2193 let out = dir.path().join("q.jsonl");
2194 std::fs::write(&good_input, "id\n1\n").unwrap();
2195
2196 let yaml = format!(
2197 r#"version: 1
2198pipeline:
2199 source: {{ type: csv, config: {{ path: {good} }} }}
2200 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2201matrix:
2202 - id: p
2203 source: {{ config: {{ path: {missing} }} }}
2204 - id: c
2205 parent: p
2206 - id: q
2207 depends_on: [c]
2208"#,
2209 good = good_input.display(),
2210 missing = dir.path().join("nonexistent.csv").display(),
2211 out = out.display(),
2212 );
2213 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2214 let nodes = expand(&cfg).unwrap();
2215 let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2216 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2217 assert_eq!(summary.invocations[0].row_id, "p");
2218 assert!(summary.invocations[0].error.is_some());
2219 assert!(
2220 !out.exists(),
2221 "q must be skipped when its dependency was skipped"
2222 );
2223 }
2224
2225 #[tokio::test]
2226 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2227 let dir = tempfile::tempdir().unwrap();
2239 let good_csv = dir.path().join("good.csv");
2240 std::fs::write(&good_csv, "x\n1\n").unwrap();
2241 let good_out = dir.path().join("good.jsonl");
2242 let bad_sink_dir = dir.path().to_path_buf();
2243
2244 let yaml = format!(
2245 r#"version: 1
2246pipeline:
2247 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2248 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2249matrix:
2250 - id: bad
2251 sink: {{ config: {{ path: {bad_dir} }} }}
2252 - id: good
2253execution:
2254 max_concurrent: 1
2255 on_error: stop
2256"#,
2257 good_csv = good_csv.display(),
2258 good_out = good_out.display(),
2259 bad_dir = bad_sink_dir.display(),
2260 );
2261 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2262 let nodes = expand(&cfg).unwrap();
2263 let summary = run_expanded(
2264 nodes,
2265 ExecuteOptions {
2266 pipeline_name: "stoptest".into(),
2267 execution: cfg.execution.clone(),
2268 dry_run: false,
2269 limit: None,
2270 state_path_override: None,
2271 shard: None,
2272 auth: Default::default(),
2273 clock: chrono::Utc::now().fixed_offset(),
2274 cancel: None,
2275 resilience: None,
2276 sla: None,
2277 #[cfg(feature = "lineage")]
2278 lineage: None,
2279 #[cfg(feature = "lineage")]
2280 lineage_cfg: None,
2281 #[cfg(feature = "notify")]
2282 notifier: None,
2283 #[cfg(feature = "catalog")]
2284 catalog: None,
2285 },
2286 )
2287 .await
2288 .unwrap();
2289
2290 assert!(summary.had_failures(), "the failing root must be reported");
2292
2293 let bad: Vec<_> = summary
2295 .invocations
2296 .iter()
2297 .filter(|o| o.row_id == "bad")
2298 .collect();
2299 assert_eq!(bad.len(), 1, "bad must run exactly once");
2300 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2301
2302 assert!(
2304 summary.invocations.len() <= 2,
2305 "at most the two roots may run, got {:?}",
2306 summary.invocations
2307 );
2308
2309 let good_wrote = summary
2316 .invocations
2317 .iter()
2318 .find(|o| o.row_id == "good" && o.error.is_none())
2319 .map(|o| o.records_written)
2320 .unwrap_or(0);
2321 if good_wrote > 0 {
2322 assert!(
2323 good_out.exists(),
2324 "a good that wrote records must have produced its output file"
2325 );
2326 }
2327 }
2328
2329 #[tokio::test]
2330 async fn invalid_pipeline_name_with_state_errors_up_front() {
2331 let dir = tempfile::tempdir().unwrap();
2335 let input = dir.path().join("in.csv");
2336 let output = dir.path().join("out.jsonl");
2337 std::fs::write(&input, "name\nalice\n").unwrap();
2338 let yaml = format!(
2339 r#"version: 1
2340pipeline:
2341 source: {{ type: csv, config: {{ path: {input} }} }}
2342 sink: {{ type: jsonl, config: {{ path: {output} }} }}
2343 state: {{ type: memory }}
2344"#,
2345 input = input.display(),
2346 output = output.display(),
2347 );
2348 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2349 let nodes = expand(&cfg).unwrap();
2350 let err = run_expanded(
2351 nodes,
2352 ExecuteOptions {
2353 pipeline_name: "bad name".into(), execution: None,
2355 dry_run: false,
2356 limit: None,
2357 state_path_override: None,
2358 shard: None,
2359 auth: Default::default(),
2360 clock: chrono::Utc::now().fixed_offset(),
2361 cancel: None,
2362 resilience: None,
2363 sla: None,
2364 #[cfg(feature = "lineage")]
2365 lineage: None,
2366 #[cfg(feature = "lineage")]
2367 lineage_cfg: None,
2368 #[cfg(feature = "notify")]
2369 notifier: None,
2370 #[cfg(feature = "catalog")]
2371 catalog: None,
2372 },
2373 )
2374 .await
2375 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2376 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2377 }
2378
2379 #[tokio::test]
2380 async fn invalid_parent_key_value_with_state_errors_up_front() {
2381 let dir = tempfile::tempdir().unwrap();
2384 let parent_csv = dir.path().join("parents.csv");
2385 let child_csv = dir.path().join("child.csv");
2386 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2388 std::fs::write(&child_csv, "x\nA\n").unwrap();
2389 let parent_out = dir.path().join("parents.jsonl");
2390 let child_out = dir.path().join("child.jsonl");
2391 let yaml = format!(
2392 r#"version: 1
2393pipeline:
2394 source: {{ type: csv, config: {{ path: {parent} }} }}
2395 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2396 state: {{ type: memory }}
2397matrix:
2398 - id: parents
2399 - id: child
2400 parent: parents
2401 source: {{ config: {{ path: {child} }} }}
2402 sink: {{ config: {{ path: {child_out} }} }}
2403"#,
2404 parent = parent_csv.display(),
2405 parent_out = parent_out.display(),
2406 child = child_csv.display(),
2407 child_out = child_out.display(),
2408 );
2409 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2410 let nodes = expand(&cfg).unwrap();
2411 let err = run_expanded(
2412 nodes,
2413 ExecuteOptions {
2414 pipeline_name: "ok".into(),
2415 execution: None,
2416 dry_run: false,
2417 limit: None,
2418 state_path_override: None,
2419 shard: None,
2420 auth: Default::default(),
2421 clock: chrono::Utc::now().fixed_offset(),
2422 cancel: None,
2423 resilience: None,
2424 sla: None,
2425 #[cfg(feature = "lineage")]
2426 lineage: None,
2427 #[cfg(feature = "lineage")]
2428 lineage_cfg: None,
2429 #[cfg(feature = "notify")]
2430 notifier: None,
2431 #[cfg(feature = "catalog")]
2432 catalog: None,
2433 },
2434 )
2435 .await
2436 .expect_err(
2437 "an illegal parent-key value must be rejected up front when state is configured",
2438 );
2439 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2440 }
2441
2442 #[tokio::test]
2443 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2444 let dir = tempfile::tempdir().unwrap();
2453 let bad_sink_dir = dir.path().to_path_buf();
2454 let good_csv = dir.path().join("good.csv");
2457 std::fs::write(&good_csv, "x\n1\n").unwrap();
2458 let yaml = format!(
2464 r#"version: 1
2465pipeline:
2466 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2467 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2468matrix:
2469 - id: bad
2470 - id: good_a
2471 - id: good_b
2472execution:
2473 max_concurrent: 3
2474 on_error: stop
2475"#,
2476 good_csv = good_csv.display(),
2477 bad_dir = bad_sink_dir.display(),
2478 );
2479 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2480 let nodes = expand(&cfg).unwrap();
2481 let summary = run_expanded(
2482 nodes,
2483 ExecuteOptions {
2484 pipeline_name: "stop_parallel".into(),
2485 execution: cfg.execution.clone(),
2486 dry_run: false,
2487 limit: None,
2488 state_path_override: None,
2489 shard: None,
2490 auth: Default::default(),
2491 clock: chrono::Utc::now().fixed_offset(),
2492 cancel: None,
2493 resilience: None,
2494 sla: None,
2495 #[cfg(feature = "lineage")]
2496 lineage: None,
2497 #[cfg(feature = "lineage")]
2498 lineage_cfg: None,
2499 #[cfg(feature = "notify")]
2500 notifier: None,
2501 #[cfg(feature = "catalog")]
2502 catalog: None,
2503 },
2504 )
2505 .await
2506 .unwrap();
2507
2508 assert!(
2513 summary.had_failures(),
2514 "summary should record at least one failure: {summary:?}"
2515 );
2516 assert!(
2517 summary.invocations[0].error.is_some(),
2518 "first outcome must be the failure that triggered stop: {summary:?}"
2519 );
2520 for inv in &summary.invocations {
2524 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2525 }
2526 }
2527
2528 #[tokio::test]
2529 async fn on_error_continue_skips_failed_subtree_only() {
2530 let dir = tempfile::tempdir().unwrap();
2532 let good_csv = dir.path().join("good.csv");
2533 std::fs::write(&good_csv, "x\n1\n").unwrap();
2534 let good_out = dir.path().join("good.jsonl");
2535
2536 let yaml = format!(
2537 r#"version: 1
2538pipeline:
2539 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2540 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2541matrix:
2542 - id: bad
2543 sink: {{ config: {{ path: {bad_dir} }} }}
2544 - id: good
2545"#,
2546 good_csv = good_csv.display(),
2547 good_out = good_out.display(),
2548 bad_dir = dir.path().display(),
2549 );
2550 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2551 let nodes = expand(&cfg).unwrap();
2552 let summary = run_expanded(
2553 nodes,
2554 ExecuteOptions {
2555 pipeline_name: "continuetest".into(),
2556 execution: None,
2557 dry_run: false,
2558 limit: None,
2559 state_path_override: None,
2560 shard: None,
2561 auth: Default::default(),
2562 clock: chrono::Utc::now().fixed_offset(),
2563 cancel: None,
2564 resilience: None,
2565 sla: None,
2566 #[cfg(feature = "lineage")]
2567 lineage: None,
2568 #[cfg(feature = "lineage")]
2569 lineage_cfg: None,
2570 #[cfg(feature = "notify")]
2571 notifier: None,
2572 #[cfg(feature = "catalog")]
2573 catalog: None,
2574 },
2575 )
2576 .await
2577 .unwrap();
2578 assert_eq!(summary.invocations.len(), 2);
2579 assert_eq!(summary.failure_count(), 1);
2580 let good_outcome = summary
2581 .invocations
2582 .iter()
2583 .find(|i| i.row_id == "good")
2584 .unwrap();
2585 assert!(good_outcome.error.is_none());
2586 }
2587
2588 #[test]
2591 fn split_path_splits_on_dots() {
2592 assert_eq!(split_path("id"), vec!["id".to_string()]);
2593 assert_eq!(
2594 split_path("user.name"),
2595 vec!["user".to_string(), "name".to_string()]
2596 );
2597 }
2598
2599 #[test]
2600 fn minimal_paths_drops_descendants_of_kept_ancestors() {
2601 let paths = vec![
2602 vec!["user".into(), "name".into()],
2603 vec!["user".into()],
2604 vec!["id".into()],
2605 vec!["id".into()],
2606 ];
2607 let min = minimal_paths(paths);
2608 assert!(min.contains(&vec!["user".to_string()]));
2609 assert!(min.contains(&vec!["id".to_string()]));
2610 assert!(
2611 !min.contains(&vec!["user".to_string(), "name".to_string()]),
2612 "user.name must be dropped — covered by user"
2613 );
2614 assert_eq!(min.len(), 2);
2615 }
2616
2617 #[test]
2618 fn project_full_clones_whole_record() {
2619 let r = json!({"a": 1, "b": {"c": 2}});
2620 assert_eq!(project_record(&r, &Projection::Full), r);
2621 }
2622
2623 #[test]
2624 fn project_keeps_only_referenced_paths() {
2625 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2626 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2627 let got = project_record(&r, &p);
2628 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2629 assert!(got.get("blob").is_none());
2630 assert!(got["user"].get("age").is_none());
2631 }
2632
2633 #[test]
2634 fn project_array_index_path_resolves_same_as_original() {
2635 let r = json!({"tags": ["x", "y", "z"]});
2636 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2637 let got = project_record(&r, &p);
2638 assert_eq!(got, json!({"tags": {"0": "x"}}));
2639 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2640 assert_eq!(
2641 resolve_parent_key(&got, "tags.0"),
2642 resolve_parent_key(&r, "tags.0"),
2643 "reduced tree must resolve the same value as the original"
2644 );
2645 }
2646
2647 #[test]
2648 fn project_numeric_object_key_resolves_same_as_original() {
2649 let r = json!({"data": {"0": "x", "1": "y"}});
2654 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2655 let got = project_record(&r, &p);
2656 assert_eq!(got, json!({"data": {"0": "x"}}));
2657 assert_eq!(
2658 resolve_parent_key(&got, "data.0"),
2659 resolve_parent_key(&r, "data.0"),
2660 "numeric object-key path must resolve identically on the reduced tree"
2661 );
2662 }
2663
2664 #[test]
2665 fn project_missing_path_is_omitted() {
2666 let r = json!({"id": 1});
2667 let p = Projection::Paths(vec![vec!["nope".into()]]);
2668 assert_eq!(project_record(&r, &p), json!({}));
2669 }
2670
2671 #[test]
2672 fn build_projections_unions_parent_key_and_refs() {
2673 use crate::config::ConnectorSpec;
2674 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2675
2676 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2677 ExpandedNode {
2678 id: id.into(),
2679 row_index: 0,
2680 role: NodeRole::Child {
2681 parent_id: parent.into(),
2682 parent_key: parent_key.into(),
2683 },
2684 source: ConnectorSpec {
2685 kind: "csv".into(),
2686 config: json!({}),
2687 transforms: None,
2688 inherit_transforms: true,
2689 },
2690 sink: ConnectorSpec {
2691 kind: "jsonl".into(),
2692 config: json!({}),
2693 transforms: None,
2694 inherit_transforms: true,
2695 },
2696 transforms: Vec::new(),
2697 state: None,
2698 dlq: None,
2699 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2700 #[cfg(feature = "quality")]
2701 quality: None,
2702 #[cfg(feature = "contract")]
2703 contract: None,
2704 #[cfg(feature = "masking")]
2705 masking: None,
2706 sink_ref: "default".into(),
2707 schema: None,
2708 depends_on: Vec::new(),
2709 deferred_refs: refs
2710 .iter()
2711 .map(|(rid, p)| DeferredRef {
2712 referenced_id: (*rid).into(),
2713 dotted_path: (*p).into(),
2714 token: format!("${{{rid}.{p}}}"),
2715 })
2716 .collect(),
2717 source_override: None,
2718 }
2719 }
2720
2721 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2722 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2723 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2724 let children_of =
2725 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2726
2727 let projs = build_projections(&nodes_by_id, &children_of);
2728 let p = projs.get("p").expect("projection for p");
2729 match &**p {
2730 Projection::Paths(paths) => {
2731 assert!(paths.contains(&vec!["id".to_string()]));
2732 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2733 assert!(paths.contains(&vec!["email".to_string()]));
2734 assert!(
2735 !paths.iter().any(|p| p == &vec!["x".to_string()]),
2736 "a ref to a different parent must not be captured under p"
2737 );
2738 }
2739 Projection::Full => panic!("expected Paths, got Full"),
2740 }
2741 }
2742
2743 #[test]
2744 fn build_projections_whole_record_ref_is_full() {
2745 use crate::config::ConnectorSpec;
2746 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2747 let c = ExpandedNode {
2748 id: "c".into(),
2749 row_index: 0,
2750 role: NodeRole::Child {
2751 parent_id: "p".into(),
2752 parent_key: "id".into(),
2753 },
2754 source: ConnectorSpec {
2755 kind: "csv".into(),
2756 config: json!({}),
2757 transforms: None,
2758 inherit_transforms: true,
2759 },
2760 sink: ConnectorSpec {
2761 kind: "jsonl".into(),
2762 config: json!({}),
2763 transforms: None,
2764 inherit_transforms: true,
2765 },
2766 transforms: Vec::new(),
2767 state: None,
2768 dlq: None,
2769 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2770 #[cfg(feature = "quality")]
2771 quality: None,
2772 #[cfg(feature = "contract")]
2773 contract: None,
2774 #[cfg(feature = "masking")]
2775 masking: None,
2776 sink_ref: "default".into(),
2777 schema: None,
2778 depends_on: Vec::new(),
2779 deferred_refs: vec![DeferredRef {
2780 referenced_id: "p".into(),
2781 dotted_path: "".into(),
2782 token: "${p}".into(),
2783 }],
2784 source_override: None,
2785 };
2786 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
2787 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
2788 let projs = build_projections(&nodes_by_id, &children_of);
2789 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
2790 }
2791
2792 fn opts(name: &str) -> ExecuteOptions {
2794 ExecuteOptions {
2795 pipeline_name: name.into(),
2796 execution: None,
2797 dry_run: false,
2798 limit: None,
2799 state_path_override: None,
2800 shard: None,
2801 auth: Default::default(),
2802 clock: chrono::Utc::now().fixed_offset(),
2803 cancel: None,
2804 resilience: None,
2805 sla: None,
2806 #[cfg(feature = "lineage")]
2807 lineage: None,
2808 #[cfg(feature = "lineage")]
2809 lineage_cfg: None,
2810 #[cfg(feature = "notify")]
2811 notifier: None,
2812 #[cfg(feature = "catalog")]
2813 catalog: None,
2814 }
2815 }
2816
2817 #[tokio::test]
2818 async fn dry_run_counts_records_without_writing_sink_file() {
2819 let dir = tempfile::tempdir().unwrap();
2822 let input = dir.path().join("in.csv");
2823 let output = dir.path().join("out.jsonl");
2824 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
2825 let cfg = cfg_csv_to_jsonl(&input, &output);
2826 let nodes = expand(&cfg).unwrap();
2827 let mut o = opts("dry");
2828 o.dry_run = true;
2829 let summary = run_expanded(nodes, o).await.unwrap();
2830 assert_eq!(summary.invocations.len(), 1);
2831 assert_eq!(summary.invocations[0].records_written, 3);
2832 assert!(!summary.had_failures());
2833 assert!(
2834 !output.exists(),
2835 "dry-run must not create the real sink file"
2836 );
2837 }
2838
2839 #[tokio::test]
2840 async fn limit_caps_records_written_across_the_run() {
2841 let dir = tempfile::tempdir().unwrap();
2843 let input = dir.path().join("in.csv");
2844 let output = dir.path().join("out.jsonl");
2845 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
2846 let cfg = cfg_csv_to_jsonl(&input, &output);
2847 let nodes = expand(&cfg).unwrap();
2848 let mut o = opts("lim");
2849 o.limit = Some(2);
2850 let summary = run_expanded(nodes, o).await.unwrap();
2851 assert_eq!(summary.invocations[0].records_written, 2);
2852 let body = std::fs::read_to_string(&output).unwrap();
2853 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
2854 }
2855
2856 #[tokio::test]
2857 async fn duplicate_state_key_among_siblings_is_rejected() {
2858 let dir = tempfile::tempdir().unwrap();
2862 let parent_csv = dir.path().join("parents.csv");
2863 let child_csv = dir.path().join("child.csv");
2864 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
2866 std::fs::write(&child_csv, "x\nA\n").unwrap();
2867 let parent_out = dir.path().join("parents.jsonl");
2868 let child_out = dir.path().join("child.jsonl");
2869 let yaml = format!(
2870 r#"version: 1
2871pipeline:
2872 source: {{ type: csv, config: {{ path: {parent} }} }}
2873 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2874 state: {{ type: memory }}
2875matrix:
2876 - id: parents
2877 - id: child
2878 parent: parents
2879 source: {{ config: {{ path: {child} }} }}
2880 sink: {{ config: {{ path: {child_out} }} }}
2881"#,
2882 parent = parent_csv.display(),
2883 parent_out = parent_out.display(),
2884 child = child_csv.display(),
2885 child_out = child_out.display(),
2886 );
2887 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2888 let nodes = expand(&cfg).unwrap();
2889 let err = run_expanded(nodes, opts("dupkey"))
2890 .await
2891 .expect_err("colliding sibling state keys must be rejected");
2892 match err {
2893 CliError::DuplicateStateKey { id, state_key } => {
2894 assert_eq!(id, "child");
2895 assert_eq!(state_key, "dupkey::child::dup");
2896 }
2897 other => panic!("expected DuplicateStateKey, got {other:?}"),
2898 }
2899 }
2900
2901 #[tokio::test]
2902 async fn state_path_override_writes_bookmark_file() {
2903 let dir = tempfile::tempdir().unwrap();
2908 let input = dir.path().join("in.csv");
2909 let output = dir.path().join("out.jsonl");
2910 let state_dir = dir.path().join("state");
2911 std::fs::write(&input, "name\nalice\n").unwrap();
2912 let cfg = cfg_csv_to_jsonl(&input, &output);
2913 let nodes = expand(&cfg).unwrap();
2914 let mut o = opts("statepath");
2915 o.state_path_override = Some(state_dir.clone());
2916 let summary = run_expanded(nodes, o).await.unwrap();
2917 assert!(!summary.had_failures());
2918 assert_eq!(summary.invocations[0].records_written, 1);
2922 }
2923
2924 #[tokio::test]
2925 async fn build_dlq_config_maps_spec_fields() {
2926 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
2927 let dir = tempfile::tempdir().unwrap();
2928 let dlq_out = dir.path().join("dlq.jsonl");
2929 let spec = DlqSpec {
2930 sink: ConnectorSpec {
2931 kind: "jsonl".into(),
2932 config: json!({ "path": dlq_out.to_str().unwrap() }),
2933 transforms: None,
2934 inherit_transforms: true,
2935 },
2936 on_batch_error: OnBatchErrorSpec::DlqAll,
2937 max_failures_per_page: Some(7),
2938 max_failures_total: Some(42),
2939 include_original_payload: false,
2940 };
2941 let cfg = build_dlq_config(&spec).await.unwrap();
2942 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
2943 assert_eq!(cfg.max_failures_per_page, Some(7));
2944 assert_eq!(cfg.max_failures_total, Some(42));
2945 assert!(!cfg.include_original_payload);
2946 }
2947
2948 #[tokio::test]
2949 async fn build_state_for_node_arms() {
2950 let dir = tempfile::tempdir().unwrap();
2951
2952 let node = stub_node(None);
2954 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
2955
2956 let p = dir.path().join("s1");
2958 assert!(
2959 build_state_for_node(&node, Some(&p))
2960 .await
2961 .unwrap()
2962 .is_some()
2963 );
2964
2965 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
2967 kind: "memory".into(),
2968 config: json!({}),
2969 }));
2970 assert!(
2971 build_state_for_node(&node_mem, None)
2972 .await
2973 .unwrap()
2974 .is_some()
2975 );
2976
2977 let node_file = stub_node(Some(crate::config::StateStoreSpec {
2979 kind: "file".into(),
2980 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
2981 }));
2982 let p2 = dir.path().join("override2");
2983 assert!(
2984 build_state_for_node(&node_file, Some(&p2))
2985 .await
2986 .unwrap()
2987 .is_some()
2988 );
2989
2990 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
2993 kind: "memory".into(),
2994 config: json!({}),
2995 }));
2996 let p3 = dir.path().join("override3");
2997 assert!(
2998 build_state_for_node(&node_mem2, Some(&p3))
2999 .await
3000 .unwrap()
3001 .is_some()
3002 );
3003 }
3004
3005 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3007 use crate::config::ConnectorSpec;
3008 ExpandedNode {
3009 id: "n".into(),
3010 row_index: 0,
3011 role: NodeRole::Root,
3012 source: ConnectorSpec {
3013 kind: "csv".into(),
3014 config: json!({}),
3015 transforms: None,
3016 inherit_transforms: true,
3017 },
3018 sink: ConnectorSpec {
3019 kind: "jsonl".into(),
3020 config: json!({}),
3021 transforms: None,
3022 inherit_transforms: true,
3023 },
3024 transforms: Vec::new(),
3025 state,
3026 dlq: None,
3027 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3028 #[cfg(feature = "quality")]
3029 quality: None,
3030 #[cfg(feature = "contract")]
3031 contract: None,
3032 #[cfg(feature = "masking")]
3033 masking: None,
3034 sink_ref: "default".into(),
3035 schema: None,
3036 depends_on: Vec::new(),
3037 deferred_refs: Vec::new(),
3038 source_override: None,
3039 }
3040 }
3041
3042 #[tokio::test]
3043 async fn state_key_override_delegates_and_overrides_key() {
3044 let dir = tempfile::tempdir().unwrap();
3046 let input = dir.path().join("in.csv");
3047 std::fs::write(&input, "name\nz\n").unwrap();
3048 let inner = build_source(
3049 "csv",
3050 json!({"path": input.to_str().unwrap()}),
3051 &AuthCatalog::new(),
3052 None,
3053 )
3054 .await
3055 .unwrap();
3056 let inner_name = inner.connector_name();
3058 let ov = StateKeyOverride {
3059 inner,
3060 key: "my::custom::key".into(),
3061 };
3062 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3063 assert_eq!(ov.connector_name(), inner_name);
3064 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3065 assert_eq!(rows.len(), 1);
3066 ov.apply_start_bookmark(json!({"any": "bookmark"}))
3068 .await
3069 .unwrap();
3070 }
3071
3072 #[tokio::test]
3073 async fn orphaned_child_surfaces_executor_deadlock() {
3074 use crate::config::ConnectorSpec;
3078 let orphan = ExpandedNode {
3079 id: "orphan".into(),
3080 row_index: 0,
3081 role: NodeRole::Child {
3082 parent_id: "missing-parent".into(),
3083 parent_key: "id".into(),
3084 },
3085 source: ConnectorSpec {
3086 kind: "csv".into(),
3087 config: json!({}),
3088 transforms: None,
3089 inherit_transforms: true,
3090 },
3091 sink: ConnectorSpec {
3092 kind: "jsonl".into(),
3093 config: json!({}),
3094 transforms: None,
3095 inherit_transforms: true,
3096 },
3097 transforms: Vec::new(),
3098 state: None,
3099 dlq: None,
3100 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3101 #[cfg(feature = "quality")]
3102 quality: None,
3103 #[cfg(feature = "contract")]
3104 contract: None,
3105 #[cfg(feature = "masking")]
3106 masking: None,
3107 sink_ref: "default".into(),
3108 schema: None,
3109 depends_on: Vec::new(),
3110 deferred_refs: Vec::new(),
3111 source_override: None,
3112 };
3113 let err = run_expanded(vec![orphan], opts("deadlock"))
3114 .await
3115 .expect_err("an orphaned child must surface as an executor deadlock");
3116 match err {
3117 CliError::Internal(msg) => {
3118 assert!(msg.contains("executor deadlock"), "{msg}");
3119 assert!(msg.contains("orphan"), "{msg}");
3120 }
3121 other => panic!("expected Internal deadlock error, got {other:?}"),
3122 }
3123 }
3124
3125 #[test]
3126 fn value_to_string_brief_unquotes_strings_only() {
3127 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3128 assert_eq!(value_to_string_brief(&json!(42)), "42");
3129 assert_eq!(value_to_string_brief(&json!(true)), "true");
3130 assert_eq!(value_to_string_brief(&json!(null)), "null");
3131 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3132 }
3133
3134 #[test]
3135 fn build_state_key_with_and_without_parent() {
3136 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3137 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3138 }
3139
3140 #[test]
3141 fn resolve_parent_key_walks_objects_arrays_and_misses() {
3142 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3143 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3144 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3145 assert_eq!(resolve_parent_key(&r, "user.age"), None);
3147 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3149 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3151 }
3152
3153 #[tokio::test]
3154 async fn cooperative_cancel_returns_partial_ok() {
3155 let dir = tempfile::tempdir().unwrap();
3159 let input = dir.path().join("in.csv");
3160 let output = dir.path().join("out.jsonl");
3161 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3162 let cfg = cfg_csv_to_jsonl(&input, &output);
3163 let nodes = expand(&cfg).unwrap();
3164 let token = CancellationToken::new();
3165 token.cancel(); let mut o = opts("cancel");
3167 o.cancel = Some(token);
3168 let summary = run_expanded(nodes, o).await.unwrap();
3169 assert_eq!(summary.invocations.len(), 1);
3172 assert!(
3173 !summary.had_failures(),
3174 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3175 );
3176 }
3177
3178 #[tokio::test]
3179 async fn fanout_projects_away_unreferenced_parent_fields() {
3180 let dir = tempfile::tempdir().unwrap();
3184 let parent_csv = dir.path().join("parents.csv");
3185 let child_csv = dir.path().join("child.csv");
3186 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3187 std::fs::write(&child_csv, "x\nA\n").unwrap();
3188 let parent_out = dir.path().join("parents.jsonl");
3189 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3190
3191 let yaml = format!(
3192 r#"version: 1
3193pipeline:
3194 source: {{ type: csv, config: {{ path: {parent} }} }}
3195 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3196matrix:
3197 - id: parents
3198 - id: child
3199 parent: parents
3200 source: {{ config: {{ path: {child} }} }}
3201 sink: {{ config: {{ path: "{child_out}" }} }}
3202"#,
3203 parent = parent_csv.display(),
3204 parent_out = parent_out.display(),
3205 child = child_csv.display(),
3206 child_out = child_out_pattern.display(),
3207 );
3208 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3209 let nodes = expand(&cfg).unwrap();
3210 let summary = run_expanded(
3211 nodes,
3212 ExecuteOptions {
3213 pipeline_name: "projtest".into(),
3214 execution: None,
3215 dry_run: false,
3216 limit: None,
3217 state_path_override: None,
3218 shard: None,
3219 auth: Default::default(),
3220 clock: chrono::Utc::now().fixed_offset(),
3221 cancel: None,
3222 resilience: None,
3223 sla: None,
3224 #[cfg(feature = "lineage")]
3225 lineage: None,
3226 #[cfg(feature = "lineage")]
3227 lineage_cfg: None,
3228 #[cfg(feature = "notify")]
3229 notifier: None,
3230 #[cfg(feature = "catalog")]
3231 catalog: None,
3232 },
3233 )
3234 .await
3235 .unwrap();
3236
3237 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3239 assert!(!summary.had_failures(), "{summary:?}");
3240 assert!(dir.path().join("child-1.jsonl").exists());
3242 assert!(dir.path().join("child-2.jsonl").exists());
3243 }
3244}