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 async_trait::async_trait;
32use chrono::{DateTime, FixedOffset};
33use faucet_core::observability::Labels;
34use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
35use serde_json::Value;
36use std::collections::{HashMap, HashSet};
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::sync::atomic::{AtomicUsize, Ordering};
40use std::time::Duration;
41use tokio::sync::{Mutex, Semaphore};
42
43type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
47
48type DiscoveredDims = Arc<Mutex<HashMap<String, crate::discovery_matrix::Dim>>>;
51
52type CollectedDims = Arc<Mutex<HashMap<String, crate::discovery_matrix::CollectedDim>>>;
57use tokio_util::sync::CancellationToken;
58
59pub struct ExecuteOptions {
61 pub pipeline_name: String,
64 pub run_id: Option<String>,
74 pub execution: Option<ExecutionSpec>,
77 pub dry_run: bool,
79 pub limit: Option<usize>,
81 pub state_path_override: Option<PathBuf>,
83 pub shard: Option<faucet_core::ShardSpec>,
88 pub auth: AuthCatalog,
92 pub clock: DateTime<FixedOffset>,
96 pub cancel: Option<CancellationToken>,
102 pub resilience: Option<faucet_core::ResiliencePolicy>,
107 pub sla: Option<crate::sla::SlaSpec>,
112 pub reconcile: Option<crate::reconcile::ReconcileSpec>,
117 #[cfg(feature = "lineage")]
120 pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
121 #[cfg(feature = "lineage")]
125 pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
126 #[cfg(feature = "notify")]
132 pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
133 #[cfg(feature = "catalog")]
140 pub catalog: Option<crate::catalog::CatalogHandle>,
141}
142
143const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
148
149#[derive(Debug)]
151pub struct InvocationOutcome {
152 pub row_id: String,
153 pub parent_record_key: Option<String>,
156 pub records_written: usize,
157 pub error: Option<String>,
158 pub metrics: Option<InvocationMetrics>,
163}
164
165#[derive(Debug, Clone, Default)]
170pub struct InvocationMetrics {
171 pub source_kind: String,
172 pub sink_kind: String,
173 pub duration_ms: u64,
174 pub records_read: Option<u64>,
175 pub dlq_count: u64,
176 pub bookmark: Option<Value>,
177}
178
179struct PipelineStats {
183 records_written: usize,
184 records_read: Option<u64>,
185 dlq_count: u64,
186 bookmark: Option<Value>,
187}
188
189#[derive(Debug)]
191pub struct RunSummary {
192 pub invocations: Vec<InvocationOutcome>,
193}
194
195impl RunSummary {
196 pub fn failure_count(&self) -> usize {
197 self.invocations
198 .iter()
199 .filter(|i| i.error.is_some())
200 .count()
201 }
202 pub fn had_failures(&self) -> bool {
203 self.failure_count() > 0
204 }
205}
206
207fn default_concurrency() -> usize {
218 std::thread::available_parallelism()
219 .map(|n| n.get())
220 .unwrap_or(4)
221 .clamp(1, 8)
222}
223
224pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
227 let on_error = opts
228 .execution
229 .as_ref()
230 .map(|e| e.on_error)
231 .unwrap_or_default();
232 let max_concurrent = opts
233 .execution
234 .as_ref()
235 .and_then(|e| e.max_concurrent)
236 .unwrap_or_else(default_concurrency)
237 .max(1);
238 let semaphore = Arc::new(Semaphore::new(max_concurrent));
239
240 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
245 for n in nodes.iter() {
246 if let NodeRole::Child { parent_id, .. } = &n.role {
247 children_of
248 .entry(parent_id.clone())
249 .or_default()
250 .push(n.id.clone());
251 }
252 }
253
254 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
259 let discovered: DiscoveredDims = Arc::new(Mutex::new(HashMap::new()));
262 let collected: CollectedDims = Arc::new(Mutex::new(HashMap::new()));
265
266 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
267 let mut skipped_subtrees: HashSet<String> = HashSet::new();
268
269 let cancel = opts.cancel.clone().unwrap_or_default();
274 let opts = Arc::new(opts);
275
276 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
280 let mut completed: HashSet<String> = HashSet::new();
281 let nodes_by_id: HashMap<String, ExpandedNode> =
282 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
283
284 let projections = build_projections(&nodes_by_id, &children_of);
287
288 let bfs_order: Vec<String> = {
292 let mut ids: Vec<(usize, String)> = nodes_by_id
293 .values()
294 .map(|n| (n.row_index, n.id.clone()))
295 .collect();
296 ids.sort_by_key(|(i, _)| *i);
297 ids.into_iter().map(|(_, id)| id).collect()
298 };
299
300 while !remaining.is_empty() {
301 let ready: Vec<String> = bfs_order
307 .iter()
308 .filter(|id| remaining.contains(*id))
309 .filter(|id| {
310 let node = &nodes_by_id[*id];
311 let parent_done = match &node.role {
312 NodeRole::Root | NodeRole::Discovery { .. } | NodeRole::Product { .. } => true,
315 NodeRole::Child { parent_id, .. } => {
316 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
317 }
318 };
319 parent_done
320 && node
321 .depends_on
322 .iter()
323 .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
324 })
325 .cloned()
326 .collect();
327
328 if ready.is_empty() {
329 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
334 stuck.sort();
335 return Err(CliError::Internal(format!(
336 "executor deadlock: {} node(s) never became ready (no completed/skipped \
337 parent or dependency): {}",
338 stuck.len(),
339 stuck.join(", ")
340 )));
341 }
342
343 let mut units: Vec<Unit> = Vec::new();
346 let level_records: HashMap<String, Vec<Arc<Value>>> = {
353 let consumed_parents: HashSet<&str> = ready
354 .iter()
355 .filter_map(|id| match &nodes_by_id[id].role {
356 NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
357 _ => None,
358 })
359 .collect();
360 let mut cap = captured.lock().await;
361 consumed_parents
362 .iter()
363 .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
364 .collect()
365 };
366 for id in &ready {
367 let node = &nodes_by_id[id];
368 if let NodeRole::Child { parent_id, .. } = &node.role
371 && skipped_subtrees.contains(parent_id)
372 {
373 skipped_subtrees.insert(id.clone());
374 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
375 continue;
376 }
377 if let Some(dep) = node
382 .depends_on
383 .iter()
384 .find(|d| skipped_subtrees.contains(d.as_str()))
385 {
386 skipped_subtrees.insert(id.clone());
387 tracing::warn!(
388 row = %id, dependency = %dep,
389 "skipping row: a depends_on row failed or was skipped"
390 );
391 continue;
392 }
393 match &node.role {
394 NodeRole::Root => {
395 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
396 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
397 validate_unit_state_key(&node.id, uses_state, &state_key)?;
398 units.push(Unit {
399 node: node.clone(),
400 parent_record: None,
401 state_key,
402 parent_record_key: None,
403 product_ctx: None,
404 });
405 }
406 NodeRole::Discovery { dims, .. } => {
407 if dims.is_empty() {
408 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
412 units.push(Unit {
413 node: node.clone(),
414 parent_record: None,
415 state_key,
416 parent_record_key: None,
417 product_ctx: None,
418 });
419 } else {
420 let resolved = match resolve_product_dims(dims, &discovered, id).await? {
423 Some(r) => r,
424 None => {
425 skipped_subtrees.insert(id.clone());
426 continue;
427 }
428 };
429 for ctx in crate::discovery_matrix::cartesian(&resolved) {
430 let suffix =
431 crate::discovery_matrix::tuple_state_key_suffix(&resolved, &ctx);
432 let state_key =
433 build_state_key(&opts.pipeline_name, &node.id, Some(&suffix));
434 units.push(Unit {
435 node: node.clone(),
436 parent_record: None,
437 state_key,
438 parent_record_key: Some(suffix),
439 product_ctx: Some(ctx),
440 });
441 }
442 }
443 }
444 NodeRole::Product {
445 dims,
446 collected: collected_ids,
447 } => {
448 let resolved = match resolve_product_dims(dims, &discovered, id).await? {
453 Some(r) => r,
454 None => {
455 skipped_subtrees.insert(id.clone());
456 continue;
457 }
458 };
459 let collected_dims: Vec<crate::discovery_matrix::CollectedDim> = {
463 let cmap = collected.lock().await;
464 collected_ids
465 .iter()
466 .filter_map(|cid| cmap.get(cid).cloned())
467 .collect()
468 };
469 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
470 let mut seen_keys: HashSet<String> = HashSet::new();
471 for mut ctx in crate::discovery_matrix::cartesian(&resolved) {
472 crate::discovery_matrix::inject_collected(&mut ctx, &collected_dims);
473 let suffix =
474 crate::discovery_matrix::tuple_state_key_suffix(&resolved, &ctx);
475 let state_key =
476 build_state_key(&opts.pipeline_name, &node.id, Some(&suffix));
477 validate_unit_state_key(&node.id, uses_state, &state_key)?;
478 if !seen_keys.insert(state_key.clone()) {
479 return Err(CliError::DuplicateStateKey {
480 id: node.id.clone(),
481 state_key,
482 });
483 }
484 units.push(Unit {
485 node: node.clone(),
486 parent_record: None,
487 state_key,
488 parent_record_key: Some(suffix),
489 product_ctx: Some(ctx),
490 });
491 }
492 }
493 NodeRole::Child {
494 parent_id,
495 parent_key,
496 } => {
497 let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
498 if parent_records.is_empty() {
499 tracing::info!(
500 row = %id, parent = %parent_id,
501 "parent produced no records — child skipped"
502 );
503 continue;
504 }
505 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
507 let mut seen_keys: HashSet<String> = HashSet::new();
508 for record in &parent_records {
509 let pk_value = resolve_parent_key(record, parent_key);
510 let pk_string = pk_value
511 .as_ref()
512 .map(value_to_string_brief)
513 .unwrap_or_else(|| "(missing)".to_string());
514 let state_key =
515 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
516 validate_unit_state_key(&node.id, uses_state, &state_key)?;
517 if !seen_keys.insert(state_key.clone()) {
518 return Err(CliError::DuplicateStateKey {
519 id: node.id.clone(),
520 state_key,
521 });
522 }
523 units.push(Unit {
524 node: node.clone(),
525 parent_record: Some(record.clone()),
526 state_key,
527 parent_record_key: Some(pk_string),
528 product_ctx: None,
529 });
530 }
531 }
532 }
533 }
534 drop(level_records);
535
536 let (overwrite_groups, overwrite_task_group) = if opts.dry_run {
548 (Vec::new(), HashMap::new())
549 } else {
550 plan_overwrite_groups(&units, opts.as_ref())?
551 };
552 for group in &overwrite_groups {
553 let sink = build_sink(&group.kind, group.cfg.clone(), &opts.auth).await?;
557 sink.begin_overwrite().await.map_err(|e| {
558 CliError::Internal(format!(
559 "overwrite: preparing destination '{}': {e}",
560 group.dest
561 ))
562 })?;
563 }
564 let mut failed_overwrite_groups: HashSet<usize> = HashSet::new();
567
568 let mut had_level_failure = false;
569 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
570
571 let level_cancel = cancel.child_token();
583 let mut joinset = tokio::task::JoinSet::new();
584 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
588 for unit in units {
589 let sem = Arc::clone(&semaphore);
590 let opts2 = Arc::clone(&opts);
591 let captured = Arc::clone(&captured);
592 let discovered = Arc::clone(&discovered);
593 let collected = Arc::clone(&collected);
594 let capture = projections.get(&unit.node.id).cloned();
595 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
596 let suppress_overwrite = overwrite_task_group.contains_key(&meta);
600 let unit_cancel = level_cancel.clone();
601 let handle = joinset.spawn(async move {
602 let _permit = sem.acquire().await.expect("semaphore not closed");
603 run_unit(
604 &unit,
605 capture,
606 &captured,
607 &discovered,
608 &collected,
609 &opts2,
610 unit_cancel,
611 suppress_overwrite,
612 )
613 .await
614 });
615 task_meta.insert(handle.id(), meta);
616 }
617
618 let mut stop_triggered = false;
619 let mut aborted = false;
620 let mut stop_deadline: Option<tokio::time::Instant> = None;
621 loop {
622 let joined = match stop_deadline {
627 Some(deadline) if !aborted => {
628 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
629 Ok(j) => j,
630 Err(_) => {
631 tracing::warn!(
632 "on_error: stop — flush grace elapsed; aborting remaining \
633 in-flight invocations"
634 );
635 joinset.abort_all();
636 aborted = true;
637 continue;
638 }
639 }
640 }
641 _ => joinset.join_next_with_id().await,
642 };
643 let Some(joined) = joined else { break };
644 let outcome = match joined {
648 Ok((_id, outcome)) => outcome,
649 Err(e) if e.is_cancelled() => {
650 continue;
653 }
654 Err(e) => {
655 let (row_id, parent_record_key) = task_meta
656 .get(&e.id())
657 .cloned()
658 .unwrap_or_else(|| ("<unknown>".to_string(), None));
659 InvocationOutcome {
660 row_id,
661 parent_record_key,
662 records_written: 0,
663 error: Some(format!("pipeline invocation task panicked: {e}")),
664 metrics: None,
665 }
666 }
667 };
668
669 if let Some(err) = &outcome.error {
670 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
671 had_level_failure = true;
672 nodes_with_any_failure.insert(outcome.row_id.clone());
673 if let Some(gi) = overwrite_task_group
676 .get(&(outcome.row_id.clone(), outcome.parent_record_key.clone()))
677 {
678 failed_overwrite_groups.insert(*gi);
679 }
680 if matches!(on_error, OnError::Stop) && !stop_triggered {
681 stop_triggered = true;
682 tracing::error!(
683 "on_error: stop — cancelling in-flight invocations (cooperative \
684 flush), then aborting any that don't stop within the grace window"
685 );
686 level_cancel.cancel();
690 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
691 }
692 } else {
693 tracing::info!(
694 row = %outcome.row_id,
695 records_written = outcome.records_written,
696 "pipeline invocation completed"
697 );
698 }
699 outcomes.push(outcome);
700 }
701
702 let level_cancelled = level_cancel.is_cancelled() || cancel.is_cancelled();
709 for (gi, group) in overwrite_groups.iter().enumerate() {
710 let must_abort = level_cancelled || failed_overwrite_groups.contains(&gi);
711 let sink = build_sink(&group.kind, group.cfg.clone(), &opts.auth).await?;
712 if must_abort {
713 if let Err(e) = sink.abort_overwrite().await {
714 tracing::warn!(
715 error = %e, dest = %group.dest,
716 "overwrite: discarding staging after a failed/cancelled fan-out failed; \
717 the destination is unchanged"
718 );
719 }
720 } else {
721 sink.commit_overwrite().await.map_err(|e| {
722 CliError::Internal(format!(
723 "overwrite: swapping destination '{}' into place: {e}",
724 group.dest
725 ))
726 })?;
727 }
728 }
729
730 for id in ready {
734 remaining.remove(&id);
735 if nodes_with_any_failure.contains(&id) {
736 skipped_subtrees.insert(id.clone());
737 if let Some(children) = children_of.get(&id) {
739 for cid in children {
740 skipped_subtrees.insert(cid.clone());
741 }
742 }
743 } else {
744 completed.insert(id);
745 }
746 }
747
748 if had_level_failure && matches!(on_error, OnError::Stop) {
749 tracing::error!("on_error: stop — aborting after first failure");
750 break;
752 }
753 }
754
755 Ok(RunSummary {
756 invocations: outcomes,
757 })
758}
759
760struct Unit {
763 node: ExpandedNode,
764 parent_record: Option<Arc<Value>>,
765 state_key: String,
766 parent_record_key: Option<String>,
767 product_ctx: Option<HashMap<String, Value>>,
770}
771
772async fn resolve_product_dims(
778 dims: &[String],
779 discovered: &DiscoveredDims,
780 id: &str,
781) -> CliResult<Option<Vec<crate::discovery_matrix::Dim>>> {
782 let resolved: Vec<crate::discovery_matrix::Dim> = {
783 let dim_map = discovered.lock().await;
784 let mut v = Vec::with_capacity(dims.len());
785 for d in dims {
786 match dim_map.get(d) {
787 Some(dim) => v.push(dim.clone()),
788 None => {
789 tracing::warn!(row = %id, dimension = %d, "for_each dimension unavailable — row skipped");
790 return Ok(None);
791 }
792 }
793 }
794 v
795 };
796 let size = crate::discovery_matrix::product_size(&resolved);
797 if size == 0 {
798 tracing::info!(row = %id, "for_each cross-product is empty — row skipped");
799 return Ok(None);
800 }
801 if size > crate::discovery_matrix::MAX_MATRIX_PRODUCT {
802 return Err(CliError::Config(format!(
803 "matrix row '{id}': for_each cross-product is {size} invocations, over the limit of \
804 {} — narrow the discovery dimensions",
805 crate::discovery_matrix::MAX_MATRIX_PRODUCT
806 )));
807 }
808 Ok(Some(resolved))
809}
810
811struct OverwriteGroup {
817 dest: String,
819 kind: String,
826 cfg: Value,
827}
828
829fn node_sink_is_overwrite(node: &ExpandedNode) -> bool {
833 node.sink.config.get("write_mode").and_then(Value::as_str) == Some("overwrite")
834}
835
836fn describe_sink_dest(kind: &str, cfg: &Value) -> String {
838 for field in [
839 "table",
840 "table_id",
841 "collection",
842 "index",
843 "dataset",
844 "path",
845 ] {
846 if let Some(v) = cfg.get(field).and_then(Value::as_str) {
847 return format!("{kind}:{v}");
848 }
849 }
850 kind.to_string()
851}
852
853fn resolved_sink_destination(unit: &Unit, opts: &ExecuteOptions) -> CliResult<Value> {
858 let mut sink_cfg = unit.node.sink.config.clone();
859 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
860 let mut ctx: HashMap<String, Value> = HashMap::new();
861 if let (Some(record), NodeRole::Child { parent_id, .. }) =
862 (unit.parent_record.as_deref(), &unit.node.role)
863 {
864 ctx.insert(parent_id.clone(), record.clone());
865 }
866 if let Some(pc) = unit.product_ctx.as_ref() {
867 for (k, v) in pc {
868 ctx.insert(k.clone(), v.clone());
869 }
870 }
871 if !ctx.is_empty() {
872 resolve_inplace(&mut sink_cfg, &ctx)?;
873 }
874 Ok(sink_cfg)
875}
876
877type OverwriteTaskGroup = HashMap<(String, Option<String>), usize>;
886
887type OverwritePlan = (Vec<OverwriteGroup>, OverwriteTaskGroup);
889
890fn plan_overwrite_groups(units: &[Unit], opts: &ExecuteOptions) -> CliResult<OverwritePlan> {
891 let mut groups: Vec<OverwriteGroup> = Vec::new();
892 let mut task_group: HashMap<(String, Option<String>), usize> = HashMap::new();
893 let mut index_by_key: HashMap<String, usize> = HashMap::new();
894 for unit in units {
895 if !node_sink_is_overwrite(&unit.node) {
896 continue;
897 }
898 let kind = unit.node.sink.kind.clone();
899 let cfg = resolved_sink_destination(unit, opts)?;
900 let key = format!(
903 "{kind}\u{0}{}",
904 serde_json::to_string(&cfg).unwrap_or_default()
905 );
906 let gi = match index_by_key.get(&key) {
907 Some(gi) => *gi,
908 None => {
909 let gi = groups.len();
910 groups.push(OverwriteGroup {
911 dest: describe_sink_dest(&kind, &cfg),
912 kind: kind.clone(),
913 cfg,
914 });
915 index_by_key.insert(key, gi);
916 gi
917 }
918 };
919 task_group.insert((unit.node.id.clone(), unit.parent_record_key.clone()), gi);
920 }
921 Ok((groups, task_group))
922}
923
924#[allow(clippy::too_many_arguments)]
925async fn run_unit(
926 unit: &Unit,
927 capture: Option<Arc<Projection>>,
928 captured: &CapturedRecords,
929 discovered: &DiscoveredDims,
930 collected: &CollectedDims,
931 opts: &ExecuteOptions,
932 cancel: CancellationToken,
933 suppress_overwrite: bool,
934) -> InvocationOutcome {
935 if let NodeRole::Discovery {
941 select,
942 as_alias,
943 collect,
944 dims,
945 } = &unit.node.role
946 {
947 return run_discovery(
948 &unit.node,
949 select,
950 as_alias,
951 *collect,
952 dims,
953 unit.product_ctx.as_ref(),
954 discovered,
955 collected,
956 opts,
957 )
958 .await;
959 }
960 let needs_capture = capture.is_some();
961 let started = std::time::Instant::now();
962 let result = run_one_invocation(
963 &unit.node,
964 unit.parent_record.as_deref(),
965 unit.product_ctx.as_ref(),
966 &unit.state_key,
967 capture,
968 opts,
969 cancel,
970 suppress_overwrite,
971 )
972 .await;
973 let duration_ms = started.elapsed().as_millis() as u64;
974 let row_id = unit.node.id.clone();
975 let parent_record_key = unit.parent_record_key.clone();
976 let base_metrics = || InvocationMetrics {
977 source_kind: unit.node.source.kind.clone(),
978 sink_kind: unit.node.sink.kind.clone(),
979 duration_ms,
980 ..Default::default()
981 };
982 match result {
983 Ok((records, stats)) => {
984 if needs_capture {
985 captured
986 .lock()
987 .await
988 .entry(row_id.clone())
989 .or_default()
990 .extend(records.into_iter().map(Arc::new));
993 }
994 InvocationOutcome {
995 row_id,
996 parent_record_key,
997 records_written: stats.records_written,
998 error: None,
999 metrics: Some(InvocationMetrics {
1000 records_read: stats.records_read,
1001 dlq_count: stats.dlq_count,
1002 bookmark: stats.bookmark,
1003 ..base_metrics()
1004 }),
1005 }
1006 }
1007 Err(e) => InvocationOutcome {
1008 row_id,
1009 parent_record_key,
1010 records_written: 0,
1011 error: Some(e.to_string()),
1012 metrics: Some(base_metrics()),
1013 },
1014 }
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1021async fn run_discovery(
1022 node: &ExpandedNode,
1023 select: &str,
1024 as_alias: &str,
1025 collect: bool,
1026 dims: &[String],
1027 product_ctx: Option<&HashMap<String, Value>>,
1028 discovered: &DiscoveredDims,
1029 collected: &CollectedDims,
1030 opts: &ExecuteOptions,
1031) -> InvocationOutcome {
1032 let started = std::time::Instant::now();
1033 let source_kind = node.source.kind.clone();
1034 let result: CliResult<usize> = async {
1035 let mut cfg = node.source.config.clone();
1036 resolve_now_inplace(&mut cfg, opts.clock)?;
1039 if let Some(pc) = product_ctx {
1042 resolve_inplace(&mut cfg, pc)?;
1043 }
1044 let source = build_source(
1045 &source_kind,
1046 cfg,
1047 &opts.auth,
1048 opts.resilience.as_ref().map(|r| &r.retry),
1049 )
1050 .await?;
1051 let records = source.fetch_all().await?;
1052 let values = crate::discovery_matrix::project_dedup(&records, select);
1053 let n = values.len();
1054 if collect {
1055 let pc = product_ctx.cloned().unwrap_or_default();
1058 let key = crate::discovery_matrix::collected_tuple_key(dims, &pc);
1059 let mut cmap = collected.lock().await;
1060 let entry = cmap.entry(node.id.clone()).or_insert_with(|| {
1061 crate::discovery_matrix::CollectedDim {
1062 id: node.id.clone(),
1063 alias: as_alias.to_string(),
1064 dims: dims.to_vec(),
1065 by_tuple: HashMap::new(),
1066 }
1067 });
1068 entry.by_tuple.insert(key, values);
1069 } else {
1070 discovered.lock().await.insert(
1071 node.id.clone(),
1072 crate::discovery_matrix::Dim {
1073 id: node.id.clone(),
1074 alias: as_alias.to_string(),
1075 values,
1076 },
1077 );
1078 }
1079 Ok(n)
1080 }
1081 .await;
1082 let duration_ms = started.elapsed().as_millis() as u64;
1083 let metrics = |records_read: usize| InvocationMetrics {
1084 source_kind: source_kind.clone(),
1085 sink_kind: String::new(),
1086 duration_ms,
1087 records_read: Some(records_read as u64),
1088 ..Default::default()
1089 };
1090 match result {
1091 Ok(n) => {
1092 tracing::info!(row = %node.id, values = n, "discovery dimension enumerated");
1093 InvocationOutcome {
1094 row_id: node.id.clone(),
1095 parent_record_key: None,
1096 records_written: 0,
1097 error: None,
1098 metrics: Some(metrics(n)),
1099 }
1100 }
1101 Err(e) => InvocationOutcome {
1102 row_id: node.id.clone(),
1103 parent_record_key: None,
1104 records_written: 0,
1105 error: Some(e.to_string()),
1106 metrics: Some(metrics(0)),
1107 },
1108 }
1109}
1110
1111pub(crate) fn build_state_key(
1113 pipeline_name: &str,
1114 row_id: &str,
1115 parent_key: Option<&str>,
1116) -> String {
1117 match parent_key {
1118 None => format!("{pipeline_name}::{row_id}"),
1119 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
1120 }
1121}
1122
1123fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
1128 if uses_state {
1129 faucet_core::state::validate_state_key(state_key).map_err(|e| {
1130 CliError::InvalidStateKey {
1131 id: node_id.to_owned(),
1132 state_key: state_key.to_owned(),
1133 reason: e.to_string(),
1134 }
1135 })?;
1136 }
1137 Ok(())
1138}
1139
1140fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
1142 let mut cur = record;
1143 for segment in parent_key.split('.') {
1144 cur = match cur {
1145 Value::Object(m) => m.get(segment)?,
1146 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
1147 _ => return None,
1148 };
1149 }
1150 Some(cur.clone())
1151}
1152
1153#[derive(Debug, Clone)]
1157enum Projection {
1158 Full,
1161 Paths(Vec<Vec<String>>),
1163}
1164
1165fn split_path(path: &str) -> Vec<String> {
1167 path.split('.').map(|s| s.to_string()).collect()
1168}
1169
1170fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
1174 paths.sort();
1175 paths.dedup();
1176 let mut kept: Vec<Vec<String>> = Vec::new();
1177 for p in paths {
1178 let covered = kept
1179 .iter()
1180 .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
1181 if !covered {
1182 kept.push(p);
1183 }
1184 }
1185 kept
1186}
1187
1188fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
1191 let mut cur = record;
1192 for seg in segments {
1193 cur = match cur {
1194 Value::Object(m) => m.get(seg)?,
1195 Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
1196 _ => return None,
1197 };
1198 }
1199 Some(cur.clone())
1200}
1201
1202fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
1207 if segments.is_empty() {
1208 return;
1209 }
1210 let mut cur = out;
1211 for seg in &segments[..segments.len() - 1] {
1212 let map = match cur {
1213 Value::Object(m) => m,
1214 _ => return,
1215 };
1216 cur = map
1217 .entry(seg.clone())
1218 .or_insert_with(|| Value::Object(serde_json::Map::new()));
1219 }
1220 if let Value::Object(m) = cur {
1221 m.insert(segments[segments.len() - 1].clone(), leaf);
1222 }
1223}
1224
1225fn project_record(record: &Value, projection: &Projection) -> Value {
1230 match projection {
1231 Projection::Full => record.clone(),
1232 Projection::Paths(paths) => {
1233 let mut out = Value::Object(serde_json::Map::new());
1234 for segs in paths {
1235 if let Some(v) = walk_value(record, segs) {
1236 graft_object(&mut out, segs, v);
1237 }
1238 }
1239 out
1240 }
1241 }
1242}
1243
1244fn build_projections(
1249 nodes_by_id: &HashMap<String, ExpandedNode>,
1250 children_of: &HashMap<String, Vec<String>>,
1251) -> HashMap<String, Arc<Projection>> {
1252 let mut out = HashMap::new();
1253 for (parent_id, child_ids) in children_of {
1254 let mut raw: Vec<Vec<String>> = Vec::new();
1255 let mut full = false;
1256 for cid in child_ids {
1257 let child = &nodes_by_id[cid];
1258 if let NodeRole::Child { parent_key, .. } = &child.role {
1259 if parent_key.is_empty() {
1260 full = true;
1261 } else {
1262 raw.push(split_path(parent_key));
1263 }
1264 }
1265 for dref in &child.deferred_refs {
1266 if dref.referenced_id == *parent_id {
1267 if dref.dotted_path.is_empty() {
1268 full = true; } else {
1270 raw.push(split_path(&dref.dotted_path));
1271 }
1272 }
1273 }
1274 }
1275 let projection = if full || raw.is_empty() {
1280 Projection::Full
1281 } else {
1282 Projection::Paths(minimal_paths(raw))
1283 };
1284 out.insert(parent_id.clone(), Arc::new(projection));
1285 }
1286 out
1287}
1288
1289#[allow(clippy::too_many_arguments)]
1300async fn build_pipeline<'a>(
1301 source: &'a dyn Source,
1302 sink: &'a dyn Sink,
1303 node: &ExpandedNode,
1304 opts: &ExecuteOptions,
1305 state: Option<Arc<dyn StateStore>>,
1306 cancel: &CancellationToken,
1307 pipeline_name: &str,
1308 row_id: &str,
1309 run_id: &str,
1310 cleanup_scope: Option<Value>,
1311 suppress_overwrite: bool,
1312) -> CliResult<Pipeline<'a, dyn Source + 'a, dyn Sink + 'a>> {
1313 let mut pipeline = Pipeline::new(source, sink)
1314 .with_name(pipeline_name.to_owned())
1315 .with_row(row_id.to_owned())
1316 .with_run_id(run_id.to_owned());
1317 if let Some(store) = state {
1318 pipeline = pipeline.with_state_store(store);
1319 }
1320 if let Some(ref dlq_spec) = node.dlq {
1321 let dlq_cfg = build_dlq_config(dlq_spec).await?;
1322 pipeline = pipeline.with_dlq(dlq_cfg);
1323 }
1324 pipeline = pipeline.with_cancel(cancel.clone());
1329 #[cfg(feature = "quality")]
1333 if let Some(ref quality_spec) = node.quality {
1334 let compiled = Arc::new(
1335 faucet_core::CompiledQuality::compile(quality_spec)
1336 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
1337 );
1338 pipeline = pipeline.with_quality(compiled);
1339 }
1340 #[cfg(feature = "contract")]
1344 if let Some(ref contract_spec) = node.contract {
1345 let compiled = Arc::new(
1346 faucet_core::CompiledContract::compile(contract_spec)
1347 .map_err(|e| CliError::Config(format!("contract: {e}")))?,
1348 );
1349 pipeline = pipeline.with_contract(compiled);
1350 }
1351 #[cfg(feature = "masking")]
1357 if let Some(ref masking_spec) = node.masking {
1358 let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
1359 let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
1360 .map_err(|e| CliError::Config(format!("masking: {e}")))?;
1361 if !compiled.is_empty() {
1362 pipeline = pipeline.with_masking(Arc::new(compiled));
1363 }
1364 }
1365 if let Some(ref sd) = node.schema {
1367 pipeline = pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd));
1368 }
1369 if let Some(scope) = cleanup_scope {
1384 let synthetic = opts.dry_run || opts.limit.is_some() || opts.shard.is_some();
1385 if synthetic {
1386 tracing::warn!(
1387 row = %row_id,
1388 "scoped cleanup skipped: --dry-run / --limit / shard runs do not write the \
1389 authoritative record set for the scope, so a delete would remove live rows"
1390 );
1391 } else {
1392 let map: std::collections::BTreeMap<String, Value> = scope
1393 .as_object()
1394 .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
1395 .unwrap_or_default();
1396 let key: Vec<String> = node
1397 .sink
1398 .config
1399 .get("key")
1400 .and_then(|v| v.as_array())
1401 .map(|a| {
1402 a.iter()
1403 .filter_map(|v| v.as_str().map(str::to_owned))
1404 .collect()
1405 })
1406 .unwrap_or_default();
1407 let policy = faucet_core::CleanupPolicy::new(map, key, faucet_core::DEFAULT_MAX_KEYS)
1408 .map_err(|e| CliError::Config(format!("cleanup: {e}")))?;
1409 pipeline = pipeline.with_cleanup(Arc::new(policy));
1410 }
1411 }
1412 if let Some(ab) = opts
1414 .execution
1415 .as_ref()
1416 .and_then(|e| e.adaptive_batch_size.clone())
1417 {
1418 ab.validate()
1419 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
1420 pipeline = pipeline.with_adaptive(ab);
1421 }
1422 if let Some(policy) = opts.resilience.clone() {
1425 pipeline = pipeline.with_resilience(policy);
1426 }
1427 let effective_delivery = if opts.dry_run || opts.limit.is_some() {
1433 faucet_core::idempotency::DeliveryMode::AtLeastOnce
1434 } else {
1435 node.delivery
1436 };
1437 pipeline = pipeline.with_delivery(effective_delivery);
1438 pipeline = pipeline.with_suppress_overwrite(suppress_overwrite);
1443 Ok(pipeline)
1444}
1445
1446#[allow(clippy::too_many_arguments)]
1447async fn run_one_invocation(
1448 node: &ExpandedNode,
1449 parent_record: Option<&Value>,
1450 product_ctx: Option<&HashMap<String, Value>>,
1451 state_key: &str,
1452 capture: Option<Arc<Projection>>,
1453 opts: &ExecuteOptions,
1454 cancel: CancellationToken,
1455 suppress_overwrite: bool,
1456) -> CliResult<(Vec<Value>, PipelineStats)> {
1457 let run_id = uuid::Uuid::now_v7().to_string();
1460 #[cfg(feature = "notify")]
1466 let invocation_started = std::time::Instant::now();
1467 #[cfg(feature = "notify")]
1468 let notify_run = crate::notify::RunContext::start(
1469 Some(opts.run_id.clone().unwrap_or_else(|| run_id.clone())),
1470 Some(run_id.clone()),
1471 );
1472 let pipeline_name = opts.pipeline_name.clone();
1473 let row_id = node.id.clone();
1474 #[cfg(feature = "lineage")]
1475 let lineage = opts.lineage.clone();
1476 #[cfg(feature = "lineage")]
1477 let lineage_cfg = opts.lineage_cfg.clone();
1478 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
1479 #[cfg(feature = "catalog")]
1483 let catalog_active = opts.catalog.is_some()
1484 && matches!(node.role, NodeRole::Root)
1485 && !opts.dry_run
1486 && opts.limit.is_none()
1487 && opts.shard.is_none();
1488 let mut source_cfg = node.source.config.clone();
1490 let mut sink_cfg = node.sink.config.clone();
1491
1492 resolve_now_inplace(&mut source_cfg, opts.clock)?;
1495 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
1496 reject_unresolved_backfill_tokens(&source_cfg, "source")?;
1502 reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
1503
1504 let mut cleanup_scope: Option<Value> = node
1508 .cleanup_scope
1509 .as_ref()
1510 .map(|m| Value::Object(m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()));
1511 if let Some(scope) = cleanup_scope.as_mut() {
1512 resolve_now_inplace(scope, opts.clock)?;
1513 reject_unresolved_backfill_tokens(scope, "complete_for")?;
1514 }
1515
1516 let mut fanout_ctx: HashMap<String, Value> = HashMap::new();
1523 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
1524 fanout_ctx.insert(parent_id.clone(), record.clone());
1525 }
1526 if let Some(pc) = product_ctx {
1527 for (k, v) in pc {
1528 fanout_ctx.insert(k.clone(), v.clone());
1529 }
1530 }
1531 if !fanout_ctx.is_empty() {
1532 resolve_inplace(&mut source_cfg, &fanout_ctx)?;
1533 resolve_inplace(&mut sink_cfg, &fanout_ctx)?;
1534 if let Some(scope) = cleanup_scope.as_mut() {
1535 resolve_inplace(scope, &fanout_ctx)?;
1536 }
1537 }
1538
1539 let source = match node.source_override.as_ref().and_then(|o| o.take()) {
1544 Some(prebuilt) => prebuilt,
1545 None => {
1546 build_source(
1547 &node.source.kind,
1548 source_cfg,
1549 &opts.auth,
1550 opts.resilience.as_ref().map(|r| &r.retry),
1551 )
1552 .await?
1553 }
1554 };
1555
1556 #[cfg(feature = "catalog")]
1559 let source_dataset_uri = source.dataset_uri();
1560
1561 if let Some(shard) = &opts.shard {
1565 source
1566 .apply_shard(shard)
1567 .await
1568 .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
1569 }
1570 let raw_sink: Box<dyn Sink> = if opts.dry_run {
1571 Box::new(CountingSink::new())
1572 } else {
1573 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
1574 };
1575 #[cfg(feature = "catalog")]
1576 let sink_dataset_uri = raw_sink.dataset_uri();
1577 let raw_sink: Box<dyn Sink> = match opts.limit {
1578 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
1579 None => raw_sink,
1580 };
1581 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
1582 let sink: Box<dyn Sink> = match &capture {
1583 Some(projection) => Box::new(CapturingSink::wrap(
1584 raw_sink,
1585 Arc::clone(&captured),
1586 Arc::clone(projection),
1587 )),
1588 None => raw_sink,
1589 };
1590
1591 #[cfg(feature = "lineage")]
1597 let (in_sample, out_sample) = {
1598 use std::sync::Arc as StdArc;
1599 let mut want = false;
1600 let mut cap = 0usize;
1601 if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
1602 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
1603 if want_schema {
1604 cap = cap.max(lc.sample_records);
1605 }
1606 want = want_schema || lc.emit_on.running;
1607 }
1608 #[cfg(feature = "catalog")]
1612 if catalog_active {
1613 want = true;
1614 cap = cap.max(
1615 opts.catalog
1616 .as_ref()
1617 .map(|h| h.sample_records)
1618 .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
1619 );
1620 }
1621 if want {
1622 (
1623 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1624 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1625 )
1626 } else {
1627 (None, None)
1628 }
1629 };
1630
1631 #[cfg(feature = "lineage")]
1634 let source: Box<dyn Source> = match &in_sample {
1635 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
1636 source,
1637 std::sync::Arc::clone(state),
1638 )),
1639 None => source,
1640 };
1641
1642 let mut transforms = node.transforms.clone();
1649 for t in &mut transforms {
1650 resolve_now_inplace(&mut t.config, opts.clock)?;
1651 if !fanout_ctx.is_empty() {
1652 resolve_inplace(&mut t.config, &fanout_ctx)?;
1653 }
1654 }
1655 #[cfg(feature = "arrow")]
1660 let source: Box<dyn Source> = {
1661 let (stages, batch_fns) = crate::transforms::compile_transforms_columnar(&transforms)?;
1662 if stages.is_empty() {
1663 source
1664 } else {
1665 Box::new(faucet_core::TransformingSource::new_with_batches(
1666 source,
1667 stages,
1668 batch_fns,
1669 obs_labels.clone(),
1670 )?)
1671 }
1672 };
1673 #[cfg(not(feature = "arrow"))]
1674 let source: Box<dyn Source> = {
1675 let stages = crate::transforms::compile_transforms(&transforms)?;
1676 if stages.is_empty() {
1677 source
1678 } else {
1679 Box::new(faucet_core::TransformingSource::new(
1680 source,
1681 stages,
1682 obs_labels.clone(),
1683 )?)
1684 }
1685 };
1686
1687 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
1691 let state: Option<Arc<dyn StateStore>> = match state {
1696 Some(inner) if opts.dry_run || opts.limit.is_some() => {
1697 Some(Arc::new(ReadOnlyStateStore { inner }))
1698 }
1699 other => other,
1700 };
1701 let sla_store = state.clone();
1704 let effective_state_key = match &opts.shard {
1707 Some(shard) => format!("{state_key}::{}", shard.id),
1708 None => state_key.to_owned(),
1709 };
1710 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
1711 Box::new(StateKeyOverride {
1712 inner: source,
1713 key: effective_state_key,
1714 })
1715 } else {
1716 source
1717 };
1718
1719 let sink: Box<dyn Sink> = match &node.metadata_columns {
1723 Some(spec) => match faucet_core::CompiledMetadata::compile(spec)
1724 .map_err(|e| CliError::Config(format!("metadata_columns: {e}")))?
1725 {
1726 Some(meta) => Box::new(faucet_core::MetadataSink::new(
1727 sink,
1728 meta,
1729 faucet_core::MetadataContext {
1730 run_id: run_id.clone(),
1731 source: node.source.kind.clone(),
1732 },
1733 )),
1734 None => sink,
1735 },
1736 None => sink,
1737 };
1738
1739 #[cfg(feature = "lineage")]
1742 let sink: Box<dyn Sink> = match &out_sample {
1743 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
1744 sink,
1745 std::sync::Arc::clone(state),
1746 )),
1747 None => sink,
1748 };
1749
1750 let pipeline = build_pipeline(
1758 source.as_ref(),
1759 sink.as_ref(),
1760 node,
1761 opts,
1762 state,
1763 &cancel,
1764 &pipeline_name,
1765 &row_id,
1766 &run_id,
1767 cleanup_scope,
1768 suppress_overwrite,
1769 )
1770 .await?;
1771 #[cfg(feature = "lineage")]
1773 let lineage_ctx = match (&lineage, &lineage_cfg) {
1774 (Some(em), Some(lc)) => {
1775 let job_name =
1776 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1777 let mut ctx = faucet_lineage::RunLifecycle {
1778 job_namespace: lc.namespace.clone(),
1779 job_name,
1780 run_id: run_id.clone(),
1781 parent: lc.parent_job.clone(),
1782 inputs: vec![faucet_lineage::DatasetRef {
1783 namespace: lc.namespace.clone(),
1784 name: source.dataset_uri(),
1785 }],
1786 output: faucet_lineage::DatasetRef {
1787 namespace: lc.namespace.clone(),
1788 name: sink.dataset_uri(),
1789 },
1790 started_at: chrono::Utc::now(),
1791 finished_at: None,
1792 records: 0,
1793 error: None,
1794 input_schemas: Vec::new(),
1795 output_schema: None,
1796 column_lineage: None,
1797 source_code: None,
1798 };
1799 em.emit(faucet_lineage::EventType::Start, &ctx).await;
1800 let hb_handle = if lc.emit_on.running {
1803 let em2 = std::sync::Arc::clone(em);
1804 let interval = lc.heartbeat_interval;
1805 let mut beat_ctx = ctx.clone();
1806 let counter = out_sample.clone();
1807 Some(tokio::spawn(async move {
1808 let mut tick = tokio::time::interval(interval);
1809 tick.tick().await; loop {
1811 tick.tick().await;
1812 if let Some(c) = &counter {
1813 beat_ctx.records = c.count();
1814 }
1815 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1816 .await;
1817 }
1818 }))
1819 } else {
1820 None
1821 };
1822 ctx.source_code = if lc.include_source_code_facet {
1823 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1824 } else {
1825 None
1826 };
1827 Some((std::sync::Arc::clone(em), ctx, hb_handle))
1828 }
1829 _ => None,
1830 };
1831
1832 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1839 Ok(r) => sink.flush().await.map(|_| r),
1840 Err(e) => Err(e),
1841 };
1842
1843 let result = match (&result, &opts.reconcile) {
1850 (Ok(r), Some(spec))
1851 if matches!(node.role, NodeRole::Root)
1852 && !opts.dry_run
1853 && opts.limit.is_none()
1854 && opts.shard.is_none()
1855 && !cancel.is_cancelled() =>
1856 {
1857 let written = r.records_written as u64;
1858 match crate::reconcile::run(spec, &opts.auth, written).await {
1859 Ok(()) => result,
1860 Err(e) => Err(e),
1861 }
1862 }
1863 _ => result,
1864 };
1865
1866 #[cfg(feature = "lineage")]
1867 if let Some((em, mut ctx, hb)) = lineage_ctx {
1868 if let Some(h) = hb {
1869 h.abort();
1870 }
1871 ctx.finished_at = Some(chrono::Utc::now());
1872 if let Some(state) = &out_sample {
1873 ctx.records = state.count();
1874 if lineage_cfg
1875 .as_ref()
1876 .map(|l| l.include_schema_facet)
1877 .unwrap_or(false)
1878 {
1879 ctx.output_schema = Some(state.inferred_schema());
1880 }
1881 }
1882 if let Some(state) = &in_sample
1883 && lineage_cfg
1884 .as_ref()
1885 .map(|l| l.include_schema_facet || l.include_column_lineage)
1886 .unwrap_or(false)
1887 {
1888 let in_schema = state.inferred_schema();
1889 if lineage_cfg
1890 .as_ref()
1891 .map(|l| l.include_column_lineage)
1892 .unwrap_or(false)
1893 {
1894 let input_fields: Vec<String> =
1895 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1896 #[cfg(feature = "masking")]
1897 let has_masking = node.masking.is_some();
1898 #[cfg(not(feature = "masking"))]
1899 let has_masking = false;
1900 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1901 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1902 }
1903 if lineage_cfg
1904 .as_ref()
1905 .map(|l| l.include_schema_facet)
1906 .unwrap_or(false)
1907 {
1908 ctx.input_schemas = vec![Some(in_schema)];
1909 }
1910 }
1911 let ev = match &result {
1912 Err(e) => {
1913 ctx.error = Some(e.to_string());
1914 faucet_lineage::EventType::Fail
1915 }
1916 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1917 Ok(_) => faucet_lineage::EventType::Complete,
1918 };
1919 em.emit(ev, &ctx).await;
1920 }
1921
1922 let is_notifiable_root = matches!(node.role, NodeRole::Root)
1932 && !opts.dry_run
1933 && opts.limit.is_none()
1934 && opts.shard.is_none()
1935 && !cancel.is_cancelled();
1936
1937 #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1938 let sla_violations = if let Some(spec) = &opts.sla
1939 && is_notifiable_root
1940 {
1941 let outcome = match &result {
1942 Ok(r) => crate::sla::RunOutcome::Success {
1943 rows: r.records_written as u64,
1944 },
1945 Err(_) => crate::sla::RunOutcome::Failure,
1946 };
1947 crate::sla::evaluate_post_run(
1948 spec,
1949 sla_store.as_ref(),
1950 state_key,
1951 &obs_labels.pipeline,
1952 &obs_labels.row,
1953 outcome,
1954 chrono::Utc::now().timestamp(),
1955 )
1956 .await
1957 } else {
1958 Vec::new()
1959 };
1960
1961 #[cfg(feature = "notify")]
1966 if let Some(notifier) = &opts.notifier
1967 && is_notifiable_root
1968 {
1969 use crate::notify::NotifyEvent;
1970 let pipeline = obs_labels.pipeline.to_string();
1971 let row = obs_labels.row.to_string();
1972 let run_ctx = notify_run.clone().finish(invocation_started);
1975 match &result {
1976 Ok(r) => {
1977 notifier
1978 .emit(
1979 NotifyEvent::run_success(
1980 pipeline.clone(),
1981 row.clone(),
1982 r.records_written as u64,
1983 )
1984 .with_run(run_ctx.clone()),
1985 )
1986 .await;
1987 if let Some(dlq) = &r.dlq
1988 && dlq.records_dlq > 0
1989 {
1990 notifier
1991 .emit(
1992 NotifyEvent::dlq_threshold(
1993 pipeline.clone(),
1994 row.clone(),
1995 dlq.records_dlq as u64,
1996 )
1997 .with_run(run_ctx.clone()),
1998 )
1999 .await;
2000 }
2001 }
2002 Err(e) => {
2003 notifier
2004 .emit(error_event(&pipeline, &row, e).with_run(run_ctx.clone()))
2005 .await;
2006 }
2007 }
2008 for v in &sla_violations {
2009 notifier
2010 .emit(
2011 NotifyEvent::sla_breach(pipeline.clone(), row.clone(), v.kind(), v.to_string())
2012 .with_run(run_ctx.clone()),
2013 )
2014 .await;
2015 }
2016 }
2017
2018 #[cfg(feature = "catalog")]
2023 if let Some(handle) = &opts.catalog
2024 && catalog_active
2025 && !cancel.is_cancelled()
2026 && let Ok(pipeline_result) = &result
2027 {
2028 use crate::catalog::model::{canonicalize_uri, schema_from_samples};
2029 use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
2030
2031 let records_written = pipeline_result.records_written as u64;
2032 let source_schema = in_sample
2033 .as_ref()
2034 .and_then(|s| schema_from_samples(&s.samples()));
2035 let sink_schema = out_sample
2036 .as_ref()
2037 .and_then(|s| schema_from_samples(&s.samples()));
2038 let records_read = in_sample
2041 .as_ref()
2042 .map(|s| s.count())
2043 .unwrap_or(records_written);
2044 let records_out = out_sample
2045 .as_ref()
2046 .map(|s| s.count())
2047 .unwrap_or(records_written);
2048
2049 let column_lineage = in_sample.as_ref().and_then(|s| {
2052 let input_fields: Vec<String> = s
2053 .inferred_schema()
2054 .fields
2055 .iter()
2056 .map(|(n, _)| n.clone())
2057 .collect();
2058 #[cfg(feature = "masking")]
2059 let has_masking = node.masking.is_some();
2060 #[cfg(not(feature = "masking"))]
2061 let has_masking = false;
2062 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
2063 faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
2064 let fields: serde_json::Map<String, Value> = cl
2067 .edges
2068 .iter()
2069 .map(|(out, ins)| {
2070 (
2071 out.clone(),
2072 Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
2073 )
2074 })
2075 .collect();
2076 serde_json::json!({ "fields": fields })
2077 })
2078 });
2079
2080 let update = CatalogUpdate {
2081 run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
2082 pipeline: obs_labels.pipeline.to_string(),
2083 row: obs_labels.row.to_string(),
2084 recorded_at: chrono::Utc::now(),
2085 sources: vec![DatasetObservation {
2089 uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
2090 kind: node.source.kind.clone(),
2091 role: DatasetRole::Source,
2092 schema: source_schema,
2093 records: records_read,
2094 }],
2095 sink: DatasetObservation {
2096 uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
2097 kind: node.sink.kind.clone(),
2098 role: DatasetRole::Sink,
2099 schema: sink_schema,
2100 records: records_out,
2101 },
2102 column_lineage,
2103 };
2104 crate::catalog::record(handle, &update).await;
2105 }
2106
2107 let result = result?;
2108
2109 #[cfg(feature = "lineage")]
2113 let records_read = in_sample.as_ref().map(|s| s.count());
2114 #[cfg(not(feature = "lineage"))]
2115 let records_read: Option<u64> = None;
2116 let stats = PipelineStats {
2117 records_written: result.records_written,
2118 records_read,
2119 dlq_count: result
2120 .dlq
2121 .as_ref()
2122 .map(|d| d.records_dlq as u64)
2123 .unwrap_or(0),
2124 bookmark: result.bookmark.clone(),
2125 };
2126
2127 let captured = if capture.is_some() {
2128 std::mem::take(&mut *captured.lock().await)
2129 } else {
2130 Vec::new()
2131 };
2132 Ok((captured, stats))
2133}
2134
2135async fn build_state_for_node(
2136 node: &ExpandedNode,
2137 state_path_override: Option<&Path>,
2138) -> CliResult<Option<Arc<dyn StateStore>>> {
2139 match (&node.state, state_path_override) {
2140 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
2141 (None, Some(path)) => Ok(Some(state_from_override(path))),
2142 (Some(spec), Some(path)) => {
2143 if spec.kind == "file" {
2144 Ok(Some(state_from_override(path)))
2145 } else {
2146 tracing::warn!(
2147 state = %spec.kind,
2148 "--state-path is only meaningful for the 'file' backend; ignoring override"
2149 );
2150 Ok(Some(build_state_store(spec).await?))
2151 }
2152 }
2153 (None, None) => Ok(None),
2154 }
2155}
2156
2157fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
2158 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
2159}
2160
2161pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
2164 let sink = build_sink(
2167 &spec.sink.kind,
2168 spec.sink.config.clone(),
2169 &AuthCatalog::new(),
2170 )
2171 .await?;
2172 Ok(DlqConfig {
2173 sink: Arc::from(sink),
2174 on_batch_error: match spec.on_batch_error {
2175 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
2176 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
2177 },
2178 max_failures_per_page: spec.max_failures_per_page,
2179 max_failures_total: spec.max_failures_total,
2180 include_original_payload: spec.include_original_payload,
2181 })
2182}
2183
2184#[cfg(feature = "notify")]
2188fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
2189 use crate::notify::NotifyEvent;
2190 match err {
2191 FaucetError::CircuitOpen { failures, cooldown } => {
2192 NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
2193 }
2194 FaucetError::ContractViolation { message, .. } => {
2195 NotifyEvent::contract_abort(pipeline, row, message.clone())
2196 }
2197 other => {
2198 NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
2199 }
2200 }
2201}
2202
2203#[cfg(feature = "notify")]
2207fn faucet_error_kind(err: &FaucetError) -> &'static str {
2208 match err {
2209 FaucetError::Config(_) => "config",
2210 FaucetError::Source(_) => "source",
2211 FaucetError::Sink(_) => "sink",
2212 FaucetError::State(_) => "state",
2213 FaucetError::QualityFailure { .. } => "quality",
2214 FaucetError::SchemaDrift { .. } => "schema_drift",
2215 _ => "error",
2216 }
2217}
2218
2219pub(crate) fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
2228 fn walk(value: &Value, owner: &str) -> CliResult<()> {
2229 match value {
2230 Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
2231 "the {owner} config references a `${{backfill.*}}` token, which only `faucet backfill` resolves — run this config via `faucet backfill --from … --to …`, or remove the token"
2232 ))),
2233 Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
2234 Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
2235 _ => Ok(()),
2236 }
2237 }
2238 walk(value, owner)
2239}
2240
2241pub(crate) fn resolve_now_inplace(
2242 value: &mut Value,
2243 clock: DateTime<FixedOffset>,
2244) -> CliResult<()> {
2245 match value {
2246 Value::String(s) => {
2247 *s = crate::interpolate::resolve_now(s, clock)?;
2248 Ok(())
2249 }
2250 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
2251 Value::Object(m) => m
2252 .values_mut()
2253 .try_for_each(|v| resolve_now_inplace(v, clock)),
2254 _ => Ok(()),
2255 }
2256}
2257
2258fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
2262 match value {
2263 Value::String(s) => {
2264 let resolved = interpolate_record(s, ctx)?;
2265 *s = resolved;
2266 Ok(())
2267 }
2268 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
2269 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
2270 _ => Ok(()),
2271 }
2272}
2273
2274pub(crate) struct ReadOnlyStateStore {
2287 pub(crate) inner: Arc<dyn StateStore>,
2288}
2289
2290#[async_trait]
2291impl StateStore for ReadOnlyStateStore {
2292 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
2293 self.inner.get(key).await
2294 }
2295 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
2296 Ok(())
2297 }
2298 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
2299 Ok(())
2300 }
2301}
2302
2303struct StateKeyOverride {
2307 inner: Box<dyn Source>,
2308 key: String,
2309}
2310
2311#[async_trait]
2312impl Source for StateKeyOverride {
2313 async fn fetch_with_context(
2314 &self,
2315 ctx: &HashMap<String, Value>,
2316 ) -> Result<Vec<Value>, FaucetError> {
2317 self.inner.fetch_with_context(ctx).await
2318 }
2319 async fn fetch_with_context_incremental(
2320 &self,
2321 ctx: &HashMap<String, Value>,
2322 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2323 self.inner.fetch_with_context_incremental(ctx).await
2324 }
2325 fn stream_pages<'a>(
2331 &'a self,
2332 ctx: &'a HashMap<String, Value>,
2333 batch_size: usize,
2334 ) -> std::pin::Pin<
2335 Box<
2336 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
2337 + Send
2338 + 'a,
2339 >,
2340 > {
2341 self.inner.stream_pages(ctx, batch_size)
2342 }
2343 fn connector_name(&self) -> &'static str {
2344 self.inner.connector_name()
2345 }
2346 fn dataset_uri(&self) -> String {
2347 self.inner.dataset_uri()
2348 }
2349 fn state_key(&self) -> Option<String> {
2350 Some(self.key.clone())
2351 }
2352 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2353 self.inner.apply_start_bookmark(bookmark).await
2354 }
2355 fn supports_exactly_once(&self) -> bool {
2356 self.inner.supports_exactly_once()
2357 }
2358 fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
2359 self.inner.replay_guarantee()
2360 }
2361 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
2362 self.inner.capture_resume_position().await
2363 }
2364}
2365
2366struct CapturingSink {
2370 inner: Box<dyn Sink>,
2371 captured: Arc<Mutex<Vec<Value>>>,
2372 projection: Arc<Projection>,
2373}
2374
2375impl CapturingSink {
2376 fn wrap(
2377 inner: Box<dyn Sink>,
2378 captured: Arc<Mutex<Vec<Value>>>,
2379 projection: Arc<Projection>,
2380 ) -> Self {
2381 Self {
2382 inner,
2383 captured,
2384 projection,
2385 }
2386 }
2387}
2388
2389#[async_trait]
2390impl Sink for CapturingSink {
2391 fn connector_name(&self) -> &'static str {
2392 self.inner.connector_name()
2393 }
2394 fn dataset_uri(&self) -> String {
2395 self.inner.dataset_uri()
2396 }
2397 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2398 let written = self.inner.write_batch(records).await?;
2399 let n = written.min(records.len());
2402 let mut buf = self.captured.lock().await;
2403 buf.extend(
2404 records
2405 .iter()
2406 .take(n)
2407 .map(|r| project_record(r, &self.projection)),
2408 );
2409 Ok(written)
2410 }
2411 async fn flush(&self) -> Result<(), FaucetError> {
2412 self.inner.flush().await
2413 }
2414 fn supports_idempotent_writes(&self) -> bool {
2418 self.inner.supports_idempotent_writes()
2419 }
2420 fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
2421 self.inner.sink_guarantee()
2422 }
2423 fn dedups_by_key(&self) -> bool {
2424 self.inner.dedups_by_key()
2425 }
2426 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
2427 self.inner.supported_write_modes()
2428 }
2429 async fn write_batch_idempotent(
2430 &self,
2431 records: &[Value],
2432 scope: &str,
2433 token: &str,
2434 ) -> Result<usize, FaucetError> {
2435 let written = self
2436 .inner
2437 .write_batch_idempotent(records, scope, token)
2438 .await?;
2439 let n = written.min(records.len());
2440 let mut buf = self.captured.lock().await;
2441 buf.extend(
2442 records
2443 .iter()
2444 .take(n)
2445 .map(|r| project_record(r, &self.projection)),
2446 );
2447 Ok(written)
2448 }
2449 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
2450 self.inner.last_committed_token(scope).await
2451 }
2452 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
2453 self.inner.current_schema().await
2454 }
2455 fn supports_schema_evolution(&self) -> bool {
2456 self.inner.supports_schema_evolution()
2457 }
2458 async fn evolve_schema(
2459 &self,
2460 evolution: &faucet_core::SchemaEvolution,
2461 ) -> Result<(), FaucetError> {
2462 self.inner.evolve_schema(evolution).await
2463 }
2464}
2465
2466pub(crate) struct LimitedSink {
2469 inner: Box<dyn Sink>,
2470 remaining: AtomicUsize,
2471}
2472
2473impl LimitedSink {
2474 pub(crate) fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
2475 Self {
2476 inner,
2477 remaining: AtomicUsize::new(cap),
2478 }
2479 }
2480}
2481
2482#[async_trait]
2483impl Sink for LimitedSink {
2484 fn connector_name(&self) -> &'static str {
2485 self.inner.connector_name()
2486 }
2487 fn dataset_uri(&self) -> String {
2488 self.inner.dataset_uri()
2489 }
2490 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2491 let remaining = self.remaining.load(Ordering::Relaxed);
2492 if remaining == 0 {
2493 return Ok(0);
2494 }
2495 let take = remaining.min(records.len());
2496 let slice = &records[..take];
2497 let written = self.inner.write_batch(slice).await?;
2498 self.remaining
2499 .fetch_sub(written.min(remaining), Ordering::Relaxed);
2500 Ok(written)
2501 }
2502 async fn flush(&self) -> Result<(), FaucetError> {
2503 self.inner.flush().await
2504 }
2505}
2506
2507pub(crate) struct CountingSink {
2510 seen: AtomicUsize,
2511}
2512
2513impl CountingSink {
2514 pub(crate) fn new() -> Self {
2515 Self {
2516 seen: AtomicUsize::new(0),
2517 }
2518 }
2519}
2520
2521#[async_trait]
2522impl Sink for CountingSink {
2523 fn connector_name(&self) -> &'static str {
2524 "dry-run"
2525 }
2526 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2527 self.seen.fetch_add(records.len(), Ordering::Relaxed);
2528 Ok(records.len())
2529 }
2530}
2531
2532fn value_to_string_brief(v: &Value) -> String {
2535 match v {
2536 Value::String(s) => s.clone(),
2537 other => other.to_string(),
2538 }
2539}
2540
2541#[cfg(test)]
2542mod tests {
2543 use super::*;
2544 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
2545 use crate::expand::expand;
2546 use serde_json::json;
2547
2548 #[tokio::test]
2549 async fn resolve_product_dims_skips_unavailable_and_rejects_oversized() {
2550 let empty: DiscoveredDims =
2552 std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
2553 let skipped = resolve_product_dims(&["missing".to_string()], &empty, "row")
2554 .await
2555 .unwrap();
2556 assert!(skipped.is_none());
2557
2558 let dim = |id: &str, n: i64| crate::discovery_matrix::Dim {
2560 id: id.to_string(),
2561 alias: id.to_string(),
2562 values: (0..n).map(|i| json!(i)).collect(),
2563 };
2564 let dd: DiscoveredDims =
2565 std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::from([
2566 ("a".to_string(), dim("a", 101)),
2567 ("b".to_string(), dim("b", 100)),
2568 ])));
2569 let err = resolve_product_dims(&["a".to_string(), "b".to_string()], &dd, "row")
2570 .await
2571 .unwrap_err();
2572 assert!(matches!(err, CliError::Config(m) if m.contains("over the limit")));
2573 }
2574
2575 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
2576 PipelineConfig {
2577 version: 1,
2578 name: Some("test".into()),
2579 vars: None,
2580 params: Default::default(),
2581 auth: None,
2582 pipeline: PipelineSpec {
2583 source: Some(ConnectorSpec {
2584 kind: "csv".into(),
2585 config: json!({"path": input.to_str().unwrap()}),
2586 transforms: None,
2587 inherit_transforms: true,
2588 status: None,
2589 tags: Vec::new(),
2590 complete_for: None,
2591 }),
2592 sink: Some(ConnectorSpec {
2593 kind: "jsonl".into(),
2594 config: json!({"path": output.to_str().unwrap()}),
2595 transforms: None,
2596 inherit_transforms: true,
2597 status: None,
2598 tags: Vec::new(),
2599 complete_for: None,
2600 }),
2601 sources: Default::default(),
2602 sinks: Default::default(),
2603 transforms: Vec::new(),
2604 state: None,
2605 dlq: None,
2606 #[cfg(feature = "quality")]
2607 quality: None,
2608 #[cfg(feature = "contract")]
2609 contract: None,
2610 #[cfg(feature = "masking")]
2611 masking: None,
2612 schema: None,
2613 nodes: std::collections::HashMap::new(),
2614 edges: Vec::new(),
2615 },
2616 matrix: Vec::new(),
2617 execution: None,
2618 metadata_columns: None,
2619 selection: None,
2620 observability: None,
2621 delivery: faucet_core::DeliveryMode::default(),
2622 resilience: None,
2623 sla: None,
2624 reconcile: None,
2625 shard: None,
2626 replication: None,
2627 backfill: None,
2628 partition: None,
2629 #[cfg(feature = "schedule")]
2630 schedule: None,
2631 #[cfg(feature = "lineage")]
2632 lineage: None,
2633 #[cfg(feature = "catalog")]
2634 catalog: None,
2635 #[cfg(feature = "notify")]
2636 notifications: Vec::new(),
2637 }
2638 }
2639
2640 #[tokio::test]
2641 async fn empty_matrix_runs_pipeline_once() {
2642 let dir = tempfile::tempdir().unwrap();
2643 let input = dir.path().join("in.csv");
2644 let output = dir.path().join("out.jsonl");
2645 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2646 let cfg = cfg_csv_to_jsonl(&input, &output);
2647 let nodes = expand(&cfg).unwrap();
2648 let summary = run_expanded(
2649 nodes,
2650 ExecuteOptions {
2651 pipeline_name: "t".into(),
2652 run_id: None,
2653 execution: None,
2654 dry_run: false,
2655 limit: None,
2656 state_path_override: None,
2657 shard: None,
2658 auth: Default::default(),
2659 clock: chrono::Utc::now().fixed_offset(),
2660 cancel: None,
2661 resilience: None,
2662 sla: None,
2663 reconcile: None,
2664 #[cfg(feature = "lineage")]
2665 lineage: None,
2666 #[cfg(feature = "lineage")]
2667 lineage_cfg: None,
2668 #[cfg(feature = "notify")]
2669 notifier: None,
2670 #[cfg(feature = "catalog")]
2671 catalog: None,
2672 },
2673 )
2674 .await
2675 .unwrap();
2676 assert_eq!(summary.invocations.len(), 1);
2677 assert_eq!(summary.invocations[0].records_written, 2);
2678 assert!(!summary.had_failures());
2679 let body = std::fs::read_to_string(&output).unwrap();
2680 assert_eq!(body.lines().count(), 2);
2681 }
2682
2683 fn exec_opts(name: &str) -> ExecuteOptions {
2690 ExecuteOptions {
2691 pipeline_name: name.into(),
2692 run_id: None,
2693 execution: None,
2694 dry_run: false,
2695 limit: None,
2696 state_path_override: None,
2697 shard: None,
2698 auth: Default::default(),
2699 clock: chrono::Utc::now().fixed_offset(),
2700 cancel: None,
2701 resilience: None,
2702 sla: None,
2703 reconcile: None,
2704 #[cfg(feature = "lineage")]
2705 lineage: None,
2706 #[cfg(feature = "lineage")]
2707 lineage_cfg: None,
2708 #[cfg(feature = "notify")]
2709 notifier: None,
2710 #[cfg(feature = "catalog")]
2711 catalog: None,
2712 }
2713 }
2714
2715 async fn run_yaml(yaml: &str, path: &Path) -> RunSummary {
2716 let cfg = PipelineConfig::from_text(yaml, path).expect("config parses");
2717 let nodes = expand(&cfg).expect("config expands");
2718 run_expanded(nodes, exec_opts("ow")).await.expect("run ok")
2719 }
2720
2721 async fn seed_table(db_url: &str) {
2726 let pool = sqlx::sqlite::SqlitePoolOptions::new()
2727 .connect(&format!("{db_url}?mode=rwc"))
2728 .await
2729 .expect("open sqlite");
2730 sqlx::query("CREATE TABLE t (id TEXT, v TEXT)")
2731 .execute(&pool)
2732 .await
2733 .expect("create table");
2734 sqlx::query("INSERT INTO t (id, v) VALUES ('99', 'OLD')")
2735 .execute(&pool)
2736 .await
2737 .expect("seed row");
2738 pool.close().await;
2739 }
2740
2741 async fn read_table_vs(db_url: &str) -> Vec<String> {
2745 let pool = sqlx::sqlite::SqlitePoolOptions::new()
2746 .connect(db_url)
2747 .await
2748 .expect("open sqlite");
2749 let rows: Vec<(String,)> = sqlx::query_as("SELECT v FROM t ORDER BY v")
2750 .fetch_all(&pool)
2751 .await
2752 .expect("read back");
2753 pool.close().await;
2754 rows.into_iter().map(|(v,)| v).collect()
2755 }
2756
2757 fn overwrite_fanout_yaml(dir: &Path, db_url: &str) -> String {
2758 format!(
2762 "version: 1\nname: ow\nexecution:\n max_concurrent: 1\npipeline:\n sources:\n parents:\n type: csv\n config: {{ path: \"{parents}\" }}\n child:\n type: csv\n config: {{ path: \"{child}\" }}\n sinks:\n trash:\n type: jsonl\n config: {{ path: \"{trash}\", append: false }}\n t:\n type: sqlite\n config:\n database_url: \"{db_url}\"\n table_name: t\n column_mapping: auto_map\n write_mode: overwrite\nmatrix:\n - id: p\n source: {{ ref: parents }}\n sink: {{ ref: trash }}\n - id: c\n parent: p\n parent_key: id\n source: {{ ref: child }}\n sink: {{ ref: t }}\n",
2763 parents = dir.join("parents.csv").display(),
2764 child = dir.join("child_${p.id}.csv").display(),
2765 trash = dir.join("trash.jsonl").display(),
2766 )
2767 }
2768
2769 fn overwrite_child_node(dir: &Path, child_table: &str) -> ExpandedNode {
2773 let yaml = format!(
2774 "version: 1\nname: t\nexecution:\n max_concurrent: 1\npipeline:\n sources:\n parents: {{ type: csv, config: {{ path: \"{p}\" }} }}\n child: {{ type: csv, config: {{ path: \"{c}\" }} }}\n sinks:\n trash: {{ type: jsonl, config: {{ path: \"{t}\" }} }}\n dst: {{ type: sqlite, config: {{ database_url: \"sqlite:{db}\", table_name: \"{child_table}\", column_mapping: auto_map, write_mode: overwrite }} }}\nmatrix:\n - id: p\n source: {{ ref: parents }}\n sink: {{ ref: trash }}\n - id: c\n parent: p\n parent_key: id\n source: {{ ref: child }}\n sink: {{ ref: dst }}\n",
2775 p = dir.join("parents.csv").display(),
2776 c = dir.join("child.csv").display(),
2777 t = dir.join("trash.jsonl").display(),
2778 db = dir.join("t.db").display(),
2779 );
2780 let cfg = PipelineConfig::from_text(&yaml, &dir.join("c.yaml")).expect("config parses");
2781 expand(&cfg)
2782 .expect("config expands")
2783 .into_iter()
2784 .find(|n| n.id == "c")
2785 .expect("child node")
2786 }
2787
2788 #[tokio::test]
2797 async fn overwrite_groups_are_one_per_resolved_destination() {
2798 let dir = tempfile::tempdir().unwrap();
2799 let opts = exec_opts("t");
2800 let mk = |node: &ExpandedNode, pid: &str, id: i64| Unit {
2801 node: node.clone(),
2802 parent_record: Some(std::sync::Arc::new(json!({ "id": id }))),
2803 state_key: format!("t::c::{pid}"),
2804 parent_record_key: Some(pid.to_string()),
2805 product_ctx: None,
2806 };
2807
2808 let shared = overwrite_child_node(dir.path(), "orders");
2811 let units = vec![mk(&shared, "1", 1), mk(&shared, "2", 2)];
2812 let (groups, task_group) = plan_overwrite_groups(&units, &opts).unwrap();
2813 assert_eq!(
2814 groups.len(),
2815 1,
2816 "a shared destination collapses to one overwrite group"
2817 );
2818 assert_eq!(
2819 task_group.len(),
2820 2,
2821 "both invocations attach to the group (their per-invocation lifecycle is suppressed)"
2822 );
2823
2824 let per = overwrite_child_node(dir.path(), "orders_${p.id}");
2827 let units2 = vec![mk(&per, "1", 1), mk(&per, "2", 2)];
2828 let (groups2, task_group2) = plan_overwrite_groups(&units2, &opts).unwrap();
2829 assert_eq!(
2830 groups2.len(),
2831 2,
2832 "per-parent tables stay separate single-member groups"
2833 );
2834 assert_eq!(task_group2.len(), 2);
2835 }
2836
2837 #[tokio::test]
2838 async fn child_fanout_overwrite_aborts_on_failure_leaving_destination_intact() {
2839 let dir = tempfile::tempdir().unwrap();
2840 let db_url = format!("sqlite:{}", dir.path().join("ow.db").display());
2841 seed_table(&db_url).await;
2842 std::fs::write(dir.path().join("parents.csv"), "id\n1\n2\n").unwrap();
2843 std::fs::write(dir.path().join("child_1.csv"), "id,v\n1,A\n").unwrap();
2844 let yaml = overwrite_fanout_yaml(dir.path(), &db_url);
2848 let summary = run_yaml(&yaml, &dir.path().join("ow.yaml")).await;
2849 assert!(
2850 summary.had_failures(),
2851 "a missing child source must fail a unit"
2852 );
2853
2854 let vs = read_table_vs(&db_url).await;
2856 assert_eq!(
2857 vs,
2858 vec!["OLD".to_string()],
2859 "destination must be unchanged: {vs:?}"
2860 );
2861 }
2862
2863 #[tokio::test]
2864 async fn metadata_columns_are_stamped_onto_output() {
2865 let dir = tempfile::tempdir().unwrap();
2866 let input = dir.path().join("in.csv");
2867 let output = dir.path().join("out.jsonl");
2868 std::fs::write(&input, "name\nalice\n").unwrap();
2869 let mut cfg = cfg_csv_to_jsonl(&input, &output);
2870 cfg.metadata_columns = Some(faucet_core::MetadataColumnsSpec {
2871 enabled: true,
2872 prefix: "_faucet".into(),
2873 columns: vec![
2874 faucet_core::MetadataColumn::RunId,
2875 faucet_core::MetadataColumn::Source,
2876 faucet_core::MetadataColumn::LoadedAt,
2877 ],
2878 });
2879 let nodes = expand(&cfg).unwrap();
2880 run_expanded(nodes, opts("t")).await.unwrap();
2881 let body = std::fs::read_to_string(&output).unwrap();
2882 let row: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
2883 assert_eq!(row["name"], "alice");
2884 assert_eq!(row["_faucet_source"], "csv");
2885 assert!(row["_faucet_run_id"].is_string());
2886 assert!(row["_faucet_loaded_at"].is_string());
2887 }
2888
2889 #[tokio::test]
2890 async fn metadata_columns_disabled_stamps_nothing() {
2891 let dir = tempfile::tempdir().unwrap();
2892 let input = dir.path().join("in.csv");
2893 let output = dir.path().join("out.jsonl");
2894 std::fs::write(&input, "name\nalice\n").unwrap();
2895 let mut cfg = cfg_csv_to_jsonl(&input, &output);
2896 cfg.metadata_columns = Some(faucet_core::MetadataColumnsSpec {
2897 enabled: false,
2898 ..Default::default()
2899 });
2900 let nodes = expand(&cfg).unwrap();
2901 run_expanded(nodes, opts("t")).await.unwrap();
2902 let body = std::fs::read_to_string(&output).unwrap();
2903 let row: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
2904 assert_eq!(row["name"], "alice");
2905 assert!(row.get("_faucet_run_id").is_none());
2906 }
2907
2908 #[cfg(feature = "catalog")]
2910 fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2911 let mut o = opts(name);
2912 o.catalog = Some(handle);
2913 o
2914 }
2915
2916 #[cfg(feature = "catalog")]
2917 #[tokio::test]
2918 async fn catalog_records_schema_timeline_across_two_runs() {
2919 use crate::catalog::CatalogHandle;
2923 use crate::serve::history::RunHistory as _;
2924 use crate::serve::history::catalog::{self, CatalogListFilter};
2925 use crate::serve::history::memory::MemoryHistory;
2926
2927 let dir = tempfile::tempdir().unwrap();
2928 let input = dir.path().join("in.csv");
2929 let output = dir.path().join("out.jsonl");
2930 let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2931 let handle = CatalogHandle {
2932 store: store.clone(),
2933 run_id: None,
2934 sample_records: 10,
2935 };
2936
2937 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2938 let cfg = cfg_csv_to_jsonl(&input, &output);
2939 let nodes = expand(&cfg).unwrap();
2940 let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2941 .await
2942 .unwrap();
2943 assert!(!summary.had_failures());
2944
2945 std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2947 let nodes = expand(&cfg).unwrap();
2948 let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2949 .await
2950 .unwrap();
2951 assert!(!summary.had_failures());
2952
2953 let page = store
2955 .catalog_list_datasets(&CatalogListFilter {
2956 limit: 10,
2957 ..Default::default()
2958 })
2959 .await
2960 .unwrap();
2961 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2962 for ds in &page.datasets {
2963 let detail = store
2964 .catalog_get_dataset(&ds.id)
2965 .await
2966 .unwrap()
2967 .expect("dataset detail");
2968 assert_eq!(detail.dataset.runs, 2);
2969 assert_eq!(
2970 detail.schema_timeline.len(),
2971 2,
2972 "exactly two timeline entries for {}",
2973 ds.uri
2974 );
2975 assert!(detail.schema_timeline[0].diff.is_none());
2976 let diff = detail.schema_timeline[1]
2977 .diff
2978 .as_ref()
2979 .expect("second version carries a diff");
2980 assert!(
2981 diff["added"]
2982 .as_array()
2983 .unwrap()
2984 .iter()
2985 .any(|c| c["column"] == "email"),
2986 "diff must show the added email column: {diff}"
2987 );
2988 assert_eq!(detail.stats.len(), 2, "one volume point per run");
2989 }
2990 let edges = store.catalog_lineage(None, 5).await.unwrap();
2992 assert_eq!(edges.len(), 1);
2993 assert_eq!(edges[0].runs, 2);
2994 assert_eq!(edges[0].last_records, 2);
2995 assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2996 }
2997
2998 #[cfg(feature = "catalog")]
3001 struct FailingCatalogStore;
3002
3003 #[cfg(feature = "catalog")]
3004 #[async_trait]
3005 impl crate::serve::history::RunHistory for FailingCatalogStore {
3006 async fn claim_idempotency(
3007 &self,
3008 _: &str,
3009 _: &str,
3010 _: &str,
3011 _: std::time::Duration,
3012 ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
3013 Err(crate::serve::history::HistoryError::Backend("down".into()))
3014 }
3015 async fn upsert(
3016 &self,
3017 _: &crate::serve::history::RunRecord,
3018 ) -> Result<(), crate::serve::history::HistoryError> {
3019 Err(crate::serve::history::HistoryError::Backend("down".into()))
3020 }
3021 async fn get(
3022 &self,
3023 _: &str,
3024 ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
3025 {
3026 Err(crate::serve::history::HistoryError::Backend("down".into()))
3027 }
3028 async fn list(
3029 &self,
3030 _: &crate::serve::history::ListFilter,
3031 ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
3032 Err(crate::serve::history::HistoryError::Backend("down".into()))
3033 }
3034 async fn delete(
3035 &self,
3036 _: &str,
3037 ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
3038 {
3039 Err(crate::serve::history::HistoryError::Backend("down".into()))
3040 }
3041 async fn purge_expired(
3042 &self,
3043 _: std::time::Duration,
3044 ) -> Result<usize, crate::serve::history::HistoryError> {
3045 Err(crate::serve::history::HistoryError::Backend("down".into()))
3046 }
3047 async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
3048 Err(crate::serve::history::HistoryError::Backend("down".into()))
3049 }
3050 async fn catalog_record(
3051 &self,
3052 _: &crate::serve::history::catalog::CatalogUpdate,
3053 ) -> Result<(), crate::serve::history::HistoryError> {
3054 Err(crate::serve::history::HistoryError::Backend(
3055 "catalog write refused".into(),
3056 ))
3057 }
3058 fn degraded(&self) -> bool {
3059 false
3060 }
3061 }
3062
3063 #[cfg(feature = "catalog")]
3064 #[tokio::test]
3065 async fn catalog_write_failure_never_fails_the_run() {
3066 use crate::catalog::CatalogHandle;
3069 let dir = tempfile::tempdir().unwrap();
3070 let input = dir.path().join("in.csv");
3071 let output = dir.path().join("out.jsonl");
3072 std::fs::write(&input, "name\nalice\n").unwrap();
3073 let cfg = cfg_csv_to_jsonl(&input, &output);
3074 let nodes = expand(&cfg).unwrap();
3075 let handle = CatalogHandle {
3076 store: Arc::new(FailingCatalogStore),
3077 run_id: None,
3078 sample_records: 10,
3079 };
3080 let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
3081 .await
3082 .unwrap();
3083 assert!(
3084 !summary.had_failures(),
3085 "catalog failure must not fail the run"
3086 );
3087 assert_eq!(summary.invocations[0].records_written, 1);
3088 assert_eq!(
3089 std::fs::read_to_string(&output).unwrap().lines().count(),
3090 1,
3091 "sink output written despite the catalog error"
3092 );
3093 }
3094
3095 #[tokio::test]
3096 async fn matrix_two_independent_roots_both_run() {
3097 let dir = tempfile::tempdir().unwrap();
3099 let csv_a = dir.path().join("a.csv");
3100 let csv_b = dir.path().join("b.csv");
3101 let out_a = dir.path().join("a.jsonl");
3102 let out_b = dir.path().join("b.jsonl");
3103 std::fs::write(&csv_a, "name\nalice\n").unwrap();
3104 std::fs::write(&csv_b, "name\nbob\n").unwrap();
3105
3106 let yaml = format!(
3107 r#"version: 1
3108pipeline:
3109 source: {{ type: csv, config: {{ path: {a} }} }}
3110 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
3111matrix:
3112 - id: rowA
3113 - id: rowB
3114 source: {{ config: {{ path: {b} }} }}
3115 sink: {{ config: {{ path: {out_b} }} }}
3116"#,
3117 a = csv_a.display(),
3118 b = csv_b.display(),
3119 out_a = out_a.display(),
3120 out_b = out_b.display(),
3121 );
3122 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3123 let nodes = expand(&cfg).unwrap();
3124 let summary = run_expanded(
3125 nodes,
3126 ExecuteOptions {
3127 pipeline_name: "matrix".into(),
3128 run_id: None,
3129 execution: None,
3130 dry_run: false,
3131 limit: None,
3132 state_path_override: None,
3133 shard: None,
3134 auth: Default::default(),
3135 clock: chrono::Utc::now().fixed_offset(),
3136 cancel: None,
3137 resilience: None,
3138 sla: None,
3139 reconcile: None,
3140 #[cfg(feature = "lineage")]
3141 lineage: None,
3142 #[cfg(feature = "lineage")]
3143 lineage_cfg: None,
3144 #[cfg(feature = "notify")]
3145 notifier: None,
3146 #[cfg(feature = "catalog")]
3147 catalog: None,
3148 },
3149 )
3150 .await
3151 .unwrap();
3152 assert_eq!(summary.invocations.len(), 2);
3153 assert!(out_a.exists());
3154 assert!(out_b.exists());
3155 }
3156
3157 #[tokio::test]
3158 async fn dag_child_fans_out_per_parent_record() {
3159 let dir = tempfile::tempdir().unwrap();
3162 let parent_csv = dir.path().join("parents.csv");
3163 let child_csv = dir.path().join("child.csv");
3164 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
3165 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
3166 let parent_out = dir.path().join("parents.jsonl");
3167 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3168
3169 let yaml = format!(
3170 r#"version: 1
3171pipeline:
3172 source: {{ type: csv, config: {{ path: {parent} }} }}
3173 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3174matrix:
3175 - id: parents
3176 - id: child
3177 parent: parents
3178 source: {{ config: {{ path: {child} }} }}
3179 sink: {{ config: {{ path: "{child_out}" }} }}
3180"#,
3181 parent = parent_csv.display(),
3182 parent_out = parent_out.display(),
3183 child = child_csv.display(),
3184 child_out = child_out_pattern.display(),
3185 );
3186 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3187 let nodes = expand(&cfg).unwrap();
3188 let summary = run_expanded(
3189 nodes,
3190 ExecuteOptions {
3191 pipeline_name: "dagtest".into(),
3192 run_id: None,
3193 execution: None,
3194 dry_run: false,
3195 limit: None,
3196 state_path_override: None,
3197 shard: None,
3198 auth: Default::default(),
3199 clock: chrono::Utc::now().fixed_offset(),
3200 cancel: None,
3201 resilience: None,
3202 sla: None,
3203 reconcile: None,
3204 #[cfg(feature = "lineage")]
3205 lineage: None,
3206 #[cfg(feature = "lineage")]
3207 lineage_cfg: None,
3208 #[cfg(feature = "notify")]
3209 notifier: None,
3210 #[cfg(feature = "catalog")]
3211 catalog: None,
3212 },
3213 )
3214 .await
3215 .unwrap();
3216
3217 assert_eq!(summary.invocations.len(), 3);
3219 assert!(!summary.had_failures(), "{:?}", summary);
3220 assert!(dir.path().join("child-1.jsonl").exists());
3221 assert!(dir.path().join("child-2.jsonl").exists());
3222 }
3223
3224 #[tokio::test]
3225 async fn depends_on_root_runs_after_dependency() {
3226 let dir = tempfile::tempdir().unwrap();
3230 let input = dir.path().join("in.csv");
3231 let mid = dir.path().join("mid.csv");
3232 let out = dir.path().join("out.jsonl");
3233 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3234
3235 let yaml = format!(
3236 r#"version: 1
3237pipeline:
3238 source: {{ type: csv, config: {{ path: {input} }} }}
3239 sink: {{ type: jsonl, config: {{ path: {out} }} }}
3240matrix:
3241 - id: stage
3242 sink: {{ type: csv, config: {{ path: {mid} }} }}
3243 - id: load
3244 depends_on: [stage]
3245 source: {{ config: {{ path: {mid} }} }}
3246"#,
3247 input = input.display(),
3248 mid = mid.display(),
3249 out = out.display(),
3250 );
3251 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3252 let nodes = expand(&cfg).unwrap();
3253 let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
3254 assert_eq!(summary.invocations.len(), 2, "{summary:?}");
3255 assert!(!summary.had_failures(), "{summary:?}");
3256 let load = summary
3257 .invocations
3258 .iter()
3259 .find(|i| i.row_id == "load")
3260 .unwrap();
3261 assert_eq!(load.records_written, 2);
3262 let written = std::fs::read_to_string(&out).unwrap();
3263 assert_eq!(written.lines().count(), 2);
3264 }
3265
3266 #[tokio::test]
3267 async fn diamond_dependency_waits_for_all_prerequisites() {
3268 let dir = tempfile::tempdir().unwrap();
3271 let input = dir.path().join("in.csv");
3272 let mid_a = dir.path().join("mid_a.csv");
3273 let mid_b = dir.path().join("mid_b.csv");
3274 let out = dir.path().join("out.jsonl");
3275 std::fs::write(&input, "name\nalice\n").unwrap();
3276
3277 let yaml = format!(
3278 r#"version: 1
3279pipeline:
3280 source: {{ type: csv, config: {{ path: {input} }} }}
3281 sink: {{ type: jsonl, config: {{ path: {out} }} }}
3282matrix:
3283 - id: a
3284 sink: {{ type: csv, config: {{ path: {mid_a} }} }}
3285 - id: b
3286 sink: {{ type: csv, config: {{ path: {mid_b} }} }}
3287 - id: c
3288 depends_on: [a, b]
3289 source: {{ config: {{ path: {mid_a} }} }}
3290"#,
3291 input = input.display(),
3292 mid_a = mid_a.display(),
3293 mid_b = mid_b.display(),
3294 out = out.display(),
3295 );
3296 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3297 let nodes = expand(&cfg).unwrap();
3298 let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
3299 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3300 assert!(!summary.had_failures(), "{summary:?}");
3301 assert!(mid_b.exists(), "b must have run before c became ready");
3302 assert!(out.exists());
3303 }
3304
3305 #[tokio::test]
3306 async fn failed_dependency_skips_dependent() {
3307 let dir = tempfile::tempdir().unwrap();
3310 let good_input = dir.path().join("good.csv");
3311 let out = dir.path().join("out.jsonl");
3312 std::fs::write(&good_input, "name\nalice\n").unwrap();
3313
3314 let yaml = format!(
3315 r#"version: 1
3316pipeline:
3317 source: {{ type: csv, config: {{ path: {good} }} }}
3318 sink: {{ type: jsonl, config: {{ path: {out} }} }}
3319matrix:
3320 - id: stage
3321 source: {{ config: {{ path: {missing} }} }}
3322 - id: load
3323 depends_on: [stage]
3324"#,
3325 good = good_input.display(),
3326 missing = dir.path().join("nonexistent.csv").display(),
3327 out = out.display(),
3328 );
3329 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3330 let nodes = expand(&cfg).unwrap();
3331 let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
3332 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
3333 assert_eq!(summary.invocations[0].row_id, "stage");
3334 assert!(summary.invocations[0].error.is_some());
3335 assert!(
3336 !out.exists(),
3337 "dependent row must not run after its dependency failed"
3338 );
3339 }
3340
3341 #[tokio::test]
3342 async fn dependency_on_skipped_row_cascades() {
3343 let dir = tempfile::tempdir().unwrap();
3346 let good_input = dir.path().join("good.csv");
3347 let out = dir.path().join("q.jsonl");
3348 std::fs::write(&good_input, "id\n1\n").unwrap();
3349
3350 let yaml = format!(
3351 r#"version: 1
3352pipeline:
3353 source: {{ type: csv, config: {{ path: {good} }} }}
3354 sink: {{ type: jsonl, config: {{ path: {out} }} }}
3355matrix:
3356 - id: p
3357 source: {{ config: {{ path: {missing} }} }}
3358 - id: c
3359 parent: p
3360 - id: q
3361 depends_on: [c]
3362"#,
3363 good = good_input.display(),
3364 missing = dir.path().join("nonexistent.csv").display(),
3365 out = out.display(),
3366 );
3367 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3368 let nodes = expand(&cfg).unwrap();
3369 let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
3370 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
3371 assert_eq!(summary.invocations[0].row_id, "p");
3372 assert!(summary.invocations[0].error.is_some());
3373 assert!(
3374 !out.exists(),
3375 "q must be skipped when its dependency was skipped"
3376 );
3377 }
3378
3379 #[tokio::test]
3380 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
3381 let dir = tempfile::tempdir().unwrap();
3393 let good_csv = dir.path().join("good.csv");
3394 std::fs::write(&good_csv, "x\n1\n").unwrap();
3395 let good_out = dir.path().join("good.jsonl");
3396 let bad_sink_dir = dir.path().to_path_buf();
3397
3398 let yaml = format!(
3399 r#"version: 1
3400pipeline:
3401 source: {{ type: csv, config: {{ path: {good_csv} }} }}
3402 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
3403matrix:
3404 - id: bad
3405 sink: {{ config: {{ path: {bad_dir} }} }}
3406 - id: good
3407execution:
3408 max_concurrent: 1
3409 on_error: stop
3410"#,
3411 good_csv = good_csv.display(),
3412 good_out = good_out.display(),
3413 bad_dir = bad_sink_dir.display(),
3414 );
3415 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3416 let nodes = expand(&cfg).unwrap();
3417 let summary = run_expanded(
3418 nodes,
3419 ExecuteOptions {
3420 pipeline_name: "stoptest".into(),
3421 run_id: None,
3422 execution: cfg.execution.clone(),
3423 dry_run: false,
3424 limit: None,
3425 state_path_override: None,
3426 shard: None,
3427 auth: Default::default(),
3428 clock: chrono::Utc::now().fixed_offset(),
3429 cancel: None,
3430 resilience: None,
3431 sla: None,
3432 reconcile: None,
3433 #[cfg(feature = "lineage")]
3434 lineage: None,
3435 #[cfg(feature = "lineage")]
3436 lineage_cfg: None,
3437 #[cfg(feature = "notify")]
3438 notifier: None,
3439 #[cfg(feature = "catalog")]
3440 catalog: None,
3441 },
3442 )
3443 .await
3444 .unwrap();
3445
3446 assert!(summary.had_failures(), "the failing root must be reported");
3448
3449 let bad: Vec<_> = summary
3451 .invocations
3452 .iter()
3453 .filter(|o| o.row_id == "bad")
3454 .collect();
3455 assert_eq!(bad.len(), 1, "bad must run exactly once");
3456 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
3457
3458 assert!(
3460 summary.invocations.len() <= 2,
3461 "at most the two roots may run, got {:?}",
3462 summary.invocations
3463 );
3464
3465 let good_wrote = summary
3472 .invocations
3473 .iter()
3474 .find(|o| o.row_id == "good" && o.error.is_none())
3475 .map(|o| o.records_written)
3476 .unwrap_or(0);
3477 if good_wrote > 0 {
3478 assert!(
3479 good_out.exists(),
3480 "a good that wrote records must have produced its output file"
3481 );
3482 }
3483 }
3484
3485 #[tokio::test]
3486 async fn invalid_pipeline_name_with_state_errors_up_front() {
3487 let dir = tempfile::tempdir().unwrap();
3491 let input = dir.path().join("in.csv");
3492 let output = dir.path().join("out.jsonl");
3493 std::fs::write(&input, "name\nalice\n").unwrap();
3494 let yaml = format!(
3495 r#"version: 1
3496pipeline:
3497 source: {{ type: csv, config: {{ path: {input} }} }}
3498 sink: {{ type: jsonl, config: {{ path: {output} }} }}
3499 state: {{ type: memory }}
3500"#,
3501 input = input.display(),
3502 output = output.display(),
3503 );
3504 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3505 let nodes = expand(&cfg).unwrap();
3506 let err = run_expanded(
3507 nodes,
3508 ExecuteOptions {
3509 pipeline_name: "bad name".into(), run_id: None,
3511 execution: None,
3512 dry_run: false,
3513 limit: None,
3514 state_path_override: None,
3515 shard: None,
3516 auth: Default::default(),
3517 clock: chrono::Utc::now().fixed_offset(),
3518 cancel: None,
3519 resilience: None,
3520 sla: None,
3521 reconcile: None,
3522 #[cfg(feature = "lineage")]
3523 lineage: None,
3524 #[cfg(feature = "lineage")]
3525 lineage_cfg: None,
3526 #[cfg(feature = "notify")]
3527 notifier: None,
3528 #[cfg(feature = "catalog")]
3529 catalog: None,
3530 },
3531 )
3532 .await
3533 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
3534 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
3535 }
3536
3537 #[tokio::test]
3538 async fn invalid_parent_key_value_with_state_errors_up_front() {
3539 let dir = tempfile::tempdir().unwrap();
3542 let parent_csv = dir.path().join("parents.csv");
3543 let child_csv = dir.path().join("child.csv");
3544 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
3546 std::fs::write(&child_csv, "x\nA\n").unwrap();
3547 let parent_out = dir.path().join("parents.jsonl");
3548 let child_out = dir.path().join("child.jsonl");
3549 let yaml = format!(
3550 r#"version: 1
3551pipeline:
3552 source: {{ type: csv, config: {{ path: {parent} }} }}
3553 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3554 state: {{ type: memory }}
3555matrix:
3556 - id: parents
3557 - id: child
3558 parent: parents
3559 source: {{ config: {{ path: {child} }} }}
3560 sink: {{ config: {{ path: {child_out} }} }}
3561"#,
3562 parent = parent_csv.display(),
3563 parent_out = parent_out.display(),
3564 child = child_csv.display(),
3565 child_out = child_out.display(),
3566 );
3567 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3568 let nodes = expand(&cfg).unwrap();
3569 let err = run_expanded(
3570 nodes,
3571 ExecuteOptions {
3572 pipeline_name: "ok".into(),
3573 run_id: None,
3574 execution: None,
3575 dry_run: false,
3576 limit: None,
3577 state_path_override: None,
3578 shard: None,
3579 auth: Default::default(),
3580 clock: chrono::Utc::now().fixed_offset(),
3581 cancel: None,
3582 resilience: None,
3583 sla: None,
3584 reconcile: None,
3585 #[cfg(feature = "lineage")]
3586 lineage: None,
3587 #[cfg(feature = "lineage")]
3588 lineage_cfg: None,
3589 #[cfg(feature = "notify")]
3590 notifier: None,
3591 #[cfg(feature = "catalog")]
3592 catalog: None,
3593 },
3594 )
3595 .await
3596 .expect_err(
3597 "an illegal parent-key value must be rejected up front when state is configured",
3598 );
3599 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
3600 }
3601
3602 #[tokio::test]
3603 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
3604 let dir = tempfile::tempdir().unwrap();
3613 let bad_sink_dir = dir.path().to_path_buf();
3614 let good_csv = dir.path().join("good.csv");
3617 std::fs::write(&good_csv, "x\n1\n").unwrap();
3618 let yaml = format!(
3624 r#"version: 1
3625pipeline:
3626 source: {{ type: csv, config: {{ path: {good_csv} }} }}
3627 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
3628matrix:
3629 - id: bad
3630 - id: good_a
3631 - id: good_b
3632execution:
3633 max_concurrent: 3
3634 on_error: stop
3635"#,
3636 good_csv = good_csv.display(),
3637 bad_dir = bad_sink_dir.display(),
3638 );
3639 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3640 let nodes = expand(&cfg).unwrap();
3641 let summary = run_expanded(
3642 nodes,
3643 ExecuteOptions {
3644 pipeline_name: "stop_parallel".into(),
3645 run_id: None,
3646 execution: cfg.execution.clone(),
3647 dry_run: false,
3648 limit: None,
3649 state_path_override: None,
3650 shard: None,
3651 auth: Default::default(),
3652 clock: chrono::Utc::now().fixed_offset(),
3653 cancel: None,
3654 resilience: None,
3655 sla: None,
3656 reconcile: None,
3657 #[cfg(feature = "lineage")]
3658 lineage: None,
3659 #[cfg(feature = "lineage")]
3660 lineage_cfg: None,
3661 #[cfg(feature = "notify")]
3662 notifier: None,
3663 #[cfg(feature = "catalog")]
3664 catalog: None,
3665 },
3666 )
3667 .await
3668 .unwrap();
3669
3670 assert!(
3675 summary.had_failures(),
3676 "summary should record at least one failure: {summary:?}"
3677 );
3678 assert!(
3679 summary.invocations[0].error.is_some(),
3680 "first outcome must be the failure that triggered stop: {summary:?}"
3681 );
3682 for inv in &summary.invocations {
3686 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
3687 }
3688 }
3689
3690 #[tokio::test]
3691 async fn on_error_continue_skips_failed_subtree_only() {
3692 let dir = tempfile::tempdir().unwrap();
3694 let good_csv = dir.path().join("good.csv");
3695 std::fs::write(&good_csv, "x\n1\n").unwrap();
3696 let good_out = dir.path().join("good.jsonl");
3697
3698 let yaml = format!(
3699 r#"version: 1
3700pipeline:
3701 source: {{ type: csv, config: {{ path: {good_csv} }} }}
3702 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
3703matrix:
3704 - id: bad
3705 sink: {{ config: {{ path: {bad_dir} }} }}
3706 - id: good
3707"#,
3708 good_csv = good_csv.display(),
3709 good_out = good_out.display(),
3710 bad_dir = dir.path().display(),
3711 );
3712 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3713 let nodes = expand(&cfg).unwrap();
3714 let summary = run_expanded(
3715 nodes,
3716 ExecuteOptions {
3717 pipeline_name: "continuetest".into(),
3718 run_id: None,
3719 execution: None,
3720 dry_run: false,
3721 limit: None,
3722 state_path_override: None,
3723 shard: None,
3724 auth: Default::default(),
3725 clock: chrono::Utc::now().fixed_offset(),
3726 cancel: None,
3727 resilience: None,
3728 sla: None,
3729 reconcile: None,
3730 #[cfg(feature = "lineage")]
3731 lineage: None,
3732 #[cfg(feature = "lineage")]
3733 lineage_cfg: None,
3734 #[cfg(feature = "notify")]
3735 notifier: None,
3736 #[cfg(feature = "catalog")]
3737 catalog: None,
3738 },
3739 )
3740 .await
3741 .unwrap();
3742 assert_eq!(summary.invocations.len(), 2);
3743 assert_eq!(summary.failure_count(), 1);
3744 let good_outcome = summary
3745 .invocations
3746 .iter()
3747 .find(|i| i.row_id == "good")
3748 .unwrap();
3749 assert!(good_outcome.error.is_none());
3750 }
3751
3752 #[test]
3755 fn split_path_splits_on_dots() {
3756 assert_eq!(split_path("id"), vec!["id".to_string()]);
3757 assert_eq!(
3758 split_path("user.name"),
3759 vec!["user".to_string(), "name".to_string()]
3760 );
3761 }
3762
3763 #[test]
3764 fn minimal_paths_drops_descendants_of_kept_ancestors() {
3765 let paths = vec![
3766 vec!["user".into(), "name".into()],
3767 vec!["user".into()],
3768 vec!["id".into()],
3769 vec!["id".into()],
3770 ];
3771 let min = minimal_paths(paths);
3772 assert!(min.contains(&vec!["user".to_string()]));
3773 assert!(min.contains(&vec!["id".to_string()]));
3774 assert!(
3775 !min.contains(&vec!["user".to_string(), "name".to_string()]),
3776 "user.name must be dropped — covered by user"
3777 );
3778 assert_eq!(min.len(), 2);
3779 }
3780
3781 #[test]
3782 fn project_full_clones_whole_record() {
3783 let r = json!({"a": 1, "b": {"c": 2}});
3784 assert_eq!(project_record(&r, &Projection::Full), r);
3785 }
3786
3787 #[test]
3788 fn project_keeps_only_referenced_paths() {
3789 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
3790 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
3791 let got = project_record(&r, &p);
3792 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
3793 assert!(got.get("blob").is_none());
3794 assert!(got["user"].get("age").is_none());
3795 }
3796
3797 #[test]
3798 fn project_array_index_path_resolves_same_as_original() {
3799 let r = json!({"tags": ["x", "y", "z"]});
3800 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
3801 let got = project_record(&r, &p);
3802 assert_eq!(got, json!({"tags": {"0": "x"}}));
3803 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
3804 assert_eq!(
3805 resolve_parent_key(&got, "tags.0"),
3806 resolve_parent_key(&r, "tags.0"),
3807 "reduced tree must resolve the same value as the original"
3808 );
3809 }
3810
3811 #[test]
3812 fn project_numeric_object_key_resolves_same_as_original() {
3813 let r = json!({"data": {"0": "x", "1": "y"}});
3818 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
3819 let got = project_record(&r, &p);
3820 assert_eq!(got, json!({"data": {"0": "x"}}));
3821 assert_eq!(
3822 resolve_parent_key(&got, "data.0"),
3823 resolve_parent_key(&r, "data.0"),
3824 "numeric object-key path must resolve identically on the reduced tree"
3825 );
3826 }
3827
3828 #[test]
3829 fn project_missing_path_is_omitted() {
3830 let r = json!({"id": 1});
3831 let p = Projection::Paths(vec![vec!["nope".into()]]);
3832 assert_eq!(project_record(&r, &p), json!({}));
3833 }
3834
3835 #[test]
3836 fn build_projections_unions_parent_key_and_refs() {
3837 use crate::config::ConnectorSpec;
3838 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3839
3840 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
3841 ExpandedNode {
3842 id: id.into(),
3843 row_index: 0,
3844 role: NodeRole::Child {
3845 parent_id: parent.into(),
3846 parent_key: parent_key.into(),
3847 },
3848 source: ConnectorSpec {
3849 kind: "csv".into(),
3850 config: json!({}),
3851 transforms: None,
3852 inherit_transforms: true,
3853 status: None,
3854 tags: Vec::new(),
3855 complete_for: None,
3856 },
3857 sink: ConnectorSpec {
3858 kind: "jsonl".into(),
3859 config: json!({}),
3860 transforms: None,
3861 inherit_transforms: true,
3862 status: None,
3863 tags: Vec::new(),
3864 complete_for: None,
3865 },
3866 transforms: Vec::new(),
3867 state: None,
3868 dlq: None,
3869 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3870 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3871 #[cfg(feature = "quality")]
3872 quality: None,
3873 #[cfg(feature = "contract")]
3874 contract: None,
3875 #[cfg(feature = "masking")]
3876 masking: None,
3877 sink_ref: "default".into(),
3878 schema: None,
3879 depends_on: Vec::new(),
3880 status: crate::config::SourceStatus::Active,
3881 tags: Vec::new(),
3882 cleanup_scope: None,
3883 metadata_columns: None,
3884 deferred_refs: refs
3885 .iter()
3886 .map(|(rid, p)| DeferredRef {
3887 referenced_id: (*rid).into(),
3888 dotted_path: (*p).into(),
3889 token: format!("${{{rid}.{p}}}"),
3890 })
3891 .collect(),
3892 source_override: None,
3893 }
3894 }
3895
3896 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
3897 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
3898 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
3899 let children_of =
3900 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
3901
3902 let projs = build_projections(&nodes_by_id, &children_of);
3903 let p = projs.get("p").expect("projection for p");
3904 match &**p {
3905 Projection::Paths(paths) => {
3906 assert!(paths.contains(&vec!["id".to_string()]));
3907 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
3908 assert!(paths.contains(&vec!["email".to_string()]));
3909 assert!(
3910 !paths.iter().any(|p| p == &vec!["x".to_string()]),
3911 "a ref to a different parent must not be captured under p"
3912 );
3913 }
3914 Projection::Full => panic!("expected Paths, got Full"),
3915 }
3916 }
3917
3918 #[test]
3919 fn build_projections_whole_record_ref_is_full() {
3920 use crate::config::ConnectorSpec;
3921 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3922 let c = ExpandedNode {
3923 id: "c".into(),
3924 row_index: 0,
3925 role: NodeRole::Child {
3926 parent_id: "p".into(),
3927 parent_key: "id".into(),
3928 },
3929 source: ConnectorSpec {
3930 kind: "csv".into(),
3931 config: json!({}),
3932 transforms: None,
3933 inherit_transforms: true,
3934 status: None,
3935 tags: Vec::new(),
3936 complete_for: None,
3937 },
3938 sink: ConnectorSpec {
3939 kind: "jsonl".into(),
3940 config: json!({}),
3941 transforms: None,
3942 inherit_transforms: true,
3943 status: None,
3944 tags: Vec::new(),
3945 complete_for: None,
3946 },
3947 transforms: Vec::new(),
3948 state: None,
3949 dlq: None,
3950 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3951 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3952 #[cfg(feature = "quality")]
3953 quality: None,
3954 #[cfg(feature = "contract")]
3955 contract: None,
3956 #[cfg(feature = "masking")]
3957 masking: None,
3958 sink_ref: "default".into(),
3959 schema: None,
3960 depends_on: Vec::new(),
3961 status: crate::config::SourceStatus::Active,
3962 tags: Vec::new(),
3963 cleanup_scope: None,
3964 metadata_columns: None,
3965 deferred_refs: vec![DeferredRef {
3966 referenced_id: "p".into(),
3967 dotted_path: "".into(),
3968 token: "${p}".into(),
3969 }],
3970 source_override: None,
3971 };
3972 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3973 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3974 let projs = build_projections(&nodes_by_id, &children_of);
3975 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3976 }
3977
3978 fn opts(name: &str) -> ExecuteOptions {
3980 ExecuteOptions {
3981 pipeline_name: name.into(),
3982 run_id: None,
3983 execution: None,
3984 dry_run: false,
3985 limit: None,
3986 state_path_override: None,
3987 shard: None,
3988 auth: Default::default(),
3989 clock: chrono::Utc::now().fixed_offset(),
3990 cancel: None,
3991 resilience: None,
3992 sla: None,
3993 reconcile: None,
3994 #[cfg(feature = "lineage")]
3995 lineage: None,
3996 #[cfg(feature = "lineage")]
3997 lineage_cfg: None,
3998 #[cfg(feature = "notify")]
3999 notifier: None,
4000 #[cfg(feature = "catalog")]
4001 catalog: None,
4002 }
4003 }
4004
4005 #[tokio::test]
4006 async fn dry_run_counts_records_without_writing_sink_file() {
4007 let dir = tempfile::tempdir().unwrap();
4010 let input = dir.path().join("in.csv");
4011 let output = dir.path().join("out.jsonl");
4012 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
4013 let cfg = cfg_csv_to_jsonl(&input, &output);
4014 let nodes = expand(&cfg).unwrap();
4015 let mut o = opts("dry");
4016 o.dry_run = true;
4017 let summary = run_expanded(nodes, o).await.unwrap();
4018 assert_eq!(summary.invocations.len(), 1);
4019 assert_eq!(summary.invocations[0].records_written, 3);
4020 assert!(!summary.had_failures());
4021 assert!(
4022 !output.exists(),
4023 "dry-run must not create the real sink file"
4024 );
4025 }
4026
4027 #[tokio::test]
4028 async fn read_only_state_store_drops_writes_keeps_reads() {
4029 let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
4032 inner.put("k", &json!("v0")).await.unwrap();
4033 let ro = ReadOnlyStateStore {
4034 inner: inner.clone(),
4035 };
4036 assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
4037 ro.put("k", &json!("advanced")).await.unwrap();
4039 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
4040 ro.delete("k").await.unwrap();
4042 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
4043 }
4044
4045 #[tokio::test]
4046 async fn dry_run_with_state_does_not_persist_bookmark() {
4047 let dir = tempfile::tempdir().unwrap();
4051 let input = dir.path().join("in.csv");
4052 let output = dir.path().join("out.jsonl");
4053 let state_dir = dir.path().join("state");
4054 std::fs::create_dir_all(&state_dir).unwrap();
4055 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
4056 let cfg = cfg_csv_to_jsonl(&input, &output);
4057 let nodes = expand(&cfg).unwrap();
4058 let mut o = opts("drystate");
4059 o.dry_run = true;
4060 o.state_path_override = Some(state_dir.clone());
4061 let summary = run_expanded(nodes, o).await.unwrap();
4062 assert!(!summary.had_failures());
4063 assert!(!output.exists(), "dry-run must not write the sink file");
4064 let persisted: Vec<_> = std::fs::read_dir(&state_dir)
4067 .unwrap()
4068 .filter_map(Result::ok)
4069 .collect();
4070 assert!(
4071 persisted.is_empty(),
4072 "dry-run must not persist any bookmark file, found: {persisted:?}"
4073 );
4074 }
4075
4076 #[tokio::test]
4077 async fn limit_caps_records_written_across_the_run() {
4078 let dir = tempfile::tempdir().unwrap();
4080 let input = dir.path().join("in.csv");
4081 let output = dir.path().join("out.jsonl");
4082 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
4083 let cfg = cfg_csv_to_jsonl(&input, &output);
4084 let nodes = expand(&cfg).unwrap();
4085 let mut o = opts("lim");
4086 o.limit = Some(2);
4087 let summary = run_expanded(nodes, o).await.unwrap();
4088 assert_eq!(summary.invocations[0].records_written, 2);
4089 let body = std::fs::read_to_string(&output).unwrap();
4090 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
4091 }
4092
4093 #[tokio::test]
4094 async fn duplicate_state_key_among_siblings_is_rejected() {
4095 let dir = tempfile::tempdir().unwrap();
4099 let parent_csv = dir.path().join("parents.csv");
4100 let child_csv = dir.path().join("child.csv");
4101 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
4103 std::fs::write(&child_csv, "x\nA\n").unwrap();
4104 let parent_out = dir.path().join("parents.jsonl");
4105 let child_out = dir.path().join("child.jsonl");
4106 let yaml = format!(
4107 r#"version: 1
4108pipeline:
4109 source: {{ type: csv, config: {{ path: {parent} }} }}
4110 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
4111 state: {{ type: memory }}
4112matrix:
4113 - id: parents
4114 - id: child
4115 parent: parents
4116 source: {{ config: {{ path: {child} }} }}
4117 sink: {{ config: {{ path: {child_out} }} }}
4118"#,
4119 parent = parent_csv.display(),
4120 parent_out = parent_out.display(),
4121 child = child_csv.display(),
4122 child_out = child_out.display(),
4123 );
4124 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
4125 let nodes = expand(&cfg).unwrap();
4126 let err = run_expanded(nodes, opts("dupkey"))
4127 .await
4128 .expect_err("colliding sibling state keys must be rejected");
4129 match err {
4130 CliError::DuplicateStateKey { id, state_key } => {
4131 assert_eq!(id, "child");
4132 assert_eq!(state_key, "dupkey::child::dup");
4133 }
4134 other => panic!("expected DuplicateStateKey, got {other:?}"),
4135 }
4136 }
4137
4138 #[tokio::test]
4139 async fn state_path_override_writes_bookmark_file() {
4140 let dir = tempfile::tempdir().unwrap();
4145 let input = dir.path().join("in.csv");
4146 let output = dir.path().join("out.jsonl");
4147 let state_dir = dir.path().join("state");
4148 std::fs::write(&input, "name\nalice\n").unwrap();
4149 let cfg = cfg_csv_to_jsonl(&input, &output);
4150 let nodes = expand(&cfg).unwrap();
4151 let mut o = opts("statepath");
4152 o.state_path_override = Some(state_dir.clone());
4153 let summary = run_expanded(nodes, o).await.unwrap();
4154 assert!(!summary.had_failures());
4155 assert_eq!(summary.invocations[0].records_written, 1);
4159 }
4160
4161 #[tokio::test]
4162 async fn build_dlq_config_maps_spec_fields() {
4163 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
4164 let dir = tempfile::tempdir().unwrap();
4165 let dlq_out = dir.path().join("dlq.jsonl");
4166 let spec = DlqSpec {
4167 sink: ConnectorSpec {
4168 kind: "jsonl".into(),
4169 config: json!({ "path": dlq_out.to_str().unwrap() }),
4170 transforms: None,
4171 inherit_transforms: true,
4172 status: None,
4173 tags: Vec::new(),
4174 complete_for: None,
4175 },
4176 on_batch_error: OnBatchErrorSpec::DlqAll,
4177 max_failures_per_page: Some(7),
4178 max_failures_total: Some(42),
4179 include_original_payload: false,
4180 };
4181 let cfg = build_dlq_config(&spec).await.unwrap();
4182 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
4183 assert_eq!(cfg.max_failures_per_page, Some(7));
4184 assert_eq!(cfg.max_failures_total, Some(42));
4185 assert!(!cfg.include_original_payload);
4186 }
4187
4188 #[tokio::test]
4189 async fn build_state_for_node_arms() {
4190 let dir = tempfile::tempdir().unwrap();
4191
4192 let node = stub_node(None);
4194 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
4195
4196 let p = dir.path().join("s1");
4198 assert!(
4199 build_state_for_node(&node, Some(&p))
4200 .await
4201 .unwrap()
4202 .is_some()
4203 );
4204
4205 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
4207 kind: "memory".into(),
4208 config: json!({}),
4209 }));
4210 assert!(
4211 build_state_for_node(&node_mem, None)
4212 .await
4213 .unwrap()
4214 .is_some()
4215 );
4216
4217 let node_file = stub_node(Some(crate::config::StateStoreSpec {
4219 kind: "file".into(),
4220 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
4221 }));
4222 let p2 = dir.path().join("override2");
4223 assert!(
4224 build_state_for_node(&node_file, Some(&p2))
4225 .await
4226 .unwrap()
4227 .is_some()
4228 );
4229
4230 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
4233 kind: "memory".into(),
4234 config: json!({}),
4235 }));
4236 let p3 = dir.path().join("override3");
4237 assert!(
4238 build_state_for_node(&node_mem2, Some(&p3))
4239 .await
4240 .unwrap()
4241 .is_some()
4242 );
4243 }
4244
4245 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
4247 use crate::config::ConnectorSpec;
4248 ExpandedNode {
4249 id: "n".into(),
4250 row_index: 0,
4251 role: NodeRole::Root,
4252 source: ConnectorSpec {
4253 kind: "csv".into(),
4254 config: json!({}),
4255 transforms: None,
4256 inherit_transforms: true,
4257 status: None,
4258 tags: Vec::new(),
4259 complete_for: None,
4260 },
4261 sink: ConnectorSpec {
4262 kind: "jsonl".into(),
4263 config: json!({}),
4264 transforms: None,
4265 inherit_transforms: true,
4266 status: None,
4267 tags: Vec::new(),
4268 complete_for: None,
4269 },
4270 transforms: Vec::new(),
4271 state,
4272 dlq: None,
4273 delivery: faucet_core::DeliveryMode::AtLeastOnce,
4274 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
4275 #[cfg(feature = "quality")]
4276 quality: None,
4277 #[cfg(feature = "contract")]
4278 contract: None,
4279 #[cfg(feature = "masking")]
4280 masking: None,
4281 sink_ref: "default".into(),
4282 schema: None,
4283 depends_on: Vec::new(),
4284 status: crate::config::SourceStatus::Active,
4285 tags: Vec::new(),
4286 cleanup_scope: None,
4287 metadata_columns: None,
4288 deferred_refs: Vec::new(),
4289 source_override: None,
4290 }
4291 }
4292
4293 #[tokio::test]
4294 async fn state_key_override_delegates_and_overrides_key() {
4295 let dir = tempfile::tempdir().unwrap();
4297 let input = dir.path().join("in.csv");
4298 std::fs::write(&input, "name\nz\n").unwrap();
4299 let inner = build_source(
4300 "csv",
4301 json!({"path": input.to_str().unwrap()}),
4302 &AuthCatalog::new(),
4303 None,
4304 )
4305 .await
4306 .unwrap();
4307 let inner_name = inner.connector_name();
4309 let ov = StateKeyOverride {
4310 inner,
4311 key: "my::custom::key".into(),
4312 };
4313 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
4314 assert_eq!(ov.connector_name(), inner_name);
4315 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
4316 assert_eq!(rows.len(), 1);
4317 ov.apply_start_bookmark(json!({"any": "bookmark"}))
4319 .await
4320 .unwrap();
4321 assert!(!ov.supports_exactly_once());
4323 assert_eq!(
4324 ov.replay_guarantee(),
4325 faucet_core::ReplayGuarantee::NonDeterministic
4326 );
4327 assert_eq!(ov.capture_resume_position().await.unwrap(), None);
4328 }
4329
4330 #[tokio::test]
4331 async fn state_key_override_forwards_native_stream_pages() {
4332 struct PerPageBookmarkSource;
4338 #[async_trait]
4339 impl Source for PerPageBookmarkSource {
4340 async fn fetch_with_context(
4341 &self,
4342 _ctx: &HashMap<String, Value>,
4343 ) -> Result<Vec<Value>, FaucetError> {
4344 Ok(vec![json!({"id": 1}), json!({"id": 2})])
4345 }
4346 fn stream_pages<'a>(
4347 &'a self,
4348 _ctx: &'a HashMap<String, Value>,
4349 _batch_size: usize,
4350 ) -> std::pin::Pin<
4351 Box<
4352 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
4353 + Send
4354 + 'a,
4355 >,
4356 > {
4357 Box::pin(faucet_core::async_stream::try_stream! {
4358 yield faucet_core::StreamPage {
4359 records: vec![json!({"id": 1})],
4360 bookmark: Some(json!("bm-1")),
4361 };
4362 yield faucet_core::StreamPage {
4363 records: vec![json!({"id": 2})],
4364 bookmark: Some(json!("bm-2")),
4365 };
4366 })
4367 }
4368 fn state_key(&self) -> Option<String> {
4369 Some("native".into())
4370 }
4371 }
4372
4373 use futures::StreamExt;
4374 let ov = StateKeyOverride {
4375 inner: Box::new(PerPageBookmarkSource),
4376 key: "override".into(),
4377 };
4378 let ctx = HashMap::new();
4379 let pages: Vec<_> = ov
4380 .stream_pages(&ctx, 1000)
4381 .collect::<Vec<_>>()
4382 .await
4383 .into_iter()
4384 .collect::<Result<Vec<_>, _>>()
4385 .unwrap();
4386 assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
4387 assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
4388 assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
4389 }
4390
4391 #[tokio::test]
4392 async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
4393 struct IdemSink;
4394 #[async_trait]
4395 impl Sink for IdemSink {
4396 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4397 Ok(records.len())
4398 }
4399 fn connector_name(&self) -> &'static str {
4400 "idem"
4401 }
4402 fn supports_idempotent_writes(&self) -> bool {
4403 true
4404 }
4405 fn dedups_by_key(&self) -> bool {
4406 true
4407 }
4408 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
4409 &[
4410 faucet_core::WriteMode::Append,
4411 faucet_core::WriteMode::Upsert,
4412 ]
4413 }
4414 async fn write_batch_idempotent(
4415 &self,
4416 records: &[Value],
4417 _scope: &str,
4418 _token: &str,
4419 ) -> Result<usize, FaucetError> {
4420 Ok(records.len())
4421 }
4422 async fn last_committed_token(
4423 &self,
4424 _scope: &str,
4425 ) -> Result<Option<String>, FaucetError> {
4426 Ok(Some("tok".into()))
4427 }
4428 }
4429
4430 let captured = Arc::new(Mutex::new(Vec::new()));
4431 let sink = CapturingSink::wrap(
4432 Box::new(IdemSink),
4433 Arc::clone(&captured),
4434 Arc::new(Projection::Full),
4435 );
4436 assert!(sink.supports_idempotent_writes());
4439 assert!(sink.dedups_by_key());
4440 assert_eq!(
4441 sink.sink_guarantee(),
4442 faucet_core::SinkGuarantee::AtomicWatermark
4443 );
4444 assert!(
4445 sink.supported_write_modes()
4446 .contains(&faucet_core::WriteMode::Upsert)
4447 );
4448 assert_eq!(
4449 sink.last_committed_token("k").await.unwrap(),
4450 Some("tok".into())
4451 );
4452 assert_eq!(sink.current_schema().await.unwrap(), None);
4453 assert!(!sink.supports_schema_evolution());
4454 let n = sink
4456 .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
4457 .await
4458 .unwrap();
4459 assert_eq!(n, 1);
4460 assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
4461 }
4462
4463 #[tokio::test]
4464 async fn orphaned_child_surfaces_executor_deadlock() {
4465 use crate::config::ConnectorSpec;
4469 let orphan = ExpandedNode {
4470 id: "orphan".into(),
4471 row_index: 0,
4472 role: NodeRole::Child {
4473 parent_id: "missing-parent".into(),
4474 parent_key: "id".into(),
4475 },
4476 source: ConnectorSpec {
4477 kind: "csv".into(),
4478 config: json!({}),
4479 transforms: None,
4480 inherit_transforms: true,
4481 status: None,
4482 tags: Vec::new(),
4483 complete_for: None,
4484 },
4485 sink: ConnectorSpec {
4486 kind: "jsonl".into(),
4487 config: json!({}),
4488 transforms: None,
4489 inherit_transforms: true,
4490 status: None,
4491 tags: Vec::new(),
4492 complete_for: None,
4493 },
4494 transforms: Vec::new(),
4495 state: None,
4496 dlq: None,
4497 delivery: faucet_core::DeliveryMode::AtLeastOnce,
4498 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
4499 #[cfg(feature = "quality")]
4500 quality: None,
4501 #[cfg(feature = "contract")]
4502 contract: None,
4503 #[cfg(feature = "masking")]
4504 masking: None,
4505 sink_ref: "default".into(),
4506 schema: None,
4507 depends_on: Vec::new(),
4508 status: crate::config::SourceStatus::Active,
4509 tags: Vec::new(),
4510 cleanup_scope: None,
4511 metadata_columns: None,
4512 deferred_refs: Vec::new(),
4513 source_override: None,
4514 };
4515 let err = run_expanded(vec![orphan], opts("deadlock"))
4516 .await
4517 .expect_err("an orphaned child must surface as an executor deadlock");
4518 match err {
4519 CliError::Internal(msg) => {
4520 assert!(msg.contains("executor deadlock"), "{msg}");
4521 assert!(msg.contains("orphan"), "{msg}");
4522 }
4523 other => panic!("expected Internal deadlock error, got {other:?}"),
4524 }
4525 }
4526
4527 #[test]
4528 fn value_to_string_brief_unquotes_strings_only() {
4529 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
4530 assert_eq!(value_to_string_brief(&json!(42)), "42");
4531 assert_eq!(value_to_string_brief(&json!(true)), "true");
4532 assert_eq!(value_to_string_brief(&json!(null)), "null");
4533 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
4534 }
4535
4536 #[test]
4537 fn build_state_key_with_and_without_parent() {
4538 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
4539 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
4540 }
4541
4542 #[test]
4543 fn resolve_parent_key_walks_objects_arrays_and_misses() {
4544 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
4545 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
4546 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
4547 assert_eq!(resolve_parent_key(&r, "user.age"), None);
4549 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
4551 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
4553 }
4554
4555 #[tokio::test]
4556 async fn cooperative_cancel_returns_partial_ok() {
4557 let dir = tempfile::tempdir().unwrap();
4561 let input = dir.path().join("in.csv");
4562 let output = dir.path().join("out.jsonl");
4563 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
4564 let cfg = cfg_csv_to_jsonl(&input, &output);
4565 let nodes = expand(&cfg).unwrap();
4566 let token = CancellationToken::new();
4567 token.cancel(); let mut o = opts("cancel");
4569 o.cancel = Some(token);
4570 let summary = run_expanded(nodes, o).await.unwrap();
4571 assert_eq!(summary.invocations.len(), 1);
4574 assert!(
4575 !summary.had_failures(),
4576 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
4577 );
4578 }
4579
4580 #[tokio::test]
4581 async fn fanout_projects_away_unreferenced_parent_fields() {
4582 let dir = tempfile::tempdir().unwrap();
4586 let parent_csv = dir.path().join("parents.csv");
4587 let child_csv = dir.path().join("child.csv");
4588 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
4589 std::fs::write(&child_csv, "x\nA\n").unwrap();
4590 let parent_out = dir.path().join("parents.jsonl");
4591 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
4592
4593 let yaml = format!(
4594 r#"version: 1
4595pipeline:
4596 source: {{ type: csv, config: {{ path: {parent} }} }}
4597 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
4598matrix:
4599 - id: parents
4600 - id: child
4601 parent: parents
4602 source: {{ config: {{ path: {child} }} }}
4603 sink: {{ config: {{ path: "{child_out}" }} }}
4604"#,
4605 parent = parent_csv.display(),
4606 parent_out = parent_out.display(),
4607 child = child_csv.display(),
4608 child_out = child_out_pattern.display(),
4609 );
4610 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
4611 let nodes = expand(&cfg).unwrap();
4612 let summary = run_expanded(
4613 nodes,
4614 ExecuteOptions {
4615 pipeline_name: "projtest".into(),
4616 run_id: None,
4617 execution: None,
4618 dry_run: false,
4619 limit: None,
4620 state_path_override: None,
4621 shard: None,
4622 auth: Default::default(),
4623 clock: chrono::Utc::now().fixed_offset(),
4624 cancel: None,
4625 resilience: None,
4626 sla: None,
4627 reconcile: None,
4628 #[cfg(feature = "lineage")]
4629 lineage: None,
4630 #[cfg(feature = "lineage")]
4631 lineage_cfg: None,
4632 #[cfg(feature = "notify")]
4633 notifier: None,
4634 #[cfg(feature = "catalog")]
4635 catalog: None,
4636 },
4637 )
4638 .await
4639 .unwrap();
4640
4641 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
4643 assert!(!summary.had_failures(), "{summary:?}");
4644 assert!(dir.path().join("child-1.jsonl").exists());
4646 assert!(dir.path().join("child-2.jsonl").exists());
4647 }
4648}