1use crate::auth_catalog::AuthCatalog;
21use crate::config::{ExecutionSpec, OnError};
22use crate::error::{CliError, CliResult};
23use crate::expand::{ExpandedNode, NodeRole};
24use crate::interpolate::interpolate_record;
25use crate::registry::{build_sink, build_source};
26use crate::state::build_state_store;
27use crate::transforms::compile_transforms;
28use async_trait::async_trait;
29use chrono::{DateTime, FixedOffset};
30use faucet_core::observability::Labels;
31use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
32use serde_json::Value;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36use std::sync::atomic::{AtomicUsize, Ordering};
37use std::time::Duration;
38use tokio::sync::{Mutex, Semaphore};
39
40type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
44use tokio_util::sync::CancellationToken;
45
46pub struct ExecuteOptions {
48 pub pipeline_name: String,
51 pub execution: Option<ExecutionSpec>,
54 pub dry_run: bool,
56 pub limit: Option<usize>,
58 pub state_path_override: Option<PathBuf>,
60 pub auth: AuthCatalog,
64 pub clock: DateTime<FixedOffset>,
68 pub cancel: Option<CancellationToken>,
74 #[cfg(feature = "lineage")]
77 pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
78 #[cfg(feature = "lineage")]
82 pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
83}
84
85const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
90
91#[derive(Debug)]
93pub struct InvocationOutcome {
94 pub row_id: String,
95 pub parent_record_key: Option<String>,
98 pub records_written: usize,
99 pub error: Option<String>,
100}
101
102#[derive(Debug)]
104pub struct RunSummary {
105 pub invocations: Vec<InvocationOutcome>,
106}
107
108impl RunSummary {
109 pub fn failure_count(&self) -> usize {
110 self.invocations
111 .iter()
112 .filter(|i| i.error.is_some())
113 .count()
114 }
115 pub fn had_failures(&self) -> bool {
116 self.failure_count() > 0
117 }
118}
119
120fn default_concurrency() -> usize {
131 std::thread::available_parallelism()
132 .map(|n| n.get())
133 .unwrap_or(4)
134 .clamp(1, 8)
135}
136
137pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
140 let on_error = opts
141 .execution
142 .as_ref()
143 .map(|e| e.on_error)
144 .unwrap_or_default();
145 let max_concurrent = opts
146 .execution
147 .as_ref()
148 .and_then(|e| e.max_concurrent)
149 .unwrap_or_else(default_concurrency)
150 .max(1);
151 let semaphore = Arc::new(Semaphore::new(max_concurrent));
152
153 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
158 for n in nodes.iter() {
159 if let NodeRole::Child { parent_id, .. } = &n.role {
160 children_of
161 .entry(parent_id.clone())
162 .or_default()
163 .push(n.id.clone());
164 }
165 }
166
167 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
172
173 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
174 let mut skipped_subtrees: HashSet<String> = HashSet::new();
175
176 let cancel = opts.cancel.clone().unwrap_or_default();
181 let opts = Arc::new(opts);
182
183 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
187 let mut completed: HashSet<String> = HashSet::new();
188 let nodes_by_id: HashMap<String, ExpandedNode> =
189 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
190
191 let projections = build_projections(&nodes_by_id, &children_of);
194
195 let bfs_order: Vec<String> = {
199 let mut ids: Vec<(usize, String)> = nodes_by_id
200 .values()
201 .map(|n| (n.row_index, n.id.clone()))
202 .collect();
203 ids.sort_by_key(|(i, _)| *i);
204 ids.into_iter().map(|(_, id)| id).collect()
205 };
206
207 while !remaining.is_empty() {
208 let ready: Vec<String> = bfs_order
211 .iter()
212 .filter(|id| remaining.contains(*id))
213 .filter(|id| match &nodes_by_id[*id].role {
214 NodeRole::Root => true,
215 NodeRole::Child { parent_id, .. } => {
216 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
217 }
218 })
219 .cloned()
220 .collect();
221
222 if ready.is_empty() {
223 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
228 stuck.sort();
229 return Err(CliError::Internal(format!(
230 "executor deadlock: {} node(s) never became ready (no completed/skipped parent): {}",
231 stuck.len(),
232 stuck.join(", ")
233 )));
234 }
235
236 let mut units: Vec<Unit> = Vec::new();
239 let level_records: HashMap<String, Vec<Arc<Value>>> = {
246 let consumed_parents: HashSet<&str> = ready
247 .iter()
248 .filter_map(|id| match &nodes_by_id[id].role {
249 NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
250 NodeRole::Root => None,
251 })
252 .collect();
253 let mut cap = captured.lock().await;
254 consumed_parents
255 .iter()
256 .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
257 .collect()
258 };
259 for id in &ready {
260 let node = &nodes_by_id[id];
261 if let NodeRole::Child { parent_id, .. } = &node.role
264 && skipped_subtrees.contains(parent_id)
265 {
266 skipped_subtrees.insert(id.clone());
267 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
268 continue;
269 }
270 match &node.role {
271 NodeRole::Root => {
272 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
273 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
274 validate_unit_state_key(&node.id, uses_state, &state_key)?;
275 units.push(Unit {
276 node: node.clone(),
277 parent_record: None,
278 state_key,
279 parent_record_key: None,
280 });
281 }
282 NodeRole::Child {
283 parent_id,
284 parent_key,
285 } => {
286 let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
287 if parent_records.is_empty() {
288 tracing::info!(
289 row = %id, parent = %parent_id,
290 "parent produced no records — child skipped"
291 );
292 continue;
293 }
294 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
296 let mut seen_keys: HashSet<String> = HashSet::new();
297 for record in &parent_records {
298 let pk_value = resolve_parent_key(record, parent_key);
299 let pk_string = pk_value
300 .as_ref()
301 .map(value_to_string_brief)
302 .unwrap_or_else(|| "(missing)".to_string());
303 let state_key =
304 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
305 validate_unit_state_key(&node.id, uses_state, &state_key)?;
306 if !seen_keys.insert(state_key.clone()) {
307 return Err(CliError::DuplicateStateKey {
308 id: node.id.clone(),
309 state_key,
310 });
311 }
312 units.push(Unit {
313 node: node.clone(),
314 parent_record: Some(record.clone()),
315 state_key,
316 parent_record_key: Some(pk_string),
317 });
318 }
319 }
320 }
321 }
322 drop(level_records);
323
324 let mut had_level_failure = false;
325 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
326
327 let level_cancel = cancel.child_token();
339 let mut joinset = tokio::task::JoinSet::new();
340 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
344 for unit in units {
345 let sem = Arc::clone(&semaphore);
346 let opts2 = Arc::clone(&opts);
347 let captured = Arc::clone(&captured);
348 let capture = projections.get(&unit.node.id).cloned();
349 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
350 let unit_cancel = level_cancel.clone();
351 let handle = joinset.spawn(async move {
352 let _permit = sem.acquire().await.expect("semaphore not closed");
353 run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
354 });
355 task_meta.insert(handle.id(), meta);
356 }
357
358 let mut stop_triggered = false;
359 let mut aborted = false;
360 let mut stop_deadline: Option<tokio::time::Instant> = None;
361 loop {
362 let joined = match stop_deadline {
367 Some(deadline) if !aborted => {
368 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
369 Ok(j) => j,
370 Err(_) => {
371 tracing::warn!(
372 "on_error: stop — flush grace elapsed; aborting remaining \
373 in-flight invocations"
374 );
375 joinset.abort_all();
376 aborted = true;
377 continue;
378 }
379 }
380 }
381 _ => joinset.join_next_with_id().await,
382 };
383 let Some(joined) = joined else { break };
384 let outcome = match joined {
388 Ok((_id, outcome)) => outcome,
389 Err(e) if e.is_cancelled() => {
390 continue;
393 }
394 Err(e) => {
395 let (row_id, parent_record_key) = task_meta
396 .get(&e.id())
397 .cloned()
398 .unwrap_or_else(|| ("<unknown>".to_string(), None));
399 InvocationOutcome {
400 row_id,
401 parent_record_key,
402 records_written: 0,
403 error: Some(format!("pipeline invocation task panicked: {e}")),
404 }
405 }
406 };
407
408 if let Some(err) = &outcome.error {
409 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
410 had_level_failure = true;
411 nodes_with_any_failure.insert(outcome.row_id.clone());
412 if matches!(on_error, OnError::Stop) && !stop_triggered {
413 stop_triggered = true;
414 tracing::error!(
415 "on_error: stop — cancelling in-flight invocations (cooperative \
416 flush), then aborting any that don't stop within the grace window"
417 );
418 level_cancel.cancel();
422 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
423 }
424 } else {
425 tracing::info!(
426 row = %outcome.row_id,
427 records_written = outcome.records_written,
428 "pipeline invocation completed"
429 );
430 }
431 outcomes.push(outcome);
432 }
433
434 for id in ready {
438 remaining.remove(&id);
439 if nodes_with_any_failure.contains(&id) {
440 skipped_subtrees.insert(id.clone());
441 if let Some(children) = children_of.get(&id) {
443 for cid in children {
444 skipped_subtrees.insert(cid.clone());
445 }
446 }
447 } else {
448 completed.insert(id);
449 }
450 }
451
452 if had_level_failure && matches!(on_error, OnError::Stop) {
453 tracing::error!("on_error: stop — aborting after first failure");
454 break;
456 }
457 }
458
459 Ok(RunSummary {
460 invocations: outcomes,
461 })
462}
463
464struct Unit {
467 node: ExpandedNode,
468 parent_record: Option<Arc<Value>>,
469 state_key: String,
470 parent_record_key: Option<String>,
471}
472
473async fn run_unit(
474 unit: &Unit,
475 capture: Option<Arc<Projection>>,
476 captured: &CapturedRecords,
477 opts: &ExecuteOptions,
478 cancel: CancellationToken,
479) -> InvocationOutcome {
480 let needs_capture = capture.is_some();
481 let result = run_one_invocation(
482 &unit.node,
483 unit.parent_record.as_deref(),
484 &unit.state_key,
485 capture,
486 opts,
487 cancel,
488 )
489 .await;
490 let row_id = unit.node.id.clone();
491 let parent_record_key = unit.parent_record_key.clone();
492 match result {
493 Ok((records, written)) => {
494 if needs_capture {
495 captured
496 .lock()
497 .await
498 .entry(row_id.clone())
499 .or_default()
500 .extend(records.into_iter().map(Arc::new));
503 }
504 InvocationOutcome {
505 row_id,
506 parent_record_key,
507 records_written: written,
508 error: None,
509 }
510 }
511 Err(e) => InvocationOutcome {
512 row_id,
513 parent_record_key,
514 records_written: 0,
515 error: Some(e.to_string()),
516 },
517 }
518}
519
520fn build_state_key(pipeline_name: &str, row_id: &str, parent_key: Option<&str>) -> String {
522 match parent_key {
523 None => format!("{pipeline_name}::{row_id}"),
524 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
525 }
526}
527
528fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
533 if uses_state {
534 faucet_core::state::validate_state_key(state_key).map_err(|e| {
535 CliError::InvalidStateKey {
536 id: node_id.to_owned(),
537 state_key: state_key.to_owned(),
538 reason: e.to_string(),
539 }
540 })?;
541 }
542 Ok(())
543}
544
545fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
547 let mut cur = record;
548 for segment in parent_key.split('.') {
549 cur = match cur {
550 Value::Object(m) => m.get(segment)?,
551 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
552 _ => return None,
553 };
554 }
555 Some(cur.clone())
556}
557
558#[derive(Debug, Clone)]
562enum Projection {
563 Full,
566 Paths(Vec<Vec<String>>),
568}
569
570fn split_path(path: &str) -> Vec<String> {
572 path.split('.').map(|s| s.to_string()).collect()
573}
574
575fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
579 paths.sort();
580 paths.dedup();
581 let mut kept: Vec<Vec<String>> = Vec::new();
582 for p in paths {
583 let covered = kept
584 .iter()
585 .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
586 if !covered {
587 kept.push(p);
588 }
589 }
590 kept
591}
592
593fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
596 let mut cur = record;
597 for seg in segments {
598 cur = match cur {
599 Value::Object(m) => m.get(seg)?,
600 Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
601 _ => return None,
602 };
603 }
604 Some(cur.clone())
605}
606
607fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
612 if segments.is_empty() {
613 return;
614 }
615 let mut cur = out;
616 for seg in &segments[..segments.len() - 1] {
617 let map = match cur {
618 Value::Object(m) => m,
619 _ => return,
620 };
621 cur = map
622 .entry(seg.clone())
623 .or_insert_with(|| Value::Object(serde_json::Map::new()));
624 }
625 if let Value::Object(m) = cur {
626 m.insert(segments[segments.len() - 1].clone(), leaf);
627 }
628}
629
630fn project_record(record: &Value, projection: &Projection) -> Value {
635 match projection {
636 Projection::Full => record.clone(),
637 Projection::Paths(paths) => {
638 let mut out = Value::Object(serde_json::Map::new());
639 for segs in paths {
640 if let Some(v) = walk_value(record, segs) {
641 graft_object(&mut out, segs, v);
642 }
643 }
644 out
645 }
646 }
647}
648
649fn build_projections(
654 nodes_by_id: &HashMap<String, ExpandedNode>,
655 children_of: &HashMap<String, Vec<String>>,
656) -> HashMap<String, Arc<Projection>> {
657 let mut out = HashMap::new();
658 for (parent_id, child_ids) in children_of {
659 let mut raw: Vec<Vec<String>> = Vec::new();
660 let mut full = false;
661 for cid in child_ids {
662 let child = &nodes_by_id[cid];
663 if let NodeRole::Child { parent_key, .. } = &child.role {
664 if parent_key.is_empty() {
665 full = true;
666 } else {
667 raw.push(split_path(parent_key));
668 }
669 }
670 for dref in &child.deferred_refs {
671 if dref.referenced_id == *parent_id {
672 if dref.dotted_path.is_empty() {
673 full = true; } else {
675 raw.push(split_path(&dref.dotted_path));
676 }
677 }
678 }
679 }
680 let projection = if full || raw.is_empty() {
685 Projection::Full
686 } else {
687 Projection::Paths(minimal_paths(raw))
688 };
689 out.insert(parent_id.clone(), Arc::new(projection));
690 }
691 out
692}
693
694async fn run_one_invocation(
696 node: &ExpandedNode,
697 parent_record: Option<&Value>,
698 state_key: &str,
699 capture: Option<Arc<Projection>>,
700 opts: &ExecuteOptions,
701 cancel: CancellationToken,
702) -> CliResult<(Vec<Value>, usize)> {
703 let run_id = uuid::Uuid::now_v7().to_string();
706 let pipeline_name = opts.pipeline_name.clone();
707 let row_id = node.id.clone();
708 #[cfg(feature = "lineage")]
709 let lineage = opts.lineage.clone();
710 #[cfg(feature = "lineage")]
711 let lineage_cfg = opts.lineage_cfg.clone();
712 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
713 let mut source_cfg = node.source.config.clone();
715 let mut sink_cfg = node.sink.config.clone();
716
717 resolve_now_inplace(&mut source_cfg, opts.clock)?;
720 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
721
722 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
723 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
724 resolve_inplace(&mut source_cfg, &ctx)?;
725 resolve_inplace(&mut sink_cfg, &ctx)?;
726 }
727
728 let source = build_source(&node.source.kind, source_cfg, &opts.auth).await?;
730 let raw_sink: Box<dyn Sink> = if opts.dry_run {
731 Box::new(CountingSink::new())
732 } else {
733 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
734 };
735 let raw_sink: Box<dyn Sink> = match opts.limit {
736 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
737 None => raw_sink,
738 };
739 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
740 let sink: Box<dyn Sink> = match &capture {
741 Some(projection) => Box::new(CapturingSink::wrap(
742 raw_sink,
743 Arc::clone(&captured),
744 Arc::clone(projection),
745 )),
746 None => raw_sink,
747 };
748
749 #[cfg(feature = "lineage")]
755 let (in_sample, out_sample) = {
756 use std::sync::Arc as StdArc;
757 match (&lineage, &lineage_cfg) {
758 (Some(_), Some(lc)) => {
759 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
760 let cap = if want_schema { lc.sample_records } else { 0 };
761 let need_counter = lc.emit_on.running;
762 if want_schema || need_counter {
763 (
764 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
765 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
766 )
767 } else {
768 (None, None)
769 }
770 }
771 _ => (None, None),
772 }
773 };
774
775 #[cfg(feature = "lineage")]
778 let source: Box<dyn Source> = match &in_sample {
779 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
780 source,
781 std::sync::Arc::clone(state),
782 )),
783 None => source,
784 };
785
786 let stages = compile_transforms(&node.transforms)?;
788 let source: Box<dyn Source> = if stages.is_empty() {
789 source
790 } else {
791 Box::new(faucet_core::TransformingSource::new(
792 source,
793 stages,
794 obs_labels.clone(),
795 )?)
796 };
797
798 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
802 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
803 Box::new(StateKeyOverride {
804 inner: source,
805 key: state_key.to_owned(),
806 })
807 } else {
808 source
809 };
810
811 #[cfg(feature = "lineage")]
814 let sink: Box<dyn Sink> = match &out_sample {
815 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
816 sink,
817 std::sync::Arc::clone(state),
818 )),
819 None => sink,
820 };
821
822 #[cfg(feature = "lineage")]
827 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
828 .with_name(pipeline_name.clone())
829 .with_row(row_id.clone())
830 .with_run_id(run_id.clone());
831 #[cfg(not(feature = "lineage"))]
832 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
833 .with_name(pipeline_name)
834 .with_row(row_id)
835 .with_run_id(run_id);
836 let pipeline = match state {
837 Some(store) => pipeline.with_state_store(store),
838 None => pipeline,
839 };
840 let pipeline = if let Some(ref dlq_spec) = node.dlq {
841 let dlq_cfg = build_dlq_config(dlq_spec).await?;
842 pipeline.with_dlq(dlq_cfg)
843 } else {
844 pipeline
845 };
846 #[cfg(feature = "lineage")]
851 let pipeline = pipeline.with_cancel(cancel.clone());
852 #[cfg(not(feature = "lineage"))]
853 let pipeline = pipeline.with_cancel(cancel);
854 #[cfg(feature = "quality")]
858 let pipeline = if let Some(ref quality_spec) = node.quality {
859 let compiled = Arc::new(
860 faucet_core::CompiledQuality::compile(quality_spec)
861 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
862 );
863 pipeline.with_quality(compiled)
864 } else {
865 pipeline
866 };
867 let pipeline = if let Some(ab) = opts
869 .execution
870 .as_ref()
871 .and_then(|e| e.adaptive_batch_size.clone())
872 {
873 ab.validate()
874 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
875 pipeline.with_adaptive(ab)
876 } else {
877 pipeline
878 };
879 let pipeline = pipeline.with_delivery(node.delivery);
882 #[cfg(feature = "lineage")]
884 let lineage_ctx = match (&lineage, &lineage_cfg) {
885 (Some(em), Some(lc)) => {
886 let job_name =
887 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
888 let mut ctx = faucet_lineage::RunLifecycle {
889 job_namespace: lc.namespace.clone(),
890 job_name,
891 run_id: run_id.clone(),
892 parent: lc.parent_job.clone(),
893 input: faucet_lineage::DatasetRef {
894 namespace: lc.namespace.clone(),
895 name: source.dataset_uri(),
896 },
897 output: faucet_lineage::DatasetRef {
898 namespace: lc.namespace.clone(),
899 name: sink.dataset_uri(),
900 },
901 started_at: chrono::Utc::now(),
902 finished_at: None,
903 records: 0,
904 error: None,
905 input_schema: None,
906 output_schema: None,
907 column_lineage: None,
908 source_code: None,
909 };
910 em.emit(faucet_lineage::EventType::Start, &ctx).await;
911 let hb_handle = if lc.emit_on.running {
914 let em2 = std::sync::Arc::clone(em);
915 let interval = lc.heartbeat_interval;
916 let mut beat_ctx = ctx.clone();
917 let counter = out_sample.clone();
918 Some(tokio::spawn(async move {
919 let mut tick = tokio::time::interval(interval);
920 tick.tick().await; loop {
922 tick.tick().await;
923 if let Some(c) = &counter {
924 beat_ctx.records = c.count();
925 }
926 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
927 .await;
928 }
929 }))
930 } else {
931 None
932 };
933 ctx.source_code = if lc.include_source_code_facet {
934 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
935 } else {
936 None
937 };
938 Some((std::sync::Arc::clone(em), ctx, hb_handle))
939 }
940 _ => None,
941 };
942
943 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
950 Ok(r) => sink.flush().await.map(|_| r),
951 Err(e) => Err(e),
952 };
953
954 #[cfg(feature = "lineage")]
955 if let Some((em, mut ctx, hb)) = lineage_ctx {
956 if let Some(h) = hb {
957 h.abort();
958 }
959 ctx.finished_at = Some(chrono::Utc::now());
960 if let Some(state) = &out_sample {
961 ctx.records = state.count();
962 if lineage_cfg
963 .as_ref()
964 .map(|l| l.include_schema_facet)
965 .unwrap_or(false)
966 {
967 ctx.output_schema = Some(state.inferred_schema());
968 }
969 }
970 if let Some(state) = &in_sample
971 && lineage_cfg
972 .as_ref()
973 .map(|l| l.include_schema_facet || l.include_column_lineage)
974 .unwrap_or(false)
975 {
976 let in_schema = state.inferred_schema();
977 if lineage_cfg
978 .as_ref()
979 .map(|l| l.include_column_lineage)
980 .unwrap_or(false)
981 {
982 let input_fields: Vec<String> =
983 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
984 let ops = crate::lineage_glue::column_ops(&node.transforms);
985 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
986 }
987 if lineage_cfg
988 .as_ref()
989 .map(|l| l.include_schema_facet)
990 .unwrap_or(false)
991 {
992 ctx.input_schema = Some(in_schema);
993 }
994 }
995 let ev = match &result {
996 Err(e) => {
997 ctx.error = Some(e.to_string());
998 faucet_lineage::EventType::Fail
999 }
1000 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1001 Ok(_) => faucet_lineage::EventType::Complete,
1002 };
1003 em.emit(ev, &ctx).await;
1004 }
1005
1006 let result = result?;
1007
1008 let captured = if capture.is_some() {
1009 std::mem::take(&mut *captured.lock().await)
1010 } else {
1011 Vec::new()
1012 };
1013 Ok((captured, result.records_written))
1014}
1015
1016async fn build_state_for_node(
1017 node: &ExpandedNode,
1018 state_path_override: Option<&Path>,
1019) -> CliResult<Option<Arc<dyn StateStore>>> {
1020 match (&node.state, state_path_override) {
1021 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1022 (None, Some(path)) => Ok(Some(state_from_override(path))),
1023 (Some(spec), Some(path)) => {
1024 if spec.kind == "file" {
1025 Ok(Some(state_from_override(path)))
1026 } else {
1027 tracing::warn!(
1028 state = %spec.kind,
1029 "--state-path is only meaningful for the 'file' backend; ignoring override"
1030 );
1031 Ok(Some(build_state_store(spec).await?))
1032 }
1033 }
1034 (None, None) => Ok(None),
1035 }
1036}
1037
1038fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1039 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1040}
1041
1042pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1045 let sink = build_sink(
1048 &spec.sink.kind,
1049 spec.sink.config.clone(),
1050 &AuthCatalog::new(),
1051 )
1052 .await?;
1053 Ok(DlqConfig {
1054 sink: Arc::from(sink),
1055 on_batch_error: match spec.on_batch_error {
1056 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1057 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1058 },
1059 max_failures_per_page: spec.max_failures_per_page,
1060 max_failures_total: spec.max_failures_total,
1061 include_original_payload: spec.include_original_payload,
1062 })
1063}
1064
1065fn resolve_now_inplace(value: &mut Value, clock: DateTime<FixedOffset>) -> CliResult<()> {
1068 match value {
1069 Value::String(s) => {
1070 *s = crate::interpolate::resolve_now(s, clock)?;
1071 Ok(())
1072 }
1073 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1074 Value::Object(m) => m
1075 .values_mut()
1076 .try_for_each(|v| resolve_now_inplace(v, clock)),
1077 _ => Ok(()),
1078 }
1079}
1080
1081fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1085 match value {
1086 Value::String(s) => {
1087 let resolved = interpolate_record(s, ctx)?;
1088 *s = resolved;
1089 Ok(())
1090 }
1091 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1092 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1093 _ => Ok(()),
1094 }
1095}
1096
1097struct StateKeyOverride {
1103 inner: Box<dyn Source>,
1104 key: String,
1105}
1106
1107#[async_trait]
1108impl Source for StateKeyOverride {
1109 async fn fetch_with_context(
1110 &self,
1111 ctx: &HashMap<String, Value>,
1112 ) -> Result<Vec<Value>, FaucetError> {
1113 self.inner.fetch_with_context(ctx).await
1114 }
1115 async fn fetch_with_context_incremental(
1116 &self,
1117 ctx: &HashMap<String, Value>,
1118 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1119 self.inner.fetch_with_context_incremental(ctx).await
1120 }
1121 fn connector_name(&self) -> &'static str {
1122 self.inner.connector_name()
1123 }
1124 fn state_key(&self) -> Option<String> {
1125 Some(self.key.clone())
1126 }
1127 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1128 self.inner.apply_start_bookmark(bookmark).await
1129 }
1130}
1131
1132struct CapturingSink {
1136 inner: Box<dyn Sink>,
1137 captured: Arc<Mutex<Vec<Value>>>,
1138 projection: Arc<Projection>,
1139}
1140
1141impl CapturingSink {
1142 fn wrap(
1143 inner: Box<dyn Sink>,
1144 captured: Arc<Mutex<Vec<Value>>>,
1145 projection: Arc<Projection>,
1146 ) -> Self {
1147 Self {
1148 inner,
1149 captured,
1150 projection,
1151 }
1152 }
1153}
1154
1155#[async_trait]
1156impl Sink for CapturingSink {
1157 fn connector_name(&self) -> &'static str {
1158 self.inner.connector_name()
1159 }
1160 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1161 let written = self.inner.write_batch(records).await?;
1162 let n = written.min(records.len());
1165 let mut buf = self.captured.lock().await;
1166 buf.extend(
1167 records
1168 .iter()
1169 .take(n)
1170 .map(|r| project_record(r, &self.projection)),
1171 );
1172 Ok(written)
1173 }
1174 async fn flush(&self) -> Result<(), FaucetError> {
1175 self.inner.flush().await
1176 }
1177}
1178
1179struct LimitedSink {
1182 inner: Box<dyn Sink>,
1183 remaining: AtomicUsize,
1184}
1185
1186impl LimitedSink {
1187 fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1188 Self {
1189 inner,
1190 remaining: AtomicUsize::new(cap),
1191 }
1192 }
1193}
1194
1195#[async_trait]
1196impl Sink for LimitedSink {
1197 fn connector_name(&self) -> &'static str {
1198 self.inner.connector_name()
1199 }
1200 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1201 let remaining = self.remaining.load(Ordering::Relaxed);
1202 if remaining == 0 {
1203 return Ok(0);
1204 }
1205 let take = remaining.min(records.len());
1206 let slice = &records[..take];
1207 let written = self.inner.write_batch(slice).await?;
1208 self.remaining
1209 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1210 Ok(written)
1211 }
1212 async fn flush(&self) -> Result<(), FaucetError> {
1213 self.inner.flush().await
1214 }
1215}
1216
1217struct CountingSink {
1220 seen: AtomicUsize,
1221}
1222
1223impl CountingSink {
1224 fn new() -> Self {
1225 Self {
1226 seen: AtomicUsize::new(0),
1227 }
1228 }
1229}
1230
1231#[async_trait]
1232impl Sink for CountingSink {
1233 fn connector_name(&self) -> &'static str {
1234 "dry-run"
1235 }
1236 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1237 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1238 Ok(records.len())
1239 }
1240}
1241
1242fn value_to_string_brief(v: &Value) -> String {
1245 match v {
1246 Value::String(s) => s.clone(),
1247 other => other.to_string(),
1248 }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::*;
1254 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1255 use crate::expand::expand;
1256 use serde_json::json;
1257
1258 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1259 PipelineConfig {
1260 version: 1,
1261 name: Some("test".into()),
1262 vars: None,
1263 auth: None,
1264 pipeline: PipelineSpec {
1265 source: Some(ConnectorSpec {
1266 kind: "csv".into(),
1267 config: json!({"path": input.to_str().unwrap()}),
1268 transforms: None,
1269 inherit_transforms: true,
1270 }),
1271 sink: Some(ConnectorSpec {
1272 kind: "jsonl".into(),
1273 config: json!({"path": output.to_str().unwrap()}),
1274 transforms: None,
1275 inherit_transforms: true,
1276 }),
1277 sources: Default::default(),
1278 sinks: Default::default(),
1279 transforms: Vec::new(),
1280 state: None,
1281 dlq: None,
1282 #[cfg(feature = "quality")]
1283 quality: None,
1284 },
1285 matrix: Vec::new(),
1286 execution: None,
1287 observability: None,
1288 delivery: faucet_core::DeliveryMode::default(),
1289 #[cfg(feature = "schedule")]
1290 schedule: None,
1291 #[cfg(feature = "lineage")]
1292 lineage: None,
1293 }
1294 }
1295
1296 #[tokio::test]
1297 async fn empty_matrix_runs_pipeline_once() {
1298 let dir = tempfile::tempdir().unwrap();
1299 let input = dir.path().join("in.csv");
1300 let output = dir.path().join("out.jsonl");
1301 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1302 let cfg = cfg_csv_to_jsonl(&input, &output);
1303 let nodes = expand(&cfg).unwrap();
1304 let summary = run_expanded(
1305 nodes,
1306 ExecuteOptions {
1307 pipeline_name: "t".into(),
1308 execution: None,
1309 dry_run: false,
1310 limit: None,
1311 state_path_override: None,
1312 auth: Default::default(),
1313 clock: chrono::Utc::now().fixed_offset(),
1314 cancel: None,
1315 #[cfg(feature = "lineage")]
1316 lineage: None,
1317 #[cfg(feature = "lineage")]
1318 lineage_cfg: None,
1319 },
1320 )
1321 .await
1322 .unwrap();
1323 assert_eq!(summary.invocations.len(), 1);
1324 assert_eq!(summary.invocations[0].records_written, 2);
1325 assert!(!summary.had_failures());
1326 let body = std::fs::read_to_string(&output).unwrap();
1327 assert_eq!(body.lines().count(), 2);
1328 }
1329
1330 #[tokio::test]
1331 async fn matrix_two_independent_roots_both_run() {
1332 let dir = tempfile::tempdir().unwrap();
1334 let csv_a = dir.path().join("a.csv");
1335 let csv_b = dir.path().join("b.csv");
1336 let out_a = dir.path().join("a.jsonl");
1337 let out_b = dir.path().join("b.jsonl");
1338 std::fs::write(&csv_a, "name\nalice\n").unwrap();
1339 std::fs::write(&csv_b, "name\nbob\n").unwrap();
1340
1341 let yaml = format!(
1342 r#"version: 1
1343pipeline:
1344 source: {{ type: csv, config: {{ path: {a} }} }}
1345 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
1346matrix:
1347 - id: rowA
1348 - id: rowB
1349 source: {{ config: {{ path: {b} }} }}
1350 sink: {{ config: {{ path: {out_b} }} }}
1351"#,
1352 a = csv_a.display(),
1353 b = csv_b.display(),
1354 out_a = out_a.display(),
1355 out_b = out_b.display(),
1356 );
1357 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1358 let nodes = expand(&cfg).unwrap();
1359 let summary = run_expanded(
1360 nodes,
1361 ExecuteOptions {
1362 pipeline_name: "matrix".into(),
1363 execution: None,
1364 dry_run: false,
1365 limit: None,
1366 state_path_override: None,
1367 auth: Default::default(),
1368 clock: chrono::Utc::now().fixed_offset(),
1369 cancel: None,
1370 #[cfg(feature = "lineage")]
1371 lineage: None,
1372 #[cfg(feature = "lineage")]
1373 lineage_cfg: None,
1374 },
1375 )
1376 .await
1377 .unwrap();
1378 assert_eq!(summary.invocations.len(), 2);
1379 assert!(out_a.exists());
1380 assert!(out_b.exists());
1381 }
1382
1383 #[tokio::test]
1384 async fn dag_child_fans_out_per_parent_record() {
1385 let dir = tempfile::tempdir().unwrap();
1388 let parent_csv = dir.path().join("parents.csv");
1389 let child_csv = dir.path().join("child.csv");
1390 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
1391 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
1392 let parent_out = dir.path().join("parents.jsonl");
1393 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
1394
1395 let yaml = format!(
1396 r#"version: 1
1397pipeline:
1398 source: {{ type: csv, config: {{ path: {parent} }} }}
1399 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
1400matrix:
1401 - id: parents
1402 - id: child
1403 parent: parents
1404 source: {{ config: {{ path: {child} }} }}
1405 sink: {{ config: {{ path: "{child_out}" }} }}
1406"#,
1407 parent = parent_csv.display(),
1408 parent_out = parent_out.display(),
1409 child = child_csv.display(),
1410 child_out = child_out_pattern.display(),
1411 );
1412 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1413 let nodes = expand(&cfg).unwrap();
1414 let summary = run_expanded(
1415 nodes,
1416 ExecuteOptions {
1417 pipeline_name: "dagtest".into(),
1418 execution: None,
1419 dry_run: false,
1420 limit: None,
1421 state_path_override: None,
1422 auth: Default::default(),
1423 clock: chrono::Utc::now().fixed_offset(),
1424 cancel: None,
1425 #[cfg(feature = "lineage")]
1426 lineage: None,
1427 #[cfg(feature = "lineage")]
1428 lineage_cfg: None,
1429 },
1430 )
1431 .await
1432 .unwrap();
1433
1434 assert_eq!(summary.invocations.len(), 3);
1436 assert!(!summary.had_failures(), "{:?}", summary);
1437 assert!(dir.path().join("child-1.jsonl").exists());
1438 assert!(dir.path().join("child-2.jsonl").exists());
1439 }
1440
1441 #[tokio::test]
1442 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
1443 let dir = tempfile::tempdir().unwrap();
1455 let good_csv = dir.path().join("good.csv");
1456 std::fs::write(&good_csv, "x\n1\n").unwrap();
1457 let good_out = dir.path().join("good.jsonl");
1458 let bad_sink_dir = dir.path().to_path_buf();
1459
1460 let yaml = format!(
1461 r#"version: 1
1462pipeline:
1463 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1464 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
1465matrix:
1466 - id: bad
1467 sink: {{ config: {{ path: {bad_dir} }} }}
1468 - id: good
1469execution:
1470 max_concurrent: 1
1471 on_error: stop
1472"#,
1473 good_csv = good_csv.display(),
1474 good_out = good_out.display(),
1475 bad_dir = bad_sink_dir.display(),
1476 );
1477 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1478 let nodes = expand(&cfg).unwrap();
1479 let summary = run_expanded(
1480 nodes,
1481 ExecuteOptions {
1482 pipeline_name: "stoptest".into(),
1483 execution: cfg.execution.clone(),
1484 dry_run: false,
1485 limit: None,
1486 state_path_override: None,
1487 auth: Default::default(),
1488 clock: chrono::Utc::now().fixed_offset(),
1489 cancel: None,
1490 #[cfg(feature = "lineage")]
1491 lineage: None,
1492 #[cfg(feature = "lineage")]
1493 lineage_cfg: None,
1494 },
1495 )
1496 .await
1497 .unwrap();
1498
1499 assert!(summary.had_failures(), "the failing root must be reported");
1501
1502 let bad: Vec<_> = summary
1504 .invocations
1505 .iter()
1506 .filter(|o| o.row_id == "bad")
1507 .collect();
1508 assert_eq!(bad.len(), 1, "bad must run exactly once");
1509 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
1510
1511 assert!(
1513 summary.invocations.len() <= 2,
1514 "at most the two roots may run, got {:?}",
1515 summary.invocations
1516 );
1517
1518 let good_wrote = summary
1525 .invocations
1526 .iter()
1527 .find(|o| o.row_id == "good" && o.error.is_none())
1528 .map(|o| o.records_written)
1529 .unwrap_or(0);
1530 if good_wrote > 0 {
1531 assert!(
1532 good_out.exists(),
1533 "a good that wrote records must have produced its output file"
1534 );
1535 }
1536 }
1537
1538 #[tokio::test]
1539 async fn invalid_pipeline_name_with_state_errors_up_front() {
1540 let dir = tempfile::tempdir().unwrap();
1544 let input = dir.path().join("in.csv");
1545 let output = dir.path().join("out.jsonl");
1546 std::fs::write(&input, "name\nalice\n").unwrap();
1547 let yaml = format!(
1548 r#"version: 1
1549pipeline:
1550 source: {{ type: csv, config: {{ path: {input} }} }}
1551 sink: {{ type: jsonl, config: {{ path: {output} }} }}
1552 state: {{ type: memory }}
1553"#,
1554 input = input.display(),
1555 output = output.display(),
1556 );
1557 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1558 let nodes = expand(&cfg).unwrap();
1559 let err = run_expanded(
1560 nodes,
1561 ExecuteOptions {
1562 pipeline_name: "bad name".into(), execution: None,
1564 dry_run: false,
1565 limit: None,
1566 state_path_override: None,
1567 auth: Default::default(),
1568 clock: chrono::Utc::now().fixed_offset(),
1569 cancel: None,
1570 #[cfg(feature = "lineage")]
1571 lineage: None,
1572 #[cfg(feature = "lineage")]
1573 lineage_cfg: None,
1574 },
1575 )
1576 .await
1577 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
1578 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1579 }
1580
1581 #[tokio::test]
1582 async fn invalid_parent_key_value_with_state_errors_up_front() {
1583 let dir = tempfile::tempdir().unwrap();
1586 let parent_csv = dir.path().join("parents.csv");
1587 let child_csv = dir.path().join("child.csv");
1588 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
1590 std::fs::write(&child_csv, "x\nA\n").unwrap();
1591 let parent_out = dir.path().join("parents.jsonl");
1592 let child_out = dir.path().join("child.jsonl");
1593 let yaml = format!(
1594 r#"version: 1
1595pipeline:
1596 source: {{ type: csv, config: {{ path: {parent} }} }}
1597 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
1598 state: {{ type: memory }}
1599matrix:
1600 - id: parents
1601 - id: child
1602 parent: parents
1603 source: {{ config: {{ path: {child} }} }}
1604 sink: {{ config: {{ path: {child_out} }} }}
1605"#,
1606 parent = parent_csv.display(),
1607 parent_out = parent_out.display(),
1608 child = child_csv.display(),
1609 child_out = child_out.display(),
1610 );
1611 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1612 let nodes = expand(&cfg).unwrap();
1613 let err = run_expanded(
1614 nodes,
1615 ExecuteOptions {
1616 pipeline_name: "ok".into(),
1617 execution: None,
1618 dry_run: false,
1619 limit: None,
1620 state_path_override: None,
1621 auth: Default::default(),
1622 clock: chrono::Utc::now().fixed_offset(),
1623 cancel: None,
1624 #[cfg(feature = "lineage")]
1625 lineage: None,
1626 #[cfg(feature = "lineage")]
1627 lineage_cfg: None,
1628 },
1629 )
1630 .await
1631 .expect_err(
1632 "an illegal parent-key value must be rejected up front when state is configured",
1633 );
1634 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1635 }
1636
1637 #[tokio::test]
1638 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
1639 let dir = tempfile::tempdir().unwrap();
1648 let bad_sink_dir = dir.path().to_path_buf();
1649 let good_csv = dir.path().join("good.csv");
1652 std::fs::write(&good_csv, "x\n1\n").unwrap();
1653 let yaml = format!(
1659 r#"version: 1
1660pipeline:
1661 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1662 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
1663matrix:
1664 - id: bad
1665 - id: good_a
1666 - id: good_b
1667execution:
1668 max_concurrent: 3
1669 on_error: stop
1670"#,
1671 good_csv = good_csv.display(),
1672 bad_dir = bad_sink_dir.display(),
1673 );
1674 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1675 let nodes = expand(&cfg).unwrap();
1676 let summary = run_expanded(
1677 nodes,
1678 ExecuteOptions {
1679 pipeline_name: "stop_parallel".into(),
1680 execution: cfg.execution.clone(),
1681 dry_run: false,
1682 limit: None,
1683 state_path_override: None,
1684 auth: Default::default(),
1685 clock: chrono::Utc::now().fixed_offset(),
1686 cancel: None,
1687 #[cfg(feature = "lineage")]
1688 lineage: None,
1689 #[cfg(feature = "lineage")]
1690 lineage_cfg: None,
1691 },
1692 )
1693 .await
1694 .unwrap();
1695
1696 assert!(
1701 summary.had_failures(),
1702 "summary should record at least one failure: {summary:?}"
1703 );
1704 assert!(
1705 summary.invocations[0].error.is_some(),
1706 "first outcome must be the failure that triggered stop: {summary:?}"
1707 );
1708 for inv in &summary.invocations {
1712 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
1713 }
1714 }
1715
1716 #[tokio::test]
1717 async fn on_error_continue_skips_failed_subtree_only() {
1718 let dir = tempfile::tempdir().unwrap();
1720 let good_csv = dir.path().join("good.csv");
1721 std::fs::write(&good_csv, "x\n1\n").unwrap();
1722 let good_out = dir.path().join("good.jsonl");
1723
1724 let yaml = format!(
1725 r#"version: 1
1726pipeline:
1727 source: {{ type: csv, config: {{ path: {good_csv} }} }}
1728 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
1729matrix:
1730 - id: bad
1731 sink: {{ config: {{ path: {bad_dir} }} }}
1732 - id: good
1733"#,
1734 good_csv = good_csv.display(),
1735 good_out = good_out.display(),
1736 bad_dir = dir.path().display(),
1737 );
1738 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1739 let nodes = expand(&cfg).unwrap();
1740 let summary = run_expanded(
1741 nodes,
1742 ExecuteOptions {
1743 pipeline_name: "continuetest".into(),
1744 execution: None,
1745 dry_run: false,
1746 limit: None,
1747 state_path_override: None,
1748 auth: Default::default(),
1749 clock: chrono::Utc::now().fixed_offset(),
1750 cancel: None,
1751 #[cfg(feature = "lineage")]
1752 lineage: None,
1753 #[cfg(feature = "lineage")]
1754 lineage_cfg: None,
1755 },
1756 )
1757 .await
1758 .unwrap();
1759 assert_eq!(summary.invocations.len(), 2);
1760 assert_eq!(summary.failure_count(), 1);
1761 let good_outcome = summary
1762 .invocations
1763 .iter()
1764 .find(|i| i.row_id == "good")
1765 .unwrap();
1766 assert!(good_outcome.error.is_none());
1767 }
1768
1769 #[test]
1772 fn split_path_splits_on_dots() {
1773 assert_eq!(split_path("id"), vec!["id".to_string()]);
1774 assert_eq!(
1775 split_path("user.name"),
1776 vec!["user".to_string(), "name".to_string()]
1777 );
1778 }
1779
1780 #[test]
1781 fn minimal_paths_drops_descendants_of_kept_ancestors() {
1782 let paths = vec![
1783 vec!["user".into(), "name".into()],
1784 vec!["user".into()],
1785 vec!["id".into()],
1786 vec!["id".into()],
1787 ];
1788 let min = minimal_paths(paths);
1789 assert!(min.contains(&vec!["user".to_string()]));
1790 assert!(min.contains(&vec!["id".to_string()]));
1791 assert!(
1792 !min.contains(&vec!["user".to_string(), "name".to_string()]),
1793 "user.name must be dropped — covered by user"
1794 );
1795 assert_eq!(min.len(), 2);
1796 }
1797
1798 #[test]
1799 fn project_full_clones_whole_record() {
1800 let r = json!({"a": 1, "b": {"c": 2}});
1801 assert_eq!(project_record(&r, &Projection::Full), r);
1802 }
1803
1804 #[test]
1805 fn project_keeps_only_referenced_paths() {
1806 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
1807 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
1808 let got = project_record(&r, &p);
1809 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
1810 assert!(got.get("blob").is_none());
1811 assert!(got["user"].get("age").is_none());
1812 }
1813
1814 #[test]
1815 fn project_array_index_path_resolves_same_as_original() {
1816 let r = json!({"tags": ["x", "y", "z"]});
1817 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
1818 let got = project_record(&r, &p);
1819 assert_eq!(got, json!({"tags": {"0": "x"}}));
1820 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
1821 assert_eq!(
1822 resolve_parent_key(&got, "tags.0"),
1823 resolve_parent_key(&r, "tags.0"),
1824 "reduced tree must resolve the same value as the original"
1825 );
1826 }
1827
1828 #[test]
1829 fn project_numeric_object_key_resolves_same_as_original() {
1830 let r = json!({"data": {"0": "x", "1": "y"}});
1835 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
1836 let got = project_record(&r, &p);
1837 assert_eq!(got, json!({"data": {"0": "x"}}));
1838 assert_eq!(
1839 resolve_parent_key(&got, "data.0"),
1840 resolve_parent_key(&r, "data.0"),
1841 "numeric object-key path must resolve identically on the reduced tree"
1842 );
1843 }
1844
1845 #[test]
1846 fn project_missing_path_is_omitted() {
1847 let r = json!({"id": 1});
1848 let p = Projection::Paths(vec![vec!["nope".into()]]);
1849 assert_eq!(project_record(&r, &p), json!({}));
1850 }
1851
1852 #[test]
1853 fn build_projections_unions_parent_key_and_refs() {
1854 use crate::config::ConnectorSpec;
1855 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
1856
1857 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
1858 ExpandedNode {
1859 id: id.into(),
1860 row_index: 0,
1861 role: NodeRole::Child {
1862 parent_id: parent.into(),
1863 parent_key: parent_key.into(),
1864 },
1865 source: ConnectorSpec {
1866 kind: "csv".into(),
1867 config: json!({}),
1868 transforms: None,
1869 inherit_transforms: true,
1870 },
1871 sink: ConnectorSpec {
1872 kind: "jsonl".into(),
1873 config: json!({}),
1874 transforms: None,
1875 inherit_transforms: true,
1876 },
1877 transforms: Vec::new(),
1878 state: None,
1879 dlq: None,
1880 delivery: faucet_core::DeliveryMode::AtLeastOnce,
1881 #[cfg(feature = "quality")]
1882 quality: None,
1883 deferred_refs: refs
1884 .iter()
1885 .map(|(rid, p)| DeferredRef {
1886 referenced_id: (*rid).into(),
1887 dotted_path: (*p).into(),
1888 token: format!("${{{rid}.{p}}}"),
1889 })
1890 .collect(),
1891 }
1892 }
1893
1894 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
1895 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
1896 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
1897 let children_of =
1898 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
1899
1900 let projs = build_projections(&nodes_by_id, &children_of);
1901 let p = projs.get("p").expect("projection for p");
1902 match &**p {
1903 Projection::Paths(paths) => {
1904 assert!(paths.contains(&vec!["id".to_string()]));
1905 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
1906 assert!(paths.contains(&vec!["email".to_string()]));
1907 assert!(
1908 !paths.iter().any(|p| p == &vec!["x".to_string()]),
1909 "a ref to a different parent must not be captured under p"
1910 );
1911 }
1912 Projection::Full => panic!("expected Paths, got Full"),
1913 }
1914 }
1915
1916 #[test]
1917 fn build_projections_whole_record_ref_is_full() {
1918 use crate::config::ConnectorSpec;
1919 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
1920 let c = ExpandedNode {
1921 id: "c".into(),
1922 row_index: 0,
1923 role: NodeRole::Child {
1924 parent_id: "p".into(),
1925 parent_key: "id".into(),
1926 },
1927 source: ConnectorSpec {
1928 kind: "csv".into(),
1929 config: json!({}),
1930 transforms: None,
1931 inherit_transforms: true,
1932 },
1933 sink: ConnectorSpec {
1934 kind: "jsonl".into(),
1935 config: json!({}),
1936 transforms: None,
1937 inherit_transforms: true,
1938 },
1939 transforms: Vec::new(),
1940 state: None,
1941 dlq: None,
1942 delivery: faucet_core::DeliveryMode::AtLeastOnce,
1943 #[cfg(feature = "quality")]
1944 quality: None,
1945 deferred_refs: vec![DeferredRef {
1946 referenced_id: "p".into(),
1947 dotted_path: "".into(),
1948 token: "${p}".into(),
1949 }],
1950 };
1951 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
1952 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
1953 let projs = build_projections(&nodes_by_id, &children_of);
1954 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
1955 }
1956
1957 fn opts(name: &str) -> ExecuteOptions {
1959 ExecuteOptions {
1960 pipeline_name: name.into(),
1961 execution: None,
1962 dry_run: false,
1963 limit: None,
1964 state_path_override: None,
1965 auth: Default::default(),
1966 clock: chrono::Utc::now().fixed_offset(),
1967 cancel: None,
1968 #[cfg(feature = "lineage")]
1969 lineage: None,
1970 #[cfg(feature = "lineage")]
1971 lineage_cfg: None,
1972 }
1973 }
1974
1975 #[tokio::test]
1976 async fn dry_run_counts_records_without_writing_sink_file() {
1977 let dir = tempfile::tempdir().unwrap();
1980 let input = dir.path().join("in.csv");
1981 let output = dir.path().join("out.jsonl");
1982 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
1983 let cfg = cfg_csv_to_jsonl(&input, &output);
1984 let nodes = expand(&cfg).unwrap();
1985 let mut o = opts("dry");
1986 o.dry_run = true;
1987 let summary = run_expanded(nodes, o).await.unwrap();
1988 assert_eq!(summary.invocations.len(), 1);
1989 assert_eq!(summary.invocations[0].records_written, 3);
1990 assert!(!summary.had_failures());
1991 assert!(
1992 !output.exists(),
1993 "dry-run must not create the real sink file"
1994 );
1995 }
1996
1997 #[tokio::test]
1998 async fn limit_caps_records_written_across_the_run() {
1999 let dir = tempfile::tempdir().unwrap();
2001 let input = dir.path().join("in.csv");
2002 let output = dir.path().join("out.jsonl");
2003 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
2004 let cfg = cfg_csv_to_jsonl(&input, &output);
2005 let nodes = expand(&cfg).unwrap();
2006 let mut o = opts("lim");
2007 o.limit = Some(2);
2008 let summary = run_expanded(nodes, o).await.unwrap();
2009 assert_eq!(summary.invocations[0].records_written, 2);
2010 let body = std::fs::read_to_string(&output).unwrap();
2011 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
2012 }
2013
2014 #[tokio::test]
2015 async fn duplicate_state_key_among_siblings_is_rejected() {
2016 let dir = tempfile::tempdir().unwrap();
2020 let parent_csv = dir.path().join("parents.csv");
2021 let child_csv = dir.path().join("child.csv");
2022 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
2024 std::fs::write(&child_csv, "x\nA\n").unwrap();
2025 let parent_out = dir.path().join("parents.jsonl");
2026 let child_out = dir.path().join("child.jsonl");
2027 let yaml = format!(
2028 r#"version: 1
2029pipeline:
2030 source: {{ type: csv, config: {{ path: {parent} }} }}
2031 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2032 state: {{ type: memory }}
2033matrix:
2034 - id: parents
2035 - id: child
2036 parent: parents
2037 source: {{ config: {{ path: {child} }} }}
2038 sink: {{ config: {{ path: {child_out} }} }}
2039"#,
2040 parent = parent_csv.display(),
2041 parent_out = parent_out.display(),
2042 child = child_csv.display(),
2043 child_out = child_out.display(),
2044 );
2045 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2046 let nodes = expand(&cfg).unwrap();
2047 let err = run_expanded(nodes, opts("dupkey"))
2048 .await
2049 .expect_err("colliding sibling state keys must be rejected");
2050 match err {
2051 CliError::DuplicateStateKey { id, state_key } => {
2052 assert_eq!(id, "child");
2053 assert_eq!(state_key, "dupkey::child::dup");
2054 }
2055 other => panic!("expected DuplicateStateKey, got {other:?}"),
2056 }
2057 }
2058
2059 #[tokio::test]
2060 async fn state_path_override_writes_bookmark_file() {
2061 let dir = tempfile::tempdir().unwrap();
2066 let input = dir.path().join("in.csv");
2067 let output = dir.path().join("out.jsonl");
2068 let state_dir = dir.path().join("state");
2069 std::fs::write(&input, "name\nalice\n").unwrap();
2070 let cfg = cfg_csv_to_jsonl(&input, &output);
2071 let nodes = expand(&cfg).unwrap();
2072 let mut o = opts("statepath");
2073 o.state_path_override = Some(state_dir.clone());
2074 let summary = run_expanded(nodes, o).await.unwrap();
2075 assert!(!summary.had_failures());
2076 assert_eq!(summary.invocations[0].records_written, 1);
2080 }
2081
2082 #[tokio::test]
2083 async fn build_dlq_config_maps_spec_fields() {
2084 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
2085 let dir = tempfile::tempdir().unwrap();
2086 let dlq_out = dir.path().join("dlq.jsonl");
2087 let spec = DlqSpec {
2088 sink: ConnectorSpec {
2089 kind: "jsonl".into(),
2090 config: json!({ "path": dlq_out.to_str().unwrap() }),
2091 transforms: None,
2092 inherit_transforms: true,
2093 },
2094 on_batch_error: OnBatchErrorSpec::DlqAll,
2095 max_failures_per_page: Some(7),
2096 max_failures_total: Some(42),
2097 include_original_payload: false,
2098 };
2099 let cfg = build_dlq_config(&spec).await.unwrap();
2100 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
2101 assert_eq!(cfg.max_failures_per_page, Some(7));
2102 assert_eq!(cfg.max_failures_total, Some(42));
2103 assert!(!cfg.include_original_payload);
2104 }
2105
2106 #[tokio::test]
2107 async fn build_state_for_node_arms() {
2108 let dir = tempfile::tempdir().unwrap();
2109
2110 let node = stub_node(None);
2112 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
2113
2114 let p = dir.path().join("s1");
2116 assert!(
2117 build_state_for_node(&node, Some(&p))
2118 .await
2119 .unwrap()
2120 .is_some()
2121 );
2122
2123 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
2125 kind: "memory".into(),
2126 config: json!({}),
2127 }));
2128 assert!(
2129 build_state_for_node(&node_mem, None)
2130 .await
2131 .unwrap()
2132 .is_some()
2133 );
2134
2135 let node_file = stub_node(Some(crate::config::StateStoreSpec {
2137 kind: "file".into(),
2138 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
2139 }));
2140 let p2 = dir.path().join("override2");
2141 assert!(
2142 build_state_for_node(&node_file, Some(&p2))
2143 .await
2144 .unwrap()
2145 .is_some()
2146 );
2147
2148 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
2151 kind: "memory".into(),
2152 config: json!({}),
2153 }));
2154 let p3 = dir.path().join("override3");
2155 assert!(
2156 build_state_for_node(&node_mem2, Some(&p3))
2157 .await
2158 .unwrap()
2159 .is_some()
2160 );
2161 }
2162
2163 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
2165 use crate::config::ConnectorSpec;
2166 ExpandedNode {
2167 id: "n".into(),
2168 row_index: 0,
2169 role: NodeRole::Root,
2170 source: ConnectorSpec {
2171 kind: "csv".into(),
2172 config: json!({}),
2173 transforms: None,
2174 inherit_transforms: true,
2175 },
2176 sink: ConnectorSpec {
2177 kind: "jsonl".into(),
2178 config: json!({}),
2179 transforms: None,
2180 inherit_transforms: true,
2181 },
2182 transforms: Vec::new(),
2183 state,
2184 dlq: None,
2185 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2186 #[cfg(feature = "quality")]
2187 quality: None,
2188 deferred_refs: Vec::new(),
2189 }
2190 }
2191
2192 #[tokio::test]
2193 async fn state_key_override_delegates_and_overrides_key() {
2194 let dir = tempfile::tempdir().unwrap();
2196 let input = dir.path().join("in.csv");
2197 std::fs::write(&input, "name\nz\n").unwrap();
2198 let inner = build_source(
2199 "csv",
2200 json!({"path": input.to_str().unwrap()}),
2201 &AuthCatalog::new(),
2202 )
2203 .await
2204 .unwrap();
2205 let inner_name = inner.connector_name();
2207 let ov = StateKeyOverride {
2208 inner,
2209 key: "my::custom::key".into(),
2210 };
2211 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
2212 assert_eq!(ov.connector_name(), inner_name);
2213 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
2214 assert_eq!(rows.len(), 1);
2215 ov.apply_start_bookmark(json!({"any": "bookmark"}))
2217 .await
2218 .unwrap();
2219 }
2220
2221 #[tokio::test]
2222 async fn orphaned_child_surfaces_executor_deadlock() {
2223 use crate::config::ConnectorSpec;
2227 let orphan = ExpandedNode {
2228 id: "orphan".into(),
2229 row_index: 0,
2230 role: NodeRole::Child {
2231 parent_id: "missing-parent".into(),
2232 parent_key: "id".into(),
2233 },
2234 source: ConnectorSpec {
2235 kind: "csv".into(),
2236 config: json!({}),
2237 transforms: None,
2238 inherit_transforms: true,
2239 },
2240 sink: ConnectorSpec {
2241 kind: "jsonl".into(),
2242 config: json!({}),
2243 transforms: None,
2244 inherit_transforms: true,
2245 },
2246 transforms: Vec::new(),
2247 state: None,
2248 dlq: None,
2249 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2250 #[cfg(feature = "quality")]
2251 quality: None,
2252 deferred_refs: Vec::new(),
2253 };
2254 let err = run_expanded(vec![orphan], opts("deadlock"))
2255 .await
2256 .expect_err("an orphaned child must surface as an executor deadlock");
2257 match err {
2258 CliError::Internal(msg) => {
2259 assert!(msg.contains("executor deadlock"), "{msg}");
2260 assert!(msg.contains("orphan"), "{msg}");
2261 }
2262 other => panic!("expected Internal deadlock error, got {other:?}"),
2263 }
2264 }
2265
2266 #[test]
2267 fn value_to_string_brief_unquotes_strings_only() {
2268 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
2269 assert_eq!(value_to_string_brief(&json!(42)), "42");
2270 assert_eq!(value_to_string_brief(&json!(true)), "true");
2271 assert_eq!(value_to_string_brief(&json!(null)), "null");
2272 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
2273 }
2274
2275 #[test]
2276 fn build_state_key_with_and_without_parent() {
2277 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
2278 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
2279 }
2280
2281 #[test]
2282 fn resolve_parent_key_walks_objects_arrays_and_misses() {
2283 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
2284 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
2285 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
2286 assert_eq!(resolve_parent_key(&r, "user.age"), None);
2288 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
2290 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
2292 }
2293
2294 #[tokio::test]
2295 async fn cooperative_cancel_returns_partial_ok() {
2296 let dir = tempfile::tempdir().unwrap();
2300 let input = dir.path().join("in.csv");
2301 let output = dir.path().join("out.jsonl");
2302 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2303 let cfg = cfg_csv_to_jsonl(&input, &output);
2304 let nodes = expand(&cfg).unwrap();
2305 let token = CancellationToken::new();
2306 token.cancel(); let mut o = opts("cancel");
2308 o.cancel = Some(token);
2309 let summary = run_expanded(nodes, o).await.unwrap();
2310 assert_eq!(summary.invocations.len(), 1);
2313 assert!(
2314 !summary.had_failures(),
2315 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
2316 );
2317 }
2318
2319 #[tokio::test]
2320 async fn fanout_projects_away_unreferenced_parent_fields() {
2321 let dir = tempfile::tempdir().unwrap();
2325 let parent_csv = dir.path().join("parents.csv");
2326 let child_csv = dir.path().join("child.csv");
2327 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
2328 std::fs::write(&child_csv, "x\nA\n").unwrap();
2329 let parent_out = dir.path().join("parents.jsonl");
2330 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2331
2332 let yaml = format!(
2333 r#"version: 1
2334pipeline:
2335 source: {{ type: csv, config: {{ path: {parent} }} }}
2336 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2337matrix:
2338 - id: parents
2339 - id: child
2340 parent: parents
2341 source: {{ config: {{ path: {child} }} }}
2342 sink: {{ config: {{ path: "{child_out}" }} }}
2343"#,
2344 parent = parent_csv.display(),
2345 parent_out = parent_out.display(),
2346 child = child_csv.display(),
2347 child_out = child_out_pattern.display(),
2348 );
2349 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2350 let nodes = expand(&cfg).unwrap();
2351 let summary = run_expanded(
2352 nodes,
2353 ExecuteOptions {
2354 pipeline_name: "projtest".into(),
2355 execution: None,
2356 dry_run: false,
2357 limit: None,
2358 state_path_override: None,
2359 auth: Default::default(),
2360 clock: chrono::Utc::now().fixed_offset(),
2361 cancel: None,
2362 #[cfg(feature = "lineage")]
2363 lineage: None,
2364 #[cfg(feature = "lineage")]
2365 lineage_cfg: None,
2366 },
2367 )
2368 .await
2369 .unwrap();
2370
2371 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2373 assert!(!summary.had_failures(), "{summary:?}");
2374 assert!(dir.path().join("child-1.jsonl").exists());
2376 assert!(dir.path().join("child-2.jsonl").exists());
2377 }
2378}