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    /// Clustered Mode B (#230): narrow this run's single source to one shard
61    /// before streaming, and suffix its state key with the shard id so resume is
62    /// per-shard. `None` (the default) runs the whole source unchanged. Only set
63    /// by the serve shard executor; every other caller leaves it `None`.
64    pub shard: Option<faucet_core::ShardSpec>,
65    /// Shared auth providers built from the top-level `auth:` block. Connectors
66    /// that reference one via `auth: { ref }` resolve against this catalog;
67    /// every row sharing a provider gets the same `Arc` (one token, shared).
68    pub auth: AuthCatalog,
69    /// Wall-clock instant for `${now.*}` interpolation in this run's configs.
70    /// `faucet run` sets process-start (or `--clock`); `faucet schedule` sets
71    /// the tick's scheduled time in the schedule timezone.
72    pub clock: DateTime<FixedOffset>,
73    /// Optional external cancellation token. When set and cancelled, in-flight
74    /// invocations stop at their next page boundary and **flush** their sinks
75    /// (so buffered output like a Parquet footer is durable), rather than being
76    /// hard-dropped (#146 H16). `faucet serve` wires this to run-cancel /
77    /// timeout / shutdown; `faucet run` leaves it `None`.
78    pub cancel: Option<CancellationToken>,
79    /// Optional resilience policy (retry/backoff/circuit-breaker/poison),
80    /// attached to every invocation's `RunStreamOptions` and injected into
81    /// rest/xml/graphql sources. Built once from the top-level `resilience:`
82    /// block; `None` preserves today's behaviour.
83    pub resilience: Option<faucet_core::ResiliencePolicy>,
84    /// Shared OpenLineage emitter, built once from the `lineage:` block. `None`
85    /// disables lineage (and adds zero overhead). Gated on the `lineage` feature.
86    #[cfg(feature = "lineage")]
87    pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
88    /// The resolved `lineage:` config block (facet/event toggles, sampling, job
89    /// name template). Carried alongside the emitter so `run_one_invocation`
90    /// knows which facets/events to assemble. Gated on the `lineage` feature.
91    #[cfg(feature = "lineage")]
92    pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
93}
94
95/// Grace window granted to in-flight invocations to flush cooperatively after
96/// an `on_error: stop` cancellation, before the remaining tasks are
97/// hard-aborted (the backstop for a sink genuinely stuck mid-write). Bounded so
98/// a hung sink can't wedge the whole run.
99const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
100
101/// One pipeline invocation's outcome.
102#[derive(Debug)]
103pub struct InvocationOutcome {
104    pub row_id: String,
105    /// `None` for root invocations; for children, the value at `parent_key` in
106    /// the parent record (rendered to a string).
107    pub parent_record_key: Option<String>,
108    pub records_written: usize,
109    pub error: Option<String>,
110}
111
112/// Aggregate outcome of `run_expanded`.
113#[derive(Debug)]
114pub struct RunSummary {
115    pub invocations: Vec<InvocationOutcome>,
116}
117
118impl RunSummary {
119    pub fn failure_count(&self) -> usize {
120        self.invocations
121            .iter()
122            .filter(|i| i.error.is_some())
123            .count()
124    }
125    pub fn had_failures(&self) -> bool {
126        self.failure_count() > 0
127    }
128}
129
130/// Default number of matrix invocations to run in parallel when neither the
131/// config's `execution.max_concurrent` nor a flag specifies one.
132///
133/// Scales with the core count but is capped at 8. The cap is deliberate: each
134/// invocation is a *full pipeline* with its own connection pools / HTTP
135/// clients, and matrix rows often target the same external system (one API,
136/// one database), so an unbounded fan-out across, say, a 64-core box would
137/// blow through that system's connection or rate limits rather than going
138/// faster. Workloads that genuinely benefit from more parallelism set
139/// `execution.max_concurrent` explicitly to opt out of the cap (#78 LOW).
140fn default_concurrency() -> usize {
141    std::thread::available_parallelism()
142        .map(|n| n.get())
143        .unwrap_or(4)
144        .clamp(1, 8)
145}
146
147/// Execute every node in `nodes`. `nodes` must be in BFS order (roots first
148/// then children) — that's what [`crate::expand::expand`] returns.
149pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
150    let on_error = opts
151        .execution
152        .as_ref()
153        .map(|e| e.on_error)
154        .unwrap_or_default();
155    let max_concurrent = opts
156        .execution
157        .as_ref()
158        .and_then(|e| e.max_concurrent)
159        .unwrap_or_else(default_concurrency)
160        .max(1);
161    let semaphore = Arc::new(Semaphore::new(max_concurrent));
162
163    // Index nodes by id for parent → children lookups.
164    // parent id → child node ids. Keyed and valued by id (not Vec index) so the
165    // failure cascade can look children up directly instead of indexing into a
166    // HashMap's nondeterministic iteration order (#78/#24).
167    let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
168    for n in nodes.iter() {
169        if let NodeRole::Child { parent_id, .. } = &n.role {
170            children_of
171                .entry(parent_id.clone())
172                .or_default()
173                .push(n.id.clone());
174        }
175    }
176
177    // Captured records per node id. Only populated for nodes that have
178    // children (= are referenced by another node's `parent:`). Records are held
179    // as `Arc<Value>` so the per-level snapshot clone and the per-child-unit
180    // hand-off are pointer bumps, not deep clones of the JSON tree (#160).
181    let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
182
183    let mut outcomes: Vec<InvocationOutcome> = Vec::new();
184    let mut skipped_subtrees: HashSet<String> = HashSet::new();
185
186    // Root cooperative-cancel token: the caller's (serve wires run-cancel /
187    // timeout / shutdown) or a fresh one. Each level derives a child token so
188    // an `on_error: stop` cancels only that level's invocations, while an
189    // external cancel of the root propagates to every level (#146 H16).
190    let cancel = opts.cancel.clone().unwrap_or_default();
191    let opts = Arc::new(opts);
192
193    // We execute level-by-level. Each level is "every node whose parent is
194    // already done." Roots are level 0. For each level, we spawn one task per
195    // (node, parent-record) pair and await them all before moving on.
196    let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
197    let mut completed: HashSet<String> = HashSet::new();
198    let nodes_by_id: HashMap<String, ExpandedNode> =
199        nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
200
201    // Per-parent projection: what to keep from each captured record so the
202    // fan-out buffer holds only the fields children reference (#160).
203    let projections = build_projections(&nodes_by_id, &children_of);
204
205    // Sort node ids in their original BFS row order so the executor is
206    // deterministic — important for `on_error: stop`, where the first failure
207    // halts the rest of the level.
208    let bfs_order: Vec<String> = {
209        let mut ids: Vec<(usize, String)> = nodes_by_id
210            .values()
211            .map(|n| (n.row_index, n.id.clone()))
212            .collect();
213        ids.sort_by_key(|(i, _)| *i);
214        ids.into_iter().map(|(_, id)| id).collect()
215    };
216
217    while !remaining.is_empty() {
218        // Pick every remaining node whose parent (if any) is already
219        // completed, in deterministic BFS order.
220        let ready: Vec<String> = bfs_order
221            .iter()
222            .filter(|id| remaining.contains(*id))
223            .filter(|id| match &nodes_by_id[*id].role {
224                NodeRole::Root => true,
225                NodeRole::Child { parent_id, .. } => {
226                    completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
227                }
228            })
229            .cloned()
230            .collect();
231
232        if ready.is_empty() {
233            // No node is ready but some remain — an expand.rs invariant was
234            // violated (e.g. an orphaned parent reference). Surface it instead
235            // of silently dropping the remaining work and reporting success
236            // (#78/#24).
237            let mut stuck: Vec<String> = remaining.iter().cloned().collect();
238            stuck.sort();
239            return Err(CliError::Internal(format!(
240                "executor deadlock: {} node(s) never became ready (no completed/skipped parent): {}",
241                stuck.len(),
242                stuck.join(", ")
243            )));
244        }
245
246        // Build the work units for this level. Each unit is one invocation —
247        // a root runs once; a child runs once per parent record.
248        let mut units: Vec<Unit> = Vec::new();
249        // Move only the captured records of the parents whose children run this
250        // level out of the shared map. This both narrows the snapshot and frees
251        // each parent's buffer the moment its children consume it: all of a
252        // parent's children become ready in the same level, so its records are
253        // needed exactly once. Units hold their own `Arc<Value>` clones, so
254        // removing the map entry here only drops the map's hold (#160).
255        let level_records: HashMap<String, Vec<Arc<Value>>> = {
256            let consumed_parents: HashSet<&str> = ready
257                .iter()
258                .filter_map(|id| match &nodes_by_id[id].role {
259                    NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
260                    NodeRole::Root => None,
261                })
262                .collect();
263            let mut cap = captured.lock().await;
264            consumed_parents
265                .iter()
266                .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
267                .collect()
268        };
269        for id in &ready {
270            let node = &nodes_by_id[id];
271            // If a parent failed (and on_error=continue), the subtree is
272            // skipped. Surface a synthetic "skipped" outcome and move on.
273            if let NodeRole::Child { parent_id, .. } = &node.role
274                && skipped_subtrees.contains(parent_id)
275            {
276                skipped_subtrees.insert(id.clone());
277                tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
278                continue;
279            }
280            match &node.role {
281                NodeRole::Root => {
282                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
283                    let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
284                    validate_unit_state_key(&node.id, uses_state, &state_key)?;
285                    units.push(Unit {
286                        node: node.clone(),
287                        parent_record: None,
288                        state_key,
289                        parent_record_key: None,
290                    });
291                }
292                NodeRole::Child {
293                    parent_id,
294                    parent_key,
295                } => {
296                    let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
297                    if parent_records.is_empty() {
298                        tracing::info!(
299                            row = %id, parent = %parent_id,
300                            "parent produced no records — child skipped"
301                        );
302                        continue;
303                    }
304                    // Detect state-key collisions among siblings sharing one parent.
305                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
306                    let mut seen_keys: HashSet<String> = HashSet::new();
307                    for record in &parent_records {
308                        let pk_value = resolve_parent_key(record, parent_key);
309                        let pk_string = pk_value
310                            .as_ref()
311                            .map(value_to_string_brief)
312                            .unwrap_or_else(|| "(missing)".to_string());
313                        let state_key =
314                            build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
315                        validate_unit_state_key(&node.id, uses_state, &state_key)?;
316                        if !seen_keys.insert(state_key.clone()) {
317                            return Err(CliError::DuplicateStateKey {
318                                id: node.id.clone(),
319                                state_key,
320                            });
321                        }
322                        units.push(Unit {
323                            node: node.clone(),
324                            parent_record: Some(record.clone()),
325                            state_key,
326                            parent_record_key: Some(pk_string),
327                        });
328                    }
329                }
330            }
331        }
332        drop(level_records);
333
334        let mut had_level_failure = false;
335        let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
336
337        // Unified parallel execution. Tasks run concurrently under the global
338        // semaphore. Under `on_error: stop`, the first failure triggers
339        // `JoinSet::abort_all()` — pending tasks waiting on a permit are
340        // dropped before they do real work, and in-flight tasks are
341        // cancelled at their next `.await` point (potentially leaving
342        // partial sink state — the trade-off users opt into by choosing
343        // `stop`). Under `on_error: continue` every spawned task runs to
344        // completion regardless of sibling failures.
345        // Per-level cancel token: cancelling it (on `on_error: stop`) stops only
346        // this level's invocations cooperatively; it is a child of the root
347        // token, so an external cancel (serve) still propagates here (#146 H16).
348        let level_cancel = cancel.child_token();
349        let mut joinset = tokio::task::JoinSet::new();
350        // Map each spawned task's id back to its row id + parent key so that a
351        // panic (surfaced as a JoinError, which doesn't carry the unit) can be
352        // attributed to the right invocation.
353        let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
354        for unit in units {
355            let sem = Arc::clone(&semaphore);
356            let opts2 = Arc::clone(&opts);
357            let captured = Arc::clone(&captured);
358            let capture = projections.get(&unit.node.id).cloned();
359            let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
360            let unit_cancel = level_cancel.clone();
361            let handle = joinset.spawn(async move {
362                let _permit = sem.acquire().await.expect("semaphore not closed");
363                run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
364            });
365            task_meta.insert(handle.id(), meta);
366        }
367
368        let mut stop_triggered = false;
369        let mut aborted = false;
370        let mut stop_deadline: Option<tokio::time::Instant> = None;
371        loop {
372            // Once `on_error: stop` has cancelled the level, give in-flight
373            // invocations a bounded grace to flush cooperatively, then
374            // hard-abort the stragglers (the backstop for a sink stuck
375            // mid-write that can't reach a page boundary to observe the cancel).
376            let joined = match stop_deadline {
377                Some(deadline) if !aborted => {
378                    match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
379                        Ok(j) => j,
380                        Err(_) => {
381                            tracing::warn!(
382                                "on_error: stop — flush grace elapsed; aborting remaining \
383                                 in-flight invocations"
384                            );
385                            joinset.abort_all();
386                            aborted = true;
387                            continue;
388                        }
389                    }
390                }
391                _ => joinset.join_next_with_id().await,
392            };
393            let Some(joined) = joined else { break };
394            // A failure (an `Err` outcome or a panicked task) marks the level
395            // failed and, under `on_error: stop`, stops the rest. A panicking
396            // connector must NOT take down the whole process (#78/#24).
397            let outcome = match joined {
398                Ok((_id, outcome)) => outcome,
399                Err(e) if e.is_cancelled() => {
400                    // Expected after abort_all() — cancelled before/at an await.
401                    // Not counted as a failure or a success.
402                    continue;
403                }
404                Err(e) => {
405                    let (row_id, parent_record_key) = task_meta
406                        .get(&e.id())
407                        .cloned()
408                        .unwrap_or_else(|| ("<unknown>".to_string(), None));
409                    InvocationOutcome {
410                        row_id,
411                        parent_record_key,
412                        records_written: 0,
413                        error: Some(format!("pipeline invocation task panicked: {e}")),
414                    }
415                }
416            };
417
418            if let Some(err) = &outcome.error {
419                tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
420                had_level_failure = true;
421                nodes_with_any_failure.insert(outcome.row_id.clone());
422                if matches!(on_error, OnError::Stop) && !stop_triggered {
423                    stop_triggered = true;
424                    tracing::error!(
425                        "on_error: stop — cancelling in-flight invocations (cooperative \
426                         flush), then aborting any that don't stop within the grace window"
427                    );
428                    // Cooperative first: in-flight pipelines flush at their next
429                    // page boundary so a Parquet footer / S3 upload is completed
430                    // rather than orphaned (#146 H16).
431                    level_cancel.cancel();
432                    stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
433                }
434            } else {
435                tracing::info!(
436                    row = %outcome.row_id,
437                    records_written = outcome.records_written,
438                    "pipeline invocation completed"
439                );
440            }
441            outcomes.push(outcome);
442        }
443
444        // Mark ready nodes done (some may have produced both successes and
445        // failures across their per-parent-record fan-outs — we treat a node
446        // as "failed" overall if any of its invocations failed).
447        for id in ready {
448            remaining.remove(&id);
449            if nodes_with_any_failure.contains(&id) {
450                skipped_subtrees.insert(id.clone());
451                // Cascade to descendants in case we have multi-level chains.
452                if let Some(children) = children_of.get(&id) {
453                    for cid in children {
454                        skipped_subtrees.insert(cid.clone());
455                    }
456                }
457            } else {
458                completed.insert(id);
459            }
460        }
461
462        if had_level_failure && matches!(on_error, OnError::Stop) {
463            tracing::error!("on_error: stop — aborting after first failure");
464            // Any unfinished work surfaces as "skipped"; we just break here.
465            break;
466        }
467    }
468
469    Ok(RunSummary {
470        invocations: outcomes,
471    })
472}
473
474/// One scheduled invocation — a root runs once, a child runs once per parent
475/// record. Built by the level loop, consumed by [`run_unit`].
476struct Unit {
477    node: ExpandedNode,
478    parent_record: Option<Arc<Value>>,
479    state_key: String,
480    parent_record_key: Option<String>,
481}
482
483async fn run_unit(
484    unit: &Unit,
485    capture: Option<Arc<Projection>>,
486    captured: &CapturedRecords,
487    opts: &ExecuteOptions,
488    cancel: CancellationToken,
489) -> InvocationOutcome {
490    let needs_capture = capture.is_some();
491    let result = run_one_invocation(
492        &unit.node,
493        unit.parent_record.as_deref(),
494        &unit.state_key,
495        capture,
496        opts,
497        cancel,
498    )
499    .await;
500    let row_id = unit.node.id.clone();
501    let parent_record_key = unit.parent_record_key.clone();
502    match result {
503        Ok((records, written)) => {
504            if needs_capture {
505                captured
506                    .lock()
507                    .await
508                    .entry(row_id.clone())
509                    .or_default()
510                    // Move each record into an `Arc` once here; downstream
511                    // per-level / per-unit hand-offs then clone only the pointer.
512                    .extend(records.into_iter().map(Arc::new));
513            }
514            InvocationOutcome {
515                row_id,
516                parent_record_key,
517                records_written: written,
518                error: None,
519            }
520        }
521        Err(e) => InvocationOutcome {
522            row_id,
523            parent_record_key,
524            records_written: 0,
525            error: Some(e.to_string()),
526        },
527    }
528}
529
530/// Produce `{pipeline_name}::{row_id}` or `{pipeline_name}::{row_id}::{key}`.
531pub(crate) fn build_state_key(
532    pipeline_name: &str,
533    row_id: &str,
534    parent_key: Option<&str>,
535) -> String {
536    match parent_key {
537        None => format!("{pipeline_name}::{row_id}"),
538        Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
539    }
540}
541
542/// Reject an invalid state key up front (at unit construction) when the node
543/// will use a state store, so a bad pipeline name or parent-key value surfaces
544/// as a clear [`CliError::InvalidStateKey`] instead of a late mid-run
545/// `FaucetError::State` after connectors are built and the stream has started.
546fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
547    if uses_state {
548        faucet_core::state::validate_state_key(state_key).map_err(|e| {
549            CliError::InvalidStateKey {
550                id: node_id.to_owned(),
551                state_key: state_key.to_owned(),
552                reason: e.to_string(),
553            }
554        })?;
555    }
556    Ok(())
557}
558
559/// Walk the parent record by `parent_key` (a dotted path) and clone the value.
560fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
561    let mut cur = record;
562    for segment in parent_key.split('.') {
563        cur = match cur {
564            Value::Object(m) => m.get(segment)?,
565            Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
566            _ => return None,
567        };
568    }
569    Some(cur.clone())
570}
571
572/// What to keep from each of a parent's records when capturing for fan-out.
573/// Projecting to only the fields children reference bounds orchestrator memory
574/// at O(referenced-fields × N) instead of O(full-record × N) (#160).
575#[derive(Debug, Clone)]
576enum Projection {
577    /// Keep the whole record — a child referenced `${parent}` (the entire record)
578    /// or used an empty `parent_key`, so nothing can be safely dropped.
579    Full,
580    /// Keep only these pre-split, non-overlapping dotted paths.
581    Paths(Vec<Vec<String>>),
582}
583
584/// Split a dotted path into segments.
585fn split_path(path: &str) -> Vec<String> {
586    path.split('.').map(|s| s.to_string()).collect()
587}
588
589/// Reduce a set of segment-paths to a minimal non-overlapping set: drop any path
590/// that has a (segment-wise prefix) ancestor in the set — `["user"]` covers
591/// `["user","name"]`. Sorting puts ancestors before their descendants.
592fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
593    paths.sort();
594    paths.dedup();
595    let mut kept: Vec<Vec<String>> = Vec::new();
596    for p in paths {
597        let covered = kept
598            .iter()
599            .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
600        if !covered {
601            kept.push(p);
602        }
603    }
604    kept
605}
606
607/// Resolve a pre-split dotted path against `record`, dispatching on each value's
608/// type exactly like `resolve_parent_key` / `interpolate::resolve_dotted`.
609fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
610    let mut cur = record;
611    for seg in segments {
612        cur = match cur {
613            Value::Object(m) => m.get(seg)?,
614            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
615            _ => return None,
616        };
617    }
618    Some(cur.clone())
619}
620
621/// Insert `leaf` at `segments` into `out`, creating intermediate `Value::Object`
622/// nodes keyed by the literal segment string. Callers pass non-overlapping
623/// `segments` (see `minimal_paths`), so a node that must be an object is never
624/// already a leaf.
625fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
626    if segments.is_empty() {
627        return;
628    }
629    let mut cur = out;
630    for seg in &segments[..segments.len() - 1] {
631        let map = match cur {
632            Value::Object(m) => m,
633            _ => return,
634        };
635        cur = map
636            .entry(seg.clone())
637            .or_insert_with(|| Value::Object(serde_json::Map::new()));
638    }
639    if let Value::Object(m) = cur {
640        m.insert(segments[segments.len() - 1].clone(), leaf);
641    }
642}
643
644/// Project `record` down to `projection`, building an all-objects reduced tree.
645/// Because the readers (`resolve_parent_key`, `interpolate_record`) dispatch on
646/// the reduced value's type, an array-index segment like `0` is stored — and
647/// later read — as the object key `"0"`, so resolution matches the original.
648fn project_record(record: &Value, projection: &Projection) -> Value {
649    match projection {
650        Projection::Full => record.clone(),
651        Projection::Paths(paths) => {
652            let mut out = Value::Object(serde_json::Map::new());
653            for segs in paths {
654                if let Some(v) = walk_value(record, segs) {
655                    graft_object(&mut out, segs, v);
656                }
657            }
658            out
659        }
660    }
661}
662
663/// Compute, per parent id, what to keep from each of its records: the union of
664/// every child's `parent_key` (the state-key path) and every `${parent.path}`
665/// token the children reference (from their pre-collected `deferred_refs`).
666/// Reused by the level loop to project captured records (#160).
667fn build_projections(
668    nodes_by_id: &HashMap<String, ExpandedNode>,
669    children_of: &HashMap<String, Vec<String>>,
670) -> HashMap<String, Arc<Projection>> {
671    let mut out = HashMap::new();
672    for (parent_id, child_ids) in children_of {
673        let mut raw: Vec<Vec<String>> = Vec::new();
674        let mut full = false;
675        for cid in child_ids {
676            let child = &nodes_by_id[cid];
677            if let NodeRole::Child { parent_key, .. } = &child.role {
678                if parent_key.is_empty() {
679                    full = true;
680                } else {
681                    raw.push(split_path(parent_key));
682                }
683            }
684            for dref in &child.deferred_refs {
685                if dref.referenced_id == *parent_id {
686                    if dref.dotted_path.is_empty() {
687                        full = true; // `${parent}` — whole record
688                    } else {
689                        raw.push(split_path(&dref.dotted_path));
690                    }
691                }
692            }
693        }
694        // Defensive: `raw` is empty only when every child had an empty
695        // parent_key, which already set `full`. The `|| raw.is_empty()` keeps us
696        // on `Full` even if that ever changes, so we never project to an empty
697        // tree that would drop the state-key path.
698        let projection = if full || raw.is_empty() {
699            Projection::Full
700        } else {
701            Projection::Paths(minimal_paths(raw))
702        };
703        out.insert(parent_id.clone(), Arc::new(projection));
704    }
705    out
706}
707
708/// Run one pipeline invocation. Returns (captured records, records_written).
709async fn run_one_invocation(
710    node: &ExpandedNode,
711    parent_record: Option<&Value>,
712    state_key: &str,
713    capture: Option<Arc<Projection>>,
714    opts: &ExecuteOptions,
715    cancel: CancellationToken,
716) -> CliResult<(Vec<Value>, usize)> {
717    // Observability identity for this invocation — built once, reused by both
718    // the Pipeline builder and the transform instrumentation.
719    let run_id = uuid::Uuid::now_v7().to_string();
720    let pipeline_name = opts.pipeline_name.clone();
721    let row_id = node.id.clone();
722    #[cfg(feature = "lineage")]
723    let lineage = opts.lineage.clone();
724    #[cfg(feature = "lineage")]
725    let lineage_cfg = opts.lineage_cfg.clone();
726    let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
727    // 1) Resolve `${parent.path}` in the per-row source + sink configs.
728    let mut source_cfg = node.source.config.clone();
729    let mut sink_cfg = node.sink.config.clone();
730
731    // Resolve `${now.*}` run-clock tokens for every invocation (root + child),
732    // before the parent-record pass. Leaves all other tokens verbatim.
733    resolve_now_inplace(&mut source_cfg, opts.clock)?;
734    resolve_now_inplace(&mut sink_cfg, opts.clock)?;
735
736    if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
737        let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
738        resolve_inplace(&mut source_cfg, &ctx)?;
739        resolve_inplace(&mut sink_cfg, &ctx)?;
740    }
741
742    // 2) Build source + sink.
743    let source = build_source(
744        &node.source.kind,
745        source_cfg,
746        &opts.auth,
747        opts.resilience.as_ref().map(|r| &r.retry),
748    )
749    .await?;
750
751    // Clustered Mode B: narrow the raw source to its assigned shard BEFORE any
752    // wrapping (the TransformingSource / StateKeyOverride wrappers do not forward
753    // apply_shard, so it must reach the concrete connector).
754    if let Some(shard) = &opts.shard {
755        source
756            .apply_shard(shard)
757            .await
758            .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
759    }
760    let raw_sink: Box<dyn Sink> = if opts.dry_run {
761        Box::new(CountingSink::new())
762    } else {
763        build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
764    };
765    let raw_sink: Box<dyn Sink> = match opts.limit {
766        Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
767        None => raw_sink,
768    };
769    let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
770    let sink: Box<dyn Sink> = match &capture {
771        Some(projection) => Box::new(CapturingSink::wrap(
772            raw_sink,
773            Arc::clone(&captured),
774            Arc::clone(projection),
775        )),
776        None => raw_sink,
777    };
778
779    // ── Lineage: sampling wrappers (only for requested facets) ───────────────
780    // `in_sample` taps the source's pre-transform records (input schema /
781    // column lineage); `out_sample` taps the sink's written records (output
782    // schema / RUNNING-heartbeat throughput counter). Both stay `None` when no
783    // facet/event needs them, so lineage adds zero per-record overhead.
784    #[cfg(feature = "lineage")]
785    let (in_sample, out_sample) = {
786        use std::sync::Arc as StdArc;
787        match (&lineage, &lineage_cfg) {
788            (Some(_), Some(lc)) => {
789                let want_schema = lc.include_schema_facet || lc.include_column_lineage;
790                let cap = if want_schema { lc.sample_records } else { 0 };
791                let need_counter = lc.emit_on.running;
792                if want_schema || need_counter {
793                    (
794                        Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
795                        Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
796                    )
797                } else {
798                    (None, None)
799                }
800            }
801            _ => (None, None),
802        }
803    };
804
805    // Wrap the raw source so it samples PRE-transform input records — this must
806    // sit between `build_source` and `TransformingSource`.
807    #[cfg(feature = "lineage")]
808    let source: Box<dyn Source> = match &in_sample {
809        Some(state) => Box::new(faucet_lineage::SamplingSource::new(
810            source,
811            std::sync::Arc::clone(state),
812        )),
813        None => source,
814    };
815
816    // 3) Compile transforms. Resolve `${now.*}` run-clock tokens in each
817    //    transform's config first — exactly as for source/sink above — so e.g. a
818    //    `set` transform stamping `${now.date}` writes the real date instead of
819    //    the literal token string. Without this the token leaks into every record.
820    let stages = if node.transforms.is_empty() {
821        compile_transforms(&node.transforms)?
822    } else {
823        let mut transforms = node.transforms.clone();
824        for t in &mut transforms {
825            resolve_now_inplace(&mut t.config, opts.clock)?;
826        }
827        compile_transforms(&transforms)?
828    };
829    let source: Box<dyn Source> = if stages.is_empty() {
830        source
831    } else {
832        Box::new(faucet_core::TransformingSource::new(
833            source,
834            stages,
835            obs_labels.clone(),
836        )?)
837    };
838
839    // 4) Build state store. If the source opts into state, wrap it so the
840    //    executor's per-row state key is used instead of the source's natural
841    //    one (which is shared across all matrix rows of the same kind).
842    let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
843    // Per-shard bookmark: suffix the state key with the shard id so a reassigned
844    // shard resumes where its dead owner left off, independent of sibling shards.
845    let effective_state_key = match &opts.shard {
846        Some(shard) => format!("{state_key}::{}", shard.id),
847        None => state_key.to_owned(),
848    };
849    let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
850        Box::new(StateKeyOverride {
851            inner: source,
852            key: effective_state_key,
853        })
854    } else {
855        source
856    };
857
858    // Wrap the sink so it samples written records — outermost, immediately
859    // before the pipeline is constructed (after capture/limit wrappers).
860    #[cfg(feature = "lineage")]
861    let sink: Box<dyn Sink> = match &out_sample {
862        Some(state) => Box::new(faucet_lineage::SamplingSink::new(
863            sink,
864            std::sync::Arc::clone(state),
865        )),
866        None => sink,
867    };
868
869    // 5) Run.
870    // When lineage is enabled the START/terminal lifecycle below still needs
871    // `pipeline_name` / `row_id` / `run_id`, so hand the builder clones; the
872    // non-lineage build moves them straight in (byte-identical to before).
873    #[cfg(feature = "lineage")]
874    let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
875        .with_name(pipeline_name.clone())
876        .with_row(row_id.clone())
877        .with_run_id(run_id.clone());
878    #[cfg(not(feature = "lineage"))]
879    let pipeline = Pipeline::new(source.as_ref(), sink.as_ref())
880        .with_name(pipeline_name)
881        .with_row(row_id)
882        .with_run_id(run_id);
883    let pipeline = match state {
884        Some(store) => pipeline.with_state_store(store),
885        None => pipeline,
886    };
887    let pipeline = if let Some(ref dlq_spec) = node.dlq {
888        let dlq_cfg = build_dlq_config(dlq_spec).await?;
889        pipeline.with_dlq(dlq_cfg)
890    } else {
891        pipeline
892    };
893    // Cooperative cancellation: a cancelled token makes the streaming loop stop
894    // at the next page boundary and flush the sink (#146 H16). Under lineage we
895    // hand the pipeline a clone so the terminal-event classification below can
896    // still read `cancel.is_cancelled()` (cheap — the token is an `Arc`).
897    #[cfg(feature = "lineage")]
898    let pipeline = pipeline.with_cancel(cancel.clone());
899    #[cfg(not(feature = "lineage"))]
900    let pipeline = pipeline.with_cancel(cancel);
901    // Pipeline-level quality checks (v1: no matrix-row override). `expand`
902    // already validated this spec, but compile again here to obtain the
903    // runtime `CompiledQuality`; map any error to a config-level failure.
904    #[cfg(feature = "quality")]
905    let pipeline = if let Some(ref quality_spec) = node.quality {
906        let compiled = Arc::new(
907            faucet_core::CompiledQuality::compile(quality_spec)
908                .map_err(|e| CliError::Config(format!("quality: {e}")))?,
909        );
910        pipeline.with_quality(compiled)
911    } else {
912        pipeline
913    };
914    // Schema-drift policy (pipeline-level in v1; same for every invocation).
915    let pipeline = if let Some(ref sd) = node.schema {
916        pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd))
917    } else {
918        pipeline
919    };
920    // Execution-level adaptive batch-size controller (shared by all rows).
921    let pipeline = if let Some(ab) = opts
922        .execution
923        .as_ref()
924        .and_then(|e| e.adaptive_batch_size.clone())
925    {
926        ab.validate()
927            .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
928        pipeline.with_adaptive(ab)
929    } else {
930        pipeline
931    };
932    // Resilience policy (retry/backoff/circuit-breaker/poison). Top-level in v1,
933    // so the same policy is attached to every invocation.
934    let pipeline = if let Some(policy) = opts.resilience.clone() {
935        pipeline.with_resilience(policy)
936    } else {
937        pipeline
938    };
939    // Delivery guarantee (exactly-once resume/skip when the node opted in; the
940    // expand gate already verified source/sink/state support).
941    let pipeline = pipeline.with_delivery(node.delivery);
942    // ── Lineage: START + heartbeat + terminal ────────────────────────────────
943    #[cfg(feature = "lineage")]
944    let lineage_ctx = match (&lineage, &lineage_cfg) {
945        (Some(em), Some(lc)) => {
946            let job_name =
947                crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
948            let mut ctx = faucet_lineage::RunLifecycle {
949                job_namespace: lc.namespace.clone(),
950                job_name,
951                run_id: run_id.clone(),
952                parent: lc.parent_job.clone(),
953                input: faucet_lineage::DatasetRef {
954                    namespace: lc.namespace.clone(),
955                    name: source.dataset_uri(),
956                },
957                output: faucet_lineage::DatasetRef {
958                    namespace: lc.namespace.clone(),
959                    name: sink.dataset_uri(),
960                },
961                started_at: chrono::Utc::now(),
962                finished_at: None,
963                records: 0,
964                error: None,
965                input_schema: None,
966                output_schema: None,
967                column_lineage: None,
968                source_code: None,
969            };
970            em.emit(faucet_lineage::EventType::Start, &ctx).await;
971            // Heartbeat task — periodic RUNNING events with the live throughput
972            // count read off the output sampler.
973            let hb_handle = if lc.emit_on.running {
974                let em2 = std::sync::Arc::clone(em);
975                let interval = lc.heartbeat_interval;
976                let mut beat_ctx = ctx.clone();
977                let counter = out_sample.clone();
978                Some(tokio::spawn(async move {
979                    let mut tick = tokio::time::interval(interval);
980                    tick.tick().await; // skip the immediate first tick
981                    loop {
982                        tick.tick().await;
983                        if let Some(c) = &counter {
984                            beat_ctx.records = c.count();
985                        }
986                        em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
987                            .await;
988                    }
989                }))
990            } else {
991                None
992            };
993            ctx.source_code = if lc.include_source_code_facet {
994                Some(serde_json::to_string(&node.source.config).unwrap_or_default())
995            } else {
996                None
997            };
998            Some((std::sync::Arc::clone(em), ctx, hb_handle))
999        }
1000        _ => None,
1001    };
1002
1003    // Combine run + final flush into one outcome BEFORE emitting the terminal
1004    // lineage event, preserving the original semantics (run error → skip flush;
1005    // run ok but flush error → overall error). The terminal event is classified
1006    // from this combined `result`, then `?`-propagated below — restoring the
1007    // original early-return behaviour while still firing the terminal event on
1008    // both success and error.
1009    let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1010        Ok(r) => sink.flush().await.map(|_| r),
1011        Err(e) => Err(e),
1012    };
1013
1014    #[cfg(feature = "lineage")]
1015    if let Some((em, mut ctx, hb)) = lineage_ctx {
1016        if let Some(h) = hb {
1017            h.abort();
1018        }
1019        ctx.finished_at = Some(chrono::Utc::now());
1020        if let Some(state) = &out_sample {
1021            ctx.records = state.count();
1022            if lineage_cfg
1023                .as_ref()
1024                .map(|l| l.include_schema_facet)
1025                .unwrap_or(false)
1026            {
1027                ctx.output_schema = Some(state.inferred_schema());
1028            }
1029        }
1030        if let Some(state) = &in_sample
1031            && lineage_cfg
1032                .as_ref()
1033                .map(|l| l.include_schema_facet || l.include_column_lineage)
1034                .unwrap_or(false)
1035        {
1036            let in_schema = state.inferred_schema();
1037            if lineage_cfg
1038                .as_ref()
1039                .map(|l| l.include_column_lineage)
1040                .unwrap_or(false)
1041            {
1042                let input_fields: Vec<String> =
1043                    in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1044                let ops = crate::lineage_glue::column_ops(&node.transforms);
1045                ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1046            }
1047            if lineage_cfg
1048                .as_ref()
1049                .map(|l| l.include_schema_facet)
1050                .unwrap_or(false)
1051            {
1052                ctx.input_schema = Some(in_schema);
1053            }
1054        }
1055        let ev = match &result {
1056            Err(e) => {
1057                ctx.error = Some(e.to_string());
1058                faucet_lineage::EventType::Fail
1059            }
1060            Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1061            Ok(_) => faucet_lineage::EventType::Complete,
1062        };
1063        em.emit(ev, &ctx).await;
1064    }
1065
1066    let result = result?;
1067
1068    let captured = if capture.is_some() {
1069        std::mem::take(&mut *captured.lock().await)
1070    } else {
1071        Vec::new()
1072    };
1073    Ok((captured, result.records_written))
1074}
1075
1076async fn build_state_for_node(
1077    node: &ExpandedNode,
1078    state_path_override: Option<&Path>,
1079) -> CliResult<Option<Arc<dyn StateStore>>> {
1080    match (&node.state, state_path_override) {
1081        (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1082        (None, Some(path)) => Ok(Some(state_from_override(path))),
1083        (Some(spec), Some(path)) => {
1084            if spec.kind == "file" {
1085                Ok(Some(state_from_override(path)))
1086            } else {
1087                tracing::warn!(
1088                    state = %spec.kind,
1089                    "--state-path is only meaningful for the 'file' backend; ignoring override"
1090                );
1091                Ok(Some(build_state_store(spec).await?))
1092            }
1093        }
1094        (None, None) => Ok(None),
1095    }
1096}
1097
1098fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1099    Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1100}
1101
1102/// Translate a [`crate::config::DlqSpec`] from the YAML/JSON config into a
1103/// runtime [`DlqConfig`] ready to attach to a [`Pipeline`].
1104pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1105    // DLQ sinks resolve against an empty catalog — shared `auth: { ref }` on a
1106    // DLQ sink is out of scope (DLQ targets are typically local jsonl/stdout).
1107    let sink = build_sink(
1108        &spec.sink.kind,
1109        spec.sink.config.clone(),
1110        &AuthCatalog::new(),
1111    )
1112    .await?;
1113    Ok(DlqConfig {
1114        sink: Arc::from(sink),
1115        on_batch_error: match spec.on_batch_error {
1116            crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1117            crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1118        },
1119        max_failures_per_page: spec.max_failures_per_page,
1120        max_failures_total: spec.max_failures_total,
1121        include_original_payload: spec.include_original_payload,
1122    })
1123}
1124
1125/// In-place `${now.*}` resolution against the run clock. Walks every string
1126/// leaf and rewrites `${now.<token>}`; all other `${...}` tokens are untouched.
1127fn resolve_now_inplace(value: &mut Value, clock: DateTime<FixedOffset>) -> CliResult<()> {
1128    match value {
1129        Value::String(s) => {
1130            *s = crate::interpolate::resolve_now(s, clock)?;
1131            Ok(())
1132        }
1133        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1134        Value::Object(m) => m
1135            .values_mut()
1136            .try_for_each(|v| resolve_now_inplace(v, clock)),
1137        _ => Ok(()),
1138    }
1139}
1140
1141/// In-place runtime interpolation against a parent-record context. Walks every
1142/// string leaf in `value` and replaces `${id.path}` tokens with stringified
1143/// values from `ctx`.
1144fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1145    match value {
1146        Value::String(s) => {
1147            let resolved = interpolate_record(s, ctx)?;
1148            *s = resolved;
1149            Ok(())
1150        }
1151        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1152        Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1153        _ => Ok(()),
1154    }
1155}
1156
1157// ── Adapter sinks/sources ───────────────────────────────────────────────────
1158
1159/// Wraps a source so its `state_key()` returns the executor-provided value
1160/// instead of the source's natural one. Lets every matrix invocation use a
1161/// distinct state-store entry even when the underlying source kind is shared.
1162struct StateKeyOverride {
1163    inner: Box<dyn Source>,
1164    key: String,
1165}
1166
1167#[async_trait]
1168impl Source for StateKeyOverride {
1169    async fn fetch_with_context(
1170        &self,
1171        ctx: &HashMap<String, Value>,
1172    ) -> Result<Vec<Value>, FaucetError> {
1173        self.inner.fetch_with_context(ctx).await
1174    }
1175    async fn fetch_with_context_incremental(
1176        &self,
1177        ctx: &HashMap<String, Value>,
1178    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1179        self.inner.fetch_with_context_incremental(ctx).await
1180    }
1181    fn connector_name(&self) -> &'static str {
1182        self.inner.connector_name()
1183    }
1184    fn state_key(&self) -> Option<String> {
1185        Some(self.key.clone())
1186    }
1187    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1188        self.inner.apply_start_bookmark(bookmark).await
1189    }
1190}
1191
1192/// Forwards each record to an inner sink while also capturing a **projected**
1193/// copy into a shared buffer for descendant rows to consume. Projecting to only
1194/// the fields children reference bounds orchestrator memory (#160).
1195struct CapturingSink {
1196    inner: Box<dyn Sink>,
1197    captured: Arc<Mutex<Vec<Value>>>,
1198    projection: Arc<Projection>,
1199}
1200
1201impl CapturingSink {
1202    fn wrap(
1203        inner: Box<dyn Sink>,
1204        captured: Arc<Mutex<Vec<Value>>>,
1205        projection: Arc<Projection>,
1206    ) -> Self {
1207        Self {
1208            inner,
1209            captured,
1210            projection,
1211        }
1212    }
1213}
1214
1215#[async_trait]
1216impl Sink for CapturingSink {
1217    fn connector_name(&self) -> &'static str {
1218        self.inner.connector_name()
1219    }
1220    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1221        let written = self.inner.write_batch(records).await?;
1222        // Capture only what actually landed (LimitedSink may have dropped some),
1223        // projected to the fields children reference (#160).
1224        let n = written.min(records.len());
1225        let mut buf = self.captured.lock().await;
1226        buf.extend(
1227            records
1228                .iter()
1229                .take(n)
1230                .map(|r| project_record(r, &self.projection)),
1231        );
1232        Ok(written)
1233    }
1234    async fn flush(&self) -> Result<(), FaucetError> {
1235        self.inner.flush().await
1236    }
1237}
1238
1239/// Cap on records written. Each `write_batch` call truncates `records` to the
1240/// remaining budget before delegating.
1241struct LimitedSink {
1242    inner: Box<dyn Sink>,
1243    remaining: AtomicUsize,
1244}
1245
1246impl LimitedSink {
1247    fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1248        Self {
1249            inner,
1250            remaining: AtomicUsize::new(cap),
1251        }
1252    }
1253}
1254
1255#[async_trait]
1256impl Sink for LimitedSink {
1257    fn connector_name(&self) -> &'static str {
1258        self.inner.connector_name()
1259    }
1260    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1261        let remaining = self.remaining.load(Ordering::Relaxed);
1262        if remaining == 0 {
1263            return Ok(0);
1264        }
1265        let take = remaining.min(records.len());
1266        let slice = &records[..take];
1267        let written = self.inner.write_batch(slice).await?;
1268        self.remaining
1269            .fetch_sub(written.min(remaining), Ordering::Relaxed);
1270        Ok(written)
1271    }
1272    async fn flush(&self) -> Result<(), FaucetError> {
1273        self.inner.flush().await
1274    }
1275}
1276
1277/// No-op sink used in `--dry-run`. Counts records seen so the rest of the
1278/// pipeline (transforms, source) still runs.
1279struct CountingSink {
1280    seen: AtomicUsize,
1281}
1282
1283impl CountingSink {
1284    fn new() -> Self {
1285        Self {
1286            seen: AtomicUsize::new(0),
1287        }
1288    }
1289}
1290
1291#[async_trait]
1292impl Sink for CountingSink {
1293    fn connector_name(&self) -> &'static str {
1294        "dry-run"
1295    }
1296    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1297        self.seen.fetch_add(records.len(), Ordering::Relaxed);
1298        Ok(records.len())
1299    }
1300}
1301
1302/// Render a JSON value compactly for use as a state-key suffix or log line.
1303/// Strings pass through unquoted; numbers/bools/null/composites use to_string.
1304fn value_to_string_brief(v: &Value) -> String {
1305    match v {
1306        Value::String(s) => s.clone(),
1307        other => other.to_string(),
1308    }
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313    use super::*;
1314    use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1315    use crate::expand::expand;
1316    use serde_json::json;
1317
1318    fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
1319        PipelineConfig {
1320            version: 1,
1321            name: Some("test".into()),
1322            vars: None,
1323            auth: None,
1324            pipeline: PipelineSpec {
1325                source: Some(ConnectorSpec {
1326                    kind: "csv".into(),
1327                    config: json!({"path": input.to_str().unwrap()}),
1328                    transforms: None,
1329                    inherit_transforms: true,
1330                }),
1331                sink: Some(ConnectorSpec {
1332                    kind: "jsonl".into(),
1333                    config: json!({"path": output.to_str().unwrap()}),
1334                    transforms: None,
1335                    inherit_transforms: true,
1336                }),
1337                sources: Default::default(),
1338                sinks: Default::default(),
1339                transforms: Vec::new(),
1340                state: None,
1341                dlq: None,
1342                #[cfg(feature = "quality")]
1343                quality: None,
1344                schema: None,
1345            },
1346            matrix: Vec::new(),
1347            execution: None,
1348            observability: None,
1349            delivery: faucet_core::DeliveryMode::default(),
1350            resilience: None,
1351            shard: None,
1352            replication: None,
1353            #[cfg(feature = "schedule")]
1354            schedule: None,
1355            #[cfg(feature = "lineage")]
1356            lineage: None,
1357        }
1358    }
1359
1360    #[tokio::test]
1361    async fn empty_matrix_runs_pipeline_once() {
1362        let dir = tempfile::tempdir().unwrap();
1363        let input = dir.path().join("in.csv");
1364        let output = dir.path().join("out.jsonl");
1365        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
1366        let cfg = cfg_csv_to_jsonl(&input, &output);
1367        let nodes = expand(&cfg).unwrap();
1368        let summary = run_expanded(
1369            nodes,
1370            ExecuteOptions {
1371                pipeline_name: "t".into(),
1372                execution: None,
1373                dry_run: false,
1374                limit: None,
1375                state_path_override: None,
1376                shard: None,
1377                auth: Default::default(),
1378                clock: chrono::Utc::now().fixed_offset(),
1379                cancel: None,
1380                resilience: None,
1381                #[cfg(feature = "lineage")]
1382                lineage: None,
1383                #[cfg(feature = "lineage")]
1384                lineage_cfg: None,
1385            },
1386        )
1387        .await
1388        .unwrap();
1389        assert_eq!(summary.invocations.len(), 1);
1390        assert_eq!(summary.invocations[0].records_written, 2);
1391        assert!(!summary.had_failures());
1392        let body = std::fs::read_to_string(&output).unwrap();
1393        assert_eq!(body.lines().count(), 2);
1394    }
1395
1396    #[tokio::test]
1397    async fn matrix_two_independent_roots_both_run() {
1398        // Two roots: one writes alice, the other writes bob — to two separate files.
1399        let dir = tempfile::tempdir().unwrap();
1400        let csv_a = dir.path().join("a.csv");
1401        let csv_b = dir.path().join("b.csv");
1402        let out_a = dir.path().join("a.jsonl");
1403        let out_b = dir.path().join("b.jsonl");
1404        std::fs::write(&csv_a, "name\nalice\n").unwrap();
1405        std::fs::write(&csv_b, "name\nbob\n").unwrap();
1406
1407        let yaml = format!(
1408            r#"version: 1
1409pipeline:
1410  source: {{ type: csv, config: {{ path: {a} }} }}
1411  sink:   {{ type: jsonl, config: {{ path: {out_a} }} }}
1412matrix:
1413  - id: rowA
1414  - id: rowB
1415    source: {{ config: {{ path: {b} }} }}
1416    sink:   {{ config: {{ path: {out_b} }} }}
1417"#,
1418            a = csv_a.display(),
1419            b = csv_b.display(),
1420            out_a = out_a.display(),
1421            out_b = out_b.display(),
1422        );
1423        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1424        let nodes = expand(&cfg).unwrap();
1425        let summary = run_expanded(
1426            nodes,
1427            ExecuteOptions {
1428                pipeline_name: "matrix".into(),
1429                execution: None,
1430                dry_run: false,
1431                limit: None,
1432                state_path_override: None,
1433                shard: None,
1434                auth: Default::default(),
1435                clock: chrono::Utc::now().fixed_offset(),
1436                cancel: None,
1437                resilience: None,
1438                #[cfg(feature = "lineage")]
1439                lineage: None,
1440                #[cfg(feature = "lineage")]
1441                lineage_cfg: None,
1442            },
1443        )
1444        .await
1445        .unwrap();
1446        assert_eq!(summary.invocations.len(), 2);
1447        assert!(out_a.exists());
1448        assert!(out_b.exists());
1449    }
1450
1451    #[tokio::test]
1452    async fn dag_child_fans_out_per_parent_record() {
1453        // Parent: CSV with two records (id=1, id=2).
1454        // Child: writes one JSONL file per parent id, using ${parent.id} in the path.
1455        let dir = tempfile::tempdir().unwrap();
1456        let parent_csv = dir.path().join("parents.csv");
1457        let child_csv = dir.path().join("child.csv");
1458        std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
1459        std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
1460        let parent_out = dir.path().join("parents.jsonl");
1461        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
1462
1463        let yaml = format!(
1464            r#"version: 1
1465pipeline:
1466  source: {{ type: csv, config: {{ path: {parent} }} }}
1467  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
1468matrix:
1469  - id: parents
1470  - id: child
1471    parent: parents
1472    source: {{ config: {{ path: {child} }} }}
1473    sink:   {{ config: {{ path: "{child_out}" }} }}
1474"#,
1475            parent = parent_csv.display(),
1476            parent_out = parent_out.display(),
1477            child = child_csv.display(),
1478            child_out = child_out_pattern.display(),
1479        );
1480        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1481        let nodes = expand(&cfg).unwrap();
1482        let summary = run_expanded(
1483            nodes,
1484            ExecuteOptions {
1485                pipeline_name: "dagtest".into(),
1486                execution: None,
1487                dry_run: false,
1488                limit: None,
1489                state_path_override: None,
1490                shard: None,
1491                auth: Default::default(),
1492                clock: chrono::Utc::now().fixed_offset(),
1493                cancel: None,
1494                resilience: None,
1495                #[cfg(feature = "lineage")]
1496                lineage: None,
1497                #[cfg(feature = "lineage")]
1498                lineage_cfg: None,
1499            },
1500        )
1501        .await
1502        .unwrap();
1503
1504        // 1 parent invocation + 2 child invocations.
1505        assert_eq!(summary.invocations.len(), 3);
1506        assert!(!summary.had_failures(), "{:?}", summary);
1507        assert!(dir.path().join("child-1.jsonl").exists());
1508        assert!(dir.path().join("child-2.jsonl").exists());
1509    }
1510
1511    #[tokio::test]
1512    async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
1513        // First root writes to an invalid sink path and fails. The second
1514        // ("good") root would succeed. Under `on_error: stop` the executor
1515        // calls `abort_all()` on the first failure, which cancels pending /
1516        // in-flight tasks at their next await point — but that is
1517        // best-effort: with `max_concurrent: 1` the two roots race for the
1518        // single permit, so "good" may already have completed before "bad"
1519        // fails. We therefore assert the guarantees that hold under *any*
1520        // scheduling rather than an exact invocation count (which was racy,
1521        // see issue #78 finding #24). The deterministic "stop actually
1522        // cancels in-flight work" path is covered by
1523        // `on_error_stop_under_parallelism_aborts_other_in_flight`.
1524        let dir = tempfile::tempdir().unwrap();
1525        let good_csv = dir.path().join("good.csv");
1526        std::fs::write(&good_csv, "x\n1\n").unwrap();
1527        let good_out = dir.path().join("good.jsonl");
1528        let bad_sink_dir = dir.path().to_path_buf();
1529
1530        let yaml = format!(
1531            r#"version: 1
1532pipeline:
1533  source: {{ type: csv, config: {{ path: {good_csv} }} }}
1534  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
1535matrix:
1536  - id: bad
1537    sink: {{ config: {{ path: {bad_dir} }} }}
1538  - id: good
1539execution:
1540  max_concurrent: 1
1541  on_error: stop
1542"#,
1543            good_csv = good_csv.display(),
1544            good_out = good_out.display(),
1545            bad_dir = bad_sink_dir.display(),
1546        );
1547        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1548        let nodes = expand(&cfg).unwrap();
1549        let summary = run_expanded(
1550            nodes,
1551            ExecuteOptions {
1552                pipeline_name: "stoptest".into(),
1553                execution: cfg.execution.clone(),
1554                dry_run: false,
1555                limit: None,
1556                state_path_override: None,
1557                shard: None,
1558                auth: Default::default(),
1559                clock: chrono::Utc::now().fixed_offset(),
1560                cancel: None,
1561                resilience: None,
1562                #[cfg(feature = "lineage")]
1563                lineage: None,
1564                #[cfg(feature = "lineage")]
1565                lineage_cfg: None,
1566            },
1567        )
1568        .await
1569        .unwrap();
1570
1571        // Invariants that hold regardless of which root won the permit race:
1572        assert!(summary.had_failures(), "the failing root must be reported");
1573
1574        // "bad" ran exactly once and is recorded as a failure.
1575        let bad: Vec<_> = summary
1576            .invocations
1577            .iter()
1578            .filter(|o| o.row_id == "bad")
1579            .collect();
1580        assert_eq!(bad.len(), 1, "bad must run exactly once");
1581        assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
1582
1583        // No duplicate / extra invocations beyond the two work units.
1584        assert!(
1585            summary.invocations.len() <= 2,
1586            "at most the two roots may run, got {:?}",
1587            summary.invocations
1588        );
1589
1590        // "good" may: (a) win the permit first and run fully (writes its row,
1591        // file exists); (b) lose the race, acquire the permit after "bad" fails,
1592        // observe the cooperative stop-cancel at its first page boundary, and
1593        // return a 0-record success (no file); or (c) never appear if it was
1594        // still pending when the level finished. So the only invariant is: a
1595        // "good" that actually WROTE records must have produced its file.
1596        let good_wrote = summary
1597            .invocations
1598            .iter()
1599            .find(|o| o.row_id == "good" && o.error.is_none())
1600            .map(|o| o.records_written)
1601            .unwrap_or(0);
1602        if good_wrote > 0 {
1603            assert!(
1604                good_out.exists(),
1605                "a good that wrote records must have produced its output file"
1606            );
1607        }
1608    }
1609
1610    #[tokio::test]
1611    async fn invalid_pipeline_name_with_state_errors_up_front() {
1612        // A pipeline name that can't form a valid state key must fail up front
1613        // (at unit construction) when state is configured — not deep mid-run
1614        // as a `FaucetError::State`.
1615        let dir = tempfile::tempdir().unwrap();
1616        let input = dir.path().join("in.csv");
1617        let output = dir.path().join("out.jsonl");
1618        std::fs::write(&input, "name\nalice\n").unwrap();
1619        let yaml = format!(
1620            r#"version: 1
1621pipeline:
1622  source: {{ type: csv, config: {{ path: {input} }} }}
1623  sink:   {{ type: jsonl, config: {{ path: {output} }} }}
1624  state:  {{ type: memory }}
1625"#,
1626            input = input.display(),
1627            output = output.display(),
1628        );
1629        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1630        let nodes = expand(&cfg).unwrap();
1631        let err = run_expanded(
1632            nodes,
1633            ExecuteOptions {
1634                pipeline_name: "bad name".into(), // space is illegal in a state key
1635                execution: None,
1636                dry_run: false,
1637                limit: None,
1638                state_path_override: None,
1639                shard: None,
1640                auth: Default::default(),
1641                clock: chrono::Utc::now().fixed_offset(),
1642                cancel: None,
1643                resilience: None,
1644                #[cfg(feature = "lineage")]
1645                lineage: None,
1646                #[cfg(feature = "lineage")]
1647                lineage_cfg: None,
1648            },
1649        )
1650        .await
1651        .expect_err("an invalid pipeline name must be rejected up front when state is configured");
1652        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1653    }
1654
1655    #[tokio::test]
1656    async fn invalid_parent_key_value_with_state_errors_up_front() {
1657        // A parent-record value that yields an illegal state-key suffix must
1658        // fail up front at the child's unit construction, not mid-run.
1659        let dir = tempfile::tempdir().unwrap();
1660        let parent_csv = dir.path().join("parents.csv");
1661        let child_csv = dir.path().join("child.csv");
1662        // The parent `id` value contains a space — illegal in a state key.
1663        std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
1664        std::fs::write(&child_csv, "x\nA\n").unwrap();
1665        let parent_out = dir.path().join("parents.jsonl");
1666        let child_out = dir.path().join("child.jsonl");
1667        let yaml = format!(
1668            r#"version: 1
1669pipeline:
1670  source: {{ type: csv, config: {{ path: {parent} }} }}
1671  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
1672  state:  {{ type: memory }}
1673matrix:
1674  - id: parents
1675  - id: child
1676    parent: parents
1677    source: {{ config: {{ path: {child} }} }}
1678    sink:   {{ config: {{ path: {child_out} }} }}
1679"#,
1680            parent = parent_csv.display(),
1681            parent_out = parent_out.display(),
1682            child = child_csv.display(),
1683            child_out = child_out.display(),
1684        );
1685        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1686        let nodes = expand(&cfg).unwrap();
1687        let err = run_expanded(
1688            nodes,
1689            ExecuteOptions {
1690                pipeline_name: "ok".into(),
1691                execution: None,
1692                dry_run: false,
1693                limit: None,
1694                state_path_override: None,
1695                shard: None,
1696                auth: Default::default(),
1697                clock: chrono::Utc::now().fixed_offset(),
1698                cancel: None,
1699                resilience: None,
1700                #[cfg(feature = "lineage")]
1701                lineage: None,
1702                #[cfg(feature = "lineage")]
1703                lineage_cfg: None,
1704            },
1705        )
1706        .await
1707        .expect_err(
1708            "an illegal parent-key value must be rejected up front when state is configured",
1709        );
1710        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
1711    }
1712
1713    #[tokio::test]
1714    async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
1715        // Three roots running with `max_concurrent: 3`. The bad row points
1716        // its sink at a directory (open fails fast). The other two point at
1717        // sinks that block forever on the writer end of a pipe — stuck *inside*
1718        // the sink write, they never reach a page boundary to observe the
1719        // cooperative stop-cancel, so the only way they can complete is the
1720        // hard-abort backstop that fires after the flush grace (#146 H16). The
1721        // test would hang if `on_error: stop` never aborted them, so a passing
1722        // run is itself the assertion.
1723        let dir = tempfile::tempdir().unwrap();
1724        let bad_sink_dir = dir.path().to_path_buf();
1725        // A real csv source with one row — small enough that the pipeline
1726        // proceeds straight to the sink phase.
1727        let good_csv = dir.path().join("good.csv");
1728        std::fs::write(&good_csv, "x\n1\n").unwrap();
1729        // The two "would never finish" sinks point at the same path as the
1730        // bad sink (an existing directory). Their sink-open also errors
1731        // out — but we still verify the *abort* path by counting how many
1732        // tasks make it past spawn before stop fires. The strict invariant
1733        // we assert: the bad row's failure is the first one observed.
1734        let yaml = format!(
1735            r#"version: 1
1736pipeline:
1737  source: {{ type: csv, config: {{ path: {good_csv} }} }}
1738  sink:   {{ type: jsonl, config: {{ path: {bad_dir} }} }}
1739matrix:
1740  - id: bad
1741  - id: good_a
1742  - id: good_b
1743execution:
1744  max_concurrent: 3
1745  on_error: stop
1746"#,
1747            good_csv = good_csv.display(),
1748            bad_dir = bad_sink_dir.display(),
1749        );
1750        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1751        let nodes = expand(&cfg).unwrap();
1752        let summary = run_expanded(
1753            nodes,
1754            ExecuteOptions {
1755                pipeline_name: "stop_parallel".into(),
1756                execution: cfg.execution.clone(),
1757                dry_run: false,
1758                limit: None,
1759                state_path_override: None,
1760                shard: None,
1761                auth: Default::default(),
1762                clock: chrono::Utc::now().fixed_offset(),
1763                cancel: None,
1764                resilience: None,
1765                #[cfg(feature = "lineage")]
1766                lineage: None,
1767                #[cfg(feature = "lineage")]
1768                lineage_cfg: None,
1769            },
1770        )
1771        .await
1772        .unwrap();
1773
1774        // First-observed failure halts the run. The first outcome in the
1775        // summary is guaranteed to be a failure (other tasks either fail
1776        // too or get cancelled — both cases never push a *success* outcome
1777        // first because every sink in this matrix is configured to fail).
1778        assert!(
1779            summary.had_failures(),
1780            "summary should record at least one failure: {summary:?}"
1781        );
1782        assert!(
1783            summary.invocations[0].error.is_some(),
1784            "first outcome must be the failure that triggered stop: {summary:?}"
1785        );
1786        // No invocation should report `records_written > 0` — every sink is
1787        // bad. (Catches a regression where abort_all somehow let a task
1788        // bypass its broken sink.)
1789        for inv in &summary.invocations {
1790            assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
1791        }
1792    }
1793
1794    #[tokio::test]
1795    async fn on_error_continue_skips_failed_subtree_only() {
1796        // Two roots: one fails. The good one's invocation still completes.
1797        let dir = tempfile::tempdir().unwrap();
1798        let good_csv = dir.path().join("good.csv");
1799        std::fs::write(&good_csv, "x\n1\n").unwrap();
1800        let good_out = dir.path().join("good.jsonl");
1801
1802        let yaml = format!(
1803            r#"version: 1
1804pipeline:
1805  source: {{ type: csv, config: {{ path: {good_csv} }} }}
1806  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
1807matrix:
1808  - id: bad
1809    sink: {{ config: {{ path: {bad_dir} }} }}
1810  - id: good
1811"#,
1812            good_csv = good_csv.display(),
1813            good_out = good_out.display(),
1814            bad_dir = dir.path().display(),
1815        );
1816        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
1817        let nodes = expand(&cfg).unwrap();
1818        let summary = run_expanded(
1819            nodes,
1820            ExecuteOptions {
1821                pipeline_name: "continuetest".into(),
1822                execution: None,
1823                dry_run: false,
1824                limit: None,
1825                state_path_override: None,
1826                shard: None,
1827                auth: Default::default(),
1828                clock: chrono::Utc::now().fixed_offset(),
1829                cancel: None,
1830                resilience: None,
1831                #[cfg(feature = "lineage")]
1832                lineage: None,
1833                #[cfg(feature = "lineage")]
1834                lineage_cfg: None,
1835            },
1836        )
1837        .await
1838        .unwrap();
1839        assert_eq!(summary.invocations.len(), 2);
1840        assert_eq!(summary.failure_count(), 1);
1841        let good_outcome = summary
1842            .invocations
1843            .iter()
1844            .find(|i| i.row_id == "good")
1845            .unwrap();
1846        assert!(good_outcome.error.is_none());
1847    }
1848
1849    // ── projection helpers (#160) ─────────────────────────────────────────────
1850
1851    #[test]
1852    fn split_path_splits_on_dots() {
1853        assert_eq!(split_path("id"), vec!["id".to_string()]);
1854        assert_eq!(
1855            split_path("user.name"),
1856            vec!["user".to_string(), "name".to_string()]
1857        );
1858    }
1859
1860    #[test]
1861    fn minimal_paths_drops_descendants_of_kept_ancestors() {
1862        let paths = vec![
1863            vec!["user".into(), "name".into()],
1864            vec!["user".into()],
1865            vec!["id".into()],
1866            vec!["id".into()],
1867        ];
1868        let min = minimal_paths(paths);
1869        assert!(min.contains(&vec!["user".to_string()]));
1870        assert!(min.contains(&vec!["id".to_string()]));
1871        assert!(
1872            !min.contains(&vec!["user".to_string(), "name".to_string()]),
1873            "user.name must be dropped — covered by user"
1874        );
1875        assert_eq!(min.len(), 2);
1876    }
1877
1878    #[test]
1879    fn project_full_clones_whole_record() {
1880        let r = json!({"a": 1, "b": {"c": 2}});
1881        assert_eq!(project_record(&r, &Projection::Full), r);
1882    }
1883
1884    #[test]
1885    fn project_keeps_only_referenced_paths() {
1886        let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
1887        let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
1888        let got = project_record(&r, &p);
1889        assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
1890        assert!(got.get("blob").is_none());
1891        assert!(got["user"].get("age").is_none());
1892    }
1893
1894    #[test]
1895    fn project_array_index_path_resolves_same_as_original() {
1896        let r = json!({"tags": ["x", "y", "z"]});
1897        let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
1898        let got = project_record(&r, &p);
1899        assert_eq!(got, json!({"tags": {"0": "x"}}));
1900        assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
1901        assert_eq!(
1902            resolve_parent_key(&got, "tags.0"),
1903            resolve_parent_key(&r, "tags.0"),
1904            "reduced tree must resolve the same value as the original"
1905        );
1906    }
1907
1908    #[test]
1909    fn project_numeric_object_key_resolves_same_as_original() {
1910        // A numeric segment can address an OBJECT key in the original (not an
1911        // array index). The reduced all-objects tree stores it under the same
1912        // key, so resolution still matches — the parity property the design
1913        // relies on, distinct from the array-index case above.
1914        let r = json!({"data": {"0": "x", "1": "y"}});
1915        let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
1916        let got = project_record(&r, &p);
1917        assert_eq!(got, json!({"data": {"0": "x"}}));
1918        assert_eq!(
1919            resolve_parent_key(&got, "data.0"),
1920            resolve_parent_key(&r, "data.0"),
1921            "numeric object-key path must resolve identically on the reduced tree"
1922        );
1923    }
1924
1925    #[test]
1926    fn project_missing_path_is_omitted() {
1927        let r = json!({"id": 1});
1928        let p = Projection::Paths(vec![vec!["nope".into()]]);
1929        assert_eq!(project_record(&r, &p), json!({}));
1930    }
1931
1932    #[test]
1933    fn build_projections_unions_parent_key_and_refs() {
1934        use crate::config::ConnectorSpec;
1935        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
1936
1937        fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
1938            ExpandedNode {
1939                id: id.into(),
1940                row_index: 0,
1941                role: NodeRole::Child {
1942                    parent_id: parent.into(),
1943                    parent_key: parent_key.into(),
1944                },
1945                source: ConnectorSpec {
1946                    kind: "csv".into(),
1947                    config: json!({}),
1948                    transforms: None,
1949                    inherit_transforms: true,
1950                },
1951                sink: ConnectorSpec {
1952                    kind: "jsonl".into(),
1953                    config: json!({}),
1954                    transforms: None,
1955                    inherit_transforms: true,
1956                },
1957                transforms: Vec::new(),
1958                state: None,
1959                dlq: None,
1960                delivery: faucet_core::DeliveryMode::AtLeastOnce,
1961                #[cfg(feature = "quality")]
1962                quality: None,
1963                schema: None,
1964                deferred_refs: refs
1965                    .iter()
1966                    .map(|(rid, p)| DeferredRef {
1967                        referenced_id: (*rid).into(),
1968                        dotted_path: (*p).into(),
1969                        token: format!("${{{rid}.{p}}}"),
1970                    })
1971                    .collect(),
1972            }
1973        }
1974
1975        let c1 = child("c1", "p", "id", &[("p", "user.name")]);
1976        let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
1977        let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
1978        let children_of =
1979            HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
1980
1981        let projs = build_projections(&nodes_by_id, &children_of);
1982        let p = projs.get("p").expect("projection for p");
1983        match &**p {
1984            Projection::Paths(paths) => {
1985                assert!(paths.contains(&vec!["id".to_string()]));
1986                assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
1987                assert!(paths.contains(&vec!["email".to_string()]));
1988                assert!(
1989                    !paths.iter().any(|p| p == &vec!["x".to_string()]),
1990                    "a ref to a different parent must not be captured under p"
1991                );
1992            }
1993            Projection::Full => panic!("expected Paths, got Full"),
1994        }
1995    }
1996
1997    #[test]
1998    fn build_projections_whole_record_ref_is_full() {
1999        use crate::config::ConnectorSpec;
2000        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
2001        let c = ExpandedNode {
2002            id: "c".into(),
2003            row_index: 0,
2004            role: NodeRole::Child {
2005                parent_id: "p".into(),
2006                parent_key: "id".into(),
2007            },
2008            source: ConnectorSpec {
2009                kind: "csv".into(),
2010                config: json!({}),
2011                transforms: None,
2012                inherit_transforms: true,
2013            },
2014            sink: ConnectorSpec {
2015                kind: "jsonl".into(),
2016                config: json!({}),
2017                transforms: None,
2018                inherit_transforms: true,
2019            },
2020            transforms: Vec::new(),
2021            state: None,
2022            dlq: None,
2023            delivery: faucet_core::DeliveryMode::AtLeastOnce,
2024            #[cfg(feature = "quality")]
2025            quality: None,
2026            schema: None,
2027            deferred_refs: vec![DeferredRef {
2028                referenced_id: "p".into(),
2029                dotted_path: "".into(),
2030                token: "${p}".into(),
2031            }],
2032        };
2033        let nodes_by_id = HashMap::from([("c".to_string(), c)]);
2034        let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
2035        let projs = build_projections(&nodes_by_id, &children_of);
2036        assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
2037    }
2038
2039    /// Helper: minimal `ExecuteOptions` with all optional knobs cleared.
2040    fn opts(name: &str) -> ExecuteOptions {
2041        ExecuteOptions {
2042            pipeline_name: name.into(),
2043            execution: None,
2044            dry_run: false,
2045            limit: None,
2046            state_path_override: None,
2047            shard: None,
2048            auth: Default::default(),
2049            clock: chrono::Utc::now().fixed_offset(),
2050            cancel: None,
2051            resilience: None,
2052            #[cfg(feature = "lineage")]
2053            lineage: None,
2054            #[cfg(feature = "lineage")]
2055            lineage_cfg: None,
2056        }
2057    }
2058
2059    #[tokio::test]
2060    async fn dry_run_counts_records_without_writing_sink_file() {
2061        // `--dry-run` swaps the real sink for a CountingSink — records flow but
2062        // no output file is produced.
2063        let dir = tempfile::tempdir().unwrap();
2064        let input = dir.path().join("in.csv");
2065        let output = dir.path().join("out.jsonl");
2066        std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
2067        let cfg = cfg_csv_to_jsonl(&input, &output);
2068        let nodes = expand(&cfg).unwrap();
2069        let mut o = opts("dry");
2070        o.dry_run = true;
2071        let summary = run_expanded(nodes, o).await.unwrap();
2072        assert_eq!(summary.invocations.len(), 1);
2073        assert_eq!(summary.invocations[0].records_written, 3);
2074        assert!(!summary.had_failures());
2075        assert!(
2076            !output.exists(),
2077            "dry-run must not create the real sink file"
2078        );
2079    }
2080
2081    #[tokio::test]
2082    async fn limit_caps_records_written_across_the_run() {
2083        // `--limit N` wraps the sink so only the first N records land.
2084        let dir = tempfile::tempdir().unwrap();
2085        let input = dir.path().join("in.csv");
2086        let output = dir.path().join("out.jsonl");
2087        std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
2088        let cfg = cfg_csv_to_jsonl(&input, &output);
2089        let nodes = expand(&cfg).unwrap();
2090        let mut o = opts("lim");
2091        o.limit = Some(2);
2092        let summary = run_expanded(nodes, o).await.unwrap();
2093        assert_eq!(summary.invocations[0].records_written, 2);
2094        let body = std::fs::read_to_string(&output).unwrap();
2095        assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
2096    }
2097
2098    #[tokio::test]
2099    async fn duplicate_state_key_among_siblings_is_rejected() {
2100        // Two parent records whose `parent_key` value collides (both id="dup")
2101        // produce two child units with the SAME state key. With state
2102        // configured, that collision must surface as DuplicateStateKey.
2103        let dir = tempfile::tempdir().unwrap();
2104        let parent_csv = dir.path().join("parents.csv");
2105        let child_csv = dir.path().join("child.csv");
2106        // Both rows share id="dup" — the per-child state-key suffix collides.
2107        std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
2108        std::fs::write(&child_csv, "x\nA\n").unwrap();
2109        let parent_out = dir.path().join("parents.jsonl");
2110        let child_out = dir.path().join("child.jsonl");
2111        let yaml = format!(
2112            r#"version: 1
2113pipeline:
2114  source: {{ type: csv, config: {{ path: {parent} }} }}
2115  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2116  state:  {{ type: memory }}
2117matrix:
2118  - id: parents
2119  - id: child
2120    parent: parents
2121    source: {{ config: {{ path: {child} }} }}
2122    sink:   {{ config: {{ path: {child_out} }} }}
2123"#,
2124            parent = parent_csv.display(),
2125            parent_out = parent_out.display(),
2126            child = child_csv.display(),
2127            child_out = child_out.display(),
2128        );
2129        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2130        let nodes = expand(&cfg).unwrap();
2131        let err = run_expanded(nodes, opts("dupkey"))
2132            .await
2133            .expect_err("colliding sibling state keys must be rejected");
2134        match err {
2135            CliError::DuplicateStateKey { id, state_key } => {
2136                assert_eq!(id, "child");
2137                assert_eq!(state_key, "dupkey::child::dup");
2138            }
2139            other => panic!("expected DuplicateStateKey, got {other:?}"),
2140        }
2141    }
2142
2143    #[tokio::test]
2144    async fn state_path_override_writes_bookmark_file() {
2145        // `--state-path` with a node that has no `state:` block wires a
2146        // FileStateStore at the override path; running it should create the
2147        // bookmark file (REST-less csv source still opts into state via the
2148        // override).
2149        let dir = tempfile::tempdir().unwrap();
2150        let input = dir.path().join("in.csv");
2151        let output = dir.path().join("out.jsonl");
2152        let state_dir = dir.path().join("state");
2153        std::fs::write(&input, "name\nalice\n").unwrap();
2154        let cfg = cfg_csv_to_jsonl(&input, &output);
2155        let nodes = expand(&cfg).unwrap();
2156        let mut o = opts("statepath");
2157        o.state_path_override = Some(state_dir.clone());
2158        let summary = run_expanded(nodes, o).await.unwrap();
2159        assert!(!summary.had_failures());
2160        // The csv source has no natural state key, so the StateKeyOverride wrap
2161        // is skipped — but build_state_for_node still constructs the store.
2162        // The run completing without error exercises the (None, Some(path)) arm.
2163        assert_eq!(summary.invocations[0].records_written, 1);
2164    }
2165
2166    #[tokio::test]
2167    async fn build_dlq_config_maps_spec_fields() {
2168        use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
2169        let dir = tempfile::tempdir().unwrap();
2170        let dlq_out = dir.path().join("dlq.jsonl");
2171        let spec = DlqSpec {
2172            sink: ConnectorSpec {
2173                kind: "jsonl".into(),
2174                config: json!({ "path": dlq_out.to_str().unwrap() }),
2175                transforms: None,
2176                inherit_transforms: true,
2177            },
2178            on_batch_error: OnBatchErrorSpec::DlqAll,
2179            max_failures_per_page: Some(7),
2180            max_failures_total: Some(42),
2181            include_original_payload: false,
2182        };
2183        let cfg = build_dlq_config(&spec).await.unwrap();
2184        assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
2185        assert_eq!(cfg.max_failures_per_page, Some(7));
2186        assert_eq!(cfg.max_failures_total, Some(42));
2187        assert!(!cfg.include_original_payload);
2188    }
2189
2190    #[tokio::test]
2191    async fn build_state_for_node_arms() {
2192        let dir = tempfile::tempdir().unwrap();
2193
2194        // (None, None) → no store.
2195        let node = stub_node(None);
2196        assert!(build_state_for_node(&node, None).await.unwrap().is_none());
2197
2198        // (None, Some(path)) → FileStateStore from override.
2199        let p = dir.path().join("s1");
2200        assert!(
2201            build_state_for_node(&node, Some(&p))
2202                .await
2203                .unwrap()
2204                .is_some()
2205        );
2206
2207        // (Some(memory spec), None) → built from spec.
2208        let node_mem = stub_node(Some(crate::config::StateStoreSpec {
2209            kind: "memory".into(),
2210            config: json!({}),
2211        }));
2212        assert!(
2213            build_state_for_node(&node_mem, None)
2214                .await
2215                .unwrap()
2216                .is_some()
2217        );
2218
2219        // (Some(file spec), Some(path)) → file backend uses the override path.
2220        let node_file = stub_node(Some(crate::config::StateStoreSpec {
2221            kind: "file".into(),
2222            config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
2223        }));
2224        let p2 = dir.path().join("override2");
2225        assert!(
2226            build_state_for_node(&node_file, Some(&p2))
2227                .await
2228                .unwrap()
2229                .is_some()
2230        );
2231
2232        // (Some(memory spec), Some(path)) → non-file backend ignores override,
2233        // still builds from spec.
2234        let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
2235            kind: "memory".into(),
2236            config: json!({}),
2237        }));
2238        let p3 = dir.path().join("override3");
2239        assert!(
2240            build_state_for_node(&node_mem2, Some(&p3))
2241                .await
2242                .unwrap()
2243                .is_some()
2244        );
2245    }
2246
2247    /// Build a minimal root `ExpandedNode` carrying only an (optional) state spec.
2248    fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
2249        use crate::config::ConnectorSpec;
2250        ExpandedNode {
2251            id: "n".into(),
2252            row_index: 0,
2253            role: NodeRole::Root,
2254            source: ConnectorSpec {
2255                kind: "csv".into(),
2256                config: json!({}),
2257                transforms: None,
2258                inherit_transforms: true,
2259            },
2260            sink: ConnectorSpec {
2261                kind: "jsonl".into(),
2262                config: json!({}),
2263                transforms: None,
2264                inherit_transforms: true,
2265            },
2266            transforms: Vec::new(),
2267            state,
2268            dlq: None,
2269            delivery: faucet_core::DeliveryMode::AtLeastOnce,
2270            #[cfg(feature = "quality")]
2271            quality: None,
2272            schema: None,
2273            deferred_refs: Vec::new(),
2274        }
2275    }
2276
2277    #[tokio::test]
2278    async fn state_key_override_delegates_and_overrides_key() {
2279        // StateKeyOverride forwards fetch/bookmark to inner and reports its own key.
2280        let dir = tempfile::tempdir().unwrap();
2281        let input = dir.path().join("in.csv");
2282        std::fs::write(&input, "name\nz\n").unwrap();
2283        let inner = build_source(
2284            "csv",
2285            json!({"path": input.to_str().unwrap()}),
2286            &AuthCatalog::new(),
2287            None,
2288        )
2289        .await
2290        .unwrap();
2291        // The wrapped name must match the inner source's, whatever it reports.
2292        let inner_name = inner.connector_name();
2293        let ov = StateKeyOverride {
2294            inner,
2295            key: "my::custom::key".into(),
2296        };
2297        assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
2298        assert_eq!(ov.connector_name(), inner_name);
2299        let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
2300        assert_eq!(rows.len(), 1);
2301        // apply_start_bookmark delegates without error (csv ignores it).
2302        ov.apply_start_bookmark(json!({"any": "bookmark"}))
2303            .await
2304            .unwrap();
2305    }
2306
2307    #[tokio::test]
2308    async fn orphaned_child_surfaces_executor_deadlock() {
2309        // A child node whose parent id is never present among the nodes can
2310        // never become ready. `expand` would reject this, but a hand-built node
2311        // list exercises the executor's own deadlock guard (lines 227-233).
2312        use crate::config::ConnectorSpec;
2313        let orphan = ExpandedNode {
2314            id: "orphan".into(),
2315            row_index: 0,
2316            role: NodeRole::Child {
2317                parent_id: "missing-parent".into(),
2318                parent_key: "id".into(),
2319            },
2320            source: ConnectorSpec {
2321                kind: "csv".into(),
2322                config: json!({}),
2323                transforms: None,
2324                inherit_transforms: true,
2325            },
2326            sink: ConnectorSpec {
2327                kind: "jsonl".into(),
2328                config: json!({}),
2329                transforms: None,
2330                inherit_transforms: true,
2331            },
2332            transforms: Vec::new(),
2333            state: None,
2334            dlq: None,
2335            delivery: faucet_core::DeliveryMode::AtLeastOnce,
2336            #[cfg(feature = "quality")]
2337            quality: None,
2338            schema: None,
2339            deferred_refs: Vec::new(),
2340        };
2341        let err = run_expanded(vec![orphan], opts("deadlock"))
2342            .await
2343            .expect_err("an orphaned child must surface as an executor deadlock");
2344        match err {
2345            CliError::Internal(msg) => {
2346                assert!(msg.contains("executor deadlock"), "{msg}");
2347                assert!(msg.contains("orphan"), "{msg}");
2348            }
2349            other => panic!("expected Internal deadlock error, got {other:?}"),
2350        }
2351    }
2352
2353    #[test]
2354    fn value_to_string_brief_unquotes_strings_only() {
2355        assert_eq!(value_to_string_brief(&json!("hello")), "hello");
2356        assert_eq!(value_to_string_brief(&json!(42)), "42");
2357        assert_eq!(value_to_string_brief(&json!(true)), "true");
2358        assert_eq!(value_to_string_brief(&json!(null)), "null");
2359        assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
2360    }
2361
2362    #[test]
2363    fn build_state_key_with_and_without_parent() {
2364        assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
2365        assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
2366    }
2367
2368    #[test]
2369    fn resolve_parent_key_walks_objects_arrays_and_misses() {
2370        let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
2371        assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
2372        assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
2373        // Missing key → None.
2374        assert_eq!(resolve_parent_key(&r, "user.age"), None);
2375        // Descending into a scalar → None.
2376        assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
2377        // Non-numeric array index → None.
2378        assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
2379    }
2380
2381    #[tokio::test]
2382    async fn cooperative_cancel_returns_partial_ok() {
2383        // A pre-cancelled token makes the run stop at the first page boundary
2384        // and flush — returning Ok with a partial (possibly empty) result
2385        // rather than erroring. Covers the cancel-threading path.
2386        let dir = tempfile::tempdir().unwrap();
2387        let input = dir.path().join("in.csv");
2388        let output = dir.path().join("out.jsonl");
2389        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2390        let cfg = cfg_csv_to_jsonl(&input, &output);
2391        let nodes = expand(&cfg).unwrap();
2392        let token = CancellationToken::new();
2393        token.cancel(); // already cancelled before the run starts
2394        let mut o = opts("cancel");
2395        o.cancel = Some(token);
2396        let summary = run_expanded(nodes, o).await.unwrap();
2397        // The single root invocation completes (Ok) — it is not reported as a
2398        // failure even though it was cancelled.
2399        assert_eq!(summary.invocations.len(), 1);
2400        assert!(
2401            !summary.had_failures(),
2402            "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
2403        );
2404    }
2405
2406    #[tokio::test]
2407    async fn fanout_projects_away_unreferenced_parent_fields() {
2408        // Parent CSV has id + a big unreferenced "payload" column. The child only
2409        // references ${parents.id} (in its output path), so projection keeps "id"
2410        // and the parent_key but drops "payload" — and fan-out still works.
2411        let dir = tempfile::tempdir().unwrap();
2412        let parent_csv = dir.path().join("parents.csv");
2413        let child_csv = dir.path().join("child.csv");
2414        std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
2415        std::fs::write(&child_csv, "x\nA\n").unwrap();
2416        let parent_out = dir.path().join("parents.jsonl");
2417        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2418
2419        let yaml = format!(
2420            r#"version: 1
2421pipeline:
2422  source: {{ type: csv, config: {{ path: {parent} }} }}
2423  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2424matrix:
2425  - id: parents
2426  - id: child
2427    parent: parents
2428    source: {{ config: {{ path: {child} }} }}
2429    sink:   {{ config: {{ path: "{child_out}" }} }}
2430"#,
2431            parent = parent_csv.display(),
2432            parent_out = parent_out.display(),
2433            child = child_csv.display(),
2434            child_out = child_out_pattern.display(),
2435        );
2436        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2437        let nodes = expand(&cfg).unwrap();
2438        let summary = run_expanded(
2439            nodes,
2440            ExecuteOptions {
2441                pipeline_name: "projtest".into(),
2442                execution: None,
2443                dry_run: false,
2444                limit: None,
2445                state_path_override: None,
2446                shard: None,
2447                auth: Default::default(),
2448                clock: chrono::Utc::now().fixed_offset(),
2449                cancel: None,
2450                resilience: None,
2451                #[cfg(feature = "lineage")]
2452                lineage: None,
2453                #[cfg(feature = "lineage")]
2454                lineage_cfg: None,
2455            },
2456        )
2457        .await
2458        .unwrap();
2459
2460        // 1 parent + 2 child invocations (one per parent record) — cardinality kept.
2461        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2462        assert!(!summary.had_failures(), "{summary:?}");
2463        // ${parents.id} resolved correctly for each child despite "payload" being projected away.
2464        assert!(dir.path().join("child-1.jsonl").exists());
2465        assert!(dir.path().join("child-2.jsonl").exists());
2466    }
2467}