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 reject_unresolved_backfill_tokens(&source_cfg, "source")?;
802 reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
803
804 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
805 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
806 resolve_inplace(&mut source_cfg, &ctx)?;
807 resolve_inplace(&mut sink_cfg, &ctx)?;
808 }
809
810 let source = match node.source_override.as_ref().and_then(|o| o.take()) {
815 Some(prebuilt) => prebuilt,
816 None => {
817 build_source(
818 &node.source.kind,
819 source_cfg,
820 &opts.auth,
821 opts.resilience.as_ref().map(|r| &r.retry),
822 )
823 .await?
824 }
825 };
826
827 #[cfg(feature = "catalog")]
830 let source_dataset_uri = source.dataset_uri();
831
832 if let Some(shard) = &opts.shard {
836 source
837 .apply_shard(shard)
838 .await
839 .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
840 }
841 let raw_sink: Box<dyn Sink> = if opts.dry_run {
842 Box::new(CountingSink::new())
843 } else {
844 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
845 };
846 #[cfg(feature = "catalog")]
847 let sink_dataset_uri = raw_sink.dataset_uri();
848 let raw_sink: Box<dyn Sink> = match opts.limit {
849 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
850 None => raw_sink,
851 };
852 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
853 let sink: Box<dyn Sink> = match &capture {
854 Some(projection) => Box::new(CapturingSink::wrap(
855 raw_sink,
856 Arc::clone(&captured),
857 Arc::clone(projection),
858 )),
859 None => raw_sink,
860 };
861
862 #[cfg(feature = "lineage")]
868 let (in_sample, out_sample) = {
869 use std::sync::Arc as StdArc;
870 let mut want = false;
871 let mut cap = 0usize;
872 if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
873 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
874 if want_schema {
875 cap = cap.max(lc.sample_records);
876 }
877 want = want_schema || lc.emit_on.running;
878 }
879 #[cfg(feature = "catalog")]
883 if catalog_active {
884 want = true;
885 cap = cap.max(
886 opts.catalog
887 .as_ref()
888 .map(|h| h.sample_records)
889 .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
890 );
891 }
892 if want {
893 (
894 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
895 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
896 )
897 } else {
898 (None, None)
899 }
900 };
901
902 #[cfg(feature = "lineage")]
905 let source: Box<dyn Source> = match &in_sample {
906 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
907 source,
908 std::sync::Arc::clone(state),
909 )),
910 None => source,
911 };
912
913 let stages = if node.transforms.is_empty() {
918 compile_transforms(&node.transforms)?
919 } else {
920 let mut transforms = node.transforms.clone();
921 for t in &mut transforms {
922 resolve_now_inplace(&mut t.config, opts.clock)?;
923 }
924 compile_transforms(&transforms)?
925 };
926 let source: Box<dyn Source> = if stages.is_empty() {
927 source
928 } else {
929 Box::new(faucet_core::TransformingSource::new(
930 source,
931 stages,
932 obs_labels.clone(),
933 )?)
934 };
935
936 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
940 let state: Option<Arc<dyn StateStore>> = match state {
945 Some(inner) if opts.dry_run || opts.limit.is_some() => {
946 Some(Arc::new(ReadOnlyStateStore { inner }))
947 }
948 other => other,
949 };
950 let sla_store = state.clone();
953 let effective_state_key = match &opts.shard {
956 Some(shard) => format!("{state_key}::{}", shard.id),
957 None => state_key.to_owned(),
958 };
959 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
960 Box::new(StateKeyOverride {
961 inner: source,
962 key: effective_state_key,
963 })
964 } else {
965 source
966 };
967
968 #[cfg(feature = "lineage")]
971 let sink: Box<dyn Sink> = match &out_sample {
972 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
973 sink,
974 std::sync::Arc::clone(state),
975 )),
976 None => sink,
977 };
978
979 #[cfg(feature = "lineage")]
984 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
985 .with_name(pipeline_name.clone())
986 .with_row(row_id.clone())
987 .with_run_id(run_id.clone());
988 #[cfg(not(feature = "lineage"))]
989 let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
990 .with_name(pipeline_name)
991 .with_row(row_id)
992 .with_run_id(run_id);
993 let pipeline = match state {
994 Some(store) => pipeline.with_state_store(store),
995 None => pipeline,
996 };
997 let pipeline = if let Some(ref dlq_spec) = node.dlq {
998 let dlq_cfg = build_dlq_config(dlq_spec).await?;
999 pipeline.with_dlq(dlq_cfg)
1000 } else {
1001 pipeline
1002 };
1003 let pipeline = pipeline.with_cancel(cancel.clone());
1009 #[cfg(feature = "quality")]
1013 let pipeline = if let Some(ref quality_spec) = node.quality {
1014 let compiled = Arc::new(
1015 faucet_core::CompiledQuality::compile(quality_spec)
1016 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
1017 );
1018 pipeline.with_quality(compiled)
1019 } else {
1020 pipeline
1021 };
1022 #[cfg(feature = "contract")]
1026 let pipeline = if let Some(ref contract_spec) = node.contract {
1027 let compiled = Arc::new(
1028 faucet_core::CompiledContract::compile(contract_spec)
1029 .map_err(|e| CliError::Config(format!("contract: {e}")))?,
1030 );
1031 pipeline.with_contract(compiled)
1032 } else {
1033 pipeline
1034 };
1035 #[cfg(feature = "masking")]
1041 let pipeline = if let Some(ref masking_spec) = node.masking {
1042 let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
1043 let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
1044 .map_err(|e| CliError::Config(format!("masking: {e}")))?;
1045 if compiled.is_empty() {
1046 pipeline
1047 } else {
1048 pipeline.with_masking(Arc::new(compiled))
1049 }
1050 } else {
1051 pipeline
1052 };
1053 let pipeline = if let Some(ref sd) = node.schema {
1055 pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd))
1056 } else {
1057 pipeline
1058 };
1059 let pipeline = if let Some(ab) = opts
1061 .execution
1062 .as_ref()
1063 .and_then(|e| e.adaptive_batch_size.clone())
1064 {
1065 ab.validate()
1066 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
1067 pipeline.with_adaptive(ab)
1068 } else {
1069 pipeline
1070 };
1071 let pipeline = if let Some(policy) = opts.resilience.clone() {
1074 pipeline.with_resilience(policy)
1075 } else {
1076 pipeline
1077 };
1078 let effective_delivery = if opts.dry_run || opts.limit.is_some() {
1084 faucet_core::idempotency::DeliveryMode::AtLeastOnce
1085 } else {
1086 node.delivery
1087 };
1088 let pipeline = pipeline.with_delivery(effective_delivery);
1089 #[cfg(feature = "lineage")]
1091 let lineage_ctx = match (&lineage, &lineage_cfg) {
1092 (Some(em), Some(lc)) => {
1093 let job_name =
1094 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1095 let mut ctx = faucet_lineage::RunLifecycle {
1096 job_namespace: lc.namespace.clone(),
1097 job_name,
1098 run_id: run_id.clone(),
1099 parent: lc.parent_job.clone(),
1100 input: faucet_lineage::DatasetRef {
1101 namespace: lc.namespace.clone(),
1102 name: source.dataset_uri(),
1103 },
1104 output: faucet_lineage::DatasetRef {
1105 namespace: lc.namespace.clone(),
1106 name: sink.dataset_uri(),
1107 },
1108 started_at: chrono::Utc::now(),
1109 finished_at: None,
1110 records: 0,
1111 error: None,
1112 input_schema: None,
1113 output_schema: None,
1114 column_lineage: None,
1115 source_code: None,
1116 };
1117 em.emit(faucet_lineage::EventType::Start, &ctx).await;
1118 let hb_handle = if lc.emit_on.running {
1121 let em2 = std::sync::Arc::clone(em);
1122 let interval = lc.heartbeat_interval;
1123 let mut beat_ctx = ctx.clone();
1124 let counter = out_sample.clone();
1125 Some(tokio::spawn(async move {
1126 let mut tick = tokio::time::interval(interval);
1127 tick.tick().await; loop {
1129 tick.tick().await;
1130 if let Some(c) = &counter {
1131 beat_ctx.records = c.count();
1132 }
1133 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1134 .await;
1135 }
1136 }))
1137 } else {
1138 None
1139 };
1140 ctx.source_code = if lc.include_source_code_facet {
1141 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1142 } else {
1143 None
1144 };
1145 Some((std::sync::Arc::clone(em), ctx, hb_handle))
1146 }
1147 _ => None,
1148 };
1149
1150 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1157 Ok(r) => sink.flush().await.map(|_| r),
1158 Err(e) => Err(e),
1159 };
1160
1161 #[cfg(feature = "lineage")]
1162 if let Some((em, mut ctx, hb)) = lineage_ctx {
1163 if let Some(h) = hb {
1164 h.abort();
1165 }
1166 ctx.finished_at = Some(chrono::Utc::now());
1167 if let Some(state) = &out_sample {
1168 ctx.records = state.count();
1169 if lineage_cfg
1170 .as_ref()
1171 .map(|l| l.include_schema_facet)
1172 .unwrap_or(false)
1173 {
1174 ctx.output_schema = Some(state.inferred_schema());
1175 }
1176 }
1177 if let Some(state) = &in_sample
1178 && lineage_cfg
1179 .as_ref()
1180 .map(|l| l.include_schema_facet || l.include_column_lineage)
1181 .unwrap_or(false)
1182 {
1183 let in_schema = state.inferred_schema();
1184 if lineage_cfg
1185 .as_ref()
1186 .map(|l| l.include_column_lineage)
1187 .unwrap_or(false)
1188 {
1189 let input_fields: Vec<String> =
1190 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1191 #[cfg(feature = "masking")]
1192 let has_masking = node.masking.is_some();
1193 #[cfg(not(feature = "masking"))]
1194 let has_masking = false;
1195 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1196 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1197 }
1198 if lineage_cfg
1199 .as_ref()
1200 .map(|l| l.include_schema_facet)
1201 .unwrap_or(false)
1202 {
1203 ctx.input_schema = Some(in_schema);
1204 }
1205 }
1206 let ev = match &result {
1207 Err(e) => {
1208 ctx.error = Some(e.to_string());
1209 faucet_lineage::EventType::Fail
1210 }
1211 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1212 Ok(_) => faucet_lineage::EventType::Complete,
1213 };
1214 em.emit(ev, &ctx).await;
1215 }
1216
1217 let is_notifiable_root = matches!(node.role, NodeRole::Root)
1227 && !opts.dry_run
1228 && opts.limit.is_none()
1229 && opts.shard.is_none()
1230 && !cancel.is_cancelled();
1231
1232 #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1233 let sla_violations = if let Some(spec) = &opts.sla
1234 && is_notifiable_root
1235 {
1236 let outcome = match &result {
1237 Ok(r) => crate::sla::RunOutcome::Success {
1238 rows: r.records_written as u64,
1239 },
1240 Err(_) => crate::sla::RunOutcome::Failure,
1241 };
1242 crate::sla::evaluate_post_run(
1243 spec,
1244 sla_store.as_ref(),
1245 state_key,
1246 &obs_labels.pipeline,
1247 &obs_labels.row,
1248 outcome,
1249 chrono::Utc::now().timestamp(),
1250 )
1251 .await
1252 } else {
1253 Vec::new()
1254 };
1255
1256 #[cfg(feature = "notify")]
1261 if let Some(notifier) = &opts.notifier
1262 && is_notifiable_root
1263 {
1264 use crate::notify::NotifyEvent;
1265 let pipeline = obs_labels.pipeline.to_string();
1266 let row = obs_labels.row.to_string();
1267 match &result {
1268 Ok(r) => {
1269 notifier
1270 .emit(NotifyEvent::run_success(
1271 pipeline.clone(),
1272 row.clone(),
1273 r.records_written as u64,
1274 ))
1275 .await;
1276 if let Some(dlq) = &r.dlq
1277 && dlq.records_dlq > 0
1278 {
1279 notifier
1280 .emit(NotifyEvent::dlq_threshold(
1281 pipeline.clone(),
1282 row.clone(),
1283 dlq.records_dlq as u64,
1284 ))
1285 .await;
1286 }
1287 }
1288 Err(e) => {
1289 notifier.emit(error_event(&pipeline, &row, e)).await;
1290 }
1291 }
1292 for v in &sla_violations {
1293 notifier
1294 .emit(NotifyEvent::sla_breach(
1295 pipeline.clone(),
1296 row.clone(),
1297 v.kind(),
1298 v.to_string(),
1299 ))
1300 .await;
1301 }
1302 }
1303
1304 #[cfg(feature = "catalog")]
1309 if let Some(handle) = &opts.catalog
1310 && catalog_active
1311 && !cancel.is_cancelled()
1312 && let Ok(pipeline_result) = &result
1313 {
1314 use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1315 use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1316
1317 let records_written = pipeline_result.records_written as u64;
1318 let source_schema = in_sample
1319 .as_ref()
1320 .and_then(|s| schema_from_samples(&s.samples()));
1321 let sink_schema = out_sample
1322 .as_ref()
1323 .and_then(|s| schema_from_samples(&s.samples()));
1324 let records_read = in_sample
1327 .as_ref()
1328 .map(|s| s.count())
1329 .unwrap_or(records_written);
1330 let records_out = out_sample
1331 .as_ref()
1332 .map(|s| s.count())
1333 .unwrap_or(records_written);
1334
1335 let column_lineage = in_sample.as_ref().and_then(|s| {
1338 let input_fields: Vec<String> = s
1339 .inferred_schema()
1340 .fields
1341 .iter()
1342 .map(|(n, _)| n.clone())
1343 .collect();
1344 #[cfg(feature = "masking")]
1345 let has_masking = node.masking.is_some();
1346 #[cfg(not(feature = "masking"))]
1347 let has_masking = false;
1348 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1349 faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1350 let fields: serde_json::Map<String, Value> = cl
1353 .edges
1354 .iter()
1355 .map(|(out, ins)| {
1356 (
1357 out.clone(),
1358 Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1359 )
1360 })
1361 .collect();
1362 serde_json::json!({ "fields": fields })
1363 })
1364 });
1365
1366 let update = CatalogUpdate {
1367 run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1368 pipeline: obs_labels.pipeline.to_string(),
1369 row: obs_labels.row.to_string(),
1370 recorded_at: chrono::Utc::now(),
1371 source: DatasetObservation {
1372 uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1373 kind: node.source.kind.clone(),
1374 role: DatasetRole::Source,
1375 schema: source_schema,
1376 records: records_read,
1377 },
1378 sink: DatasetObservation {
1379 uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1380 kind: node.sink.kind.clone(),
1381 role: DatasetRole::Sink,
1382 schema: sink_schema,
1383 records: records_out,
1384 },
1385 column_lineage,
1386 };
1387 crate::catalog::record(handle, &update).await;
1388 }
1389
1390 let result = result?;
1391
1392 let captured = if capture.is_some() {
1393 std::mem::take(&mut *captured.lock().await)
1394 } else {
1395 Vec::new()
1396 };
1397 Ok((captured, result.records_written))
1398}
1399
1400async fn build_state_for_node(
1401 node: &ExpandedNode,
1402 state_path_override: Option<&Path>,
1403) -> CliResult<Option<Arc<dyn StateStore>>> {
1404 match (&node.state, state_path_override) {
1405 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1406 (None, Some(path)) => Ok(Some(state_from_override(path))),
1407 (Some(spec), Some(path)) => {
1408 if spec.kind == "file" {
1409 Ok(Some(state_from_override(path)))
1410 } else {
1411 tracing::warn!(
1412 state = %spec.kind,
1413 "--state-path is only meaningful for the 'file' backend; ignoring override"
1414 );
1415 Ok(Some(build_state_store(spec).await?))
1416 }
1417 }
1418 (None, None) => Ok(None),
1419 }
1420}
1421
1422fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1423 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1424}
1425
1426pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1429 let sink = build_sink(
1432 &spec.sink.kind,
1433 spec.sink.config.clone(),
1434 &AuthCatalog::new(),
1435 )
1436 .await?;
1437 Ok(DlqConfig {
1438 sink: Arc::from(sink),
1439 on_batch_error: match spec.on_batch_error {
1440 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1441 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1442 },
1443 max_failures_per_page: spec.max_failures_per_page,
1444 max_failures_total: spec.max_failures_total,
1445 include_original_payload: spec.include_original_payload,
1446 })
1447}
1448
1449#[cfg(feature = "notify")]
1453fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1454 use crate::notify::NotifyEvent;
1455 match err {
1456 FaucetError::CircuitOpen { failures, cooldown } => {
1457 NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1458 }
1459 FaucetError::ContractViolation { message, .. } => {
1460 NotifyEvent::contract_abort(pipeline, row, message.clone())
1461 }
1462 other => {
1463 NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1464 }
1465 }
1466}
1467
1468#[cfg(feature = "notify")]
1472fn faucet_error_kind(err: &FaucetError) -> &'static str {
1473 match err {
1474 FaucetError::Config(_) => "config",
1475 FaucetError::Source(_) => "source",
1476 FaucetError::Sink(_) => "sink",
1477 FaucetError::State(_) => "state",
1478 FaucetError::QualityFailure { .. } => "quality",
1479 FaucetError::SchemaDrift { .. } => "schema_drift",
1480 _ => "error",
1481 }
1482}
1483
1484fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1493 fn walk(value: &Value, owner: &str) -> CliResult<()> {
1494 match value {
1495 Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1496 "the {owner} config references a `${{backfill.*}}` token, which only `faucet backfill` resolves — run this config via `faucet backfill --from … --to …`, or remove the token"
1497 ))),
1498 Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1499 Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1500 _ => Ok(()),
1501 }
1502 }
1503 walk(value, owner)
1504}
1505
1506pub(crate) fn resolve_now_inplace(
1507 value: &mut Value,
1508 clock: DateTime<FixedOffset>,
1509) -> CliResult<()> {
1510 match value {
1511 Value::String(s) => {
1512 *s = crate::interpolate::resolve_now(s, clock)?;
1513 Ok(())
1514 }
1515 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1516 Value::Object(m) => m
1517 .values_mut()
1518 .try_for_each(|v| resolve_now_inplace(v, clock)),
1519 _ => Ok(()),
1520 }
1521}
1522
1523fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1527 match value {
1528 Value::String(s) => {
1529 let resolved = interpolate_record(s, ctx)?;
1530 *s = resolved;
1531 Ok(())
1532 }
1533 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1534 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1535 _ => Ok(()),
1536 }
1537}
1538
1539struct ReadOnlyStateStore {
1552 inner: Arc<dyn StateStore>,
1553}
1554
1555#[async_trait]
1556impl StateStore for ReadOnlyStateStore {
1557 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1558 self.inner.get(key).await
1559 }
1560 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1561 Ok(())
1562 }
1563 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1564 Ok(())
1565 }
1566}
1567
1568struct StateKeyOverride {
1572 inner: Box<dyn Source>,
1573 key: String,
1574}
1575
1576#[async_trait]
1577impl Source for StateKeyOverride {
1578 async fn fetch_with_context(
1579 &self,
1580 ctx: &HashMap<String, Value>,
1581 ) -> Result<Vec<Value>, FaucetError> {
1582 self.inner.fetch_with_context(ctx).await
1583 }
1584 async fn fetch_with_context_incremental(
1585 &self,
1586 ctx: &HashMap<String, Value>,
1587 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1588 self.inner.fetch_with_context_incremental(ctx).await
1589 }
1590 fn stream_pages<'a>(
1596 &'a self,
1597 ctx: &'a HashMap<String, Value>,
1598 batch_size: usize,
1599 ) -> std::pin::Pin<
1600 Box<
1601 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1602 + Send
1603 + 'a,
1604 >,
1605 > {
1606 self.inner.stream_pages(ctx, batch_size)
1607 }
1608 fn connector_name(&self) -> &'static str {
1609 self.inner.connector_name()
1610 }
1611 fn dataset_uri(&self) -> String {
1612 self.inner.dataset_uri()
1613 }
1614 fn state_key(&self) -> Option<String> {
1615 Some(self.key.clone())
1616 }
1617 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1618 self.inner.apply_start_bookmark(bookmark).await
1619 }
1620 fn supports_exactly_once(&self) -> bool {
1621 self.inner.supports_exactly_once()
1622 }
1623 fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1624 self.inner.replay_guarantee()
1625 }
1626 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1627 self.inner.capture_resume_position().await
1628 }
1629}
1630
1631struct CapturingSink {
1635 inner: Box<dyn Sink>,
1636 captured: Arc<Mutex<Vec<Value>>>,
1637 projection: Arc<Projection>,
1638}
1639
1640impl CapturingSink {
1641 fn wrap(
1642 inner: Box<dyn Sink>,
1643 captured: Arc<Mutex<Vec<Value>>>,
1644 projection: Arc<Projection>,
1645 ) -> Self {
1646 Self {
1647 inner,
1648 captured,
1649 projection,
1650 }
1651 }
1652}
1653
1654#[async_trait]
1655impl Sink for CapturingSink {
1656 fn connector_name(&self) -> &'static str {
1657 self.inner.connector_name()
1658 }
1659 fn dataset_uri(&self) -> String {
1660 self.inner.dataset_uri()
1661 }
1662 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1663 let written = self.inner.write_batch(records).await?;
1664 let n = written.min(records.len());
1667 let mut buf = self.captured.lock().await;
1668 buf.extend(
1669 records
1670 .iter()
1671 .take(n)
1672 .map(|r| project_record(r, &self.projection)),
1673 );
1674 Ok(written)
1675 }
1676 async fn flush(&self) -> Result<(), FaucetError> {
1677 self.inner.flush().await
1678 }
1679 fn supports_idempotent_writes(&self) -> bool {
1683 self.inner.supports_idempotent_writes()
1684 }
1685 fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1686 self.inner.sink_guarantee()
1687 }
1688 fn dedups_by_key(&self) -> bool {
1689 self.inner.dedups_by_key()
1690 }
1691 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1692 self.inner.supported_write_modes()
1693 }
1694 async fn write_batch_idempotent(
1695 &self,
1696 records: &[Value],
1697 scope: &str,
1698 token: &str,
1699 ) -> Result<usize, FaucetError> {
1700 let written = self
1701 .inner
1702 .write_batch_idempotent(records, scope, token)
1703 .await?;
1704 let n = written.min(records.len());
1705 let mut buf = self.captured.lock().await;
1706 buf.extend(
1707 records
1708 .iter()
1709 .take(n)
1710 .map(|r| project_record(r, &self.projection)),
1711 );
1712 Ok(written)
1713 }
1714 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1715 self.inner.last_committed_token(scope).await
1716 }
1717 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1718 self.inner.current_schema().await
1719 }
1720 fn supports_schema_evolution(&self) -> bool {
1721 self.inner.supports_schema_evolution()
1722 }
1723 async fn evolve_schema(
1724 &self,
1725 evolution: &faucet_core::SchemaEvolution,
1726 ) -> Result<(), FaucetError> {
1727 self.inner.evolve_schema(evolution).await
1728 }
1729}
1730
1731struct LimitedSink {
1734 inner: Box<dyn Sink>,
1735 remaining: AtomicUsize,
1736}
1737
1738impl LimitedSink {
1739 fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1740 Self {
1741 inner,
1742 remaining: AtomicUsize::new(cap),
1743 }
1744 }
1745}
1746
1747#[async_trait]
1748impl Sink for LimitedSink {
1749 fn connector_name(&self) -> &'static str {
1750 self.inner.connector_name()
1751 }
1752 fn dataset_uri(&self) -> String {
1753 self.inner.dataset_uri()
1754 }
1755 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1756 let remaining = self.remaining.load(Ordering::Relaxed);
1757 if remaining == 0 {
1758 return Ok(0);
1759 }
1760 let take = remaining.min(records.len());
1761 let slice = &records[..take];
1762 let written = self.inner.write_batch(slice).await?;
1763 self.remaining
1764 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1765 Ok(written)
1766 }
1767 async fn flush(&self) -> Result<(), FaucetError> {
1768 self.inner.flush().await
1769 }
1770}
1771
1772struct CountingSink {
1775 seen: AtomicUsize,
1776}
1777
1778impl CountingSink {
1779 fn new() -> Self {
1780 Self {
1781 seen: AtomicUsize::new(0),
1782 }
1783 }
1784}
1785
1786#[async_trait]
1787impl Sink for CountingSink {
1788 fn connector_name(&self) -> &'static str {
1789 "dry-run"
1790 }
1791 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1792 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1793 Ok(records.len())
1794 }
1795}
1796
1797fn value_to_string_brief(v: &Value) -> String {
1800 match v {
1801 Value::String(s) => s.clone(),
1802 other => other.to_string(),
1803 }
1804}
1805
1806#[cfg(test)]
1807mod tests {
1808 use super::*;
1809 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1810 use crate::expand::expand;
1811 use serde_json::json;
1812
1813 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1814 PipelineConfig {
1815 version: 1,
1816 name: Some("test".into()),
1817 vars: None,
1818 auth: None,
1819 pipeline: PipelineSpec {
1820 source: Some(ConnectorSpec {
1821 kind: "csv".into(),
1822 config: json!({"path": input.to_str().unwrap()}),
1823 transforms: None,
1824 inherit_transforms: true,
1825 }),
1826 sink: Some(ConnectorSpec {
1827 kind: "jsonl".into(),
1828 config: json!({"path": output.to_str().unwrap()}),
1829 transforms: None,
1830 inherit_transforms: true,
1831 }),
1832 sources: Default::default(),
1833 sinks: Default::default(),
1834 transforms: Vec::new(),
1835 state: None,
1836 dlq: None,
1837 #[cfg(feature = "quality")]
1838 quality: None,
1839 #[cfg(feature = "contract")]
1840 contract: None,
1841 #[cfg(feature = "masking")]
1842 masking: None,
1843 schema: None,
1844 },
1845 matrix: Vec::new(),
1846 execution: None,
1847 observability: None,
1848 delivery: faucet_core::DeliveryMode::default(),
1849 resilience: None,
1850 sla: None,
1851 shard: None,
1852 replication: None,
1853 backfill: None,
1854 #[cfg(feature = "schedule")]
1855 schedule: None,
1856 #[cfg(feature = "lineage")]
1857 lineage: None,
1858 #[cfg(feature = "catalog")]
1859 catalog: None,
1860 #[cfg(feature = "notify")]
1861 notifications: Vec::new(),
1862 }
1863 }
1864
1865 #[tokio::test]
1866 async fn empty_matrix_runs_pipeline_once() {
1867 let dir = tempfile::tempdir().unwrap();
1868 let input = dir.path().join("in.csv");
1869 let output = dir.path().join("out.jsonl");
1870 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1871 let cfg = cfg_csv_to_jsonl(&input, &output);
1872 let nodes = expand(&cfg).unwrap();
1873 let summary = run_expanded(
1874 nodes,
1875 ExecuteOptions {
1876 pipeline_name: "t".into(),
1877 execution: None,
1878 dry_run: false,
1879 limit: None,
1880 state_path_override: None,
1881 shard: None,
1882 auth: Default::default(),
1883 clock: chrono::Utc::now().fixed_offset(),
1884 cancel: None,
1885 resilience: None,
1886 sla: None,
1887 #[cfg(feature = "lineage")]
1888 lineage: None,
1889 #[cfg(feature = "lineage")]
1890 lineage_cfg: None,
1891 #[cfg(feature = "notify")]
1892 notifier: None,
1893 #[cfg(feature = "catalog")]
1894 catalog: None,
1895 },
1896 )
1897 .await
1898 .unwrap();
1899 assert_eq!(summary.invocations.len(), 1);
1900 assert_eq!(summary.invocations[0].records_written, 2);
1901 assert!(!summary.had_failures());
1902 let body = std::fs::read_to_string(&output).unwrap();
1903 assert_eq!(body.lines().count(), 2);
1904 }
1905
1906 #[cfg(feature = "catalog")]
1908 fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
1909 let mut o = opts(name);
1910 o.catalog = Some(handle);
1911 o
1912 }
1913
1914 #[cfg(feature = "catalog")]
1915 #[tokio::test]
1916 async fn catalog_records_schema_timeline_across_two_runs() {
1917 use crate::catalog::CatalogHandle;
1921 use crate::serve::history::RunHistory as _;
1922 use crate::serve::history::catalog::{self, CatalogListFilter};
1923 use crate::serve::history::memory::MemoryHistory;
1924
1925 let dir = tempfile::tempdir().unwrap();
1926 let input = dir.path().join("in.csv");
1927 let output = dir.path().join("out.jsonl");
1928 let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
1929 let handle = CatalogHandle {
1930 store: store.clone(),
1931 run_id: None,
1932 sample_records: 10,
1933 };
1934
1935 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
1936 let cfg = cfg_csv_to_jsonl(&input, &output);
1937 let nodes = expand(&cfg).unwrap();
1938 let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
1939 .await
1940 .unwrap();
1941 assert!(!summary.had_failures());
1942
1943 std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
1945 let nodes = expand(&cfg).unwrap();
1946 let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
1947 .await
1948 .unwrap();
1949 assert!(!summary.had_failures());
1950
1951 let page = store
1953 .catalog_list_datasets(&CatalogListFilter {
1954 limit: 10,
1955 ..Default::default()
1956 })
1957 .await
1958 .unwrap();
1959 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
1960 for ds in &page.datasets {
1961 let detail = store
1962 .catalog_get_dataset(&ds.id)
1963 .await
1964 .unwrap()
1965 .expect("dataset detail");
1966 assert_eq!(detail.dataset.runs, 2);
1967 assert_eq!(
1968 detail.schema_timeline.len(),
1969 2,
1970 "exactly two timeline entries for {}",
1971 ds.uri
1972 );
1973 assert!(detail.schema_timeline[0].diff.is_none());
1974 let diff = detail.schema_timeline[1]
1975 .diff
1976 .as_ref()
1977 .expect("second version carries a diff");
1978 assert!(
1979 diff["added"]
1980 .as_array()
1981 .unwrap()
1982 .iter()
1983 .any(|c| c["column"] == "email"),
1984 "diff must show the added email column: {diff}"
1985 );
1986 assert_eq!(detail.stats.len(), 2, "one volume point per run");
1987 }
1988 let edges = store.catalog_lineage(None, 5).await.unwrap();
1990 assert_eq!(edges.len(), 1);
1991 assert_eq!(edges[0].runs, 2);
1992 assert_eq!(edges[0].last_records, 2);
1993 assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
1994 }
1995
1996 #[cfg(feature = "catalog")]
1999 struct FailingCatalogStore;
2000
2001 #[cfg(feature = "catalog")]
2002 #[async_trait]
2003 impl crate::serve::history::RunHistory for FailingCatalogStore {
2004 async fn claim_idempotency(
2005 &self,
2006 _: &str,
2007 _: &str,
2008 _: &str,
2009 _: std::time::Duration,
2010 ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2011 Err(crate::serve::history::HistoryError::Backend("down".into()))
2012 }
2013 async fn upsert(
2014 &self,
2015 _: &crate::serve::history::RunRecord,
2016 ) -> Result<(), crate::serve::history::HistoryError> {
2017 Err(crate::serve::history::HistoryError::Backend("down".into()))
2018 }
2019 async fn get(
2020 &self,
2021 _: &str,
2022 ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2023 {
2024 Err(crate::serve::history::HistoryError::Backend("down".into()))
2025 }
2026 async fn list(
2027 &self,
2028 _: &crate::serve::history::ListFilter,
2029 ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2030 Err(crate::serve::history::HistoryError::Backend("down".into()))
2031 }
2032 async fn delete(
2033 &self,
2034 _: &str,
2035 ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2036 {
2037 Err(crate::serve::history::HistoryError::Backend("down".into()))
2038 }
2039 async fn purge_expired(
2040 &self,
2041 _: std::time::Duration,
2042 ) -> Result<usize, crate::serve::history::HistoryError> {
2043 Err(crate::serve::history::HistoryError::Backend("down".into()))
2044 }
2045 async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2046 Err(crate::serve::history::HistoryError::Backend("down".into()))
2047 }
2048 async fn catalog_record(
2049 &self,
2050 _: &crate::serve::history::catalog::CatalogUpdate,
2051 ) -> Result<(), crate::serve::history::HistoryError> {
2052 Err(crate::serve::history::HistoryError::Backend(
2053 "catalog write refused".into(),
2054 ))
2055 }
2056 fn degraded(&self) -> bool {
2057 false
2058 }
2059 }
2060
2061 #[cfg(feature = "catalog")]
2062 #[tokio::test]
2063 async fn catalog_write_failure_never_fails_the_run() {
2064 use crate::catalog::CatalogHandle;
2067 let dir = tempfile::tempdir().unwrap();
2068 let input = dir.path().join("in.csv");
2069 let output = dir.path().join("out.jsonl");
2070 std::fs::write(&input, "name\nalice\n").unwrap();
2071 let cfg = cfg_csv_to_jsonl(&input, &output);
2072 let nodes = expand(&cfg).unwrap();
2073 let handle = CatalogHandle {
2074 store: Arc::new(FailingCatalogStore),
2075 run_id: None,
2076 sample_records: 10,
2077 };
2078 let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2079 .await
2080 .unwrap();
2081 assert!(
2082 !summary.had_failures(),
2083 "catalog failure must not fail the run"
2084 );
2085 assert_eq!(summary.invocations[0].records_written, 1);
2086 assert_eq!(
2087 std::fs::read_to_string(&output).unwrap().lines().count(),
2088 1,
2089 "sink output written despite the catalog error"
2090 );
2091 }
2092
2093 #[tokio::test]
2094 async fn matrix_two_independent_roots_both_run() {
2095 let dir = tempfile::tempdir().unwrap();
2097 let csv_a = dir.path().join("a.csv");
2098 let csv_b = dir.path().join("b.csv");
2099 let out_a = dir.path().join("a.jsonl");
2100 let out_b = dir.path().join("b.jsonl");
2101 std::fs::write(&csv_a, "name\nalice\n").unwrap();
2102 std::fs::write(&csv_b, "name\nbob\n").unwrap();
2103
2104 let yaml = format!(
2105 r#"version: 1
2106pipeline:
2107 source: {{ type: csv, config: {{ path: {a} }} }}
2108 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
2109matrix:
2110 - id: rowA
2111 - id: rowB
2112 source: {{ config: {{ path: {b} }} }}
2113 sink: {{ config: {{ path: {out_b} }} }}
2114"#,
2115 a = csv_a.display(),
2116 b = csv_b.display(),
2117 out_a = out_a.display(),
2118 out_b = out_b.display(),
2119 );
2120 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2121 let nodes = expand(&cfg).unwrap();
2122 let summary = run_expanded(
2123 nodes,
2124 ExecuteOptions {
2125 pipeline_name: "matrix".into(),
2126 execution: None,
2127 dry_run: false,
2128 limit: None,
2129 state_path_override: None,
2130 shard: None,
2131 auth: Default::default(),
2132 clock: chrono::Utc::now().fixed_offset(),
2133 cancel: None,
2134 resilience: None,
2135 sla: None,
2136 #[cfg(feature = "lineage")]
2137 lineage: None,
2138 #[cfg(feature = "lineage")]
2139 lineage_cfg: None,
2140 #[cfg(feature = "notify")]
2141 notifier: None,
2142 #[cfg(feature = "catalog")]
2143 catalog: None,
2144 },
2145 )
2146 .await
2147 .unwrap();
2148 assert_eq!(summary.invocations.len(), 2);
2149 assert!(out_a.exists());
2150 assert!(out_b.exists());
2151 }
2152
2153 #[tokio::test]
2154 async fn dag_child_fans_out_per_parent_record() {
2155 let dir = tempfile::tempdir().unwrap();
2158 let parent_csv = dir.path().join("parents.csv");
2159 let child_csv = dir.path().join("child.csv");
2160 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2161 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2162 let parent_out = dir.path().join("parents.jsonl");
2163 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2164
2165 let yaml = format!(
2166 r#"version: 1
2167pipeline:
2168 source: {{ type: csv, config: {{ path: {parent} }} }}
2169 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2170matrix:
2171 - id: parents
2172 - id: child
2173 parent: parents
2174 source: {{ config: {{ path: {child} }} }}
2175 sink: {{ config: {{ path: "{child_out}" }} }}
2176"#,
2177 parent = parent_csv.display(),
2178 parent_out = parent_out.display(),
2179 child = child_csv.display(),
2180 child_out = child_out_pattern.display(),
2181 );
2182 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2183 let nodes = expand(&cfg).unwrap();
2184 let summary = run_expanded(
2185 nodes,
2186 ExecuteOptions {
2187 pipeline_name: "dagtest".into(),
2188 execution: None,
2189 dry_run: false,
2190 limit: None,
2191 state_path_override: None,
2192 shard: None,
2193 auth: Default::default(),
2194 clock: chrono::Utc::now().fixed_offset(),
2195 cancel: None,
2196 resilience: None,
2197 sla: None,
2198 #[cfg(feature = "lineage")]
2199 lineage: None,
2200 #[cfg(feature = "lineage")]
2201 lineage_cfg: None,
2202 #[cfg(feature = "notify")]
2203 notifier: None,
2204 #[cfg(feature = "catalog")]
2205 catalog: None,
2206 },
2207 )
2208 .await
2209 .unwrap();
2210
2211 assert_eq!(summary.invocations.len(), 3);
2213 assert!(!summary.had_failures(), "{:?}", summary);
2214 assert!(dir.path().join("child-1.jsonl").exists());
2215 assert!(dir.path().join("child-2.jsonl").exists());
2216 }
2217
2218 #[tokio::test]
2219 async fn depends_on_root_runs_after_dependency() {
2220 let dir = tempfile::tempdir().unwrap();
2224 let input = dir.path().join("in.csv");
2225 let mid = dir.path().join("mid.csv");
2226 let out = dir.path().join("out.jsonl");
2227 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2228
2229 let yaml = format!(
2230 r#"version: 1
2231pipeline:
2232 source: {{ type: csv, config: {{ path: {input} }} }}
2233 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2234matrix:
2235 - id: stage
2236 sink: {{ type: csv, config: {{ path: {mid} }} }}
2237 - id: load
2238 depends_on: [stage]
2239 source: {{ config: {{ path: {mid} }} }}
2240"#,
2241 input = input.display(),
2242 mid = mid.display(),
2243 out = out.display(),
2244 );
2245 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2246 let nodes = expand(&cfg).unwrap();
2247 let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2248 assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2249 assert!(!summary.had_failures(), "{summary:?}");
2250 let load = summary
2251 .invocations
2252 .iter()
2253 .find(|i| i.row_id == "load")
2254 .unwrap();
2255 assert_eq!(load.records_written, 2);
2256 let written = std::fs::read_to_string(&out).unwrap();
2257 assert_eq!(written.lines().count(), 2);
2258 }
2259
2260 #[tokio::test]
2261 async fn diamond_dependency_waits_for_all_prerequisites() {
2262 let dir = tempfile::tempdir().unwrap();
2265 let input = dir.path().join("in.csv");
2266 let mid_a = dir.path().join("mid_a.csv");
2267 let mid_b = dir.path().join("mid_b.csv");
2268 let out = dir.path().join("out.jsonl");
2269 std::fs::write(&input, "name\nalice\n").unwrap();
2270
2271 let yaml = format!(
2272 r#"version: 1
2273pipeline:
2274 source: {{ type: csv, config: {{ path: {input} }} }}
2275 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2276matrix:
2277 - id: a
2278 sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2279 - id: b
2280 sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2281 - id: c
2282 depends_on: [a, b]
2283 source: {{ config: {{ path: {mid_a} }} }}
2284"#,
2285 input = input.display(),
2286 mid_a = mid_a.display(),
2287 mid_b = mid_b.display(),
2288 out = out.display(),
2289 );
2290 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2291 let nodes = expand(&cfg).unwrap();
2292 let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2293 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2294 assert!(!summary.had_failures(), "{summary:?}");
2295 assert!(mid_b.exists(), "b must have run before c became ready");
2296 assert!(out.exists());
2297 }
2298
2299 #[tokio::test]
2300 async fn failed_dependency_skips_dependent() {
2301 let dir = tempfile::tempdir().unwrap();
2304 let good_input = dir.path().join("good.csv");
2305 let out = dir.path().join("out.jsonl");
2306 std::fs::write(&good_input, "name\nalice\n").unwrap();
2307
2308 let yaml = format!(
2309 r#"version: 1
2310pipeline:
2311 source: {{ type: csv, config: {{ path: {good} }} }}
2312 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2313matrix:
2314 - id: stage
2315 source: {{ config: {{ path: {missing} }} }}
2316 - id: load
2317 depends_on: [stage]
2318"#,
2319 good = good_input.display(),
2320 missing = dir.path().join("nonexistent.csv").display(),
2321 out = out.display(),
2322 );
2323 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2324 let nodes = expand(&cfg).unwrap();
2325 let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2326 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2327 assert_eq!(summary.invocations[0].row_id, "stage");
2328 assert!(summary.invocations[0].error.is_some());
2329 assert!(
2330 !out.exists(),
2331 "dependent row must not run after its dependency failed"
2332 );
2333 }
2334
2335 #[tokio::test]
2336 async fn dependency_on_skipped_row_cascades() {
2337 let dir = tempfile::tempdir().unwrap();
2340 let good_input = dir.path().join("good.csv");
2341 let out = dir.path().join("q.jsonl");
2342 std::fs::write(&good_input, "id\n1\n").unwrap();
2343
2344 let yaml = format!(
2345 r#"version: 1
2346pipeline:
2347 source: {{ type: csv, config: {{ path: {good} }} }}
2348 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2349matrix:
2350 - id: p
2351 source: {{ config: {{ path: {missing} }} }}
2352 - id: c
2353 parent: p
2354 - id: q
2355 depends_on: [c]
2356"#,
2357 good = good_input.display(),
2358 missing = dir.path().join("nonexistent.csv").display(),
2359 out = out.display(),
2360 );
2361 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2362 let nodes = expand(&cfg).unwrap();
2363 let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2364 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2365 assert_eq!(summary.invocations[0].row_id, "p");
2366 assert!(summary.invocations[0].error.is_some());
2367 assert!(
2368 !out.exists(),
2369 "q must be skipped when its dependency was skipped"
2370 );
2371 }
2372
2373 #[tokio::test]
2374 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2375 let dir = tempfile::tempdir().unwrap();
2387 let good_csv = dir.path().join("good.csv");
2388 std::fs::write(&good_csv, "x\n1\n").unwrap();
2389 let good_out = dir.path().join("good.jsonl");
2390 let bad_sink_dir = dir.path().to_path_buf();
2391
2392 let yaml = format!(
2393 r#"version: 1
2394pipeline:
2395 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2396 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2397matrix:
2398 - id: bad
2399 sink: {{ config: {{ path: {bad_dir} }} }}
2400 - id: good
2401execution:
2402 max_concurrent: 1
2403 on_error: stop
2404"#,
2405 good_csv = good_csv.display(),
2406 good_out = good_out.display(),
2407 bad_dir = bad_sink_dir.display(),
2408 );
2409 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2410 let nodes = expand(&cfg).unwrap();
2411 let summary = run_expanded(
2412 nodes,
2413 ExecuteOptions {
2414 pipeline_name: "stoptest".into(),
2415 execution: cfg.execution.clone(),
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 .unwrap();
2437
2438 assert!(summary.had_failures(), "the failing root must be reported");
2440
2441 let bad: Vec<_> = summary
2443 .invocations
2444 .iter()
2445 .filter(|o| o.row_id == "bad")
2446 .collect();
2447 assert_eq!(bad.len(), 1, "bad must run exactly once");
2448 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2449
2450 assert!(
2452 summary.invocations.len() <= 2,
2453 "at most the two roots may run, got {:?}",
2454 summary.invocations
2455 );
2456
2457 let good_wrote = summary
2464 .invocations
2465 .iter()
2466 .find(|o| o.row_id == "good" && o.error.is_none())
2467 .map(|o| o.records_written)
2468 .unwrap_or(0);
2469 if good_wrote > 0 {
2470 assert!(
2471 good_out.exists(),
2472 "a good that wrote records must have produced its output file"
2473 );
2474 }
2475 }
2476
2477 #[tokio::test]
2478 async fn invalid_pipeline_name_with_state_errors_up_front() {
2479 let dir = tempfile::tempdir().unwrap();
2483 let input = dir.path().join("in.csv");
2484 let output = dir.path().join("out.jsonl");
2485 std::fs::write(&input, "name\nalice\n").unwrap();
2486 let yaml = format!(
2487 r#"version: 1
2488pipeline:
2489 source: {{ type: csv, config: {{ path: {input} }} }}
2490 sink: {{ type: jsonl, config: {{ path: {output} }} }}
2491 state: {{ type: memory }}
2492"#,
2493 input = input.display(),
2494 output = output.display(),
2495 );
2496 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2497 let nodes = expand(&cfg).unwrap();
2498 let err = run_expanded(
2499 nodes,
2500 ExecuteOptions {
2501 pipeline_name: "bad name".into(), execution: None,
2503 dry_run: false,
2504 limit: None,
2505 state_path_override: None,
2506 shard: None,
2507 auth: Default::default(),
2508 clock: chrono::Utc::now().fixed_offset(),
2509 cancel: None,
2510 resilience: None,
2511 sla: None,
2512 #[cfg(feature = "lineage")]
2513 lineage: None,
2514 #[cfg(feature = "lineage")]
2515 lineage_cfg: None,
2516 #[cfg(feature = "notify")]
2517 notifier: None,
2518 #[cfg(feature = "catalog")]
2519 catalog: None,
2520 },
2521 )
2522 .await
2523 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2524 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2525 }
2526
2527 #[tokio::test]
2528 async fn invalid_parent_key_value_with_state_errors_up_front() {
2529 let dir = tempfile::tempdir().unwrap();
2532 let parent_csv = dir.path().join("parents.csv");
2533 let child_csv = dir.path().join("child.csv");
2534 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2536 std::fs::write(&child_csv, "x\nA\n").unwrap();
2537 let parent_out = dir.path().join("parents.jsonl");
2538 let child_out = dir.path().join("child.jsonl");
2539 let yaml = format!(
2540 r#"version: 1
2541pipeline:
2542 source: {{ type: csv, config: {{ path: {parent} }} }}
2543 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2544 state: {{ type: memory }}
2545matrix:
2546 - id: parents
2547 - id: child
2548 parent: parents
2549 source: {{ config: {{ path: {child} }} }}
2550 sink: {{ config: {{ path: {child_out} }} }}
2551"#,
2552 parent = parent_csv.display(),
2553 parent_out = parent_out.display(),
2554 child = child_csv.display(),
2555 child_out = child_out.display(),
2556 );
2557 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2558 let nodes = expand(&cfg).unwrap();
2559 let err = run_expanded(
2560 nodes,
2561 ExecuteOptions {
2562 pipeline_name: "ok".into(),
2563 execution: None,
2564 dry_run: false,
2565 limit: None,
2566 state_path_override: None,
2567 shard: None,
2568 auth: Default::default(),
2569 clock: chrono::Utc::now().fixed_offset(),
2570 cancel: None,
2571 resilience: None,
2572 sla: None,
2573 #[cfg(feature = "lineage")]
2574 lineage: None,
2575 #[cfg(feature = "lineage")]
2576 lineage_cfg: None,
2577 #[cfg(feature = "notify")]
2578 notifier: None,
2579 #[cfg(feature = "catalog")]
2580 catalog: None,
2581 },
2582 )
2583 .await
2584 .expect_err(
2585 "an illegal parent-key value must be rejected up front when state is configured",
2586 );
2587 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2588 }
2589
2590 #[tokio::test]
2591 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2592 let dir = tempfile::tempdir().unwrap();
2601 let bad_sink_dir = dir.path().to_path_buf();
2602 let good_csv = dir.path().join("good.csv");
2605 std::fs::write(&good_csv, "x\n1\n").unwrap();
2606 let yaml = format!(
2612 r#"version: 1
2613pipeline:
2614 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2615 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2616matrix:
2617 - id: bad
2618 - id: good_a
2619 - id: good_b
2620execution:
2621 max_concurrent: 3
2622 on_error: stop
2623"#,
2624 good_csv = good_csv.display(),
2625 bad_dir = bad_sink_dir.display(),
2626 );
2627 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2628 let nodes = expand(&cfg).unwrap();
2629 let summary = run_expanded(
2630 nodes,
2631 ExecuteOptions {
2632 pipeline_name: "stop_parallel".into(),
2633 execution: cfg.execution.clone(),
2634 dry_run: false,
2635 limit: None,
2636 state_path_override: None,
2637 shard: None,
2638 auth: Default::default(),
2639 clock: chrono::Utc::now().fixed_offset(),
2640 cancel: None,
2641 resilience: None,
2642 sla: None,
2643 #[cfg(feature = "lineage")]
2644 lineage: None,
2645 #[cfg(feature = "lineage")]
2646 lineage_cfg: None,
2647 #[cfg(feature = "notify")]
2648 notifier: None,
2649 #[cfg(feature = "catalog")]
2650 catalog: None,
2651 },
2652 )
2653 .await
2654 .unwrap();
2655
2656 assert!(
2661 summary.had_failures(),
2662 "summary should record at least one failure: {summary:?}"
2663 );
2664 assert!(
2665 summary.invocations[0].error.is_some(),
2666 "first outcome must be the failure that triggered stop: {summary:?}"
2667 );
2668 for inv in &summary.invocations {
2672 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2673 }
2674 }
2675
2676 #[tokio::test]
2677 async fn on_error_continue_skips_failed_subtree_only() {
2678 let dir = tempfile::tempdir().unwrap();
2680 let good_csv = dir.path().join("good.csv");
2681 std::fs::write(&good_csv, "x\n1\n").unwrap();
2682 let good_out = dir.path().join("good.jsonl");
2683
2684 let yaml = format!(
2685 r#"version: 1
2686pipeline:
2687 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2688 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2689matrix:
2690 - id: bad
2691 sink: {{ config: {{ path: {bad_dir} }} }}
2692 - id: good
2693"#,
2694 good_csv = good_csv.display(),
2695 good_out = good_out.display(),
2696 bad_dir = dir.path().display(),
2697 );
2698 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2699 let nodes = expand(&cfg).unwrap();
2700 let summary = run_expanded(
2701 nodes,
2702 ExecuteOptions {
2703 pipeline_name: "continuetest".into(),
2704 execution: None,
2705 dry_run: false,
2706 limit: None,
2707 state_path_override: None,
2708 shard: None,
2709 auth: Default::default(),
2710 clock: chrono::Utc::now().fixed_offset(),
2711 cancel: None,
2712 resilience: None,
2713 sla: None,
2714 #[cfg(feature = "lineage")]
2715 lineage: None,
2716 #[cfg(feature = "lineage")]
2717 lineage_cfg: None,
2718 #[cfg(feature = "notify")]
2719 notifier: None,
2720 #[cfg(feature = "catalog")]
2721 catalog: None,
2722 },
2723 )
2724 .await
2725 .unwrap();
2726 assert_eq!(summary.invocations.len(), 2);
2727 assert_eq!(summary.failure_count(), 1);
2728 let good_outcome = summary
2729 .invocations
2730 .iter()
2731 .find(|i| i.row_id == "good")
2732 .unwrap();
2733 assert!(good_outcome.error.is_none());
2734 }
2735
2736 #[test]
2739 fn split_path_splits_on_dots() {
2740 assert_eq!(split_path("id"), vec!["id".to_string()]);
2741 assert_eq!(
2742 split_path("user.name"),
2743 vec!["user".to_string(), "name".to_string()]
2744 );
2745 }
2746
2747 #[test]
2748 fn minimal_paths_drops_descendants_of_kept_ancestors() {
2749 let paths = vec![
2750 vec!["user".into(), "name".into()],
2751 vec!["user".into()],
2752 vec!["id".into()],
2753 vec!["id".into()],
2754 ];
2755 let min = minimal_paths(paths);
2756 assert!(min.contains(&vec!["user".to_string()]));
2757 assert!(min.contains(&vec!["id".to_string()]));
2758 assert!(
2759 !min.contains(&vec!["user".to_string(), "name".to_string()]),
2760 "user.name must be dropped — covered by user"
2761 );
2762 assert_eq!(min.len(), 2);
2763 }
2764
2765 #[test]
2766 fn project_full_clones_whole_record() {
2767 let r = json!({"a": 1, "b": {"c": 2}});
2768 assert_eq!(project_record(&r, &Projection::Full), r);
2769 }
2770
2771 #[test]
2772 fn project_keeps_only_referenced_paths() {
2773 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2774 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2775 let got = project_record(&r, &p);
2776 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2777 assert!(got.get("blob").is_none());
2778 assert!(got["user"].get("age").is_none());
2779 }
2780
2781 #[test]
2782 fn project_array_index_path_resolves_same_as_original() {
2783 let r = json!({"tags": ["x", "y", "z"]});
2784 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2785 let got = project_record(&r, &p);
2786 assert_eq!(got, json!({"tags": {"0": "x"}}));
2787 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2788 assert_eq!(
2789 resolve_parent_key(&got, "tags.0"),
2790 resolve_parent_key(&r, "tags.0"),
2791 "reduced tree must resolve the same value as the original"
2792 );
2793 }
2794
2795 #[test]
2796 fn project_numeric_object_key_resolves_same_as_original() {
2797 let r = json!({"data": {"0": "x", "1": "y"}});
2802 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2803 let got = project_record(&r, &p);
2804 assert_eq!(got, json!({"data": {"0": "x"}}));
2805 assert_eq!(
2806 resolve_parent_key(&got, "data.0"),
2807 resolve_parent_key(&r, "data.0"),
2808 "numeric object-key path must resolve identically on the reduced tree"
2809 );
2810 }
2811
2812 #[test]
2813 fn project_missing_path_is_omitted() {
2814 let r = json!({"id": 1});
2815 let p = Projection::Paths(vec![vec!["nope".into()]]);
2816 assert_eq!(project_record(&r, &p), json!({}));
2817 }
2818
2819 #[test]
2820 fn build_projections_unions_parent_key_and_refs() {
2821 use crate::config::ConnectorSpec;
2822 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2823
2824 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2825 ExpandedNode {
2826 id: id.into(),
2827 row_index: 0,
2828 role: NodeRole::Child {
2829 parent_id: parent.into(),
2830 parent_key: parent_key.into(),
2831 },
2832 source: ConnectorSpec {
2833 kind: "csv".into(),
2834 config: json!({}),
2835 transforms: None,
2836 inherit_transforms: true,
2837 },
2838 sink: ConnectorSpec {
2839 kind: "jsonl".into(),
2840 config: json!({}),
2841 transforms: None,
2842 inherit_transforms: true,
2843 },
2844 transforms: Vec::new(),
2845 state: None,
2846 dlq: None,
2847 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2848 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2849 #[cfg(feature = "quality")]
2850 quality: None,
2851 #[cfg(feature = "contract")]
2852 contract: None,
2853 #[cfg(feature = "masking")]
2854 masking: None,
2855 sink_ref: "default".into(),
2856 schema: None,
2857 depends_on: Vec::new(),
2858 deferred_refs: refs
2859 .iter()
2860 .map(|(rid, p)| DeferredRef {
2861 referenced_id: (*rid).into(),
2862 dotted_path: (*p).into(),
2863 token: format!("${{{rid}.{p}}}"),
2864 })
2865 .collect(),
2866 source_override: None,
2867 }
2868 }
2869
2870 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2871 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2872 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2873 let children_of =
2874 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2875
2876 let projs = build_projections(&nodes_by_id, &children_of);
2877 let p = projs.get("p").expect("projection for p");
2878 match &**p {
2879 Projection::Paths(paths) => {
2880 assert!(paths.contains(&vec!["id".to_string()]));
2881 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2882 assert!(paths.contains(&vec!["email".to_string()]));
2883 assert!(
2884 !paths.iter().any(|p| p == &vec!["x".to_string()]),
2885 "a ref to a different parent must not be captured under p"
2886 );
2887 }
2888 Projection::Full => panic!("expected Paths, got Full"),
2889 }
2890 }
2891
2892 #[test]
2893 fn build_projections_whole_record_ref_is_full() {
2894 use crate::config::ConnectorSpec;
2895 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2896 let c = ExpandedNode {
2897 id: "c".into(),
2898 row_index: 0,
2899 role: NodeRole::Child {
2900 parent_id: "p".into(),
2901 parent_key: "id".into(),
2902 },
2903 source: ConnectorSpec {
2904 kind: "csv".into(),
2905 config: json!({}),
2906 transforms: None,
2907 inherit_transforms: true,
2908 },
2909 sink: ConnectorSpec {
2910 kind: "jsonl".into(),
2911 config: json!({}),
2912 transforms: None,
2913 inherit_transforms: true,
2914 },
2915 transforms: Vec::new(),
2916 state: None,
2917 dlq: None,
2918 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2919 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2920 #[cfg(feature = "quality")]
2921 quality: None,
2922 #[cfg(feature = "contract")]
2923 contract: None,
2924 #[cfg(feature = "masking")]
2925 masking: None,
2926 sink_ref: "default".into(),
2927 schema: None,
2928 depends_on: Vec::new(),
2929 deferred_refs: vec![DeferredRef {
2930 referenced_id: "p".into(),
2931 dotted_path: "".into(),
2932 token: "${p}".into(),
2933 }],
2934 source_override: None,
2935 };
2936 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
2937 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
2938 let projs = build_projections(&nodes_by_id, &children_of);
2939 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
2940 }
2941
2942 fn opts(name: &str) -> ExecuteOptions {
2944 ExecuteOptions {
2945 pipeline_name: name.into(),
2946 execution: None,
2947 dry_run: false,
2948 limit: None,
2949 state_path_override: None,
2950 shard: None,
2951 auth: Default::default(),
2952 clock: chrono::Utc::now().fixed_offset(),
2953 cancel: None,
2954 resilience: None,
2955 sla: None,
2956 #[cfg(feature = "lineage")]
2957 lineage: None,
2958 #[cfg(feature = "lineage")]
2959 lineage_cfg: None,
2960 #[cfg(feature = "notify")]
2961 notifier: None,
2962 #[cfg(feature = "catalog")]
2963 catalog: None,
2964 }
2965 }
2966
2967 #[tokio::test]
2968 async fn dry_run_counts_records_without_writing_sink_file() {
2969 let dir = tempfile::tempdir().unwrap();
2972 let input = dir.path().join("in.csv");
2973 let output = dir.path().join("out.jsonl");
2974 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
2975 let cfg = cfg_csv_to_jsonl(&input, &output);
2976 let nodes = expand(&cfg).unwrap();
2977 let mut o = opts("dry");
2978 o.dry_run = true;
2979 let summary = run_expanded(nodes, o).await.unwrap();
2980 assert_eq!(summary.invocations.len(), 1);
2981 assert_eq!(summary.invocations[0].records_written, 3);
2982 assert!(!summary.had_failures());
2983 assert!(
2984 !output.exists(),
2985 "dry-run must not create the real sink file"
2986 );
2987 }
2988
2989 #[tokio::test]
2990 async fn read_only_state_store_drops_writes_keeps_reads() {
2991 let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
2994 inner.put("k", &json!("v0")).await.unwrap();
2995 let ro = ReadOnlyStateStore {
2996 inner: inner.clone(),
2997 };
2998 assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
2999 ro.put("k", &json!("advanced")).await.unwrap();
3001 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3002 ro.delete("k").await.unwrap();
3004 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3005 }
3006
3007 #[tokio::test]
3008 async fn dry_run_with_state_does_not_persist_bookmark() {
3009 let dir = tempfile::tempdir().unwrap();
3013 let input = dir.path().join("in.csv");
3014 let output = dir.path().join("out.jsonl");
3015 let state_dir = dir.path().join("state");
3016 std::fs::create_dir_all(&state_dir).unwrap();
3017 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3018 let cfg = cfg_csv_to_jsonl(&input, &output);
3019 let nodes = expand(&cfg).unwrap();
3020 let mut o = opts("drystate");
3021 o.dry_run = true;
3022 o.state_path_override = Some(state_dir.clone());
3023 let summary = run_expanded(nodes, o).await.unwrap();
3024 assert!(!summary.had_failures());
3025 assert!(!output.exists(), "dry-run must not write the sink file");
3026 let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3029 .unwrap()
3030 .filter_map(Result::ok)
3031 .collect();
3032 assert!(
3033 persisted.is_empty(),
3034 "dry-run must not persist any bookmark file, found: {persisted:?}"
3035 );
3036 }
3037
3038 #[tokio::test]
3039 async fn limit_caps_records_written_across_the_run() {
3040 let dir = tempfile::tempdir().unwrap();
3042 let input = dir.path().join("in.csv");
3043 let output = dir.path().join("out.jsonl");
3044 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3045 let cfg = cfg_csv_to_jsonl(&input, &output);
3046 let nodes = expand(&cfg).unwrap();
3047 let mut o = opts("lim");
3048 o.limit = Some(2);
3049 let summary = run_expanded(nodes, o).await.unwrap();
3050 assert_eq!(summary.invocations[0].records_written, 2);
3051 let body = std::fs::read_to_string(&output).unwrap();
3052 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3053 }
3054
3055 #[tokio::test]
3056 async fn duplicate_state_key_among_siblings_is_rejected() {
3057 let dir = tempfile::tempdir().unwrap();
3061 let parent_csv = dir.path().join("parents.csv");
3062 let child_csv = dir.path().join("child.csv");
3063 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3065 std::fs::write(&child_csv, "x\nA\n").unwrap();
3066 let parent_out = dir.path().join("parents.jsonl");
3067 let child_out = dir.path().join("child.jsonl");
3068 let yaml = format!(
3069 r#"version: 1
3070pipeline:
3071 source: {{ type: csv, config: {{ path: {parent} }} }}
3072 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3073 state: {{ type: memory }}
3074matrix:
3075 - id: parents
3076 - id: child
3077 parent: parents
3078 source: {{ config: {{ path: {child} }} }}
3079 sink: {{ config: {{ path: {child_out} }} }}
3080"#,
3081 parent = parent_csv.display(),
3082 parent_out = parent_out.display(),
3083 child = child_csv.display(),
3084 child_out = child_out.display(),
3085 );
3086 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3087 let nodes = expand(&cfg).unwrap();
3088 let err = run_expanded(nodes, opts("dupkey"))
3089 .await
3090 .expect_err("colliding sibling state keys must be rejected");
3091 match err {
3092 CliError::DuplicateStateKey { id, state_key } => {
3093 assert_eq!(id, "child");
3094 assert_eq!(state_key, "dupkey::child::dup");
3095 }
3096 other => panic!("expected DuplicateStateKey, got {other:?}"),
3097 }
3098 }
3099
3100 #[tokio::test]
3101 async fn state_path_override_writes_bookmark_file() {
3102 let dir = tempfile::tempdir().unwrap();
3107 let input = dir.path().join("in.csv");
3108 let output = dir.path().join("out.jsonl");
3109 let state_dir = dir.path().join("state");
3110 std::fs::write(&input, "name\nalice\n").unwrap();
3111 let cfg = cfg_csv_to_jsonl(&input, &output);
3112 let nodes = expand(&cfg).unwrap();
3113 let mut o = opts("statepath");
3114 o.state_path_override = Some(state_dir.clone());
3115 let summary = run_expanded(nodes, o).await.unwrap();
3116 assert!(!summary.had_failures());
3117 assert_eq!(summary.invocations[0].records_written, 1);
3121 }
3122
3123 #[tokio::test]
3124 async fn build_dlq_config_maps_spec_fields() {
3125 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3126 let dir = tempfile::tempdir().unwrap();
3127 let dlq_out = dir.path().join("dlq.jsonl");
3128 let spec = DlqSpec {
3129 sink: ConnectorSpec {
3130 kind: "jsonl".into(),
3131 config: json!({ "path": dlq_out.to_str().unwrap() }),
3132 transforms: None,
3133 inherit_transforms: true,
3134 },
3135 on_batch_error: OnBatchErrorSpec::DlqAll,
3136 max_failures_per_page: Some(7),
3137 max_failures_total: Some(42),
3138 include_original_payload: false,
3139 };
3140 let cfg = build_dlq_config(&spec).await.unwrap();
3141 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3142 assert_eq!(cfg.max_failures_per_page, Some(7));
3143 assert_eq!(cfg.max_failures_total, Some(42));
3144 assert!(!cfg.include_original_payload);
3145 }
3146
3147 #[tokio::test]
3148 async fn build_state_for_node_arms() {
3149 let dir = tempfile::tempdir().unwrap();
3150
3151 let node = stub_node(None);
3153 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3154
3155 let p = dir.path().join("s1");
3157 assert!(
3158 build_state_for_node(&node, Some(&p))
3159 .await
3160 .unwrap()
3161 .is_some()
3162 );
3163
3164 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3166 kind: "memory".into(),
3167 config: json!({}),
3168 }));
3169 assert!(
3170 build_state_for_node(&node_mem, None)
3171 .await
3172 .unwrap()
3173 .is_some()
3174 );
3175
3176 let node_file = stub_node(Some(crate::config::StateStoreSpec {
3178 kind: "file".into(),
3179 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3180 }));
3181 let p2 = dir.path().join("override2");
3182 assert!(
3183 build_state_for_node(&node_file, Some(&p2))
3184 .await
3185 .unwrap()
3186 .is_some()
3187 );
3188
3189 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3192 kind: "memory".into(),
3193 config: json!({}),
3194 }));
3195 let p3 = dir.path().join("override3");
3196 assert!(
3197 build_state_for_node(&node_mem2, Some(&p3))
3198 .await
3199 .unwrap()
3200 .is_some()
3201 );
3202 }
3203
3204 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3206 use crate::config::ConnectorSpec;
3207 ExpandedNode {
3208 id: "n".into(),
3209 row_index: 0,
3210 role: NodeRole::Root,
3211 source: ConnectorSpec {
3212 kind: "csv".into(),
3213 config: json!({}),
3214 transforms: None,
3215 inherit_transforms: true,
3216 },
3217 sink: ConnectorSpec {
3218 kind: "jsonl".into(),
3219 config: json!({}),
3220 transforms: None,
3221 inherit_transforms: true,
3222 },
3223 transforms: Vec::new(),
3224 state,
3225 dlq: None,
3226 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3227 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3228 #[cfg(feature = "quality")]
3229 quality: None,
3230 #[cfg(feature = "contract")]
3231 contract: None,
3232 #[cfg(feature = "masking")]
3233 masking: None,
3234 sink_ref: "default".into(),
3235 schema: None,
3236 depends_on: Vec::new(),
3237 deferred_refs: Vec::new(),
3238 source_override: None,
3239 }
3240 }
3241
3242 #[tokio::test]
3243 async fn state_key_override_delegates_and_overrides_key() {
3244 let dir = tempfile::tempdir().unwrap();
3246 let input = dir.path().join("in.csv");
3247 std::fs::write(&input, "name\nz\n").unwrap();
3248 let inner = build_source(
3249 "csv",
3250 json!({"path": input.to_str().unwrap()}),
3251 &AuthCatalog::new(),
3252 None,
3253 )
3254 .await
3255 .unwrap();
3256 let inner_name = inner.connector_name();
3258 let ov = StateKeyOverride {
3259 inner,
3260 key: "my::custom::key".into(),
3261 };
3262 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3263 assert_eq!(ov.connector_name(), inner_name);
3264 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3265 assert_eq!(rows.len(), 1);
3266 ov.apply_start_bookmark(json!({"any": "bookmark"}))
3268 .await
3269 .unwrap();
3270 assert!(!ov.supports_exactly_once());
3272 assert_eq!(
3273 ov.replay_guarantee(),
3274 faucet_core::ReplayGuarantee::NonDeterministic
3275 );
3276 assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3277 }
3278
3279 #[tokio::test]
3280 async fn state_key_override_forwards_native_stream_pages() {
3281 struct PerPageBookmarkSource;
3287 #[async_trait]
3288 impl Source for PerPageBookmarkSource {
3289 async fn fetch_with_context(
3290 &self,
3291 _ctx: &HashMap<String, Value>,
3292 ) -> Result<Vec<Value>, FaucetError> {
3293 Ok(vec![json!({"id": 1}), json!({"id": 2})])
3294 }
3295 fn stream_pages<'a>(
3296 &'a self,
3297 _ctx: &'a HashMap<String, Value>,
3298 _batch_size: usize,
3299 ) -> std::pin::Pin<
3300 Box<
3301 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3302 + Send
3303 + 'a,
3304 >,
3305 > {
3306 Box::pin(faucet_core::async_stream::try_stream! {
3307 yield faucet_core::StreamPage {
3308 records: vec![json!({"id": 1})],
3309 bookmark: Some(json!("bm-1")),
3310 };
3311 yield faucet_core::StreamPage {
3312 records: vec![json!({"id": 2})],
3313 bookmark: Some(json!("bm-2")),
3314 };
3315 })
3316 }
3317 fn state_key(&self) -> Option<String> {
3318 Some("native".into())
3319 }
3320 }
3321
3322 use futures::StreamExt;
3323 let ov = StateKeyOverride {
3324 inner: Box::new(PerPageBookmarkSource),
3325 key: "override".into(),
3326 };
3327 let ctx = HashMap::new();
3328 let pages: Vec<_> = ov
3329 .stream_pages(&ctx, 1000)
3330 .collect::<Vec<_>>()
3331 .await
3332 .into_iter()
3333 .collect::<Result<Vec<_>, _>>()
3334 .unwrap();
3335 assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3336 assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3337 assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3338 }
3339
3340 #[tokio::test]
3341 async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3342 struct IdemSink;
3343 #[async_trait]
3344 impl Sink for IdemSink {
3345 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3346 Ok(records.len())
3347 }
3348 fn connector_name(&self) -> &'static str {
3349 "idem"
3350 }
3351 fn supports_idempotent_writes(&self) -> bool {
3352 true
3353 }
3354 fn dedups_by_key(&self) -> bool {
3355 true
3356 }
3357 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3358 &[
3359 faucet_core::WriteMode::Append,
3360 faucet_core::WriteMode::Upsert,
3361 ]
3362 }
3363 async fn write_batch_idempotent(
3364 &self,
3365 records: &[Value],
3366 _scope: &str,
3367 _token: &str,
3368 ) -> Result<usize, FaucetError> {
3369 Ok(records.len())
3370 }
3371 async fn last_committed_token(
3372 &self,
3373 _scope: &str,
3374 ) -> Result<Option<String>, FaucetError> {
3375 Ok(Some("tok".into()))
3376 }
3377 }
3378
3379 let captured = Arc::new(Mutex::new(Vec::new()));
3380 let sink = CapturingSink::wrap(
3381 Box::new(IdemSink),
3382 Arc::clone(&captured),
3383 Arc::new(Projection::Full),
3384 );
3385 assert!(sink.supports_idempotent_writes());
3388 assert!(sink.dedups_by_key());
3389 assert_eq!(
3390 sink.sink_guarantee(),
3391 faucet_core::SinkGuarantee::AtomicWatermark
3392 );
3393 assert!(
3394 sink.supported_write_modes()
3395 .contains(&faucet_core::WriteMode::Upsert)
3396 );
3397 assert_eq!(
3398 sink.last_committed_token("k").await.unwrap(),
3399 Some("tok".into())
3400 );
3401 assert_eq!(sink.current_schema().await.unwrap(), None);
3402 assert!(!sink.supports_schema_evolution());
3403 let n = sink
3405 .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3406 .await
3407 .unwrap();
3408 assert_eq!(n, 1);
3409 assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3410 }
3411
3412 #[tokio::test]
3413 async fn orphaned_child_surfaces_executor_deadlock() {
3414 use crate::config::ConnectorSpec;
3418 let orphan = ExpandedNode {
3419 id: "orphan".into(),
3420 row_index: 0,
3421 role: NodeRole::Child {
3422 parent_id: "missing-parent".into(),
3423 parent_key: "id".into(),
3424 },
3425 source: ConnectorSpec {
3426 kind: "csv".into(),
3427 config: json!({}),
3428 transforms: None,
3429 inherit_transforms: true,
3430 },
3431 sink: ConnectorSpec {
3432 kind: "jsonl".into(),
3433 config: json!({}),
3434 transforms: None,
3435 inherit_transforms: true,
3436 },
3437 transforms: Vec::new(),
3438 state: None,
3439 dlq: None,
3440 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3441 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3442 #[cfg(feature = "quality")]
3443 quality: None,
3444 #[cfg(feature = "contract")]
3445 contract: None,
3446 #[cfg(feature = "masking")]
3447 masking: None,
3448 sink_ref: "default".into(),
3449 schema: None,
3450 depends_on: Vec::new(),
3451 deferred_refs: Vec::new(),
3452 source_override: None,
3453 };
3454 let err = run_expanded(vec![orphan], opts("deadlock"))
3455 .await
3456 .expect_err("an orphaned child must surface as an executor deadlock");
3457 match err {
3458 CliError::Internal(msg) => {
3459 assert!(msg.contains("executor deadlock"), "{msg}");
3460 assert!(msg.contains("orphan"), "{msg}");
3461 }
3462 other => panic!("expected Internal deadlock error, got {other:?}"),
3463 }
3464 }
3465
3466 #[test]
3467 fn value_to_string_brief_unquotes_strings_only() {
3468 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3469 assert_eq!(value_to_string_brief(&json!(42)), "42");
3470 assert_eq!(value_to_string_brief(&json!(true)), "true");
3471 assert_eq!(value_to_string_brief(&json!(null)), "null");
3472 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3473 }
3474
3475 #[test]
3476 fn build_state_key_with_and_without_parent() {
3477 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3478 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3479 }
3480
3481 #[test]
3482 fn resolve_parent_key_walks_objects_arrays_and_misses() {
3483 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3484 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3485 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3486 assert_eq!(resolve_parent_key(&r, "user.age"), None);
3488 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3490 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3492 }
3493
3494 #[tokio::test]
3495 async fn cooperative_cancel_returns_partial_ok() {
3496 let dir = tempfile::tempdir().unwrap();
3500 let input = dir.path().join("in.csv");
3501 let output = dir.path().join("out.jsonl");
3502 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3503 let cfg = cfg_csv_to_jsonl(&input, &output);
3504 let nodes = expand(&cfg).unwrap();
3505 let token = CancellationToken::new();
3506 token.cancel(); let mut o = opts("cancel");
3508 o.cancel = Some(token);
3509 let summary = run_expanded(nodes, o).await.unwrap();
3510 assert_eq!(summary.invocations.len(), 1);
3513 assert!(
3514 !summary.had_failures(),
3515 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3516 );
3517 }
3518
3519 #[tokio::test]
3520 async fn fanout_projects_away_unreferenced_parent_fields() {
3521 let dir = tempfile::tempdir().unwrap();
3525 let parent_csv = dir.path().join("parents.csv");
3526 let child_csv = dir.path().join("child.csv");
3527 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3528 std::fs::write(&child_csv, "x\nA\n").unwrap();
3529 let parent_out = dir.path().join("parents.jsonl");
3530 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3531
3532 let yaml = format!(
3533 r#"version: 1
3534pipeline:
3535 source: {{ type: csv, config: {{ path: {parent} }} }}
3536 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3537matrix:
3538 - id: parents
3539 - id: child
3540 parent: parents
3541 source: {{ config: {{ path: {child} }} }}
3542 sink: {{ config: {{ path: "{child_out}" }} }}
3543"#,
3544 parent = parent_csv.display(),
3545 parent_out = parent_out.display(),
3546 child = child_csv.display(),
3547 child_out = child_out_pattern.display(),
3548 );
3549 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3550 let nodes = expand(&cfg).unwrap();
3551 let summary = run_expanded(
3552 nodes,
3553 ExecuteOptions {
3554 pipeline_name: "projtest".into(),
3555 execution: None,
3556 dry_run: false,
3557 limit: None,
3558 state_path_override: None,
3559 shard: None,
3560 auth: Default::default(),
3561 clock: chrono::Utc::now().fixed_offset(),
3562 cancel: None,
3563 resilience: None,
3564 sla: None,
3565 #[cfg(feature = "lineage")]
3566 lineage: None,
3567 #[cfg(feature = "lineage")]
3568 lineage_cfg: None,
3569 #[cfg(feature = "notify")]
3570 notifier: None,
3571 #[cfg(feature = "catalog")]
3572 catalog: None,
3573 },
3574 )
3575 .await
3576 .unwrap();
3577
3578 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3580 assert!(!summary.had_failures(), "{summary:?}");
3581 assert!(dir.path().join("child-1.jsonl").exists());
3583 assert!(dir.path().join("child-2.jsonl").exists());
3584 }
3585}