Skip to main content

faucet_cli/
executor.rs

1//! Run a list of [`ExpandedNode`]s under a bounded-concurrency executor.
2//!
3//! Semantics:
4//!
5//! - Roots run concurrently under `Semaphore(max_concurrent)`.
6//! - Each root captures its written records (via a `CapturingSink` wrapper)
7//!   so descendants can fan out per parent record.
8//! - For each child whose parent has finished successfully, one pipeline
9//!   invocation runs per parent record. `${parent.dotted.path}` tokens in the
10//!   source / sink config and state-key suffix are resolved against that
11//!   record via [`interpolate_record`].
12//! - All invocations share one global semaphore — children and roots compete
13//!   for the same budget.
14//! - `on_error: continue` (default) skips a failed node's subtree but keeps
15//!   running siblings. `on_error: stop` cancels everything after the first
16//!   failure.
17//! - State-key collisions among children of the same parent surface as a
18//!   `CliError::DuplicateStateKey`.
19
20use crate::auth_catalog::AuthCatalog;
21use crate::config::{ExecutionSpec, OnError};
22use crate::error::{CliError, CliResult};
23use crate::expand::{ExpandedNode, NodeRole};
24use crate::interpolate::interpolate_record;
25use crate::registry::{build_sink, build_source};
26use crate::state::build_state_store;
27use crate::transforms::compile_transforms;
28use async_trait::async_trait;
29use chrono::{DateTime, FixedOffset};
30use faucet_core::observability::Labels;
31use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
32use serde_json::Value;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36use std::sync::atomic::{AtomicUsize, Ordering};
37use std::time::Duration;
38use tokio::sync::{Mutex, Semaphore};
39
40/// Captured fan-out records, keyed by node id. Records are held as `Arc<Value>`
41/// so the per-level snapshot and per-child-unit hand-off are pointer bumps, not
42/// deep clones of the JSON tree (#160).
43type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
44use tokio_util::sync::CancellationToken;
45
46/// Knobs passed to [`run_expanded`].
47pub struct ExecuteOptions {
48    /// Pipeline name — used in log lines and as the first segment of every
49    /// state key.
50    pub pipeline_name: String,
51    /// Override for `execution.max_concurrent`. `None` → use the value in
52    /// `ExecutionSpec` or the default (`num_cpus::get().min(4)`, floored at 1).
53    pub execution: Option<ExecutionSpec>,
54    /// `--dry-run` — every sink is replaced with a no-op counter.
55    pub dry_run: bool,
56    /// `--limit N` — wraps every sink to drop records past the cap.
57    pub limit: Option<usize>,
58    /// `--state-path PATH` — overrides the `file` state-store path.
59    pub state_path_override: Option<PathBuf>,
60    /// Shared auth providers built from the top-level `auth:` block. Connectors
61    /// that reference one via `auth: { ref }` resolve against this catalog;
62    /// every row sharing a provider gets the same `Arc` (one token, shared).
63    pub auth: AuthCatalog,
64    /// Wall-clock instant for `${now.*}` interpolation in this run's configs.
65    /// `faucet run` sets process-start (or `--clock`); `faucet schedule` sets
66    /// the tick's scheduled time in the schedule timezone.
67    pub clock: DateTime<FixedOffset>,
68    /// Optional external cancellation token. When set and cancelled, in-flight
69    /// invocations stop at their next page boundary and **flush** their sinks
70    /// (so buffered output like a Parquet footer is durable), rather than being
71    /// hard-dropped (#146 H16). `faucet serve` wires this to run-cancel /
72    /// timeout / shutdown; `faucet run` leaves it `None`.
73    pub cancel: Option<CancellationToken>,
74}
75
76/// Grace window granted to in-flight invocations to flush cooperatively after
77/// an `on_error: stop` cancellation, before the remaining tasks are
78/// hard-aborted (the backstop for a sink genuinely stuck mid-write). Bounded so
79/// a hung sink can't wedge the whole run.
80const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
81
82/// One pipeline invocation's outcome.
83#[derive(Debug)]
84pub struct InvocationOutcome {
85    pub row_id: String,
86    /// `None` for root invocations; for children, the value at `parent_key` in
87    /// the parent record (rendered to a string).
88    pub parent_record_key: Option<String>,
89    pub records_written: usize,
90    pub error: Option<String>,
91}
92
93/// Aggregate outcome of `run_expanded`.
94#[derive(Debug)]
95pub struct RunSummary {
96    pub invocations: Vec<InvocationOutcome>,
97}
98
99impl RunSummary {
100    pub fn failure_count(&self) -> usize {
101        self.invocations
102            .iter()
103            .filter(|i| i.error.is_some())
104            .count()
105    }
106    pub fn had_failures(&self) -> bool {
107        self.failure_count() > 0
108    }
109}
110
111/// Default number of matrix invocations to run in parallel when neither the
112/// config's `execution.max_concurrent` nor a flag specifies one.
113///
114/// Scales with the core count but is capped at 8. The cap is deliberate: each
115/// invocation is a *full pipeline* with its own connection pools / HTTP
116/// clients, and matrix rows often target the same external system (one API,
117/// one database), so an unbounded fan-out across, say, a 64-core box would
118/// blow through that system's connection or rate limits rather than going
119/// faster. Workloads that genuinely benefit from more parallelism set
120/// `execution.max_concurrent` explicitly to opt out of the cap (#78 LOW).
121fn default_concurrency() -> usize {
122    std::thread::available_parallelism()
123        .map(|n| n.get())
124        .unwrap_or(4)
125        .clamp(1, 8)
126}
127
128/// Execute every node in `nodes`. `nodes` must be in BFS order (roots first
129/// then children) — that's what [`crate::expand::expand`] returns.
130pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
131    let on_error = opts
132        .execution
133        .as_ref()
134        .map(|e| e.on_error)
135        .unwrap_or_default();
136    let max_concurrent = opts
137        .execution
138        .as_ref()
139        .and_then(|e| e.max_concurrent)
140        .unwrap_or_else(default_concurrency)
141        .max(1);
142    let semaphore = Arc::new(Semaphore::new(max_concurrent));
143
144    // Index nodes by id for parent → children lookups.
145    // parent id → child node ids. Keyed and valued by id (not Vec index) so the
146    // failure cascade can look children up directly instead of indexing into a
147    // HashMap's nondeterministic iteration order (#78/#24).
148    let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
149    for n in nodes.iter() {
150        if let NodeRole::Child { parent_id, .. } = &n.role {
151            children_of
152                .entry(parent_id.clone())
153                .or_default()
154                .push(n.id.clone());
155        }
156    }
157
158    // Captured records per node id. Only populated for nodes that have
159    // children (= are referenced by another node's `parent:`). Records are held
160    // as `Arc<Value>` so the per-level snapshot clone and the per-child-unit
161    // hand-off are pointer bumps, not deep clones of the JSON tree (#160).
162    let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
163    let nodes_with_descendants: HashSet<String> = children_of.keys().cloned().collect();
164
165    let mut outcomes: Vec<InvocationOutcome> = Vec::new();
166    let mut skipped_subtrees: HashSet<String> = HashSet::new();
167
168    // Root cooperative-cancel token: the caller's (serve wires run-cancel /
169    // timeout / shutdown) or a fresh one. Each level derives a child token so
170    // an `on_error: stop` cancels only that level's invocations, while an
171    // external cancel of the root propagates to every level (#146 H16).
172    let cancel = opts.cancel.clone().unwrap_or_default();
173    let opts = Arc::new(opts);
174
175    // We execute level-by-level. Each level is "every node whose parent is
176    // already done." Roots are level 0. For each level, we spawn one task per
177    // (node, parent-record) pair and await them all before moving on.
178    let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
179    let mut completed: HashSet<String> = HashSet::new();
180    let nodes_by_id: HashMap<String, ExpandedNode> =
181        nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
182
183    // Sort node ids in their original BFS row order so the executor is
184    // deterministic — important for `on_error: stop`, where the first failure
185    // halts the rest of the level.
186    let bfs_order: Vec<String> = {
187        let mut ids: Vec<(usize, String)> = nodes_by_id
188            .values()
189            .map(|n| (n.row_index, n.id.clone()))
190            .collect();
191        ids.sort_by_key(|(i, _)| *i);
192        ids.into_iter().map(|(_, id)| id).collect()
193    };
194
195    while !remaining.is_empty() {
196        // Pick every remaining node whose parent (if any) is already
197        // completed, in deterministic BFS order.
198        let ready: Vec<String> = bfs_order
199            .iter()
200            .filter(|id| remaining.contains(*id))
201            .filter(|id| match &nodes_by_id[*id].role {
202                NodeRole::Root => true,
203                NodeRole::Child { parent_id, .. } => {
204                    completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
205                }
206            })
207            .cloned()
208            .collect();
209
210        if ready.is_empty() {
211            // No node is ready but some remain — an expand.rs invariant was
212            // violated (e.g. an orphaned parent reference). Surface it instead
213            // of silently dropping the remaining work and reporting success
214            // (#78/#24).
215            let mut stuck: Vec<String> = remaining.iter().cloned().collect();
216            stuck.sort();
217            return Err(CliError::Internal(format!(
218                "executor deadlock: {} node(s) never became ready (no completed/skipped parent): {}",
219                stuck.len(),
220                stuck.join(", ")
221            )));
222        }
223
224        // Build the work units for this level. Each unit is one invocation —
225        // a root runs once; a child runs once per parent record.
226        let mut units: Vec<Unit> = Vec::new();
227        let captured_snapshot = captured.lock().await.clone();
228        for id in &ready {
229            let node = &nodes_by_id[id];
230            // If a parent failed (and on_error=continue), the subtree is
231            // skipped. Surface a synthetic "skipped" outcome and move on.
232            if let NodeRole::Child { parent_id, .. } = &node.role
233                && skipped_subtrees.contains(parent_id)
234            {
235                skipped_subtrees.insert(id.clone());
236                tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
237                continue;
238            }
239            match &node.role {
240                NodeRole::Root => {
241                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
242                    let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
243                    validate_unit_state_key(&node.id, uses_state, &state_key)?;
244                    units.push(Unit {
245                        node: node.clone(),
246                        parent_record: None,
247                        state_key,
248                        parent_record_key: None,
249                    });
250                }
251                NodeRole::Child {
252                    parent_id,
253                    parent_key,
254                } => {
255                    let parent_records = captured_snapshot
256                        .get(parent_id)
257                        .cloned()
258                        .unwrap_or_default();
259                    if parent_records.is_empty() {
260                        tracing::info!(
261                            row = %id, parent = %parent_id,
262                            "parent produced no records — child skipped"
263                        );
264                        continue;
265                    }
266                    // Detect state-key collisions among siblings sharing one parent.
267                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
268                    let mut seen_keys: HashSet<String> = HashSet::new();
269                    for record in &parent_records {
270                        let pk_value = resolve_parent_key(record, parent_key);
271                        let pk_string = pk_value
272                            .as_ref()
273                            .map(value_to_string_brief)
274                            .unwrap_or_else(|| "(missing)".to_string());
275                        let state_key =
276                            build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
277                        validate_unit_state_key(&node.id, uses_state, &state_key)?;
278                        if !seen_keys.insert(state_key.clone()) {
279                            return Err(CliError::DuplicateStateKey {
280                                id: node.id.clone(),
281                                state_key,
282                            });
283                        }
284                        units.push(Unit {
285                            node: node.clone(),
286                            parent_record: Some(record.clone()),
287                            state_key,
288                            parent_record_key: Some(pk_string),
289                        });
290                    }
291                }
292            }
293        }
294        drop(captured_snapshot);
295
296        let mut had_level_failure = false;
297        let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
298
299        // Unified parallel execution. Tasks run concurrently under the global
300        // semaphore. Under `on_error: stop`, the first failure triggers
301        // `JoinSet::abort_all()` — pending tasks waiting on a permit are
302        // dropped before they do real work, and in-flight tasks are
303        // cancelled at their next `.await` point (potentially leaving
304        // partial sink state — the trade-off users opt into by choosing
305        // `stop`). Under `on_error: continue` every spawned task runs to
306        // completion regardless of sibling failures.
307        // Per-level cancel token: cancelling it (on `on_error: stop`) stops only
308        // this level's invocations cooperatively; it is a child of the root
309        // token, so an external cancel (serve) still propagates here (#146 H16).
310        let level_cancel = cancel.child_token();
311        let mut joinset = tokio::task::JoinSet::new();
312        // Map each spawned task's id back to its row id + parent key so that a
313        // panic (surfaced as a JoinError, which doesn't carry the unit) can be
314        // attributed to the right invocation.
315        let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
316        for unit in units {
317            let sem = Arc::clone(&semaphore);
318            let opts2 = Arc::clone(&opts);
319            let captured = Arc::clone(&captured);
320            let needs_capture = nodes_with_descendants.contains(&unit.node.id);
321            let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
322            let unit_cancel = level_cancel.clone();
323            let handle = joinset.spawn(async move {
324                let _permit = sem.acquire().await.expect("semaphore not closed");
325                run_unit(&unit, needs_capture, &captured, &opts2, unit_cancel).await
326            });
327            task_meta.insert(handle.id(), meta);
328        }
329
330        let mut stop_triggered = false;
331        let mut aborted = false;
332        let mut stop_deadline: Option<tokio::time::Instant> = None;
333        loop {
334            // Once `on_error: stop` has cancelled the level, give in-flight
335            // invocations a bounded grace to flush cooperatively, then
336            // hard-abort the stragglers (the backstop for a sink stuck
337            // mid-write that can't reach a page boundary to observe the cancel).
338            let joined = match stop_deadline {
339                Some(deadline) if !aborted => {
340                    match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
341                        Ok(j) => j,
342                        Err(_) => {
343                            tracing::warn!(
344                                "on_error: stop — flush grace elapsed; aborting remaining \
345                                 in-flight invocations"
346                            );
347                            joinset.abort_all();
348                            aborted = true;
349                            continue;
350                        }
351                    }
352                }
353                _ => joinset.join_next_with_id().await,
354            };
355            let Some(joined) = joined else { break };
356            // A failure (an `Err` outcome or a panicked task) marks the level
357            // failed and, under `on_error: stop`, stops the rest. A panicking
358            // connector must NOT take down the whole process (#78/#24).
359            let outcome = match joined {
360                Ok((_id, outcome)) => outcome,
361                Err(e) if e.is_cancelled() => {
362                    // Expected after abort_all() — cancelled before/at an await.
363                    // Not counted as a failure or a success.
364                    continue;
365                }
366                Err(e) => {
367                    let (row_id, parent_record_key) = task_meta
368                        .get(&e.id())
369                        .cloned()
370                        .unwrap_or_else(|| ("<unknown>".to_string(), None));
371                    InvocationOutcome {
372                        row_id,
373                        parent_record_key,
374                        records_written: 0,
375                        error: Some(format!("pipeline invocation task panicked: {e}")),
376                    }
377                }
378            };
379
380            if let Some(err) = &outcome.error {
381                tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
382                had_level_failure = true;
383                nodes_with_any_failure.insert(outcome.row_id.clone());
384                if matches!(on_error, OnError::Stop) && !stop_triggered {
385                    stop_triggered = true;
386                    tracing::error!(
387                        "on_error: stop — cancelling in-flight invocations (cooperative \
388                         flush), then aborting any that don't stop within the grace window"
389                    );
390                    // Cooperative first: in-flight pipelines flush at their next
391                    // page boundary so a Parquet footer / S3 upload is completed
392                    // rather than orphaned (#146 H16).
393                    level_cancel.cancel();
394                    stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
395                }
396            } else {
397                tracing::info!(
398                    row = %outcome.row_id,
399                    records_written = outcome.records_written,
400                    "pipeline invocation completed"
401                );
402            }
403            outcomes.push(outcome);
404        }
405
406        // Mark ready nodes done (some may have produced both successes and
407        // failures across their per-parent-record fan-outs — we treat a node
408        // as "failed" overall if any of its invocations failed).
409        for id in ready {
410            remaining.remove(&id);
411            if nodes_with_any_failure.contains(&id) {
412                skipped_subtrees.insert(id.clone());
413                // Cascade to descendants in case we have multi-level chains.
414                if let Some(children) = children_of.get(&id) {
415                    for cid in children {
416                        skipped_subtrees.insert(cid.clone());
417                    }
418                }
419            } else {
420                completed.insert(id);
421            }
422        }
423
424        if had_level_failure && matches!(on_error, OnError::Stop) {
425            tracing::error!("on_error: stop — aborting after first failure");
426            // Any unfinished work surfaces as "skipped"; we just break here.
427            break;
428        }
429    }
430
431    Ok(RunSummary {
432        invocations: outcomes,
433    })
434}
435
436/// One scheduled invocation — a root runs once, a child runs once per parent
437/// record. Built by the level loop, consumed by [`run_unit`].
438struct Unit {
439    node: ExpandedNode,
440    parent_record: Option<Arc<Value>>,
441    state_key: String,
442    parent_record_key: Option<String>,
443}
444
445async fn run_unit(
446    unit: &Unit,
447    needs_capture: bool,
448    captured: &CapturedRecords,
449    opts: &ExecuteOptions,
450    cancel: CancellationToken,
451) -> InvocationOutcome {
452    let result = run_one_invocation(
453        &unit.node,
454        unit.parent_record.as_deref(),
455        &unit.state_key,
456        needs_capture,
457        opts,
458        cancel,
459    )
460    .await;
461    let row_id = unit.node.id.clone();
462    let parent_record_key = unit.parent_record_key.clone();
463    match result {
464        Ok((records, written)) => {
465            if needs_capture {
466                captured
467                    .lock()
468                    .await
469                    .entry(row_id.clone())
470                    .or_default()
471                    // Move each record into an `Arc` once here; downstream
472                    // per-level / per-unit hand-offs then clone only the pointer.
473                    .extend(records.into_iter().map(Arc::new));
474            }
475            InvocationOutcome {
476                row_id,
477                parent_record_key,
478                records_written: written,
479                error: None,
480            }
481        }
482        Err(e) => InvocationOutcome {
483            row_id,
484            parent_record_key,
485            records_written: 0,
486            error: Some(e.to_string()),
487        },
488    }
489}
490
491/// Produce `{pipeline_name}::{row_id}` or `{pipeline_name}::{row_id}::{key}`.
492fn build_state_key(pipeline_name: &str, row_id: &str, parent_key: Option<&str>) -> String {
493    match parent_key {
494        None => format!("{pipeline_name}::{row_id}"),
495        Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
496    }
497}
498
499/// Reject an invalid state key up front (at unit construction) when the node
500/// will use a state store, so a bad pipeline name or parent-key value surfaces
501/// as a clear [`CliError::InvalidStateKey`] instead of a late mid-run
502/// `FaucetError::State` after connectors are built and the stream has started.
503fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
504    if uses_state {
505        faucet_core::state::validate_state_key(state_key).map_err(|e| {
506            CliError::InvalidStateKey {
507                id: node_id.to_owned(),
508                state_key: state_key.to_owned(),
509                reason: e.to_string(),
510            }
511        })?;
512    }
513    Ok(())
514}
515
516/// Walk the parent record by `parent_key` (a dotted path) and clone the value.
517fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
518    let mut cur = record;
519    for segment in parent_key.split('.') {
520        cur = match cur {
521            Value::Object(m) => m.get(segment)?,
522            Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
523            _ => return None,
524        };
525    }
526    Some(cur.clone())
527}
528
529/// Run one pipeline invocation. Returns (captured records, records_written).
530async fn run_one_invocation(
531    node: &ExpandedNode,
532    parent_record: Option<&Value>,
533    state_key: &str,
534    needs_capture: bool,
535    opts: &ExecuteOptions,
536    cancel: CancellationToken,
537) -> CliResult<(Vec<Value>, usize)> {
538    // Observability identity for this invocation — built once, reused by both
539    // the Pipeline builder and the transform instrumentation.
540    let run_id = uuid::Uuid::now_v7().to_string();
541    let pipeline_name = opts.pipeline_name.clone();
542    let row_id = node.id.clone();
543    let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
544    // 1) Resolve `${parent.path}` in the per-row source + sink configs.
545    let mut source_cfg = node.source.config.clone();
546    let mut sink_cfg = node.sink.config.clone();
547
548    // Resolve `${now.*}` run-clock tokens for every invocation (root + child),
549    // before the parent-record pass. Leaves all other tokens verbatim.
550    resolve_now_inplace(&mut source_cfg, opts.clock)?;
551    resolve_now_inplace(&mut sink_cfg, opts.clock)?;
552
553    if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
554        let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
555        resolve_inplace(&mut source_cfg, &ctx)?;
556        resolve_inplace(&mut sink_cfg, &ctx)?;
557    }
558
559    // 2) Build source + sink.
560    let source = build_source(&node.source.kind, source_cfg, &opts.auth).await?;
561    let raw_sink: Box<dyn Sink> = if opts.dry_run {
562        Box::new(CountingSink::new())
563    } else {
564        build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
565    };
566    let raw_sink: Box<dyn Sink> = match opts.limit {
567        Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
568        None => raw_sink,
569    };
570    let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
571    let sink: Box<dyn Sink> = if needs_capture {
572        Box::new(CapturingSink::wrap(raw_sink, Arc::clone(&captured)))
573    } else {
574        raw_sink
575    };
576
577    // 3) Compile transforms.
578    let stages = compile_transforms(&node.transforms)?;
579    let source: Box<dyn Source> = if stages.is_empty() {
580        source
581    } else {
582        Box::new(faucet_core::TransformingSource::new(
583            source,
584            stages,
585            obs_labels.clone(),
586        )?)
587    };
588
589    // 4) Build state store. If the source opts into state, wrap it so the
590    //    executor's per-row state key is used instead of the source's natural
591    //    one (which is shared across all matrix rows of the same kind).
592    let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
593    let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
594        Box::new(StateKeyOverride {
595            inner: source,
596            key: state_key.to_owned(),
597        })
598    } else {
599        source
600    };
601
602    // 5) Run.
603    let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
604        .with_name(pipeline_name)
605        .with_row(row_id)
606        .with_run_id(run_id);
607    let pipeline = match state {
608        Some(store) => pipeline.with_state_store(store),
609        None => pipeline,
610    };
611    let pipeline = if let Some(ref dlq_spec) = node.dlq {
612        let dlq_cfg = build_dlq_config(dlq_spec).await?;
613        pipeline.with_dlq(dlq_cfg)
614    } else {
615        pipeline
616    };
617    // Cooperative cancellation: a cancelled token makes the streaming loop stop
618    // at the next page boundary and flush the sink (#146 H16).
619    let pipeline = pipeline.with_cancel(cancel);
620    // Pipeline-level quality checks (v1: no matrix-row override). `expand`
621    // already validated this spec, but compile again here to obtain the
622    // runtime `CompiledQuality`; map any error to a config-level failure.
623    #[cfg(feature = "quality")]
624    let pipeline = if let Some(ref quality_spec) = node.quality {
625        let compiled = Arc::new(
626            faucet_core::CompiledQuality::compile(quality_spec)
627                .map_err(|e| CliError::Config(format!("quality: {e}")))?,
628        );
629        pipeline.with_quality(compiled)
630    } else {
631        pipeline
632    };
633    // Execution-level adaptive batch-size controller (shared by all rows).
634    let pipeline = if let Some(ab) = opts
635        .execution
636        .as_ref()
637        .and_then(|e| e.adaptive_batch_size.clone())
638    {
639        ab.validate()
640            .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
641        pipeline.with_adaptive(ab)
642    } else {
643        pipeline
644    };
645    let result = pipeline.run().await?;
646    sink.flush().await?;
647
648    let captured = if needs_capture {
649        std::mem::take(&mut *captured.lock().await)
650    } else {
651        Vec::new()
652    };
653    Ok((captured, result.records_written))
654}
655
656async fn build_state_for_node(
657    node: &ExpandedNode,
658    state_path_override: Option<&Path>,
659) -> CliResult<Option<Arc<dyn StateStore>>> {
660    match (&node.state, state_path_override) {
661        (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
662        (None, Some(path)) => Ok(Some(state_from_override(path))),
663        (Some(spec), Some(path)) => {
664            if spec.kind == "file" {
665                Ok(Some(state_from_override(path)))
666            } else {
667                tracing::warn!(
668                    state = %spec.kind,
669                    "--state-path is only meaningful for the 'file' backend; ignoring override"
670                );
671                Ok(Some(build_state_store(spec).await?))
672            }
673        }
674        (None, None) => Ok(None),
675    }
676}
677
678fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
679    Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
680}
681
682/// Translate a [`crate::config::DlqSpec`] from the YAML/JSON config into a
683/// runtime [`DlqConfig`] ready to attach to a [`Pipeline`].
684pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
685    // DLQ sinks resolve against an empty catalog — shared `auth: { ref }` on a
686    // DLQ sink is out of scope (DLQ targets are typically local jsonl/stdout).
687    let sink = build_sink(
688        &spec.sink.kind,
689        spec.sink.config.clone(),
690        &AuthCatalog::new(),
691    )
692    .await?;
693    Ok(DlqConfig {
694        sink: Arc::from(sink),
695        on_batch_error: match spec.on_batch_error {
696            crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
697            crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
698        },
699        max_failures_per_page: spec.max_failures_per_page,
700        max_failures_total: spec.max_failures_total,
701        include_original_payload: spec.include_original_payload,
702    })
703}
704
705/// In-place `${now.*}` resolution against the run clock. Walks every string
706/// leaf and rewrites `${now.<token>}`; all other `${...}` tokens are untouched.
707fn resolve_now_inplace(value: &mut Value, clock: DateTime<FixedOffset>) -> CliResult<()> {
708    match value {
709        Value::String(s) => {
710            *s = crate::interpolate::resolve_now(s, clock)?;
711            Ok(())
712        }
713        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
714        Value::Object(m) => m
715            .values_mut()
716            .try_for_each(|v| resolve_now_inplace(v, clock)),
717        _ => Ok(()),
718    }
719}
720
721/// In-place runtime interpolation against a parent-record context. Walks every
722/// string leaf in `value` and replaces `${id.path}` tokens with stringified
723/// values from `ctx`.
724fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
725    match value {
726        Value::String(s) => {
727            let resolved = interpolate_record(s, ctx)?;
728            *s = resolved;
729            Ok(())
730        }
731        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
732        Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
733        _ => Ok(()),
734    }
735}
736
737// ── Adapter sinks/sources ───────────────────────────────────────────────────
738
739/// Wraps a source so its `state_key()` returns the executor-provided value
740/// instead of the source's natural one. Lets every matrix invocation use a
741/// distinct state-store entry even when the underlying source kind is shared.
742struct StateKeyOverride {
743    inner: Box<dyn Source>,
744    key: String,
745}
746
747#[async_trait]
748impl Source for StateKeyOverride {
749    async fn fetch_with_context(
750        &self,
751        ctx: &HashMap<String, Value>,
752    ) -> Result<Vec<Value>, FaucetError> {
753        self.inner.fetch_with_context(ctx).await
754    }
755    async fn fetch_with_context_incremental(
756        &self,
757        ctx: &HashMap<String, Value>,
758    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
759        self.inner.fetch_with_context_incremental(ctx).await
760    }
761    fn connector_name(&self) -> &'static str {
762        self.inner.connector_name()
763    }
764    fn state_key(&self) -> Option<String> {
765        Some(self.key.clone())
766    }
767    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
768        self.inner.apply_start_bookmark(bookmark).await
769    }
770}
771
772/// Forwards each record to an inner sink while also cloning it into a shared
773/// buffer for descendant rows to consume.
774struct CapturingSink {
775    inner: Box<dyn Sink>,
776    captured: Arc<Mutex<Vec<Value>>>,
777}
778
779impl CapturingSink {
780    fn wrap(inner: Box<dyn Sink>, captured: Arc<Mutex<Vec<Value>>>) -> Self {
781        Self { inner, captured }
782    }
783}
784
785#[async_trait]
786impl Sink for CapturingSink {
787    fn connector_name(&self) -> &'static str {
788        self.inner.connector_name()
789    }
790    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
791        let written = self.inner.write_batch(records).await?;
792        // Capture only what actually landed (LimitedSink may have dropped some).
793        let n = written.min(records.len());
794        let mut buf = self.captured.lock().await;
795        buf.extend(records.iter().take(n).cloned());
796        Ok(written)
797    }
798    async fn flush(&self) -> Result<(), FaucetError> {
799        self.inner.flush().await
800    }
801}
802
803/// Cap on records written. Each `write_batch` call truncates `records` to the
804/// remaining budget before delegating.
805struct LimitedSink {
806    inner: Box<dyn Sink>,
807    remaining: AtomicUsize,
808}
809
810impl LimitedSink {
811    fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
812        Self {
813            inner,
814            remaining: AtomicUsize::new(cap),
815        }
816    }
817}
818
819#[async_trait]
820impl Sink for LimitedSink {
821    fn connector_name(&self) -> &'static str {
822        self.inner.connector_name()
823    }
824    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
825        let remaining = self.remaining.load(Ordering::Relaxed);
826        if remaining == 0 {
827            return Ok(0);
828        }
829        let take = remaining.min(records.len());
830        let slice = &records[..take];
831        let written = self.inner.write_batch(slice).await?;
832        self.remaining
833            .fetch_sub(written.min(remaining), Ordering::Relaxed);
834        Ok(written)
835    }
836    async fn flush(&self) -> Result<(), FaucetError> {
837        self.inner.flush().await
838    }
839}
840
841/// No-op sink used in `--dry-run`. Counts records seen so the rest of the
842/// pipeline (transforms, source) still runs.
843struct CountingSink {
844    seen: AtomicUsize,
845}
846
847impl CountingSink {
848    fn new() -> Self {
849        Self {
850            seen: AtomicUsize::new(0),
851        }
852    }
853}
854
855#[async_trait]
856impl Sink for CountingSink {
857    fn connector_name(&self) -> &'static str {
858        "dry-run"
859    }
860    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
861        self.seen.fetch_add(records.len(), Ordering::Relaxed);
862        Ok(records.len())
863    }
864}
865
866/// Render a JSON value compactly for use as a state-key suffix or log line.
867/// Strings pass through unquoted; numbers/bools/null/composites use to_string.
868fn value_to_string_brief(v: &Value) -> String {
869    match v {
870        Value::String(s) => s.clone(),
871        other => other.to_string(),
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
879    use crate::expand::expand;
880    use serde_json::json;
881
882    fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
883        PipelineConfig {
884            version: 1,
885            name: Some("test".into()),
886            vars: None,
887            auth: None,
888            pipeline: PipelineSpec {
889                source: Some(ConnectorSpec {
890                    kind: "csv".into(),
891                    config: json!({"path": input.to_str().unwrap()}),
892                    transforms: None,
893                    inherit_transforms: true,
894                }),
895                sink: Some(ConnectorSpec {
896                    kind: "jsonl".into(),
897                    config: json!({"path": output.to_str().unwrap()}),
898                    transforms: None,
899                    inherit_transforms: true,
900                }),
901                sources: Default::default(),
902                sinks: Default::default(),
903                transforms: Vec::new(),
904                state: None,
905                dlq: None,
906                #[cfg(feature = "quality")]
907                quality: None,
908            },
909            matrix: Vec::new(),
910            execution: None,
911            observability: None,
912            #[cfg(feature = "schedule")]
913            schedule: None,
914        }
915    }
916
917    #[tokio::test]
918    async fn empty_matrix_runs_pipeline_once() {
919        let dir = tempfile::tempdir().unwrap();
920        let input = dir.path().join("in.csv");
921        let output = dir.path().join("out.jsonl");
922        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
923        let cfg = cfg_csv_to_jsonl(&input, &output);
924        let nodes = expand(&cfg).unwrap();
925        let summary = run_expanded(
926            nodes,
927            ExecuteOptions {
928                pipeline_name: "t".into(),
929                execution: None,
930                dry_run: false,
931                limit: None,
932                state_path_override: None,
933                auth: Default::default(),
934                clock: chrono::Utc::now().fixed_offset(),
935                cancel: None,
936            },
937        )
938        .await
939        .unwrap();
940        assert_eq!(summary.invocations.len(), 1);
941        assert_eq!(summary.invocations[0].records_written, 2);
942        assert!(!summary.had_failures());
943        let body = std::fs::read_to_string(&output).unwrap();
944        assert_eq!(body.lines().count(), 2);
945    }
946
947    #[tokio::test]
948    async fn matrix_two_independent_roots_both_run() {
949        // Two roots: one writes alice, the other writes bob — to two separate files.
950        let dir = tempfile::tempdir().unwrap();
951        let csv_a = dir.path().join("a.csv");
952        let csv_b = dir.path().join("b.csv");
953        let out_a = dir.path().join("a.jsonl");
954        let out_b = dir.path().join("b.jsonl");
955        std::fs::write(&csv_a, "name\nalice\n").unwrap();
956        std::fs::write(&csv_b, "name\nbob\n").unwrap();
957
958        let yaml = format!(
959            r#"version: 1
960pipeline:
961  source: {{ type: csv, config: {{ path: {a} }} }}
962  sink:   {{ type: jsonl, config: {{ path: {out_a} }} }}
963matrix:
964  - id: rowA
965  - id: rowB
966    source: {{ config: {{ path: {b} }} }}
967    sink:   {{ config: {{ path: {out_b} }} }}
968"#,
969            a = csv_a.display(),
970            b = csv_b.display(),
971            out_a = out_a.display(),
972            out_b = out_b.display(),
973        );
974        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
975        let nodes = expand(&cfg).unwrap();
976        let summary = run_expanded(
977            nodes,
978            ExecuteOptions {
979                pipeline_name: "matrix".into(),
980                execution: None,
981                dry_run: false,
982                limit: None,
983                state_path_override: None,
984                auth: Default::default(),
985                clock: chrono::Utc::now().fixed_offset(),
986                cancel: None,
987            },
988        )
989        .await
990        .unwrap();
991        assert_eq!(summary.invocations.len(), 2);
992        assert!(out_a.exists());
993        assert!(out_b.exists());
994    }
995
996    #[tokio::test]
997    async fn dag_child_fans_out_per_parent_record() {
998        // Parent: CSV with two records (id=1, id=2).
999        // Child: writes one JSONL file per parent id, using ${parent.id} in the path.
1000        let dir = tempfile::tempdir().unwrap();
1001        let parent_csv = dir.path().join("parents.csv");
1002        let child_csv = dir.path().join("child.csv");
1003        std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
1004        std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
1005        let parent_out = dir.path().join("parents.jsonl");
1006        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
1007
1008        let yaml = format!(
1009            r#"version: 1
1010pipeline:
1011  source: {{ type: csv, config: {{ path: {parent} }} }}
1012  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
1013matrix:
1014  - id: parents
1015  - id: child
1016    parent: parents
1017    source: {{ config: {{ path: {child} }} }}
1018    sink:   {{ config: {{ path: "{child_out}" }} }}
1019"#,
1020            parent = parent_csv.display(),
1021            parent_out = parent_out.display(),
1022            child = child_csv.display(),
1023            child_out = child_out_pattern.display(),
1024        );
1025        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1026        let nodes = expand(&cfg).unwrap();
1027        let summary = run_expanded(
1028            nodes,
1029            ExecuteOptions {
1030                pipeline_name: "dagtest".into(),
1031                execution: None,
1032                dry_run: false,
1033                limit: None,
1034                state_path_override: None,
1035                auth: Default::default(),
1036                clock: chrono::Utc::now().fixed_offset(),
1037                cancel: None,
1038            },
1039        )
1040        .await
1041        .unwrap();
1042
1043        // 1 parent invocation + 2 child invocations.
1044        assert_eq!(summary.invocations.len(), 3);
1045        assert!(!summary.had_failures(), "{:?}", summary);
1046        assert!(dir.path().join("child-1.jsonl").exists());
1047        assert!(dir.path().join("child-2.jsonl").exists());
1048    }
1049
1050    #[tokio::test]
1051    async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
1052        // First root writes to an invalid sink path and fails. The second
1053        // ("good") root would succeed. Under `on_error: stop` the executor
1054        // calls `abort_all()` on the first failure, which cancels pending /
1055        // in-flight tasks at their next await point — but that is
1056        // best-effort: with `max_concurrent: 1` the two roots race for the
1057        // single permit, so "good" may already have completed before "bad"
1058        // fails. We therefore assert the guarantees that hold under *any*
1059        // scheduling rather than an exact invocation count (which was racy,
1060        // see issue #78 finding #24). The deterministic "stop actually
1061        // cancels in-flight work" path is covered by
1062        // `on_error_stop_under_parallelism_aborts_other_in_flight`.
1063        let dir = tempfile::tempdir().unwrap();
1064        let good_csv = dir.path().join("good.csv");
1065        std::fs::write(&good_csv, "x\n1\n").unwrap();
1066        let good_out = dir.path().join("good.jsonl");
1067        let bad_sink_dir = dir.path().to_path_buf();
1068
1069        let yaml = format!(
1070            r#"version: 1
1071pipeline:
1072  source: {{ type: csv, config: {{ path: {good_csv} }} }}
1073  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
1074matrix:
1075  - id: bad
1076    sink: {{ config: {{ path: {bad_dir} }} }}
1077  - id: good
1078execution:
1079  max_concurrent: 1
1080  on_error: stop
1081"#,
1082            good_csv = good_csv.display(),
1083            good_out = good_out.display(),
1084            bad_dir = bad_sink_dir.display(),
1085        );
1086        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1087        let nodes = expand(&cfg).unwrap();
1088        let summary = run_expanded(
1089            nodes,
1090            ExecuteOptions {
1091                pipeline_name: "stoptest".into(),
1092                execution: cfg.execution.clone(),
1093                dry_run: false,
1094                limit: None,
1095                state_path_override: None,
1096                auth: Default::default(),
1097                clock: chrono::Utc::now().fixed_offset(),
1098                cancel: None,
1099            },
1100        )
1101        .await
1102        .unwrap();
1103
1104        // Invariants that hold regardless of which root won the permit race:
1105        assert!(summary.had_failures(), "the failing root must be reported");
1106
1107        // "bad" ran exactly once and is recorded as a failure.
1108        let bad: Vec<_> = summary
1109            .invocations
1110            .iter()
1111            .filter(|o| o.row_id == "bad")
1112            .collect();
1113        assert_eq!(bad.len(), 1, "bad must run exactly once");
1114        assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
1115
1116        // No duplicate / extra invocations beyond the two work units.
1117        assert!(
1118            summary.invocations.len() <= 2,
1119            "at most the two roots may run, got {:?}",
1120            summary.invocations
1121        );
1122
1123        // "good" may: (a) win the permit first and run fully (writes its row,
1124        // file exists); (b) lose the race, acquire the permit after "bad" fails,
1125        // observe the cooperative stop-cancel at its first page boundary, and
1126        // return a 0-record success (no file); or (c) never appear if it was
1127        // still pending when the level finished. So the only invariant is: a
1128        // "good" that actually WROTE records must have produced its file.
1129        let good_wrote = summary
1130            .invocations
1131            .iter()
1132            .find(|o| o.row_id == "good" && o.error.is_none())
1133            .map(|o| o.records_written)
1134            .unwrap_or(0);
1135        if good_wrote > 0 {
1136            assert!(
1137                good_out.exists(),
1138                "a good that wrote records must have produced its output file"
1139            );
1140        }
1141    }
1142
1143    #[tokio::test]
1144    async fn invalid_pipeline_name_with_state_errors_up_front() {
1145        // A pipeline name that can't form a valid state key must fail up front
1146        // (at unit construction) when state is configured — not deep mid-run
1147        // as a `FaucetError::State`.
1148        let dir = tempfile::tempdir().unwrap();
1149        let input = dir.path().join("in.csv");
1150        let output = dir.path().join("out.jsonl");
1151        std::fs::write(&input, "name\nalice\n").unwrap();
1152        let yaml = format!(
1153            r#"version: 1
1154pipeline:
1155  source: {{ type: csv, config: {{ path: {input} }} }}
1156  sink:   {{ type: jsonl, config: {{ path: {output} }} }}
1157  state:  {{ type: memory }}
1158"#,
1159            input = input.display(),
1160            output = output.display(),
1161        );
1162        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1163        let nodes = expand(&cfg).unwrap();
1164        let err = run_expanded(
1165            nodes,
1166            ExecuteOptions {
1167                pipeline_name: "bad name".into(), // space is illegal in a state key
1168                execution: None,
1169                dry_run: false,
1170                limit: None,
1171                state_path_override: None,
1172                auth: Default::default(),
1173                clock: chrono::Utc::now().fixed_offset(),
1174                cancel: None,
1175            },
1176        )
1177        .await
1178        .expect_err("an invalid pipeline name must be rejected up front when state is configured");
1179        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1180    }
1181
1182    #[tokio::test]
1183    async fn invalid_parent_key_value_with_state_errors_up_front() {
1184        // A parent-record value that yields an illegal state-key suffix must
1185        // fail up front at the child's unit construction, not mid-run.
1186        let dir = tempfile::tempdir().unwrap();
1187        let parent_csv = dir.path().join("parents.csv");
1188        let child_csv = dir.path().join("child.csv");
1189        // The parent `id` value contains a space — illegal in a state key.
1190        std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
1191        std::fs::write(&child_csv, "x\nA\n").unwrap();
1192        let parent_out = dir.path().join("parents.jsonl");
1193        let child_out = dir.path().join("child.jsonl");
1194        let yaml = format!(
1195            r#"version: 1
1196pipeline:
1197  source: {{ type: csv, config: {{ path: {parent} }} }}
1198  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
1199  state:  {{ type: memory }}
1200matrix:
1201  - id: parents
1202  - id: child
1203    parent: parents
1204    source: {{ config: {{ path: {child} }} }}
1205    sink:   {{ config: {{ path: {child_out} }} }}
1206"#,
1207            parent = parent_csv.display(),
1208            parent_out = parent_out.display(),
1209            child = child_csv.display(),
1210            child_out = child_out.display(),
1211        );
1212        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1213        let nodes = expand(&cfg).unwrap();
1214        let err = run_expanded(
1215            nodes,
1216            ExecuteOptions {
1217                pipeline_name: "ok".into(),
1218                execution: None,
1219                dry_run: false,
1220                limit: None,
1221                state_path_override: None,
1222                auth: Default::default(),
1223                clock: chrono::Utc::now().fixed_offset(),
1224                cancel: None,
1225            },
1226        )
1227        .await
1228        .expect_err(
1229            "an illegal parent-key value must be rejected up front when state is configured",
1230        );
1231        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1232    }
1233
1234    #[tokio::test]
1235    async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
1236        // Three roots running with `max_concurrent: 3`. The bad row points
1237        // its sink at a directory (open fails fast). The other two point at
1238        // sinks that block forever on the writer end of a pipe — stuck *inside*
1239        // the sink write, they never reach a page boundary to observe the
1240        // cooperative stop-cancel, so the only way they can complete is the
1241        // hard-abort backstop that fires after the flush grace (#146 H16). The
1242        // test would hang if `on_error: stop` never aborted them, so a passing
1243        // run is itself the assertion.
1244        let dir = tempfile::tempdir().unwrap();
1245        let bad_sink_dir = dir.path().to_path_buf();
1246        // A real csv source with one row — small enough that the pipeline
1247        // proceeds straight to the sink phase.
1248        let good_csv = dir.path().join("good.csv");
1249        std::fs::write(&good_csv, "x\n1\n").unwrap();
1250        // The two "would never finish" sinks point at the same path as the
1251        // bad sink (an existing directory). Their sink-open also errors
1252        // out — but we still verify the *abort* path by counting how many
1253        // tasks make it past spawn before stop fires. The strict invariant
1254        // we assert: the bad row's failure is the first one observed.
1255        let yaml = format!(
1256            r#"version: 1
1257pipeline:
1258  source: {{ type: csv, config: {{ path: {good_csv} }} }}
1259  sink:   {{ type: jsonl, config: {{ path: {bad_dir} }} }}
1260matrix:
1261  - id: bad
1262  - id: good_a
1263  - id: good_b
1264execution:
1265  max_concurrent: 3
1266  on_error: stop
1267"#,
1268            good_csv = good_csv.display(),
1269            bad_dir = bad_sink_dir.display(),
1270        );
1271        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1272        let nodes = expand(&cfg).unwrap();
1273        let summary = run_expanded(
1274            nodes,
1275            ExecuteOptions {
1276                pipeline_name: "stop_parallel".into(),
1277                execution: cfg.execution.clone(),
1278                dry_run: false,
1279                limit: None,
1280                state_path_override: None,
1281                auth: Default::default(),
1282                clock: chrono::Utc::now().fixed_offset(),
1283                cancel: None,
1284            },
1285        )
1286        .await
1287        .unwrap();
1288
1289        // First-observed failure halts the run. The first outcome in the
1290        // summary is guaranteed to be a failure (other tasks either fail
1291        // too or get cancelled — both cases never push a *success* outcome
1292        // first because every sink in this matrix is configured to fail).
1293        assert!(
1294            summary.had_failures(),
1295            "summary should record at least one failure: {summary:?}"
1296        );
1297        assert!(
1298            summary.invocations[0].error.is_some(),
1299            "first outcome must be the failure that triggered stop: {summary:?}"
1300        );
1301        // No invocation should report `records_written > 0` — every sink is
1302        // bad. (Catches a regression where abort_all somehow let a task
1303        // bypass its broken sink.)
1304        for inv in &summary.invocations {
1305            assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
1306        }
1307    }
1308
1309    #[tokio::test]
1310    async fn on_error_continue_skips_failed_subtree_only() {
1311        // Two roots: one fails. The good one's invocation still completes.
1312        let dir = tempfile::tempdir().unwrap();
1313        let good_csv = dir.path().join("good.csv");
1314        std::fs::write(&good_csv, "x\n1\n").unwrap();
1315        let good_out = dir.path().join("good.jsonl");
1316
1317        let yaml = format!(
1318            r#"version: 1
1319pipeline:
1320  source: {{ type: csv, config: {{ path: {good_csv} }} }}
1321  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
1322matrix:
1323  - id: bad
1324    sink: {{ config: {{ path: {bad_dir} }} }}
1325  - id: good
1326"#,
1327            good_csv = good_csv.display(),
1328            good_out = good_out.display(),
1329            bad_dir = dir.path().display(),
1330        );
1331        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1332        let nodes = expand(&cfg).unwrap();
1333        let summary = run_expanded(
1334            nodes,
1335            ExecuteOptions {
1336                pipeline_name: "continuetest".into(),
1337                execution: None,
1338                dry_run: false,
1339                limit: None,
1340                state_path_override: None,
1341                auth: Default::default(),
1342                clock: chrono::Utc::now().fixed_offset(),
1343                cancel: None,
1344            },
1345        )
1346        .await
1347        .unwrap();
1348        assert_eq!(summary.invocations.len(), 2);
1349        assert_eq!(summary.failure_count(), 1);
1350        let good_outcome = summary
1351            .invocations
1352            .iter()
1353            .find(|i| i.row_id == "good")
1354            .unwrap();
1355        assert!(good_outcome.error.is_none());
1356    }
1357}