1mod 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;
31pub(crate) mod retry;
34mod run;
40mod run_store;
41mod schema_drift;
42mod single;
43mod sink;
44mod summary;
45mod validate;
46mod validate_cmd;
47mod validate_manifest;
48
49pub 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};
64pub use validate_manifest::ValidateDepth;
69
70pub use summary::RunSummary;
74
75pub(crate) use job::run_export_job_with_chunk_source;
78#[cfg(test)]
79#[allow(unused_imports)]
80pub(crate) use retry::is_transient;
81
82#[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#[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
129pub use run::{RunOptions, run};
134#[allow(unused_imports)] pub(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
149pub(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
174pub(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 #[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 #[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 #[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 #[test]
398 fn rejected_plan_produces_rejected_diagnostic_blocking_run_export_job() {
399 let mut plan = minimal_plan();
400 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 #[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 #[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 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 #[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}