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