1use super::ipc::{self, ChildEvent};
18use super::{format_bytes, multi_export_mode, strip_chunked_recovery_hint};
19use crate::journal::{PlanSnapshot, RunEvent, RunJournal};
20use crate::manifest::ManifestPart;
21use crate::plan::ResolvedRunPlan;
22
23fn plan_snapshot_from(plan: &ResolvedRunPlan) -> PlanSnapshot {
29 PlanSnapshot {
30 export_name: plan.export_name.clone(),
31 base_query: plan.base_query.clone(),
32 strategy: plan.strategy.mode_label().to_string(),
33 format: plan.format.label().to_string(),
34 compression: plan.compression.label().to_string(),
35 destination_type: plan.destination.destination_type.label().to_string(),
36 tuning_profile: plan.tuning_profile_label.clone(),
37 batch_size: plan.tuning.batch_size,
38 validate: plan.validate,
39 reconcile: plan.reconcile,
40 resume: plan.resume,
41 chunk_key: plan.strategy.chunk_key().map(|c| c.to_string()),
42 resumable: plan.strategy.is_resumable(),
43 }
44}
45
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
54pub struct ApplyContext {
55 pub plan_id: String,
57 pub forced: bool,
59 pub force_bypassed: Vec<String>,
64}
65
66#[derive(Debug, Clone, Default)]
72pub struct RunSummary {
73 pub run_id: String,
74 pub export_name: String,
75 pub status: String,
76 pub total_rows: i64,
77 pub files_produced: usize,
78 pub bytes_written: u64,
79 pub files_committed: usize,
82 pub duration_ms: i64,
83 pub peak_rss_mb: i64,
84 pub retries: u32,
85 pub validated: Option<bool>,
86 pub schema_changed: Option<bool>,
87 pub quality_passed: Option<bool>,
88 pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
91 pub checksum_key_column: Option<String>,
93 pub column_checksums_incomplete: bool,
104 pub cursor_column: Option<String>,
109 pub cursor_low: Option<String>,
110 pub cursor_high: Option<String>,
111 pub error_message: Option<String>,
112 pub tuning_profile: String,
114 pub batch_size: usize,
116 pub batch_size_memory_mb: Option<usize>,
118 pub format: String,
119 pub mode: String,
120 pub compression: String,
121 pub destination_uri: Option<String>,
126 pub pg_temp_bytes_delta: Option<i64>,
133 pub skip_reason: Option<String>,
139 pub source_count: Option<i64>,
141 pub reconciled: Option<bool>,
143 pub manifest_parts: Vec<ManifestPart>,
148 pub schema_fingerprint: Option<String>,
162 pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
168 pub apply_context: Option<ApplyContext>,
172 pub journal: RunJournal,
174}
175
176type Row = (&'static str, String);
179
180impl RunSummary {
181 pub(super) fn new(plan: &ResolvedRunPlan) -> Self {
182 let run_id = format!(
183 "{}_{}",
184 plan.export_name,
185 chrono::Utc::now().format("%Y%m%dT%H%M%S%.3f"),
186 );
187 let mut journal = RunJournal::new(&run_id, &plan.export_name);
188 journal.record(RunEvent::PlanResolved(plan_snapshot_from(plan)));
189
190 ipc::emit_event(&ChildEvent::Started {
191 export_name: plan.export_name.clone(),
192 run_id: run_id.clone(),
193 mode: plan.strategy.mode_label().to_string(),
194 tuning_profile: plan.tuning_profile_label.clone(),
195 batch_size: plan.tuning.batch_size,
196 });
197
198 Self {
199 run_id,
200 export_name: plan.export_name.clone(),
201 status: "running".into(),
202 total_rows: 0,
203 files_produced: 0,
204 bytes_written: 0,
205 files_committed: 0,
206 duration_ms: 0,
207 peak_rss_mb: 0,
208 retries: 0,
209 validated: None,
210 schema_changed: None,
211 quality_passed: None,
212 error_message: None,
213 tuning_profile: plan.tuning_profile_label.clone(),
214 batch_size: plan.tuning.batch_size,
215 batch_size_memory_mb: plan.tuning.batch_size_memory_mb,
216 format: plan.format.label().to_string(),
217 mode: plan.strategy.mode_label().to_string(),
218 compression: plan.compression.label().to_string(),
219 destination_uri: (!matches!(
220 plan.destination.destination_type,
221 crate::config::DestinationType::Stdout
222 ))
223 .then(|| crate::pipeline::finalize::destination_uri_for_manifest(&plan.destination)),
224 pg_temp_bytes_delta: None,
225 skip_reason: None,
226 source_count: None,
227 reconciled: None,
228 manifest_parts: Vec::new(),
229 schema_fingerprint: None,
230 manifest_verification: None,
231 apply_context: None,
232 column_checksums: Vec::new(),
233 column_checksums_incomplete: false,
234 checksum_key_column: None,
235 cursor_column: None,
236 cursor_low: None,
237 cursor_high: None,
238 journal,
239 }
240 }
241
242 #[doc(hidden)]
263 #[allow(dead_code)]
264 pub fn stub_for_testing(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
265 let run_id = run_id.into();
266 let export_name = export_name.into();
267 let journal = RunJournal::new(&run_id, &export_name);
268 Self {
269 run_id,
270 export_name,
271 status: "running".into(),
272 total_rows: 0,
273 files_produced: 0,
274 bytes_written: 0,
275 files_committed: 0,
276 duration_ms: 0,
277 peak_rss_mb: 0,
278 retries: 0,
279 validated: None,
280 schema_changed: None,
281 quality_passed: None,
282 error_message: None,
283 tuning_profile: "balanced".into(),
284 batch_size: 1000,
285 batch_size_memory_mb: None,
286 format: "parquet".into(),
287 mode: "snapshot".into(),
288 compression: "zstd".into(),
289 destination_uri: None,
290 pg_temp_bytes_delta: None,
291 skip_reason: None,
292 source_count: None,
293 reconciled: None,
294 manifest_parts: Vec::new(),
295 schema_fingerprint: None,
296 manifest_verification: None,
297 apply_context: None,
298 column_checksums: Vec::new(),
299 column_checksums_incomplete: false,
300 checksum_key_column: None,
301 cursor_column: None,
302 cursor_low: None,
303 cursor_high: None,
304 journal,
305 }
306 }
307
308 #[doc(hidden)]
315 #[allow(dead_code)]
316 pub fn with_status(mut self, status: impl Into<String>) -> Self {
317 let s = status.into();
318 if (s == "success" || s == "failed") && self.journal.final_outcome().is_none() {
319 self.journal.record(RunEvent::RunCompleted {
320 status: s.clone(),
321 error_message: self.error_message.clone(),
322 duration_ms: self.duration_ms,
323 });
324 }
325 self.status = s;
326 self
327 }
328
329 #[doc(hidden)]
333 #[allow(dead_code)]
334 pub fn with_files_committed(mut self, n: usize) -> Self {
335 self.files_committed = n;
336 self
337 }
338
339 #[doc(hidden)]
343 #[allow(dead_code)]
344 pub fn with_manifest_parts(mut self, parts: Vec<crate::manifest::ManifestPart>) -> Self {
345 self.total_rows = parts.iter().map(|p| p.rows).sum();
346 self.bytes_written = parts.iter().map(|p| p.size_bytes).sum();
347 self.files_produced = parts.len();
348 self.files_committed = parts.len();
349 self.manifest_parts = parts;
350 self
351 }
352
353 #[doc(hidden)]
357 #[allow(dead_code)]
358 pub fn with_error(mut self, msg: impl Into<String>) -> Self {
359 self.error_message = Some(msg.into());
360 self
361 }
362
363 #[doc(hidden)]
368 #[allow(dead_code)]
369 pub fn with_plan_snapshot(mut self, snap: PlanSnapshot) -> Self {
370 self.journal.record(RunEvent::PlanResolved(snap));
371 self
372 }
373
374 pub(super) fn print(&self) {
375 if ipc::capturing_events() {
379 ipc::emit_event(&ChildEvent::Finished {
380 export_name: self.export_name.clone(),
381 run_id: self.run_id.clone(),
382 status: self.status.clone(),
383 total_rows: self.total_rows,
384 files_produced: self.files_produced as u64,
385 bytes_written: self.bytes_written,
386 duration_ms: self.duration_ms,
387 peak_rss_mb: self.peak_rss_mb,
388 error_message: self.error_message.clone(),
389 });
390 return;
391 }
392
393 self.print_stderr_block();
394 }
395
396 pub(super) fn print_stderr_block(&self) {
401 let block = if multi_export_mode() {
402 self.render_compact()
403 } else {
404 self.render().trim_end_matches('\n').to_string()
410 };
411
412 use std::io::Write;
413 let mut buf = super::parent_ui::sanitize_terminal(&block);
420 buf.push('\n');
421 let stderr = std::io::stderr();
422 let mut handle = stderr.lock();
423 let _ = handle.write_all(buf.as_bytes());
424 let _ = handle.flush();
425 }
426
427 fn render_compact(&self) -> String {
432 const NAME_COL: usize = 22;
433 const MODE_COL: usize = 8;
434 let icon = match self.status.as_str() {
435 "success" => "✓",
436 "failed" => "✗",
437 _ => "•",
438 };
439 let body = if self.status == "failed" {
440 let err = self
441 .error_message
442 .as_deref()
443 .unwrap_or("(no error message recorded)");
444 let (cause, _) = strip_chunked_recovery_hint(err);
445 compact_error(cause)
449 } else {
450 let rss = if self.peak_rss_mb > 0 {
451 format!(" RSS {} MB", fmt_thousands(self.peak_rss_mb))
452 } else {
453 String::new()
454 };
455 format!(
456 "{} rows {} files {} {}{}",
457 fmt_thousands(self.total_rows),
458 fmt_thousands(self.files_produced as i64),
459 format_bytes(self.bytes_written),
460 fmt_duration_ms(self.duration_ms),
461 rss
462 )
463 };
464 format!(
465 "{} {:<name$} {:<mode$} {}",
466 icon,
467 self.export_name,
468 self.mode,
469 body,
470 name = NAME_COL,
471 mode = MODE_COL,
472 )
473 }
474
475 fn render(&self) -> String {
487 let mut rows: Vec<Row> = Vec::with_capacity(16);
488 rows.push(("run_id", self.run_id.clone()));
489 rows.push(self.status_row());
490 rows.push(self.tuning_row());
491 rows.push(("rows", fmt_thousands(self.total_rows)));
492 rows.push(("files", fmt_thousands(self.files_produced as i64)));
493 rows.extend(self.output_row());
494 rows.extend(self.position_row());
495 rows.extend(self.bytes_row());
496 rows.push(("duration", fmt_duration_ms(self.duration_ms)));
497 rows.extend(self.peak_rss_row());
498 rows.extend(self.pg_temp_spill_row());
499 rows.extend(self.compression_row());
500 rows.extend(self.retries_row());
501 rows.extend(self.outcome_rows());
502 rows.extend(self.error_row());
503 format_block(&self.export_name, &rows)
504 }
505
506 fn status_row(&self) -> Row {
508 let value = match (&self.status, &self.skip_reason) {
509 (s, Some(reason)) if s == "skipped" => format!("{s} ({reason})"),
510 (s, _) => s.clone(),
511 };
512 ("status", value)
513 }
514
515 fn tuning_row(&self) -> Row {
518 let value = match self.batch_size_memory_mb {
519 Some(mem) => format!(
520 "profile={}, batch_size={} (batch_size_memory_mb={}MiB → effective FETCH in logs)",
521 self.tuning_profile,
522 fmt_thousands(self.batch_size as i64),
523 mem
524 ),
525 None => format!(
526 "profile={}, batch_size={}",
527 self.tuning_profile,
528 fmt_thousands(self.batch_size as i64)
529 ),
530 };
531 ("tuning", value)
532 }
533
534 fn output_row(&self) -> Option<Row> {
538 if self.files_produced > 0 {
539 self.destination_uri.clone().map(|uri| ("output", uri))
540 } else {
541 None
542 }
543 }
544
545 fn position_row(&self) -> Option<Row> {
552 if let Some(pos) = incremental_position_line(self.skip_reason.as_deref()) {
553 Some(("cursor", pos))
554 } else {
555 time_window_skip_line(&self.mode, self.skip_reason.as_deref()).map(|w| ("window", w))
556 }
557 }
558
559 fn bytes_row(&self) -> Option<Row> {
561 if self.bytes_written > 0 {
562 Some(("bytes", format_bytes(self.bytes_written)))
563 } else {
564 None
565 }
566 }
567
568 fn peak_rss_row(&self) -> Option<Row> {
570 if self.peak_rss_mb > 0 {
571 Some((
572 "peak RSS",
573 format!(
574 "{} MB (sampled during run)",
575 fmt_thousands(self.peak_rss_mb)
576 ),
577 ))
578 } else {
579 None
580 }
581 }
582
583 fn pg_temp_spill_row(&self) -> Option<Row> {
587 let temp = self.pg_temp_bytes_delta?;
588 if temp <= 0 {
589 return None;
590 }
591 let temp_mb = temp as f64 / (1024.0 * 1024.0);
592 let label = if temp > 100 * 1024 * 1024 {
593 format!(
594 "{:.1} MB ⚠ shrink tuning.batch_size or set batch_size_memory_mb",
595 temp_mb
596 )
597 } else {
598 format!("{:.1} MB", temp_mb)
599 };
600 Some(("pg temp spill", label))
601 }
602
603 fn compression_row(&self) -> Option<Row> {
605 if self.format == "parquet" && self.compression != "zstd" {
606 Some(("compression", self.compression.clone()))
607 } else {
608 None
609 }
610 }
611
612 fn retries_row(&self) -> Option<Row> {
614 if self.retries > 0 {
615 Some(("retries", self.retries.to_string()))
616 } else {
617 None
618 }
619 }
620
621 fn outcome_rows(&self) -> Vec<Row> {
627 let mut rows: Vec<Row> = Vec::new();
628 if let Some(v) = self.validated {
629 rows.push(("validated", if v { "pass".into() } else { "FAIL".into() }));
630 }
631 if let Some(sc) = self.schema_changed {
632 rows.push((
633 "schema",
634 if sc {
635 "CHANGED".into()
636 } else {
637 "unchanged".into()
638 },
639 ));
640 }
641 if let Some(q) = self.quality_passed {
642 rows.push(("quality", if q { "pass".into() } else { "FAIL".into() }));
643 }
644 if let Some(reconciled) = self.reconciled {
645 let src = self
646 .source_count
647 .map(fmt_thousands)
648 .unwrap_or_else(|| "?".into());
649 let exported = fmt_thousands(self.total_rows);
650 let value = if reconciled {
651 format!("MATCH ({exported}/{src})")
652 } else {
653 format!("MISMATCH (exported {exported} vs source {src})")
654 };
655 rows.push(("reconcile", value));
656 }
657 if self.status == "success"
662 && self.files_produced > 0
663 && self.validated.is_none()
664 && self.reconciled.is_none()
665 {
666 rows.push((
667 "verify",
668 "not run — add `--reconcile` (count vs source) or `rivet validate` (re-read outputs)"
669 .into(),
670 ));
671 }
672 rows
673 }
674
675 fn error_row(&self) -> Option<Row> {
681 self.error_message
682 .as_ref()
683 .map(|err| ("error", err.trim_end().to_string()))
684 }
685
686 pub fn check_post_run_invariants(&self) -> Result<(), String> {
702 let parts_bytes: u64 = self.manifest_parts.iter().map(|p| p.size_bytes).sum();
703
704 if self.files_committed > self.manifest_parts.len() {
705 return Err(format!(
706 "summary.files_committed ({}) > manifest_parts.len() ({}) — \
707 a runner bumped files_committed without commit::record_part",
708 self.files_committed,
709 self.manifest_parts.len()
710 ));
711 }
712 if self.files_produced > self.manifest_parts.len() {
713 return Err(format!(
714 "summary.files_produced ({}) > manifest_parts.len() ({}) — \
715 a runner bumped files_produced without commit::record_part",
716 self.files_produced,
717 self.manifest_parts.len()
718 ));
719 }
720 if self.bytes_written > parts_bytes {
721 return Err(format!(
722 "summary.bytes_written ({}) > sum(manifest_parts.size_bytes) ({}) — \
723 a runner bumped bytes_written without commit::record_part",
724 self.bytes_written, parts_bytes
725 ));
726 }
727 if self.status == "success" && self.files_committed > 0 && self.manifest_parts.is_empty() {
728 return Err(format!(
729 "success run with files_committed={} has empty manifest_parts — \
730 cloud manifest (ADR-0012 M1) would ship with no part list \
731 (this is the gap parallel_checkpoint had before commit e9b0796)",
732 self.files_committed
733 ));
734 }
735 if self.status == "success" && self.total_rows > 0 && self.files_committed == 0 {
747 return Err(format!(
748 "summary.total_rows={} but files_committed=0 — rows extracted from \
749 source but no files committed (no output reached the destination)",
750 self.total_rows
751 ));
752 }
753 Ok(())
754 }
755}
756
757fn compact_error(raw: &str) -> String {
769 const MAX_CHARS: usize = 240;
770 if let Some(summary) = summarize_parallel_chunk_errors(raw) {
771 return clamp_chars(&summary, MAX_CHARS);
772 }
773 let collapsed: String = raw
774 .lines()
775 .map(str::trim_end)
776 .filter(|s| !s.is_empty())
777 .collect::<Vec<_>>()
778 .join("; ");
779 clamp_chars(&collapsed, MAX_CHARS)
780}
781
782fn incremental_position_line(skip_reason: Option<&str>) -> Option<String> {
790 let col = skip_reason?
791 .strip_prefix("no new rows since cursor '")?
792 .strip_suffix('\'')?;
793 Some(format!("'{col}' unchanged (no new rows this run)"))
794}
795
796fn time_window_skip_line(mode: &str, skip_reason: Option<&str>) -> Option<String> {
813 skip_reason?;
814 if mode != "timewindow" {
815 return None;
816 }
817 Some("rolling time window matched no rows — check `time_column`/`days_window`".to_string())
818}
819
820fn summarize_parallel_chunk_errors(raw: &str) -> Option<String> {
821 let header_pos = raw.find("parallel checkpoint worker errors:")?;
822 let prefix = raw[..header_pos].trim_end_matches(": ").trim_end();
823 let tail = &raw[header_pos + "parallel checkpoint worker errors:".len()..];
824
825 let chunk_lines: Vec<&str> = tail
826 .lines()
827 .map(str::trim)
828 .filter(|l| l.starts_with("chunk "))
829 .collect();
830 if chunk_lines.is_empty() {
831 return None;
832 }
833 let first_chunk_full = chunk_lines[0];
834 let first_chunk_short = clamp_chars(first_chunk_full, 140);
836 let prefix = if prefix.is_empty() {
837 String::new()
838 } else {
839 format!("{}: ", prefix)
840 };
841 Some(format!(
842 "{}parallel checkpoint workers failed: {} chunk(s) ({}); see stderr for full payloads",
843 prefix,
844 chunk_lines.len(),
845 first_chunk_short
846 ))
847}
848
849fn clamp_chars(s: &str, max_chars: usize) -> String {
850 if max_chars == 0 {
851 return String::new();
852 }
853 if s.chars().count() <= max_chars {
854 return s.to_string();
855 }
856 let keep = max_chars.saturating_sub(1);
857 let mut out: String = s.chars().take(keep).collect();
858 out.push('…');
859 out
860}
861
862fn format_block(name: &str, rows: &[(&str, String)]) -> String {
865 const HEADER_WIDTH: usize = 60;
866 let label_w = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
867
868 let prefix = format!("── {} ", name);
869 let prefix_chars = prefix.chars().count();
870 let dashes = HEADER_WIDTH.saturating_sub(prefix_chars);
871 let mut out = String::with_capacity(HEADER_WIDTH * (rows.len() + 3));
872 out.push('\n');
873 out.push_str(&prefix);
874 for _ in 0..dashes {
875 out.push('─');
876 }
877 out.push('\n');
878 let value_indent = " ".repeat(2 + (label_w + 1) + 2);
882 for (label, value) in rows {
883 let mut lines = value.split('\n');
886 let first = lines.next().unwrap_or("");
887 out.push_str(&format!(
888 " {:<width$} {}\n",
889 format!("{label}:"),
890 first,
891 width = label_w + 1
892 ));
893 for cont in lines {
894 out.push_str(&value_indent);
895 out.push_str(cont);
896 out.push('\n');
897 }
898 }
899 out
900}
901
902fn fmt_duration_ms(ms: i64) -> String {
903 if ms < 1000 {
904 return format!("{}ms", ms);
905 }
906 let total_secs = ms / 1000;
907 let h = total_secs / 3600;
908 let m = (total_secs % 3600) / 60;
909 let s_frac = (ms % 60_000) as f64 / 1000.0;
910 if h > 0 {
911 format!("{}h {:02}m {:04.1}s", h, m, s_frac)
912 } else if m > 0 {
913 format!("{}m {:04.1}s", m, s_frac)
914 } else {
915 format!("{:.1}s", ms as f64 / 1000.0)
916 }
917}
918
919fn fmt_thousands(n: i64) -> String {
923 let abs = n.unsigned_abs();
924 let s = abs.to_string();
925 let bytes = s.as_bytes();
926 let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
927 if n < 0 {
928 out.push('-');
929 }
930 for (i, b) in bytes.iter().enumerate() {
931 let from_end = bytes.len() - i;
932 if i > 0 && from_end.is_multiple_of(3) {
933 out.push(',');
934 }
935 out.push(*b as char);
936 }
937 out
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943
944 #[test]
945 fn fmt_thousands_handles_small_and_large() {
946 assert_eq!(fmt_thousands(0), "0");
947 assert_eq!(fmt_thousands(7), "7");
948 assert_eq!(fmt_thousands(999), "999");
949 assert_eq!(fmt_thousands(1_000), "1,000");
950 assert_eq!(fmt_thousands(1_000_908), "1,000,908");
951 assert_eq!(fmt_thousands(39_990_376), "39,990,376");
952 assert_eq!(fmt_thousands(-1_234), "-1,234");
953 assert_eq!(fmt_thousands(i64::MAX), "9,223,372,036,854,775,807");
954 }
955
956 #[test]
957 fn fmt_duration_picks_unit() {
958 assert_eq!(fmt_duration_ms(0), "0ms");
959 assert_eq!(fmt_duration_ms(800), "800ms");
960 assert_eq!(fmt_duration_ms(1_500), "1.5s");
961 assert_eq!(fmt_duration_ms(68_400), "1m 08.4s");
962 assert_eq!(fmt_duration_ms(3_725_300), "1h 02m 05.3s");
963 }
964
965 #[test]
966 fn format_block_pads_labels_uniformly() {
967 let rows = vec![
968 ("run_id", "abc".to_string()),
969 ("rows", "42".to_string()),
970 ("compression", "zstd".to_string()),
971 ];
972 let out = format_block("orders", &rows);
973
974 let lines: Vec<&str> = out.lines().filter(|l| l.contains(':')).collect();
976 assert_eq!(lines.len(), 3);
977 let value_starts: Vec<usize> = lines
978 .iter()
979 .map(|l| l.find(':').unwrap() + l[l.find(':').unwrap()..].find(' ').unwrap())
980 .collect();
981 let value_col = lines[0].rfind("abc").unwrap();
985 assert_eq!(lines[1].rfind("42").unwrap(), value_col);
986 assert_eq!(lines[2].rfind("zstd").unwrap(), value_col);
987 let _ = value_starts;
989 }
990
991 #[test]
992 fn format_block_header_has_consistent_width() {
993 let block_a = format_block("a", &[("rows", "1".into())]);
994 let block_b = format_block("orders_table_xyz", &[("rows", "1".into())]);
995 let header_a = block_a.lines().nth(1).unwrap();
996 let header_b = block_b.lines().nth(1).unwrap();
997 assert_eq!(
998 header_a.chars().count(),
999 header_b.chars().count(),
1000 "headers must be the same width regardless of name length: {:?} vs {:?}",
1001 header_a,
1002 header_b
1003 );
1004 }
1005
1006 #[test]
1007 fn render_produces_a_single_string_with_trailing_newline() {
1008 use crate::plan::{
1009 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1010 MetaColumns, ResolvedRunPlan,
1011 };
1012 use crate::tuning::SourceTuning;
1013 let plan = ResolvedRunPlan {
1014 export_name: "orders".into(),
1015 base_query: "SELECT 1".into(),
1016 strategy: ExtractionStrategy::Snapshot,
1017 format: FormatType::Parquet,
1018 compression: CompressionType::default(),
1019 compression_level: None,
1020 max_file_size_bytes: None,
1021 skip_empty: false,
1022 meta_columns: MetaColumns::default(),
1023 destination: DestinationConfig {
1024 destination_type: DestinationType::Local,
1025 path: Some("./out".into()),
1026 ..Default::default()
1027 },
1028 quality: None,
1029 tuning: SourceTuning::from_config(None),
1030 tuning_profile_label: "balanced (default)".into(),
1031 validate: false,
1032 reconcile: false,
1033 resume: false,
1034 source: crate::config::SourceConfig {
1035 source_type: crate::config::SourceType::Postgres,
1036 url: Some("postgresql://localhost/test".into()),
1037 url_env: None,
1038 url_file: None,
1039 host: None,
1040 port: None,
1041 user: None,
1042 password: None,
1043 password_env: None,
1044 database: None,
1045 environment: None,
1046 tuning: None,
1047 tls: None,
1048 mongo: None,
1049 },
1050 column_overrides: Default::default(),
1051 verify: crate::config::VerifyMode::Size,
1052 schema_drift_policy: Default::default(),
1053 shape_drift_warn_factor: 2.0,
1054 parquet: None,
1055 };
1056 let mut s = RunSummary::new(&plan);
1057 s.status = "success".into();
1058 s.total_rows = 1_000_908;
1059 s.files_produced = 11;
1060 s.bytes_written = 32 * 1024 * 1024 + 400 * 1024;
1061 s.duration_ms = 68_400;
1062 s.peak_rss_mb = 884;
1063
1064 let block = s.render();
1065 assert!(
1066 block.starts_with('\n'),
1067 "block should start with a blank line"
1068 );
1069 assert!(block.ends_with('\n'), "block should end with a newline");
1070 assert!(block.contains("── orders "));
1071 assert!(
1072 block.contains("1,000,908"),
1073 "rows should be formatted with thousands separator: {}",
1074 block
1075 );
1076 assert!(block.contains("1m 08.4s"), "duration formatting: {}", block);
1077 assert!(!block.contains('\r'));
1080
1081 let line = s.render_compact();
1083 assert!(line.starts_with("✓ "), "success icon present: {:?}", line);
1084 assert!(line.contains("orders"), "export name present: {:?}", line);
1085 assert!(line.contains("1,000,908 rows"), "rows present: {:?}", line);
1086 assert!(line.contains("32.4 MB"), "bytes present: {:?}", line);
1087 assert!(line.contains("1m 08.4s"), "duration present: {:?}", line);
1088 assert!(line.contains("RSS 884 MB"), "rss present: {:?}", line);
1089 assert!(!line.contains('\n'), "single line: {:?}", line);
1090 }
1091
1092 #[test]
1093 fn compact_error_summarises_parallel_chunk_errors() {
1094 let raw = "export 'page_views': parallel checkpoint worker errors:\n\
1095 chunk 4: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202442_chunk4.parquet?partNumber=1&uploadId=ABPnzm7RqplA, called: http_util::Client::send } => send http request, source: error sending request: client error (SendRequest): dispatch task is gone\n\
1096 chunk 5: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202443_chunk5.parquet?partNumber=1&uploadId=ABPnzm6q, called: http_util::Client::send } => send http request, source: dispatch task is gone";
1097 let out = compact_error(raw);
1098 assert!(
1099 out.contains("2 chunk(s)"),
1100 "should report number of failed chunks: {:?}",
1101 out
1102 );
1103 assert!(
1104 out.starts_with("export 'page_views': parallel checkpoint workers failed:"),
1105 "should keep export prefix and use compact phrasing: {:?}",
1106 out
1107 );
1108 assert!(
1109 out.contains("chunk 4:"),
1110 "should include the first chunk as an example: {:?}",
1111 out
1112 );
1113 assert!(!out.contains('\n'), "single line output: {:?}", out);
1114 assert!(
1115 out.chars().count() <= 240,
1116 "must be clamped to <=240 chars, got {}: {:?}",
1117 out.chars().count(),
1118 out
1119 );
1120 }
1121
1122 #[test]
1123 fn compact_error_collapses_generic_multiline() {
1124 let raw = "first line of trouble\nsecond line with detail\n\nthird line\n";
1125 let out = compact_error(raw);
1126 assert_eq!(
1127 out, "first line of trouble; second line with detail; third line",
1128 "newlines should collapse to '; ' and blanks dropped"
1129 );
1130 }
1131
1132 #[test]
1133 fn compact_error_clamps_excessively_long_lines() {
1134 let raw = "x".repeat(1_000);
1135 let out = compact_error(&raw);
1136 assert_eq!(out.chars().count(), 240);
1137 assert!(out.ends_with('…'));
1138 }
1139
1140 #[test]
1141 fn render_compact_strips_chunked_recovery_hint_for_failed() {
1142 use crate::plan::{
1143 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1144 MetaColumns, ResolvedRunPlan,
1145 };
1146 use crate::tuning::SourceTuning;
1147 let plan = ResolvedRunPlan {
1148 export_name: "events".into(),
1149 base_query: "SELECT 1".into(),
1150 strategy: ExtractionStrategy::Snapshot,
1151 format: FormatType::Parquet,
1152 compression: CompressionType::default(),
1153 compression_level: None,
1154 max_file_size_bytes: None,
1155 skip_empty: false,
1156 meta_columns: MetaColumns::default(),
1157 destination: DestinationConfig {
1158 destination_type: DestinationType::Local,
1159 path: Some("./out".into()),
1160 ..Default::default()
1161 },
1162 quality: None,
1163 tuning: SourceTuning::from_config(None),
1164 tuning_profile_label: "balanced (default)".into(),
1165 validate: false,
1166 reconcile: false,
1167 resume: false,
1168 source: crate::config::SourceConfig {
1169 source_type: crate::config::SourceType::Postgres,
1170 url: Some("postgresql://localhost/test".into()),
1171 url_env: None,
1172 url_file: None,
1173 host: None,
1174 port: None,
1175 user: None,
1176 password: None,
1177 password_env: None,
1178 database: None,
1179 environment: None,
1180 tuning: None,
1181 tls: None,
1182 mongo: None,
1183 },
1184 column_overrides: Default::default(),
1185 verify: crate::config::VerifyMode::Size,
1186 schema_drift_policy: Default::default(),
1187 shape_drift_warn_factor: 2.0,
1188 parquet: None,
1189 };
1190 let mut s = RunSummary::new(&plan);
1191 s.status = "failed".into();
1192 s.error_message = Some(
1193 "export 'events': --resume but no in-progress chunk checkpoint; \
1194 run without --resume first or `rivet state reset-chunks --config x.yaml --export events`"
1195 .to_string(),
1196 );
1197
1198 let line = s.render_compact();
1199 assert!(line.starts_with("✗ "), "failure icon: {:?}", line);
1200 assert!(line.contains("events"), "name present: {:?}", line);
1201 assert!(
1202 line.contains("--resume but no in-progress chunk checkpoint"),
1203 "cause kept: {:?}",
1204 line
1205 );
1206 assert!(
1207 !line.contains("rivet state reset-chunks"),
1208 "recovery hint should be stripped from per-export line: {:?}",
1209 line
1210 );
1211 assert!(!line.contains('\n'), "single line: {:?}", line);
1212 }
1213
1214 fn plan_for(export_name: &str) -> crate::plan::ResolvedRunPlan {
1215 use crate::plan::{
1216 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1217 MetaColumns, ResolvedRunPlan,
1218 };
1219 use crate::tuning::SourceTuning;
1220 ResolvedRunPlan {
1221 export_name: export_name.into(),
1222 base_query: "SELECT 1".into(),
1223 strategy: ExtractionStrategy::Snapshot,
1224 format: FormatType::Parquet,
1225 compression: CompressionType::default(),
1226 compression_level: None,
1227 max_file_size_bytes: None,
1228 skip_empty: false,
1229 meta_columns: MetaColumns::default(),
1230 destination: DestinationConfig {
1231 destination_type: DestinationType::Local,
1232 path: Some("./out".into()),
1233 ..Default::default()
1234 },
1235 quality: None,
1236 tuning: SourceTuning::from_config(None),
1237 tuning_profile_label: "balanced (default)".into(),
1238 validate: false,
1239 reconcile: false,
1240 resume: false,
1241 source: crate::config::SourceConfig {
1242 source_type: crate::config::SourceType::Postgres,
1243 url: Some("postgresql://localhost/test".into()),
1244 url_env: None,
1245 url_file: None,
1246 host: None,
1247 port: None,
1248 user: None,
1249 password: None,
1250 password_env: None,
1251 database: None,
1252 environment: None,
1253 tuning: None,
1254 tls: None,
1255 mongo: None,
1256 },
1257 column_overrides: Default::default(),
1258 verify: crate::config::VerifyMode::Size,
1259 schema_drift_policy: Default::default(),
1260 shape_drift_warn_factor: 2.0,
1261 parquet: None,
1262 }
1263 }
1264
1265 #[test]
1266 fn plan_snapshot_records_chunk_key_and_resumable_for_post_mortem() {
1267 use crate::plan::{ExtractionStrategy, KeysetPlan};
1268 let mut plan = plan_for("statistic_lifetime");
1272 plan.strategy = ExtractionStrategy::Keyset(KeysetPlan {
1273 key_column: "id".into(),
1274 chunk_size: 1_000_000,
1275 checkpoint: true,
1276 incremental: false,
1277 parallel: 1,
1278 });
1279 let s = RunSummary::new(&plan);
1280 let snap = s.journal.plan_snapshot().expect("PlanResolved recorded");
1281 assert_eq!(snap.chunk_key.as_deref(), Some("id"));
1282 assert!(
1283 snap.resumable,
1284 "chunk_checkpoint on → resumable must be recorded"
1285 );
1286
1287 let full = RunSummary::new(&plan_for("small"));
1289 let fsnap = full.journal.plan_snapshot().unwrap();
1290 assert_eq!(fsnap.chunk_key, None);
1291 assert!(!fsnap.resumable);
1292 }
1293
1294 #[test]
1295 fn plan_snapshot_deserializes_pre_field_journal_as_defaults() {
1296 let legacy = r#"{
1301 "export_name":"orders","base_query":"SELECT 1","strategy":"keyset",
1302 "format":"parquet","compression":"zstd","destination_type":"gcs",
1303 "tuning_profile":"balanced","batch_size":10000,
1304 "validate":false,"reconcile":false,"resume":false
1305 }"#;
1306 let snap: crate::journal::PlanSnapshot = serde_json::from_str(legacy).unwrap();
1307 assert_eq!(snap.chunk_key, None);
1308 assert!(!snap.resumable);
1309 assert_eq!(snap.strategy, "keyset");
1310 }
1311
1312 #[test]
1313 fn render_preserves_multiline_error_block() {
1314 let mut s = RunSummary::new(&plan_for("orders"));
1318 s.status = "failed".into();
1319 s.error_message = Some(
1320 "export 'orders': 1 quality check(s) failed:\n \
1321 - row_count 10 below minimum 999999\n \
1322 Fix the source data, or adjust the thresholds under `quality:` in your config."
1323 .to_string(),
1324 );
1325
1326 let block = s.render();
1327 assert!(
1330 !block.contains("failed:;"),
1331 "error must not be '; '-flattened in the detailed block: {block}"
1332 );
1333 assert!(
1334 block.contains("- row_count 10 below minimum 999999"),
1335 "failing check line present: {block}"
1336 );
1337 let err_lines: Vec<&str> = block
1339 .lines()
1340 .filter(|l| {
1341 l.contains("quality check(s) failed")
1342 || l.contains("row_count 10 below minimum")
1343 || l.contains("Fix the source data")
1344 })
1345 .collect();
1346 assert_eq!(
1347 err_lines.len(),
1348 3,
1349 "all three error lines should render on separate lines: {block}"
1350 );
1351 for l in &err_lines {
1353 assert!(l.starts_with(' '), "error line should be indented: {l:?}");
1354 }
1355 }
1356
1357 #[test]
1358 fn render_nudges_verification_when_unverified_success() {
1359 let mut s = RunSummary::new(&plan_for("orders"));
1362 s.status = "success".into();
1363 s.files_produced = 3;
1364 s.total_rows = 1_000;
1365 let block = s.render();
1367 assert!(
1368 block.lines().any(|l| l.trim_start().starts_with("verify:")),
1369 "expected a verify nudge on an unverified success: {block}"
1370 );
1371
1372 let mut s2 = RunSummary::new(&plan_for("orders"));
1374 s2.status = "success".into();
1375 s2.files_produced = 3;
1376 s2.validated = Some(true);
1377 let block2 = s2.render();
1378 assert!(
1379 !block2
1380 .lines()
1381 .any(|l| l.trim_start().starts_with("verify:")),
1382 "a verified run must not nudge: {block2}"
1383 );
1384 }
1385
1386 #[test]
1387 fn pg_temp_spill_row_only_for_real_spill_and_annotates_large() {
1388 let mut s = RunSummary::stub_for_testing("r", "orders");
1391 assert_eq!(s.pg_temp_spill_row(), None, "no delta → no row");
1392 s.pg_temp_bytes_delta = Some(0);
1393 assert_eq!(s.pg_temp_spill_row(), None, "zero spill → no row");
1394 s.pg_temp_bytes_delta = Some(-5);
1395 assert_eq!(s.pg_temp_spill_row(), None, "negative delta → no row");
1396
1397 s.pg_temp_bytes_delta = Some(50 * 1024 * 1024);
1398 let (label, value) = s.pg_temp_spill_row().expect("50MB spill → row");
1399 assert_eq!(label, "pg temp spill");
1400 assert!(
1401 value.contains("50.0 MB") && !value.contains('⚠'),
1402 "small spill is plain info: {value:?}"
1403 );
1404
1405 s.pg_temp_bytes_delta = Some(200 * 1024 * 1024);
1406 let (_, value) = s.pg_temp_spill_row().expect("200MB spill → row");
1407 assert!(
1408 value.contains('⚠') && value.contains("batch_size"),
1409 "spill over 100 MB carries the tuning hint: {value:?}"
1410 );
1411 }
1412
1413 #[test]
1414 fn outcome_rows_format_reconcile_and_suppress_nudge_when_checked() {
1415 let mut s = RunSummary::stub_for_testing("r", "orders");
1416 s.reconciled = Some(true);
1417 s.source_count = Some(1_000);
1418 s.total_rows = 1_000;
1419 assert!(
1420 s.outcome_rows()
1421 .iter()
1422 .any(|(l, v)| *l == "reconcile" && v == "MATCH (1,000/1,000)"),
1423 "match wording: {:?}",
1424 s.outcome_rows()
1425 );
1426
1427 s.reconciled = Some(false);
1428 s.source_count = Some(1_200);
1429 let rows = s.outcome_rows();
1430 let recon = rows
1431 .iter()
1432 .find(|(l, _)| *l == "reconcile")
1433 .expect("reconcile row");
1434 assert!(
1435 recon.1.contains("MISMATCH") && recon.1.contains("1,000") && recon.1.contains("1,200"),
1436 "mismatch names both sides: {:?}",
1437 recon
1438 );
1439
1440 s.status = "success".into();
1443 s.files_produced = 2;
1444 assert!(
1445 !s.outcome_rows().iter().any(|(l, _)| *l == "verify"),
1446 "a reconciled run must not also nudge"
1447 );
1448 }
1449
1450 #[test]
1451 fn render_surfaces_cursor_position_on_zero_new_incremental() {
1452 let mut s = RunSummary::new(&plan_for("orders"));
1456 s.status = "skipped".into();
1457 s.skip_reason = Some("no new rows since cursor 'updated_at'".into());
1458
1459 let block = s.render();
1460 let cursor_line = block
1461 .lines()
1462 .find(|l| l.trim_start().starts_with("cursor:"))
1463 .unwrap_or_else(|| panic!("expected a cursor: line in block: {block}"));
1464 assert!(
1465 cursor_line.contains("'updated_at'"),
1466 "cursor line names the column: {cursor_line:?}"
1467 );
1468 assert!(
1469 cursor_line.contains("unchanged"),
1470 "cursor line reports the position held: {cursor_line:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn incremental_position_line_only_for_cursor_skips() {
1476 assert_eq!(
1478 incremental_position_line(Some("no new rows since cursor 'ts'")),
1479 Some("'ts' unchanged (no new rows this run)".into())
1480 );
1481 assert_eq!(
1482 incremental_position_line(Some("source returned 0 rows")),
1483 None
1484 );
1485 assert_eq!(incremental_position_line(None), None);
1486 }
1487
1488 #[test]
1489 fn render_surfaces_window_position_on_zero_row_time_window() {
1490 let mut s = RunSummary::new(&plan_for("events"));
1496 s.status = "skipped".into();
1497 s.mode = "timewindow".into();
1498 s.skip_reason = Some("source returned 0 rows".into());
1499
1500 let block = s.render();
1501 let window_line = block
1502 .lines()
1503 .find(|l| l.trim_start().starts_with("window:"))
1504 .unwrap_or_else(|| panic!("expected a window: line in block: {block}"));
1505 assert!(
1506 window_line.contains("matched no rows"),
1507 "window line reports the empty window: {window_line:?}"
1508 );
1509 assert!(
1510 window_line.contains("time_column") && window_line.contains("days_window"),
1511 "window line points at the window config to check: {window_line:?}"
1512 );
1513 assert!(
1515 !block.lines().any(|l| l.trim_start().starts_with("cursor:")),
1516 "no cursor line for a non-cursor strategy: {block}"
1517 );
1518 }
1519
1520 #[test]
1521 fn time_window_skip_line_only_for_skipped_time_window() {
1522 assert_eq!(
1524 time_window_skip_line("timewindow", Some("source returned 0 rows")),
1525 Some("rolling time window matched no rows — check `time_column`/`days_window`".into())
1526 );
1527 assert_eq!(
1529 time_window_skip_line("incremental", Some("source returned 0 rows")),
1530 None
1531 );
1532 assert_eq!(
1533 time_window_skip_line("full", Some("source returned 0 rows")),
1534 None
1535 );
1536 assert_eq!(time_window_skip_line("timewindow", None), None);
1538 }
1539}