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>>>>>;
47use tokio_util::sync::CancellationToken;
48
49pub struct ExecuteOptions {
51 pub pipeline_name: String,
54 pub execution: Option<ExecutionSpec>,
57 pub dry_run: bool,
59 pub limit: Option<usize>,
61 pub state_path_override: Option<PathBuf>,
63 pub shard: Option<faucet_core::ShardSpec>,
68 pub auth: AuthCatalog,
72 pub clock: DateTime<FixedOffset>,
76 pub cancel: Option<CancellationToken>,
82 pub resilience: Option<faucet_core::ResiliencePolicy>,
87 pub sla: Option<crate::sla::SlaSpec>,
92 #[cfg(feature = "lineage")]
95 pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
96 #[cfg(feature = "lineage")]
100 pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
101 #[cfg(feature = "notify")]
107 pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
108 #[cfg(feature = "catalog")]
115 pub catalog: Option<crate::catalog::CatalogHandle>,
116}
117
118const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
123
124#[derive(Debug)]
126pub struct InvocationOutcome {
127 pub row_id: String,
128 pub parent_record_key: Option<String>,
131 pub records_written: usize,
132 pub error: Option<String>,
133 pub metrics: Option<InvocationMetrics>,
138}
139
140#[derive(Debug, Clone, Default)]
145pub struct InvocationMetrics {
146 pub source_kind: String,
147 pub sink_kind: String,
148 pub duration_ms: u64,
149 pub records_read: Option<u64>,
150 pub dlq_count: u64,
151 pub bookmark: Option<Value>,
152}
153
154struct PipelineStats {
158 records_written: usize,
159 records_read: Option<u64>,
160 dlq_count: u64,
161 bookmark: Option<Value>,
162}
163
164#[derive(Debug)]
166pub struct RunSummary {
167 pub invocations: Vec<InvocationOutcome>,
168}
169
170impl RunSummary {
171 pub fn failure_count(&self) -> usize {
172 self.invocations
173 .iter()
174 .filter(|i| i.error.is_some())
175 .count()
176 }
177 pub fn had_failures(&self) -> bool {
178 self.failure_count() > 0
179 }
180}
181
182fn default_concurrency() -> usize {
193 std::thread::available_parallelism()
194 .map(|n| n.get())
195 .unwrap_or(4)
196 .clamp(1, 8)
197}
198
199pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
202 let on_error = opts
203 .execution
204 .as_ref()
205 .map(|e| e.on_error)
206 .unwrap_or_default();
207 let max_concurrent = opts
208 .execution
209 .as_ref()
210 .and_then(|e| e.max_concurrent)
211 .unwrap_or_else(default_concurrency)
212 .max(1);
213 let semaphore = Arc::new(Semaphore::new(max_concurrent));
214
215 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
220 for n in nodes.iter() {
221 if let NodeRole::Child { parent_id, .. } = &n.role {
222 children_of
223 .entry(parent_id.clone())
224 .or_default()
225 .push(n.id.clone());
226 }
227 }
228
229 let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
234
235 let mut outcomes: Vec<InvocationOutcome> = Vec::new();
236 let mut skipped_subtrees: HashSet<String> = HashSet::new();
237
238 let cancel = opts.cancel.clone().unwrap_or_default();
243 let opts = Arc::new(opts);
244
245 let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
249 let mut completed: HashSet<String> = HashSet::new();
250 let nodes_by_id: HashMap<String, ExpandedNode> =
251 nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
252
253 let projections = build_projections(&nodes_by_id, &children_of);
256
257 let bfs_order: Vec<String> = {
261 let mut ids: Vec<(usize, String)> = nodes_by_id
262 .values()
263 .map(|n| (n.row_index, n.id.clone()))
264 .collect();
265 ids.sort_by_key(|(i, _)| *i);
266 ids.into_iter().map(|(_, id)| id).collect()
267 };
268
269 while !remaining.is_empty() {
270 let ready: Vec<String> = bfs_order
276 .iter()
277 .filter(|id| remaining.contains(*id))
278 .filter(|id| {
279 let node = &nodes_by_id[*id];
280 let parent_done = match &node.role {
281 NodeRole::Root => true,
282 NodeRole::Child { parent_id, .. } => {
283 completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
284 }
285 };
286 parent_done
287 && node
288 .depends_on
289 .iter()
290 .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
291 })
292 .cloned()
293 .collect();
294
295 if ready.is_empty() {
296 let mut stuck: Vec<String> = remaining.iter().cloned().collect();
301 stuck.sort();
302 return Err(CliError::Internal(format!(
303 "executor deadlock: {} node(s) never became ready (no completed/skipped \
304 parent or dependency): {}",
305 stuck.len(),
306 stuck.join(", ")
307 )));
308 }
309
310 let mut units: Vec<Unit> = Vec::new();
313 let level_records: HashMap<String, Vec<Arc<Value>>> = {
320 let consumed_parents: HashSet<&str> = ready
321 .iter()
322 .filter_map(|id| match &nodes_by_id[id].role {
323 NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
324 NodeRole::Root => None,
325 })
326 .collect();
327 let mut cap = captured.lock().await;
328 consumed_parents
329 .iter()
330 .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
331 .collect()
332 };
333 for id in &ready {
334 let node = &nodes_by_id[id];
335 if let NodeRole::Child { parent_id, .. } = &node.role
338 && skipped_subtrees.contains(parent_id)
339 {
340 skipped_subtrees.insert(id.clone());
341 tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
342 continue;
343 }
344 if let Some(dep) = node
349 .depends_on
350 .iter()
351 .find(|d| skipped_subtrees.contains(d.as_str()))
352 {
353 skipped_subtrees.insert(id.clone());
354 tracing::warn!(
355 row = %id, dependency = %dep,
356 "skipping row: a depends_on row failed or was skipped"
357 );
358 continue;
359 }
360 match &node.role {
361 NodeRole::Root => {
362 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
363 let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
364 validate_unit_state_key(&node.id, uses_state, &state_key)?;
365 units.push(Unit {
366 node: node.clone(),
367 parent_record: None,
368 state_key,
369 parent_record_key: None,
370 });
371 }
372 NodeRole::Child {
373 parent_id,
374 parent_key,
375 } => {
376 let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
377 if parent_records.is_empty() {
378 tracing::info!(
379 row = %id, parent = %parent_id,
380 "parent produced no records — child skipped"
381 );
382 continue;
383 }
384 let uses_state = node.state.is_some() || opts.state_path_override.is_some();
386 let mut seen_keys: HashSet<String> = HashSet::new();
387 for record in &parent_records {
388 let pk_value = resolve_parent_key(record, parent_key);
389 let pk_string = pk_value
390 .as_ref()
391 .map(value_to_string_brief)
392 .unwrap_or_else(|| "(missing)".to_string());
393 let state_key =
394 build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
395 validate_unit_state_key(&node.id, uses_state, &state_key)?;
396 if !seen_keys.insert(state_key.clone()) {
397 return Err(CliError::DuplicateStateKey {
398 id: node.id.clone(),
399 state_key,
400 });
401 }
402 units.push(Unit {
403 node: node.clone(),
404 parent_record: Some(record.clone()),
405 state_key,
406 parent_record_key: Some(pk_string),
407 });
408 }
409 }
410 }
411 }
412 drop(level_records);
413
414 let mut had_level_failure = false;
415 let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
416
417 let level_cancel = cancel.child_token();
429 let mut joinset = tokio::task::JoinSet::new();
430 let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
434 for unit in units {
435 let sem = Arc::clone(&semaphore);
436 let opts2 = Arc::clone(&opts);
437 let captured = Arc::clone(&captured);
438 let capture = projections.get(&unit.node.id).cloned();
439 let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
440 let unit_cancel = level_cancel.clone();
441 let handle = joinset.spawn(async move {
442 let _permit = sem.acquire().await.expect("semaphore not closed");
443 run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
444 });
445 task_meta.insert(handle.id(), meta);
446 }
447
448 let mut stop_triggered = false;
449 let mut aborted = false;
450 let mut stop_deadline: Option<tokio::time::Instant> = None;
451 loop {
452 let joined = match stop_deadline {
457 Some(deadline) if !aborted => {
458 match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
459 Ok(j) => j,
460 Err(_) => {
461 tracing::warn!(
462 "on_error: stop — flush grace elapsed; aborting remaining \
463 in-flight invocations"
464 );
465 joinset.abort_all();
466 aborted = true;
467 continue;
468 }
469 }
470 }
471 _ => joinset.join_next_with_id().await,
472 };
473 let Some(joined) = joined else { break };
474 let outcome = match joined {
478 Ok((_id, outcome)) => outcome,
479 Err(e) if e.is_cancelled() => {
480 continue;
483 }
484 Err(e) => {
485 let (row_id, parent_record_key) = task_meta
486 .get(&e.id())
487 .cloned()
488 .unwrap_or_else(|| ("<unknown>".to_string(), None));
489 InvocationOutcome {
490 row_id,
491 parent_record_key,
492 records_written: 0,
493 error: Some(format!("pipeline invocation task panicked: {e}")),
494 metrics: None,
495 }
496 }
497 };
498
499 if let Some(err) = &outcome.error {
500 tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
501 had_level_failure = true;
502 nodes_with_any_failure.insert(outcome.row_id.clone());
503 if matches!(on_error, OnError::Stop) && !stop_triggered {
504 stop_triggered = true;
505 tracing::error!(
506 "on_error: stop — cancelling in-flight invocations (cooperative \
507 flush), then aborting any that don't stop within the grace window"
508 );
509 level_cancel.cancel();
513 stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
514 }
515 } else {
516 tracing::info!(
517 row = %outcome.row_id,
518 records_written = outcome.records_written,
519 "pipeline invocation completed"
520 );
521 }
522 outcomes.push(outcome);
523 }
524
525 for id in ready {
529 remaining.remove(&id);
530 if nodes_with_any_failure.contains(&id) {
531 skipped_subtrees.insert(id.clone());
532 if let Some(children) = children_of.get(&id) {
534 for cid in children {
535 skipped_subtrees.insert(cid.clone());
536 }
537 }
538 } else {
539 completed.insert(id);
540 }
541 }
542
543 if had_level_failure && matches!(on_error, OnError::Stop) {
544 tracing::error!("on_error: stop — aborting after first failure");
545 break;
547 }
548 }
549
550 Ok(RunSummary {
551 invocations: outcomes,
552 })
553}
554
555struct Unit {
558 node: ExpandedNode,
559 parent_record: Option<Arc<Value>>,
560 state_key: String,
561 parent_record_key: Option<String>,
562}
563
564async fn run_unit(
565 unit: &Unit,
566 capture: Option<Arc<Projection>>,
567 captured: &CapturedRecords,
568 opts: &ExecuteOptions,
569 cancel: CancellationToken,
570) -> InvocationOutcome {
571 let needs_capture = capture.is_some();
572 let started = std::time::Instant::now();
573 let result = run_one_invocation(
574 &unit.node,
575 unit.parent_record.as_deref(),
576 &unit.state_key,
577 capture,
578 opts,
579 cancel,
580 )
581 .await;
582 let duration_ms = started.elapsed().as_millis() as u64;
583 let row_id = unit.node.id.clone();
584 let parent_record_key = unit.parent_record_key.clone();
585 let base_metrics = || InvocationMetrics {
586 source_kind: unit.node.source.kind.clone(),
587 sink_kind: unit.node.sink.kind.clone(),
588 duration_ms,
589 ..Default::default()
590 };
591 match result {
592 Ok((records, stats)) => {
593 if needs_capture {
594 captured
595 .lock()
596 .await
597 .entry(row_id.clone())
598 .or_default()
599 .extend(records.into_iter().map(Arc::new));
602 }
603 InvocationOutcome {
604 row_id,
605 parent_record_key,
606 records_written: stats.records_written,
607 error: None,
608 metrics: Some(InvocationMetrics {
609 records_read: stats.records_read,
610 dlq_count: stats.dlq_count,
611 bookmark: stats.bookmark,
612 ..base_metrics()
613 }),
614 }
615 }
616 Err(e) => InvocationOutcome {
617 row_id,
618 parent_record_key,
619 records_written: 0,
620 error: Some(e.to_string()),
621 metrics: Some(base_metrics()),
622 },
623 }
624}
625
626pub(crate) fn build_state_key(
628 pipeline_name: &str,
629 row_id: &str,
630 parent_key: Option<&str>,
631) -> String {
632 match parent_key {
633 None => format!("{pipeline_name}::{row_id}"),
634 Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
635 }
636}
637
638fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
643 if uses_state {
644 faucet_core::state::validate_state_key(state_key).map_err(|e| {
645 CliError::InvalidStateKey {
646 id: node_id.to_owned(),
647 state_key: state_key.to_owned(),
648 reason: e.to_string(),
649 }
650 })?;
651 }
652 Ok(())
653}
654
655fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
657 let mut cur = record;
658 for segment in parent_key.split('.') {
659 cur = match cur {
660 Value::Object(m) => m.get(segment)?,
661 Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
662 _ => return None,
663 };
664 }
665 Some(cur.clone())
666}
667
668#[derive(Debug, Clone)]
672enum Projection {
673 Full,
676 Paths(Vec<Vec<String>>),
678}
679
680fn split_path(path: &str) -> Vec<String> {
682 path.split('.').map(|s| s.to_string()).collect()
683}
684
685fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
689 paths.sort();
690 paths.dedup();
691 let mut kept: Vec<Vec<String>> = Vec::new();
692 for p in paths {
693 let covered = kept
694 .iter()
695 .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
696 if !covered {
697 kept.push(p);
698 }
699 }
700 kept
701}
702
703fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
706 let mut cur = record;
707 for seg in segments {
708 cur = match cur {
709 Value::Object(m) => m.get(seg)?,
710 Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
711 _ => return None,
712 };
713 }
714 Some(cur.clone())
715}
716
717fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
722 if segments.is_empty() {
723 return;
724 }
725 let mut cur = out;
726 for seg in &segments[..segments.len() - 1] {
727 let map = match cur {
728 Value::Object(m) => m,
729 _ => return,
730 };
731 cur = map
732 .entry(seg.clone())
733 .or_insert_with(|| Value::Object(serde_json::Map::new()));
734 }
735 if let Value::Object(m) = cur {
736 m.insert(segments[segments.len() - 1].clone(), leaf);
737 }
738}
739
740fn project_record(record: &Value, projection: &Projection) -> Value {
745 match projection {
746 Projection::Full => record.clone(),
747 Projection::Paths(paths) => {
748 let mut out = Value::Object(serde_json::Map::new());
749 for segs in paths {
750 if let Some(v) = walk_value(record, segs) {
751 graft_object(&mut out, segs, v);
752 }
753 }
754 out
755 }
756 }
757}
758
759fn build_projections(
764 nodes_by_id: &HashMap<String, ExpandedNode>,
765 children_of: &HashMap<String, Vec<String>>,
766) -> HashMap<String, Arc<Projection>> {
767 let mut out = HashMap::new();
768 for (parent_id, child_ids) in children_of {
769 let mut raw: Vec<Vec<String>> = Vec::new();
770 let mut full = false;
771 for cid in child_ids {
772 let child = &nodes_by_id[cid];
773 if let NodeRole::Child { parent_key, .. } = &child.role {
774 if parent_key.is_empty() {
775 full = true;
776 } else {
777 raw.push(split_path(parent_key));
778 }
779 }
780 for dref in &child.deferred_refs {
781 if dref.referenced_id == *parent_id {
782 if dref.dotted_path.is_empty() {
783 full = true; } else {
785 raw.push(split_path(&dref.dotted_path));
786 }
787 }
788 }
789 }
790 let projection = if full || raw.is_empty() {
795 Projection::Full
796 } else {
797 Projection::Paths(minimal_paths(raw))
798 };
799 out.insert(parent_id.clone(), Arc::new(projection));
800 }
801 out
802}
803
804#[allow(clippy::too_many_arguments)]
815async fn build_pipeline<'a>(
816 source: &'a dyn Source,
817 sink: &'a dyn Sink,
818 node: &ExpandedNode,
819 opts: &ExecuteOptions,
820 state: Option<Arc<dyn StateStore>>,
821 cancel: &CancellationToken,
822 pipeline_name: &str,
823 row_id: &str,
824 run_id: &str,
825) -> CliResult<Pipeline<'a, dyn Source + 'a, dyn Sink + 'a>> {
826 let mut pipeline = Pipeline::new(source, sink)
827 .with_name(pipeline_name.to_owned())
828 .with_row(row_id.to_owned())
829 .with_run_id(run_id.to_owned());
830 if let Some(store) = state {
831 pipeline = pipeline.with_state_store(store);
832 }
833 if let Some(ref dlq_spec) = node.dlq {
834 let dlq_cfg = build_dlq_config(dlq_spec).await?;
835 pipeline = pipeline.with_dlq(dlq_cfg);
836 }
837 pipeline = pipeline.with_cancel(cancel.clone());
842 #[cfg(feature = "quality")]
846 if let Some(ref quality_spec) = node.quality {
847 let compiled = Arc::new(
848 faucet_core::CompiledQuality::compile(quality_spec)
849 .map_err(|e| CliError::Config(format!("quality: {e}")))?,
850 );
851 pipeline = pipeline.with_quality(compiled);
852 }
853 #[cfg(feature = "contract")]
857 if let Some(ref contract_spec) = node.contract {
858 let compiled = Arc::new(
859 faucet_core::CompiledContract::compile(contract_spec)
860 .map_err(|e| CliError::Config(format!("contract: {e}")))?,
861 );
862 pipeline = pipeline.with_contract(compiled);
863 }
864 #[cfg(feature = "masking")]
870 if let Some(ref masking_spec) = node.masking {
871 let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
872 let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
873 .map_err(|e| CliError::Config(format!("masking: {e}")))?;
874 if !compiled.is_empty() {
875 pipeline = pipeline.with_masking(Arc::new(compiled));
876 }
877 }
878 if let Some(ref sd) = node.schema {
880 pipeline = pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd));
881 }
882 if let Some(ab) = opts
884 .execution
885 .as_ref()
886 .and_then(|e| e.adaptive_batch_size.clone())
887 {
888 ab.validate()
889 .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
890 pipeline = pipeline.with_adaptive(ab);
891 }
892 if let Some(policy) = opts.resilience.clone() {
895 pipeline = pipeline.with_resilience(policy);
896 }
897 let effective_delivery = if opts.dry_run || opts.limit.is_some() {
903 faucet_core::idempotency::DeliveryMode::AtLeastOnce
904 } else {
905 node.delivery
906 };
907 pipeline = pipeline.with_delivery(effective_delivery);
908 Ok(pipeline)
909}
910
911async fn run_one_invocation(
912 node: &ExpandedNode,
913 parent_record: Option<&Value>,
914 state_key: &str,
915 capture: Option<Arc<Projection>>,
916 opts: &ExecuteOptions,
917 cancel: CancellationToken,
918) -> CliResult<(Vec<Value>, PipelineStats)> {
919 let run_id = uuid::Uuid::now_v7().to_string();
922 let pipeline_name = opts.pipeline_name.clone();
923 let row_id = node.id.clone();
924 #[cfg(feature = "lineage")]
925 let lineage = opts.lineage.clone();
926 #[cfg(feature = "lineage")]
927 let lineage_cfg = opts.lineage_cfg.clone();
928 let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
929 #[cfg(feature = "catalog")]
933 let catalog_active = opts.catalog.is_some()
934 && matches!(node.role, NodeRole::Root)
935 && !opts.dry_run
936 && opts.limit.is_none()
937 && opts.shard.is_none();
938 let mut source_cfg = node.source.config.clone();
940 let mut sink_cfg = node.sink.config.clone();
941
942 resolve_now_inplace(&mut source_cfg, opts.clock)?;
945 resolve_now_inplace(&mut sink_cfg, opts.clock)?;
946 reject_unresolved_backfill_tokens(&source_cfg, "source")?;
952 reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
953
954 if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
955 let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
956 resolve_inplace(&mut source_cfg, &ctx)?;
957 resolve_inplace(&mut sink_cfg, &ctx)?;
958 }
959
960 let source = match node.source_override.as_ref().and_then(|o| o.take()) {
965 Some(prebuilt) => prebuilt,
966 None => {
967 build_source(
968 &node.source.kind,
969 source_cfg,
970 &opts.auth,
971 opts.resilience.as_ref().map(|r| &r.retry),
972 )
973 .await?
974 }
975 };
976
977 #[cfg(feature = "catalog")]
980 let source_dataset_uri = source.dataset_uri();
981
982 if let Some(shard) = &opts.shard {
986 source
987 .apply_shard(shard)
988 .await
989 .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
990 }
991 let raw_sink: Box<dyn Sink> = if opts.dry_run {
992 Box::new(CountingSink::new())
993 } else {
994 build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
995 };
996 #[cfg(feature = "catalog")]
997 let sink_dataset_uri = raw_sink.dataset_uri();
998 let raw_sink: Box<dyn Sink> = match opts.limit {
999 Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
1000 None => raw_sink,
1001 };
1002 let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
1003 let sink: Box<dyn Sink> = match &capture {
1004 Some(projection) => Box::new(CapturingSink::wrap(
1005 raw_sink,
1006 Arc::clone(&captured),
1007 Arc::clone(projection),
1008 )),
1009 None => raw_sink,
1010 };
1011
1012 #[cfg(feature = "lineage")]
1018 let (in_sample, out_sample) = {
1019 use std::sync::Arc as StdArc;
1020 let mut want = false;
1021 let mut cap = 0usize;
1022 if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
1023 let want_schema = lc.include_schema_facet || lc.include_column_lineage;
1024 if want_schema {
1025 cap = cap.max(lc.sample_records);
1026 }
1027 want = want_schema || lc.emit_on.running;
1028 }
1029 #[cfg(feature = "catalog")]
1033 if catalog_active {
1034 want = true;
1035 cap = cap.max(
1036 opts.catalog
1037 .as_ref()
1038 .map(|h| h.sample_records)
1039 .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
1040 );
1041 }
1042 if want {
1043 (
1044 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1045 Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1046 )
1047 } else {
1048 (None, None)
1049 }
1050 };
1051
1052 #[cfg(feature = "lineage")]
1055 let source: Box<dyn Source> = match &in_sample {
1056 Some(state) => Box::new(faucet_lineage::SamplingSource::new(
1057 source,
1058 std::sync::Arc::clone(state),
1059 )),
1060 None => source,
1061 };
1062
1063 let mut transforms = node.transforms.clone();
1068 for t in &mut transforms {
1069 resolve_now_inplace(&mut t.config, opts.clock)?;
1070 }
1071 #[cfg(feature = "arrow")]
1076 let source: Box<dyn Source> = {
1077 let (stages, batch_fns) = crate::transforms::compile_transforms_columnar(&transforms)?;
1078 if stages.is_empty() {
1079 source
1080 } else {
1081 Box::new(faucet_core::TransformingSource::new_with_batches(
1082 source,
1083 stages,
1084 batch_fns,
1085 obs_labels.clone(),
1086 )?)
1087 }
1088 };
1089 #[cfg(not(feature = "arrow"))]
1090 let source: Box<dyn Source> = {
1091 let stages = crate::transforms::compile_transforms(&transforms)?;
1092 if stages.is_empty() {
1093 source
1094 } else {
1095 Box::new(faucet_core::TransformingSource::new(
1096 source,
1097 stages,
1098 obs_labels.clone(),
1099 )?)
1100 }
1101 };
1102
1103 let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
1107 let state: Option<Arc<dyn StateStore>> = match state {
1112 Some(inner) if opts.dry_run || opts.limit.is_some() => {
1113 Some(Arc::new(ReadOnlyStateStore { inner }))
1114 }
1115 other => other,
1116 };
1117 let sla_store = state.clone();
1120 let effective_state_key = match &opts.shard {
1123 Some(shard) => format!("{state_key}::{}", shard.id),
1124 None => state_key.to_owned(),
1125 };
1126 let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
1127 Box::new(StateKeyOverride {
1128 inner: source,
1129 key: effective_state_key,
1130 })
1131 } else {
1132 source
1133 };
1134
1135 #[cfg(feature = "lineage")]
1138 let sink: Box<dyn Sink> = match &out_sample {
1139 Some(state) => Box::new(faucet_lineage::SamplingSink::new(
1140 sink,
1141 std::sync::Arc::clone(state),
1142 )),
1143 None => sink,
1144 };
1145
1146 let pipeline = build_pipeline(
1154 source.as_ref(),
1155 sink.as_ref(),
1156 node,
1157 opts,
1158 state,
1159 &cancel,
1160 &pipeline_name,
1161 &row_id,
1162 &run_id,
1163 )
1164 .await?;
1165 #[cfg(feature = "lineage")]
1167 let lineage_ctx = match (&lineage, &lineage_cfg) {
1168 (Some(em), Some(lc)) => {
1169 let job_name =
1170 crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1171 let mut ctx = faucet_lineage::RunLifecycle {
1172 job_namespace: lc.namespace.clone(),
1173 job_name,
1174 run_id: run_id.clone(),
1175 parent: lc.parent_job.clone(),
1176 input: faucet_lineage::DatasetRef {
1177 namespace: lc.namespace.clone(),
1178 name: source.dataset_uri(),
1179 },
1180 output: faucet_lineage::DatasetRef {
1181 namespace: lc.namespace.clone(),
1182 name: sink.dataset_uri(),
1183 },
1184 started_at: chrono::Utc::now(),
1185 finished_at: None,
1186 records: 0,
1187 error: None,
1188 input_schema: None,
1189 output_schema: None,
1190 column_lineage: None,
1191 source_code: None,
1192 };
1193 em.emit(faucet_lineage::EventType::Start, &ctx).await;
1194 let hb_handle = if lc.emit_on.running {
1197 let em2 = std::sync::Arc::clone(em);
1198 let interval = lc.heartbeat_interval;
1199 let mut beat_ctx = ctx.clone();
1200 let counter = out_sample.clone();
1201 Some(tokio::spawn(async move {
1202 let mut tick = tokio::time::interval(interval);
1203 tick.tick().await; loop {
1205 tick.tick().await;
1206 if let Some(c) = &counter {
1207 beat_ctx.records = c.count();
1208 }
1209 em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1210 .await;
1211 }
1212 }))
1213 } else {
1214 None
1215 };
1216 ctx.source_code = if lc.include_source_code_facet {
1217 Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1218 } else {
1219 None
1220 };
1221 Some((std::sync::Arc::clone(em), ctx, hb_handle))
1222 }
1223 _ => None,
1224 };
1225
1226 let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1233 Ok(r) => sink.flush().await.map(|_| r),
1234 Err(e) => Err(e),
1235 };
1236
1237 #[cfg(feature = "lineage")]
1238 if let Some((em, mut ctx, hb)) = lineage_ctx {
1239 if let Some(h) = hb {
1240 h.abort();
1241 }
1242 ctx.finished_at = Some(chrono::Utc::now());
1243 if let Some(state) = &out_sample {
1244 ctx.records = state.count();
1245 if lineage_cfg
1246 .as_ref()
1247 .map(|l| l.include_schema_facet)
1248 .unwrap_or(false)
1249 {
1250 ctx.output_schema = Some(state.inferred_schema());
1251 }
1252 }
1253 if let Some(state) = &in_sample
1254 && lineage_cfg
1255 .as_ref()
1256 .map(|l| l.include_schema_facet || l.include_column_lineage)
1257 .unwrap_or(false)
1258 {
1259 let in_schema = state.inferred_schema();
1260 if lineage_cfg
1261 .as_ref()
1262 .map(|l| l.include_column_lineage)
1263 .unwrap_or(false)
1264 {
1265 let input_fields: Vec<String> =
1266 in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1267 #[cfg(feature = "masking")]
1268 let has_masking = node.masking.is_some();
1269 #[cfg(not(feature = "masking"))]
1270 let has_masking = false;
1271 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1272 ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1273 }
1274 if lineage_cfg
1275 .as_ref()
1276 .map(|l| l.include_schema_facet)
1277 .unwrap_or(false)
1278 {
1279 ctx.input_schema = Some(in_schema);
1280 }
1281 }
1282 let ev = match &result {
1283 Err(e) => {
1284 ctx.error = Some(e.to_string());
1285 faucet_lineage::EventType::Fail
1286 }
1287 Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1288 Ok(_) => faucet_lineage::EventType::Complete,
1289 };
1290 em.emit(ev, &ctx).await;
1291 }
1292
1293 let is_notifiable_root = matches!(node.role, NodeRole::Root)
1303 && !opts.dry_run
1304 && opts.limit.is_none()
1305 && opts.shard.is_none()
1306 && !cancel.is_cancelled();
1307
1308 #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1309 let sla_violations = if let Some(spec) = &opts.sla
1310 && is_notifiable_root
1311 {
1312 let outcome = match &result {
1313 Ok(r) => crate::sla::RunOutcome::Success {
1314 rows: r.records_written as u64,
1315 },
1316 Err(_) => crate::sla::RunOutcome::Failure,
1317 };
1318 crate::sla::evaluate_post_run(
1319 spec,
1320 sla_store.as_ref(),
1321 state_key,
1322 &obs_labels.pipeline,
1323 &obs_labels.row,
1324 outcome,
1325 chrono::Utc::now().timestamp(),
1326 )
1327 .await
1328 } else {
1329 Vec::new()
1330 };
1331
1332 #[cfg(feature = "notify")]
1337 if let Some(notifier) = &opts.notifier
1338 && is_notifiable_root
1339 {
1340 use crate::notify::NotifyEvent;
1341 let pipeline = obs_labels.pipeline.to_string();
1342 let row = obs_labels.row.to_string();
1343 match &result {
1344 Ok(r) => {
1345 notifier
1346 .emit(NotifyEvent::run_success(
1347 pipeline.clone(),
1348 row.clone(),
1349 r.records_written as u64,
1350 ))
1351 .await;
1352 if let Some(dlq) = &r.dlq
1353 && dlq.records_dlq > 0
1354 {
1355 notifier
1356 .emit(NotifyEvent::dlq_threshold(
1357 pipeline.clone(),
1358 row.clone(),
1359 dlq.records_dlq as u64,
1360 ))
1361 .await;
1362 }
1363 }
1364 Err(e) => {
1365 notifier.emit(error_event(&pipeline, &row, e)).await;
1366 }
1367 }
1368 for v in &sla_violations {
1369 notifier
1370 .emit(NotifyEvent::sla_breach(
1371 pipeline.clone(),
1372 row.clone(),
1373 v.kind(),
1374 v.to_string(),
1375 ))
1376 .await;
1377 }
1378 }
1379
1380 #[cfg(feature = "catalog")]
1385 if let Some(handle) = &opts.catalog
1386 && catalog_active
1387 && !cancel.is_cancelled()
1388 && let Ok(pipeline_result) = &result
1389 {
1390 use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1391 use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1392
1393 let records_written = pipeline_result.records_written as u64;
1394 let source_schema = in_sample
1395 .as_ref()
1396 .and_then(|s| schema_from_samples(&s.samples()));
1397 let sink_schema = out_sample
1398 .as_ref()
1399 .and_then(|s| schema_from_samples(&s.samples()));
1400 let records_read = in_sample
1403 .as_ref()
1404 .map(|s| s.count())
1405 .unwrap_or(records_written);
1406 let records_out = out_sample
1407 .as_ref()
1408 .map(|s| s.count())
1409 .unwrap_or(records_written);
1410
1411 let column_lineage = in_sample.as_ref().and_then(|s| {
1414 let input_fields: Vec<String> = s
1415 .inferred_schema()
1416 .fields
1417 .iter()
1418 .map(|(n, _)| n.clone())
1419 .collect();
1420 #[cfg(feature = "masking")]
1421 let has_masking = node.masking.is_some();
1422 #[cfg(not(feature = "masking"))]
1423 let has_masking = false;
1424 let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1425 faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1426 let fields: serde_json::Map<String, Value> = cl
1429 .edges
1430 .iter()
1431 .map(|(out, ins)| {
1432 (
1433 out.clone(),
1434 Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1435 )
1436 })
1437 .collect();
1438 serde_json::json!({ "fields": fields })
1439 })
1440 });
1441
1442 let update = CatalogUpdate {
1443 run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1444 pipeline: obs_labels.pipeline.to_string(),
1445 row: obs_labels.row.to_string(),
1446 recorded_at: chrono::Utc::now(),
1447 source: DatasetObservation {
1448 uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1449 kind: node.source.kind.clone(),
1450 role: DatasetRole::Source,
1451 schema: source_schema,
1452 records: records_read,
1453 },
1454 sink: DatasetObservation {
1455 uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1456 kind: node.sink.kind.clone(),
1457 role: DatasetRole::Sink,
1458 schema: sink_schema,
1459 records: records_out,
1460 },
1461 column_lineage,
1462 };
1463 crate::catalog::record(handle, &update).await;
1464 }
1465
1466 let result = result?;
1467
1468 #[cfg(feature = "lineage")]
1472 let records_read = in_sample.as_ref().map(|s| s.count());
1473 #[cfg(not(feature = "lineage"))]
1474 let records_read: Option<u64> = None;
1475 let stats = PipelineStats {
1476 records_written: result.records_written,
1477 records_read,
1478 dlq_count: result
1479 .dlq
1480 .as_ref()
1481 .map(|d| d.records_dlq as u64)
1482 .unwrap_or(0),
1483 bookmark: result.bookmark.clone(),
1484 };
1485
1486 let captured = if capture.is_some() {
1487 std::mem::take(&mut *captured.lock().await)
1488 } else {
1489 Vec::new()
1490 };
1491 Ok((captured, stats))
1492}
1493
1494async fn build_state_for_node(
1495 node: &ExpandedNode,
1496 state_path_override: Option<&Path>,
1497) -> CliResult<Option<Arc<dyn StateStore>>> {
1498 match (&node.state, state_path_override) {
1499 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1500 (None, Some(path)) => Ok(Some(state_from_override(path))),
1501 (Some(spec), Some(path)) => {
1502 if spec.kind == "file" {
1503 Ok(Some(state_from_override(path)))
1504 } else {
1505 tracing::warn!(
1506 state = %spec.kind,
1507 "--state-path is only meaningful for the 'file' backend; ignoring override"
1508 );
1509 Ok(Some(build_state_store(spec).await?))
1510 }
1511 }
1512 (None, None) => Ok(None),
1513 }
1514}
1515
1516fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1517 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1518}
1519
1520pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1523 let sink = build_sink(
1526 &spec.sink.kind,
1527 spec.sink.config.clone(),
1528 &AuthCatalog::new(),
1529 )
1530 .await?;
1531 Ok(DlqConfig {
1532 sink: Arc::from(sink),
1533 on_batch_error: match spec.on_batch_error {
1534 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1535 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1536 },
1537 max_failures_per_page: spec.max_failures_per_page,
1538 max_failures_total: spec.max_failures_total,
1539 include_original_payload: spec.include_original_payload,
1540 })
1541}
1542
1543#[cfg(feature = "notify")]
1547fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1548 use crate::notify::NotifyEvent;
1549 match err {
1550 FaucetError::CircuitOpen { failures, cooldown } => {
1551 NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1552 }
1553 FaucetError::ContractViolation { message, .. } => {
1554 NotifyEvent::contract_abort(pipeline, row, message.clone())
1555 }
1556 other => {
1557 NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1558 }
1559 }
1560}
1561
1562#[cfg(feature = "notify")]
1566fn faucet_error_kind(err: &FaucetError) -> &'static str {
1567 match err {
1568 FaucetError::Config(_) => "config",
1569 FaucetError::Source(_) => "source",
1570 FaucetError::Sink(_) => "sink",
1571 FaucetError::State(_) => "state",
1572 FaucetError::QualityFailure { .. } => "quality",
1573 FaucetError::SchemaDrift { .. } => "schema_drift",
1574 _ => "error",
1575 }
1576}
1577
1578fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1587 fn walk(value: &Value, owner: &str) -> CliResult<()> {
1588 match value {
1589 Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1590 "the {owner} config references a `${{backfill.*}}` token, which only `faucet backfill` resolves — run this config via `faucet backfill --from … --to …`, or remove the token"
1591 ))),
1592 Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1593 Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1594 _ => Ok(()),
1595 }
1596 }
1597 walk(value, owner)
1598}
1599
1600pub(crate) fn resolve_now_inplace(
1601 value: &mut Value,
1602 clock: DateTime<FixedOffset>,
1603) -> CliResult<()> {
1604 match value {
1605 Value::String(s) => {
1606 *s = crate::interpolate::resolve_now(s, clock)?;
1607 Ok(())
1608 }
1609 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1610 Value::Object(m) => m
1611 .values_mut()
1612 .try_for_each(|v| resolve_now_inplace(v, clock)),
1613 _ => Ok(()),
1614 }
1615}
1616
1617fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1621 match value {
1622 Value::String(s) => {
1623 let resolved = interpolate_record(s, ctx)?;
1624 *s = resolved;
1625 Ok(())
1626 }
1627 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1628 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1629 _ => Ok(()),
1630 }
1631}
1632
1633struct ReadOnlyStateStore {
1646 inner: Arc<dyn StateStore>,
1647}
1648
1649#[async_trait]
1650impl StateStore for ReadOnlyStateStore {
1651 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1652 self.inner.get(key).await
1653 }
1654 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1655 Ok(())
1656 }
1657 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1658 Ok(())
1659 }
1660}
1661
1662struct StateKeyOverride {
1666 inner: Box<dyn Source>,
1667 key: String,
1668}
1669
1670#[async_trait]
1671impl Source for StateKeyOverride {
1672 async fn fetch_with_context(
1673 &self,
1674 ctx: &HashMap<String, Value>,
1675 ) -> Result<Vec<Value>, FaucetError> {
1676 self.inner.fetch_with_context(ctx).await
1677 }
1678 async fn fetch_with_context_incremental(
1679 &self,
1680 ctx: &HashMap<String, Value>,
1681 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1682 self.inner.fetch_with_context_incremental(ctx).await
1683 }
1684 fn stream_pages<'a>(
1690 &'a self,
1691 ctx: &'a HashMap<String, Value>,
1692 batch_size: usize,
1693 ) -> std::pin::Pin<
1694 Box<
1695 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1696 + Send
1697 + 'a,
1698 >,
1699 > {
1700 self.inner.stream_pages(ctx, batch_size)
1701 }
1702 fn connector_name(&self) -> &'static str {
1703 self.inner.connector_name()
1704 }
1705 fn dataset_uri(&self) -> String {
1706 self.inner.dataset_uri()
1707 }
1708 fn state_key(&self) -> Option<String> {
1709 Some(self.key.clone())
1710 }
1711 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1712 self.inner.apply_start_bookmark(bookmark).await
1713 }
1714 fn supports_exactly_once(&self) -> bool {
1715 self.inner.supports_exactly_once()
1716 }
1717 fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1718 self.inner.replay_guarantee()
1719 }
1720 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1721 self.inner.capture_resume_position().await
1722 }
1723}
1724
1725struct CapturingSink {
1729 inner: Box<dyn Sink>,
1730 captured: Arc<Mutex<Vec<Value>>>,
1731 projection: Arc<Projection>,
1732}
1733
1734impl CapturingSink {
1735 fn wrap(
1736 inner: Box<dyn Sink>,
1737 captured: Arc<Mutex<Vec<Value>>>,
1738 projection: Arc<Projection>,
1739 ) -> Self {
1740 Self {
1741 inner,
1742 captured,
1743 projection,
1744 }
1745 }
1746}
1747
1748#[async_trait]
1749impl Sink for CapturingSink {
1750 fn connector_name(&self) -> &'static str {
1751 self.inner.connector_name()
1752 }
1753 fn dataset_uri(&self) -> String {
1754 self.inner.dataset_uri()
1755 }
1756 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1757 let written = self.inner.write_batch(records).await?;
1758 let n = written.min(records.len());
1761 let mut buf = self.captured.lock().await;
1762 buf.extend(
1763 records
1764 .iter()
1765 .take(n)
1766 .map(|r| project_record(r, &self.projection)),
1767 );
1768 Ok(written)
1769 }
1770 async fn flush(&self) -> Result<(), FaucetError> {
1771 self.inner.flush().await
1772 }
1773 fn supports_idempotent_writes(&self) -> bool {
1777 self.inner.supports_idempotent_writes()
1778 }
1779 fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1780 self.inner.sink_guarantee()
1781 }
1782 fn dedups_by_key(&self) -> bool {
1783 self.inner.dedups_by_key()
1784 }
1785 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1786 self.inner.supported_write_modes()
1787 }
1788 async fn write_batch_idempotent(
1789 &self,
1790 records: &[Value],
1791 scope: &str,
1792 token: &str,
1793 ) -> Result<usize, FaucetError> {
1794 let written = self
1795 .inner
1796 .write_batch_idempotent(records, scope, token)
1797 .await?;
1798 let n = written.min(records.len());
1799 let mut buf = self.captured.lock().await;
1800 buf.extend(
1801 records
1802 .iter()
1803 .take(n)
1804 .map(|r| project_record(r, &self.projection)),
1805 );
1806 Ok(written)
1807 }
1808 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1809 self.inner.last_committed_token(scope).await
1810 }
1811 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1812 self.inner.current_schema().await
1813 }
1814 fn supports_schema_evolution(&self) -> bool {
1815 self.inner.supports_schema_evolution()
1816 }
1817 async fn evolve_schema(
1818 &self,
1819 evolution: &faucet_core::SchemaEvolution,
1820 ) -> Result<(), FaucetError> {
1821 self.inner.evolve_schema(evolution).await
1822 }
1823}
1824
1825struct LimitedSink {
1828 inner: Box<dyn Sink>,
1829 remaining: AtomicUsize,
1830}
1831
1832impl LimitedSink {
1833 fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1834 Self {
1835 inner,
1836 remaining: AtomicUsize::new(cap),
1837 }
1838 }
1839}
1840
1841#[async_trait]
1842impl Sink for LimitedSink {
1843 fn connector_name(&self) -> &'static str {
1844 self.inner.connector_name()
1845 }
1846 fn dataset_uri(&self) -> String {
1847 self.inner.dataset_uri()
1848 }
1849 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1850 let remaining = self.remaining.load(Ordering::Relaxed);
1851 if remaining == 0 {
1852 return Ok(0);
1853 }
1854 let take = remaining.min(records.len());
1855 let slice = &records[..take];
1856 let written = self.inner.write_batch(slice).await?;
1857 self.remaining
1858 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1859 Ok(written)
1860 }
1861 async fn flush(&self) -> Result<(), FaucetError> {
1862 self.inner.flush().await
1863 }
1864}
1865
1866struct CountingSink {
1869 seen: AtomicUsize,
1870}
1871
1872impl CountingSink {
1873 fn new() -> Self {
1874 Self {
1875 seen: AtomicUsize::new(0),
1876 }
1877 }
1878}
1879
1880#[async_trait]
1881impl Sink for CountingSink {
1882 fn connector_name(&self) -> &'static str {
1883 "dry-run"
1884 }
1885 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1886 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1887 Ok(records.len())
1888 }
1889}
1890
1891fn value_to_string_brief(v: &Value) -> String {
1894 match v {
1895 Value::String(s) => s.clone(),
1896 other => other.to_string(),
1897 }
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902 use super::*;
1903 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1904 use crate::expand::expand;
1905 use serde_json::json;
1906
1907 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1908 PipelineConfig {
1909 version: 1,
1910 name: Some("test".into()),
1911 vars: None,
1912 auth: None,
1913 pipeline: PipelineSpec {
1914 source: Some(ConnectorSpec {
1915 kind: "csv".into(),
1916 config: json!({"path": input.to_str().unwrap()}),
1917 transforms: None,
1918 inherit_transforms: true,
1919 status: None,
1920 tags: Vec::new(),
1921 }),
1922 sink: Some(ConnectorSpec {
1923 kind: "jsonl".into(),
1924 config: json!({"path": output.to_str().unwrap()}),
1925 transforms: None,
1926 inherit_transforms: true,
1927 status: None,
1928 tags: Vec::new(),
1929 }),
1930 sources: Default::default(),
1931 sinks: Default::default(),
1932 transforms: Vec::new(),
1933 state: None,
1934 dlq: None,
1935 #[cfg(feature = "quality")]
1936 quality: None,
1937 #[cfg(feature = "contract")]
1938 contract: None,
1939 #[cfg(feature = "masking")]
1940 masking: None,
1941 schema: None,
1942 },
1943 matrix: Vec::new(),
1944 execution: None,
1945 selection: None,
1946 observability: None,
1947 delivery: faucet_core::DeliveryMode::default(),
1948 resilience: None,
1949 sla: None,
1950 shard: None,
1951 replication: None,
1952 backfill: None,
1953 #[cfg(feature = "schedule")]
1954 schedule: None,
1955 #[cfg(feature = "lineage")]
1956 lineage: None,
1957 #[cfg(feature = "catalog")]
1958 catalog: None,
1959 #[cfg(feature = "notify")]
1960 notifications: Vec::new(),
1961 }
1962 }
1963
1964 #[tokio::test]
1965 async fn empty_matrix_runs_pipeline_once() {
1966 let dir = tempfile::tempdir().unwrap();
1967 let input = dir.path().join("in.csv");
1968 let output = dir.path().join("out.jsonl");
1969 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1970 let cfg = cfg_csv_to_jsonl(&input, &output);
1971 let nodes = expand(&cfg).unwrap();
1972 let summary = run_expanded(
1973 nodes,
1974 ExecuteOptions {
1975 pipeline_name: "t".into(),
1976 execution: None,
1977 dry_run: false,
1978 limit: None,
1979 state_path_override: None,
1980 shard: None,
1981 auth: Default::default(),
1982 clock: chrono::Utc::now().fixed_offset(),
1983 cancel: None,
1984 resilience: None,
1985 sla: None,
1986 #[cfg(feature = "lineage")]
1987 lineage: None,
1988 #[cfg(feature = "lineage")]
1989 lineage_cfg: None,
1990 #[cfg(feature = "notify")]
1991 notifier: None,
1992 #[cfg(feature = "catalog")]
1993 catalog: None,
1994 },
1995 )
1996 .await
1997 .unwrap();
1998 assert_eq!(summary.invocations.len(), 1);
1999 assert_eq!(summary.invocations[0].records_written, 2);
2000 assert!(!summary.had_failures());
2001 let body = std::fs::read_to_string(&output).unwrap();
2002 assert_eq!(body.lines().count(), 2);
2003 }
2004
2005 #[cfg(feature = "catalog")]
2007 fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2008 let mut o = opts(name);
2009 o.catalog = Some(handle);
2010 o
2011 }
2012
2013 #[cfg(feature = "catalog")]
2014 #[tokio::test]
2015 async fn catalog_records_schema_timeline_across_two_runs() {
2016 use crate::catalog::CatalogHandle;
2020 use crate::serve::history::RunHistory as _;
2021 use crate::serve::history::catalog::{self, CatalogListFilter};
2022 use crate::serve::history::memory::MemoryHistory;
2023
2024 let dir = tempfile::tempdir().unwrap();
2025 let input = dir.path().join("in.csv");
2026 let output = dir.path().join("out.jsonl");
2027 let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2028 let handle = CatalogHandle {
2029 store: store.clone(),
2030 run_id: None,
2031 sample_records: 10,
2032 };
2033
2034 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2035 let cfg = cfg_csv_to_jsonl(&input, &output);
2036 let nodes = expand(&cfg).unwrap();
2037 let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2038 .await
2039 .unwrap();
2040 assert!(!summary.had_failures());
2041
2042 std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2044 let nodes = expand(&cfg).unwrap();
2045 let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2046 .await
2047 .unwrap();
2048 assert!(!summary.had_failures());
2049
2050 let page = store
2052 .catalog_list_datasets(&CatalogListFilter {
2053 limit: 10,
2054 ..Default::default()
2055 })
2056 .await
2057 .unwrap();
2058 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2059 for ds in &page.datasets {
2060 let detail = store
2061 .catalog_get_dataset(&ds.id)
2062 .await
2063 .unwrap()
2064 .expect("dataset detail");
2065 assert_eq!(detail.dataset.runs, 2);
2066 assert_eq!(
2067 detail.schema_timeline.len(),
2068 2,
2069 "exactly two timeline entries for {}",
2070 ds.uri
2071 );
2072 assert!(detail.schema_timeline[0].diff.is_none());
2073 let diff = detail.schema_timeline[1]
2074 .diff
2075 .as_ref()
2076 .expect("second version carries a diff");
2077 assert!(
2078 diff["added"]
2079 .as_array()
2080 .unwrap()
2081 .iter()
2082 .any(|c| c["column"] == "email"),
2083 "diff must show the added email column: {diff}"
2084 );
2085 assert_eq!(detail.stats.len(), 2, "one volume point per run");
2086 }
2087 let edges = store.catalog_lineage(None, 5).await.unwrap();
2089 assert_eq!(edges.len(), 1);
2090 assert_eq!(edges[0].runs, 2);
2091 assert_eq!(edges[0].last_records, 2);
2092 assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2093 }
2094
2095 #[cfg(feature = "catalog")]
2098 struct FailingCatalogStore;
2099
2100 #[cfg(feature = "catalog")]
2101 #[async_trait]
2102 impl crate::serve::history::RunHistory for FailingCatalogStore {
2103 async fn claim_idempotency(
2104 &self,
2105 _: &str,
2106 _: &str,
2107 _: &str,
2108 _: std::time::Duration,
2109 ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2110 Err(crate::serve::history::HistoryError::Backend("down".into()))
2111 }
2112 async fn upsert(
2113 &self,
2114 _: &crate::serve::history::RunRecord,
2115 ) -> Result<(), crate::serve::history::HistoryError> {
2116 Err(crate::serve::history::HistoryError::Backend("down".into()))
2117 }
2118 async fn get(
2119 &self,
2120 _: &str,
2121 ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2122 {
2123 Err(crate::serve::history::HistoryError::Backend("down".into()))
2124 }
2125 async fn list(
2126 &self,
2127 _: &crate::serve::history::ListFilter,
2128 ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2129 Err(crate::serve::history::HistoryError::Backend("down".into()))
2130 }
2131 async fn delete(
2132 &self,
2133 _: &str,
2134 ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2135 {
2136 Err(crate::serve::history::HistoryError::Backend("down".into()))
2137 }
2138 async fn purge_expired(
2139 &self,
2140 _: std::time::Duration,
2141 ) -> Result<usize, crate::serve::history::HistoryError> {
2142 Err(crate::serve::history::HistoryError::Backend("down".into()))
2143 }
2144 async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2145 Err(crate::serve::history::HistoryError::Backend("down".into()))
2146 }
2147 async fn catalog_record(
2148 &self,
2149 _: &crate::serve::history::catalog::CatalogUpdate,
2150 ) -> Result<(), crate::serve::history::HistoryError> {
2151 Err(crate::serve::history::HistoryError::Backend(
2152 "catalog write refused".into(),
2153 ))
2154 }
2155 fn degraded(&self) -> bool {
2156 false
2157 }
2158 }
2159
2160 #[cfg(feature = "catalog")]
2161 #[tokio::test]
2162 async fn catalog_write_failure_never_fails_the_run() {
2163 use crate::catalog::CatalogHandle;
2166 let dir = tempfile::tempdir().unwrap();
2167 let input = dir.path().join("in.csv");
2168 let output = dir.path().join("out.jsonl");
2169 std::fs::write(&input, "name\nalice\n").unwrap();
2170 let cfg = cfg_csv_to_jsonl(&input, &output);
2171 let nodes = expand(&cfg).unwrap();
2172 let handle = CatalogHandle {
2173 store: Arc::new(FailingCatalogStore),
2174 run_id: None,
2175 sample_records: 10,
2176 };
2177 let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2178 .await
2179 .unwrap();
2180 assert!(
2181 !summary.had_failures(),
2182 "catalog failure must not fail the run"
2183 );
2184 assert_eq!(summary.invocations[0].records_written, 1);
2185 assert_eq!(
2186 std::fs::read_to_string(&output).unwrap().lines().count(),
2187 1,
2188 "sink output written despite the catalog error"
2189 );
2190 }
2191
2192 #[tokio::test]
2193 async fn matrix_two_independent_roots_both_run() {
2194 let dir = tempfile::tempdir().unwrap();
2196 let csv_a = dir.path().join("a.csv");
2197 let csv_b = dir.path().join("b.csv");
2198 let out_a = dir.path().join("a.jsonl");
2199 let out_b = dir.path().join("b.jsonl");
2200 std::fs::write(&csv_a, "name\nalice\n").unwrap();
2201 std::fs::write(&csv_b, "name\nbob\n").unwrap();
2202
2203 let yaml = format!(
2204 r#"version: 1
2205pipeline:
2206 source: {{ type: csv, config: {{ path: {a} }} }}
2207 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
2208matrix:
2209 - id: rowA
2210 - id: rowB
2211 source: {{ config: {{ path: {b} }} }}
2212 sink: {{ config: {{ path: {out_b} }} }}
2213"#,
2214 a = csv_a.display(),
2215 b = csv_b.display(),
2216 out_a = out_a.display(),
2217 out_b = out_b.display(),
2218 );
2219 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2220 let nodes = expand(&cfg).unwrap();
2221 let summary = run_expanded(
2222 nodes,
2223 ExecuteOptions {
2224 pipeline_name: "matrix".into(),
2225 execution: None,
2226 dry_run: false,
2227 limit: None,
2228 state_path_override: None,
2229 shard: None,
2230 auth: Default::default(),
2231 clock: chrono::Utc::now().fixed_offset(),
2232 cancel: None,
2233 resilience: None,
2234 sla: None,
2235 #[cfg(feature = "lineage")]
2236 lineage: None,
2237 #[cfg(feature = "lineage")]
2238 lineage_cfg: None,
2239 #[cfg(feature = "notify")]
2240 notifier: None,
2241 #[cfg(feature = "catalog")]
2242 catalog: None,
2243 },
2244 )
2245 .await
2246 .unwrap();
2247 assert_eq!(summary.invocations.len(), 2);
2248 assert!(out_a.exists());
2249 assert!(out_b.exists());
2250 }
2251
2252 #[tokio::test]
2253 async fn dag_child_fans_out_per_parent_record() {
2254 let dir = tempfile::tempdir().unwrap();
2257 let parent_csv = dir.path().join("parents.csv");
2258 let child_csv = dir.path().join("child.csv");
2259 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2260 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2261 let parent_out = dir.path().join("parents.jsonl");
2262 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2263
2264 let yaml = format!(
2265 r#"version: 1
2266pipeline:
2267 source: {{ type: csv, config: {{ path: {parent} }} }}
2268 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2269matrix:
2270 - id: parents
2271 - id: child
2272 parent: parents
2273 source: {{ config: {{ path: {child} }} }}
2274 sink: {{ config: {{ path: "{child_out}" }} }}
2275"#,
2276 parent = parent_csv.display(),
2277 parent_out = parent_out.display(),
2278 child = child_csv.display(),
2279 child_out = child_out_pattern.display(),
2280 );
2281 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2282 let nodes = expand(&cfg).unwrap();
2283 let summary = run_expanded(
2284 nodes,
2285 ExecuteOptions {
2286 pipeline_name: "dagtest".into(),
2287 execution: None,
2288 dry_run: false,
2289 limit: None,
2290 state_path_override: None,
2291 shard: None,
2292 auth: Default::default(),
2293 clock: chrono::Utc::now().fixed_offset(),
2294 cancel: None,
2295 resilience: None,
2296 sla: None,
2297 #[cfg(feature = "lineage")]
2298 lineage: None,
2299 #[cfg(feature = "lineage")]
2300 lineage_cfg: None,
2301 #[cfg(feature = "notify")]
2302 notifier: None,
2303 #[cfg(feature = "catalog")]
2304 catalog: None,
2305 },
2306 )
2307 .await
2308 .unwrap();
2309
2310 assert_eq!(summary.invocations.len(), 3);
2312 assert!(!summary.had_failures(), "{:?}", summary);
2313 assert!(dir.path().join("child-1.jsonl").exists());
2314 assert!(dir.path().join("child-2.jsonl").exists());
2315 }
2316
2317 #[tokio::test]
2318 async fn depends_on_root_runs_after_dependency() {
2319 let dir = tempfile::tempdir().unwrap();
2323 let input = dir.path().join("in.csv");
2324 let mid = dir.path().join("mid.csv");
2325 let out = dir.path().join("out.jsonl");
2326 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2327
2328 let yaml = format!(
2329 r#"version: 1
2330pipeline:
2331 source: {{ type: csv, config: {{ path: {input} }} }}
2332 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2333matrix:
2334 - id: stage
2335 sink: {{ type: csv, config: {{ path: {mid} }} }}
2336 - id: load
2337 depends_on: [stage]
2338 source: {{ config: {{ path: {mid} }} }}
2339"#,
2340 input = input.display(),
2341 mid = mid.display(),
2342 out = out.display(),
2343 );
2344 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2345 let nodes = expand(&cfg).unwrap();
2346 let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2347 assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2348 assert!(!summary.had_failures(), "{summary:?}");
2349 let load = summary
2350 .invocations
2351 .iter()
2352 .find(|i| i.row_id == "load")
2353 .unwrap();
2354 assert_eq!(load.records_written, 2);
2355 let written = std::fs::read_to_string(&out).unwrap();
2356 assert_eq!(written.lines().count(), 2);
2357 }
2358
2359 #[tokio::test]
2360 async fn diamond_dependency_waits_for_all_prerequisites() {
2361 let dir = tempfile::tempdir().unwrap();
2364 let input = dir.path().join("in.csv");
2365 let mid_a = dir.path().join("mid_a.csv");
2366 let mid_b = dir.path().join("mid_b.csv");
2367 let out = dir.path().join("out.jsonl");
2368 std::fs::write(&input, "name\nalice\n").unwrap();
2369
2370 let yaml = format!(
2371 r#"version: 1
2372pipeline:
2373 source: {{ type: csv, config: {{ path: {input} }} }}
2374 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2375matrix:
2376 - id: a
2377 sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2378 - id: b
2379 sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2380 - id: c
2381 depends_on: [a, b]
2382 source: {{ config: {{ path: {mid_a} }} }}
2383"#,
2384 input = input.display(),
2385 mid_a = mid_a.display(),
2386 mid_b = mid_b.display(),
2387 out = out.display(),
2388 );
2389 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2390 let nodes = expand(&cfg).unwrap();
2391 let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2392 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2393 assert!(!summary.had_failures(), "{summary:?}");
2394 assert!(mid_b.exists(), "b must have run before c became ready");
2395 assert!(out.exists());
2396 }
2397
2398 #[tokio::test]
2399 async fn failed_dependency_skips_dependent() {
2400 let dir = tempfile::tempdir().unwrap();
2403 let good_input = dir.path().join("good.csv");
2404 let out = dir.path().join("out.jsonl");
2405 std::fs::write(&good_input, "name\nalice\n").unwrap();
2406
2407 let yaml = format!(
2408 r#"version: 1
2409pipeline:
2410 source: {{ type: csv, config: {{ path: {good} }} }}
2411 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2412matrix:
2413 - id: stage
2414 source: {{ config: {{ path: {missing} }} }}
2415 - id: load
2416 depends_on: [stage]
2417"#,
2418 good = good_input.display(),
2419 missing = dir.path().join("nonexistent.csv").display(),
2420 out = out.display(),
2421 );
2422 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2423 let nodes = expand(&cfg).unwrap();
2424 let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2425 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2426 assert_eq!(summary.invocations[0].row_id, "stage");
2427 assert!(summary.invocations[0].error.is_some());
2428 assert!(
2429 !out.exists(),
2430 "dependent row must not run after its dependency failed"
2431 );
2432 }
2433
2434 #[tokio::test]
2435 async fn dependency_on_skipped_row_cascades() {
2436 let dir = tempfile::tempdir().unwrap();
2439 let good_input = dir.path().join("good.csv");
2440 let out = dir.path().join("q.jsonl");
2441 std::fs::write(&good_input, "id\n1\n").unwrap();
2442
2443 let yaml = format!(
2444 r#"version: 1
2445pipeline:
2446 source: {{ type: csv, config: {{ path: {good} }} }}
2447 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2448matrix:
2449 - id: p
2450 source: {{ config: {{ path: {missing} }} }}
2451 - id: c
2452 parent: p
2453 - id: q
2454 depends_on: [c]
2455"#,
2456 good = good_input.display(),
2457 missing = dir.path().join("nonexistent.csv").display(),
2458 out = out.display(),
2459 );
2460 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2461 let nodes = expand(&cfg).unwrap();
2462 let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2463 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2464 assert_eq!(summary.invocations[0].row_id, "p");
2465 assert!(summary.invocations[0].error.is_some());
2466 assert!(
2467 !out.exists(),
2468 "q must be skipped when its dependency was skipped"
2469 );
2470 }
2471
2472 #[tokio::test]
2473 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2474 let dir = tempfile::tempdir().unwrap();
2486 let good_csv = dir.path().join("good.csv");
2487 std::fs::write(&good_csv, "x\n1\n").unwrap();
2488 let good_out = dir.path().join("good.jsonl");
2489 let bad_sink_dir = dir.path().to_path_buf();
2490
2491 let yaml = format!(
2492 r#"version: 1
2493pipeline:
2494 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2495 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2496matrix:
2497 - id: bad
2498 sink: {{ config: {{ path: {bad_dir} }} }}
2499 - id: good
2500execution:
2501 max_concurrent: 1
2502 on_error: stop
2503"#,
2504 good_csv = good_csv.display(),
2505 good_out = good_out.display(),
2506 bad_dir = bad_sink_dir.display(),
2507 );
2508 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2509 let nodes = expand(&cfg).unwrap();
2510 let summary = run_expanded(
2511 nodes,
2512 ExecuteOptions {
2513 pipeline_name: "stoptest".into(),
2514 execution: cfg.execution.clone(),
2515 dry_run: false,
2516 limit: None,
2517 state_path_override: None,
2518 shard: None,
2519 auth: Default::default(),
2520 clock: chrono::Utc::now().fixed_offset(),
2521 cancel: None,
2522 resilience: None,
2523 sla: None,
2524 #[cfg(feature = "lineage")]
2525 lineage: None,
2526 #[cfg(feature = "lineage")]
2527 lineage_cfg: None,
2528 #[cfg(feature = "notify")]
2529 notifier: None,
2530 #[cfg(feature = "catalog")]
2531 catalog: None,
2532 },
2533 )
2534 .await
2535 .unwrap();
2536
2537 assert!(summary.had_failures(), "the failing root must be reported");
2539
2540 let bad: Vec<_> = summary
2542 .invocations
2543 .iter()
2544 .filter(|o| o.row_id == "bad")
2545 .collect();
2546 assert_eq!(bad.len(), 1, "bad must run exactly once");
2547 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2548
2549 assert!(
2551 summary.invocations.len() <= 2,
2552 "at most the two roots may run, got {:?}",
2553 summary.invocations
2554 );
2555
2556 let good_wrote = summary
2563 .invocations
2564 .iter()
2565 .find(|o| o.row_id == "good" && o.error.is_none())
2566 .map(|o| o.records_written)
2567 .unwrap_or(0);
2568 if good_wrote > 0 {
2569 assert!(
2570 good_out.exists(),
2571 "a good that wrote records must have produced its output file"
2572 );
2573 }
2574 }
2575
2576 #[tokio::test]
2577 async fn invalid_pipeline_name_with_state_errors_up_front() {
2578 let dir = tempfile::tempdir().unwrap();
2582 let input = dir.path().join("in.csv");
2583 let output = dir.path().join("out.jsonl");
2584 std::fs::write(&input, "name\nalice\n").unwrap();
2585 let yaml = format!(
2586 r#"version: 1
2587pipeline:
2588 source: {{ type: csv, config: {{ path: {input} }} }}
2589 sink: {{ type: jsonl, config: {{ path: {output} }} }}
2590 state: {{ type: memory }}
2591"#,
2592 input = input.display(),
2593 output = output.display(),
2594 );
2595 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2596 let nodes = expand(&cfg).unwrap();
2597 let err = run_expanded(
2598 nodes,
2599 ExecuteOptions {
2600 pipeline_name: "bad name".into(), execution: None,
2602 dry_run: false,
2603 limit: None,
2604 state_path_override: None,
2605 shard: None,
2606 auth: Default::default(),
2607 clock: chrono::Utc::now().fixed_offset(),
2608 cancel: None,
2609 resilience: None,
2610 sla: None,
2611 #[cfg(feature = "lineage")]
2612 lineage: None,
2613 #[cfg(feature = "lineage")]
2614 lineage_cfg: None,
2615 #[cfg(feature = "notify")]
2616 notifier: None,
2617 #[cfg(feature = "catalog")]
2618 catalog: None,
2619 },
2620 )
2621 .await
2622 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2623 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2624 }
2625
2626 #[tokio::test]
2627 async fn invalid_parent_key_value_with_state_errors_up_front() {
2628 let dir = tempfile::tempdir().unwrap();
2631 let parent_csv = dir.path().join("parents.csv");
2632 let child_csv = dir.path().join("child.csv");
2633 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2635 std::fs::write(&child_csv, "x\nA\n").unwrap();
2636 let parent_out = dir.path().join("parents.jsonl");
2637 let child_out = dir.path().join("child.jsonl");
2638 let yaml = format!(
2639 r#"version: 1
2640pipeline:
2641 source: {{ type: csv, config: {{ path: {parent} }} }}
2642 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2643 state: {{ type: memory }}
2644matrix:
2645 - id: parents
2646 - id: child
2647 parent: parents
2648 source: {{ config: {{ path: {child} }} }}
2649 sink: {{ config: {{ path: {child_out} }} }}
2650"#,
2651 parent = parent_csv.display(),
2652 parent_out = parent_out.display(),
2653 child = child_csv.display(),
2654 child_out = child_out.display(),
2655 );
2656 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2657 let nodes = expand(&cfg).unwrap();
2658 let err = run_expanded(
2659 nodes,
2660 ExecuteOptions {
2661 pipeline_name: "ok".into(),
2662 execution: None,
2663 dry_run: false,
2664 limit: None,
2665 state_path_override: None,
2666 shard: None,
2667 auth: Default::default(),
2668 clock: chrono::Utc::now().fixed_offset(),
2669 cancel: None,
2670 resilience: None,
2671 sla: None,
2672 #[cfg(feature = "lineage")]
2673 lineage: None,
2674 #[cfg(feature = "lineage")]
2675 lineage_cfg: None,
2676 #[cfg(feature = "notify")]
2677 notifier: None,
2678 #[cfg(feature = "catalog")]
2679 catalog: None,
2680 },
2681 )
2682 .await
2683 .expect_err(
2684 "an illegal parent-key value must be rejected up front when state is configured",
2685 );
2686 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2687 }
2688
2689 #[tokio::test]
2690 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2691 let dir = tempfile::tempdir().unwrap();
2700 let bad_sink_dir = dir.path().to_path_buf();
2701 let good_csv = dir.path().join("good.csv");
2704 std::fs::write(&good_csv, "x\n1\n").unwrap();
2705 let yaml = format!(
2711 r#"version: 1
2712pipeline:
2713 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2714 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2715matrix:
2716 - id: bad
2717 - id: good_a
2718 - id: good_b
2719execution:
2720 max_concurrent: 3
2721 on_error: stop
2722"#,
2723 good_csv = good_csv.display(),
2724 bad_dir = bad_sink_dir.display(),
2725 );
2726 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2727 let nodes = expand(&cfg).unwrap();
2728 let summary = run_expanded(
2729 nodes,
2730 ExecuteOptions {
2731 pipeline_name: "stop_parallel".into(),
2732 execution: cfg.execution.clone(),
2733 dry_run: false,
2734 limit: None,
2735 state_path_override: None,
2736 shard: None,
2737 auth: Default::default(),
2738 clock: chrono::Utc::now().fixed_offset(),
2739 cancel: None,
2740 resilience: None,
2741 sla: None,
2742 #[cfg(feature = "lineage")]
2743 lineage: None,
2744 #[cfg(feature = "lineage")]
2745 lineage_cfg: None,
2746 #[cfg(feature = "notify")]
2747 notifier: None,
2748 #[cfg(feature = "catalog")]
2749 catalog: None,
2750 },
2751 )
2752 .await
2753 .unwrap();
2754
2755 assert!(
2760 summary.had_failures(),
2761 "summary should record at least one failure: {summary:?}"
2762 );
2763 assert!(
2764 summary.invocations[0].error.is_some(),
2765 "first outcome must be the failure that triggered stop: {summary:?}"
2766 );
2767 for inv in &summary.invocations {
2771 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2772 }
2773 }
2774
2775 #[tokio::test]
2776 async fn on_error_continue_skips_failed_subtree_only() {
2777 let dir = tempfile::tempdir().unwrap();
2779 let good_csv = dir.path().join("good.csv");
2780 std::fs::write(&good_csv, "x\n1\n").unwrap();
2781 let good_out = dir.path().join("good.jsonl");
2782
2783 let yaml = format!(
2784 r#"version: 1
2785pipeline:
2786 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2787 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2788matrix:
2789 - id: bad
2790 sink: {{ config: {{ path: {bad_dir} }} }}
2791 - id: good
2792"#,
2793 good_csv = good_csv.display(),
2794 good_out = good_out.display(),
2795 bad_dir = dir.path().display(),
2796 );
2797 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2798 let nodes = expand(&cfg).unwrap();
2799 let summary = run_expanded(
2800 nodes,
2801 ExecuteOptions {
2802 pipeline_name: "continuetest".into(),
2803 execution: None,
2804 dry_run: false,
2805 limit: None,
2806 state_path_override: None,
2807 shard: None,
2808 auth: Default::default(),
2809 clock: chrono::Utc::now().fixed_offset(),
2810 cancel: None,
2811 resilience: None,
2812 sla: None,
2813 #[cfg(feature = "lineage")]
2814 lineage: None,
2815 #[cfg(feature = "lineage")]
2816 lineage_cfg: None,
2817 #[cfg(feature = "notify")]
2818 notifier: None,
2819 #[cfg(feature = "catalog")]
2820 catalog: None,
2821 },
2822 )
2823 .await
2824 .unwrap();
2825 assert_eq!(summary.invocations.len(), 2);
2826 assert_eq!(summary.failure_count(), 1);
2827 let good_outcome = summary
2828 .invocations
2829 .iter()
2830 .find(|i| i.row_id == "good")
2831 .unwrap();
2832 assert!(good_outcome.error.is_none());
2833 }
2834
2835 #[test]
2838 fn split_path_splits_on_dots() {
2839 assert_eq!(split_path("id"), vec!["id".to_string()]);
2840 assert_eq!(
2841 split_path("user.name"),
2842 vec!["user".to_string(), "name".to_string()]
2843 );
2844 }
2845
2846 #[test]
2847 fn minimal_paths_drops_descendants_of_kept_ancestors() {
2848 let paths = vec![
2849 vec!["user".into(), "name".into()],
2850 vec!["user".into()],
2851 vec!["id".into()],
2852 vec!["id".into()],
2853 ];
2854 let min = minimal_paths(paths);
2855 assert!(min.contains(&vec!["user".to_string()]));
2856 assert!(min.contains(&vec!["id".to_string()]));
2857 assert!(
2858 !min.contains(&vec!["user".to_string(), "name".to_string()]),
2859 "user.name must be dropped — covered by user"
2860 );
2861 assert_eq!(min.len(), 2);
2862 }
2863
2864 #[test]
2865 fn project_full_clones_whole_record() {
2866 let r = json!({"a": 1, "b": {"c": 2}});
2867 assert_eq!(project_record(&r, &Projection::Full), r);
2868 }
2869
2870 #[test]
2871 fn project_keeps_only_referenced_paths() {
2872 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2873 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2874 let got = project_record(&r, &p);
2875 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2876 assert!(got.get("blob").is_none());
2877 assert!(got["user"].get("age").is_none());
2878 }
2879
2880 #[test]
2881 fn project_array_index_path_resolves_same_as_original() {
2882 let r = json!({"tags": ["x", "y", "z"]});
2883 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2884 let got = project_record(&r, &p);
2885 assert_eq!(got, json!({"tags": {"0": "x"}}));
2886 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2887 assert_eq!(
2888 resolve_parent_key(&got, "tags.0"),
2889 resolve_parent_key(&r, "tags.0"),
2890 "reduced tree must resolve the same value as the original"
2891 );
2892 }
2893
2894 #[test]
2895 fn project_numeric_object_key_resolves_same_as_original() {
2896 let r = json!({"data": {"0": "x", "1": "y"}});
2901 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2902 let got = project_record(&r, &p);
2903 assert_eq!(got, json!({"data": {"0": "x"}}));
2904 assert_eq!(
2905 resolve_parent_key(&got, "data.0"),
2906 resolve_parent_key(&r, "data.0"),
2907 "numeric object-key path must resolve identically on the reduced tree"
2908 );
2909 }
2910
2911 #[test]
2912 fn project_missing_path_is_omitted() {
2913 let r = json!({"id": 1});
2914 let p = Projection::Paths(vec![vec!["nope".into()]]);
2915 assert_eq!(project_record(&r, &p), json!({}));
2916 }
2917
2918 #[test]
2919 fn build_projections_unions_parent_key_and_refs() {
2920 use crate::config::ConnectorSpec;
2921 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2922
2923 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2924 ExpandedNode {
2925 id: id.into(),
2926 row_index: 0,
2927 role: NodeRole::Child {
2928 parent_id: parent.into(),
2929 parent_key: parent_key.into(),
2930 },
2931 source: ConnectorSpec {
2932 kind: "csv".into(),
2933 config: json!({}),
2934 transforms: None,
2935 inherit_transforms: true,
2936 status: None,
2937 tags: Vec::new(),
2938 },
2939 sink: ConnectorSpec {
2940 kind: "jsonl".into(),
2941 config: json!({}),
2942 transforms: None,
2943 inherit_transforms: true,
2944 status: None,
2945 tags: Vec::new(),
2946 },
2947 transforms: Vec::new(),
2948 state: None,
2949 dlq: None,
2950 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2951 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2952 #[cfg(feature = "quality")]
2953 quality: None,
2954 #[cfg(feature = "contract")]
2955 contract: None,
2956 #[cfg(feature = "masking")]
2957 masking: None,
2958 sink_ref: "default".into(),
2959 schema: None,
2960 depends_on: Vec::new(),
2961 status: crate::config::SourceStatus::Active,
2962 tags: Vec::new(),
2963 deferred_refs: refs
2964 .iter()
2965 .map(|(rid, p)| DeferredRef {
2966 referenced_id: (*rid).into(),
2967 dotted_path: (*p).into(),
2968 token: format!("${{{rid}.{p}}}"),
2969 })
2970 .collect(),
2971 source_override: None,
2972 }
2973 }
2974
2975 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2976 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2977 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2978 let children_of =
2979 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2980
2981 let projs = build_projections(&nodes_by_id, &children_of);
2982 let p = projs.get("p").expect("projection for p");
2983 match &**p {
2984 Projection::Paths(paths) => {
2985 assert!(paths.contains(&vec!["id".to_string()]));
2986 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2987 assert!(paths.contains(&vec!["email".to_string()]));
2988 assert!(
2989 !paths.iter().any(|p| p == &vec!["x".to_string()]),
2990 "a ref to a different parent must not be captured under p"
2991 );
2992 }
2993 Projection::Full => panic!("expected Paths, got Full"),
2994 }
2995 }
2996
2997 #[test]
2998 fn build_projections_whole_record_ref_is_full() {
2999 use crate::config::ConnectorSpec;
3000 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3001 let c = ExpandedNode {
3002 id: "c".into(),
3003 row_index: 0,
3004 role: NodeRole::Child {
3005 parent_id: "p".into(),
3006 parent_key: "id".into(),
3007 },
3008 source: ConnectorSpec {
3009 kind: "csv".into(),
3010 config: json!({}),
3011 transforms: None,
3012 inherit_transforms: true,
3013 status: None,
3014 tags: Vec::new(),
3015 },
3016 sink: ConnectorSpec {
3017 kind: "jsonl".into(),
3018 config: json!({}),
3019 transforms: None,
3020 inherit_transforms: true,
3021 status: None,
3022 tags: Vec::new(),
3023 },
3024 transforms: Vec::new(),
3025 state: None,
3026 dlq: None,
3027 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3028 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3029 #[cfg(feature = "quality")]
3030 quality: None,
3031 #[cfg(feature = "contract")]
3032 contract: None,
3033 #[cfg(feature = "masking")]
3034 masking: None,
3035 sink_ref: "default".into(),
3036 schema: None,
3037 depends_on: Vec::new(),
3038 status: crate::config::SourceStatus::Active,
3039 tags: Vec::new(),
3040 deferred_refs: vec![DeferredRef {
3041 referenced_id: "p".into(),
3042 dotted_path: "".into(),
3043 token: "${p}".into(),
3044 }],
3045 source_override: None,
3046 };
3047 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3048 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3049 let projs = build_projections(&nodes_by_id, &children_of);
3050 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3051 }
3052
3053 fn opts(name: &str) -> ExecuteOptions {
3055 ExecuteOptions {
3056 pipeline_name: name.into(),
3057 execution: None,
3058 dry_run: false,
3059 limit: None,
3060 state_path_override: None,
3061 shard: None,
3062 auth: Default::default(),
3063 clock: chrono::Utc::now().fixed_offset(),
3064 cancel: None,
3065 resilience: None,
3066 sla: None,
3067 #[cfg(feature = "lineage")]
3068 lineage: None,
3069 #[cfg(feature = "lineage")]
3070 lineage_cfg: None,
3071 #[cfg(feature = "notify")]
3072 notifier: None,
3073 #[cfg(feature = "catalog")]
3074 catalog: None,
3075 }
3076 }
3077
3078 #[tokio::test]
3079 async fn dry_run_counts_records_without_writing_sink_file() {
3080 let dir = tempfile::tempdir().unwrap();
3083 let input = dir.path().join("in.csv");
3084 let output = dir.path().join("out.jsonl");
3085 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
3086 let cfg = cfg_csv_to_jsonl(&input, &output);
3087 let nodes = expand(&cfg).unwrap();
3088 let mut o = opts("dry");
3089 o.dry_run = true;
3090 let summary = run_expanded(nodes, o).await.unwrap();
3091 assert_eq!(summary.invocations.len(), 1);
3092 assert_eq!(summary.invocations[0].records_written, 3);
3093 assert!(!summary.had_failures());
3094 assert!(
3095 !output.exists(),
3096 "dry-run must not create the real sink file"
3097 );
3098 }
3099
3100 #[tokio::test]
3101 async fn read_only_state_store_drops_writes_keeps_reads() {
3102 let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
3105 inner.put("k", &json!("v0")).await.unwrap();
3106 let ro = ReadOnlyStateStore {
3107 inner: inner.clone(),
3108 };
3109 assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
3110 ro.put("k", &json!("advanced")).await.unwrap();
3112 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3113 ro.delete("k").await.unwrap();
3115 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3116 }
3117
3118 #[tokio::test]
3119 async fn dry_run_with_state_does_not_persist_bookmark() {
3120 let dir = tempfile::tempdir().unwrap();
3124 let input = dir.path().join("in.csv");
3125 let output = dir.path().join("out.jsonl");
3126 let state_dir = dir.path().join("state");
3127 std::fs::create_dir_all(&state_dir).unwrap();
3128 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3129 let cfg = cfg_csv_to_jsonl(&input, &output);
3130 let nodes = expand(&cfg).unwrap();
3131 let mut o = opts("drystate");
3132 o.dry_run = true;
3133 o.state_path_override = Some(state_dir.clone());
3134 let summary = run_expanded(nodes, o).await.unwrap();
3135 assert!(!summary.had_failures());
3136 assert!(!output.exists(), "dry-run must not write the sink file");
3137 let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3140 .unwrap()
3141 .filter_map(Result::ok)
3142 .collect();
3143 assert!(
3144 persisted.is_empty(),
3145 "dry-run must not persist any bookmark file, found: {persisted:?}"
3146 );
3147 }
3148
3149 #[tokio::test]
3150 async fn limit_caps_records_written_across_the_run() {
3151 let dir = tempfile::tempdir().unwrap();
3153 let input = dir.path().join("in.csv");
3154 let output = dir.path().join("out.jsonl");
3155 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3156 let cfg = cfg_csv_to_jsonl(&input, &output);
3157 let nodes = expand(&cfg).unwrap();
3158 let mut o = opts("lim");
3159 o.limit = Some(2);
3160 let summary = run_expanded(nodes, o).await.unwrap();
3161 assert_eq!(summary.invocations[0].records_written, 2);
3162 let body = std::fs::read_to_string(&output).unwrap();
3163 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3164 }
3165
3166 #[tokio::test]
3167 async fn duplicate_state_key_among_siblings_is_rejected() {
3168 let dir = tempfile::tempdir().unwrap();
3172 let parent_csv = dir.path().join("parents.csv");
3173 let child_csv = dir.path().join("child.csv");
3174 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3176 std::fs::write(&child_csv, "x\nA\n").unwrap();
3177 let parent_out = dir.path().join("parents.jsonl");
3178 let child_out = dir.path().join("child.jsonl");
3179 let yaml = format!(
3180 r#"version: 1
3181pipeline:
3182 source: {{ type: csv, config: {{ path: {parent} }} }}
3183 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3184 state: {{ type: memory }}
3185matrix:
3186 - id: parents
3187 - id: child
3188 parent: parents
3189 source: {{ config: {{ path: {child} }} }}
3190 sink: {{ config: {{ path: {child_out} }} }}
3191"#,
3192 parent = parent_csv.display(),
3193 parent_out = parent_out.display(),
3194 child = child_csv.display(),
3195 child_out = child_out.display(),
3196 );
3197 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3198 let nodes = expand(&cfg).unwrap();
3199 let err = run_expanded(nodes, opts("dupkey"))
3200 .await
3201 .expect_err("colliding sibling state keys must be rejected");
3202 match err {
3203 CliError::DuplicateStateKey { id, state_key } => {
3204 assert_eq!(id, "child");
3205 assert_eq!(state_key, "dupkey::child::dup");
3206 }
3207 other => panic!("expected DuplicateStateKey, got {other:?}"),
3208 }
3209 }
3210
3211 #[tokio::test]
3212 async fn state_path_override_writes_bookmark_file() {
3213 let dir = tempfile::tempdir().unwrap();
3218 let input = dir.path().join("in.csv");
3219 let output = dir.path().join("out.jsonl");
3220 let state_dir = dir.path().join("state");
3221 std::fs::write(&input, "name\nalice\n").unwrap();
3222 let cfg = cfg_csv_to_jsonl(&input, &output);
3223 let nodes = expand(&cfg).unwrap();
3224 let mut o = opts("statepath");
3225 o.state_path_override = Some(state_dir.clone());
3226 let summary = run_expanded(nodes, o).await.unwrap();
3227 assert!(!summary.had_failures());
3228 assert_eq!(summary.invocations[0].records_written, 1);
3232 }
3233
3234 #[tokio::test]
3235 async fn build_dlq_config_maps_spec_fields() {
3236 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3237 let dir = tempfile::tempdir().unwrap();
3238 let dlq_out = dir.path().join("dlq.jsonl");
3239 let spec = DlqSpec {
3240 sink: ConnectorSpec {
3241 kind: "jsonl".into(),
3242 config: json!({ "path": dlq_out.to_str().unwrap() }),
3243 transforms: None,
3244 inherit_transforms: true,
3245 status: None,
3246 tags: Vec::new(),
3247 },
3248 on_batch_error: OnBatchErrorSpec::DlqAll,
3249 max_failures_per_page: Some(7),
3250 max_failures_total: Some(42),
3251 include_original_payload: false,
3252 };
3253 let cfg = build_dlq_config(&spec).await.unwrap();
3254 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3255 assert_eq!(cfg.max_failures_per_page, Some(7));
3256 assert_eq!(cfg.max_failures_total, Some(42));
3257 assert!(!cfg.include_original_payload);
3258 }
3259
3260 #[tokio::test]
3261 async fn build_state_for_node_arms() {
3262 let dir = tempfile::tempdir().unwrap();
3263
3264 let node = stub_node(None);
3266 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3267
3268 let p = dir.path().join("s1");
3270 assert!(
3271 build_state_for_node(&node, Some(&p))
3272 .await
3273 .unwrap()
3274 .is_some()
3275 );
3276
3277 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3279 kind: "memory".into(),
3280 config: json!({}),
3281 }));
3282 assert!(
3283 build_state_for_node(&node_mem, None)
3284 .await
3285 .unwrap()
3286 .is_some()
3287 );
3288
3289 let node_file = stub_node(Some(crate::config::StateStoreSpec {
3291 kind: "file".into(),
3292 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3293 }));
3294 let p2 = dir.path().join("override2");
3295 assert!(
3296 build_state_for_node(&node_file, Some(&p2))
3297 .await
3298 .unwrap()
3299 .is_some()
3300 );
3301
3302 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3305 kind: "memory".into(),
3306 config: json!({}),
3307 }));
3308 let p3 = dir.path().join("override3");
3309 assert!(
3310 build_state_for_node(&node_mem2, Some(&p3))
3311 .await
3312 .unwrap()
3313 .is_some()
3314 );
3315 }
3316
3317 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3319 use crate::config::ConnectorSpec;
3320 ExpandedNode {
3321 id: "n".into(),
3322 row_index: 0,
3323 role: NodeRole::Root,
3324 source: ConnectorSpec {
3325 kind: "csv".into(),
3326 config: json!({}),
3327 transforms: None,
3328 inherit_transforms: true,
3329 status: None,
3330 tags: Vec::new(),
3331 },
3332 sink: ConnectorSpec {
3333 kind: "jsonl".into(),
3334 config: json!({}),
3335 transforms: None,
3336 inherit_transforms: true,
3337 status: None,
3338 tags: Vec::new(),
3339 },
3340 transforms: Vec::new(),
3341 state,
3342 dlq: None,
3343 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3344 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3345 #[cfg(feature = "quality")]
3346 quality: None,
3347 #[cfg(feature = "contract")]
3348 contract: None,
3349 #[cfg(feature = "masking")]
3350 masking: None,
3351 sink_ref: "default".into(),
3352 schema: None,
3353 depends_on: Vec::new(),
3354 status: crate::config::SourceStatus::Active,
3355 tags: Vec::new(),
3356 deferred_refs: Vec::new(),
3357 source_override: None,
3358 }
3359 }
3360
3361 #[tokio::test]
3362 async fn state_key_override_delegates_and_overrides_key() {
3363 let dir = tempfile::tempdir().unwrap();
3365 let input = dir.path().join("in.csv");
3366 std::fs::write(&input, "name\nz\n").unwrap();
3367 let inner = build_source(
3368 "csv",
3369 json!({"path": input.to_str().unwrap()}),
3370 &AuthCatalog::new(),
3371 None,
3372 )
3373 .await
3374 .unwrap();
3375 let inner_name = inner.connector_name();
3377 let ov = StateKeyOverride {
3378 inner,
3379 key: "my::custom::key".into(),
3380 };
3381 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3382 assert_eq!(ov.connector_name(), inner_name);
3383 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3384 assert_eq!(rows.len(), 1);
3385 ov.apply_start_bookmark(json!({"any": "bookmark"}))
3387 .await
3388 .unwrap();
3389 assert!(!ov.supports_exactly_once());
3391 assert_eq!(
3392 ov.replay_guarantee(),
3393 faucet_core::ReplayGuarantee::NonDeterministic
3394 );
3395 assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3396 }
3397
3398 #[tokio::test]
3399 async fn state_key_override_forwards_native_stream_pages() {
3400 struct PerPageBookmarkSource;
3406 #[async_trait]
3407 impl Source for PerPageBookmarkSource {
3408 async fn fetch_with_context(
3409 &self,
3410 _ctx: &HashMap<String, Value>,
3411 ) -> Result<Vec<Value>, FaucetError> {
3412 Ok(vec![json!({"id": 1}), json!({"id": 2})])
3413 }
3414 fn stream_pages<'a>(
3415 &'a self,
3416 _ctx: &'a HashMap<String, Value>,
3417 _batch_size: usize,
3418 ) -> std::pin::Pin<
3419 Box<
3420 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3421 + Send
3422 + 'a,
3423 >,
3424 > {
3425 Box::pin(faucet_core::async_stream::try_stream! {
3426 yield faucet_core::StreamPage {
3427 records: vec![json!({"id": 1})],
3428 bookmark: Some(json!("bm-1")),
3429 };
3430 yield faucet_core::StreamPage {
3431 records: vec![json!({"id": 2})],
3432 bookmark: Some(json!("bm-2")),
3433 };
3434 })
3435 }
3436 fn state_key(&self) -> Option<String> {
3437 Some("native".into())
3438 }
3439 }
3440
3441 use futures::StreamExt;
3442 let ov = StateKeyOverride {
3443 inner: Box::new(PerPageBookmarkSource),
3444 key: "override".into(),
3445 };
3446 let ctx = HashMap::new();
3447 let pages: Vec<_> = ov
3448 .stream_pages(&ctx, 1000)
3449 .collect::<Vec<_>>()
3450 .await
3451 .into_iter()
3452 .collect::<Result<Vec<_>, _>>()
3453 .unwrap();
3454 assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3455 assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3456 assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3457 }
3458
3459 #[tokio::test]
3460 async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3461 struct IdemSink;
3462 #[async_trait]
3463 impl Sink for IdemSink {
3464 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3465 Ok(records.len())
3466 }
3467 fn connector_name(&self) -> &'static str {
3468 "idem"
3469 }
3470 fn supports_idempotent_writes(&self) -> bool {
3471 true
3472 }
3473 fn dedups_by_key(&self) -> bool {
3474 true
3475 }
3476 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3477 &[
3478 faucet_core::WriteMode::Append,
3479 faucet_core::WriteMode::Upsert,
3480 ]
3481 }
3482 async fn write_batch_idempotent(
3483 &self,
3484 records: &[Value],
3485 _scope: &str,
3486 _token: &str,
3487 ) -> Result<usize, FaucetError> {
3488 Ok(records.len())
3489 }
3490 async fn last_committed_token(
3491 &self,
3492 _scope: &str,
3493 ) -> Result<Option<String>, FaucetError> {
3494 Ok(Some("tok".into()))
3495 }
3496 }
3497
3498 let captured = Arc::new(Mutex::new(Vec::new()));
3499 let sink = CapturingSink::wrap(
3500 Box::new(IdemSink),
3501 Arc::clone(&captured),
3502 Arc::new(Projection::Full),
3503 );
3504 assert!(sink.supports_idempotent_writes());
3507 assert!(sink.dedups_by_key());
3508 assert_eq!(
3509 sink.sink_guarantee(),
3510 faucet_core::SinkGuarantee::AtomicWatermark
3511 );
3512 assert!(
3513 sink.supported_write_modes()
3514 .contains(&faucet_core::WriteMode::Upsert)
3515 );
3516 assert_eq!(
3517 sink.last_committed_token("k").await.unwrap(),
3518 Some("tok".into())
3519 );
3520 assert_eq!(sink.current_schema().await.unwrap(), None);
3521 assert!(!sink.supports_schema_evolution());
3522 let n = sink
3524 .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3525 .await
3526 .unwrap();
3527 assert_eq!(n, 1);
3528 assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3529 }
3530
3531 #[tokio::test]
3532 async fn orphaned_child_surfaces_executor_deadlock() {
3533 use crate::config::ConnectorSpec;
3537 let orphan = ExpandedNode {
3538 id: "orphan".into(),
3539 row_index: 0,
3540 role: NodeRole::Child {
3541 parent_id: "missing-parent".into(),
3542 parent_key: "id".into(),
3543 },
3544 source: ConnectorSpec {
3545 kind: "csv".into(),
3546 config: json!({}),
3547 transforms: None,
3548 inherit_transforms: true,
3549 status: None,
3550 tags: Vec::new(),
3551 },
3552 sink: ConnectorSpec {
3553 kind: "jsonl".into(),
3554 config: json!({}),
3555 transforms: None,
3556 inherit_transforms: true,
3557 status: None,
3558 tags: Vec::new(),
3559 },
3560 transforms: Vec::new(),
3561 state: None,
3562 dlq: None,
3563 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3564 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3565 #[cfg(feature = "quality")]
3566 quality: None,
3567 #[cfg(feature = "contract")]
3568 contract: None,
3569 #[cfg(feature = "masking")]
3570 masking: None,
3571 sink_ref: "default".into(),
3572 schema: None,
3573 depends_on: Vec::new(),
3574 status: crate::config::SourceStatus::Active,
3575 tags: Vec::new(),
3576 deferred_refs: Vec::new(),
3577 source_override: None,
3578 };
3579 let err = run_expanded(vec![orphan], opts("deadlock"))
3580 .await
3581 .expect_err("an orphaned child must surface as an executor deadlock");
3582 match err {
3583 CliError::Internal(msg) => {
3584 assert!(msg.contains("executor deadlock"), "{msg}");
3585 assert!(msg.contains("orphan"), "{msg}");
3586 }
3587 other => panic!("expected Internal deadlock error, got {other:?}"),
3588 }
3589 }
3590
3591 #[test]
3592 fn value_to_string_brief_unquotes_strings_only() {
3593 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3594 assert_eq!(value_to_string_brief(&json!(42)), "42");
3595 assert_eq!(value_to_string_brief(&json!(true)), "true");
3596 assert_eq!(value_to_string_brief(&json!(null)), "null");
3597 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3598 }
3599
3600 #[test]
3601 fn build_state_key_with_and_without_parent() {
3602 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3603 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3604 }
3605
3606 #[test]
3607 fn resolve_parent_key_walks_objects_arrays_and_misses() {
3608 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3609 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3610 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3611 assert_eq!(resolve_parent_key(&r, "user.age"), None);
3613 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3615 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3617 }
3618
3619 #[tokio::test]
3620 async fn cooperative_cancel_returns_partial_ok() {
3621 let dir = tempfile::tempdir().unwrap();
3625 let input = dir.path().join("in.csv");
3626 let output = dir.path().join("out.jsonl");
3627 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3628 let cfg = cfg_csv_to_jsonl(&input, &output);
3629 let nodes = expand(&cfg).unwrap();
3630 let token = CancellationToken::new();
3631 token.cancel(); let mut o = opts("cancel");
3633 o.cancel = Some(token);
3634 let summary = run_expanded(nodes, o).await.unwrap();
3635 assert_eq!(summary.invocations.len(), 1);
3638 assert!(
3639 !summary.had_failures(),
3640 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3641 );
3642 }
3643
3644 #[tokio::test]
3645 async fn fanout_projects_away_unreferenced_parent_fields() {
3646 let dir = tempfile::tempdir().unwrap();
3650 let parent_csv = dir.path().join("parents.csv");
3651 let child_csv = dir.path().join("child.csv");
3652 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3653 std::fs::write(&child_csv, "x\nA\n").unwrap();
3654 let parent_out = dir.path().join("parents.jsonl");
3655 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3656
3657 let yaml = format!(
3658 r#"version: 1
3659pipeline:
3660 source: {{ type: csv, config: {{ path: {parent} }} }}
3661 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3662matrix:
3663 - id: parents
3664 - id: child
3665 parent: parents
3666 source: {{ config: {{ path: {child} }} }}
3667 sink: {{ config: {{ path: "{child_out}" }} }}
3668"#,
3669 parent = parent_csv.display(),
3670 parent_out = parent_out.display(),
3671 child = child_csv.display(),
3672 child_out = child_out_pattern.display(),
3673 );
3674 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3675 let nodes = expand(&cfg).unwrap();
3676 let summary = run_expanded(
3677 nodes,
3678 ExecuteOptions {
3679 pipeline_name: "projtest".into(),
3680 execution: None,
3681 dry_run: false,
3682 limit: None,
3683 state_path_override: None,
3684 shard: None,
3685 auth: Default::default(),
3686 clock: chrono::Utc::now().fixed_offset(),
3687 cancel: None,
3688 resilience: None,
3689 sla: None,
3690 #[cfg(feature = "lineage")]
3691 lineage: None,
3692 #[cfg(feature = "lineage")]
3693 lineage_cfg: None,
3694 #[cfg(feature = "notify")]
3695 notifier: None,
3696 #[cfg(feature = "catalog")]
3697 catalog: None,
3698 },
3699 )
3700 .await
3701 .unwrap();
3702
3703 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3705 assert!(!summary.had_failures(), "{summary:?}");
3706 assert!(dir.path().join("child-1.jsonl").exists());
3708 assert!(dir.path().join("child-2.jsonl").exists());
3709 }
3710}