Skip to main content

rivet/pipeline/
mod.rs

1//! **Layer: Coordinator** (planning → execution → persistence/observability)
2//!
3//! `pipeline/mod.rs` is the only module allowed to bridge all three layers.
4//! It reads a resolved plan (planning), dispatches to execution modules, then
5//! records metrics and sends notifications (persistence/observability).
6//!
7//! See `docs/adr/0003-layer-classification.md` for the full module taxonomy.
8
9mod aggregate;
10mod apply_cmd;
11mod cdc_job;
12pub(crate) mod chunked;
13mod cli;
14pub(crate) mod commit;
15mod finalize;
16pub(crate) mod ipc;
17mod job;
18mod keyset;
19mod manifest_reconcile;
20pub(crate) mod manifest_writer;
21mod mongo_parallel;
22mod parallel_children;
23pub(crate) mod parent_ui;
24mod partition_expand;
25mod plan_cmd;
26pub(crate) mod progress;
27mod reconcile_cmd;
28mod repair_cmd;
29pub(crate) mod report;
30mod resume_decisions;
31// `pub(crate)` so `error::classify_exit` can reach `retry::classify_error`
32// (transient → exit-code 2) without routing through the test-only re-export.
33pub(crate) mod retry;
34// The `rivet run` orchestrator (~290 LOC) lives next door so this facade
35// stays a thin re-export layer.  Module name shadows `pub fn run` below;
36// the duplicate is resolved by Rust's namespace rules (modules live in
37// the type namespace, fns in the value namespace) and unambiguous at
38// every call site (`pipeline::run(...)` is the function).
39mod run;
40mod run_store;
41mod schema_drift;
42mod single;
43mod sink;
44mod summary;
45mod validate;
46mod validate_cmd;
47mod validate_manifest;
48
49// ── Public API surface (consumed by `src/cli/dispatch.rs` + binaries) ──────
50//
51// These items are the contract the binary depends on.  Adding to this list
52// is an API change that requires a release-note entry; removing or
53// renaming requires a deprecation cycle.
54
55pub use apply_cmd::run_apply_command;
56pub use cli::{
57    reset_chunk_checkpoint, reset_chunk_checkpoints_stuck, reset_state, show_chunk_checkpoint,
58    show_files, show_journal, show_metrics, show_progression, show_state,
59};
60pub use plan_cmd::{PlanOutputFormat, run_plan_command};
61pub use reconcile_cmd::{ReconcileOutputFormat, run_reconcile_command};
62pub use repair_cmd::{RepairOutputFormat, RepairReportSource, run_repair_command};
63pub use validate_cmd::{ValidateOutputFormat, ValidateTarget, run_validate_command};
64// The graded verify depth is part of the CLI contract (`--depth`) and is the
65// same enum that gates the checks in `verify_at_destination`.  Defined in the
66// pipeline layer; re-exported here so `cli::args` can parse it on the flag
67// without the CLI→pipeline layering inversion of defining it in `cli`.
68pub use validate_manifest::ValidateDepth;
69
70// `RunSummary` is consumed by `notify::*` (via the Coordinator path) plus
71// integration-test fixtures.  It is the canonical observability struct so
72// it stays in the regular public surface.
73pub use summary::RunSummary;
74
75// ── Crate-internal cross-module use ────────────────────────────────────────
76
77pub(crate) use job::run_export_job_with_chunk_source;
78#[cfg(test)]
79#[allow(unused_imports)]
80pub(crate) use retry::is_transient;
81
82// ── Test-only surface ──────────────────────────────────────────────────────
83//
84// The integration tests in `tests/*.rs` exercise the trust-contract writers,
85// readers, and decision logic without spinning up a full pipeline.  These
86// items are NOT part of the public CLI contract — operators get them only
87// transitively (via `summary.json`, `manifest.json`, `--validate`, etc.).
88//
89// Hidden behind `#[doc(hidden)] pub mod for_tests` so they don't pollute
90// the rendered crate docs and so a renaming refactor here is a clear
91// "test-only" change rather than appearing as a public API break.
92//
93// Convention matches the existing `destination_for_tests` window in
94// `lib.rs`: tests reach these via `rivet::pipeline::for_tests::*`.
95
96#[doc(hidden)]
97pub mod for_tests {
98    pub use super::chunked::generate_chunks;
99    pub use super::manifest_writer::{ManifestBuilder, WriteOutcome, write_manifest};
100    pub use super::report::{RunReport, report_dir, write_run_report};
101    pub use super::resume_decisions::{
102        PartDecision, QuarantineReason, ResumeDecision, ResumePlan, UntrackedDecision,
103        build_resume_plan,
104    };
105    pub use super::retry::{RetryClass, classify_error};
106    pub use super::validate::validate_output;
107    pub use super::validate_manifest::{
108        Failure as ManifestVerificationFailure, ManifestVerification, verify_at_destination,
109    };
110    pub use crate::plan::build_time_window_query;
111}
112
113// Backwards-compat re-exports at the crate root so existing test files
114// keep compiling without a sweeping import-site update.  Each is delegated
115// to `for_tests::*`; new test code should import from `for_tests` directly.
116//
117// `#[allow(unused_imports)]` because the bin target's dead-code analysis
118// doesn't see the integration tests that consume these — same situation
119// as `RunSummary::stub_for_testing`.
120#[doc(hidden)]
121#[allow(unused_imports)]
122pub use for_tests::{
123    ManifestBuilder, ManifestVerification, ManifestVerificationFailure, PartDecision,
124    QuarantineReason, ResumeDecision, ResumePlan, RetryClass, RunReport, UntrackedDecision,
125    WriteOutcome, build_resume_plan, build_time_window_query, classify_error, generate_chunks,
126    report_dir, validate_output, verify_at_destination, write_manifest, write_run_report,
127};
128
129// The orchestrator and its `RunOptions` live in `run.rs`.  Re-exported
130// here so external call sites keep using `pipeline::run(...)` and
131// `pipeline::RunOptions`.  Multi-export render-mode flags ride along
132// because `RunSummary::print` and the in-place card renderer read them.
133pub use run::{RunOptions, run};
134#[allow(unused_imports)] // `multi_export_concurrent` is wired for future use
135pub(crate) use run::{multi_export_concurrent, multi_export_mode};
136
137pub(crate) fn format_bytes(b: u64) -> String {
138    if b >= 1_073_741_824 {
139        format!("{:.1} GB", b as f64 / 1_073_741_824.0)
140    } else if b >= 1_048_576 {
141        format!("{:.1} MB", b as f64 / 1_048_576.0)
142    } else if b >= 1024 {
143        format!("{:.1} KB", b as f64 / 1024.0)
144    } else {
145        format!("{} B", b)
146    }
147}
148
149/// Strip the trailing recovery-hint portion of a chunked-pipeline error
150/// message produced by `pipeline::chunked`.  Returns the cause prefix and
151/// whether a chunked-checkpoint hint was detected.
152///
153/// Hints emitted by `pipeline::chunked` always follow the pattern
154/// `<cause>; <connector> \`rivet …\` …`, so we cut at the first `; ` whose
155/// remainder contains a backtick-quoted `rivet` invocation.
156///
157/// Used by both the per-export card renderer (`parent_ui`) and the run
158/// aggregator (`aggregate`) so the long inline command doesn't wrap, distort
159/// the in-place card layout, and doesn't repeat the consolidated recovery
160/// block printed by the aggregator.
161pub(crate) fn strip_chunked_recovery_hint(msg: &str) -> (&str, bool) {
162    let mut pos = 0;
163    while let Some(off) = msg[pos..].find("; ") {
164        let abs = pos + off;
165        let tail = &msg[abs + 2..];
166        if tail.contains("`rivet ") {
167            return (&msg[..abs], true);
168        }
169        pos = abs + 2;
170    }
171    (msg, false)
172}
173
174/// Truncate `s` to at most `max_chars` Unicode characters, appending `…`
175/// when truncated.  Returns `s` unchanged if already short enough.  Used by
176/// the in-place card renderer to keep every line within the chosen
177/// terminal width — line wrapping breaks the cursor-up redraw math and
178/// causes cards to drift down the screen.
179pub(crate) fn clamp_line(s: &str, max_chars: usize) -> String {
180    if max_chars == 0 {
181        return String::new();
182    }
183    if s.chars().count() <= max_chars {
184        return s.to_string();
185    }
186    let keep = max_chars.saturating_sub(1);
187    let mut out: String = s.chars().take(keep).collect();
188    out.push('…');
189    out
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::config::{SourceConfig, SourceType};
196    use crate::plan::{
197        CompressionType, DestinationConfig, DestinationType, DiagnosticLevel, ExtractionStrategy,
198        FormatType, MetaColumns, ResolvedRunPlan, validate_plan,
199    };
200    use crate::tuning::SourceTuning;
201
202    #[test]
203    fn test_format_bytes() {
204        assert_eq!(format_bytes(500), "500 B");
205        assert_eq!(format_bytes(1024), "1.0 KB");
206        assert_eq!(format_bytes(1536), "1.5 KB");
207        assert_eq!(format_bytes(1_048_576), "1.0 MB");
208        assert_eq!(format_bytes(1_073_741_824), "1.0 GB");
209        assert_eq!(format_bytes(2_684_354_560), "2.5 GB");
210    }
211
212    #[test]
213    fn strip_chunked_recovery_hint_strips_use_form() {
214        let m = "export 'users': chunk checkpoint run 'users_x' still in progress; \
215                 use `rivet run --config foo.yaml --export users --resume` or \
216                 `rivet state reset-chunks --config foo.yaml --export users`";
217        let (cause, hinted) = strip_chunked_recovery_hint(m);
218        assert!(hinted);
219        assert_eq!(
220            cause,
221            "export 'users': chunk checkpoint run 'users_x' still in progress"
222        );
223    }
224
225    #[test]
226    fn strip_chunked_recovery_hint_strips_fix_errors_form() {
227        let m = "export 'a': chunk checkpoint incomplete (3 tasks not completed); \
228                 fix errors and `rivet run --config c.yaml --export a --resume` or \
229                 `rivet state reset-chunks --config c.yaml --export a`";
230        let (cause, hinted) = strip_chunked_recovery_hint(m);
231        assert!(hinted);
232        assert_eq!(
233            cause,
234            "export 'a': chunk checkpoint incomplete (3 tasks not completed)"
235        );
236    }
237
238    #[test]
239    fn strip_chunked_recovery_hint_passthrough_when_no_hint() {
240        let m = "export 'q': source connection refused; retry exhausted";
241        let (cause, hinted) = strip_chunked_recovery_hint(m);
242        assert!(!hinted);
243        assert_eq!(cause, m);
244    }
245
246    #[test]
247    fn clamp_line_truncates_with_ellipsis() {
248        assert_eq!(clamp_line("short", 80), "short");
249        assert_eq!(clamp_line("hello world", 8), "hello w…");
250        let s = "αβγδ".repeat(50);
251        let out = clamp_line(&s, 10);
252        assert_eq!(out.chars().count(), 10);
253        assert!(out.ends_with('…'));
254    }
255
256    #[test]
257    fn format_bytes_boundary_values() {
258        assert_eq!(format_bytes(0), "0 B");
259        assert_eq!(format_bytes(1), "1 B");
260        assert_eq!(format_bytes(1023), "1023 B");
261        assert_eq!(format_bytes(1024), "1.0 KB");
262        assert_eq!(format_bytes(1025), "1.0 KB");
263        assert_eq!(format_bytes(1_048_575), "1024.0 KB");
264        assert_eq!(format_bytes(1_048_576), "1.0 MB");
265        assert_eq!(format_bytes(1_073_741_823), "1024.0 MB");
266        assert_eq!(format_bytes(1_073_741_824), "1.0 GB");
267    }
268
269    fn minimal_plan() -> ResolvedRunPlan {
270        ResolvedRunPlan {
271            export_name: "test_export".into(),
272            base_query: "SELECT 1".into(),
273            strategy: ExtractionStrategy::Snapshot,
274            format: FormatType::Parquet,
275            compression: CompressionType::default(),
276            compression_level: None,
277            max_file_size_bytes: None,
278            skip_empty: false,
279            meta_columns: MetaColumns::default(),
280            destination: DestinationConfig {
281                destination_type: DestinationType::Local,
282                path: Some("./out".into()),
283                ..Default::default()
284            },
285            quality: None,
286            tuning: SourceTuning::from_config(None),
287            tuning_profile_label: "balanced (default)".into(),
288            validate: false,
289            reconcile: false,
290            resume: false,
291            source: SourceConfig {
292                source_type: SourceType::Postgres,
293                url: Some("postgresql://localhost/test".into()),
294                url_env: None,
295                url_file: None,
296                host: None,
297                port: None,
298                user: None,
299                password: None,
300                password_env: None,
301                database: None,
302                environment: None,
303                tuning: None,
304                tls: None,
305                mongo: None,
306            },
307            column_overrides: Default::default(),
308            verify: crate::config::VerifyMode::Size,
309            schema_drift_policy: Default::default(),
310            shape_drift_warn_factor: 2.0,
311            parquet: None,
312        }
313    }
314
315    #[test]
316    fn test_run_summary_fields() {
317        let plan = minimal_plan();
318        let summary = RunSummary::new(&plan);
319        assert_eq!(summary.export_name, "test_export");
320        assert_eq!(summary.status, "running");
321        assert_eq!(summary.total_rows, 0);
322        assert_eq!(summary.files_produced, 0);
323        assert_eq!(summary.tuning_profile, "balanced (default)");
324        assert_eq!(summary.batch_size, 10_000);
325        assert_eq!(summary.format, "parquet");
326        assert_eq!(summary.mode, "full");
327        assert!(
328            summary.run_id.starts_with("test_export_"),
329            "run_id should start with export name, got: {}",
330            summary.run_id
331        );
332    }
333
334    // ─── RunSummary::new() journal invariants ────────────────────────────────
335
336    /// `RunSummary::new()` must immediately record a `PlanResolved` event as the
337    /// first journal entry.  This satisfies the "what was planned?" query from ADR-0001.
338    #[test]
339    fn run_summary_new_records_plan_resolved_as_first_event() {
340        let plan = minimal_plan();
341        let summary = RunSummary::new(&plan);
342
343        assert!(
344            !summary.journal.entries.is_empty(),
345            "journal must have at least one entry after RunSummary::new()"
346        );
347        assert!(
348            matches!(
349                summary.journal.entries[0].event,
350                crate::journal::RunEvent::PlanResolved(_)
351            ),
352            "first journal event must be PlanResolved, got: {:?}",
353            summary.journal.entries[0].event
354        );
355    }
356
357    /// The `PlanSnapshot` recorded inside `PlanResolved` must faithfully capture
358    /// key fields from the `ResolvedRunPlan`.
359    #[test]
360    fn run_summary_plan_snapshot_matches_plan_fields() {
361        let plan = minimal_plan();
362        let summary = RunSummary::new(&plan);
363
364        let snap = summary
365            .journal
366            .plan_snapshot()
367            .expect("plan_snapshot() must be Some after RunSummary::new()");
368
369        assert_eq!(snap.export_name, plan.export_name);
370        assert_eq!(snap.validate, plan.validate);
371        assert_eq!(snap.reconcile, plan.reconcile);
372        assert_eq!(snap.resume, plan.resume);
373        assert_eq!(snap.batch_size, plan.tuning.batch_size);
374    }
375
376    /// The journal's `run_id` must match the `RunSummary`'s `run_id`.
377    #[test]
378    fn run_summary_journal_run_id_matches_summary_run_id() {
379        let plan = minimal_plan();
380        let summary = RunSummary::new(&plan);
381        assert_eq!(
382            summary.journal.run_id, summary.run_id,
383            "journal run_id must match summary run_id"
384        );
385    }
386
387    // ─── Rejected plan gate ──────────────────────────────────────────────────
388
389    /// Gap 7 — `run_export_job` bails before execution when `validate_plan` returns
390    /// a `Rejected` diagnostic.  The wiring is in `run_export_job` (this file,
391    /// ~line 210): if `rejected` is non-empty, the function returns `anyhow::bail!`.
392    ///
393    /// This test verifies the *condition* that triggers the bail: that `validate_plan`
394    /// does in fact produce a `Rejected` diagnostic for the stdout+split combination.
395    /// The gate itself (`run_export_job`) cannot be called directly in tests because
396    /// it requires a live database connection and config; we test its precondition here.
397    #[test]
398    fn rejected_plan_produces_rejected_diagnostic_blocking_run_export_job() {
399        let mut plan = minimal_plan();
400        // stdout + max_file_size triggers check_stdout_split → Rejected.
401        plan.destination.destination_type = DestinationType::Stdout;
402        plan.max_file_size_bytes = Some(10 * 1024 * 1024);
403
404        let diags = validate_plan(&plan);
405        let rejected_count = diags
406            .iter()
407            .filter(|d| d.level == DiagnosticLevel::Rejected)
408            .count();
409
410        assert!(
411            rejected_count > 0,
412            "stdout + max_file_size must produce a Rejected diagnostic so that \
413             run_export_job bails before calling run_with_reconnect; got: {:?}",
414            diags
415                .iter()
416                .map(|d| (&d.rule, &d.level))
417                .collect::<Vec<_>>()
418        );
419    }
420
421    /// stdout + chunked strategy also triggers a Rejected diagnostic (check_stdout_chunked).
422    #[test]
423    fn rejected_plan_stdout_chunked_blocks_run_export_job() {
424        use crate::plan::ChunkedPlan;
425        let mut plan = minimal_plan();
426        plan.destination.destination_type = DestinationType::Stdout;
427        plan.strategy = ExtractionStrategy::Chunked(ChunkedPlan {
428            column: "id".into(),
429            chunk_size: 1000,
430            chunk_count: None,
431            parallel: 1,
432            dense: false,
433            by_days: None,
434            max_attempts: 3,
435            checkpoint: false,
436        });
437
438        let diags = validate_plan(&plan);
439        assert!(
440            diags.iter().any(|d| d.level == DiagnosticLevel::Rejected),
441            "stdout + chunked must produce a Rejected diagnostic"
442        );
443    }
444
445    // ─── synthetic_failed_summary ────────────────────────────────────────────
446
447    /// Pre-`RunSummary::new` failures (plan-build error, plan-validation
448    /// rejection) still need to be aggregated.  `synthetic_failed_summary`
449    /// produces a minimally-populated summary that aggregation can consume
450    /// without panicking.
451    #[test]
452    fn synthetic_failed_summary_carries_error_and_status() {
453        let err = anyhow::anyhow!("could not connect to source: timeout");
454        let s = job::synthetic_failed_summary("orders", &err);
455        assert_eq!(s.export_name, "orders");
456        assert_eq!(s.status, "failed");
457        assert_eq!(
458            s.error_message.as_deref(),
459            Some("could not connect to source: timeout")
460        );
461        assert!(
462            s.run_id.starts_with("orders_"),
463            "run_id must be derived from export name, got {}",
464            s.run_id
465        );
466        // Aggregation reads these fields directly — they must default to zero.
467        assert_eq!(s.total_rows, 0);
468        assert_eq!(s.files_produced, 0);
469        assert_eq!(s.bytes_written, 0);
470        assert_eq!(s.duration_ms, 0);
471    }
472
473    /// `entry_from_summary` must faithfully copy fields the aggregate cares
474    /// about.  This guards against silent drift if `RunAggregateEntry` or
475    /// `RunSummary` gain new fields.
476    #[test]
477    fn aggregate_entry_from_summary_copies_observable_fields() {
478        let plan = minimal_plan();
479        let mut summary = RunSummary::new(&plan);
480        summary.status = "success".into();
481        summary.total_rows = 12_345;
482        summary.files_produced = 3;
483        summary.bytes_written = 9_876_543;
484        summary.duration_ms = 5_000;
485
486        let entry = aggregate::entry_from_summary(&summary);
487        assert_eq!(entry.export_name, summary.export_name);
488        assert_eq!(entry.status, "success");
489        assert_eq!(entry.run_id, summary.run_id);
490        assert_eq!(entry.rows, 12_345);
491        assert_eq!(entry.files, 3);
492        assert_eq!(entry.bytes, 9_876_543);
493        assert_eq!(entry.duration_ms, 5_000);
494        assert_eq!(entry.mode, summary.mode);
495        assert_eq!(entry.error_message, None);
496    }
497}