Skip to main content

rivet/pipeline/
run.rs

1//! **Layer: Coordinator entrypoint** — the `rivet run` orchestrator.
2//!
3//! Single bridge between planning, execution, and persistence/observability.
4//! Owns the multi-export render-mode flags, decides between sequential vs
5//! thread-parallel vs process-parallel, and produces the run aggregate at
6//! the end.
7//!
8//! Lives in its own file so [`crate::pipeline`] (which is read as a facade
9//! by every other module) stays a thin re-export layer rather than a
10//! ~300-LOC orchestrator wrapped in mod-level declarations.
11
12use std::path::Path;
13use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
14
15use crate::config::{Config, ExportConfig};
16use crate::error::Result;
17use crate::state::StateStore;
18
19use super::summary::RunSummary;
20use super::{aggregate, finalize, ipc, job, parallel_children, parent_ui, partition_expand};
21
22/// Per-run configuration flags passed from the CLI to the pipeline.
23///
24/// Replaces the previous pattern of threading 4+ positional `bool` arguments
25/// through `run`, `run_export_job`, and child-process invocations.  Named fields
26/// prevent silent argument transposition (e.g., `validate` and `reconcile`
27/// swapped).
28#[derive(Debug, Clone, Copy)]
29pub struct RunOptions<'a> {
30    pub validate: bool,
31    pub reconcile: bool,
32    pub resume: bool,
33    /// Override safety gates that would otherwise refuse to start the run.
34    ///
35    /// Currently used by ADR-0012 M8 — `--resume` against a prefix whose
36    /// `_SUCCESS` marker is present is refused unless `--force` is given,
37    /// so an operator cannot accidentally re-export over a verified
38    /// dataset.  Other gates may share the same flag in the future
39    /// (per ADR-0013: one `--force`, scoped to whichever gate it overrides).
40    pub force: bool,
41    pub params: Option<&'a std::collections::HashMap<String, String>>,
42}
43
44/// True when the current process is running more than one export in this
45/// `rivet run` invocation (sequential or `--parallel-exports`).  Per-export
46/// renderers (`RunSummary::print`, `ChunkProgress`) read this to switch to
47/// the compact one-line format and to suppress the indicatif chunk bar
48/// respectively, so 15 exports take 15 lines instead of 100+ and threads
49/// don't stack progress bars on top of each other.
50///
51/// Children of `--parallel-export-processes` always have `exports.len() == 1`
52/// in their own process so this flag stays `false` for them; the parent
53/// renders cards itself via `parent_ui`.
54pub(crate) static MULTI_EXPORT_MODE: AtomicBool = AtomicBool::new(false);
55
56/// True only when multiple exports run **concurrently** in the current
57/// process (i.e. `--parallel-exports`, threads).  Used to suppress
58/// per-export `indicatif` chunk progress bars whose terminal writes
59/// otherwise interleave across threads and corrupt each other.
60pub(crate) static MULTI_EXPORT_CONCURRENT: AtomicBool = AtomicBool::new(false);
61
62pub(crate) fn multi_export_mode() -> bool {
63    MULTI_EXPORT_MODE.load(AtomicOrdering::Relaxed)
64}
65
66#[allow(dead_code)] // kept for future renderers; flag is still set in `run` below.
67pub(crate) fn multi_export_concurrent() -> bool {
68    MULTI_EXPORT_CONCURRENT.load(AtomicOrdering::Relaxed)
69}
70
71fn print_json_summary(agg: &crate::state::RunAggregate) {
72    match serde_json::to_string_pretty(agg) {
73        Ok(json) => println!("{json}"),
74        Err(e) => eprintln!(
75            "rivet: error: failed to serialize run summary as JSON: {:#}",
76            e
77        ),
78    }
79}
80
81/// Emit captured child stderr from a parallel run. It's verbose — every child's
82/// full run card — so write it to a timestamped log beside the config and print
83/// a one-line pointer, instead of flooding the console with all N exports'
84/// stderr. Falls back to the inline console dump if the file can't be written.
85fn emit_child_stderr(dump: &str, dir: &Path) {
86    if dump.is_empty() {
87        return;
88    }
89    let name = format!(
90        "rivet-child-stderr-{}.log",
91        chrono::Utc::now().format("%Y%m%dT%H%M%S")
92    );
93    let path = dir.join(name);
94    match std::fs::write(&path, dump) {
95        // stderr, not stdout — stdout may carry the machine-readable `--json`
96        // run summary, which this pointer would otherwise corrupt.
97        Ok(()) => eprintln!(
98            "\n  child stderr (full per-export logs) → {}",
99            path.display()
100        ),
101        Err(e) => {
102            log::warn!(
103                "could not write child stderr to {} ({e}); printing inline",
104                path.display()
105            );
106            use std::io::Write;
107            let mut h = std::io::stderr().lock();
108            let _ = h.write_all(dump.as_bytes());
109            let _ = h.flush();
110        }
111    }
112}
113
114#[allow(clippy::too_many_arguments)] // CLI fan-in; surface stays stable per ADR-0013
115pub fn run(
116    config_path: &str,
117    export_name: Option<&str>,
118    validate: bool,
119    reconcile: bool,
120    resume: bool,
121    force: bool,
122    params: Option<&std::collections::HashMap<String, String>>,
123    parallel_exports_cli: bool,
124    parallel_export_processes_cli: bool,
125    summary_output: Option<&Path>,
126    json_output: bool,
127) -> Result<()> {
128    // F-NEW-B (0.7.5 audit): `--force` is scoped to whichever gate it
129    // overrides (today: the `_SUCCESS`-already-present refusal on
130    // resume).  When the operator passes `--force` without `--resume`,
131    // the flag is a no-op — surface that explicitly so a typo or
132    // copy-paste mistake does not pass silently.
133    if force && !resume {
134        log::warn!(
135            "--force without --resume is a no-op today (force only overrides the resume safety \
136             gate against a destination prefix whose _SUCCESS is already present)"
137        );
138    }
139    let config = Config::load_with_params(config_path, params)?;
140
141    let config_dir = Path::new(config_path)
142        .parent()
143        .unwrap_or(Path::new("."))
144        .to_path_buf();
145
146    let selected: Vec<&ExportConfig> = if let Some(name) = export_name {
147        let e = config
148            .exports
149            .iter()
150            .find(|e| e.name == name)
151            .ok_or_else(|| anyhow::anyhow!("export '{}' not found in config", name))?;
152        vec![e]
153    } else {
154        config.exports.iter().collect()
155    };
156
157    // Value-based partitioning: rewrite any `partition_by` export into one
158    // concrete child export per bucket *before* the run loop. Non-partitioned
159    // exports pass through. The owned vec must outlive the borrowed `exports`
160    // view rebuilt over it, so it is declared in the enclosing scope.
161    let partitioned = partition_expand::any_partitioned(&selected);
162    let expanded_owned: Vec<ExportConfig>;
163    let exports: Vec<&ExportConfig> = if partitioned {
164        expanded_owned = partition_expand::expand_partitioned_exports(
165            &selected,
166            &config.source,
167            &config_dir,
168            params,
169        )?;
170        expanded_owned.iter().collect()
171    } else {
172        selected
173    };
174
175    let opts = RunOptions {
176        validate,
177        reconcile,
178        resume,
179        force,
180        params,
181    };
182
183    // Seeds the card-table name column so it aligns from the first redraw
184    // (the renderer can't see a long name until its export emits `Started`).
185    let name_floor = exports
186        .iter()
187        .map(|e| e.name.chars().count())
188        .max()
189        .unwrap_or(0);
190    let process_mode_requested = parallel_export_processes_cli || config.parallel_export_processes;
191    // Process-mode children re-exec `rivet run --export <name>` and re-load the
192    // config from disk, so they cannot see the synthesised partition child
193    // names. Force in-process execution when partitioning is active.
194    if partitioned && process_mode_requested {
195        log::warn!(
196            "partition_by: --parallel-export-processes is disabled with partitioned exports \
197             (child processes re-load the config and can't see synthesised partitions); \
198             running in-process"
199        );
200    }
201    let run_parallel_processes =
202        process_mode_requested && export_name.is_none() && exports.len() > 1 && !partitioned;
203
204    let started_at = chrono::Utc::now();
205
206    if run_parallel_processes {
207        // Run schema migrations once in the parent BEFORE forking children.
208        // Otherwise N children race for the exclusive write lock on a
209        // brand-new `.rivet_state.db` and `busy_timeout` is not enough to
210        // serialise them — most fail with `migration v1 failed: database is
211        // locked`.  After this open succeeds the schema is at the latest
212        // version and children's `StateStore::open` calls become idempotent
213        // (the `MIGRATIONS` loop is a no-op when `ver <= current`).
214        if let Err(e) = StateStore::open(config_path) {
215            return Err(anyhow::anyhow!(
216                "state: failed to initialize state DB before spawning children: {:#}",
217                e
218            ));
219        }
220
221        let (result, child_failures, stderr_dump) =
222            parallel_children::run_exports_as_child_processes(
223                config_path,
224                &exports,
225                validate,
226                reconcile,
227                resume,
228                force,
229                params,
230                name_floor,
231            );
232        let finished_at = chrono::Utc::now();
233        // Best-effort aggregate: open the state DB read-only-ish and reconstruct
234        // entries from the per-child `record_metric` rows.  Failure to open the
235        // DB here only suppresses the aggregate, not the run itself.
236        match StateStore::open(config_path) {
237            Ok(state) => {
238                let entries =
239                    aggregate::collect_child_entries(&state, &exports, started_at, &child_failures);
240                let agg = aggregate::build(
241                    entries,
242                    started_at,
243                    finished_at,
244                    Some(config_path),
245                    "parallel-processes",
246                );
247                aggregate::print(&agg);
248                aggregate::persist(&state, &agg, summary_output);
249                if json_output {
250                    print_json_summary(&agg);
251                }
252            }
253            Err(e) => log::warn!(
254                "aggregate: cannot open state DB to record run aggregate: {:#}",
255                e
256            ),
257        }
258        // Captured child stderr (verbose per-export cards) goes to a file
259        // artifact beside the config, with a one-line console pointer — the run
260        // summary stays clean instead of flooding with every child's stderr.
261        emit_child_stderr(&stderr_dump, &config_dir);
262        return result;
263    }
264
265    let run_parallel = (parallel_exports_cli || config.parallel_exports)
266        && export_name.is_none()
267        && exports.len() > 1;
268
269    // Compact-rendering hints for the per-export renderers.  Set once here so
270    // every code path below — sequential, `--parallel-exports`, the apply
271    // path, etc. — sees a consistent mode.  Restored at the end of the run
272    // so subsequent invocations within the same process (tests, library
273    // callers) start with a clean slate.
274    let multi_export = export_name.is_none() && exports.len() > 1;
275    let prev_multi = MULTI_EXPORT_MODE.swap(multi_export, AtomicOrdering::Relaxed);
276    let prev_concurrent = MULTI_EXPORT_CONCURRENT.swap(run_parallel, AtomicOrdering::Relaxed);
277    struct ResetMultiExport(bool, bool);
278    impl Drop for ResetMultiExport {
279        fn drop(&mut self) {
280            MULTI_EXPORT_MODE.store(self.0, AtomicOrdering::Relaxed);
281            MULTI_EXPORT_CONCURRENT.store(self.1, AtomicOrdering::Relaxed);
282        }
283    }
284    let _reset_multi = ResetMultiExport(prev_multi, prev_concurrent);
285
286    let mut summaries: Vec<RunSummary> = Vec::with_capacity(exports.len());
287    // Keep the typed `anyhow::Error`s (not flattened strings) so the final bail
288    // can carry a representative one — its DataIntegrityError / SchemaDriftError /
289    // transient marker downcasts through anyhow's context chain in
290    // `error::classify_exit`, giving the right process exit code without grepping
291    // the message.
292    let mut failures: Vec<anyhow::Error> = Vec::new();
293
294    if run_parallel {
295        log::info!(
296            "running {} exports in parallel (separate state DB connection per export)",
297            exports.len()
298        );
299
300        // In threads mode every export emits the same `ChildEvent` stream
301        // that `--parallel-export-processes` children emit, but routed
302        // through an in-process `mpsc` channel.  A single UI thread (the
303        // same `parent_ui::run_ui` used for the process-mode parent) owns
304        // stderr and renders one card line per export — no indicatif, no
305        // multi-bar coordination headache, no scrollback artefacts from
306        // concurrent redraws.  Ensure stderr is also pre-migrated so child
307        // threads opening their own `StateStore` don't race on schema DDL.
308        if let Err(e) = StateStore::open(config_path) {
309            return Err(anyhow::anyhow!(
310                "state: failed to initialize state DB before spawning export threads: {:#}",
311                e
312            ));
313        }
314        let n_cards = exports.len();
315        let (tx, rx) = std::sync::mpsc::channel::<parent_ui::UiMessage>();
316        ipc::install_in_process_tx(tx);
317        let ui_thread = std::thread::Builder::new()
318            .name("rivet-ui".to_string())
319            .spawn(move || parent_ui::run_ui(rx, name_floor, n_cards))
320            .ok();
321
322        let collected: std::sync::Mutex<Vec<(Result<()>, RunSummary)>> =
323            std::sync::Mutex::new(Vec::with_capacity(exports.len()));
324        std::thread::scope(|s| {
325            let mut handles = Vec::new();
326            for &export in &exports {
327                handles.push(s.spawn(|| {
328                    let state = match StateStore::open(config_path) {
329                        Ok(s) => s,
330                        Err(e) => {
331                            let err = anyhow::anyhow!(
332                                "export '{}': failed to open state database: {:#}",
333                                export.name,
334                                e
335                            );
336                            let summary = job::synthetic_failed_summary(&export.name, &err);
337                            return (Err(err), summary);
338                        }
339                    };
340                    job::run_export_job(config_path, &config, export, &state, &config_dir, &opts)
341                }));
342            }
343            for h in handles {
344                match h.join() {
345                    Ok(pair) => collected.lock().unwrap().push(pair),
346                    Err(payload) => std::panic::resume_unwind(payload),
347                }
348            }
349        });
350
351        // All exports are done → drop the sender so `parent_ui::run_ui`
352        // sees the channel close and exits cleanly (committing the final
353        // card stack to scrollback).  Joining is best-effort: even if the
354        // UI thread is wedged we still want to print the run aggregate
355        // below.
356        ipc::clear_in_process_tx();
357        if let Some(t) = ui_thread {
358            let _ = t.join();
359        }
360
361        for (res, summary) in collected.into_inner().unwrap() {
362            if let Err(e) = res {
363                failures.push(e);
364            }
365            summaries.push(summary);
366        }
367    } else {
368        let state = StateStore::open(config_path)?;
369
370        // Always route through `parent_ui` — same as `--parallel-exports`.
371        // Gating on `is_attended()` left VHS/ttyd on indicatif when the
372        // attended bit is unset; `run_ui` already falls back to linear
373        // mode for piped stderr.
374        let n_cards = exports.len();
375        let (tx, rx) = std::sync::mpsc::channel::<parent_ui::UiMessage>();
376        ipc::install_in_process_tx(tx);
377        let ui_thread = std::thread::Builder::new()
378            .name("rivet-ui".to_string())
379            .spawn(move || parent_ui::run_ui(rx, name_floor, n_cards))
380            .ok();
381
382        for export in &exports {
383            let (res, summary) =
384                job::run_export_job(config_path, &config, export, &state, &config_dir, &opts);
385            if let Err(e) = res {
386                failures.push(e);
387            }
388            summaries.push(summary);
389        }
390
391        ipc::clear_in_process_tx();
392        if let Some(t) = ui_thread {
393            let _ = t.join();
394        }
395        // Single-export sequential runs still emit the detailed block after
396        // the card commits to scrollback.
397        if exports.len() == 1
398            && let Some(summary) = summaries.last()
399        {
400            summary.print_stderr_block();
401        }
402    }
403
404    let finished_at = chrono::Utc::now();
405    // Skip the aggregate for single-export runs.  Two cases this catches:
406    //   1) `rivet run --export X` (manual one-off): the per-export block
407    //      already says everything, an aggregate of one row is just noise.
408    //   2) Children spawned by `--parallel-export-processes`: each child
409    //      enters this code path with exports.len() == 1.  The parent
410    //      (parallel_processes branch above) builds the run-wide aggregate
411    //      from every child's `export_metrics` row, so a child-level
412    //      aggregate would just write a duplicate into `run_aggregate`.
413    // Force-write the JSON file even when skipping, so `--summary-output`
414    // remains useful for one-off runs.
415    if exports.len() > 1 {
416        let parallel_mode = if run_parallel {
417            "parallel-threads"
418        } else {
419            "sequential"
420        };
421        let entries: Vec<_> = summaries
422            .iter()
423            .map(aggregate::entry_from_summary)
424            .collect();
425        let agg = aggregate::build(
426            entries,
427            started_at,
428            finished_at,
429            Some(config_path),
430            parallel_mode,
431        );
432        aggregate::print(&agg);
433        // Open a fresh state handle for persisting the aggregate so we don't
434        // assume which thread owned the per-export `StateStore` above.
435        match StateStore::open(config_path) {
436            Ok(state) => aggregate::persist(&state, &agg, summary_output),
437            Err(e) => log::warn!(
438                "aggregate: cannot open state DB to record run aggregate: {:#}",
439                e
440            ),
441        }
442        if json_output {
443            print_json_summary(&agg);
444        }
445    } else if summary_output.is_some() || json_output {
446        // One export, but the user asked for a summary file and/or JSON stdout —
447        // honour both without polluting the DB or stderr.
448        let entries: Vec<_> = summaries
449            .iter()
450            .map(aggregate::entry_from_summary)
451            .collect();
452        let agg = aggregate::build(
453            entries,
454            started_at,
455            finished_at,
456            Some(config_path),
457            "sequential",
458        );
459        if let Some(out) = summary_output
460            && let Err(e) =
461                std::fs::write(out, serde_json::to_string_pretty(&agg).unwrap_or_default())
462        {
463            log::warn!(
464                "aggregate: failed to write summary JSON to {}: {:#}",
465                out.display(),
466                e
467            );
468        }
469        if json_output {
470            print_json_summary(&agg);
471        }
472    }
473
474    if !failures.is_empty() {
475        // Carry a representative typed failure as the returned error so
476        // `error::classify_exit` downcasts the marker (DataIntegrityError=3,
477        // SchemaDriftError=4, transient=2) through anyhow's context chain. Pick
478        // the most "stop-worthy" class — data-integrity (possibly-wrong data)
479        // outranks schema-drift, which outranks retryable, which outranks
480        // generic — so a mixed batch exits on the scariest reason.
481        let primary_idx = representative_failure_idx(&failures).unwrap();
482        let primary = failures.remove(primary_idx);
483        if failures.is_empty() {
484            // Single failure — return it verbatim (its own message + marker).
485            return Err(primary);
486        }
487        // Multiple failures: list the others as higher-level context; `primary`
488        // (with its typed marker) rides underneath so the downcast still finds it.
489        let others = failures
490            .iter()
491            .map(|e| format!("{e:#}"))
492            .collect::<Vec<_>>()
493            .join("; ");
494        return Err(primary.context(format!(
495            "{} export(s) failed; representative error follows (also: {others})",
496            failures.len() + 1
497        )));
498    }
499
500    Ok(())
501}
502
503/// `rivet apply -c config.yaml` (plan→apply cycle): run every export of the
504/// config **wave by wave** in ascending `wave:` order — exports with no `wave:`
505/// run last — reusing the same per-export job + run aggregate as [`run`]. This
506/// first cut runs each wave's exports SEQUENTIALLY (deterministic); safety-aware
507/// within-wave parallelism is a follow-up, and `partition_by` exports are not
508/// expanded here yet (use `rivet run` for those).
509pub(crate) fn run_waves(
510    config_path: &str,
511    force: bool,
512    parallel_cli: bool,
513    resume: bool,
514) -> Result<()> {
515    let config = Config::load_with_params(config_path, None)?;
516    let config_dir = Path::new(config_path)
517        .parent()
518        .unwrap_or(Path::new("."))
519        .to_path_buf();
520    let opts = RunOptions {
521        validate: false,
522        reconcile: false,
523        resume,
524        force,
525        params: None,
526    };
527
528    // Group exports by wave (ascending; an export with no `wave:` runs last).
529    // The ordering is the contract apply depends on, so it lives in a pure
530    // tested helper rather than hiding inline here.
531    let by_wave = group_exports_by_wave(&config.exports);
532    let total: usize = by_wave.iter().map(|(_, v)| v.len()).sum();
533    if total == 0 {
534        log::warn!("apply: config '{config_path}' defines no exports");
535        return Ok(());
536    }
537
538    // `--parallel` (or `parallel_export_processes: true` in the config) opts into
539    // within-wave parallelism: each wave's exports run as concurrent child
540    // processes (per-child governor keeps each one source-safe), the call blocks
541    // until all exit = the wave barrier. Default stays sequential.
542    let parallel = parallel_cli || config.parallel_export_processes;
543
544    // Compact per-export rendering for the SEQUENTIAL path only. The parallel
545    // (subprocess) path renders the parent card stack itself and each child sees
546    // `exports.len() == 1`, so the flag must stay clear there — matching `run`'s
547    // parallel-processes branch.
548    let prev_multi = MULTI_EXPORT_MODE.swap(total > 1 && !parallel, AtomicOrdering::Relaxed);
549    struct ResetMulti(bool);
550    impl Drop for ResetMulti {
551        fn drop(&mut self) {
552            MULTI_EXPORT_MODE.store(self.0, AtomicOrdering::Relaxed);
553        }
554    }
555    let _reset = ResetMulti(prev_multi);
556
557    let state = StateStore::open(config_path)?;
558    let started_at = chrono::Utc::now();
559    let mut summaries: Vec<RunSummary> = Vec::with_capacity(total);
560    let mut failures: Vec<anyhow::Error> = Vec::new();
561    // Parallel-path accumulators: per-child metrics live in the state DB, so the
562    // parent reconstructs one aggregate from them after every wave has joined.
563    let mut all_exports: Vec<&ExportConfig> = Vec::with_capacity(total);
564    let mut child_failures: std::collections::HashMap<String, String> =
565        std::collections::HashMap::new();
566    let mut combined_stderr = String::new();
567
568    for (wave, exports) in &by_wave {
569        let label = if *wave == u32::MAX {
570            "unscheduled".to_string()
571        } else {
572            wave.to_string()
573        };
574        // Skip-completed under --resume: an export whose destination already has
575        // `_SUCCESS` is done — re-running must not redo it (and would hit the
576        // resume gate). The rest run with `resume`, so an incomplete chunked
577        // export continues from its checkpoint. Reuses `finalize`'s prior-run
578        // probe rather than re-implementing the marker check.
579        let pending: Vec<&ExportConfig> = exports
580            .iter()
581            .copied()
582            .filter(|e| {
583                // Probe the EXPANDED destination, not the raw template. A
584                // templated prefix (`{export}`/`{table}`/`{date}`) never matches a
585                // literal `_SUCCESS` path, so a completed templated export was
586                // never skipped and instead re-ran into the resume gate. Resolve
587                // the same way `rivet run` does at write time (today's UTC date, no
588                // `{run_id}` — a run-unique prefix is fresh every run, so there is
589                // nothing to skip and the literal token correctly never matches).
590                let ctx = crate::destination::placeholder::PlaceholderContext::for_today(&e.name);
591                let expanded = crate::destination::placeholder::expand_destination(
592                    e.destination.clone(),
593                    &ctx,
594                );
595                let done = resume && finalize::destination_has_success(&expanded);
596                if done {
597                    log::info!(
598                        "apply: skipping '{}' — destination already complete (_SUCCESS)",
599                        e.name
600                    );
601                }
602                !done
603            })
604            .collect();
605        if pending.is_empty() {
606            continue;
607        }
608        if total > 1 {
609            println!("\n  ── wave {label} · {} export(s) ──", pending.len());
610        }
611        // The wave barrier is the loop itself: each strategy below fully drains
612        // the wave (the sequential loop, or the blocking child-process join)
613        // before the next iteration starts the next wave.
614        if parallel {
615            // Cost safety-gate: within the wave, the cheap (`parallel_safe`)
616            // exports run together in ONE concurrent batch; every heavier export
617            // runs ALONE in its own single-child batch, since a big table already
618            // chunk-parallelizes internally and two at once would overload the
619            // source. The per-child governor still bounds each one; this gate also
620            // bounds the concurrent connection count.
621            let (safe, lone): (Vec<&ExportConfig>, Vec<&ExportConfig>) =
622                pending.iter().copied().partition(|e| is_parallel_safe(e));
623            log::info!(
624                "apply: wave {} — {} parallel-safe export(s) in parallel, {} run alone",
625                label,
626                safe.len(),
627                lone.len()
628            );
629            // One single-child batch per lone export (run sequentially), then
630            // one concurrent batch for all parallel-safe exports.
631            let mut batches: Vec<Vec<&ExportConfig>> = lone.iter().map(|e| vec![*e]).collect();
632            if !safe.is_empty() {
633                batches.push(safe);
634            }
635            // Wave-wide name floor so cards align across the safe/lone batches
636            // (the cost gate splits a wave into one safe batch + N lone batches,
637            // each its own renderer — without a shared floor they'd each pad to
638            // their own widest name and the table would step).
639            let wave_name_floor = pending
640                .iter()
641                .map(|e| e.name.chars().count())
642                .max()
643                .unwrap_or(0);
644            for batch in &batches {
645                let (result, cf, stderr_dump) = parallel_children::run_exports_as_child_processes(
646                    config_path,
647                    batch,
648                    false,
649                    false,
650                    resume,
651                    force,
652                    None,
653                    wave_name_floor,
654                );
655                child_failures.extend(cf);
656                combined_stderr.push_str(&stderr_dump);
657                if let Err(e) = result {
658                    failures.push(e);
659                }
660            }
661            all_exports.extend_from_slice(&pending);
662        } else {
663            log::info!(
664                "apply: wave {} — {} export(s), sequential",
665                label,
666                pending.len()
667            );
668            for export in &pending {
669                let (res, summary) =
670                    job::run_export_job(config_path, &config, export, &state, &config_dir, &opts);
671                if let Err(e) = res {
672                    failures.push(e);
673                }
674                summaries.push(summary);
675            }
676        }
677    }
678
679    let finished_at = chrono::Utc::now();
680    if total > 1 {
681        let entries = if parallel {
682            aggregate::collect_child_entries(&state, &all_exports, started_at, &child_failures)
683        } else {
684            summaries
685                .iter()
686                .map(aggregate::entry_from_summary)
687                .collect()
688        };
689        let agg = aggregate::build(
690            entries,
691            started_at,
692            finished_at,
693            Some(config_path),
694            if parallel {
695                "wave-parallel-processes"
696            } else {
697                "wave-sequential"
698            },
699        );
700        aggregate::print(&agg);
701        aggregate::persist(&state, &agg, None);
702    }
703    // Captured child stderr (verbose per-export cards, parallel path only) goes
704    // to a file artifact beside the config, with a one-line console pointer.
705    emit_child_stderr(&combined_stderr, &config_dir);
706
707    if !failures.is_empty() {
708        let primary_idx = representative_failure_idx(&failures).unwrap();
709        let primary = failures.remove(primary_idx);
710        if failures.is_empty() {
711            return Err(primary);
712        }
713        let others = failures
714            .iter()
715            .map(|e| format!("{e:#}"))
716            .collect::<Vec<_>>()
717            .join("; ");
718        return Err(primary.context(format!(
719            "{} export(s) failed across waves; representative error follows (also: {others})",
720            failures.len() + 1
721        )));
722    }
723    Ok(())
724}
725
726/// Group exports by `wave:` in ascending order; an export with no `wave:` runs
727/// last (sorted as `u32::MAX`). Pure + unit-tested — the ordering is the
728/// contract `apply` depends on, so it does not hide inside [`run_waves`].
729fn group_exports_by_wave(exports: &[ExportConfig]) -> Vec<(u32, Vec<&ExportConfig>)> {
730    let mut by_wave: std::collections::BTreeMap<u32, Vec<&ExportConfig>> =
731        std::collections::BTreeMap::new();
732    for e in exports {
733        by_wave
734            .entry(e.wave.unwrap_or(u32::MAX))
735            .or_default()
736            .push(e);
737    }
738    by_wave.into_iter().collect()
739}
740
741/// Whether an export may run concurrently with its wave-mates: the
742/// `parallel_safe` flag that `rivet plan` records from the source-aware cost
743/// class (true only for cheap, `Low`-cost tables — see
744/// [`ExportConfig::parallel_safe`]). A heavy table already chunk-parallelizes
745/// internally, so it runs ALONE within its wave; only the cheap exports share a
746/// concurrent batch. `None` (un-planned / hand-written) is treated as not-safe.
747fn is_parallel_safe(export: &ExportConfig) -> bool {
748    export.parallel_safe.unwrap_or(false)
749}
750
751#[cfg(test)]
752mod wave_grouping_tests {
753    use super::{group_exports_by_wave, is_parallel_safe};
754
755    #[test]
756    fn groups_ascending_with_unscheduled_last() {
757        let mut a = crate::config::sample_export("a");
758        a.wave = Some(3);
759        let mut b = crate::config::sample_export("b");
760        b.wave = None; // unscheduled → must sort last
761        let mut c = crate::config::sample_export("c");
762        c.wave = Some(1);
763        let mut d = crate::config::sample_export("d");
764        d.wave = Some(1); // shares wave 1 with c, preserves input order
765
766        let exports = vec![a, b, c, d];
767        let grouped = group_exports_by_wave(&exports);
768
769        let waves: Vec<u32> = grouped.iter().map(|(w, _)| *w).collect();
770        assert_eq!(waves, vec![1, 3, u32::MAX], "ascending, unscheduled last");
771        let wave1: Vec<&str> = grouped[0].1.iter().map(|e| e.name.as_str()).collect();
772        assert_eq!(wave1, vec!["c", "d"], "same-wave keeps input order");
773        assert_eq!(grouped[2].1.len(), 1);
774        assert_eq!(
775            grouped[2].1[0].name, "b",
776            "the no-wave export lands in the last group"
777        );
778    }
779
780    #[test]
781    fn parallel_safe_reads_the_plan_flag() {
782        // default sample_export leaves `parallel_safe` None → not safe
783        let unset = crate::config::sample_export("unset");
784        assert!(!is_parallel_safe(&unset), "None is treated as not-safe");
785
786        let mut safe = crate::config::sample_export("safe");
787        safe.parallel_safe = Some(true);
788        assert!(is_parallel_safe(&safe), "parallel_safe: true → concurrent");
789
790        let mut not_safe = crate::config::sample_export("heavy");
791        not_safe.parallel_safe = Some(false);
792        assert!(!is_parallel_safe(&not_safe), "parallel_safe: false → alone");
793    }
794}
795
796/// Index of the most "stop-worthy" failure in a batch: data-integrity (exit 3)
797/// outranks schema-drift (4), which outranks retryable (2), which outranks
798/// generic (1). The chosen error's typed marker then rides up so `classify_exit`
799/// exits the process on the scariest reason rather than whichever export happened
800/// to fail first. Returns `None` for an empty slice.
801fn representative_failure_idx(failures: &[anyhow::Error]) -> Option<usize> {
802    let rank = |e: &anyhow::Error| match crate::error::classify_exit(e) {
803        c if c == crate::error::ExitClass::DataIntegrity.code() => 3,
804        c if c == crate::error::ExitClass::SchemaDrift.code() => 2,
805        c if c == crate::error::ExitClass::Retryable.code() => 1,
806        _ => 0,
807    };
808    (0..failures.len()).max_by_key(|&i| rank(&failures[i]))
809}
810
811#[cfg(test)]
812mod representative_failure_tests {
813    use super::representative_failure_idx;
814    use crate::error::{DataIntegrityError, ExitClass, SchemaDriftError, classify_exit};
815
816    #[test]
817    fn empty_batch_has_no_representative() {
818        assert_eq!(representative_failure_idx(&[]), None);
819    }
820
821    #[test]
822    fn data_integrity_outranks_everything_regardless_of_position() {
823        // Data-integrity sits LAST so a naive "first failure" or a flipped
824        // min/max selector would pick the generic error instead.
825        let failures = vec![
826            anyhow::anyhow!("generic boom"),
827            SchemaDriftError::new("shape changed").into(),
828            anyhow::anyhow!("another generic"),
829            DataIntegrityError::new("reconcile mismatch").into(),
830        ];
831        let idx = representative_failure_idx(&failures).unwrap();
832        assert_eq!(
833            classify_exit(&failures[idx]),
834            ExitClass::DataIntegrity.code(),
835            "a mixed batch must surface the data-integrity (exit 3) failure"
836        );
837    }
838
839    #[test]
840    fn schema_drift_outranks_retryable_and_generic() {
841        // No data-integrity present → schema-drift (exit 4) is the scariest.
842        let failures = vec![
843            anyhow::anyhow!("generic"),
844            SchemaDriftError::new("drift").into(),
845        ];
846        let idx = representative_failure_idx(&failures).unwrap();
847        assert_eq!(classify_exit(&failures[idx]), ExitClass::SchemaDrift.code());
848    }
849}