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//! - A node with `depends_on: [row, …]` starts only after every listed row's
15//!   invocations finish successfully — pure completion-ordering, no record
16//!   hand-off. A failed or skipped dependency skips the node (and, in turn,
17//!   its own subtree and dependents).
18//! - `on_error: continue` (default) skips a failed node's subtree but keeps
19//!   running siblings. `on_error: stop` cancels everything after the first
20//!   failure.
21//! - State-key collisions among children of the same parent surface as a
22//!   `CliError::DuplicateStateKey`.
23
24use crate::auth_catalog::AuthCatalog;
25use crate::config::{ExecutionSpec, OnError};
26use crate::error::{CliError, CliResult};
27use crate::expand::{ExpandedNode, NodeRole};
28use crate::interpolate::interpolate_record;
29use crate::registry::{build_sink, build_source};
30use crate::state::build_state_store;
31use async_trait::async_trait;
32use chrono::{DateTime, FixedOffset};
33use faucet_core::observability::Labels;
34use faucet_core::{DlqConfig, FaucetError, OnBatchError, Pipeline, Sink, Source, StateStore};
35use serde_json::Value;
36use std::collections::{HashMap, HashSet};
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::sync::atomic::{AtomicUsize, Ordering};
40use std::time::Duration;
41use tokio::sync::{Mutex, Semaphore};
42
43/// Captured fan-out records, keyed by node id. Records are held as `Arc<Value>`
44/// so the per-level snapshot and per-child-unit hand-off are pointer bumps, not
45/// deep clones of the JSON tree (#160).
46type CapturedRecords = Arc<Mutex<HashMap<String, Vec<Arc<Value>>>>>;
47use tokio_util::sync::CancellationToken;
48
49/// Knobs passed to [`run_expanded`].
50pub struct ExecuteOptions {
51    /// Pipeline name — used in log lines and as the first segment of every
52    /// state key.
53    pub pipeline_name: String,
54    /// Correlation id for the submitted run (#480). `faucet serve` passes the
55    /// id it returned from `POST /v1/runs` so a completion notification can be
56    /// matched back to the submission; every other runtime leaves it `None` and
57    /// each invocation falls back to its own generated id.
58    ///
59    /// Deliberately *not* the observability `run_id`: one submitted run expands
60    /// to one invocation per matrix row, and the span identity stays unique per
61    /// invocation. Notifications carry both — `run_id` (this) to correlate to
62    /// the submission, `invocation_id` to tell rows apart.
63    pub run_id: Option<String>,
64    /// Override for `execution.max_concurrent`. `None` → use the value in
65    /// `ExecutionSpec` or the default (`num_cpus::get().min(4)`, floored at 1).
66    pub execution: Option<ExecutionSpec>,
67    /// `--dry-run` — every sink is replaced with a no-op counter.
68    pub dry_run: bool,
69    /// `--limit N` — wraps every sink to drop records past the cap.
70    pub limit: Option<usize>,
71    /// `--state-path PATH` — overrides the `file` state-store path.
72    pub state_path_override: Option<PathBuf>,
73    /// Clustered Mode B (#230): narrow this run's single source to one shard
74    /// before streaming, and suffix its state key with the shard id so resume is
75    /// per-shard. `None` (the default) runs the whole source unchanged. Only set
76    /// by the serve shard executor; every other caller leaves it `None`.
77    pub shard: Option<faucet_core::ShardSpec>,
78    /// Shared auth providers built from the top-level `auth:` block. Connectors
79    /// that reference one via `auth: { ref }` resolve against this catalog;
80    /// every row sharing a provider gets the same `Arc` (one token, shared).
81    pub auth: AuthCatalog,
82    /// Wall-clock instant for `${now.*}` interpolation in this run's configs.
83    /// `faucet run` sets process-start (or `--clock`); `faucet schedule` sets
84    /// the tick's scheduled time in the schedule timezone.
85    pub clock: DateTime<FixedOffset>,
86    /// Optional external cancellation token. When set and cancelled, in-flight
87    /// invocations stop at their next page boundary and **flush** their sinks
88    /// (so buffered output like a Parquet footer is durable), rather than being
89    /// hard-dropped (#146 H16). `faucet serve` wires this to run-cancel /
90    /// timeout / shutdown; `faucet run` leaves it `None`.
91    pub cancel: Option<CancellationToken>,
92    /// Optional resilience policy (retry/backoff/circuit-breaker/poison),
93    /// attached to every invocation's `RunStreamOptions` and injected into
94    /// rest/xml/graphql sources. Built once from the top-level `resilience:`
95    /// block; `None` preserves today's behaviour.
96    pub resilience: Option<faucet_core::ResiliencePolicy>,
97    /// Optional freshness/volume SLA (#202), evaluated after every **root**
98    /// invocation against history persisted in the node's state store.
99    /// Violations emit metrics + warnings; they never fail the run. `None`
100    /// disables the pass entirely.
101    pub sla: Option<crate::sla::SlaSpec>,
102    /// Shared OpenLineage emitter, built once from the `lineage:` block. `None`
103    /// disables lineage (and adds zero overhead). Gated on the `lineage` feature.
104    #[cfg(feature = "lineage")]
105    pub lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
106    /// The resolved `lineage:` config block (facet/event toggles, sampling, job
107    /// name template). Carried alongside the emitter so `run_one_invocation`
108    /// knows which facets/events to assemble. Gated on the `lineage` feature.
109    #[cfg(feature = "lineage")]
110    pub lineage_cfg: Option<faucet_lineage::LineageConfig>,
111    /// Optional notification/incident-routing notifier (#280), built once from
112    /// the top-level `notifications:` block and shared across invocations. Fires
113    /// run success/failure, SLA breach, circuit-open, contract-abort, and
114    /// DLQ-threshold events after every **root** invocation. `None` disables
115    /// notifications entirely (zero overhead). Gated on the `notify` feature.
116    #[cfg(feature = "notify")]
117    pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
118    /// Optional Data Movement Catalog store (#279), recorded into after every
119    /// successful **root** invocation (dataset identity, schema timeline,
120    /// volume/freshness, lineage edge). `faucet serve` passes its run-history
121    /// backend + the serve run id; the CLI runtimes connect one from the
122    /// `catalog:` block. `None` disables recording entirely (zero overhead).
123    /// Recording never fails the run. Gated on the `catalog` feature.
124    #[cfg(feature = "catalog")]
125    pub catalog: Option<crate::catalog::CatalogHandle>,
126}
127
128/// Grace window granted to in-flight invocations to flush cooperatively after
129/// an `on_error: stop` cancellation, before the remaining tasks are
130/// hard-aborted (the backstop for a sink genuinely stuck mid-write). Bounded so
131/// a hung sink can't wedge the whole run.
132const STOP_FLUSH_GRACE: Duration = Duration::from_secs(5);
133
134/// One pipeline invocation's outcome.
135#[derive(Debug)]
136pub struct InvocationOutcome {
137    pub row_id: String,
138    /// `None` for root invocations; for children, the value at `parent_key` in
139    /// the parent record (rendered to a string).
140    pub parent_record_key: Option<String>,
141    pub records_written: usize,
142    pub error: Option<String>,
143    /// Machine-readable per-invocation stats for `faucet run --output json`
144    /// (#390). `None` for synthetic outcomes (a task panic, or the placeholder
145    /// outcomes the replication / schedule orchestrators build) where no
146    /// pipeline actually ran.
147    pub metrics: Option<InvocationMetrics>,
148}
149
150/// Per-invocation stats surfaced by `faucet run --output json` (#390). Every
151/// field comes from counters the pipeline already maintains — no new
152/// measurement. `records_read` is `None` unless input sampling was active
153/// (a `lineage:` or `catalog:` block installs the source sampler).
154#[derive(Debug, Clone, Default)]
155pub struct InvocationMetrics {
156    pub source_kind: String,
157    pub sink_kind: String,
158    pub duration_ms: u64,
159    pub records_read: Option<u64>,
160    pub dlq_count: u64,
161    pub bookmark: Option<Value>,
162}
163
164/// What [`run_one_invocation`] hands back on success alongside the captured
165/// records — the pipeline-level counters `run_unit` folds into an
166/// [`InvocationMetrics`] (which then adds the connector kinds + wall-clock).
167struct PipelineStats {
168    records_written: usize,
169    records_read: Option<u64>,
170    dlq_count: u64,
171    bookmark: Option<Value>,
172}
173
174/// Aggregate outcome of `run_expanded`.
175#[derive(Debug)]
176pub struct RunSummary {
177    pub invocations: Vec<InvocationOutcome>,
178}
179
180impl RunSummary {
181    pub fn failure_count(&self) -> usize {
182        self.invocations
183            .iter()
184            .filter(|i| i.error.is_some())
185            .count()
186    }
187    pub fn had_failures(&self) -> bool {
188        self.failure_count() > 0
189    }
190}
191
192/// Default number of matrix invocations to run in parallel when neither the
193/// config's `execution.max_concurrent` nor a flag specifies one.
194///
195/// Scales with the core count but is capped at 8. The cap is deliberate: each
196/// invocation is a *full pipeline* with its own connection pools / HTTP
197/// clients, and matrix rows often target the same external system (one API,
198/// one database), so an unbounded fan-out across, say, a 64-core box would
199/// blow through that system's connection or rate limits rather than going
200/// faster. Workloads that genuinely benefit from more parallelism set
201/// `execution.max_concurrent` explicitly to opt out of the cap (#78 LOW).
202fn default_concurrency() -> usize {
203    std::thread::available_parallelism()
204        .map(|n| n.get())
205        .unwrap_or(4)
206        .clamp(1, 8)
207}
208
209/// Execute every node in `nodes`. `nodes` must be in BFS order (roots first
210/// then children) — that's what [`crate::expand::expand`] returns.
211pub async fn run_expanded(nodes: Vec<ExpandedNode>, opts: ExecuteOptions) -> CliResult<RunSummary> {
212    let on_error = opts
213        .execution
214        .as_ref()
215        .map(|e| e.on_error)
216        .unwrap_or_default();
217    let max_concurrent = opts
218        .execution
219        .as_ref()
220        .and_then(|e| e.max_concurrent)
221        .unwrap_or_else(default_concurrency)
222        .max(1);
223    let semaphore = Arc::new(Semaphore::new(max_concurrent));
224
225    // Index nodes by id for parent → children lookups.
226    // parent id → child node ids. Keyed and valued by id (not Vec index) so the
227    // failure cascade can look children up directly instead of indexing into a
228    // HashMap's nondeterministic iteration order (#78/#24).
229    let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
230    for n in nodes.iter() {
231        if let NodeRole::Child { parent_id, .. } = &n.role {
232            children_of
233                .entry(parent_id.clone())
234                .or_default()
235                .push(n.id.clone());
236        }
237    }
238
239    // Captured records per node id. Only populated for nodes that have
240    // children (= are referenced by another node's `parent:`). Records are held
241    // as `Arc<Value>` so the per-level snapshot clone and the per-child-unit
242    // hand-off are pointer bumps, not deep clones of the JSON tree (#160).
243    let captured: CapturedRecords = Arc::new(Mutex::new(HashMap::new()));
244
245    let mut outcomes: Vec<InvocationOutcome> = Vec::new();
246    let mut skipped_subtrees: HashSet<String> = HashSet::new();
247
248    // Root cooperative-cancel token: the caller's (serve wires run-cancel /
249    // timeout / shutdown) or a fresh one. Each level derives a child token so
250    // an `on_error: stop` cancels only that level's invocations, while an
251    // external cancel of the root propagates to every level (#146 H16).
252    let cancel = opts.cancel.clone().unwrap_or_default();
253    let opts = Arc::new(opts);
254
255    // We execute level-by-level. Each level is "every node whose parent is
256    // already done." Roots are level 0. For each level, we spawn one task per
257    // (node, parent-record) pair and await them all before moving on.
258    let mut remaining: HashSet<String> = nodes.iter().map(|n| n.id.clone()).collect();
259    let mut completed: HashSet<String> = HashSet::new();
260    let nodes_by_id: HashMap<String, ExpandedNode> =
261        nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
262
263    // Per-parent projection: what to keep from each captured record so the
264    // fan-out buffer holds only the fields children reference (#160).
265    let projections = build_projections(&nodes_by_id, &children_of);
266
267    // Sort node ids in their original BFS row order so the executor is
268    // deterministic — important for `on_error: stop`, where the first failure
269    // halts the rest of the level.
270    let bfs_order: Vec<String> = {
271        let mut ids: Vec<(usize, String)> = nodes_by_id
272            .values()
273            .map(|n| (n.row_index, n.id.clone()))
274            .collect();
275        ids.sort_by_key(|(i, _)| *i);
276        ids.into_iter().map(|(_, id)| id).collect()
277    };
278
279    while !remaining.is_empty() {
280        // Pick every remaining node whose parent (if any) and every
281        // `depends_on` row are already terminal (completed or skipped), in
282        // deterministic BFS order. Whether a skipped/failed prerequisite
283        // *skips* the node is decided in the unit loop below — readiness only
284        // asks "is there anything left to wait for".
285        let ready: Vec<String> = bfs_order
286            .iter()
287            .filter(|id| remaining.contains(*id))
288            .filter(|id| {
289                let node = &nodes_by_id[*id];
290                let parent_done = match &node.role {
291                    NodeRole::Root => true,
292                    NodeRole::Child { parent_id, .. } => {
293                        completed.contains(parent_id) || skipped_subtrees.contains(parent_id)
294                    }
295                };
296                parent_done
297                    && node
298                        .depends_on
299                        .iter()
300                        .all(|d| completed.contains(d) || skipped_subtrees.contains(d))
301            })
302            .cloned()
303            .collect();
304
305        if ready.is_empty() {
306            // No node is ready but some remain — an expand.rs invariant was
307            // violated (e.g. an orphaned parent reference). Surface it instead
308            // of silently dropping the remaining work and reporting success
309            // (#78/#24).
310            let mut stuck: Vec<String> = remaining.iter().cloned().collect();
311            stuck.sort();
312            return Err(CliError::Internal(format!(
313                "executor deadlock: {} node(s) never became ready (no completed/skipped \
314                 parent or dependency): {}",
315                stuck.len(),
316                stuck.join(", ")
317            )));
318        }
319
320        // Build the work units for this level. Each unit is one invocation —
321        // a root runs once; a child runs once per parent record.
322        let mut units: Vec<Unit> = Vec::new();
323        // Move only the captured records of the parents whose children run this
324        // level out of the shared map. This both narrows the snapshot and frees
325        // each parent's buffer the moment its children consume it: all of a
326        // parent's children become ready in the same level, so its records are
327        // needed exactly once. Units hold their own `Arc<Value>` clones, so
328        // removing the map entry here only drops the map's hold (#160).
329        let level_records: HashMap<String, Vec<Arc<Value>>> = {
330            let consumed_parents: HashSet<&str> = ready
331                .iter()
332                .filter_map(|id| match &nodes_by_id[id].role {
333                    NodeRole::Child { parent_id, .. } => Some(parent_id.as_str()),
334                    NodeRole::Root => None,
335                })
336                .collect();
337            let mut cap = captured.lock().await;
338            consumed_parents
339                .iter()
340                .filter_map(|p| cap.remove(*p).map(|v| (p.to_string(), v)))
341                .collect()
342        };
343        for id in &ready {
344            let node = &nodes_by_id[id];
345            // If a parent failed (and on_error=continue), the subtree is
346            // skipped. Surface a synthetic "skipped" outcome and move on.
347            if let NodeRole::Child { parent_id, .. } = &node.role
348                && skipped_subtrees.contains(parent_id)
349            {
350                skipped_subtrees.insert(id.clone());
351                tracing::warn!(row = %id, parent = %parent_id, "skipping subtree under failed parent");
352                continue;
353            }
354            // Same for a failed/skipped `depends_on` prerequisite: the row
355            // waited for something that never succeeded, so running it would
356            // violate the ordering contract. Mark it skipped so its own
357            // subtree and dependents cascade too.
358            if let Some(dep) = node
359                .depends_on
360                .iter()
361                .find(|d| skipped_subtrees.contains(d.as_str()))
362            {
363                skipped_subtrees.insert(id.clone());
364                tracing::warn!(
365                    row = %id, dependency = %dep,
366                    "skipping row: a depends_on row failed or was skipped"
367                );
368                continue;
369            }
370            match &node.role {
371                NodeRole::Root => {
372                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
373                    let state_key = build_state_key(&opts.pipeline_name, &node.id, None);
374                    validate_unit_state_key(&node.id, uses_state, &state_key)?;
375                    units.push(Unit {
376                        node: node.clone(),
377                        parent_record: None,
378                        state_key,
379                        parent_record_key: None,
380                    });
381                }
382                NodeRole::Child {
383                    parent_id,
384                    parent_key,
385                } => {
386                    let parent_records = level_records.get(parent_id).cloned().unwrap_or_default();
387                    if parent_records.is_empty() {
388                        tracing::info!(
389                            row = %id, parent = %parent_id,
390                            "parent produced no records — child skipped"
391                        );
392                        continue;
393                    }
394                    // Detect state-key collisions among siblings sharing one parent.
395                    let uses_state = node.state.is_some() || opts.state_path_override.is_some();
396                    let mut seen_keys: HashSet<String> = HashSet::new();
397                    for record in &parent_records {
398                        let pk_value = resolve_parent_key(record, parent_key);
399                        let pk_string = pk_value
400                            .as_ref()
401                            .map(value_to_string_brief)
402                            .unwrap_or_else(|| "(missing)".to_string());
403                        let state_key =
404                            build_state_key(&opts.pipeline_name, &node.id, Some(&pk_string));
405                        validate_unit_state_key(&node.id, uses_state, &state_key)?;
406                        if !seen_keys.insert(state_key.clone()) {
407                            return Err(CliError::DuplicateStateKey {
408                                id: node.id.clone(),
409                                state_key,
410                            });
411                        }
412                        units.push(Unit {
413                            node: node.clone(),
414                            parent_record: Some(record.clone()),
415                            state_key,
416                            parent_record_key: Some(pk_string),
417                        });
418                    }
419                }
420            }
421        }
422        drop(level_records);
423
424        let mut had_level_failure = false;
425        let mut nodes_with_any_failure: HashSet<String> = HashSet::new();
426
427        // Unified parallel execution. Tasks run concurrently under the global
428        // semaphore. Under `on_error: stop`, the first failure triggers
429        // `JoinSet::abort_all()` — pending tasks waiting on a permit are
430        // dropped before they do real work, and in-flight tasks are
431        // cancelled at their next `.await` point (potentially leaving
432        // partial sink state — the trade-off users opt into by choosing
433        // `stop`). Under `on_error: continue` every spawned task runs to
434        // completion regardless of sibling failures.
435        // Per-level cancel token: cancelling it (on `on_error: stop`) stops only
436        // this level's invocations cooperatively; it is a child of the root
437        // token, so an external cancel (serve) still propagates here (#146 H16).
438        let level_cancel = cancel.child_token();
439        let mut joinset = tokio::task::JoinSet::new();
440        // Map each spawned task's id back to its row id + parent key so that a
441        // panic (surfaced as a JoinError, which doesn't carry the unit) can be
442        // attributed to the right invocation.
443        let mut task_meta: HashMap<tokio::task::Id, (String, Option<String>)> = HashMap::new();
444        for unit in units {
445            let sem = Arc::clone(&semaphore);
446            let opts2 = Arc::clone(&opts);
447            let captured = Arc::clone(&captured);
448            let capture = projections.get(&unit.node.id).cloned();
449            let meta = (unit.node.id.clone(), unit.parent_record_key.clone());
450            let unit_cancel = level_cancel.clone();
451            let handle = joinset.spawn(async move {
452                let _permit = sem.acquire().await.expect("semaphore not closed");
453                run_unit(&unit, capture, &captured, &opts2, unit_cancel).await
454            });
455            task_meta.insert(handle.id(), meta);
456        }
457
458        let mut stop_triggered = false;
459        let mut aborted = false;
460        let mut stop_deadline: Option<tokio::time::Instant> = None;
461        loop {
462            // Once `on_error: stop` has cancelled the level, give in-flight
463            // invocations a bounded grace to flush cooperatively, then
464            // hard-abort the stragglers (the backstop for a sink stuck
465            // mid-write that can't reach a page boundary to observe the cancel).
466            let joined = match stop_deadline {
467                Some(deadline) if !aborted => {
468                    match tokio::time::timeout_at(deadline, joinset.join_next_with_id()).await {
469                        Ok(j) => j,
470                        Err(_) => {
471                            tracing::warn!(
472                                "on_error: stop — flush grace elapsed; aborting remaining \
473                                 in-flight invocations"
474                            );
475                            joinset.abort_all();
476                            aborted = true;
477                            continue;
478                        }
479                    }
480                }
481                _ => joinset.join_next_with_id().await,
482            };
483            let Some(joined) = joined else { break };
484            // A failure (an `Err` outcome or a panicked task) marks the level
485            // failed and, under `on_error: stop`, stops the rest. A panicking
486            // connector must NOT take down the whole process (#78/#24).
487            let outcome = match joined {
488                Ok((_id, outcome)) => outcome,
489                Err(e) if e.is_cancelled() => {
490                    // Expected after abort_all() — cancelled before/at an await.
491                    // Not counted as a failure or a success.
492                    continue;
493                }
494                Err(e) => {
495                    let (row_id, parent_record_key) = task_meta
496                        .get(&e.id())
497                        .cloned()
498                        .unwrap_or_else(|| ("<unknown>".to_string(), None));
499                    InvocationOutcome {
500                        row_id,
501                        parent_record_key,
502                        records_written: 0,
503                        error: Some(format!("pipeline invocation task panicked: {e}")),
504                        metrics: None,
505                    }
506                }
507            };
508
509            if let Some(err) = &outcome.error {
510                tracing::error!(row = %outcome.row_id, error = %err, "pipeline invocation failed");
511                had_level_failure = true;
512                nodes_with_any_failure.insert(outcome.row_id.clone());
513                if matches!(on_error, OnError::Stop) && !stop_triggered {
514                    stop_triggered = true;
515                    tracing::error!(
516                        "on_error: stop — cancelling in-flight invocations (cooperative \
517                         flush), then aborting any that don't stop within the grace window"
518                    );
519                    // Cooperative first: in-flight pipelines flush at their next
520                    // page boundary so a Parquet footer / S3 upload is completed
521                    // rather than orphaned (#146 H16).
522                    level_cancel.cancel();
523                    stop_deadline = Some(tokio::time::Instant::now() + STOP_FLUSH_GRACE);
524                }
525            } else {
526                tracing::info!(
527                    row = %outcome.row_id,
528                    records_written = outcome.records_written,
529                    "pipeline invocation completed"
530                );
531            }
532            outcomes.push(outcome);
533        }
534
535        // Mark ready nodes done (some may have produced both successes and
536        // failures across their per-parent-record fan-outs — we treat a node
537        // as "failed" overall if any of its invocations failed).
538        for id in ready {
539            remaining.remove(&id);
540            if nodes_with_any_failure.contains(&id) {
541                skipped_subtrees.insert(id.clone());
542                // Cascade to descendants in case we have multi-level chains.
543                if let Some(children) = children_of.get(&id) {
544                    for cid in children {
545                        skipped_subtrees.insert(cid.clone());
546                    }
547                }
548            } else {
549                completed.insert(id);
550            }
551        }
552
553        if had_level_failure && matches!(on_error, OnError::Stop) {
554            tracing::error!("on_error: stop — aborting after first failure");
555            // Any unfinished work surfaces as "skipped"; we just break here.
556            break;
557        }
558    }
559
560    Ok(RunSummary {
561        invocations: outcomes,
562    })
563}
564
565/// One scheduled invocation — a root runs once, a child runs once per parent
566/// record. Built by the level loop, consumed by [`run_unit`].
567struct Unit {
568    node: ExpandedNode,
569    parent_record: Option<Arc<Value>>,
570    state_key: String,
571    parent_record_key: Option<String>,
572}
573
574async fn run_unit(
575    unit: &Unit,
576    capture: Option<Arc<Projection>>,
577    captured: &CapturedRecords,
578    opts: &ExecuteOptions,
579    cancel: CancellationToken,
580) -> InvocationOutcome {
581    let needs_capture = capture.is_some();
582    let started = std::time::Instant::now();
583    let result = run_one_invocation(
584        &unit.node,
585        unit.parent_record.as_deref(),
586        &unit.state_key,
587        capture,
588        opts,
589        cancel,
590    )
591    .await;
592    let duration_ms = started.elapsed().as_millis() as u64;
593    let row_id = unit.node.id.clone();
594    let parent_record_key = unit.parent_record_key.clone();
595    let base_metrics = || InvocationMetrics {
596        source_kind: unit.node.source.kind.clone(),
597        sink_kind: unit.node.sink.kind.clone(),
598        duration_ms,
599        ..Default::default()
600    };
601    match result {
602        Ok((records, stats)) => {
603            if needs_capture {
604                captured
605                    .lock()
606                    .await
607                    .entry(row_id.clone())
608                    .or_default()
609                    // Move each record into an `Arc` once here; downstream
610                    // per-level / per-unit hand-offs then clone only the pointer.
611                    .extend(records.into_iter().map(Arc::new));
612            }
613            InvocationOutcome {
614                row_id,
615                parent_record_key,
616                records_written: stats.records_written,
617                error: None,
618                metrics: Some(InvocationMetrics {
619                    records_read: stats.records_read,
620                    dlq_count: stats.dlq_count,
621                    bookmark: stats.bookmark,
622                    ..base_metrics()
623                }),
624            }
625        }
626        Err(e) => InvocationOutcome {
627            row_id,
628            parent_record_key,
629            records_written: 0,
630            error: Some(e.to_string()),
631            metrics: Some(base_metrics()),
632        },
633    }
634}
635
636/// Produce `{pipeline_name}::{row_id}` or `{pipeline_name}::{row_id}::{key}`.
637pub(crate) fn build_state_key(
638    pipeline_name: &str,
639    row_id: &str,
640    parent_key: Option<&str>,
641) -> String {
642    match parent_key {
643        None => format!("{pipeline_name}::{row_id}"),
644        Some(k) => format!("{pipeline_name}::{row_id}::{k}"),
645    }
646}
647
648/// Reject an invalid state key up front (at unit construction) when the node
649/// will use a state store, so a bad pipeline name or parent-key value surfaces
650/// as a clear [`CliError::InvalidStateKey`] instead of a late mid-run
651/// `FaucetError::State` after connectors are built and the stream has started.
652fn validate_unit_state_key(node_id: &str, uses_state: bool, state_key: &str) -> CliResult<()> {
653    if uses_state {
654        faucet_core::state::validate_state_key(state_key).map_err(|e| {
655            CliError::InvalidStateKey {
656                id: node_id.to_owned(),
657                state_key: state_key.to_owned(),
658                reason: e.to_string(),
659            }
660        })?;
661    }
662    Ok(())
663}
664
665/// Walk the parent record by `parent_key` (a dotted path) and clone the value.
666fn resolve_parent_key(record: &Value, parent_key: &str) -> Option<Value> {
667    let mut cur = record;
668    for segment in parent_key.split('.') {
669        cur = match cur {
670            Value::Object(m) => m.get(segment)?,
671            Value::Array(a) => a.get(segment.parse::<usize>().ok()?)?,
672            _ => return None,
673        };
674    }
675    Some(cur.clone())
676}
677
678/// What to keep from each of a parent's records when capturing for fan-out.
679/// Projecting to only the fields children reference bounds orchestrator memory
680/// at O(referenced-fields × N) instead of O(full-record × N) (#160).
681#[derive(Debug, Clone)]
682enum Projection {
683    /// Keep the whole record — a child referenced `${parent}` (the entire record)
684    /// or used an empty `parent_key`, so nothing can be safely dropped.
685    Full,
686    /// Keep only these pre-split, non-overlapping dotted paths.
687    Paths(Vec<Vec<String>>),
688}
689
690/// Split a dotted path into segments.
691fn split_path(path: &str) -> Vec<String> {
692    path.split('.').map(|s| s.to_string()).collect()
693}
694
695/// Reduce a set of segment-paths to a minimal non-overlapping set: drop any path
696/// that has a (segment-wise prefix) ancestor in the set — `["user"]` covers
697/// `["user","name"]`. Sorting puts ancestors before their descendants.
698fn minimal_paths(mut paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
699    paths.sort();
700    paths.dedup();
701    let mut kept: Vec<Vec<String>> = Vec::new();
702    for p in paths {
703        let covered = kept
704            .iter()
705            .any(|anc| p.len() >= anc.len() && p[..anc.len()] == anc[..]);
706        if !covered {
707            kept.push(p);
708        }
709    }
710    kept
711}
712
713/// Resolve a pre-split dotted path against `record`, dispatching on each value's
714/// type exactly like `resolve_parent_key` / `interpolate::resolve_dotted`.
715fn walk_value(record: &Value, segments: &[String]) -> Option<Value> {
716    let mut cur = record;
717    for seg in segments {
718        cur = match cur {
719            Value::Object(m) => m.get(seg)?,
720            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
721            _ => return None,
722        };
723    }
724    Some(cur.clone())
725}
726
727/// Insert `leaf` at `segments` into `out`, creating intermediate `Value::Object`
728/// nodes keyed by the literal segment string. Callers pass non-overlapping
729/// `segments` (see `minimal_paths`), so a node that must be an object is never
730/// already a leaf.
731fn graft_object(out: &mut Value, segments: &[String], leaf: Value) {
732    if segments.is_empty() {
733        return;
734    }
735    let mut cur = out;
736    for seg in &segments[..segments.len() - 1] {
737        let map = match cur {
738            Value::Object(m) => m,
739            _ => return,
740        };
741        cur = map
742            .entry(seg.clone())
743            .or_insert_with(|| Value::Object(serde_json::Map::new()));
744    }
745    if let Value::Object(m) = cur {
746        m.insert(segments[segments.len() - 1].clone(), leaf);
747    }
748}
749
750/// Project `record` down to `projection`, building an all-objects reduced tree.
751/// Because the readers (`resolve_parent_key`, `interpolate_record`) dispatch on
752/// the reduced value's type, an array-index segment like `0` is stored — and
753/// later read — as the object key `"0"`, so resolution matches the original.
754fn project_record(record: &Value, projection: &Projection) -> Value {
755    match projection {
756        Projection::Full => record.clone(),
757        Projection::Paths(paths) => {
758            let mut out = Value::Object(serde_json::Map::new());
759            for segs in paths {
760                if let Some(v) = walk_value(record, segs) {
761                    graft_object(&mut out, segs, v);
762                }
763            }
764            out
765        }
766    }
767}
768
769/// Compute, per parent id, what to keep from each of its records: the union of
770/// every child's `parent_key` (the state-key path) and every `${parent.path}`
771/// token the children reference (from their pre-collected `deferred_refs`).
772/// Reused by the level loop to project captured records (#160).
773fn build_projections(
774    nodes_by_id: &HashMap<String, ExpandedNode>,
775    children_of: &HashMap<String, Vec<String>>,
776) -> HashMap<String, Arc<Projection>> {
777    let mut out = HashMap::new();
778    for (parent_id, child_ids) in children_of {
779        let mut raw: Vec<Vec<String>> = Vec::new();
780        let mut full = false;
781        for cid in child_ids {
782            let child = &nodes_by_id[cid];
783            if let NodeRole::Child { parent_key, .. } = &child.role {
784                if parent_key.is_empty() {
785                    full = true;
786                } else {
787                    raw.push(split_path(parent_key));
788                }
789            }
790            for dref in &child.deferred_refs {
791                if dref.referenced_id == *parent_id {
792                    if dref.dotted_path.is_empty() {
793                        full = true; // `${parent}` — whole record
794                    } else {
795                        raw.push(split_path(&dref.dotted_path));
796                    }
797                }
798            }
799        }
800        // Defensive: `raw` is empty only when every child had an empty
801        // parent_key, which already set `full`. The `|| raw.is_empty()` keeps us
802        // on `Full` even if that ever changes, so we never project to an empty
803        // tree that would drop the state-key path.
804        let projection = if full || raw.is_empty() {
805            Projection::Full
806        } else {
807            Projection::Paths(minimal_paths(raw))
808        };
809        out.insert(parent_id.clone(), Arc::new(projection));
810    }
811    out
812}
813
814/// Run one pipeline invocation. Returns (captured records, records_written).
815/// Assemble the runtime [`Pipeline`] for one invocation from a node's specs.
816///
817/// This is the `with_*` builder chain — state store, DLQ, cancellation, quality,
818/// contract, masking, schema-drift, adaptive batching, resilience, and delivery
819/// mode — lifted out of [`run_one_invocation`] so the wiring is testable in
820/// isolation and a preview-mode / feature-gate mistake (cf. #321 H1) is harder
821/// to make (#324 C). Behaviour is identical to the previous inline chain; the
822/// only I/O it performs is building the DLQ sink. `source`/`sink` are borrowed
823/// for the returned pipeline's lifetime; `state` is moved in.
824#[allow(clippy::too_many_arguments)]
825async fn build_pipeline<'a>(
826    source: &'a dyn Source,
827    sink: &'a dyn Sink,
828    node: &ExpandedNode,
829    opts: &ExecuteOptions,
830    state: Option<Arc<dyn StateStore>>,
831    cancel: &CancellationToken,
832    pipeline_name: &str,
833    row_id: &str,
834    run_id: &str,
835    cleanup_scope: Option<Value>,
836) -> CliResult<Pipeline<'a, dyn Source + 'a, dyn Sink + 'a>> {
837    let mut pipeline = Pipeline::new(source, sink)
838        .with_name(pipeline_name.to_owned())
839        .with_row(row_id.to_owned())
840        .with_run_id(run_id.to_owned());
841    if let Some(store) = state {
842        pipeline = pipeline.with_state_store(store);
843    }
844    if let Some(ref dlq_spec) = node.dlq {
845        let dlq_cfg = build_dlq_config(dlq_spec).await?;
846        pipeline = pipeline.with_dlq(dlq_cfg);
847    }
848    // Cooperative cancellation: a cancelled token makes the streaming loop stop
849    // at the next page boundary and flush the sink (#146 H16). The pipeline takes
850    // a clone so the caller's lineage terminal-event classification and SLA pass
851    // can still read `cancel.is_cancelled()` (cheap — the token is an `Arc`).
852    pipeline = pipeline.with_cancel(cancel.clone());
853    // Pipeline-level quality checks (v1: no matrix-row override). `expand` already
854    // validated this spec, but compile again here to obtain the runtime
855    // `CompiledQuality`; map any error to a config-level failure.
856    #[cfg(feature = "quality")]
857    if let Some(ref quality_spec) = node.quality {
858        let compiled = Arc::new(
859            faucet_core::CompiledQuality::compile(quality_spec)
860                .map_err(|e| CliError::Config(format!("quality: {e}")))?,
861        );
862        pipeline = pipeline.with_quality(compiled);
863    }
864    // Pipeline-level data contract (v1: no matrix-row override). `expand` already
865    // validated the spec; compile again here to obtain the runtime
866    // `CompiledContract`.
867    #[cfg(feature = "contract")]
868    if let Some(ref contract_spec) = node.contract {
869        let compiled = Arc::new(
870            faucet_core::CompiledContract::compile(contract_spec)
871                .map_err(|e| CliError::Config(format!("contract: {e}")))?,
872        );
873        pipeline = pipeline.with_contract(compiled);
874    }
875    // Pipeline-level PII masking (v1: no matrix-row override). Compile scoped to
876    // this node's destination sink — by template name (`sink_ref`) and by
877    // connector kind — so `applies_to` per-destination rules resolve. When no
878    // rule applies to this sink the compiled policy is empty and the pass is
879    // skipped entirely.
880    #[cfg(feature = "masking")]
881    if let Some(ref masking_spec) = node.masking {
882        let sink_ids = [node.sink_ref.as_str(), node.sink.kind.as_str()];
883        let compiled = faucet_core::CompiledMasking::compile_for_sink(masking_spec, &sink_ids)
884            .map_err(|e| CliError::Config(format!("masking: {e}")))?;
885        if !compiled.is_empty() {
886            pipeline = pipeline.with_masking(Arc::new(compiled));
887        }
888    }
889    // Schema-drift policy (pipeline-level in v1; same for every invocation).
890    if let Some(ref sd) = node.schema {
891        pipeline = pipeline.with_schema_drift(faucet_core::SchemaDriftPolicy::compile(sd));
892    }
893    // ── Scoped cleanup (#478) ────────────────────────────────────────────────
894    // Attaching the policy is what authorises a delete, so the coarse gating
895    // lives here — `run_stream` only adds "the run finished uncancelled".
896    //
897    //  - Roots only: a child fans out per parent record, and each invocation
898    //    claims its own scope, which is exactly right — but a *non-root* here
899    //    means a child, which is still a root-of-its-own-scope, so the real
900    //    exclusions are the synthetic runs below.
901    //  - `--dry-run` replaces the sink with a counter and `--limit` wraps it to
902    //    drop records: under either, "what this run wrote" is a fiction, so a
903    //    delete computed from it would remove live rows.
904    //  - A shard reads a fraction of the source, so its written-key set is
905    //    incomplete by construction — deleting the difference would delete the
906    //    other shards' rows.
907    if let Some(scope) = cleanup_scope {
908        let synthetic = opts.dry_run || opts.limit.is_some() || opts.shard.is_some();
909        if synthetic {
910            tracing::warn!(
911                row = %row_id,
912                "scoped cleanup skipped: --dry-run / --limit / shard runs do not write the \
913                 authoritative record set for the scope, so a delete would remove live rows"
914            );
915        } else {
916            let map: std::collections::BTreeMap<String, Value> = scope
917                .as_object()
918                .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
919                .unwrap_or_default();
920            let key: Vec<String> = node
921                .sink
922                .config
923                .get("key")
924                .and_then(|v| v.as_array())
925                .map(|a| {
926                    a.iter()
927                        .filter_map(|v| v.as_str().map(str::to_owned))
928                        .collect()
929                })
930                .unwrap_or_default();
931            let policy = faucet_core::CleanupPolicy::new(map, key, faucet_core::DEFAULT_MAX_KEYS)
932                .map_err(|e| CliError::Config(format!("cleanup: {e}")))?;
933            pipeline = pipeline.with_cleanup(Arc::new(policy));
934        }
935    }
936    // Execution-level adaptive batch-size controller (shared by all rows).
937    if let Some(ab) = opts
938        .execution
939        .as_ref()
940        .and_then(|e| e.adaptive_batch_size.clone())
941    {
942        ab.validate()
943            .map_err(|e| CliError::Config(format!("adaptive_batch_size: {e}")))?;
944        pipeline = pipeline.with_adaptive(ab);
945    }
946    // Resilience policy (retry/backoff/circuit-breaker/poison). Top-level in v1,
947    // so the same policy is attached to every invocation.
948    if let Some(policy) = opts.resilience.clone() {
949        pipeline = pipeline.with_resilience(policy);
950    }
951    // Delivery guarantee (exactly-once resume/skip when the node opted in; the
952    // expand gate already verified source/sink/state support). Preview modes
953    // (`--dry-run`, `--limit`) swap in counting/truncating sinks that cannot
954    // uphold an atomic watermark — and a token committed for a truncated page
955    // would corrupt it — so they always run at-least-once.
956    let effective_delivery = if opts.dry_run || opts.limit.is_some() {
957        faucet_core::idempotency::DeliveryMode::AtLeastOnce
958    } else {
959        node.delivery
960    };
961    pipeline = pipeline.with_delivery(effective_delivery);
962    Ok(pipeline)
963}
964
965async fn run_one_invocation(
966    node: &ExpandedNode,
967    parent_record: Option<&Value>,
968    state_key: &str,
969    capture: Option<Arc<Projection>>,
970    opts: &ExecuteOptions,
971    cancel: CancellationToken,
972) -> CliResult<(Vec<Value>, PipelineStats)> {
973    // Observability identity for this invocation — built once, reused by both
974    // the Pipeline builder and the transform instrumentation.
975    let run_id = uuid::Uuid::now_v7().to_string();
976    // Notification run identity + timing (#480). `run_id` here correlates to the
977    // *submission* (serve passes the id it returned from `POST /v1/runs`);
978    // `invocation_id` is this row's own id, so a matrix run's notifications are
979    // still individually identifiable. Monotonic `Instant` for the duration —
980    // subtracting the wall-clock stamps would go negative across an NTP step.
981    #[cfg(feature = "notify")]
982    let invocation_started = std::time::Instant::now();
983    #[cfg(feature = "notify")]
984    let notify_run = crate::notify::RunContext::start(
985        Some(opts.run_id.clone().unwrap_or_else(|| run_id.clone())),
986        Some(run_id.clone()),
987    );
988    let pipeline_name = opts.pipeline_name.clone();
989    let row_id = node.id.clone();
990    #[cfg(feature = "lineage")]
991    let lineage = opts.lineage.clone();
992    #[cfg(feature = "lineage")]
993    let lineage_cfg = opts.lineage_cfg.clone();
994    let obs_labels = Labels::new(pipeline_name.clone(), row_id.clone(), run_id.clone());
995    // Whether this invocation records into the Data Movement Catalog (#279):
996    // roots only, and never for dry-run / --limit / shard runs (their volumes
997    // and datasets are partial or synthetic) — the same scoping as SLA.
998    #[cfg(feature = "catalog")]
999    let catalog_active = opts.catalog.is_some()
1000        && matches!(node.role, NodeRole::Root)
1001        && !opts.dry_run
1002        && opts.limit.is_none()
1003        && opts.shard.is_none();
1004    // 1) Resolve `${parent.path}` in the per-row source + sink configs.
1005    let mut source_cfg = node.source.config.clone();
1006    let mut sink_cfg = node.sink.config.clone();
1007
1008    // Resolve `${now.*}` run-clock tokens for every invocation (root + child),
1009    // before the parent-record pass. Leaves all other tokens verbatim.
1010    resolve_now_inplace(&mut source_cfg, opts.clock)?;
1011    resolve_now_inplace(&mut sink_cfg, opts.clock)?;
1012    // `${backfill.*}` tokens are substituted by the `faucet backfill`
1013    // orchestrator before nodes reach the executor. One still present here
1014    // means a window-scoped config is being driven by `run`/`schedule`/
1015    // `serve` — fail loudly instead of handing the literal token to the
1016    // connector (#282).
1017    reject_unresolved_backfill_tokens(&source_cfg, "source")?;
1018    reject_unresolved_backfill_tokens(&sink_cfg, "sink")?;
1019
1020    // Scoped-cleanup claim (#478). Resolved through the *same* token passes as
1021    // the connector configs — the scope is typically `${parent.id}` on a child
1022    // row, so it has to see the parent record exactly like the source URL does.
1023    let mut cleanup_scope: Option<Value> = node
1024        .cleanup_scope
1025        .as_ref()
1026        .map(|m| Value::Object(m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()));
1027    if let Some(scope) = cleanup_scope.as_mut() {
1028        resolve_now_inplace(scope, opts.clock)?;
1029        reject_unresolved_backfill_tokens(scope, "complete_for")?;
1030    }
1031
1032    if let (Some(record), NodeRole::Child { parent_id, .. }) = (parent_record, &node.role) {
1033        let ctx: HashMap<String, Value> = HashMap::from([(parent_id.clone(), record.clone())]);
1034        resolve_inplace(&mut source_cfg, &ctx)?;
1035        resolve_inplace(&mut sink_cfg, &ctx)?;
1036        if let Some(scope) = cleanup_scope.as_mut() {
1037            resolve_inplace(scope, &ctx)?;
1038        }
1039    }
1040
1041    // 2) Build source + sink. `faucet dlq replay` injects a pre-built source
1042    //    (an envelope-unwrapping DLQ reader) via `source_override`; every
1043    //    config-driven node builds from the connector registry. The override
1044    //    is taken once — replay runs a single invocation.
1045    let source = match node.source_override.as_ref().and_then(|o| o.take()) {
1046        Some(prebuilt) => prebuilt,
1047        None => {
1048            build_source(
1049                &node.source.kind,
1050                source_cfg,
1051                &opts.auth,
1052                opts.resilience.as_ref().map(|r| &r.retry),
1053            )
1054            .await?
1055        }
1056    };
1057
1058    // Catalog identity (#279): read the dataset URIs off the *raw* connectors,
1059    // before any wrapper is layered on.
1060    #[cfg(feature = "catalog")]
1061    let source_dataset_uri = source.dataset_uri();
1062
1063    // Clustered Mode B: narrow the raw source to its assigned shard BEFORE any
1064    // wrapping (the TransformingSource / StateKeyOverride wrappers do not forward
1065    // apply_shard, so it must reach the concrete connector).
1066    if let Some(shard) = &opts.shard {
1067        source
1068            .apply_shard(shard)
1069            .await
1070            .map_err(|e| CliError::Internal(format!("applying shard {:?}: {e}", shard.id)))?;
1071    }
1072    let raw_sink: Box<dyn Sink> = if opts.dry_run {
1073        Box::new(CountingSink::new())
1074    } else {
1075        build_sink(&node.sink.kind, sink_cfg, &opts.auth).await?
1076    };
1077    #[cfg(feature = "catalog")]
1078    let sink_dataset_uri = raw_sink.dataset_uri();
1079    let raw_sink: Box<dyn Sink> = match opts.limit {
1080        Some(n) => Box::new(LimitedSink::wrap(raw_sink, n)),
1081        None => raw_sink,
1082    };
1083    let captured = Arc::new(Mutex::new(Vec::<Value>::new()));
1084    let sink: Box<dyn Sink> = match &capture {
1085        Some(projection) => Box::new(CapturingSink::wrap(
1086            raw_sink,
1087            Arc::clone(&captured),
1088            Arc::clone(projection),
1089        )),
1090        None => raw_sink,
1091    };
1092
1093    // ── Lineage: sampling wrappers (only for requested facets) ───────────────
1094    // `in_sample` taps the source's pre-transform records (input schema /
1095    // column lineage); `out_sample` taps the sink's written records (output
1096    // schema / RUNNING-heartbeat throughput counter). Both stay `None` when no
1097    // facet/event needs them, so lineage adds zero per-record overhead.
1098    #[cfg(feature = "lineage")]
1099    let (in_sample, out_sample) = {
1100        use std::sync::Arc as StdArc;
1101        let mut want = false;
1102        let mut cap = 0usize;
1103        if let (Some(_), Some(lc)) = (&lineage, &lineage_cfg) {
1104            let want_schema = lc.include_schema_facet || lc.include_column_lineage;
1105            if want_schema {
1106                cap = cap.max(lc.sample_records);
1107            }
1108            want = want_schema || lc.emit_on.running;
1109        }
1110        // The catalog (#279) needs input/output samples for schema inference
1111        // and record counts regardless of any `lineage:` block; take the max
1112        // of both caps when both are active.
1113        #[cfg(feature = "catalog")]
1114        if catalog_active {
1115            want = true;
1116            cap = cap.max(
1117                opts.catalog
1118                    .as_ref()
1119                    .map(|h| h.sample_records)
1120                    .unwrap_or(crate::catalog::DEFAULT_SAMPLE_RECORDS),
1121            );
1122        }
1123        if want {
1124            (
1125                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1126                Some(StdArc::new(faucet_lineage::SampleState::new(cap))),
1127            )
1128        } else {
1129            (None, None)
1130        }
1131    };
1132
1133    // Wrap the raw source so it samples PRE-transform input records — this must
1134    // sit between `build_source` and `TransformingSource`.
1135    #[cfg(feature = "lineage")]
1136    let source: Box<dyn Source> = match &in_sample {
1137        Some(state) => Box::new(faucet_lineage::SamplingSource::new(
1138            source,
1139            std::sync::Arc::clone(state),
1140        )),
1141        None => source,
1142    };
1143
1144    // 3) Compile transforms. Resolve `${now.*}` run-clock tokens in each
1145    //    transform's config first — exactly as for source/sink above — so e.g. a
1146    //    `set` transform stamping `${now.date}` writes the real date instead of
1147    //    the literal token string. Without this the token leaks into every record.
1148    let mut transforms = node.transforms.clone();
1149    for t in &mut transforms {
1150        resolve_now_inplace(&mut t.config, opts.clock)?;
1151    }
1152    // With `arrow`, compile the columnar batch forms too so an all-columnar
1153    // chain (e.g. `parquet → sql → parquet`) runs on the Arrow fast path; a
1154    // `Value`-only stage in the chain leaves `batch_fns` with a `None` entry,
1155    // which keeps the whole pipeline on the `Value` path (#375).
1156    #[cfg(feature = "arrow")]
1157    let source: Box<dyn Source> = {
1158        let (stages, batch_fns) = crate::transforms::compile_transforms_columnar(&transforms)?;
1159        if stages.is_empty() {
1160            source
1161        } else {
1162            Box::new(faucet_core::TransformingSource::new_with_batches(
1163                source,
1164                stages,
1165                batch_fns,
1166                obs_labels.clone(),
1167            )?)
1168        }
1169    };
1170    #[cfg(not(feature = "arrow"))]
1171    let source: Box<dyn Source> = {
1172        let stages = crate::transforms::compile_transforms(&transforms)?;
1173        if stages.is_empty() {
1174            source
1175        } else {
1176            Box::new(faucet_core::TransformingSource::new(
1177                source,
1178                stages,
1179                obs_labels.clone(),
1180            )?)
1181        }
1182    };
1183
1184    // 4) Build state store. If the source opts into state, wrap it so the
1185    //    executor's per-row state key is used instead of the source's natural
1186    //    one (which is shared across all matrix rows of the same kind).
1187    let state = build_state_for_node(node, opts.state_path_override.as_deref()).await?;
1188    // Preview modes must not persist bookmarks: the counting/truncating sinks
1189    // return `Ok` without a real write, so a persisted (advanced) bookmark would
1190    // make the next real run skip unwritten records (#321 H1). Wrap the store so
1191    // reads still resume faithfully but writes are dropped.
1192    let state: Option<Arc<dyn StateStore>> = match state {
1193        Some(inner) if opts.dry_run || opts.limit.is_some() => {
1194            Some(Arc::new(ReadOnlyStateStore { inner }))
1195        }
1196        other => other,
1197    };
1198    // Keep a handle for the post-run SLA pass (#202) — `state` itself is moved
1199    // into the pipeline below.
1200    let sla_store = state.clone();
1201    // Per-shard bookmark: suffix the state key with the shard id so a reassigned
1202    // shard resumes where its dead owner left off, independent of sibling shards.
1203    let effective_state_key = match &opts.shard {
1204        Some(shard) => format!("{state_key}::{}", shard.id),
1205        None => state_key.to_owned(),
1206    };
1207    let source: Box<dyn Source> = if state.is_some() && source.state_key().is_some() {
1208        Box::new(StateKeyOverride {
1209            inner: source,
1210            key: effective_state_key,
1211        })
1212    } else {
1213        source
1214    };
1215
1216    // Wrap the sink so it samples written records — outermost, immediately
1217    // before the pipeline is constructed (after capture/limit wrappers).
1218    #[cfg(feature = "lineage")]
1219    let sink: Box<dyn Sink> = match &out_sample {
1220        Some(state) => Box::new(faucet_lineage::SamplingSink::new(
1221            sink,
1222            std::sync::Arc::clone(state),
1223        )),
1224        None => sink,
1225    };
1226
1227    // 5) Assemble the runtime pipeline. The whole `with_*` builder chain (state,
1228    //    DLQ, cancellation, quality, contract, masking, schema-drift, adaptive
1229    //    batching, resilience, delivery) lives in `build_pipeline` so the wiring
1230    //    is testable in isolation and a preview-mode / feature-gate mistake
1231    //    (cf. #321 H1) is harder to make (#324 C). The identity strings are
1232    //    borrowed — the lineage START/terminal lifecycle below still needs
1233    //    `pipeline_name` / `row_id` / `run_id`.
1234    let pipeline = build_pipeline(
1235        source.as_ref(),
1236        sink.as_ref(),
1237        node,
1238        opts,
1239        state,
1240        &cancel,
1241        &pipeline_name,
1242        &row_id,
1243        &run_id,
1244        cleanup_scope,
1245    )
1246    .await?;
1247    // ── Lineage: START + heartbeat + terminal ────────────────────────────────
1248    #[cfg(feature = "lineage")]
1249    let lineage_ctx = match (&lineage, &lineage_cfg) {
1250        (Some(em), Some(lc)) => {
1251            let job_name =
1252                crate::interpolate::resolve_lineage_job_name(&lc.job_name, &pipeline_name, &row_id);
1253            let mut ctx = faucet_lineage::RunLifecycle {
1254                job_namespace: lc.namespace.clone(),
1255                job_name,
1256                run_id: run_id.clone(),
1257                parent: lc.parent_job.clone(),
1258                inputs: vec![faucet_lineage::DatasetRef {
1259                    namespace: lc.namespace.clone(),
1260                    name: source.dataset_uri(),
1261                }],
1262                output: faucet_lineage::DatasetRef {
1263                    namespace: lc.namespace.clone(),
1264                    name: sink.dataset_uri(),
1265                },
1266                started_at: chrono::Utc::now(),
1267                finished_at: None,
1268                records: 0,
1269                error: None,
1270                input_schemas: Vec::new(),
1271                output_schema: None,
1272                column_lineage: None,
1273                source_code: None,
1274            };
1275            em.emit(faucet_lineage::EventType::Start, &ctx).await;
1276            // Heartbeat task — periodic RUNNING events with the live throughput
1277            // count read off the output sampler.
1278            let hb_handle = if lc.emit_on.running {
1279                let em2 = std::sync::Arc::clone(em);
1280                let interval = lc.heartbeat_interval;
1281                let mut beat_ctx = ctx.clone();
1282                let counter = out_sample.clone();
1283                Some(tokio::spawn(async move {
1284                    let mut tick = tokio::time::interval(interval);
1285                    tick.tick().await; // skip the immediate first tick
1286                    loop {
1287                        tick.tick().await;
1288                        if let Some(c) = &counter {
1289                            beat_ctx.records = c.count();
1290                        }
1291                        em2.emit(faucet_lineage::EventType::Running, &beat_ctx)
1292                            .await;
1293                    }
1294                }))
1295            } else {
1296                None
1297            };
1298            ctx.source_code = if lc.include_source_code_facet {
1299                Some(serde_json::to_string(&node.source.config).unwrap_or_default())
1300            } else {
1301                None
1302            };
1303            Some((std::sync::Arc::clone(em), ctx, hb_handle))
1304        }
1305        _ => None,
1306    };
1307
1308    // Combine run + final flush into one outcome BEFORE emitting the terminal
1309    // lineage event, preserving the original semantics (run error → skip flush;
1310    // run ok but flush error → overall error). The terminal event is classified
1311    // from this combined `result`, then `?`-propagated below — restoring the
1312    // original early-return behaviour while still firing the terminal event on
1313    // both success and error.
1314    let result: Result<faucet_core::PipelineResult, FaucetError> = match pipeline.run().await {
1315        Ok(r) => sink.flush().await.map(|_| r),
1316        Err(e) => Err(e),
1317    };
1318
1319    #[cfg(feature = "lineage")]
1320    if let Some((em, mut ctx, hb)) = lineage_ctx {
1321        if let Some(h) = hb {
1322            h.abort();
1323        }
1324        ctx.finished_at = Some(chrono::Utc::now());
1325        if let Some(state) = &out_sample {
1326            ctx.records = state.count();
1327            if lineage_cfg
1328                .as_ref()
1329                .map(|l| l.include_schema_facet)
1330                .unwrap_or(false)
1331            {
1332                ctx.output_schema = Some(state.inferred_schema());
1333            }
1334        }
1335        if let Some(state) = &in_sample
1336            && lineage_cfg
1337                .as_ref()
1338                .map(|l| l.include_schema_facet || l.include_column_lineage)
1339                .unwrap_or(false)
1340        {
1341            let in_schema = state.inferred_schema();
1342            if lineage_cfg
1343                .as_ref()
1344                .map(|l| l.include_column_lineage)
1345                .unwrap_or(false)
1346            {
1347                let input_fields: Vec<String> =
1348                    in_schema.fields.iter().map(|(n, _)| n.clone()).collect();
1349                #[cfg(feature = "masking")]
1350                let has_masking = node.masking.is_some();
1351                #[cfg(not(feature = "masking"))]
1352                let has_masking = false;
1353                let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1354                ctx.column_lineage = faucet_lineage::derive_column_lineage(&input_fields, &ops);
1355            }
1356            if lineage_cfg
1357                .as_ref()
1358                .map(|l| l.include_schema_facet)
1359                .unwrap_or(false)
1360            {
1361                ctx.input_schemas = vec![Some(in_schema)];
1362            }
1363        }
1364        let ev = match &result {
1365            Err(e) => {
1366                ctx.error = Some(e.to_string());
1367                faucet_lineage::EventType::Fail
1368            }
1369            Ok(_) if cancel.is_cancelled() => faucet_lineage::EventType::Abort,
1370            Ok(_) => faucet_lineage::EventType::Complete,
1371        };
1372        em.emit(ev, &ctx).await;
1373    }
1374
1375    // ── SLA post-run evaluation (#202) ───────────────────────────────────────
1376    // Roots only: children fan out per parent record, so their volumes are not
1377    // a stable series to baseline (same scoping as `faucet doctor`). Skipped
1378    // for dry-run / --limit (synthetic volumes would poison the baseline), for
1379    // shard executions (a shard's volume is a fraction of the row's, and shard
1380    // counts change run to run), and for cancelled runs (a partial volume is
1381    // not a signal). Monitoring never fails the run — see `evaluate_post_run`.
1382    // Roots only, and never for dry-run / --limit / shard / cancelled runs —
1383    // the same scoping the notification pass below reuses.
1384    let is_notifiable_root = matches!(node.role, NodeRole::Root)
1385        && !opts.dry_run
1386        && opts.limit.is_none()
1387        && opts.shard.is_none()
1388        && !cancel.is_cancelled();
1389
1390    #[cfg_attr(not(feature = "notify"), allow(unused_variables))]
1391    let sla_violations = if let Some(spec) = &opts.sla
1392        && is_notifiable_root
1393    {
1394        let outcome = match &result {
1395            Ok(r) => crate::sla::RunOutcome::Success {
1396                rows: r.records_written as u64,
1397            },
1398            Err(_) => crate::sla::RunOutcome::Failure,
1399        };
1400        crate::sla::evaluate_post_run(
1401            spec,
1402            sla_store.as_ref(),
1403            state_key,
1404            &obs_labels.pipeline,
1405            &obs_labels.row,
1406            outcome,
1407            chrono::Utc::now().timestamp(),
1408        )
1409        .await
1410    } else {
1411        Vec::new()
1412    };
1413
1414    // ── Notifications (#280) ─────────────────────────────────────────────────
1415    // Fan run success/failure, SLA breach, circuit-open, contract-abort, and
1416    // DLQ-threshold out to the configured channels. Same root/real-run scoping
1417    // as SLA; delivery is fire-and-forget and never fails the run.
1418    #[cfg(feature = "notify")]
1419    if let Some(notifier) = &opts.notifier
1420        && is_notifiable_root
1421    {
1422        use crate::notify::NotifyEvent;
1423        let pipeline = obs_labels.pipeline.to_string();
1424        let row = obs_labels.row.to_string();
1425        // Closed once here, so every event from this invocation reports the same
1426        // terminal timestamp and duration.
1427        let run_ctx = notify_run.clone().finish(invocation_started);
1428        match &result {
1429            Ok(r) => {
1430                notifier
1431                    .emit(
1432                        NotifyEvent::run_success(
1433                            pipeline.clone(),
1434                            row.clone(),
1435                            r.records_written as u64,
1436                        )
1437                        .with_run(run_ctx.clone()),
1438                    )
1439                    .await;
1440                if let Some(dlq) = &r.dlq
1441                    && dlq.records_dlq > 0
1442                {
1443                    notifier
1444                        .emit(
1445                            NotifyEvent::dlq_threshold(
1446                                pipeline.clone(),
1447                                row.clone(),
1448                                dlq.records_dlq as u64,
1449                            )
1450                            .with_run(run_ctx.clone()),
1451                        )
1452                        .await;
1453                }
1454            }
1455            Err(e) => {
1456                notifier
1457                    .emit(error_event(&pipeline, &row, e).with_run(run_ctx.clone()))
1458                    .await;
1459            }
1460        }
1461        for v in &sla_violations {
1462            notifier
1463                .emit(
1464                    NotifyEvent::sla_breach(pipeline.clone(), row.clone(), v.kind(), v.to_string())
1465                        .with_run(run_ctx.clone()),
1466                )
1467                .await;
1468        }
1469    }
1470
1471    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
1472    // Fold this run's dataset observations + lineage edge into the catalog.
1473    // Successful, complete root runs only (a cancelled run's partial volume is
1474    // not a signal). Recording never fails the run — see `catalog::record`.
1475    #[cfg(feature = "catalog")]
1476    if let Some(handle) = &opts.catalog
1477        && catalog_active
1478        && !cancel.is_cancelled()
1479        && let Ok(pipeline_result) = &result
1480    {
1481        use crate::catalog::model::{canonicalize_uri, schema_from_samples};
1482        use crate::serve::history::catalog::{CatalogUpdate, DatasetObservation, DatasetRole};
1483
1484        let records_written = pipeline_result.records_written as u64;
1485        let source_schema = in_sample
1486            .as_ref()
1487            .and_then(|s| schema_from_samples(&s.samples()));
1488        let sink_schema = out_sample
1489            .as_ref()
1490            .and_then(|s| schema_from_samples(&s.samples()));
1491        // The samplers are always installed while the catalog is active, so the
1492        // unwrap_or arms are defensive only.
1493        let records_read = in_sample
1494            .as_ref()
1495            .map(|s| s.count())
1496            .unwrap_or(records_written);
1497        let records_out = out_sample
1498            .as_ref()
1499            .map(|s| s.count())
1500            .unwrap_or(records_written);
1501
1502        // Column lineage for the edge — the same derivation `faucet-lineage`
1503        // emits, so the catalog's edges match the OpenLineage output.
1504        let column_lineage = in_sample.as_ref().and_then(|s| {
1505            let input_fields: Vec<String> = s
1506                .inferred_schema()
1507                .fields
1508                .iter()
1509                .map(|(n, _)| n.clone())
1510                .collect();
1511            #[cfg(feature = "masking")]
1512            let has_masking = node.masking.is_some();
1513            #[cfg(not(feature = "masking"))]
1514            let has_masking = false;
1515            let ops = crate::lineage_glue::column_ops(&node.transforms, has_masking);
1516            faucet_lineage::derive_column_lineage(&input_fields, &ops).map(|cl| {
1517                // `ColumnLineage` is not `Serialize` (IndexMap); render the
1518                // stable `{"fields": {out: [in, …]}}` shape by hand.
1519                let fields: serde_json::Map<String, Value> = cl
1520                    .edges
1521                    .iter()
1522                    .map(|(out, ins)| {
1523                        (
1524                            out.clone(),
1525                            Value::Array(ins.iter().map(|s| Value::String(s.clone())).collect()),
1526                        )
1527                    })
1528                    .collect();
1529                serde_json::json!({ "fields": fields })
1530            })
1531        });
1532
1533        let update = CatalogUpdate {
1534            run_id: handle.run_id.clone().unwrap_or_else(|| run_id.clone()),
1535            pipeline: obs_labels.pipeline.to_string(),
1536            row: obs_labels.row.to_string(),
1537            recorded_at: chrono::Utc::now(),
1538            // A matrix invocation is always one source → one sink; the vector
1539            // shape exists for topology graphs, where a sink can be fed by several
1540            // (#459).
1541            sources: vec![DatasetObservation {
1542                uri: canonicalize_uri(&source_dataset_uri, &node.source.config, opts.clock),
1543                kind: node.source.kind.clone(),
1544                role: DatasetRole::Source,
1545                schema: source_schema,
1546                records: records_read,
1547            }],
1548            sink: DatasetObservation {
1549                uri: canonicalize_uri(&sink_dataset_uri, &node.sink.config, opts.clock),
1550                kind: node.sink.kind.clone(),
1551                role: DatasetRole::Sink,
1552                schema: sink_schema,
1553                records: records_out,
1554            },
1555            column_lineage,
1556        };
1557        crate::catalog::record(handle, &update).await;
1558    }
1559
1560    let result = result?;
1561
1562    // Per-invocation stats for `faucet run --output json` (#390). `records_read`
1563    // is only known when the source sampler was installed (a `lineage:` or
1564    // `catalog:` block); otherwise it stays `None` rather than guessing.
1565    #[cfg(feature = "lineage")]
1566    let records_read = in_sample.as_ref().map(|s| s.count());
1567    #[cfg(not(feature = "lineage"))]
1568    let records_read: Option<u64> = None;
1569    let stats = PipelineStats {
1570        records_written: result.records_written,
1571        records_read,
1572        dlq_count: result
1573            .dlq
1574            .as_ref()
1575            .map(|d| d.records_dlq as u64)
1576            .unwrap_or(0),
1577        bookmark: result.bookmark.clone(),
1578    };
1579
1580    let captured = if capture.is_some() {
1581        std::mem::take(&mut *captured.lock().await)
1582    } else {
1583        Vec::new()
1584    };
1585    Ok((captured, stats))
1586}
1587
1588async fn build_state_for_node(
1589    node: &ExpandedNode,
1590    state_path_override: Option<&Path>,
1591) -> CliResult<Option<Arc<dyn StateStore>>> {
1592    match (&node.state, state_path_override) {
1593        (Some(spec), None) => Ok(Some(build_state_store(spec).await?)),
1594        (None, Some(path)) => Ok(Some(state_from_override(path))),
1595        (Some(spec), Some(path)) => {
1596            if spec.kind == "file" {
1597                Ok(Some(state_from_override(path)))
1598            } else {
1599                tracing::warn!(
1600                    state = %spec.kind,
1601                    "--state-path is only meaningful for the 'file' backend; ignoring override"
1602                );
1603                Ok(Some(build_state_store(spec).await?))
1604            }
1605        }
1606        (None, None) => Ok(None),
1607    }
1608}
1609
1610fn state_from_override(path: &Path) -> Arc<dyn StateStore> {
1611    Arc::new(faucet_core::FileStateStore::new(path)) as Arc<dyn StateStore>
1612}
1613
1614/// Translate a [`crate::config::DlqSpec`] from the YAML/JSON config into a
1615/// runtime [`DlqConfig`] ready to attach to a [`Pipeline`].
1616pub async fn build_dlq_config(spec: &crate::config::DlqSpec) -> CliResult<DlqConfig> {
1617    // DLQ sinks resolve against an empty catalog — shared `auth: { ref }` on a
1618    // DLQ sink is out of scope (DLQ targets are typically local jsonl/stdout).
1619    let sink = build_sink(
1620        &spec.sink.kind,
1621        spec.sink.config.clone(),
1622        &AuthCatalog::new(),
1623    )
1624    .await?;
1625    Ok(DlqConfig {
1626        sink: Arc::from(sink),
1627        on_batch_error: match spec.on_batch_error {
1628            crate::config::OnBatchErrorSpec::Propagate => OnBatchError::Propagate,
1629            crate::config::OnBatchErrorSpec::DlqAll => OnBatchError::DlqAll,
1630        },
1631        max_failures_per_page: spec.max_failures_per_page,
1632        max_failures_total: spec.max_failures_total,
1633        include_original_payload: spec.include_original_payload,
1634    })
1635}
1636
1637/// Classify a pipeline error into a notification event (#280). A circuit-breaker
1638/// trip and a contract-abort breach get their own event kinds; everything else
1639/// is a generic `run_failure` carrying a short error-kind label.
1640#[cfg(feature = "notify")]
1641fn error_event(pipeline: &str, row: &str, err: &FaucetError) -> crate::notify::NotifyEvent {
1642    use crate::notify::NotifyEvent;
1643    match err {
1644        FaucetError::CircuitOpen { failures, cooldown } => {
1645            NotifyEvent::circuit_open(pipeline, row, *failures, cooldown.as_secs())
1646        }
1647        FaucetError::ContractViolation { message, .. } => {
1648            NotifyEvent::contract_abort(pipeline, row, message.clone())
1649        }
1650        other => {
1651            NotifyEvent::run_failure(pipeline, row, faucet_error_kind(other), other.to_string())
1652        }
1653    }
1654}
1655
1656/// Short, stable label for a `FaucetError` variant used as the `error_kind`
1657/// detail on a `run_failure` notification. (`faucet-core`'s own `error_kind`
1658/// helper is `pub(crate)`, so we keep a small CLI-side mapping.)
1659#[cfg(feature = "notify")]
1660fn faucet_error_kind(err: &FaucetError) -> &'static str {
1661    match err {
1662        FaucetError::Config(_) => "config",
1663        FaucetError::Source(_) => "source",
1664        FaucetError::Sink(_) => "sink",
1665        FaucetError::State(_) => "state",
1666        FaucetError::QualityFailure { .. } => "quality",
1667        FaucetError::SchemaDrift { .. } => "schema_drift",
1668        _ => "error",
1669    }
1670}
1671
1672/// In-place `${now.*}` resolution against the run clock. Walks every string
1673/// leaf and rewrites `${now.<token>}`; all other `${...}` tokens are untouched.
1674/// Shared with `faucet test`, which applies the same pre-pass to transform
1675/// configs under the case clock.
1676/// Error on a leftover `${backfill.*}` token: those resolve only inside
1677/// `faucet backfill`, which substitutes them per window unit before the
1678/// executor runs. Reaching here means another runtime picked up a
1679/// window-scoped config.
1680pub(crate) fn reject_unresolved_backfill_tokens(value: &Value, owner: &str) -> CliResult<()> {
1681    fn walk(value: &Value, owner: &str) -> CliResult<()> {
1682        match value {
1683            Value::String(s) if s.contains("${backfill.") => Err(CliError::Config(format!(
1684                "the {owner} config references a `${{backfill.*}}` token, which only                  `faucet backfill` resolves — run this config via `faucet backfill                  --from … --to …`, or remove the token"
1685            ))),
1686            Value::Array(a) => a.iter().try_for_each(|v| walk(v, owner)),
1687            Value::Object(m) => m.values().try_for_each(|v| walk(v, owner)),
1688            _ => Ok(()),
1689        }
1690    }
1691    walk(value, owner)
1692}
1693
1694pub(crate) fn resolve_now_inplace(
1695    value: &mut Value,
1696    clock: DateTime<FixedOffset>,
1697) -> CliResult<()> {
1698    match value {
1699        Value::String(s) => {
1700            *s = crate::interpolate::resolve_now(s, clock)?;
1701            Ok(())
1702        }
1703        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_now_inplace(v, clock)),
1704        Value::Object(m) => m
1705            .values_mut()
1706            .try_for_each(|v| resolve_now_inplace(v, clock)),
1707        _ => Ok(()),
1708    }
1709}
1710
1711/// In-place runtime interpolation against a parent-record context. Walks every
1712/// string leaf in `value` and replaces `${id.path}` tokens with stringified
1713/// values from `ctx`.
1714fn resolve_inplace(value: &mut Value, ctx: &HashMap<String, Value>) -> CliResult<()> {
1715    match value {
1716        Value::String(s) => {
1717            let resolved = interpolate_record(s, ctx)?;
1718            *s = resolved;
1719            Ok(())
1720        }
1721        Value::Array(a) => a.iter_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1722        Value::Object(m) => m.values_mut().try_for_each(|v| resolve_inplace(v, ctx)),
1723        _ => Ok(()),
1724    }
1725}
1726
1727// ── Adapter sinks/sources ───────────────────────────────────────────────────
1728
1729/// Wraps a [`StateStore`] so reads pass through but writes/deletes are dropped.
1730///
1731/// Attached under `--dry-run` / `--limit`: those modes swap in counting /
1732/// truncating sinks that return `Ok` without a real durable write, and
1733/// `run_stream` persists a page's bookmark after any `Ok`. Persisting an
1734/// advanced bookmark from a preview would make the next *real* run resume past
1735/// records that were never written — a `--dry-run` silently causing data loss
1736/// (audit #321 H1; for postgres-cdc it also lets Postgres recycle WAL for
1737/// undelivered changes). Reads still pass through so the preview faithfully
1738/// resumes from the existing bookmark.
1739pub(crate) struct ReadOnlyStateStore {
1740    pub(crate) inner: Arc<dyn StateStore>,
1741}
1742
1743#[async_trait]
1744impl StateStore for ReadOnlyStateStore {
1745    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1746        self.inner.get(key).await
1747    }
1748    async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1749        Ok(())
1750    }
1751    async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1752        Ok(())
1753    }
1754}
1755
1756/// Wraps a source so its `state_key()` returns the executor-provided value
1757/// instead of the source's natural one. Lets every matrix invocation use a
1758/// distinct state-store entry even when the underlying source kind is shared.
1759struct StateKeyOverride {
1760    inner: Box<dyn Source>,
1761    key: String,
1762}
1763
1764#[async_trait]
1765impl Source for StateKeyOverride {
1766    async fn fetch_with_context(
1767        &self,
1768        ctx: &HashMap<String, Value>,
1769    ) -> Result<Vec<Value>, FaucetError> {
1770        self.inner.fetch_with_context(ctx).await
1771    }
1772    async fn fetch_with_context_incremental(
1773        &self,
1774        ctx: &HashMap<String, Value>,
1775    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1776        self.inner.fetch_with_context_incremental(ctx).await
1777    }
1778    // Forward `stream_pages` so the wrapped connector's *native* page stream
1779    // survives the wrap. Without this the trait's buffering default kicks in
1780    // for every stateful run — losing per-page bookmarks (CDC per-transaction
1781    // durability, exactly-once per-page tokens) and the O(batch_size) memory
1782    // bound.
1783    fn stream_pages<'a>(
1784        &'a self,
1785        ctx: &'a HashMap<String, Value>,
1786        batch_size: usize,
1787    ) -> std::pin::Pin<
1788        Box<
1789            dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
1790                + Send
1791                + 'a,
1792        >,
1793    > {
1794        self.inner.stream_pages(ctx, batch_size)
1795    }
1796    fn connector_name(&self) -> &'static str {
1797        self.inner.connector_name()
1798    }
1799    fn dataset_uri(&self) -> String {
1800        self.inner.dataset_uri()
1801    }
1802    fn state_key(&self) -> Option<String> {
1803        Some(self.key.clone())
1804    }
1805    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1806        self.inner.apply_start_bookmark(bookmark).await
1807    }
1808    fn supports_exactly_once(&self) -> bool {
1809        self.inner.supports_exactly_once()
1810    }
1811    fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
1812        self.inner.replay_guarantee()
1813    }
1814    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1815        self.inner.capture_resume_position().await
1816    }
1817}
1818
1819/// Forwards each record to an inner sink while also capturing a **projected**
1820/// copy into a shared buffer for descendant rows to consume. Projecting to only
1821/// the fields children reference bounds orchestrator memory (#160).
1822struct CapturingSink {
1823    inner: Box<dyn Sink>,
1824    captured: Arc<Mutex<Vec<Value>>>,
1825    projection: Arc<Projection>,
1826}
1827
1828impl CapturingSink {
1829    fn wrap(
1830        inner: Box<dyn Sink>,
1831        captured: Arc<Mutex<Vec<Value>>>,
1832        projection: Arc<Projection>,
1833    ) -> Self {
1834        Self {
1835            inner,
1836            captured,
1837            projection,
1838        }
1839    }
1840}
1841
1842#[async_trait]
1843impl Sink for CapturingSink {
1844    fn connector_name(&self) -> &'static str {
1845        self.inner.connector_name()
1846    }
1847    fn dataset_uri(&self) -> String {
1848        self.inner.dataset_uri()
1849    }
1850    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1851        let written = self.inner.write_batch(records).await?;
1852        // Capture only what actually landed (LimitedSink may have dropped some),
1853        // projected to the fields children reference (#160).
1854        let n = written.min(records.len());
1855        let mut buf = self.captured.lock().await;
1856        buf.extend(
1857            records
1858                .iter()
1859                .take(n)
1860                .map(|r| project_record(r, &self.projection)),
1861        );
1862        Ok(written)
1863    }
1864    async fn flush(&self) -> Result<(), FaucetError> {
1865        self.inner.flush().await
1866    }
1867    // Capability + exactly-once passthroughs, so a parent row that fans out to
1868    // children (which is what this wrapper serves) keeps the inner sink's
1869    // delivery semantics instead of being masked down to the trait defaults.
1870    fn supports_idempotent_writes(&self) -> bool {
1871        self.inner.supports_idempotent_writes()
1872    }
1873    fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
1874        self.inner.sink_guarantee()
1875    }
1876    fn dedups_by_key(&self) -> bool {
1877        self.inner.dedups_by_key()
1878    }
1879    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
1880        self.inner.supported_write_modes()
1881    }
1882    async fn write_batch_idempotent(
1883        &self,
1884        records: &[Value],
1885        scope: &str,
1886        token: &str,
1887    ) -> Result<usize, FaucetError> {
1888        let written = self
1889            .inner
1890            .write_batch_idempotent(records, scope, token)
1891            .await?;
1892        let n = written.min(records.len());
1893        let mut buf = self.captured.lock().await;
1894        buf.extend(
1895            records
1896                .iter()
1897                .take(n)
1898                .map(|r| project_record(r, &self.projection)),
1899        );
1900        Ok(written)
1901    }
1902    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1903        self.inner.last_committed_token(scope).await
1904    }
1905    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1906        self.inner.current_schema().await
1907    }
1908    fn supports_schema_evolution(&self) -> bool {
1909        self.inner.supports_schema_evolution()
1910    }
1911    async fn evolve_schema(
1912        &self,
1913        evolution: &faucet_core::SchemaEvolution,
1914    ) -> Result<(), FaucetError> {
1915        self.inner.evolve_schema(evolution).await
1916    }
1917}
1918
1919/// Cap on records written. Each `write_batch` call truncates `records` to the
1920/// remaining budget before delegating.
1921pub(crate) struct LimitedSink {
1922    inner: Box<dyn Sink>,
1923    remaining: AtomicUsize,
1924}
1925
1926impl LimitedSink {
1927    pub(crate) fn wrap(inner: Box<dyn Sink>, cap: usize) -> Self {
1928        Self {
1929            inner,
1930            remaining: AtomicUsize::new(cap),
1931        }
1932    }
1933}
1934
1935#[async_trait]
1936impl Sink for LimitedSink {
1937    fn connector_name(&self) -> &'static str {
1938        self.inner.connector_name()
1939    }
1940    fn dataset_uri(&self) -> String {
1941        self.inner.dataset_uri()
1942    }
1943    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1944        let remaining = self.remaining.load(Ordering::Relaxed);
1945        if remaining == 0 {
1946            return Ok(0);
1947        }
1948        let take = remaining.min(records.len());
1949        let slice = &records[..take];
1950        let written = self.inner.write_batch(slice).await?;
1951        self.remaining
1952            .fetch_sub(written.min(remaining), Ordering::Relaxed);
1953        Ok(written)
1954    }
1955    async fn flush(&self) -> Result<(), FaucetError> {
1956        self.inner.flush().await
1957    }
1958}
1959
1960/// No-op sink used in `--dry-run`. Counts records seen so the rest of the
1961/// pipeline (transforms, source) still runs.
1962pub(crate) struct CountingSink {
1963    seen: AtomicUsize,
1964}
1965
1966impl CountingSink {
1967    pub(crate) fn new() -> Self {
1968        Self {
1969            seen: AtomicUsize::new(0),
1970        }
1971    }
1972}
1973
1974#[async_trait]
1975impl Sink for CountingSink {
1976    fn connector_name(&self) -> &'static str {
1977        "dry-run"
1978    }
1979    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1980        self.seen.fetch_add(records.len(), Ordering::Relaxed);
1981        Ok(records.len())
1982    }
1983}
1984
1985/// Render a JSON value compactly for use as a state-key suffix or log line.
1986/// Strings pass through unquoted; numbers/bools/null/composites use to_string.
1987fn value_to_string_brief(v: &Value) -> String {
1988    match v {
1989        Value::String(s) => s.clone(),
1990        other => other.to_string(),
1991    }
1992}
1993
1994#[cfg(test)]
1995mod tests {
1996    use super::*;
1997    use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
1998    use crate::expand::expand;
1999    use serde_json::json;
2000
2001    fn cfg_csv_to_jsonl(input: &Path, output: &Path) -> PipelineConfig {
2002        PipelineConfig {
2003            version: 1,
2004            name: Some("test".into()),
2005            vars: None,
2006            params: Default::default(),
2007            auth: None,
2008            pipeline: PipelineSpec {
2009                source: Some(ConnectorSpec {
2010                    kind: "csv".into(),
2011                    config: json!({"path": input.to_str().unwrap()}),
2012                    transforms: None,
2013                    inherit_transforms: true,
2014                    status: None,
2015                    tags: Vec::new(),
2016                    complete_for: None,
2017                }),
2018                sink: Some(ConnectorSpec {
2019                    kind: "jsonl".into(),
2020                    config: json!({"path": output.to_str().unwrap()}),
2021                    transforms: None,
2022                    inherit_transforms: true,
2023                    status: None,
2024                    tags: Vec::new(),
2025                    complete_for: None,
2026                }),
2027                sources: Default::default(),
2028                sinks: Default::default(),
2029                transforms: Vec::new(),
2030                state: None,
2031                dlq: None,
2032                #[cfg(feature = "quality")]
2033                quality: None,
2034                #[cfg(feature = "contract")]
2035                contract: None,
2036                #[cfg(feature = "masking")]
2037                masking: None,
2038                schema: None,
2039                nodes: std::collections::HashMap::new(),
2040                edges: Vec::new(),
2041            },
2042            matrix: Vec::new(),
2043            execution: None,
2044            selection: None,
2045            observability: None,
2046            delivery: faucet_core::DeliveryMode::default(),
2047            resilience: None,
2048            sla: None,
2049            shard: None,
2050            replication: None,
2051            backfill: None,
2052            partition: None,
2053            #[cfg(feature = "schedule")]
2054            schedule: None,
2055            #[cfg(feature = "lineage")]
2056            lineage: None,
2057            #[cfg(feature = "catalog")]
2058            catalog: None,
2059            #[cfg(feature = "notify")]
2060            notifications: Vec::new(),
2061        }
2062    }
2063
2064    #[tokio::test]
2065    async fn empty_matrix_runs_pipeline_once() {
2066        let dir = tempfile::tempdir().unwrap();
2067        let input = dir.path().join("in.csv");
2068        let output = dir.path().join("out.jsonl");
2069        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2070        let cfg = cfg_csv_to_jsonl(&input, &output);
2071        let nodes = expand(&cfg).unwrap();
2072        let summary = run_expanded(
2073            nodes,
2074            ExecuteOptions {
2075                pipeline_name: "t".into(),
2076                run_id: None,
2077                execution: None,
2078                dry_run: false,
2079                limit: None,
2080                state_path_override: None,
2081                shard: None,
2082                auth: Default::default(),
2083                clock: chrono::Utc::now().fixed_offset(),
2084                cancel: None,
2085                resilience: None,
2086                sla: None,
2087                #[cfg(feature = "lineage")]
2088                lineage: None,
2089                #[cfg(feature = "lineage")]
2090                lineage_cfg: None,
2091                #[cfg(feature = "notify")]
2092                notifier: None,
2093                #[cfg(feature = "catalog")]
2094                catalog: None,
2095            },
2096        )
2097        .await
2098        .unwrap();
2099        assert_eq!(summary.invocations.len(), 1);
2100        assert_eq!(summary.invocations[0].records_written, 2);
2101        assert!(!summary.had_failures());
2102        let body = std::fs::read_to_string(&output).unwrap();
2103        assert_eq!(body.lines().count(), 2);
2104    }
2105
2106    /// Minimal options with a catalog handle attached.
2107    #[cfg(feature = "catalog")]
2108    fn opts_with_catalog(name: &str, handle: crate::catalog::CatalogHandle) -> ExecuteOptions {
2109        let mut o = opts(name);
2110        o.catalog = Some(handle);
2111        o
2112    }
2113
2114    #[cfg(feature = "catalog")]
2115    #[tokio::test]
2116    async fn catalog_records_schema_timeline_across_two_runs() {
2117        // Acceptance (#279): running the same pipeline twice with a schema
2118        // change in between produces exactly two schema-timeline entries for
2119        // the dataset, the second carrying a computed diff.
2120        use crate::catalog::CatalogHandle;
2121        use crate::serve::history::RunHistory as _;
2122        use crate::serve::history::catalog::{self, CatalogListFilter};
2123        use crate::serve::history::memory::MemoryHistory;
2124
2125        let dir = tempfile::tempdir().unwrap();
2126        let input = dir.path().join("in.csv");
2127        let output = dir.path().join("out.jsonl");
2128        let store = Arc::new(MemoryHistory::new(std::time::Duration::from_secs(60)));
2129        let handle = CatalogHandle {
2130            store: store.clone(),
2131            run_id: None,
2132            sample_records: 10,
2133        };
2134
2135        std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2136        let cfg = cfg_csv_to_jsonl(&input, &output);
2137        let nodes = expand(&cfg).unwrap();
2138        let summary = run_expanded(nodes, opts_with_catalog("cat", handle.clone()))
2139            .await
2140            .unwrap();
2141        assert!(!summary.had_failures());
2142
2143        // Second run: same pipeline, schema gains an `email` column.
2144        std::fs::write(&input, "id,name,email\n1,alice,a@x.io\n2,bob,b@x.io\n").unwrap();
2145        let nodes = expand(&cfg).unwrap();
2146        let summary = run_expanded(nodes, opts_with_catalog("cat", handle))
2147            .await
2148            .unwrap();
2149        assert!(!summary.had_failures());
2150
2151        // Two datasets (source + sink), each with a 2-entry deduped timeline.
2152        let page = store
2153            .catalog_list_datasets(&CatalogListFilter {
2154                limit: 10,
2155                ..Default::default()
2156            })
2157            .await
2158            .unwrap();
2159        assert_eq!(page.datasets.len(), 2, "source + sink datasets");
2160        for ds in &page.datasets {
2161            let detail = store
2162                .catalog_get_dataset(&ds.id)
2163                .await
2164                .unwrap()
2165                .expect("dataset detail");
2166            assert_eq!(detail.dataset.runs, 2);
2167            assert_eq!(
2168                detail.schema_timeline.len(),
2169                2,
2170                "exactly two timeline entries for {}",
2171                ds.uri
2172            );
2173            assert!(detail.schema_timeline[0].diff.is_none());
2174            let diff = detail.schema_timeline[1]
2175                .diff
2176                .as_ref()
2177                .expect("second version carries a diff");
2178            assert!(
2179                diff["added"]
2180                    .as_array()
2181                    .unwrap()
2182                    .iter()
2183                    .any(|c| c["column"] == "email"),
2184                "diff must show the added email column: {diff}"
2185            );
2186            assert_eq!(detail.stats.len(), 2, "one volume point per run");
2187        }
2188        // One lineage edge, csv → jsonl, traversed twice.
2189        let edges = store.catalog_lineage(None, 5).await.unwrap();
2190        assert_eq!(edges.len(), 1);
2191        assert_eq!(edges[0].runs, 2);
2192        assert_eq!(edges[0].last_records, 2);
2193        assert_eq!(edges[0].src_id, catalog::dataset_id(&edges[0].src_uri));
2194    }
2195
2196    /// A catalog store whose writes always fail — drives the never-fail-the-run
2197    /// contract.
2198    #[cfg(feature = "catalog")]
2199    struct FailingCatalogStore;
2200
2201    #[cfg(feature = "catalog")]
2202    #[async_trait]
2203    impl crate::serve::history::RunHistory for FailingCatalogStore {
2204        async fn claim_idempotency(
2205            &self,
2206            _: &str,
2207            _: &str,
2208            _: &str,
2209            _: std::time::Duration,
2210        ) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError> {
2211            Err(crate::serve::history::HistoryError::Backend("down".into()))
2212        }
2213        async fn upsert(
2214            &self,
2215            _: &crate::serve::history::RunRecord,
2216        ) -> Result<(), crate::serve::history::HistoryError> {
2217            Err(crate::serve::history::HistoryError::Backend("down".into()))
2218        }
2219        async fn get(
2220            &self,
2221            _: &str,
2222        ) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
2223        {
2224            Err(crate::serve::history::HistoryError::Backend("down".into()))
2225        }
2226        async fn list(
2227            &self,
2228            _: &crate::serve::history::ListFilter,
2229        ) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError> {
2230            Err(crate::serve::history::HistoryError::Backend("down".into()))
2231        }
2232        async fn delete(
2233            &self,
2234            _: &str,
2235        ) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
2236        {
2237            Err(crate::serve::history::HistoryError::Backend("down".into()))
2238        }
2239        async fn purge_expired(
2240            &self,
2241            _: std::time::Duration,
2242        ) -> Result<usize, crate::serve::history::HistoryError> {
2243            Err(crate::serve::history::HistoryError::Backend("down".into()))
2244        }
2245        async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
2246            Err(crate::serve::history::HistoryError::Backend("down".into()))
2247        }
2248        async fn catalog_record(
2249            &self,
2250            _: &crate::serve::history::catalog::CatalogUpdate,
2251        ) -> Result<(), crate::serve::history::HistoryError> {
2252            Err(crate::serve::history::HistoryError::Backend(
2253                "catalog write refused".into(),
2254            ))
2255        }
2256        fn degraded(&self) -> bool {
2257            false
2258        }
2259    }
2260
2261    #[cfg(feature = "catalog")]
2262    #[tokio::test]
2263    async fn catalog_write_failure_never_fails_the_run() {
2264        // Acceptance (#279): a forced catalog-backend error degrades (logged)
2265        // while the pipeline still succeeds and writes its output.
2266        use crate::catalog::CatalogHandle;
2267        let dir = tempfile::tempdir().unwrap();
2268        let input = dir.path().join("in.csv");
2269        let output = dir.path().join("out.jsonl");
2270        std::fs::write(&input, "name\nalice\n").unwrap();
2271        let cfg = cfg_csv_to_jsonl(&input, &output);
2272        let nodes = expand(&cfg).unwrap();
2273        let handle = CatalogHandle {
2274            store: Arc::new(FailingCatalogStore),
2275            run_id: None,
2276            sample_records: 10,
2277        };
2278        let summary = run_expanded(nodes, opts_with_catalog("cat-fail", handle))
2279            .await
2280            .unwrap();
2281        assert!(
2282            !summary.had_failures(),
2283            "catalog failure must not fail the run"
2284        );
2285        assert_eq!(summary.invocations[0].records_written, 1);
2286        assert_eq!(
2287            std::fs::read_to_string(&output).unwrap().lines().count(),
2288            1,
2289            "sink output written despite the catalog error"
2290        );
2291    }
2292
2293    #[tokio::test]
2294    async fn matrix_two_independent_roots_both_run() {
2295        // Two roots: one writes alice, the other writes bob — to two separate files.
2296        let dir = tempfile::tempdir().unwrap();
2297        let csv_a = dir.path().join("a.csv");
2298        let csv_b = dir.path().join("b.csv");
2299        let out_a = dir.path().join("a.jsonl");
2300        let out_b = dir.path().join("b.jsonl");
2301        std::fs::write(&csv_a, "name\nalice\n").unwrap();
2302        std::fs::write(&csv_b, "name\nbob\n").unwrap();
2303
2304        let yaml = format!(
2305            r#"version: 1
2306pipeline:
2307  source: {{ type: csv, config: {{ path: {a} }} }}
2308  sink:   {{ type: jsonl, config: {{ path: {out_a} }} }}
2309matrix:
2310  - id: rowA
2311  - id: rowB
2312    source: {{ config: {{ path: {b} }} }}
2313    sink:   {{ config: {{ path: {out_b} }} }}
2314"#,
2315            a = csv_a.display(),
2316            b = csv_b.display(),
2317            out_a = out_a.display(),
2318            out_b = out_b.display(),
2319        );
2320        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2321        let nodes = expand(&cfg).unwrap();
2322        let summary = run_expanded(
2323            nodes,
2324            ExecuteOptions {
2325                pipeline_name: "matrix".into(),
2326                run_id: None,
2327                execution: None,
2328                dry_run: false,
2329                limit: None,
2330                state_path_override: None,
2331                shard: None,
2332                auth: Default::default(),
2333                clock: chrono::Utc::now().fixed_offset(),
2334                cancel: None,
2335                resilience: None,
2336                sla: None,
2337                #[cfg(feature = "lineage")]
2338                lineage: None,
2339                #[cfg(feature = "lineage")]
2340                lineage_cfg: None,
2341                #[cfg(feature = "notify")]
2342                notifier: None,
2343                #[cfg(feature = "catalog")]
2344                catalog: None,
2345            },
2346        )
2347        .await
2348        .unwrap();
2349        assert_eq!(summary.invocations.len(), 2);
2350        assert!(out_a.exists());
2351        assert!(out_b.exists());
2352    }
2353
2354    #[tokio::test]
2355    async fn dag_child_fans_out_per_parent_record() {
2356        // Parent: CSV with two records (id=1, id=2).
2357        // Child: writes one JSONL file per parent id, using ${parent.id} in the path.
2358        let dir = tempfile::tempdir().unwrap();
2359        let parent_csv = dir.path().join("parents.csv");
2360        let child_csv = dir.path().join("child.csv");
2361        std::fs::write(&parent_csv, "id,name\n1,alice\n2,bob\n").unwrap();
2362        std::fs::write(&child_csv, "x\nA\nB\nC\n").unwrap();
2363        let parent_out = dir.path().join("parents.jsonl");
2364        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
2365
2366        let yaml = format!(
2367            r#"version: 1
2368pipeline:
2369  source: {{ type: csv, config: {{ path: {parent} }} }}
2370  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2371matrix:
2372  - id: parents
2373  - id: child
2374    parent: parents
2375    source: {{ config: {{ path: {child} }} }}
2376    sink:   {{ config: {{ path: "{child_out}" }} }}
2377"#,
2378            parent = parent_csv.display(),
2379            parent_out = parent_out.display(),
2380            child = child_csv.display(),
2381            child_out = child_out_pattern.display(),
2382        );
2383        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2384        let nodes = expand(&cfg).unwrap();
2385        let summary = run_expanded(
2386            nodes,
2387            ExecuteOptions {
2388                pipeline_name: "dagtest".into(),
2389                run_id: None,
2390                execution: None,
2391                dry_run: false,
2392                limit: None,
2393                state_path_override: None,
2394                shard: None,
2395                auth: Default::default(),
2396                clock: chrono::Utc::now().fixed_offset(),
2397                cancel: None,
2398                resilience: None,
2399                sla: None,
2400                #[cfg(feature = "lineage")]
2401                lineage: None,
2402                #[cfg(feature = "lineage")]
2403                lineage_cfg: None,
2404                #[cfg(feature = "notify")]
2405                notifier: None,
2406                #[cfg(feature = "catalog")]
2407                catalog: None,
2408            },
2409        )
2410        .await
2411        .unwrap();
2412
2413        // 1 parent invocation + 2 child invocations.
2414        assert_eq!(summary.invocations.len(), 3);
2415        assert!(!summary.had_failures(), "{:?}", summary);
2416        assert!(dir.path().join("child-1.jsonl").exists());
2417        assert!(dir.path().join("child-2.jsonl").exists());
2418    }
2419
2420    #[tokio::test]
2421    async fn depends_on_root_runs_after_dependency() {
2422        // `stage` writes a CSV that `load` reads — `load` can only succeed if
2423        // it genuinely starts after `stage` finishes (pure ordering, no
2424        // record hand-off).
2425        let dir = tempfile::tempdir().unwrap();
2426        let input = dir.path().join("in.csv");
2427        let mid = dir.path().join("mid.csv");
2428        let out = dir.path().join("out.jsonl");
2429        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
2430
2431        let yaml = format!(
2432            r#"version: 1
2433pipeline:
2434  source: {{ type: csv, config: {{ path: {input} }} }}
2435  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2436matrix:
2437  - id: stage
2438    sink: {{ type: csv, config: {{ path: {mid} }} }}
2439  - id: load
2440    depends_on: [stage]
2441    source: {{ config: {{ path: {mid} }} }}
2442"#,
2443            input = input.display(),
2444            mid = mid.display(),
2445            out = out.display(),
2446        );
2447        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2448        let nodes = expand(&cfg).unwrap();
2449        let summary = run_expanded(nodes, opts("depsorder")).await.unwrap();
2450        assert_eq!(summary.invocations.len(), 2, "{summary:?}");
2451        assert!(!summary.had_failures(), "{summary:?}");
2452        let load = summary
2453            .invocations
2454            .iter()
2455            .find(|i| i.row_id == "load")
2456            .unwrap();
2457        assert_eq!(load.records_written, 2);
2458        let written = std::fs::read_to_string(&out).unwrap();
2459        assert_eq!(written.lines().count(), 2);
2460    }
2461
2462    #[tokio::test]
2463    async fn diamond_dependency_waits_for_all_prerequisites() {
2464        // c waits on both a and b (a diamond join): readiness must require
2465        // *every* dependency to be terminal, not just the first.
2466        let dir = tempfile::tempdir().unwrap();
2467        let input = dir.path().join("in.csv");
2468        let mid_a = dir.path().join("mid_a.csv");
2469        let mid_b = dir.path().join("mid_b.csv");
2470        let out = dir.path().join("out.jsonl");
2471        std::fs::write(&input, "name\nalice\n").unwrap();
2472
2473        let yaml = format!(
2474            r#"version: 1
2475pipeline:
2476  source: {{ type: csv, config: {{ path: {input} }} }}
2477  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2478matrix:
2479  - id: a
2480    sink: {{ type: csv, config: {{ path: {mid_a} }} }}
2481  - id: b
2482    sink: {{ type: csv, config: {{ path: {mid_b} }} }}
2483  - id: c
2484    depends_on: [a, b]
2485    source: {{ config: {{ path: {mid_a} }} }}
2486"#,
2487            input = input.display(),
2488            mid_a = mid_a.display(),
2489            mid_b = mid_b.display(),
2490            out = out.display(),
2491        );
2492        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2493        let nodes = expand(&cfg).unwrap();
2494        let summary = run_expanded(nodes, opts("diamond")).await.unwrap();
2495        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
2496        assert!(!summary.had_failures(), "{summary:?}");
2497        assert!(mid_b.exists(), "b must have run before c became ready");
2498        assert!(out.exists());
2499    }
2500
2501    #[tokio::test]
2502    async fn failed_dependency_skips_dependent() {
2503        // `stage` fails (missing input file); `load` depends on it and must be
2504        // skipped — no invocation outcome, no output file.
2505        let dir = tempfile::tempdir().unwrap();
2506        let good_input = dir.path().join("good.csv");
2507        let out = dir.path().join("out.jsonl");
2508        std::fs::write(&good_input, "name\nalice\n").unwrap();
2509
2510        let yaml = format!(
2511            r#"version: 1
2512pipeline:
2513  source: {{ type: csv, config: {{ path: {good} }} }}
2514  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2515matrix:
2516  - id: stage
2517    source: {{ config: {{ path: {missing} }} }}
2518  - id: load
2519    depends_on: [stage]
2520"#,
2521            good = good_input.display(),
2522            missing = dir.path().join("nonexistent.csv").display(),
2523            out = out.display(),
2524        );
2525        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2526        let nodes = expand(&cfg).unwrap();
2527        let summary = run_expanded(nodes, opts("depskip")).await.unwrap();
2528        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2529        assert_eq!(summary.invocations[0].row_id, "stage");
2530        assert!(summary.invocations[0].error.is_some());
2531        assert!(
2532            !out.exists(),
2533            "dependent row must not run after its dependency failed"
2534        );
2535    }
2536
2537    #[tokio::test]
2538    async fn dependency_on_skipped_row_cascades() {
2539        // p fails → its child c is skipped → q (which depends on c) must be
2540        // skipped too, even though c itself never *failed*.
2541        let dir = tempfile::tempdir().unwrap();
2542        let good_input = dir.path().join("good.csv");
2543        let out = dir.path().join("q.jsonl");
2544        std::fs::write(&good_input, "id\n1\n").unwrap();
2545
2546        let yaml = format!(
2547            r#"version: 1
2548pipeline:
2549  source: {{ type: csv, config: {{ path: {good} }} }}
2550  sink:   {{ type: jsonl, config: {{ path: {out} }} }}
2551matrix:
2552  - id: p
2553    source: {{ config: {{ path: {missing} }} }}
2554  - id: c
2555    parent: p
2556  - id: q
2557    depends_on: [c]
2558"#,
2559            good = good_input.display(),
2560            missing = dir.path().join("nonexistent.csv").display(),
2561            out = out.display(),
2562        );
2563        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2564        let nodes = expand(&cfg).unwrap();
2565        let summary = run_expanded(nodes, opts("depcascade")).await.unwrap();
2566        assert_eq!(summary.invocations.len(), 1, "{summary:?}");
2567        assert_eq!(summary.invocations[0].row_id, "p");
2568        assert!(summary.invocations[0].error.is_some());
2569        assert!(
2570            !out.exists(),
2571            "q must be skipped when its dependency was skipped"
2572        );
2573    }
2574
2575    #[tokio::test]
2576    async fn on_error_stop_reports_failure_and_runs_no_extra_work() {
2577        // First root writes to an invalid sink path and fails. The second
2578        // ("good") root would succeed. Under `on_error: stop` the executor
2579        // calls `abort_all()` on the first failure, which cancels pending /
2580        // in-flight tasks at their next await point — but that is
2581        // best-effort: with `max_concurrent: 1` the two roots race for the
2582        // single permit, so "good" may already have completed before "bad"
2583        // fails. We therefore assert the guarantees that hold under *any*
2584        // scheduling rather than an exact invocation count (which was racy,
2585        // see issue #78 finding #24). The deterministic "stop actually
2586        // cancels in-flight work" path is covered by
2587        // `on_error_stop_under_parallelism_aborts_other_in_flight`.
2588        let dir = tempfile::tempdir().unwrap();
2589        let good_csv = dir.path().join("good.csv");
2590        std::fs::write(&good_csv, "x\n1\n").unwrap();
2591        let good_out = dir.path().join("good.jsonl");
2592        let bad_sink_dir = dir.path().to_path_buf();
2593
2594        let yaml = format!(
2595            r#"version: 1
2596pipeline:
2597  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2598  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2599matrix:
2600  - id: bad
2601    sink: {{ config: {{ path: {bad_dir} }} }}
2602  - id: good
2603execution:
2604  max_concurrent: 1
2605  on_error: stop
2606"#,
2607            good_csv = good_csv.display(),
2608            good_out = good_out.display(),
2609            bad_dir = bad_sink_dir.display(),
2610        );
2611        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2612        let nodes = expand(&cfg).unwrap();
2613        let summary = run_expanded(
2614            nodes,
2615            ExecuteOptions {
2616                pipeline_name: "stoptest".into(),
2617                run_id: None,
2618                execution: cfg.execution.clone(),
2619                dry_run: false,
2620                limit: None,
2621                state_path_override: None,
2622                shard: None,
2623                auth: Default::default(),
2624                clock: chrono::Utc::now().fixed_offset(),
2625                cancel: None,
2626                resilience: None,
2627                sla: None,
2628                #[cfg(feature = "lineage")]
2629                lineage: None,
2630                #[cfg(feature = "lineage")]
2631                lineage_cfg: None,
2632                #[cfg(feature = "notify")]
2633                notifier: None,
2634                #[cfg(feature = "catalog")]
2635                catalog: None,
2636            },
2637        )
2638        .await
2639        .unwrap();
2640
2641        // Invariants that hold regardless of which root won the permit race:
2642        assert!(summary.had_failures(), "the failing root must be reported");
2643
2644        // "bad" ran exactly once and is recorded as a failure.
2645        let bad: Vec<_> = summary
2646            .invocations
2647            .iter()
2648            .filter(|o| o.row_id == "bad")
2649            .collect();
2650        assert_eq!(bad.len(), 1, "bad must run exactly once");
2651        assert!(bad[0].error.is_some(), "bad must be recorded as a failure");
2652
2653        // No duplicate / extra invocations beyond the two work units.
2654        assert!(
2655            summary.invocations.len() <= 2,
2656            "at most the two roots may run, got {:?}",
2657            summary.invocations
2658        );
2659
2660        // "good" may: (a) win the permit first and run fully (writes its row,
2661        // file exists); (b) lose the race, acquire the permit after "bad" fails,
2662        // observe the cooperative stop-cancel at its first page boundary, and
2663        // return a 0-record success (no file); or (c) never appear if it was
2664        // still pending when the level finished. So the only invariant is: a
2665        // "good" that actually WROTE records must have produced its file.
2666        let good_wrote = summary
2667            .invocations
2668            .iter()
2669            .find(|o| o.row_id == "good" && o.error.is_none())
2670            .map(|o| o.records_written)
2671            .unwrap_or(0);
2672        if good_wrote > 0 {
2673            assert!(
2674                good_out.exists(),
2675                "a good that wrote records must have produced its output file"
2676            );
2677        }
2678    }
2679
2680    #[tokio::test]
2681    async fn invalid_pipeline_name_with_state_errors_up_front() {
2682        // A pipeline name that can't form a valid state key must fail up front
2683        // (at unit construction) when state is configured — not deep mid-run
2684        // as a `FaucetError::State`.
2685        let dir = tempfile::tempdir().unwrap();
2686        let input = dir.path().join("in.csv");
2687        let output = dir.path().join("out.jsonl");
2688        std::fs::write(&input, "name\nalice\n").unwrap();
2689        let yaml = format!(
2690            r#"version: 1
2691pipeline:
2692  source: {{ type: csv, config: {{ path: {input} }} }}
2693  sink:   {{ type: jsonl, config: {{ path: {output} }} }}
2694  state:  {{ type: memory }}
2695"#,
2696            input = input.display(),
2697            output = output.display(),
2698        );
2699        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2700        let nodes = expand(&cfg).unwrap();
2701        let err = run_expanded(
2702            nodes,
2703            ExecuteOptions {
2704                pipeline_name: "bad name".into(), // space is illegal in a state key
2705                run_id: None,
2706                execution: None,
2707                dry_run: false,
2708                limit: None,
2709                state_path_override: None,
2710                shard: None,
2711                auth: Default::default(),
2712                clock: chrono::Utc::now().fixed_offset(),
2713                cancel: None,
2714                resilience: None,
2715                sla: None,
2716                #[cfg(feature = "lineage")]
2717                lineage: None,
2718                #[cfg(feature = "lineage")]
2719                lineage_cfg: None,
2720                #[cfg(feature = "notify")]
2721                notifier: None,
2722                #[cfg(feature = "catalog")]
2723                catalog: None,
2724            },
2725        )
2726        .await
2727        .expect_err("an invalid pipeline name must be rejected up front when state is configured");
2728        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2729    }
2730
2731    #[tokio::test]
2732    async fn invalid_parent_key_value_with_state_errors_up_front() {
2733        // A parent-record value that yields an illegal state-key suffix must
2734        // fail up front at the child's unit construction, not mid-run.
2735        let dir = tempfile::tempdir().unwrap();
2736        let parent_csv = dir.path().join("parents.csv");
2737        let child_csv = dir.path().join("child.csv");
2738        // The parent `id` value contains a space — illegal in a state key.
2739        std::fs::write(&parent_csv, "id\nbad id\n").unwrap();
2740        std::fs::write(&child_csv, "x\nA\n").unwrap();
2741        let parent_out = dir.path().join("parents.jsonl");
2742        let child_out = dir.path().join("child.jsonl");
2743        let yaml = format!(
2744            r#"version: 1
2745pipeline:
2746  source: {{ type: csv, config: {{ path: {parent} }} }}
2747  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
2748  state:  {{ type: memory }}
2749matrix:
2750  - id: parents
2751  - id: child
2752    parent: parents
2753    source: {{ config: {{ path: {child} }} }}
2754    sink:   {{ config: {{ path: {child_out} }} }}
2755"#,
2756            parent = parent_csv.display(),
2757            parent_out = parent_out.display(),
2758            child = child_csv.display(),
2759            child_out = child_out.display(),
2760        );
2761        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2762        let nodes = expand(&cfg).unwrap();
2763        let err = run_expanded(
2764            nodes,
2765            ExecuteOptions {
2766                pipeline_name: "ok".into(),
2767                run_id: None,
2768                execution: None,
2769                dry_run: false,
2770                limit: None,
2771                state_path_override: None,
2772                shard: None,
2773                auth: Default::default(),
2774                clock: chrono::Utc::now().fixed_offset(),
2775                cancel: None,
2776                resilience: None,
2777                sla: None,
2778                #[cfg(feature = "lineage")]
2779                lineage: None,
2780                #[cfg(feature = "lineage")]
2781                lineage_cfg: None,
2782                #[cfg(feature = "notify")]
2783                notifier: None,
2784                #[cfg(feature = "catalog")]
2785                catalog: None,
2786            },
2787        )
2788        .await
2789        .expect_err(
2790            "an illegal parent-key value must be rejected up front when state is configured",
2791        );
2792        assert!(matches!(err, CliError::InvalidStateKey { .. }), "{err:?}");
2793    }
2794
2795    #[tokio::test]
2796    async fn on_error_stop_under_parallelism_aborts_other_in_flight() {
2797        // Three roots running with `max_concurrent: 3`. The bad row points
2798        // its sink at a directory (open fails fast). The other two point at
2799        // sinks that block forever on the writer end of a pipe — stuck *inside*
2800        // the sink write, they never reach a page boundary to observe the
2801        // cooperative stop-cancel, so the only way they can complete is the
2802        // hard-abort backstop that fires after the flush grace (#146 H16). The
2803        // test would hang if `on_error: stop` never aborted them, so a passing
2804        // run is itself the assertion.
2805        let dir = tempfile::tempdir().unwrap();
2806        let bad_sink_dir = dir.path().to_path_buf();
2807        // A real csv source with one row — small enough that the pipeline
2808        // proceeds straight to the sink phase.
2809        let good_csv = dir.path().join("good.csv");
2810        std::fs::write(&good_csv, "x\n1\n").unwrap();
2811        // The two "would never finish" sinks point at the same path as the
2812        // bad sink (an existing directory). Their sink-open also errors
2813        // out — but we still verify the *abort* path by counting how many
2814        // tasks make it past spawn before stop fires. The strict invariant
2815        // we assert: the bad row's failure is the first one observed.
2816        let yaml = format!(
2817            r#"version: 1
2818pipeline:
2819  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2820  sink:   {{ type: jsonl, config: {{ path: {bad_dir} }} }}
2821matrix:
2822  - id: bad
2823  - id: good_a
2824  - id: good_b
2825execution:
2826  max_concurrent: 3
2827  on_error: stop
2828"#,
2829            good_csv = good_csv.display(),
2830            bad_dir = bad_sink_dir.display(),
2831        );
2832        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2833        let nodes = expand(&cfg).unwrap();
2834        let summary = run_expanded(
2835            nodes,
2836            ExecuteOptions {
2837                pipeline_name: "stop_parallel".into(),
2838                run_id: None,
2839                execution: cfg.execution.clone(),
2840                dry_run: false,
2841                limit: None,
2842                state_path_override: None,
2843                shard: None,
2844                auth: Default::default(),
2845                clock: chrono::Utc::now().fixed_offset(),
2846                cancel: None,
2847                resilience: None,
2848                sla: None,
2849                #[cfg(feature = "lineage")]
2850                lineage: None,
2851                #[cfg(feature = "lineage")]
2852                lineage_cfg: None,
2853                #[cfg(feature = "notify")]
2854                notifier: None,
2855                #[cfg(feature = "catalog")]
2856                catalog: None,
2857            },
2858        )
2859        .await
2860        .unwrap();
2861
2862        // First-observed failure halts the run. The first outcome in the
2863        // summary is guaranteed to be a failure (other tasks either fail
2864        // too or get cancelled — both cases never push a *success* outcome
2865        // first because every sink in this matrix is configured to fail).
2866        assert!(
2867            summary.had_failures(),
2868            "summary should record at least one failure: {summary:?}"
2869        );
2870        assert!(
2871            summary.invocations[0].error.is_some(),
2872            "first outcome must be the failure that triggered stop: {summary:?}"
2873        );
2874        // No invocation should report `records_written > 0` — every sink is
2875        // bad. (Catches a regression where abort_all somehow let a task
2876        // bypass its broken sink.)
2877        for inv in &summary.invocations {
2878            assert_eq!(inv.records_written, 0, "no records should land: {inv:?}");
2879        }
2880    }
2881
2882    #[tokio::test]
2883    async fn on_error_continue_skips_failed_subtree_only() {
2884        // Two roots: one fails. The good one's invocation still completes.
2885        let dir = tempfile::tempdir().unwrap();
2886        let good_csv = dir.path().join("good.csv");
2887        std::fs::write(&good_csv, "x\n1\n").unwrap();
2888        let good_out = dir.path().join("good.jsonl");
2889
2890        let yaml = format!(
2891            r#"version: 1
2892pipeline:
2893  source: {{ type: csv, config: {{ path: {good_csv} }} }}
2894  sink:   {{ type: jsonl, config: {{ path: {good_out} }} }}
2895matrix:
2896  - id: bad
2897    sink: {{ config: {{ path: {bad_dir} }} }}
2898  - id: good
2899"#,
2900            good_csv = good_csv.display(),
2901            good_out = good_out.display(),
2902            bad_dir = dir.path().display(),
2903        );
2904        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
2905        let nodes = expand(&cfg).unwrap();
2906        let summary = run_expanded(
2907            nodes,
2908            ExecuteOptions {
2909                pipeline_name: "continuetest".into(),
2910                run_id: None,
2911                execution: None,
2912                dry_run: false,
2913                limit: None,
2914                state_path_override: None,
2915                shard: None,
2916                auth: Default::default(),
2917                clock: chrono::Utc::now().fixed_offset(),
2918                cancel: None,
2919                resilience: None,
2920                sla: None,
2921                #[cfg(feature = "lineage")]
2922                lineage: None,
2923                #[cfg(feature = "lineage")]
2924                lineage_cfg: None,
2925                #[cfg(feature = "notify")]
2926                notifier: None,
2927                #[cfg(feature = "catalog")]
2928                catalog: None,
2929            },
2930        )
2931        .await
2932        .unwrap();
2933        assert_eq!(summary.invocations.len(), 2);
2934        assert_eq!(summary.failure_count(), 1);
2935        let good_outcome = summary
2936            .invocations
2937            .iter()
2938            .find(|i| i.row_id == "good")
2939            .unwrap();
2940        assert!(good_outcome.error.is_none());
2941    }
2942
2943    // ── projection helpers (#160) ─────────────────────────────────────────────
2944
2945    #[test]
2946    fn split_path_splits_on_dots() {
2947        assert_eq!(split_path("id"), vec!["id".to_string()]);
2948        assert_eq!(
2949            split_path("user.name"),
2950            vec!["user".to_string(), "name".to_string()]
2951        );
2952    }
2953
2954    #[test]
2955    fn minimal_paths_drops_descendants_of_kept_ancestors() {
2956        let paths = vec![
2957            vec!["user".into(), "name".into()],
2958            vec!["user".into()],
2959            vec!["id".into()],
2960            vec!["id".into()],
2961        ];
2962        let min = minimal_paths(paths);
2963        assert!(min.contains(&vec!["user".to_string()]));
2964        assert!(min.contains(&vec!["id".to_string()]));
2965        assert!(
2966            !min.contains(&vec!["user".to_string(), "name".to_string()]),
2967            "user.name must be dropped — covered by user"
2968        );
2969        assert_eq!(min.len(), 2);
2970    }
2971
2972    #[test]
2973    fn project_full_clones_whole_record() {
2974        let r = json!({"a": 1, "b": {"c": 2}});
2975        assert_eq!(project_record(&r, &Projection::Full), r);
2976    }
2977
2978    #[test]
2979    fn project_keeps_only_referenced_paths() {
2980        let r = json!({"id": 7, "user": {"name": "a", "age": 3}, "blob": "<huge>"});
2981        let p = Projection::Paths(vec![vec!["id".into()], vec!["user".into(), "name".into()]]);
2982        let got = project_record(&r, &p);
2983        assert_eq!(got, json!({"id": 7, "user": {"name": "a"}}));
2984        assert!(got.get("blob").is_none());
2985        assert!(got["user"].get("age").is_none());
2986    }
2987
2988    #[test]
2989    fn project_array_index_path_resolves_same_as_original() {
2990        let r = json!({"tags": ["x", "y", "z"]});
2991        let p = Projection::Paths(vec![vec!["tags".into(), "0".into()]]);
2992        let got = project_record(&r, &p);
2993        assert_eq!(got, json!({"tags": {"0": "x"}}));
2994        assert_eq!(resolve_parent_key(&got, "tags.0"), Some(json!("x")));
2995        assert_eq!(
2996            resolve_parent_key(&got, "tags.0"),
2997            resolve_parent_key(&r, "tags.0"),
2998            "reduced tree must resolve the same value as the original"
2999        );
3000    }
3001
3002    #[test]
3003    fn project_numeric_object_key_resolves_same_as_original() {
3004        // A numeric segment can address an OBJECT key in the original (not an
3005        // array index). The reduced all-objects tree stores it under the same
3006        // key, so resolution still matches — the parity property the design
3007        // relies on, distinct from the array-index case above.
3008        let r = json!({"data": {"0": "x", "1": "y"}});
3009        let p = Projection::Paths(vec![vec!["data".into(), "0".into()]]);
3010        let got = project_record(&r, &p);
3011        assert_eq!(got, json!({"data": {"0": "x"}}));
3012        assert_eq!(
3013            resolve_parent_key(&got, "data.0"),
3014            resolve_parent_key(&r, "data.0"),
3015            "numeric object-key path must resolve identically on the reduced tree"
3016        );
3017    }
3018
3019    #[test]
3020    fn project_missing_path_is_omitted() {
3021        let r = json!({"id": 1});
3022        let p = Projection::Paths(vec![vec!["nope".into()]]);
3023        assert_eq!(project_record(&r, &p), json!({}));
3024    }
3025
3026    #[test]
3027    fn build_projections_unions_parent_key_and_refs() {
3028        use crate::config::ConnectorSpec;
3029        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3030
3031        fn child(id: &str, parent: &str, parent_key: &str, refs: &[(&str, &str)]) -> ExpandedNode {
3032            ExpandedNode {
3033                id: id.into(),
3034                row_index: 0,
3035                role: NodeRole::Child {
3036                    parent_id: parent.into(),
3037                    parent_key: parent_key.into(),
3038                },
3039                source: ConnectorSpec {
3040                    kind: "csv".into(),
3041                    config: json!({}),
3042                    transforms: None,
3043                    inherit_transforms: true,
3044                    status: None,
3045                    tags: Vec::new(),
3046                    complete_for: None,
3047                },
3048                sink: ConnectorSpec {
3049                    kind: "jsonl".into(),
3050                    config: json!({}),
3051                    transforms: None,
3052                    inherit_transforms: true,
3053                    status: None,
3054                    tags: Vec::new(),
3055                    complete_for: None,
3056                },
3057                transforms: Vec::new(),
3058                state: None,
3059                dlq: None,
3060                delivery: faucet_core::DeliveryMode::AtLeastOnce,
3061                delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3062                #[cfg(feature = "quality")]
3063                quality: None,
3064                #[cfg(feature = "contract")]
3065                contract: None,
3066                #[cfg(feature = "masking")]
3067                masking: None,
3068                sink_ref: "default".into(),
3069                schema: None,
3070                depends_on: Vec::new(),
3071                status: crate::config::SourceStatus::Active,
3072                tags: Vec::new(),
3073                cleanup_scope: None,
3074                deferred_refs: refs
3075                    .iter()
3076                    .map(|(rid, p)| DeferredRef {
3077                        referenced_id: (*rid).into(),
3078                        dotted_path: (*p).into(),
3079                        token: format!("${{{rid}.{p}}}"),
3080                    })
3081                    .collect(),
3082                source_override: None,
3083            }
3084        }
3085
3086        let c1 = child("c1", "p", "id", &[("p", "user.name")]);
3087        let c2 = child("c2", "p", "id", &[("p", "email"), ("q", "x")]);
3088        let nodes_by_id = HashMap::from([("c1".to_string(), c1), ("c2".to_string(), c2)]);
3089        let children_of =
3090            HashMap::from([("p".to_string(), vec!["c1".to_string(), "c2".to_string()])]);
3091
3092        let projs = build_projections(&nodes_by_id, &children_of);
3093        let p = projs.get("p").expect("projection for p");
3094        match &**p {
3095            Projection::Paths(paths) => {
3096                assert!(paths.contains(&vec!["id".to_string()]));
3097                assert!(paths.contains(&vec!["user".to_string(), "name".to_string()]));
3098                assert!(paths.contains(&vec!["email".to_string()]));
3099                assert!(
3100                    !paths.iter().any(|p| p == &vec!["x".to_string()]),
3101                    "a ref to a different parent must not be captured under p"
3102                );
3103            }
3104            Projection::Full => panic!("expected Paths, got Full"),
3105        }
3106    }
3107
3108    #[test]
3109    fn build_projections_whole_record_ref_is_full() {
3110        use crate::config::ConnectorSpec;
3111        use crate::expand::{DeferredRef, ExpandedNode, NodeRole};
3112        let c = ExpandedNode {
3113            id: "c".into(),
3114            row_index: 0,
3115            role: NodeRole::Child {
3116                parent_id: "p".into(),
3117                parent_key: "id".into(),
3118            },
3119            source: ConnectorSpec {
3120                kind: "csv".into(),
3121                config: json!({}),
3122                transforms: None,
3123                inherit_transforms: true,
3124                status: None,
3125                tags: Vec::new(),
3126                complete_for: None,
3127            },
3128            sink: ConnectorSpec {
3129                kind: "jsonl".into(),
3130                config: json!({}),
3131                transforms: None,
3132                inherit_transforms: true,
3133                status: None,
3134                tags: Vec::new(),
3135                complete_for: None,
3136            },
3137            transforms: Vec::new(),
3138            state: None,
3139            dlq: None,
3140            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3141            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3142            #[cfg(feature = "quality")]
3143            quality: None,
3144            #[cfg(feature = "contract")]
3145            contract: None,
3146            #[cfg(feature = "masking")]
3147            masking: None,
3148            sink_ref: "default".into(),
3149            schema: None,
3150            depends_on: Vec::new(),
3151            status: crate::config::SourceStatus::Active,
3152            tags: Vec::new(),
3153            cleanup_scope: None,
3154            deferred_refs: vec![DeferredRef {
3155                referenced_id: "p".into(),
3156                dotted_path: "".into(),
3157                token: "${p}".into(),
3158            }],
3159            source_override: None,
3160        };
3161        let nodes_by_id = HashMap::from([("c".to_string(), c)]);
3162        let children_of = HashMap::from([("p".to_string(), vec!["c".to_string()])]);
3163        let projs = build_projections(&nodes_by_id, &children_of);
3164        assert!(matches!(&**projs.get("p").unwrap(), Projection::Full));
3165    }
3166
3167    /// Helper: minimal `ExecuteOptions` with all optional knobs cleared.
3168    fn opts(name: &str) -> ExecuteOptions {
3169        ExecuteOptions {
3170            pipeline_name: name.into(),
3171            run_id: None,
3172            execution: None,
3173            dry_run: false,
3174            limit: None,
3175            state_path_override: None,
3176            shard: None,
3177            auth: Default::default(),
3178            clock: chrono::Utc::now().fixed_offset(),
3179            cancel: None,
3180            resilience: None,
3181            sla: None,
3182            #[cfg(feature = "lineage")]
3183            lineage: None,
3184            #[cfg(feature = "lineage")]
3185            lineage_cfg: None,
3186            #[cfg(feature = "notify")]
3187            notifier: None,
3188            #[cfg(feature = "catalog")]
3189            catalog: None,
3190        }
3191    }
3192
3193    #[tokio::test]
3194    async fn dry_run_counts_records_without_writing_sink_file() {
3195        // `--dry-run` swaps the real sink for a CountingSink — records flow but
3196        // no output file is produced.
3197        let dir = tempfile::tempdir().unwrap();
3198        let input = dir.path().join("in.csv");
3199        let output = dir.path().join("out.jsonl");
3200        std::fs::write(&input, "name\nalice\nbob\ncarol\n").unwrap();
3201        let cfg = cfg_csv_to_jsonl(&input, &output);
3202        let nodes = expand(&cfg).unwrap();
3203        let mut o = opts("dry");
3204        o.dry_run = true;
3205        let summary = run_expanded(nodes, o).await.unwrap();
3206        assert_eq!(summary.invocations.len(), 1);
3207        assert_eq!(summary.invocations[0].records_written, 3);
3208        assert!(!summary.had_failures());
3209        assert!(
3210            !output.exists(),
3211            "dry-run must not create the real sink file"
3212        );
3213    }
3214
3215    #[tokio::test]
3216    async fn read_only_state_store_drops_writes_keeps_reads() {
3217        // #321 H1: reads pass through; put/delete are dropped so a preview never
3218        // mutates the durable bookmark.
3219        let inner = Arc::new(faucet_core::MemoryStateStore::new()) as Arc<dyn StateStore>;
3220        inner.put("k", &json!("v0")).await.unwrap();
3221        let ro = ReadOnlyStateStore {
3222            inner: inner.clone(),
3223        };
3224        assert_eq!(ro.get("k").await.unwrap(), Some(json!("v0")));
3225        // A write must be a no-op — the inner store keeps its original value.
3226        ro.put("k", &json!("advanced")).await.unwrap();
3227        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3228        // A delete must be a no-op too.
3229        ro.delete("k").await.unwrap();
3230        assert_eq!(inner.get("k").await.unwrap(), Some(json!("v0")));
3231    }
3232
3233    #[tokio::test]
3234    async fn dry_run_with_state_does_not_persist_bookmark() {
3235        // #321 H1: a `--dry-run` with a durable state store must not advance the
3236        // persisted bookmark. Pre-seed a bookmark file, run dry, and confirm it is
3237        // byte-for-byte unchanged (the ReadOnlyStateStore wrapper drops writes).
3238        let dir = tempfile::tempdir().unwrap();
3239        let input = dir.path().join("in.csv");
3240        let output = dir.path().join("out.jsonl");
3241        let state_dir = dir.path().join("state");
3242        std::fs::create_dir_all(&state_dir).unwrap();
3243        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3244        let cfg = cfg_csv_to_jsonl(&input, &output);
3245        let nodes = expand(&cfg).unwrap();
3246        let mut o = opts("drystate");
3247        o.dry_run = true;
3248        o.state_path_override = Some(state_dir.clone());
3249        let summary = run_expanded(nodes, o).await.unwrap();
3250        assert!(!summary.had_failures());
3251        assert!(!output.exists(), "dry-run must not write the sink file");
3252        // The state store is wrapped read-only under dry-run, so no bookmark
3253        // file is ever persisted — the state dir stays empty.
3254        let persisted: Vec<_> = std::fs::read_dir(&state_dir)
3255            .unwrap()
3256            .filter_map(Result::ok)
3257            .collect();
3258        assert!(
3259            persisted.is_empty(),
3260            "dry-run must not persist any bookmark file, found: {persisted:?}"
3261        );
3262    }
3263
3264    #[tokio::test]
3265    async fn limit_caps_records_written_across_the_run() {
3266        // `--limit N` wraps the sink so only the first N records land.
3267        let dir = tempfile::tempdir().unwrap();
3268        let input = dir.path().join("in.csv");
3269        let output = dir.path().join("out.jsonl");
3270        std::fs::write(&input, "name\na\nb\nc\nd\ne\n").unwrap();
3271        let cfg = cfg_csv_to_jsonl(&input, &output);
3272        let nodes = expand(&cfg).unwrap();
3273        let mut o = opts("lim");
3274        o.limit = Some(2);
3275        let summary = run_expanded(nodes, o).await.unwrap();
3276        assert_eq!(summary.invocations[0].records_written, 2);
3277        let body = std::fs::read_to_string(&output).unwrap();
3278        assert_eq!(body.lines().count(), 2, "only the first 2 rows are written");
3279    }
3280
3281    #[tokio::test]
3282    async fn duplicate_state_key_among_siblings_is_rejected() {
3283        // Two parent records whose `parent_key` value collides (both id="dup")
3284        // produce two child units with the SAME state key. With state
3285        // configured, that collision must surface as DuplicateStateKey.
3286        let dir = tempfile::tempdir().unwrap();
3287        let parent_csv = dir.path().join("parents.csv");
3288        let child_csv = dir.path().join("child.csv");
3289        // Both rows share id="dup" — the per-child state-key suffix collides.
3290        std::fs::write(&parent_csv, "id\ndup\ndup\n").unwrap();
3291        std::fs::write(&child_csv, "x\nA\n").unwrap();
3292        let parent_out = dir.path().join("parents.jsonl");
3293        let child_out = dir.path().join("child.jsonl");
3294        let yaml = format!(
3295            r#"version: 1
3296pipeline:
3297  source: {{ type: csv, config: {{ path: {parent} }} }}
3298  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3299  state:  {{ type: memory }}
3300matrix:
3301  - id: parents
3302  - id: child
3303    parent: parents
3304    source: {{ config: {{ path: {child} }} }}
3305    sink:   {{ config: {{ path: {child_out} }} }}
3306"#,
3307            parent = parent_csv.display(),
3308            parent_out = parent_out.display(),
3309            child = child_csv.display(),
3310            child_out = child_out.display(),
3311        );
3312        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3313        let nodes = expand(&cfg).unwrap();
3314        let err = run_expanded(nodes, opts("dupkey"))
3315            .await
3316            .expect_err("colliding sibling state keys must be rejected");
3317        match err {
3318            CliError::DuplicateStateKey { id, state_key } => {
3319                assert_eq!(id, "child");
3320                assert_eq!(state_key, "dupkey::child::dup");
3321            }
3322            other => panic!("expected DuplicateStateKey, got {other:?}"),
3323        }
3324    }
3325
3326    #[tokio::test]
3327    async fn state_path_override_writes_bookmark_file() {
3328        // `--state-path` with a node that has no `state:` block wires a
3329        // FileStateStore at the override path; running it should create the
3330        // bookmark file (REST-less csv source still opts into state via the
3331        // override).
3332        let dir = tempfile::tempdir().unwrap();
3333        let input = dir.path().join("in.csv");
3334        let output = dir.path().join("out.jsonl");
3335        let state_dir = dir.path().join("state");
3336        std::fs::write(&input, "name\nalice\n").unwrap();
3337        let cfg = cfg_csv_to_jsonl(&input, &output);
3338        let nodes = expand(&cfg).unwrap();
3339        let mut o = opts("statepath");
3340        o.state_path_override = Some(state_dir.clone());
3341        let summary = run_expanded(nodes, o).await.unwrap();
3342        assert!(!summary.had_failures());
3343        // The csv source has no natural state key, so the StateKeyOverride wrap
3344        // is skipped — but build_state_for_node still constructs the store.
3345        // The run completing without error exercises the (None, Some(path)) arm.
3346        assert_eq!(summary.invocations[0].records_written, 1);
3347    }
3348
3349    #[tokio::test]
3350    async fn build_dlq_config_maps_spec_fields() {
3351        use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec};
3352        let dir = tempfile::tempdir().unwrap();
3353        let dlq_out = dir.path().join("dlq.jsonl");
3354        let spec = DlqSpec {
3355            sink: ConnectorSpec {
3356                kind: "jsonl".into(),
3357                config: json!({ "path": dlq_out.to_str().unwrap() }),
3358                transforms: None,
3359                inherit_transforms: true,
3360                status: None,
3361                tags: Vec::new(),
3362                complete_for: None,
3363            },
3364            on_batch_error: OnBatchErrorSpec::DlqAll,
3365            max_failures_per_page: Some(7),
3366            max_failures_total: Some(42),
3367            include_original_payload: false,
3368        };
3369        let cfg = build_dlq_config(&spec).await.unwrap();
3370        assert!(matches!(cfg.on_batch_error, OnBatchError::DlqAll));
3371        assert_eq!(cfg.max_failures_per_page, Some(7));
3372        assert_eq!(cfg.max_failures_total, Some(42));
3373        assert!(!cfg.include_original_payload);
3374    }
3375
3376    #[tokio::test]
3377    async fn build_state_for_node_arms() {
3378        let dir = tempfile::tempdir().unwrap();
3379
3380        // (None, None) → no store.
3381        let node = stub_node(None);
3382        assert!(build_state_for_node(&node, None).await.unwrap().is_none());
3383
3384        // (None, Some(path)) → FileStateStore from override.
3385        let p = dir.path().join("s1");
3386        assert!(
3387            build_state_for_node(&node, Some(&p))
3388                .await
3389                .unwrap()
3390                .is_some()
3391        );
3392
3393        // (Some(memory spec), None) → built from spec.
3394        let node_mem = stub_node(Some(crate::config::StateStoreSpec {
3395            kind: "memory".into(),
3396            config: json!({}),
3397        }));
3398        assert!(
3399            build_state_for_node(&node_mem, None)
3400                .await
3401                .unwrap()
3402                .is_some()
3403        );
3404
3405        // (Some(file spec), Some(path)) → file backend uses the override path.
3406        let node_file = stub_node(Some(crate::config::StateStoreSpec {
3407            kind: "file".into(),
3408            config: json!({ "path": dir.path().join("orig").to_str().unwrap() }),
3409        }));
3410        let p2 = dir.path().join("override2");
3411        assert!(
3412            build_state_for_node(&node_file, Some(&p2))
3413                .await
3414                .unwrap()
3415                .is_some()
3416        );
3417
3418        // (Some(memory spec), Some(path)) → non-file backend ignores override,
3419        // still builds from spec.
3420        let node_mem2 = stub_node(Some(crate::config::StateStoreSpec {
3421            kind: "memory".into(),
3422            config: json!({}),
3423        }));
3424        let p3 = dir.path().join("override3");
3425        assert!(
3426            build_state_for_node(&node_mem2, Some(&p3))
3427                .await
3428                .unwrap()
3429                .is_some()
3430        );
3431    }
3432
3433    /// Build a minimal root `ExpandedNode` carrying only an (optional) state spec.
3434    fn stub_node(state: Option<crate::config::StateStoreSpec>) -> ExpandedNode {
3435        use crate::config::ConnectorSpec;
3436        ExpandedNode {
3437            id: "n".into(),
3438            row_index: 0,
3439            role: NodeRole::Root,
3440            source: ConnectorSpec {
3441                kind: "csv".into(),
3442                config: json!({}),
3443                transforms: None,
3444                inherit_transforms: true,
3445                status: None,
3446                tags: Vec::new(),
3447                complete_for: None,
3448            },
3449            sink: ConnectorSpec {
3450                kind: "jsonl".into(),
3451                config: json!({}),
3452                transforms: None,
3453                inherit_transforms: true,
3454                status: None,
3455                tags: Vec::new(),
3456                complete_for: None,
3457            },
3458            transforms: Vec::new(),
3459            state,
3460            dlq: None,
3461            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3462            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3463            #[cfg(feature = "quality")]
3464            quality: None,
3465            #[cfg(feature = "contract")]
3466            contract: None,
3467            #[cfg(feature = "masking")]
3468            masking: None,
3469            sink_ref: "default".into(),
3470            schema: None,
3471            depends_on: Vec::new(),
3472            status: crate::config::SourceStatus::Active,
3473            tags: Vec::new(),
3474            cleanup_scope: None,
3475            deferred_refs: Vec::new(),
3476            source_override: None,
3477        }
3478    }
3479
3480    #[tokio::test]
3481    async fn state_key_override_delegates_and_overrides_key() {
3482        // StateKeyOverride forwards fetch/bookmark to inner and reports its own key.
3483        let dir = tempfile::tempdir().unwrap();
3484        let input = dir.path().join("in.csv");
3485        std::fs::write(&input, "name\nz\n").unwrap();
3486        let inner = build_source(
3487            "csv",
3488            json!({"path": input.to_str().unwrap()}),
3489            &AuthCatalog::new(),
3490            None,
3491        )
3492        .await
3493        .unwrap();
3494        // The wrapped name must match the inner source's, whatever it reports.
3495        let inner_name = inner.connector_name();
3496        let ov = StateKeyOverride {
3497            inner,
3498            key: "my::custom::key".into(),
3499        };
3500        assert_eq!(ov.state_key(), Some("my::custom::key".to_string()));
3501        assert_eq!(ov.connector_name(), inner_name);
3502        let rows = ov.fetch_with_context(&HashMap::new()).await.unwrap();
3503        assert_eq!(rows.len(), 1);
3504        // apply_start_bookmark delegates without error (csv ignores it).
3505        ov.apply_start_bookmark(json!({"any": "bookmark"}))
3506            .await
3507            .unwrap();
3508        // Capability passthroughs (csv defaults).
3509        assert!(!ov.supports_exactly_once());
3510        assert_eq!(
3511            ov.replay_guarantee(),
3512            faucet_core::ReplayGuarantee::NonDeterministic
3513        );
3514        assert_eq!(ov.capture_resume_position().await.unwrap(), None);
3515    }
3516
3517    #[tokio::test]
3518    async fn state_key_override_forwards_native_stream_pages() {
3519        // The wrap must preserve the inner source's NATIVE page stream —
3520        // per-page bookmarks included. Without the `stream_pages` forward, the
3521        // trait's buffering default kicks in and collapses everything into
3522        // final-page-bookmark-only pages (losing CDC per-transaction
3523        // durability and exactly-once per-page tokens).
3524        struct PerPageBookmarkSource;
3525        #[async_trait]
3526        impl Source for PerPageBookmarkSource {
3527            async fn fetch_with_context(
3528                &self,
3529                _ctx: &HashMap<String, Value>,
3530            ) -> Result<Vec<Value>, FaucetError> {
3531                Ok(vec![json!({"id": 1}), json!({"id": 2})])
3532            }
3533            fn stream_pages<'a>(
3534                &'a self,
3535                _ctx: &'a HashMap<String, Value>,
3536                _batch_size: usize,
3537            ) -> std::pin::Pin<
3538                Box<
3539                    dyn faucet_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
3540                        + Send
3541                        + 'a,
3542                >,
3543            > {
3544                Box::pin(faucet_core::async_stream::try_stream! {
3545                    yield faucet_core::StreamPage {
3546                        records: vec![json!({"id": 1})],
3547                        bookmark: Some(json!("bm-1")),
3548                    };
3549                    yield faucet_core::StreamPage {
3550                        records: vec![json!({"id": 2})],
3551                        bookmark: Some(json!("bm-2")),
3552                    };
3553                })
3554            }
3555            fn state_key(&self) -> Option<String> {
3556                Some("native".into())
3557            }
3558        }
3559
3560        use futures::StreamExt;
3561        let ov = StateKeyOverride {
3562            inner: Box::new(PerPageBookmarkSource),
3563            key: "override".into(),
3564        };
3565        let ctx = HashMap::new();
3566        let pages: Vec<_> = ov
3567            .stream_pages(&ctx, 1000)
3568            .collect::<Vec<_>>()
3569            .await
3570            .into_iter()
3571            .collect::<Result<Vec<_>, _>>()
3572            .unwrap();
3573        assert_eq!(pages.len(), 2, "native page boundaries survive the wrap");
3574        assert_eq!(pages[0].bookmark, Some(json!("bm-1")));
3575        assert_eq!(pages[1].bookmark, Some(json!("bm-2")));
3576    }
3577
3578    #[tokio::test]
3579    async fn capturing_sink_forwards_capabilities_and_captures_idempotent_writes() {
3580        struct IdemSink;
3581        #[async_trait]
3582        impl Sink for IdemSink {
3583            async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3584                Ok(records.len())
3585            }
3586            fn connector_name(&self) -> &'static str {
3587                "idem"
3588            }
3589            fn supports_idempotent_writes(&self) -> bool {
3590                true
3591            }
3592            fn dedups_by_key(&self) -> bool {
3593                true
3594            }
3595            fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
3596                &[
3597                    faucet_core::WriteMode::Append,
3598                    faucet_core::WriteMode::Upsert,
3599                ]
3600            }
3601            async fn write_batch_idempotent(
3602                &self,
3603                records: &[Value],
3604                _scope: &str,
3605                _token: &str,
3606            ) -> Result<usize, FaucetError> {
3607                Ok(records.len())
3608            }
3609            async fn last_committed_token(
3610                &self,
3611                _scope: &str,
3612            ) -> Result<Option<String>, FaucetError> {
3613                Ok(Some("tok".into()))
3614            }
3615        }
3616
3617        let captured = Arc::new(Mutex::new(Vec::new()));
3618        let sink = CapturingSink::wrap(
3619            Box::new(IdemSink),
3620            Arc::clone(&captured),
3621            Arc::new(Projection::Full),
3622        );
3623        // Capability passthroughs: a parent row feeding children keeps the
3624        // inner sink's delivery semantics.
3625        assert!(sink.supports_idempotent_writes());
3626        assert!(sink.dedups_by_key());
3627        assert_eq!(
3628            sink.sink_guarantee(),
3629            faucet_core::SinkGuarantee::AtomicWatermark
3630        );
3631        assert!(
3632            sink.supported_write_modes()
3633                .contains(&faucet_core::WriteMode::Upsert)
3634        );
3635        assert_eq!(
3636            sink.last_committed_token("k").await.unwrap(),
3637            Some("tok".into())
3638        );
3639        assert_eq!(sink.current_schema().await.unwrap(), None);
3640        assert!(!sink.supports_schema_evolution());
3641        // Idempotent writes are captured for child fan-out like plain writes.
3642        let n = sink
3643            .write_batch_idempotent(&[json!({"id": 7})], "k", "t")
3644            .await
3645            .unwrap();
3646        assert_eq!(n, 1);
3647        assert_eq!(*captured.lock().await, vec![json!({"id": 7})]);
3648    }
3649
3650    #[tokio::test]
3651    async fn orphaned_child_surfaces_executor_deadlock() {
3652        // A child node whose parent id is never present among the nodes can
3653        // never become ready. `expand` would reject this, but a hand-built node
3654        // list exercises the executor's own deadlock guard (lines 227-233).
3655        use crate::config::ConnectorSpec;
3656        let orphan = ExpandedNode {
3657            id: "orphan".into(),
3658            row_index: 0,
3659            role: NodeRole::Child {
3660                parent_id: "missing-parent".into(),
3661                parent_key: "id".into(),
3662            },
3663            source: ConnectorSpec {
3664                kind: "csv".into(),
3665                config: json!({}),
3666                transforms: None,
3667                inherit_transforms: true,
3668                status: None,
3669                tags: Vec::new(),
3670                complete_for: None,
3671            },
3672            sink: ConnectorSpec {
3673                kind: "jsonl".into(),
3674                config: json!({}),
3675                transforms: None,
3676                inherit_transforms: true,
3677                status: None,
3678                tags: Vec::new(),
3679                complete_for: None,
3680            },
3681            transforms: Vec::new(),
3682            state: None,
3683            dlq: None,
3684            delivery: faucet_core::DeliveryMode::AtLeastOnce,
3685            delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
3686            #[cfg(feature = "quality")]
3687            quality: None,
3688            #[cfg(feature = "contract")]
3689            contract: None,
3690            #[cfg(feature = "masking")]
3691            masking: None,
3692            sink_ref: "default".into(),
3693            schema: None,
3694            depends_on: Vec::new(),
3695            status: crate::config::SourceStatus::Active,
3696            tags: Vec::new(),
3697            cleanup_scope: None,
3698            deferred_refs: Vec::new(),
3699            source_override: None,
3700        };
3701        let err = run_expanded(vec![orphan], opts("deadlock"))
3702            .await
3703            .expect_err("an orphaned child must surface as an executor deadlock");
3704        match err {
3705            CliError::Internal(msg) => {
3706                assert!(msg.contains("executor deadlock"), "{msg}");
3707                assert!(msg.contains("orphan"), "{msg}");
3708            }
3709            other => panic!("expected Internal deadlock error, got {other:?}"),
3710        }
3711    }
3712
3713    #[test]
3714    fn value_to_string_brief_unquotes_strings_only() {
3715        assert_eq!(value_to_string_brief(&json!("hello")), "hello");
3716        assert_eq!(value_to_string_brief(&json!(42)), "42");
3717        assert_eq!(value_to_string_brief(&json!(true)), "true");
3718        assert_eq!(value_to_string_brief(&json!(null)), "null");
3719        assert_eq!(value_to_string_brief(&json!({"a": 1})), "{\"a\":1}");
3720    }
3721
3722    #[test]
3723    fn build_state_key_with_and_without_parent() {
3724        assert_eq!(build_state_key("pipe", "row", None), "pipe::row");
3725        assert_eq!(build_state_key("pipe", "row", Some("k")), "pipe::row::k");
3726    }
3727
3728    #[test]
3729    fn resolve_parent_key_walks_objects_arrays_and_misses() {
3730        let r = json!({"user": {"name": "ada"}, "tags": ["x", "y"]});
3731        assert_eq!(resolve_parent_key(&r, "user.name"), Some(json!("ada")));
3732        assert_eq!(resolve_parent_key(&r, "tags.1"), Some(json!("y")));
3733        // Missing key → None.
3734        assert_eq!(resolve_parent_key(&r, "user.age"), None);
3735        // Descending into a scalar → None.
3736        assert_eq!(resolve_parent_key(&r, "user.name.deep"), None);
3737        // Non-numeric array index → None.
3738        assert_eq!(resolve_parent_key(&r, "tags.notanindex"), None);
3739    }
3740
3741    #[tokio::test]
3742    async fn cooperative_cancel_returns_partial_ok() {
3743        // A pre-cancelled token makes the run stop at the first page boundary
3744        // and flush — returning Ok with a partial (possibly empty) result
3745        // rather than erroring. Covers the cancel-threading path.
3746        let dir = tempfile::tempdir().unwrap();
3747        let input = dir.path().join("in.csv");
3748        let output = dir.path().join("out.jsonl");
3749        std::fs::write(&input, "name\nalice\nbob\n").unwrap();
3750        let cfg = cfg_csv_to_jsonl(&input, &output);
3751        let nodes = expand(&cfg).unwrap();
3752        let token = CancellationToken::new();
3753        token.cancel(); // already cancelled before the run starts
3754        let mut o = opts("cancel");
3755        o.cancel = Some(token);
3756        let summary = run_expanded(nodes, o).await.unwrap();
3757        // The single root invocation completes (Ok) — it is not reported as a
3758        // failure even though it was cancelled.
3759        assert_eq!(summary.invocations.len(), 1);
3760        assert!(
3761            !summary.had_failures(),
3762            "a cooperatively-cancelled run is Ok, not a failure: {summary:?}"
3763        );
3764    }
3765
3766    #[tokio::test]
3767    async fn fanout_projects_away_unreferenced_parent_fields() {
3768        // Parent CSV has id + a big unreferenced "payload" column. The child only
3769        // references ${parents.id} (in its output path), so projection keeps "id"
3770        // and the parent_key but drops "payload" — and fan-out still works.
3771        let dir = tempfile::tempdir().unwrap();
3772        let parent_csv = dir.path().join("parents.csv");
3773        let child_csv = dir.path().join("child.csv");
3774        std::fs::write(&parent_csv, "id,payload\n1,aaaaaaaaaa\n2,bbbbbbbbbb\n").unwrap();
3775        std::fs::write(&child_csv, "x\nA\n").unwrap();
3776        let parent_out = dir.path().join("parents.jsonl");
3777        let child_out_pattern = dir.path().join("child-${parents.id}.jsonl");
3778
3779        let yaml = format!(
3780            r#"version: 1
3781pipeline:
3782  source: {{ type: csv, config: {{ path: {parent} }} }}
3783  sink:   {{ type: jsonl, config: {{ path: {parent_out} }} }}
3784matrix:
3785  - id: parents
3786  - id: child
3787    parent: parents
3788    source: {{ config: {{ path: {child} }} }}
3789    sink:   {{ config: {{ path: "{child_out}" }} }}
3790"#,
3791            parent = parent_csv.display(),
3792            parent_out = parent_out.display(),
3793            child = child_csv.display(),
3794            child_out = child_out_pattern.display(),
3795        );
3796        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
3797        let nodes = expand(&cfg).unwrap();
3798        let summary = run_expanded(
3799            nodes,
3800            ExecuteOptions {
3801                pipeline_name: "projtest".into(),
3802                run_id: None,
3803                execution: None,
3804                dry_run: false,
3805                limit: None,
3806                state_path_override: None,
3807                shard: None,
3808                auth: Default::default(),
3809                clock: chrono::Utc::now().fixed_offset(),
3810                cancel: None,
3811                resilience: None,
3812                sla: None,
3813                #[cfg(feature = "lineage")]
3814                lineage: None,
3815                #[cfg(feature = "lineage")]
3816                lineage_cfg: None,
3817                #[cfg(feature = "notify")]
3818                notifier: None,
3819                #[cfg(feature = "catalog")]
3820                catalog: None,
3821            },
3822        )
3823        .await
3824        .unwrap();
3825
3826        // 1 parent + 2 child invocations (one per parent record) — cardinality kept.
3827        assert_eq!(summary.invocations.len(), 3, "{summary:?}");
3828        assert!(!summary.had_failures(), "{summary:?}");
3829        // ${parents.id} resolved correctly for each child despite "payload" being projected away.
3830        assert!(dir.path().join("child-1.jsonl").exists());
3831        assert!(dir.path().join("child-2.jsonl").exists());
3832    }
3833}