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 inputs: vec![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_schemas: Vec::new(),
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_schemas = vec![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 sources: vec![DatasetObservation {
1451 uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1452 kind: node.source.kind.clone(),
1453 role: DatasetRole::Source,
1454 schema: source_schema,
1455 records: records_read,
1456 }],
1457 sink: DatasetObservation {
1458 uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1459 kind: node.sink.kind.clone(),
1460 role: DatasetRole::Sink,
1461 schema: sink_schema,
1462 records: records_out,
1463 },
1464 column_lineage,
1465 };
1466 crate::catalog::record(handle, &update).await;
1467 }
1468
1469 let result = result?;
1470
1471 #[cfg(feature = "lineage")]
1475 let records_read = in_sample.as_ref().map(|s| s.count());
1476 #[cfg(not(feature = "lineage"))]
1477 let records_read: Option<u64> = None;
1478 let stats = PipelineStats {
1479 records_written: result.records_written,
1480 records_read,
1481 dlq_count: result
1482 .dlq
1483 .as_ref()
1484 .map(|d| d.records_dlq as u64)
1485 .unwrap_or(0),
1486 bookmark: result.bookmark.clone(),
1487 };
1488
1489 let captured = if capture.is_some() {
1490 std::mem::take(&mut *captured.lock().await)
1491 } else {
1492 Vec::new()
1493 };
1494 Ok((captured, stats))
1495}
1496
1497async fn build_state_for_node(
1498 node: &ExpandedNode,
1499 state_path_override: Option<&Path>,
1500) -> CliResult<Option<Arc<dyn StateStore>>> {
1501 match (&node.state, state_path_override) {
1502 (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1503 (None, Some(path)) => Ok(Some(state_from_override(path))),
1504 (Some(spec), Some(path)) => {
1505 if spec.kind == "file" {
1506 Ok(Some(state_from_override(path)))
1507 } else {
1508 tracing::warn!(
1509 state = %spec.kind,
1510 "--state-path is only meaningful for the 'file' backend; ignoring override"
1511 );
1512 Ok(Some(build_state_store(spec).await?))
1513 }
1514 }
1515 (None, None) => Ok(None),
1516 }
1517}
1518
1519fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1520 Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1521}
1522
1523pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1526 let sink = build_sink(
1529 &spec.sink.kind,
1530 spec.sink.config.clone(),
1531 &AuthCatalog::new(),
1532 )
1533 .await?;
1534 Ok(DlqConfig {
1535 sink: Arc::from(sink),
1536 on_batch_error: match spec.on_batch_error {
1537 crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1538 crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1539 },
1540 max_failures_per_page: spec.max_failures_per_page,
1541 max_failures_total: spec.max_failures_total,
1542 include_original_payload: spec.include_original_payload,
1543 })
1544}
1545
1546#[cfg(feature = "notify")]
1550fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1551 use crate::notify::NotifyEvent;
1552 match err {
1553 FaucetError::CircuitOpen { failures, cooldown } => {
1554 NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1555 }
1556 FaucetError::ContractViolation { message, .. } => {
1557 NotifyEvent::contract_abort(pipeline, row, message.clone())
1558 }
1559 other => {
1560 NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1561 }
1562 }
1563}
1564
1565#[cfg(feature = "notify")]
1569fn faucet_error_kind(err: &FaucetError) -> &'static str {
1570 match err {
1571 FaucetError::Config(_) => "config",
1572 FaucetError::Source(_) => "source",
1573 FaucetError::Sink(_) => "sink",
1574 FaucetError::State(_) => "state",
1575 FaucetError::QualityFailure { .. } => "quality",
1576 FaucetError::SchemaDrift { .. } => "schema_drift",
1577 _ => "error",
1578 }
1579}
1580
1581pub(crate) fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1590 fn walk(value: &Value, owner: &str) -> CliResult<()> {
1591 match value {
1592 Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1593 "the {owner} config references a `${{backfill.*}}` token, which only `faucet backfill` resolves — run this config via `faucet backfill --from … --to …`, or remove the token"
1594 ))),
1595 Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1596 Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1597 _ => Ok(()),
1598 }
1599 }
1600 walk(value, owner)
1601}
1602
1603pub(crate) fn resolve_now_inplace(
1604 value: &mut Value,
1605 clock: DateTime<FixedOffset>,
1606) -> CliResult<()> {
1607 match value {
1608 Value::String(s) => {
1609 *s = crate::interpolate::resolve_now(s, clock)?;
1610 Ok(())
1611 }
1612 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1613 Value::Object(m) => m
1614 .values_mut()
1615 .try_for_each(|v| resolve_now_inplace(v, clock)),
1616 _ => Ok(()),
1617 }
1618}
1619
1620fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1624 match value {
1625 Value::String(s) => {
1626 let resolved = interpolate_record(s, ctx)?;
1627 *s = resolved;
1628 Ok(())
1629 }
1630 Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1631 Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1632 _ => Ok(()),
1633 }
1634}
1635
1636pub(crate) struct ReadOnlyStateStore {
1649 pub(crate) inner: Arc<dyn StateStore>,
1650}
1651
1652#[async_trait]
1653impl StateStore for ReadOnlyStateStore {
1654 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1655 self.inner.get(key).await
1656 }
1657 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1658 Ok(())
1659 }
1660 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1661 Ok(())
1662 }
1663}
1664
1665struct StateKeyOverride {
1669 inner: Box<dyn Source>,
1670 key: String,
1671}
1672
1673#[async_trait]
1674impl Source for StateKeyOverride {
1675 async fn fetch_with_context(
1676 &self,
1677 ctx: &HashMap<String, Value>,
1678 ) -> Result<Vec<Value>, FaucetError> {
1679 self.inner.fetch_with_context(ctx).await
1680 }
1681 async fn fetch_with_context_incremental(
1682 &self,
1683 ctx: &HashMap<String, Value>,
1684 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1685 self.inner.fetch_with_context_incremental(ctx).await
1686 }
1687 fn stream_pages<'a>(
1693 &'a self,
1694 ctx: &'a HashMap<String, Value>,
1695 batch_size: usize,
1696 ) -> std::pin::Pin<
1697 Box<
1698 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1699 + Send
1700 + 'a,
1701 >,
1702 > {
1703 self.inner.stream_pages(ctx, batch_size)
1704 }
1705 fn connector_name(&self) -> &'static str {
1706 self.inner.connector_name()
1707 }
1708 fn dataset_uri(&self) -> String {
1709 self.inner.dataset_uri()
1710 }
1711 fn state_key(&self) -> Option<String> {
1712 Some(self.key.clone())
1713 }
1714 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1715 self.inner.apply_start_bookmark(bookmark).await
1716 }
1717 fn supports_exactly_once(&self) -> bool {
1718 self.inner.supports_exactly_once()
1719 }
1720 fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1721 self.inner.replay_guarantee()
1722 }
1723 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1724 self.inner.capture_resume_position().await
1725 }
1726}
1727
1728struct CapturingSink {
1732 inner: Box<dyn Sink>,
1733 captured: Arc<Mutex<Vec<Value>>>,
1734 projection: Arc<Projection>,
1735}
1736
1737impl CapturingSink {
1738 fn wrap(
1739 inner: Box<dyn Sink>,
1740 captured: Arc<Mutex<Vec<Value>>>,
1741 projection: Arc<Projection>,
1742 ) -> Self {
1743 Self {
1744 inner,
1745 captured,
1746 projection,
1747 }
1748 }
1749}
1750
1751#[async_trait]
1752impl Sink for CapturingSink {
1753 fn connector_name(&self) -> &'static str {
1754 self.inner.connector_name()
1755 }
1756 fn dataset_uri(&self) -> String {
1757 self.inner.dataset_uri()
1758 }
1759 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1760 let written = self.inner.write_batch(records).await?;
1761 let n = written.min(records.len());
1764 let mut buf = self.captured.lock().await;
1765 buf.extend(
1766 records
1767 .iter()
1768 .take(n)
1769 .map(|r| project_record(r, &self.projection)),
1770 );
1771 Ok(written)
1772 }
1773 async fn flush(&self) -> Result<(), FaucetError> {
1774 self.inner.flush().await
1775 }
1776 fn supports_idempotent_writes(&self) -> bool {
1780 self.inner.supports_idempotent_writes()
1781 }
1782 fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1783 self.inner.sink_guarantee()
1784 }
1785 fn dedups_by_key(&self) -> bool {
1786 self.inner.dedups_by_key()
1787 }
1788 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1789 self.inner.supported_write_modes()
1790 }
1791 async fn write_batch_idempotent(
1792 &self,
1793 records: &[Value],
1794 scope: &str,
1795 token: &str,
1796 ) -> Result<usize, FaucetError> {
1797 let written = self
1798 .inner
1799 .write_batch_idempotent(records, scope, token)
1800 .await?;
1801 let n = written.min(records.len());
1802 let mut buf = self.captured.lock().await;
1803 buf.extend(
1804 records
1805 .iter()
1806 .take(n)
1807 .map(|r| project_record(r, &self.projection)),
1808 );
1809 Ok(written)
1810 }
1811 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1812 self.inner.last_committed_token(scope).await
1813 }
1814 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1815 self.inner.current_schema().await
1816 }
1817 fn supports_schema_evolution(&self) -> bool {
1818 self.inner.supports_schema_evolution()
1819 }
1820 async fn evolve_schema(
1821 &self,
1822 evolution: &faucet_core::SchemaEvolution,
1823 ) -> Result<(), FaucetError> {
1824 self.inner.evolve_schema(evolution).await
1825 }
1826}
1827
1828pub(crate) struct LimitedSink {
1831 inner: Box<dyn Sink>,
1832 remaining: AtomicUsize,
1833}
1834
1835impl LimitedSink {
1836 pub(crate) fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1837 Self {
1838 inner,
1839 remaining: AtomicUsize::new(cap),
1840 }
1841 }
1842}
1843
1844#[async_trait]
1845impl Sink for LimitedSink {
1846 fn connector_name(&self) -> &'static str {
1847 self.inner.connector_name()
1848 }
1849 fn dataset_uri(&self) -> String {
1850 self.inner.dataset_uri()
1851 }
1852 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1853 let remaining = self.remaining.load(Ordering::Relaxed);
1854 if remaining == 0 {
1855 return Ok(0);
1856 }
1857 let take = remaining.min(records.len());
1858 let slice = &records[..take];
1859 let written = self.inner.write_batch(slice).await?;
1860 self.remaining
1861 .fetch_sub(written.min(remaining), Ordering::Relaxed);
1862 Ok(written)
1863 }
1864 async fn flush(&self) -> Result<(), FaucetError> {
1865 self.inner.flush().await
1866 }
1867}
1868
1869pub(crate) struct CountingSink {
1872 seen: AtomicUsize,
1873}
1874
1875impl CountingSink {
1876 pub(crate) fn new() -> Self {
1877 Self {
1878 seen: AtomicUsize::new(0),
1879 }
1880 }
1881}
1882
1883#[async_trait]
1884impl Sink for CountingSink {
1885 fn connector_name(&self) -> &'static str {
1886 "dry-run"
1887 }
1888 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1889 self.seen.fetch_add(records.len(), Ordering::Relaxed);
1890 Ok(records.len())
1891 }
1892}
1893
1894fn value_to_string_brief(v: &Value) -> String {
1897 match v {
1898 Value::String(s) => s.clone(),
1899 other => other.to_string(),
1900 }
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905 use super::*;
1906 use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1907 use crate::expand::expand;
1908 use serde_json::json;
1909
1910 fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1911 PipelineConfig {
1912 version: 1,
1913 name: Some("test".into()),
1914 vars: None,
1915 params: Default::default(),
1916 auth: None,
1917 pipeline: PipelineSpec {
1918 source: Some(ConnectorSpec {
1919 kind: "csv".into(),
1920 config: json!({"path": input.to_str().unwrap()}),
1921 transforms: None,
1922 inherit_transforms: true,
1923 status: None,
1924 tags: Vec::new(),
1925 }),
1926 sink: Some(ConnectorSpec {
1927 kind: "jsonl".into(),
1928 config: json!({"path": output.to_str().unwrap()}),
1929 transforms: None,
1930 inherit_transforms: true,
1931 status: None,
1932 tags: Vec::new(),
1933 }),
1934 sources: Default::default(),
1935 sinks: Default::default(),
1936 transforms: Vec::new(),
1937 state: None,
1938 dlq: None,
1939 #[cfg(feature = "quality")]
1940 quality: None,
1941 #[cfg(feature = "contract")]
1942 contract: None,
1943 #[cfg(feature = "masking")]
1944 masking: None,
1945 schema: None,
1946 nodes: std::collections::HashMap::new(),
1947 edges: Vec::new(),
1948 },
1949 matrix: Vec::new(),
1950 execution: None,
1951 selection: None,
1952 observability: None,
1953 delivery: faucet_core::DeliveryMode::default(),
1954 resilience: None,
1955 sla: None,
1956 shard: None,
1957 replication: None,
1958 backfill: None,
1959 #[cfg(feature = "schedule")]
1960 schedule: None,
1961 #[cfg(feature = "lineage")]
1962 lineage: None,
1963 #[cfg(feature = "catalog")]
1964 catalog: None,
1965 #[cfg(feature = "notify")]
1966 notifications: Vec::new(),
1967 }
1968 }
1969
1970 #[tokio::test]
1971 async fn empty_matrix_runs_pipeline_once() {
1972 let dir = tempfile::tempdir().unwrap();
1973 let input = dir.path().join("in.csv");
1974 let output = dir.path().join("out.jsonl");
1975 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1976 let cfg = cfg_csv_to_jsonl(&input, &output);
1977 let nodes = expand(&cfg).unwrap();
1978 let summary = run_expanded(
1979 nodes,
1980 ExecuteOptions {
1981 pipeline_name: "t".into(),
1982 execution: None,
1983 dry_run: false,
1984 limit: None,
1985 state_path_override: None,
1986 shard: None,
1987 auth: Default::default(),
1988 clock: chrono::Utc::now().fixed_offset(),
1989 cancel: None,
1990 resilience: None,
1991 sla: None,
1992 #[cfg(feature = "lineage")]
1993 lineage: None,
1994 #[cfg(feature = "lineage")]
1995 lineage_cfg: None,
1996 #[cfg(feature = "notify")]
1997 notifier: None,
1998 #[cfg(feature = "catalog")]
1999 catalog: None,
2000 },
2001 )
2002 .await
2003 .unwrap();
2004 assert_eq!(summary.invocations.len(), 1);
2005 assert_eq!(summary.invocations[0].records_written, 2);
2006 assert!(!summary.had_failures());
2007 let body = std::fs::read_to_string(&output).unwrap();
2008 assert_eq!(body.lines().count(), 2);
2009 }
2010
2011 #[cfg(feature = "catalog")]
2013 fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2014 let mut o = opts(name);
2015 o.catalog = Some(handle);
2016 o
2017 }
2018
2019 #[cfg(feature = "catalog")]
2020 #[tokio::test]
2021 async fn catalog_records_schema_timeline_across_two_runs() {
2022 use crate::catalog::CatalogHandle;
2026 use crate::serve::history::RunHistory as _;
2027 use crate::serve::history::catalog::{self, CatalogListFilter};
2028 use crate::serve::history::memory::MemoryHistory;
2029
2030 let dir = tempfile::tempdir().unwrap();
2031 let input = dir.path().join("in.csv");
2032 let output = dir.path().join("out.jsonl");
2033 let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2034 let handle = CatalogHandle {
2035 store: store.clone(),
2036 run_id: None,
2037 sample_records: 10,
2038 };
2039
2040 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2041 let cfg = cfg_csv_to_jsonl(&input, &output);
2042 let nodes = expand(&cfg).unwrap();
2043 let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2044 .await
2045 .unwrap();
2046 assert!(!summary.had_failures());
2047
2048 std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2050 let nodes = expand(&cfg).unwrap();
2051 let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2052 .await
2053 .unwrap();
2054 assert!(!summary.had_failures());
2055
2056 let page = store
2058 .catalog_list_datasets(&CatalogListFilter {
2059 limit: 10,
2060 ..Default::default()
2061 })
2062 .await
2063 .unwrap();
2064 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2065 for ds in &page.datasets {
2066 let detail = store
2067 .catalog_get_dataset(&ds.id)
2068 .await
2069 .unwrap()
2070 .expect("dataset detail");
2071 assert_eq!(detail.dataset.runs, 2);
2072 assert_eq!(
2073 detail.schema_timeline.len(),
2074 2,
2075 "exactly two timeline entries for {}",
2076 ds.uri
2077 );
2078 assert!(detail.schema_timeline[0].diff.is_none());
2079 let diff = detail.schema_timeline[1]
2080 .diff
2081 .as_ref()
2082 .expect("second version carries a diff");
2083 assert!(
2084 diff["added"]
2085 .as_array()
2086 .unwrap()
2087 .iter()
2088 .any(|c| c["column"] == "email"),
2089 "diff must show the added email column: {diff}"
2090 );
2091 assert_eq!(detail.stats.len(), 2, "one volume point per run");
2092 }
2093 let edges = store.catalog_lineage(None, 5).await.unwrap();
2095 assert_eq!(edges.len(), 1);
2096 assert_eq!(edges[0].runs, 2);
2097 assert_eq!(edges[0].last_records, 2);
2098 assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2099 }
2100
2101 #[cfg(feature = "catalog")]
2104 struct FailingCatalogStore;
2105
2106 #[cfg(feature = "catalog")]
2107 #[async_trait]
2108 impl crate::serve::history::RunHistory for FailingCatalogStore {
2109 async fn claim_idempotency(
2110 &self,
2111 _: &str,
2112 _: &str,
2113 _: &str,
2114 _: std::time::Duration,
2115 ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2116 Err(crate::serve::history::HistoryError::Backend("down".into()))
2117 }
2118 async fn upsert(
2119 &self,
2120 _: &crate::serve::history::RunRecord,
2121 ) -> Result<(), crate::serve::history::HistoryError> {
2122 Err(crate::serve::history::HistoryError::Backend("down".into()))
2123 }
2124 async fn get(
2125 &self,
2126 _: &str,
2127 ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2128 {
2129 Err(crate::serve::history::HistoryError::Backend("down".into()))
2130 }
2131 async fn list(
2132 &self,
2133 _: &crate::serve::history::ListFilter,
2134 ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2135 Err(crate::serve::history::HistoryError::Backend("down".into()))
2136 }
2137 async fn delete(
2138 &self,
2139 _: &str,
2140 ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2141 {
2142 Err(crate::serve::history::HistoryError::Backend("down".into()))
2143 }
2144 async fn purge_expired(
2145 &self,
2146 _: std::time::Duration,
2147 ) -> Result<usize, crate::serve::history::HistoryError> {
2148 Err(crate::serve::history::HistoryError::Backend("down".into()))
2149 }
2150 async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2151 Err(crate::serve::history::HistoryError::Backend("down".into()))
2152 }
2153 async fn catalog_record(
2154 &self,
2155 _: &crate::serve::history::catalog::CatalogUpdate,
2156 ) -> Result<(), crate::serve::history::HistoryError> {
2157 Err(crate::serve::history::HistoryError::Backend(
2158 "catalog write refused".into(),
2159 ))
2160 }
2161 fn degraded(&self) -> bool {
2162 false
2163 }
2164 }
2165
2166 #[cfg(feature = "catalog")]
2167 #[tokio::test]
2168 async fn catalog_write_failure_never_fails_the_run() {
2169 use crate::catalog::CatalogHandle;
2172 let dir = tempfile::tempdir().unwrap();
2173 let input = dir.path().join("in.csv");
2174 let output = dir.path().join("out.jsonl");
2175 std::fs::write(&input, "name\nalice\n").unwrap();
2176 let cfg = cfg_csv_to_jsonl(&input, &output);
2177 let nodes = expand(&cfg).unwrap();
2178 let handle = CatalogHandle {
2179 store: Arc::new(FailingCatalogStore),
2180 run_id: None,
2181 sample_records: 10,
2182 };
2183 let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2184 .await
2185 .unwrap();
2186 assert!(
2187 !summary.had_failures(),
2188 "catalog failure must not fail the run"
2189 );
2190 assert_eq!(summary.invocations[0].records_written, 1);
2191 assert_eq!(
2192 std::fs::read_to_string(&output).unwrap().lines().count(),
2193 1,
2194 "sink output written despite the catalog error"
2195 );
2196 }
2197
2198 #[tokio::test]
2199 async fn matrix_two_independent_roots_both_run() {
2200 let dir = tempfile::tempdir().unwrap();
2202 let csv_a = dir.path().join("a.csv");
2203 let csv_b = dir.path().join("b.csv");
2204 let out_a = dir.path().join("a.jsonl");
2205 let out_b = dir.path().join("b.jsonl");
2206 std::fs::write(&csv_a, "name\nalice\n").unwrap();
2207 std::fs::write(&csv_b, "name\nbob\n").unwrap();
2208
2209 let yaml = format!(
2210 r#"version: 1
2211pipeline:
2212 source: {{ type: csv, config: {{ path: {a} }} }}
2213 sink: {{ type: jsonl, config: {{ path: {out_a} }} }}
2214matrix:
2215 - id: rowA
2216 - id: rowB
2217 source: {{ config: {{ path: {b} }} }}
2218 sink: {{ config: {{ path: {out_b} }} }}
2219"#,
2220 a = csv_a.display(),
2221 b = csv_b.display(),
2222 out_a = out_a.display(),
2223 out_b = out_b.display(),
2224 );
2225 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2226 let nodes = expand(&cfg).unwrap();
2227 let summary = run_expanded(
2228 nodes,
2229 ExecuteOptions {
2230 pipeline_name: "matrix".into(),
2231 execution: None,
2232 dry_run: false,
2233 limit: None,
2234 state_path_override: None,
2235 shard: None,
2236 auth: Default::default(),
2237 clock: chrono::Utc::now().fixed_offset(),
2238 cancel: None,
2239 resilience: None,
2240 sla: None,
2241 #[cfg(feature = "lineage")]
2242 lineage: None,
2243 #[cfg(feature = "lineage")]
2244 lineage_cfg: None,
2245 #[cfg(feature = "notify")]
2246 notifier: None,
2247 #[cfg(feature = "catalog")]
2248 catalog: None,
2249 },
2250 )
2251 .await
2252 .unwrap();
2253 assert_eq!(summary.invocations.len(), 2);
2254 assert!(out_a.exists());
2255 assert!(out_b.exists());
2256 }
2257
2258 #[tokio::test]
2259 async fn dag_child_fans_out_per_parent_record() {
2260 let dir = tempfile::tempdir().unwrap();
2263 let parent_csv = dir.path().join("parents.csv");
2264 let child_csv = dir.path().join("child.csv");
2265 std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2266 std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2267 let parent_out = dir.path().join("parents.jsonl");
2268 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2269
2270 let yaml = format!(
2271 r#"version: 1
2272pipeline:
2273 source: {{ type: csv, config: {{ path: {parent} }} }}
2274 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2275matrix:
2276 - id: parents
2277 - id: child
2278 parent: parents
2279 source: {{ config: {{ path: {child} }} }}
2280 sink: {{ config: {{ path: "{child_out}" }} }}
2281"#,
2282 parent = parent_csv.display(),
2283 parent_out = parent_out.display(),
2284 child = child_csv.display(),
2285 child_out = child_out_pattern.display(),
2286 );
2287 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2288 let nodes = expand(&cfg).unwrap();
2289 let summary = run_expanded(
2290 nodes,
2291 ExecuteOptions {
2292 pipeline_name: "dagtest".into(),
2293 execution: None,
2294 dry_run: false,
2295 limit: None,
2296 state_path_override: None,
2297 shard: None,
2298 auth: Default::default(),
2299 clock: chrono::Utc::now().fixed_offset(),
2300 cancel: None,
2301 resilience: None,
2302 sla: None,
2303 #[cfg(feature = "lineage")]
2304 lineage: None,
2305 #[cfg(feature = "lineage")]
2306 lineage_cfg: None,
2307 #[cfg(feature = "notify")]
2308 notifier: None,
2309 #[cfg(feature = "catalog")]
2310 catalog: None,
2311 },
2312 )
2313 .await
2314 .unwrap();
2315
2316 assert_eq!(summary.invocations.len(), 3);
2318 assert!(!summary.had_failures(), "{:?}", summary);
2319 assert!(dir.path().join("child-1.jsonl").exists());
2320 assert!(dir.path().join("child-2.jsonl").exists());
2321 }
2322
2323 #[tokio::test]
2324 async fn depends_on_root_runs_after_dependency() {
2325 let dir = tempfile::tempdir().unwrap();
2329 let input = dir.path().join("in.csv");
2330 let mid = dir.path().join("mid.csv");
2331 let out = dir.path().join("out.jsonl");
2332 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2333
2334 let yaml = format!(
2335 r#"version: 1
2336pipeline:
2337 source: {{ type: csv, config: {{ path: {input} }} }}
2338 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2339matrix:
2340 - id: stage
2341 sink: {{ type: csv, config: {{ path: {mid} }} }}
2342 - id: load
2343 depends_on: [stage]
2344 source: {{ config: {{ path: {mid} }} }}
2345"#,
2346 input = input.display(),
2347 mid = mid.display(),
2348 out = out.display(),
2349 );
2350 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2351 let nodes = expand(&cfg).unwrap();
2352 let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2353 assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2354 assert!(!summary.had_failures(), "{summary:?}");
2355 let load = summary
2356 .invocations
2357 .iter()
2358 .find(|i| i.row_id == "load")
2359 .unwrap();
2360 assert_eq!(load.records_written, 2);
2361 let written = std::fs::read_to_string(&out).unwrap();
2362 assert_eq!(written.lines().count(), 2);
2363 }
2364
2365 #[tokio::test]
2366 async fn diamond_dependency_waits_for_all_prerequisites() {
2367 let dir = tempfile::tempdir().unwrap();
2370 let input = dir.path().join("in.csv");
2371 let mid_a = dir.path().join("mid_a.csv");
2372 let mid_b = dir.path().join("mid_b.csv");
2373 let out = dir.path().join("out.jsonl");
2374 std::fs::write(&input, "name\nalice\n").unwrap();
2375
2376 let yaml = format!(
2377 r#"version: 1
2378pipeline:
2379 source: {{ type: csv, config: {{ path: {input} }} }}
2380 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2381matrix:
2382 - id: a
2383 sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2384 - id: b
2385 sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2386 - id: c
2387 depends_on: [a, b]
2388 source: {{ config: {{ path: {mid_a} }} }}
2389"#,
2390 input = input.display(),
2391 mid_a = mid_a.display(),
2392 mid_b = mid_b.display(),
2393 out = out.display(),
2394 );
2395 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2396 let nodes = expand(&cfg).unwrap();
2397 let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2398 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2399 assert!(!summary.had_failures(), "{summary:?}");
2400 assert!(mid_b.exists(), "b must have run before c became ready");
2401 assert!(out.exists());
2402 }
2403
2404 #[tokio::test]
2405 async fn failed_dependency_skips_dependent() {
2406 let dir = tempfile::tempdir().unwrap();
2409 let good_input = dir.path().join("good.csv");
2410 let out = dir.path().join("out.jsonl");
2411 std::fs::write(&good_input, "name\nalice\n").unwrap();
2412
2413 let yaml = format!(
2414 r#"version: 1
2415pipeline:
2416 source: {{ type: csv, config: {{ path: {good} }} }}
2417 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2418matrix:
2419 - id: stage
2420 source: {{ config: {{ path: {missing} }} }}
2421 - id: load
2422 depends_on: [stage]
2423"#,
2424 good = good_input.display(),
2425 missing = dir.path().join("nonexistent.csv").display(),
2426 out = out.display(),
2427 );
2428 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2429 let nodes = expand(&cfg).unwrap();
2430 let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2431 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2432 assert_eq!(summary.invocations[0].row_id, "stage");
2433 assert!(summary.invocations[0].error.is_some());
2434 assert!(
2435 !out.exists(),
2436 "dependent row must not run after its dependency failed"
2437 );
2438 }
2439
2440 #[tokio::test]
2441 async fn dependency_on_skipped_row_cascades() {
2442 let dir = tempfile::tempdir().unwrap();
2445 let good_input = dir.path().join("good.csv");
2446 let out = dir.path().join("q.jsonl");
2447 std::fs::write(&good_input, "id\n1\n").unwrap();
2448
2449 let yaml = format!(
2450 r#"version: 1
2451pipeline:
2452 source: {{ type: csv, config: {{ path: {good} }} }}
2453 sink: {{ type: jsonl, config: {{ path: {out} }} }}
2454matrix:
2455 - id: p
2456 source: {{ config: {{ path: {missing} }} }}
2457 - id: c
2458 parent: p
2459 - id: q
2460 depends_on: [c]
2461"#,
2462 good = good_input.display(),
2463 missing = dir.path().join("nonexistent.csv").display(),
2464 out = out.display(),
2465 );
2466 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2467 let nodes = expand(&cfg).unwrap();
2468 let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2469 assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2470 assert_eq!(summary.invocations[0].row_id, "p");
2471 assert!(summary.invocations[0].error.is_some());
2472 assert!(
2473 !out.exists(),
2474 "q must be skipped when its dependency was skipped"
2475 );
2476 }
2477
2478 #[tokio::test]
2479 async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2480 let dir = tempfile::tempdir().unwrap();
2492 let good_csv = dir.path().join("good.csv");
2493 std::fs::write(&good_csv, "x\n1\n").unwrap();
2494 let good_out = dir.path().join("good.jsonl");
2495 let bad_sink_dir = dir.path().to_path_buf();
2496
2497 let yaml = format!(
2498 r#"version: 1
2499pipeline:
2500 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2501 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2502matrix:
2503 - id: bad
2504 sink: {{ config: {{ path: {bad_dir} }} }}
2505 - id: good
2506execution:
2507 max_concurrent: 1
2508 on_error: stop
2509"#,
2510 good_csv = good_csv.display(),
2511 good_out = good_out.display(),
2512 bad_dir = bad_sink_dir.display(),
2513 );
2514 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2515 let nodes = expand(&cfg).unwrap();
2516 let summary = run_expanded(
2517 nodes,
2518 ExecuteOptions {
2519 pipeline_name: "stoptest".into(),
2520 execution: cfg.execution.clone(),
2521 dry_run: false,
2522 limit: None,
2523 state_path_override: None,
2524 shard: None,
2525 auth: Default::default(),
2526 clock: chrono::Utc::now().fixed_offset(),
2527 cancel: None,
2528 resilience: None,
2529 sla: None,
2530 #[cfg(feature = "lineage")]
2531 lineage: None,
2532 #[cfg(feature = "lineage")]
2533 lineage_cfg: None,
2534 #[cfg(feature = "notify")]
2535 notifier: None,
2536 #[cfg(feature = "catalog")]
2537 catalog: None,
2538 },
2539 )
2540 .await
2541 .unwrap();
2542
2543 assert!(summary.had_failures(), "the failing root must be reported");
2545
2546 let bad: Vec<_> = summary
2548 .invocations
2549 .iter()
2550 .filter(|o| o.row_id == "bad")
2551 .collect();
2552 assert_eq!(bad.len(), 1, "bad must run exactly once");
2553 assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2554
2555 assert!(
2557 summary.invocations.len() <= 2,
2558 "at most the two roots may run, got {:?}",
2559 summary.invocations
2560 );
2561
2562 let good_wrote = summary
2569 .invocations
2570 .iter()
2571 .find(|o| o.row_id == "good" && o.error.is_none())
2572 .map(|o| o.records_written)
2573 .unwrap_or(0);
2574 if good_wrote > 0 {
2575 assert!(
2576 good_out.exists(),
2577 "a good that wrote records must have produced its output file"
2578 );
2579 }
2580 }
2581
2582 #[tokio::test]
2583 async fn invalid_pipeline_name_with_state_errors_up_front() {
2584 let dir = tempfile::tempdir().unwrap();
2588 let input = dir.path().join("in.csv");
2589 let output = dir.path().join("out.jsonl");
2590 std::fs::write(&input, "name\nalice\n").unwrap();
2591 let yaml = format!(
2592 r#"version: 1
2593pipeline:
2594 source: {{ type: csv, config: {{ path: {input} }} }}
2595 sink: {{ type: jsonl, config: {{ path: {output} }} }}
2596 state: {{ type: memory }}
2597"#,
2598 input = input.display(),
2599 output = output.display(),
2600 );
2601 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2602 let nodes = expand(&cfg).unwrap();
2603 let err = run_expanded(
2604 nodes,
2605 ExecuteOptions {
2606 pipeline_name: "bad name".into(), execution: None,
2608 dry_run: false,
2609 limit: None,
2610 state_path_override: None,
2611 shard: None,
2612 auth: Default::default(),
2613 clock: chrono::Utc::now().fixed_offset(),
2614 cancel: None,
2615 resilience: None,
2616 sla: None,
2617 #[cfg(feature = "lineage")]
2618 lineage: None,
2619 #[cfg(feature = "lineage")]
2620 lineage_cfg: None,
2621 #[cfg(feature = "notify")]
2622 notifier: None,
2623 #[cfg(feature = "catalog")]
2624 catalog: None,
2625 },
2626 )
2627 .await
2628 .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2629 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2630 }
2631
2632 #[tokio::test]
2633 async fn invalid_parent_key_value_with_state_errors_up_front() {
2634 let dir = tempfile::tempdir().unwrap();
2637 let parent_csv = dir.path().join("parents.csv");
2638 let child_csv = dir.path().join("child.csv");
2639 std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2641 std::fs::write(&child_csv, "x\nA\n").unwrap();
2642 let parent_out = dir.path().join("parents.jsonl");
2643 let child_out = dir.path().join("child.jsonl");
2644 let yaml = format!(
2645 r#"version: 1
2646pipeline:
2647 source: {{ type: csv, config: {{ path: {parent} }} }}
2648 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
2649 state: {{ type: memory }}
2650matrix:
2651 - id: parents
2652 - id: child
2653 parent: parents
2654 source: {{ config: {{ path: {child} }} }}
2655 sink: {{ config: {{ path: {child_out} }} }}
2656"#,
2657 parent = parent_csv.display(),
2658 parent_out = parent_out.display(),
2659 child = child_csv.display(),
2660 child_out = child_out.display(),
2661 );
2662 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2663 let nodes = expand(&cfg).unwrap();
2664 let err = run_expanded(
2665 nodes,
2666 ExecuteOptions {
2667 pipeline_name: "ok".into(),
2668 execution: None,
2669 dry_run: false,
2670 limit: None,
2671 state_path_override: None,
2672 shard: None,
2673 auth: Default::default(),
2674 clock: chrono::Utc::now().fixed_offset(),
2675 cancel: None,
2676 resilience: None,
2677 sla: None,
2678 #[cfg(feature = "lineage")]
2679 lineage: None,
2680 #[cfg(feature = "lineage")]
2681 lineage_cfg: None,
2682 #[cfg(feature = "notify")]
2683 notifier: None,
2684 #[cfg(feature = "catalog")]
2685 catalog: None,
2686 },
2687 )
2688 .await
2689 .expect_err(
2690 "an illegal parent-key value must be rejected up front when state is configured",
2691 );
2692 assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2693 }
2694
2695 #[tokio::test]
2696 async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2697 let dir = tempfile::tempdir().unwrap();
2706 let bad_sink_dir = dir.path().to_path_buf();
2707 let good_csv = dir.path().join("good.csv");
2710 std::fs::write(&good_csv, "x\n1\n").unwrap();
2711 let yaml = format!(
2717 r#"version: 1
2718pipeline:
2719 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2720 sink: {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2721matrix:
2722 - id: bad
2723 - id: good_a
2724 - id: good_b
2725execution:
2726 max_concurrent: 3
2727 on_error: stop
2728"#,
2729 good_csv = good_csv.display(),
2730 bad_dir = bad_sink_dir.display(),
2731 );
2732 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2733 let nodes = expand(&cfg).unwrap();
2734 let summary = run_expanded(
2735 nodes,
2736 ExecuteOptions {
2737 pipeline_name: "stop_parallel".into(),
2738 execution: cfg.execution.clone(),
2739 dry_run: false,
2740 limit: None,
2741 state_path_override: None,
2742 shard: None,
2743 auth: Default::default(),
2744 clock: chrono::Utc::now().fixed_offset(),
2745 cancel: None,
2746 resilience: None,
2747 sla: None,
2748 #[cfg(feature = "lineage")]
2749 lineage: None,
2750 #[cfg(feature = "lineage")]
2751 lineage_cfg: None,
2752 #[cfg(feature = "notify")]
2753 notifier: None,
2754 #[cfg(feature = "catalog")]
2755 catalog: None,
2756 },
2757 )
2758 .await
2759 .unwrap();
2760
2761 assert!(
2766 summary.had_failures(),
2767 "summary should record at least one failure: {summary:?}"
2768 );
2769 assert!(
2770 summary.invocations[0].error.is_some(),
2771 "first outcome must be the failure that triggered stop: {summary:?}"
2772 );
2773 for inv in &summary.invocations {
2777 assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2778 }
2779 }
2780
2781 #[tokio::test]
2782 async fn on_error_continue_skips_failed_subtree_only() {
2783 let dir = tempfile::tempdir().unwrap();
2785 let good_csv = dir.path().join("good.csv");
2786 std::fs::write(&good_csv, "x\n1\n").unwrap();
2787 let good_out = dir.path().join("good.jsonl");
2788
2789 let yaml = format!(
2790 r#"version: 1
2791pipeline:
2792 source: {{ type: csv, config: {{ path: {good_csv} }} }}
2793 sink: {{ type: jsonl, config: {{ path: {good_out} }} }}
2794matrix:
2795 - id: bad
2796 sink: {{ config: {{ path: {bad_dir} }} }}
2797 - id: good
2798"#,
2799 good_csv = good_csv.display(),
2800 good_out = good_out.display(),
2801 bad_dir = dir.path().display(),
2802 );
2803 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2804 let nodes = expand(&cfg).unwrap();
2805 let summary = run_expanded(
2806 nodes,
2807 ExecuteOptions {
2808 pipeline_name: "continuetest".into(),
2809 execution: None,
2810 dry_run: false,
2811 limit: None,
2812 state_path_override: None,
2813 shard: None,
2814 auth: Default::default(),
2815 clock: chrono::Utc::now().fixed_offset(),
2816 cancel: None,
2817 resilience: None,
2818 sla: None,
2819 #[cfg(feature = "lineage")]
2820 lineage: None,
2821 #[cfg(feature = "lineage")]
2822 lineage_cfg: None,
2823 #[cfg(feature = "notify")]
2824 notifier: None,
2825 #[cfg(feature = "catalog")]
2826 catalog: None,
2827 },
2828 )
2829 .await
2830 .unwrap();
2831 assert_eq!(summary.invocations.len(), 2);
2832 assert_eq!(summary.failure_count(), 1);
2833 let good_outcome = summary
2834 .invocations
2835 .iter()
2836 .find(|i| i.row_id == "good")
2837 .unwrap();
2838 assert!(good_outcome.error.is_none());
2839 }
2840
2841 #[test]
2844 fn split_path_splits_on_dots() {
2845 assert_eq!(split_path("id"), vec!["id".to_string()]);
2846 assert_eq!(
2847 split_path("user.name"),
2848 vec!["user".to_string(), "name".to_string()]
2849 );
2850 }
2851
2852 #[test]
2853 fn minimal_paths_drops_descendants_of_kept_ancestors() {
2854 let paths = vec![
2855 vec!["user".into(), "name".into()],
2856 vec!["user".into()],
2857 vec!["id".into()],
2858 vec!["id".into()],
2859 ];
2860 let min = minimal_paths(paths);
2861 assert!(min.contains(&vec!["user".to_string()]));
2862 assert!(min.contains(&vec!["id".to_string()]));
2863 assert!(
2864 !min.contains(&vec!["user".to_string(), "name".to_string()]),
2865 "user.name must be dropped — covered by user"
2866 );
2867 assert_eq!(min.len(), 2);
2868 }
2869
2870 #[test]
2871 fn project_full_clones_whole_record() {
2872 let r = json!({"a": 1, "b": {"c": 2}});
2873 assert_eq!(project_record(&r, &Projection::Full), r);
2874 }
2875
2876 #[test]
2877 fn project_keeps_only_referenced_paths() {
2878 let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2879 let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2880 let got = project_record(&r, &p);
2881 assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2882 assert!(got.get("blob").is_none());
2883 assert!(got["user"].get("age").is_none());
2884 }
2885
2886 #[test]
2887 fn project_array_index_path_resolves_same_as_original() {
2888 let r = json!({"tags": ["x", "y", "z"]});
2889 let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2890 let got = project_record(&r, &p);
2891 assert_eq!(got, json!({"tags": {"0": "x"}}));
2892 assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2893 assert_eq!(
2894 resolve_parent_key(&got, "tags.0"),
2895 resolve_parent_key(&r, "tags.0"),
2896 "reduced tree must resolve the same value as the original"
2897 );
2898 }
2899
2900 #[test]
2901 fn project_numeric_object_key_resolves_same_as_original() {
2902 let r = json!({"data": {"0": "x", "1": "y"}});
2907 let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
2908 let got = project_record(&r, &p);
2909 assert_eq!(got, json!({"data": {"0": "x"}}));
2910 assert_eq!(
2911 resolve_parent_key(&got, "data.0"),
2912 resolve_parent_key(&r, "data.0"),
2913 "numeric object-key path must resolve identically on the reduced tree"
2914 );
2915 }
2916
2917 #[test]
2918 fn project_missing_path_is_omitted() {
2919 let r = json!({"id": 1});
2920 let p = Projection::Paths(vec![vec!["nope".into()]]);
2921 assert_eq!(project_record(&r, &p), json!({}));
2922 }
2923
2924 #[test]
2925 fn build_projections_unions_parent_key_and_refs() {
2926 use crate::config::ConnectorSpec;
2927 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2928
2929 fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
2930 ExpandedNode {
2931 id: id.into(),
2932 row_index: 0,
2933 role: NodeRole::Child {
2934 parent_id: parent.into(),
2935 parent_key: parent_key.into(),
2936 },
2937 source: ConnectorSpec {
2938 kind: "csv".into(),
2939 config: json!({}),
2940 transforms: None,
2941 inherit_transforms: true,
2942 status: None,
2943 tags: Vec::new(),
2944 },
2945 sink: ConnectorSpec {
2946 kind: "jsonl".into(),
2947 config: json!({}),
2948 transforms: None,
2949 inherit_transforms: true,
2950 status: None,
2951 tags: Vec::new(),
2952 },
2953 transforms: Vec::new(),
2954 state: None,
2955 dlq: None,
2956 delivery: faucet_core::DeliveryMode::AtLeastOnce,
2957 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
2958 #[cfg(feature = "quality")]
2959 quality: None,
2960 #[cfg(feature = "contract")]
2961 contract: None,
2962 #[cfg(feature = "masking")]
2963 masking: None,
2964 sink_ref: "default".into(),
2965 schema: None,
2966 depends_on: Vec::new(),
2967 status: crate::config::SourceStatus::Active,
2968 tags: Vec::new(),
2969 deferred_refs: refs
2970 .iter()
2971 .map(|(rid, p)| DeferredRef {
2972 referenced_id: (*rid).into(),
2973 dotted_path: (*p).into(),
2974 token: format!("${{{rid}.{p}}}"),
2975 })
2976 .collect(),
2977 source_override: None,
2978 }
2979 }
2980
2981 let c1 = child("c1", "p", "id", &[("p", "user.name")]);
2982 let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
2983 let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
2984 let children_of =
2985 HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
2986
2987 let projs = build_projections(&nodes_by_id, &children_of);
2988 let p = projs.get("p").expect("projection for p");
2989 match &**p {
2990 Projection::Paths(paths) => {
2991 assert!(paths.contains(&vec!["id".to_string()]));
2992 assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
2993 assert!(paths.contains(&vec!["email".to_string()]));
2994 assert!(
2995 !paths.iter().any(|p| p == &vec!["x".to_string()]),
2996 "a ref to a different parent must not be captured under p"
2997 );
2998 }
2999 Projection::Full => panic!("expected Paths, got Full"),
3000 }
3001 }
3002
3003 #[test]
3004 fn build_projections_whole_record_ref_is_full() {
3005 use crate::config::ConnectorSpec;
3006 use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3007 let c = ExpandedNode {
3008 id: "c".into(),
3009 row_index: 0,
3010 role: NodeRole::Child {
3011 parent_id: "p".into(),
3012 parent_key: "id".into(),
3013 },
3014 source: ConnectorSpec {
3015 kind: "csv".into(),
3016 config: json!({}),
3017 transforms: None,
3018 inherit_transforms: true,
3019 status: None,
3020 tags: Vec::new(),
3021 },
3022 sink: ConnectorSpec {
3023 kind: "jsonl".into(),
3024 config: json!({}),
3025 transforms: None,
3026 inherit_transforms: true,
3027 status: None,
3028 tags: Vec::new(),
3029 },
3030 transforms: Vec::new(),
3031 state: None,
3032 dlq: None,
3033 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3034 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3035 #[cfg(feature = "quality")]
3036 quality: None,
3037 #[cfg(feature = "contract")]
3038 contract: None,
3039 #[cfg(feature = "masking")]
3040 masking: None,
3041 sink_ref: "default".into(),
3042 schema: None,
3043 depends_on: Vec::new(),
3044 status: crate::config::SourceStatus::Active,
3045 tags: Vec::new(),
3046 deferred_refs: vec![DeferredRef {
3047 referenced_id: "p".into(),
3048 dotted_path: "".into(),
3049 token: "${p}".into(),
3050 }],
3051 source_override: None,
3052 };
3053 let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3054 let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3055 let projs = build_projections(&nodes_by_id, &children_of);
3056 assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3057 }
3058
3059 fn opts(name: &str) -> ExecuteOptions {
3061 ExecuteOptions {
3062 pipeline_name: name.into(),
3063 execution: None,
3064 dry_run: false,
3065 limit: None,
3066 state_path_override: None,
3067 shard: None,
3068 auth: Default::default(),
3069 clock: chrono::Utc::now().fixed_offset(),
3070 cancel: None,
3071 resilience: None,
3072 sla: None,
3073 #[cfg(feature = "lineage")]
3074 lineage: None,
3075 #[cfg(feature = "lineage")]
3076 lineage_cfg: None,
3077 #[cfg(feature = "notify")]
3078 notifier: None,
3079 #[cfg(feature = "catalog")]
3080 catalog: None,
3081 }
3082 }
3083
3084 #[tokio::test]
3085 async fn dry_run_counts_records_without_writing_sink_file() {
3086 let dir = tempfile::tempdir().unwrap();
3089 let input = dir.path().join("in.csv");
3090 let output = dir.path().join("out.jsonl");
3091 std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
3092 let cfg = cfg_csv_to_jsonl(&input, &output);
3093 let nodes = expand(&cfg).unwrap();
3094 let mut o = opts("dry");
3095 o.dry_run = true;
3096 let summary = run_expanded(nodes, o).await.unwrap();
3097 assert_eq!(summary.invocations.len(), 1);
3098 assert_eq!(summary.invocations[0].records_written, 3);
3099 assert!(!summary.had_failures());
3100 assert!(
3101 !output.exists(),
3102 "dry-run must not create the real sink file"
3103 );
3104 }
3105
3106 #[tokio::test]
3107 async fn read_only_state_store_drops_writes_keeps_reads() {
3108 let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
3111 inner.put("k", &json!("v0")).await.unwrap();
3112 let ro = ReadOnlyStateStore {
3113 inner: inner.clone(),
3114 };
3115 assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
3116 ro.put("k", &json!("advanced")).await.unwrap();
3118 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3119 ro.delete("k").await.unwrap();
3121 assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3122 }
3123
3124 #[tokio::test]
3125 async fn dry_run_with_state_does_not_persist_bookmark() {
3126 let dir = tempfile::tempdir().unwrap();
3130 let input = dir.path().join("in.csv");
3131 let output = dir.path().join("out.jsonl");
3132 let state_dir = dir.path().join("state");
3133 std::fs::create_dir_all(&state_dir).unwrap();
3134 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3135 let cfg = cfg_csv_to_jsonl(&input, &output);
3136 let nodes = expand(&cfg).unwrap();
3137 let mut o = opts("drystate");
3138 o.dry_run = true;
3139 o.state_path_override = Some(state_dir.clone());
3140 let summary = run_expanded(nodes, o).await.unwrap();
3141 assert!(!summary.had_failures());
3142 assert!(!output.exists(), "dry-run must not write the sink file");
3143 let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3146 .unwrap()
3147 .filter_map(Result::ok)
3148 .collect();
3149 assert!(
3150 persisted.is_empty(),
3151 "dry-run must not persist any bookmark file, found: {persisted:?}"
3152 );
3153 }
3154
3155 #[tokio::test]
3156 async fn limit_caps_records_written_across_the_run() {
3157 let dir = tempfile::tempdir().unwrap();
3159 let input = dir.path().join("in.csv");
3160 let output = dir.path().join("out.jsonl");
3161 std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3162 let cfg = cfg_csv_to_jsonl(&input, &output);
3163 let nodes = expand(&cfg).unwrap();
3164 let mut o = opts("lim");
3165 o.limit = Some(2);
3166 let summary = run_expanded(nodes, o).await.unwrap();
3167 assert_eq!(summary.invocations[0].records_written, 2);
3168 let body = std::fs::read_to_string(&output).unwrap();
3169 assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3170 }
3171
3172 #[tokio::test]
3173 async fn duplicate_state_key_among_siblings_is_rejected() {
3174 let dir = tempfile::tempdir().unwrap();
3178 let parent_csv = dir.path().join("parents.csv");
3179 let child_csv = dir.path().join("child.csv");
3180 std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3182 std::fs::write(&child_csv, "x\nA\n").unwrap();
3183 let parent_out = dir.path().join("parents.jsonl");
3184 let child_out = dir.path().join("child.jsonl");
3185 let yaml = format!(
3186 r#"version: 1
3187pipeline:
3188 source: {{ type: csv, config: {{ path: {parent} }} }}
3189 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3190 state: {{ type: memory }}
3191matrix:
3192 - id: parents
3193 - id: child
3194 parent: parents
3195 source: {{ config: {{ path: {child} }} }}
3196 sink: {{ config: {{ path: {child_out} }} }}
3197"#,
3198 parent = parent_csv.display(),
3199 parent_out = parent_out.display(),
3200 child = child_csv.display(),
3201 child_out = child_out.display(),
3202 );
3203 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3204 let nodes = expand(&cfg).unwrap();
3205 let err = run_expanded(nodes, opts("dupkey"))
3206 .await
3207 .expect_err("colliding sibling state keys must be rejected");
3208 match err {
3209 CliError::DuplicateStateKey { id, state_key } => {
3210 assert_eq!(id, "child");
3211 assert_eq!(state_key, "dupkey::child::dup");
3212 }
3213 other => panic!("expected DuplicateStateKey, got {other:?}"),
3214 }
3215 }
3216
3217 #[tokio::test]
3218 async fn state_path_override_writes_bookmark_file() {
3219 let dir = tempfile::tempdir().unwrap();
3224 let input = dir.path().join("in.csv");
3225 let output = dir.path().join("out.jsonl");
3226 let state_dir = dir.path().join("state");
3227 std::fs::write(&input, "name\nalice\n").unwrap();
3228 let cfg = cfg_csv_to_jsonl(&input, &output);
3229 let nodes = expand(&cfg).unwrap();
3230 let mut o = opts("statepath");
3231 o.state_path_override = Some(state_dir.clone());
3232 let summary = run_expanded(nodes, o).await.unwrap();
3233 assert!(!summary.had_failures());
3234 assert_eq!(summary.invocations[0].records_written, 1);
3238 }
3239
3240 #[tokio::test]
3241 async fn build_dlq_config_maps_spec_fields() {
3242 use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3243 let dir = tempfile::tempdir().unwrap();
3244 let dlq_out = dir.path().join("dlq.jsonl");
3245 let spec = DlqSpec {
3246 sink: ConnectorSpec {
3247 kind: "jsonl".into(),
3248 config: json!({ "path": dlq_out.to_str().unwrap() }),
3249 transforms: None,
3250 inherit_transforms: true,
3251 status: None,
3252 tags: Vec::new(),
3253 },
3254 on_batch_error: OnBatchErrorSpec::DlqAll,
3255 max_failures_per_page: Some(7),
3256 max_failures_total: Some(42),
3257 include_original_payload: false,
3258 };
3259 let cfg = build_dlq_config(&spec).await.unwrap();
3260 assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3261 assert_eq!(cfg.max_failures_per_page, Some(7));
3262 assert_eq!(cfg.max_failures_total, Some(42));
3263 assert!(!cfg.include_original_payload);
3264 }
3265
3266 #[tokio::test]
3267 async fn build_state_for_node_arms() {
3268 let dir = tempfile::tempdir().unwrap();
3269
3270 let node = stub_node(None);
3272 assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3273
3274 let p = dir.path().join("s1");
3276 assert!(
3277 build_state_for_node(&node, Some(&p))
3278 .await
3279 .unwrap()
3280 .is_some()
3281 );
3282
3283 let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3285 kind: "memory".into(),
3286 config: json!({}),
3287 }));
3288 assert!(
3289 build_state_for_node(&node_mem, None)
3290 .await
3291 .unwrap()
3292 .is_some()
3293 );
3294
3295 let node_file = stub_node(Some(crate::config::StateStoreSpec {
3297 kind: "file".into(),
3298 config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3299 }));
3300 let p2 = dir.path().join("override2");
3301 assert!(
3302 build_state_for_node(&node_file, Some(&p2))
3303 .await
3304 .unwrap()
3305 .is_some()
3306 );
3307
3308 let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3311 kind: "memory".into(),
3312 config: json!({}),
3313 }));
3314 let p3 = dir.path().join("override3");
3315 assert!(
3316 build_state_for_node(&node_mem2, Some(&p3))
3317 .await
3318 .unwrap()
3319 .is_some()
3320 );
3321 }
3322
3323 fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3325 use crate::config::ConnectorSpec;
3326 ExpandedNode {
3327 id: "n".into(),
3328 row_index: 0,
3329 role: NodeRole::Root,
3330 source: ConnectorSpec {
3331 kind: "csv".into(),
3332 config: json!({}),
3333 transforms: None,
3334 inherit_transforms: true,
3335 status: None,
3336 tags: Vec::new(),
3337 },
3338 sink: ConnectorSpec {
3339 kind: "jsonl".into(),
3340 config: json!({}),
3341 transforms: None,
3342 inherit_transforms: true,
3343 status: None,
3344 tags: Vec::new(),
3345 },
3346 transforms: Vec::new(),
3347 state,
3348 dlq: None,
3349 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3350 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3351 #[cfg(feature = "quality")]
3352 quality: None,
3353 #[cfg(feature = "contract")]
3354 contract: None,
3355 #[cfg(feature = "masking")]
3356 masking: None,
3357 sink_ref: "default".into(),
3358 schema: None,
3359 depends_on: Vec::new(),
3360 status: crate::config::SourceStatus::Active,
3361 tags: Vec::new(),
3362 deferred_refs: Vec::new(),
3363 source_override: None,
3364 }
3365 }
3366
3367 #[tokio::test]
3368 async fn state_key_override_delegates_and_overrides_key() {
3369 let dir = tempfile::tempdir().unwrap();
3371 let input = dir.path().join("in.csv");
3372 std::fs::write(&input, "name\nz\n").unwrap();
3373 let inner = build_source(
3374 "csv",
3375 json!({"path": input.to_str().unwrap()}),
3376 &AuthCatalog::new(),
3377 None,
3378 )
3379 .await
3380 .unwrap();
3381 let inner_name = inner.connector_name();
3383 let ov = StateKeyOverride {
3384 inner,
3385 key: "my::custom::key".into(),
3386 };
3387 assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3388 assert_eq!(ov.connector_name(), inner_name);
3389 let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3390 assert_eq!(rows.len(), 1);
3391 ov.apply_start_bookmark(json!({"any": "bookmark"}))
3393 .await
3394 .unwrap();
3395 assert!(!ov.supports_exactly_once());
3397 assert_eq!(
3398 ov.replay_guarantee(),
3399 faucet_core::ReplayGuarantee::NonDeterministic
3400 );
3401 assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3402 }
3403
3404 #[tokio::test]
3405 async fn state_key_override_forwards_native_stream_pages() {
3406 struct PerPageBookmarkSource;
3412 #[async_trait]
3413 impl Source for PerPageBookmarkSource {
3414 async fn fetch_with_context(
3415 &self,
3416 _ctx: &HashMap<String, Value>,
3417 ) -> Result<Vec<Value>, FaucetError> {
3418 Ok(vec![json!({"id": 1}), json!({"id": 2})])
3419 }
3420 fn stream_pages<'a>(
3421 &'a self,
3422 _ctx: &'a HashMap<String, Value>,
3423 _batch_size: usize,
3424 ) -> std::pin::Pin<
3425 Box<
3426 dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3427 + Send
3428 + 'a,
3429 >,
3430 > {
3431 Box::pin(faucet_core::async_stream::try_stream! {
3432 yield faucet_core::StreamPage {
3433 records: vec![json!({"id": 1})],
3434 bookmark: Some(json!("bm-1")),
3435 };
3436 yield faucet_core::StreamPage {
3437 records: vec![json!({"id": 2})],
3438 bookmark: Some(json!("bm-2")),
3439 };
3440 })
3441 }
3442 fn state_key(&self) -> Option<String> {
3443 Some("native".into())
3444 }
3445 }
3446
3447 use futures::StreamExt;
3448 let ov = StateKeyOverride {
3449 inner: Box::new(PerPageBookmarkSource),
3450 key: "override".into(),
3451 };
3452 let ctx = HashMap::new();
3453 let pages: Vec<_> = ov
3454 .stream_pages(&ctx, 1000)
3455 .collect::<Vec<_>>()
3456 .await
3457 .into_iter()
3458 .collect::<Result<Vec<_>, _>>()
3459 .unwrap();
3460 assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3461 assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3462 assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3463 }
3464
3465 #[tokio::test]
3466 async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3467 struct IdemSink;
3468 #[async_trait]
3469 impl Sink for IdemSink {
3470 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3471 Ok(records.len())
3472 }
3473 fn connector_name(&self) -> &'static str {
3474 "idem"
3475 }
3476 fn supports_idempotent_writes(&self) -> bool {
3477 true
3478 }
3479 fn dedups_by_key(&self) -> bool {
3480 true
3481 }
3482 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3483 &[
3484 faucet_core::WriteMode::Append,
3485 faucet_core::WriteMode::Upsert,
3486 ]
3487 }
3488 async fn write_batch_idempotent(
3489 &self,
3490 records: &[Value],
3491 _scope: &str,
3492 _token: &str,
3493 ) -> Result<usize, FaucetError> {
3494 Ok(records.len())
3495 }
3496 async fn last_committed_token(
3497 &self,
3498 _scope: &str,
3499 ) -> Result<Option<String>, FaucetError> {
3500 Ok(Some("tok".into()))
3501 }
3502 }
3503
3504 let captured = Arc::new(Mutex::new(Vec::new()));
3505 let sink = CapturingSink::wrap(
3506 Box::new(IdemSink),
3507 Arc::clone(&captured),
3508 Arc::new(Projection::Full),
3509 );
3510 assert!(sink.supports_idempotent_writes());
3513 assert!(sink.dedups_by_key());
3514 assert_eq!(
3515 sink.sink_guarantee(),
3516 faucet_core::SinkGuarantee::AtomicWatermark
3517 );
3518 assert!(
3519 sink.supported_write_modes()
3520 .contains(&faucet_core::WriteMode::Upsert)
3521 );
3522 assert_eq!(
3523 sink.last_committed_token("k").await.unwrap(),
3524 Some("tok".into())
3525 );
3526 assert_eq!(sink.current_schema().await.unwrap(), None);
3527 assert!(!sink.supports_schema_evolution());
3528 let n = sink
3530 .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3531 .await
3532 .unwrap();
3533 assert_eq!(n, 1);
3534 assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3535 }
3536
3537 #[tokio::test]
3538 async fn orphaned_child_surfaces_executor_deadlock() {
3539 use crate::config::ConnectorSpec;
3543 let orphan = ExpandedNode {
3544 id: "orphan".into(),
3545 row_index: 0,
3546 role: NodeRole::Child {
3547 parent_id: "missing-parent".into(),
3548 parent_key: "id".into(),
3549 },
3550 source: ConnectorSpec {
3551 kind: "csv".into(),
3552 config: json!({}),
3553 transforms: None,
3554 inherit_transforms: true,
3555 status: None,
3556 tags: Vec::new(),
3557 },
3558 sink: ConnectorSpec {
3559 kind: "jsonl".into(),
3560 config: json!({}),
3561 transforms: None,
3562 inherit_transforms: true,
3563 status: None,
3564 tags: Vec::new(),
3565 },
3566 transforms: Vec::new(),
3567 state: None,
3568 dlq: None,
3569 delivery: faucet_core::DeliveryMode::AtLeastOnce,
3570 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3571 #[cfg(feature = "quality")]
3572 quality: None,
3573 #[cfg(feature = "contract")]
3574 contract: None,
3575 #[cfg(feature = "masking")]
3576 masking: None,
3577 sink_ref: "default".into(),
3578 schema: None,
3579 depends_on: Vec::new(),
3580 status: crate::config::SourceStatus::Active,
3581 tags: Vec::new(),
3582 deferred_refs: Vec::new(),
3583 source_override: None,
3584 };
3585 let err = run_expanded(vec![orphan], opts("deadlock"))
3586 .await
3587 .expect_err("an orphaned child must surface as an executor deadlock");
3588 match err {
3589 CliError::Internal(msg) => {
3590 assert!(msg.contains("executor deadlock"), "{msg}");
3591 assert!(msg.contains("orphan"), "{msg}");
3592 }
3593 other => panic!("expected Internal deadlock error, got {other:?}"),
3594 }
3595 }
3596
3597 #[test]
3598 fn value_to_string_brief_unquotes_strings_only() {
3599 assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3600 assert_eq!(value_to_string_brief(&json!(42)), "42");
3601 assert_eq!(value_to_string_brief(&json!(true)), "true");
3602 assert_eq!(value_to_string_brief(&json!(null)), "null");
3603 assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3604 }
3605
3606 #[test]
3607 fn build_state_key_with_and_without_parent() {
3608 assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3609 assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3610 }
3611
3612 #[test]
3613 fn resolve_parent_key_walks_objects_arrays_and_misses() {
3614 let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3615 assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3616 assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3617 assert_eq!(resolve_parent_key(&r, "user.age"), None);
3619 assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3621 assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3623 }
3624
3625 #[tokio::test]
3626 async fn cooperative_cancel_returns_partial_ok() {
3627 let dir = tempfile::tempdir().unwrap();
3631 let input = dir.path().join("in.csv");
3632 let output = dir.path().join("out.jsonl");
3633 std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3634 let cfg = cfg_csv_to_jsonl(&input, &output);
3635 let nodes = expand(&cfg).unwrap();
3636 let token = CancellationToken::new();
3637 token.cancel(); let mut o = opts("cancel");
3639 o.cancel = Some(token);
3640 let summary = run_expanded(nodes, o).await.unwrap();
3641 assert_eq!(summary.invocations.len(), 1);
3644 assert!(
3645 !summary.had_failures(),
3646 "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3647 );
3648 }
3649
3650 #[tokio::test]
3651 async fn fanout_projects_away_unreferenced_parent_fields() {
3652 let dir = tempfile::tempdir().unwrap();
3656 let parent_csv = dir.path().join("parents.csv");
3657 let child_csv = dir.path().join("child.csv");
3658 std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3659 std::fs::write(&child_csv, "x\nA\n").unwrap();
3660 let parent_out = dir.path().join("parents.jsonl");
3661 let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3662
3663 let yaml = format!(
3664 r#"version: 1
3665pipeline:
3666 source: {{ type: csv, config: {{ path: {parent} }} }}
3667 sink: {{ type: jsonl, config: {{ path: {parent_out} }} }}
3668matrix:
3669 - id: parents
3670 - id: child
3671 parent: parents
3672 source: {{ config: {{ path: {child} }} }}
3673 sink: {{ config: {{ path: "{child_out}" }} }}
3674"#,
3675 parent = parent_csv.display(),
3676 parent_out = parent_out.display(),
3677 child = child_csv.display(),
3678 child_out = child_out_pattern.display(),
3679 );
3680 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3681 let nodes = expand(&cfg).unwrap();
3682 let summary = run_expanded(
3683 nodes,
3684 ExecuteOptions {
3685 pipeline_name: "projtest".into(),
3686 execution: None,
3687 dry_run: false,
3688 limit: None,
3689 state_path_override: None,
3690 shard: None,
3691 auth: Default::default(),
3692 clock: chrono::Utc::now().fixed_offset(),
3693 cancel: None,
3694 resilience: None,
3695 sla: None,
3696 #[cfg(feature = "lineage")]
3697 lineage: None,
3698 #[cfg(feature = "lineage")]
3699 lineage_cfg: None,
3700 #[cfg(feature = "notify")]
3701 notifier: None,
3702 #[cfg(feature = "catalog")]
3703 catalog: None,
3704 },
3705 )
3706 .await
3707 .unwrap();
3708
3709 assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3711 assert!(!summary.had_failures(), "{summary:?}");
3712 assert!(dir.path().join("child-1.jsonl").exists());
3714 assert!(dir.path().join("child-2.jsonl").exists());
3715 }
3716}