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 }
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct ApplyContext {
53 pub plan_id: String,
55 pub forced: bool,
57 pub force_bypassed: Vec<String>,
62}
63
64#[derive(Debug, Clone, Default)]
70pub struct RunSummary {
71 pub run_id: String,
72 pub export_name: String,
73 pub status: String,
74 pub total_rows: i64,
75 pub files_produced: usize,
76 pub bytes_written: u64,
77 pub files_committed: usize,
80 pub duration_ms: i64,
81 pub peak_rss_mb: i64,
82 pub retries: u32,
83 pub validated: Option<bool>,
84 pub schema_changed: Option<bool>,
85 pub quality_passed: Option<bool>,
86 pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
89 pub checksum_key_column: Option<String>,
91 pub cursor_column: Option<String>,
96 pub cursor_low: Option<String>,
97 pub cursor_high: Option<String>,
98 pub error_message: Option<String>,
99 pub tuning_profile: String,
101 pub batch_size: usize,
103 pub batch_size_memory_mb: Option<usize>,
105 pub format: String,
106 pub mode: String,
107 pub compression: String,
108 pub destination_uri: Option<String>,
113 pub pg_temp_bytes_delta: Option<i64>,
120 pub skip_reason: Option<String>,
126 pub source_count: Option<i64>,
128 pub reconciled: Option<bool>,
130 pub manifest_parts: Vec<ManifestPart>,
135 pub schema_fingerprint: Option<String>,
149 pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
155 pub apply_context: Option<ApplyContext>,
159 pub journal: RunJournal,
161}
162
163type Row = (&'static str, String);
166
167impl RunSummary {
168 pub(super) fn new(plan: &ResolvedRunPlan) -> Self {
169 let run_id = format!(
170 "{}_{}",
171 plan.export_name,
172 chrono::Utc::now().format("%Y%m%dT%H%M%S%.3f"),
173 );
174 let mut journal = RunJournal::new(&run_id, &plan.export_name);
175 journal.record(RunEvent::PlanResolved(plan_snapshot_from(plan)));
176
177 ipc::emit_event(&ChildEvent::Started {
178 export_name: plan.export_name.clone(),
179 run_id: run_id.clone(),
180 mode: plan.strategy.mode_label().to_string(),
181 tuning_profile: plan.tuning_profile_label.clone(),
182 batch_size: plan.tuning.batch_size,
183 });
184
185 Self {
186 run_id,
187 export_name: plan.export_name.clone(),
188 status: "running".into(),
189 total_rows: 0,
190 files_produced: 0,
191 bytes_written: 0,
192 files_committed: 0,
193 duration_ms: 0,
194 peak_rss_mb: 0,
195 retries: 0,
196 validated: None,
197 schema_changed: None,
198 quality_passed: None,
199 error_message: None,
200 tuning_profile: plan.tuning_profile_label.clone(),
201 batch_size: plan.tuning.batch_size,
202 batch_size_memory_mb: plan.tuning.batch_size_memory_mb,
203 format: plan.format.label().to_string(),
204 mode: plan.strategy.mode_label().to_string(),
205 compression: plan.compression.label().to_string(),
206 destination_uri: (!matches!(
207 plan.destination.destination_type,
208 crate::config::DestinationType::Stdout
209 ))
210 .then(|| crate::pipeline::finalize::destination_uri_for_manifest(&plan.destination)),
211 pg_temp_bytes_delta: None,
212 skip_reason: None,
213 source_count: None,
214 reconciled: None,
215 manifest_parts: Vec::new(),
216 schema_fingerprint: None,
217 manifest_verification: None,
218 apply_context: None,
219 column_checksums: Vec::new(),
220 checksum_key_column: None,
221 cursor_column: None,
222 cursor_low: None,
223 cursor_high: None,
224 journal,
225 }
226 }
227
228 #[doc(hidden)]
249 #[allow(dead_code)]
250 pub fn stub_for_testing(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
251 let run_id = run_id.into();
252 let export_name = export_name.into();
253 let journal = RunJournal::new(&run_id, &export_name);
254 Self {
255 run_id,
256 export_name,
257 status: "running".into(),
258 total_rows: 0,
259 files_produced: 0,
260 bytes_written: 0,
261 files_committed: 0,
262 duration_ms: 0,
263 peak_rss_mb: 0,
264 retries: 0,
265 validated: None,
266 schema_changed: None,
267 quality_passed: None,
268 error_message: None,
269 tuning_profile: "balanced".into(),
270 batch_size: 1000,
271 batch_size_memory_mb: None,
272 format: "parquet".into(),
273 mode: "snapshot".into(),
274 compression: "zstd".into(),
275 destination_uri: None,
276 pg_temp_bytes_delta: None,
277 skip_reason: None,
278 source_count: None,
279 reconciled: None,
280 manifest_parts: Vec::new(),
281 schema_fingerprint: None,
282 manifest_verification: None,
283 apply_context: None,
284 column_checksums: Vec::new(),
285 checksum_key_column: None,
286 cursor_column: None,
287 cursor_low: None,
288 cursor_high: None,
289 journal,
290 }
291 }
292
293 #[doc(hidden)]
300 #[allow(dead_code)]
301 pub fn with_status(mut self, status: impl Into<String>) -> Self {
302 let s = status.into();
303 if (s == "success" || s == "failed") && self.journal.final_outcome().is_none() {
304 self.journal.record(RunEvent::RunCompleted {
305 status: s.clone(),
306 error_message: self.error_message.clone(),
307 duration_ms: self.duration_ms,
308 });
309 }
310 self.status = s;
311 self
312 }
313
314 #[doc(hidden)]
318 #[allow(dead_code)]
319 pub fn with_files_committed(mut self, n: usize) -> Self {
320 self.files_committed = n;
321 self
322 }
323
324 #[doc(hidden)]
328 #[allow(dead_code)]
329 pub fn with_manifest_parts(mut self, parts: Vec<crate::manifest::ManifestPart>) -> Self {
330 self.total_rows = parts.iter().map(|p| p.rows).sum();
331 self.bytes_written = parts.iter().map(|p| p.size_bytes).sum();
332 self.files_produced = parts.len();
333 self.files_committed = parts.len();
334 self.manifest_parts = parts;
335 self
336 }
337
338 #[doc(hidden)]
342 #[allow(dead_code)]
343 pub fn with_error(mut self, msg: impl Into<String>) -> Self {
344 self.error_message = Some(msg.into());
345 self
346 }
347
348 #[doc(hidden)]
353 #[allow(dead_code)]
354 pub fn with_plan_snapshot(mut self, snap: PlanSnapshot) -> Self {
355 self.journal.record(RunEvent::PlanResolved(snap));
356 self
357 }
358
359 pub(super) fn print(&self) {
360 if ipc::capturing_events() {
364 ipc::emit_event(&ChildEvent::Finished {
365 export_name: self.export_name.clone(),
366 run_id: self.run_id.clone(),
367 status: self.status.clone(),
368 total_rows: self.total_rows,
369 files_produced: self.files_produced as u64,
370 bytes_written: self.bytes_written,
371 duration_ms: self.duration_ms,
372 peak_rss_mb: self.peak_rss_mb,
373 error_message: self.error_message.clone(),
374 });
375 return;
376 }
377
378 self.print_stderr_block();
379 }
380
381 pub(super) fn print_stderr_block(&self) {
386 let block = if multi_export_mode() {
387 self.render_compact()
388 } else {
389 self.render().trim_end_matches('\n').to_string()
395 };
396
397 use std::io::Write;
398 let mut buf = super::parent_ui::sanitize_terminal(&block);
405 buf.push('\n');
406 let stderr = std::io::stderr();
407 let mut handle = stderr.lock();
408 let _ = handle.write_all(buf.as_bytes());
409 let _ = handle.flush();
410 }
411
412 fn render_compact(&self) -> String {
417 const NAME_COL: usize = 22;
418 const MODE_COL: usize = 8;
419 let icon = match self.status.as_str() {
420 "success" => "✓",
421 "failed" => "✗",
422 _ => "•",
423 };
424 let body = if self.status == "failed" {
425 let err = self
426 .error_message
427 .as_deref()
428 .unwrap_or("(no error message recorded)");
429 let (cause, _) = strip_chunked_recovery_hint(err);
430 compact_error(cause)
434 } else {
435 let rss = if self.peak_rss_mb > 0 {
436 format!(" RSS {} MB", fmt_thousands(self.peak_rss_mb))
437 } else {
438 String::new()
439 };
440 format!(
441 "{} rows {} files {} {}{}",
442 fmt_thousands(self.total_rows),
443 fmt_thousands(self.files_produced as i64),
444 format_bytes(self.bytes_written),
445 fmt_duration_ms(self.duration_ms),
446 rss
447 )
448 };
449 format!(
450 "{} {:<name$} {:<mode$} {}",
451 icon,
452 self.export_name,
453 self.mode,
454 body,
455 name = NAME_COL,
456 mode = MODE_COL,
457 )
458 }
459
460 fn render(&self) -> String {
472 let mut rows: Vec<Row> = Vec::with_capacity(16);
473 rows.push(("run_id", self.run_id.clone()));
474 rows.push(self.status_row());
475 rows.push(self.tuning_row());
476 rows.push(("rows", fmt_thousands(self.total_rows)));
477 rows.push(("files", fmt_thousands(self.files_produced as i64)));
478 rows.extend(self.output_row());
479 rows.extend(self.position_row());
480 rows.extend(self.bytes_row());
481 rows.push(("duration", fmt_duration_ms(self.duration_ms)));
482 rows.extend(self.peak_rss_row());
483 rows.extend(self.pg_temp_spill_row());
484 rows.extend(self.compression_row());
485 rows.extend(self.retries_row());
486 rows.extend(self.outcome_rows());
487 rows.extend(self.error_row());
488 format_block(&self.export_name, &rows)
489 }
490
491 fn status_row(&self) -> Row {
493 let value = match (&self.status, &self.skip_reason) {
494 (s, Some(reason)) if s == "skipped" => format!("{s} ({reason})"),
495 (s, _) => s.clone(),
496 };
497 ("status", value)
498 }
499
500 fn tuning_row(&self) -> Row {
503 let value = match self.batch_size_memory_mb {
504 Some(mem) => format!(
505 "profile={}, batch_size={} (batch_size_memory_mb={}MiB → effective FETCH in logs)",
506 self.tuning_profile,
507 fmt_thousands(self.batch_size as i64),
508 mem
509 ),
510 None => format!(
511 "profile={}, batch_size={}",
512 self.tuning_profile,
513 fmt_thousands(self.batch_size as i64)
514 ),
515 };
516 ("tuning", value)
517 }
518
519 fn output_row(&self) -> Option<Row> {
523 if self.files_produced > 0 {
524 self.destination_uri.clone().map(|uri| ("output", uri))
525 } else {
526 None
527 }
528 }
529
530 fn position_row(&self) -> Option<Row> {
537 if let Some(pos) = incremental_position_line(self.skip_reason.as_deref()) {
538 Some(("cursor", pos))
539 } else {
540 time_window_skip_line(&self.mode, self.skip_reason.as_deref()).map(|w| ("window", w))
541 }
542 }
543
544 fn bytes_row(&self) -> Option<Row> {
546 if self.bytes_written > 0 {
547 Some(("bytes", format_bytes(self.bytes_written)))
548 } else {
549 None
550 }
551 }
552
553 fn peak_rss_row(&self) -> Option<Row> {
555 if self.peak_rss_mb > 0 {
556 Some((
557 "peak RSS",
558 format!(
559 "{} MB (sampled during run)",
560 fmt_thousands(self.peak_rss_mb)
561 ),
562 ))
563 } else {
564 None
565 }
566 }
567
568 fn pg_temp_spill_row(&self) -> Option<Row> {
572 let temp = self.pg_temp_bytes_delta?;
573 if temp <= 0 {
574 return None;
575 }
576 let temp_mb = temp as f64 / (1024.0 * 1024.0);
577 let label = if temp > 100 * 1024 * 1024 {
578 format!(
579 "{:.1} MB ⚠ shrink tuning.batch_size or set batch_size_memory_mb",
580 temp_mb
581 )
582 } else {
583 format!("{:.1} MB", temp_mb)
584 };
585 Some(("pg temp spill", label))
586 }
587
588 fn compression_row(&self) -> Option<Row> {
590 if self.format == "parquet" && self.compression != "zstd" {
591 Some(("compression", self.compression.clone()))
592 } else {
593 None
594 }
595 }
596
597 fn retries_row(&self) -> Option<Row> {
599 if self.retries > 0 {
600 Some(("retries", self.retries.to_string()))
601 } else {
602 None
603 }
604 }
605
606 fn outcome_rows(&self) -> Vec<Row> {
612 let mut rows: Vec<Row> = Vec::new();
613 if let Some(v) = self.validated {
614 rows.push(("validated", if v { "pass".into() } else { "FAIL".into() }));
615 }
616 if let Some(sc) = self.schema_changed {
617 rows.push((
618 "schema",
619 if sc {
620 "CHANGED".into()
621 } else {
622 "unchanged".into()
623 },
624 ));
625 }
626 if let Some(q) = self.quality_passed {
627 rows.push(("quality", if q { "pass".into() } else { "FAIL".into() }));
628 }
629 if let Some(reconciled) = self.reconciled {
630 let src = self
631 .source_count
632 .map(fmt_thousands)
633 .unwrap_or_else(|| "?".into());
634 let exported = fmt_thousands(self.total_rows);
635 let value = if reconciled {
636 format!("MATCH ({exported}/{src})")
637 } else {
638 format!("MISMATCH (exported {exported} vs source {src})")
639 };
640 rows.push(("reconcile", value));
641 }
642 if self.status == "success"
647 && self.files_produced > 0
648 && self.validated.is_none()
649 && self.reconciled.is_none()
650 {
651 rows.push((
652 "verify",
653 "not run — add `--reconcile` (count vs source) or `rivet validate` (re-read outputs)"
654 .into(),
655 ));
656 }
657 rows
658 }
659
660 fn error_row(&self) -> Option<Row> {
666 self.error_message
667 .as_ref()
668 .map(|err| ("error", err.trim_end().to_string()))
669 }
670
671 pub fn check_post_run_invariants(&self) -> Result<(), String> {
687 let parts_bytes: u64 = self.manifest_parts.iter().map(|p| p.size_bytes).sum();
688
689 if self.files_committed > self.manifest_parts.len() {
690 return Err(format!(
691 "summary.files_committed ({}) > manifest_parts.len() ({}) — \
692 a runner bumped files_committed without commit::record_part",
693 self.files_committed,
694 self.manifest_parts.len()
695 ));
696 }
697 if self.files_produced > self.manifest_parts.len() {
698 return Err(format!(
699 "summary.files_produced ({}) > manifest_parts.len() ({}) — \
700 a runner bumped files_produced without commit::record_part",
701 self.files_produced,
702 self.manifest_parts.len()
703 ));
704 }
705 if self.bytes_written > parts_bytes {
706 return Err(format!(
707 "summary.bytes_written ({}) > sum(manifest_parts.size_bytes) ({}) — \
708 a runner bumped bytes_written without commit::record_part",
709 self.bytes_written, parts_bytes
710 ));
711 }
712 if self.status == "success" && self.files_committed > 0 && self.manifest_parts.is_empty() {
713 return Err(format!(
714 "success run with files_committed={} has empty manifest_parts — \
715 cloud manifest (ADR-0012 M1) would ship with no part list \
716 (this is the gap parallel_checkpoint had before commit e9b0796)",
717 self.files_committed
718 ));
719 }
720 if self.status == "success" && self.total_rows > 0 && self.files_committed == 0 {
732 return Err(format!(
733 "summary.total_rows={} but files_committed=0 — rows extracted from \
734 source but no files committed (no output reached the destination)",
735 self.total_rows
736 ));
737 }
738 Ok(())
739 }
740}
741
742fn compact_error(raw: &str) -> String {
754 const MAX_CHARS: usize = 240;
755 if let Some(summary) = summarize_parallel_chunk_errors(raw) {
756 return clamp_chars(&summary, MAX_CHARS);
757 }
758 let collapsed: String = raw
759 .lines()
760 .map(str::trim_end)
761 .filter(|s| !s.is_empty())
762 .collect::<Vec<_>>()
763 .join("; ");
764 clamp_chars(&collapsed, MAX_CHARS)
765}
766
767fn incremental_position_line(skip_reason: Option<&str>) -> Option<String> {
775 let col = skip_reason?
776 .strip_prefix("no new rows since cursor '")?
777 .strip_suffix('\'')?;
778 Some(format!("'{col}' unchanged (no new rows this run)"))
779}
780
781fn time_window_skip_line(mode: &str, skip_reason: Option<&str>) -> Option<String> {
798 skip_reason?;
799 if mode != "timewindow" {
800 return None;
801 }
802 Some("rolling time window matched no rows — check `time_column`/`days_window`".to_string())
803}
804
805fn summarize_parallel_chunk_errors(raw: &str) -> Option<String> {
806 let header_pos = raw.find("parallel checkpoint worker errors:")?;
807 let prefix = raw[..header_pos].trim_end_matches(": ").trim_end();
808 let tail = &raw[header_pos + "parallel checkpoint worker errors:".len()..];
809
810 let chunk_lines: Vec<&str> = tail
811 .lines()
812 .map(str::trim)
813 .filter(|l| l.starts_with("chunk "))
814 .collect();
815 if chunk_lines.is_empty() {
816 return None;
817 }
818 let first_chunk_full = chunk_lines[0];
819 let first_chunk_short = clamp_chars(first_chunk_full, 140);
821 let prefix = if prefix.is_empty() {
822 String::new()
823 } else {
824 format!("{}: ", prefix)
825 };
826 Some(format!(
827 "{}parallel checkpoint workers failed: {} chunk(s) ({}); see stderr for full payloads",
828 prefix,
829 chunk_lines.len(),
830 first_chunk_short
831 ))
832}
833
834fn clamp_chars(s: &str, max_chars: usize) -> String {
835 if max_chars == 0 {
836 return String::new();
837 }
838 if s.chars().count() <= max_chars {
839 return s.to_string();
840 }
841 let keep = max_chars.saturating_sub(1);
842 let mut out: String = s.chars().take(keep).collect();
843 out.push('…');
844 out
845}
846
847fn format_block(name: &str, rows: &[(&str, String)]) -> String {
850 const HEADER_WIDTH: usize = 60;
851 let label_w = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
852
853 let prefix = format!("── {} ", name);
854 let prefix_chars = prefix.chars().count();
855 let dashes = HEADER_WIDTH.saturating_sub(prefix_chars);
856 let mut out = String::with_capacity(HEADER_WIDTH * (rows.len() + 3));
857 out.push('\n');
858 out.push_str(&prefix);
859 for _ in 0..dashes {
860 out.push('─');
861 }
862 out.push('\n');
863 let value_indent = " ".repeat(2 + (label_w + 1) + 2);
867 for (label, value) in rows {
868 let mut lines = value.split('\n');
871 let first = lines.next().unwrap_or("");
872 out.push_str(&format!(
873 " {:<width$} {}\n",
874 format!("{label}:"),
875 first,
876 width = label_w + 1
877 ));
878 for cont in lines {
879 out.push_str(&value_indent);
880 out.push_str(cont);
881 out.push('\n');
882 }
883 }
884 out
885}
886
887fn fmt_duration_ms(ms: i64) -> String {
888 if ms < 1000 {
889 return format!("{}ms", ms);
890 }
891 let total_secs = ms / 1000;
892 let h = total_secs / 3600;
893 let m = (total_secs % 3600) / 60;
894 let s_frac = (ms % 60_000) as f64 / 1000.0;
895 if h > 0 {
896 format!("{}h {:02}m {:04.1}s", h, m, s_frac)
897 } else if m > 0 {
898 format!("{}m {:04.1}s", m, s_frac)
899 } else {
900 format!("{:.1}s", ms as f64 / 1000.0)
901 }
902}
903
904fn fmt_thousands(n: i64) -> String {
908 let abs = n.unsigned_abs();
909 let s = abs.to_string();
910 let bytes = s.as_bytes();
911 let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
912 if n < 0 {
913 out.push('-');
914 }
915 for (i, b) in bytes.iter().enumerate() {
916 let from_end = bytes.len() - i;
917 if i > 0 && from_end.is_multiple_of(3) {
918 out.push(',');
919 }
920 out.push(*b as char);
921 }
922 out
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 #[test]
930 fn fmt_thousands_handles_small_and_large() {
931 assert_eq!(fmt_thousands(0), "0");
932 assert_eq!(fmt_thousands(7), "7");
933 assert_eq!(fmt_thousands(999), "999");
934 assert_eq!(fmt_thousands(1_000), "1,000");
935 assert_eq!(fmt_thousands(1_000_908), "1,000,908");
936 assert_eq!(fmt_thousands(39_990_376), "39,990,376");
937 assert_eq!(fmt_thousands(-1_234), "-1,234");
938 assert_eq!(fmt_thousands(i64::MAX), "9,223,372,036,854,775,807");
939 }
940
941 #[test]
942 fn fmt_duration_picks_unit() {
943 assert_eq!(fmt_duration_ms(0), "0ms");
944 assert_eq!(fmt_duration_ms(800), "800ms");
945 assert_eq!(fmt_duration_ms(1_500), "1.5s");
946 assert_eq!(fmt_duration_ms(68_400), "1m 08.4s");
947 assert_eq!(fmt_duration_ms(3_725_300), "1h 02m 05.3s");
948 }
949
950 #[test]
951 fn format_block_pads_labels_uniformly() {
952 let rows = vec![
953 ("run_id", "abc".to_string()),
954 ("rows", "42".to_string()),
955 ("compression", "zstd".to_string()),
956 ];
957 let out = format_block("orders", &rows);
958
959 let lines: Vec<&str> = out.lines().filter(|l| l.contains(':')).collect();
961 assert_eq!(lines.len(), 3);
962 let value_starts: Vec<usize> = lines
963 .iter()
964 .map(|l| l.find(':').unwrap() + l[l.find(':').unwrap()..].find(' ').unwrap())
965 .collect();
966 let value_col = lines[0].rfind("abc").unwrap();
970 assert_eq!(lines[1].rfind("42").unwrap(), value_col);
971 assert_eq!(lines[2].rfind("zstd").unwrap(), value_col);
972 let _ = value_starts;
974 }
975
976 #[test]
977 fn format_block_header_has_consistent_width() {
978 let block_a = format_block("a", &[("rows", "1".into())]);
979 let block_b = format_block("orders_table_xyz", &[("rows", "1".into())]);
980 let header_a = block_a.lines().nth(1).unwrap();
981 let header_b = block_b.lines().nth(1).unwrap();
982 assert_eq!(
983 header_a.chars().count(),
984 header_b.chars().count(),
985 "headers must be the same width regardless of name length: {:?} vs {:?}",
986 header_a,
987 header_b
988 );
989 }
990
991 #[test]
992 fn render_produces_a_single_string_with_trailing_newline() {
993 use crate::plan::{
994 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
995 MetaColumns, ResolvedRunPlan,
996 };
997 use crate::tuning::SourceTuning;
998 let plan = ResolvedRunPlan {
999 export_name: "orders".into(),
1000 base_query: "SELECT 1".into(),
1001 strategy: ExtractionStrategy::Snapshot,
1002 format: FormatType::Parquet,
1003 compression: CompressionType::default(),
1004 compression_level: None,
1005 max_file_size_bytes: None,
1006 skip_empty: false,
1007 meta_columns: MetaColumns::default(),
1008 destination: DestinationConfig {
1009 destination_type: DestinationType::Local,
1010 path: Some("./out".into()),
1011 ..Default::default()
1012 },
1013 quality: None,
1014 tuning: SourceTuning::from_config(None),
1015 tuning_profile_label: "balanced (default)".into(),
1016 validate: false,
1017 reconcile: false,
1018 resume: false,
1019 source: crate::config::SourceConfig {
1020 source_type: crate::config::SourceType::Postgres,
1021 url: Some("postgresql://localhost/test".into()),
1022 url_env: None,
1023 url_file: None,
1024 host: None,
1025 port: None,
1026 user: None,
1027 password: None,
1028 password_env: None,
1029 database: None,
1030 environment: None,
1031 tuning: None,
1032 tls: None,
1033 },
1034 column_overrides: Default::default(),
1035 verify: crate::config::VerifyMode::Size,
1036 schema_drift_policy: Default::default(),
1037 shape_drift_warn_factor: 2.0,
1038 parquet: None,
1039 };
1040 let mut s = RunSummary::new(&plan);
1041 s.status = "success".into();
1042 s.total_rows = 1_000_908;
1043 s.files_produced = 11;
1044 s.bytes_written = 32 * 1024 * 1024 + 400 * 1024;
1045 s.duration_ms = 68_400;
1046 s.peak_rss_mb = 884;
1047
1048 let block = s.render();
1049 assert!(
1050 block.starts_with('\n'),
1051 "block should start with a blank line"
1052 );
1053 assert!(block.ends_with('\n'), "block should end with a newline");
1054 assert!(block.contains("── orders "));
1055 assert!(
1056 block.contains("1,000,908"),
1057 "rows should be formatted with thousands separator: {}",
1058 block
1059 );
1060 assert!(block.contains("1m 08.4s"), "duration formatting: {}", block);
1061 assert!(!block.contains('\r'));
1064
1065 let line = s.render_compact();
1067 assert!(line.starts_with("✓ "), "success icon present: {:?}", line);
1068 assert!(line.contains("orders"), "export name present: {:?}", line);
1069 assert!(line.contains("1,000,908 rows"), "rows present: {:?}", line);
1070 assert!(line.contains("32.4 MB"), "bytes present: {:?}", line);
1071 assert!(line.contains("1m 08.4s"), "duration present: {:?}", line);
1072 assert!(line.contains("RSS 884 MB"), "rss present: {:?}", line);
1073 assert!(!line.contains('\n'), "single line: {:?}", line);
1074 }
1075
1076 #[test]
1077 fn compact_error_summarises_parallel_chunk_errors() {
1078 let raw = "export 'page_views': parallel checkpoint worker errors:\n\
1079 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\
1080 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";
1081 let out = compact_error(raw);
1082 assert!(
1083 out.contains("2 chunk(s)"),
1084 "should report number of failed chunks: {:?}",
1085 out
1086 );
1087 assert!(
1088 out.starts_with("export 'page_views': parallel checkpoint workers failed:"),
1089 "should keep export prefix and use compact phrasing: {:?}",
1090 out
1091 );
1092 assert!(
1093 out.contains("chunk 4:"),
1094 "should include the first chunk as an example: {:?}",
1095 out
1096 );
1097 assert!(!out.contains('\n'), "single line output: {:?}", out);
1098 assert!(
1099 out.chars().count() <= 240,
1100 "must be clamped to <=240 chars, got {}: {:?}",
1101 out.chars().count(),
1102 out
1103 );
1104 }
1105
1106 #[test]
1107 fn compact_error_collapses_generic_multiline() {
1108 let raw = "first line of trouble\nsecond line with detail\n\nthird line\n";
1109 let out = compact_error(raw);
1110 assert_eq!(
1111 out, "first line of trouble; second line with detail; third line",
1112 "newlines should collapse to '; ' and blanks dropped"
1113 );
1114 }
1115
1116 #[test]
1117 fn compact_error_clamps_excessively_long_lines() {
1118 let raw = "x".repeat(1_000);
1119 let out = compact_error(&raw);
1120 assert_eq!(out.chars().count(), 240);
1121 assert!(out.ends_with('…'));
1122 }
1123
1124 #[test]
1125 fn render_compact_strips_chunked_recovery_hint_for_failed() {
1126 use crate::plan::{
1127 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1128 MetaColumns, ResolvedRunPlan,
1129 };
1130 use crate::tuning::SourceTuning;
1131 let plan = ResolvedRunPlan {
1132 export_name: "events".into(),
1133 base_query: "SELECT 1".into(),
1134 strategy: ExtractionStrategy::Snapshot,
1135 format: FormatType::Parquet,
1136 compression: CompressionType::default(),
1137 compression_level: None,
1138 max_file_size_bytes: None,
1139 skip_empty: false,
1140 meta_columns: MetaColumns::default(),
1141 destination: DestinationConfig {
1142 destination_type: DestinationType::Local,
1143 path: Some("./out".into()),
1144 ..Default::default()
1145 },
1146 quality: None,
1147 tuning: SourceTuning::from_config(None),
1148 tuning_profile_label: "balanced (default)".into(),
1149 validate: false,
1150 reconcile: false,
1151 resume: false,
1152 source: crate::config::SourceConfig {
1153 source_type: crate::config::SourceType::Postgres,
1154 url: Some("postgresql://localhost/test".into()),
1155 url_env: None,
1156 url_file: None,
1157 host: None,
1158 port: None,
1159 user: None,
1160 password: None,
1161 password_env: None,
1162 database: None,
1163 environment: None,
1164 tuning: None,
1165 tls: None,
1166 },
1167 column_overrides: Default::default(),
1168 verify: crate::config::VerifyMode::Size,
1169 schema_drift_policy: Default::default(),
1170 shape_drift_warn_factor: 2.0,
1171 parquet: None,
1172 };
1173 let mut s = RunSummary::new(&plan);
1174 s.status = "failed".into();
1175 s.error_message = Some(
1176 "export 'events': --resume but no in-progress chunk checkpoint; \
1177 run without --resume first or `rivet state reset-chunks --config x.yaml --export events`"
1178 .to_string(),
1179 );
1180
1181 let line = s.render_compact();
1182 assert!(line.starts_with("✗ "), "failure icon: {:?}", line);
1183 assert!(line.contains("events"), "name present: {:?}", line);
1184 assert!(
1185 line.contains("--resume but no in-progress chunk checkpoint"),
1186 "cause kept: {:?}",
1187 line
1188 );
1189 assert!(
1190 !line.contains("rivet state reset-chunks"),
1191 "recovery hint should be stripped from per-export line: {:?}",
1192 line
1193 );
1194 assert!(!line.contains('\n'), "single line: {:?}", line);
1195 }
1196
1197 fn plan_for(export_name: &str) -> crate::plan::ResolvedRunPlan {
1198 use crate::plan::{
1199 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1200 MetaColumns, ResolvedRunPlan,
1201 };
1202 use crate::tuning::SourceTuning;
1203 ResolvedRunPlan {
1204 export_name: export_name.into(),
1205 base_query: "SELECT 1".into(),
1206 strategy: ExtractionStrategy::Snapshot,
1207 format: FormatType::Parquet,
1208 compression: CompressionType::default(),
1209 compression_level: None,
1210 max_file_size_bytes: None,
1211 skip_empty: false,
1212 meta_columns: MetaColumns::default(),
1213 destination: DestinationConfig {
1214 destination_type: DestinationType::Local,
1215 path: Some("./out".into()),
1216 ..Default::default()
1217 },
1218 quality: None,
1219 tuning: SourceTuning::from_config(None),
1220 tuning_profile_label: "balanced (default)".into(),
1221 validate: false,
1222 reconcile: false,
1223 resume: false,
1224 source: crate::config::SourceConfig {
1225 source_type: crate::config::SourceType::Postgres,
1226 url: Some("postgresql://localhost/test".into()),
1227 url_env: None,
1228 url_file: None,
1229 host: None,
1230 port: None,
1231 user: None,
1232 password: None,
1233 password_env: None,
1234 database: None,
1235 environment: None,
1236 tuning: None,
1237 tls: None,
1238 },
1239 column_overrides: Default::default(),
1240 verify: crate::config::VerifyMode::Size,
1241 schema_drift_policy: Default::default(),
1242 shape_drift_warn_factor: 2.0,
1243 parquet: None,
1244 }
1245 }
1246
1247 #[test]
1248 fn render_preserves_multiline_error_block() {
1249 let mut s = RunSummary::new(&plan_for("orders"));
1253 s.status = "failed".into();
1254 s.error_message = Some(
1255 "export 'orders': 1 quality check(s) failed:\n \
1256 - row_count 10 below minimum 999999\n \
1257 Fix the source data, or adjust the thresholds under `quality:` in your config."
1258 .to_string(),
1259 );
1260
1261 let block = s.render();
1262 assert!(
1265 !block.contains("failed:;"),
1266 "error must not be '; '-flattened in the detailed block: {block}"
1267 );
1268 assert!(
1269 block.contains("- row_count 10 below minimum 999999"),
1270 "failing check line present: {block}"
1271 );
1272 let err_lines: Vec<&str> = block
1274 .lines()
1275 .filter(|l| {
1276 l.contains("quality check(s) failed")
1277 || l.contains("row_count 10 below minimum")
1278 || l.contains("Fix the source data")
1279 })
1280 .collect();
1281 assert_eq!(
1282 err_lines.len(),
1283 3,
1284 "all three error lines should render on separate lines: {block}"
1285 );
1286 for l in &err_lines {
1288 assert!(l.starts_with(' '), "error line should be indented: {l:?}");
1289 }
1290 }
1291
1292 #[test]
1293 fn render_nudges_verification_when_unverified_success() {
1294 let mut s = RunSummary::new(&plan_for("orders"));
1297 s.status = "success".into();
1298 s.files_produced = 3;
1299 s.total_rows = 1_000;
1300 let block = s.render();
1302 assert!(
1303 block.lines().any(|l| l.trim_start().starts_with("verify:")),
1304 "expected a verify nudge on an unverified success: {block}"
1305 );
1306
1307 let mut s2 = RunSummary::new(&plan_for("orders"));
1309 s2.status = "success".into();
1310 s2.files_produced = 3;
1311 s2.validated = Some(true);
1312 let block2 = s2.render();
1313 assert!(
1314 !block2
1315 .lines()
1316 .any(|l| l.trim_start().starts_with("verify:")),
1317 "a verified run must not nudge: {block2}"
1318 );
1319 }
1320
1321 #[test]
1322 fn pg_temp_spill_row_only_for_real_spill_and_annotates_large() {
1323 let mut s = RunSummary::stub_for_testing("r", "orders");
1326 assert_eq!(s.pg_temp_spill_row(), None, "no delta → no row");
1327 s.pg_temp_bytes_delta = Some(0);
1328 assert_eq!(s.pg_temp_spill_row(), None, "zero spill → no row");
1329 s.pg_temp_bytes_delta = Some(-5);
1330 assert_eq!(s.pg_temp_spill_row(), None, "negative delta → no row");
1331
1332 s.pg_temp_bytes_delta = Some(50 * 1024 * 1024);
1333 let (label, value) = s.pg_temp_spill_row().expect("50MB spill → row");
1334 assert_eq!(label, "pg temp spill");
1335 assert!(
1336 value.contains("50.0 MB") && !value.contains('⚠'),
1337 "small spill is plain info: {value:?}"
1338 );
1339
1340 s.pg_temp_bytes_delta = Some(200 * 1024 * 1024);
1341 let (_, value) = s.pg_temp_spill_row().expect("200MB spill → row");
1342 assert!(
1343 value.contains('⚠') && value.contains("batch_size"),
1344 "spill over 100 MB carries the tuning hint: {value:?}"
1345 );
1346 }
1347
1348 #[test]
1349 fn outcome_rows_format_reconcile_and_suppress_nudge_when_checked() {
1350 let mut s = RunSummary::stub_for_testing("r", "orders");
1351 s.reconciled = Some(true);
1352 s.source_count = Some(1_000);
1353 s.total_rows = 1_000;
1354 assert!(
1355 s.outcome_rows()
1356 .iter()
1357 .any(|(l, v)| *l == "reconcile" && v == "MATCH (1,000/1,000)"),
1358 "match wording: {:?}",
1359 s.outcome_rows()
1360 );
1361
1362 s.reconciled = Some(false);
1363 s.source_count = Some(1_200);
1364 let rows = s.outcome_rows();
1365 let recon = rows
1366 .iter()
1367 .find(|(l, _)| *l == "reconcile")
1368 .expect("reconcile row");
1369 assert!(
1370 recon.1.contains("MISMATCH") && recon.1.contains("1,000") && recon.1.contains("1,200"),
1371 "mismatch names both sides: {:?}",
1372 recon
1373 );
1374
1375 s.status = "success".into();
1378 s.files_produced = 2;
1379 assert!(
1380 !s.outcome_rows().iter().any(|(l, _)| *l == "verify"),
1381 "a reconciled run must not also nudge"
1382 );
1383 }
1384
1385 #[test]
1386 fn render_surfaces_cursor_position_on_zero_new_incremental() {
1387 let mut s = RunSummary::new(&plan_for("orders"));
1391 s.status = "skipped".into();
1392 s.skip_reason = Some("no new rows since cursor 'updated_at'".into());
1393
1394 let block = s.render();
1395 let cursor_line = block
1396 .lines()
1397 .find(|l| l.trim_start().starts_with("cursor:"))
1398 .unwrap_or_else(|| panic!("expected a cursor: line in block: {block}"));
1399 assert!(
1400 cursor_line.contains("'updated_at'"),
1401 "cursor line names the column: {cursor_line:?}"
1402 );
1403 assert!(
1404 cursor_line.contains("unchanged"),
1405 "cursor line reports the position held: {cursor_line:?}"
1406 );
1407 }
1408
1409 #[test]
1410 fn incremental_position_line_only_for_cursor_skips() {
1411 assert_eq!(
1413 incremental_position_line(Some("no new rows since cursor 'ts'")),
1414 Some("'ts' unchanged (no new rows this run)".into())
1415 );
1416 assert_eq!(
1417 incremental_position_line(Some("source returned 0 rows")),
1418 None
1419 );
1420 assert_eq!(incremental_position_line(None), None);
1421 }
1422
1423 #[test]
1424 fn render_surfaces_window_position_on_zero_row_time_window() {
1425 let mut s = RunSummary::new(&plan_for("events"));
1431 s.status = "skipped".into();
1432 s.mode = "timewindow".into();
1433 s.skip_reason = Some("source returned 0 rows".into());
1434
1435 let block = s.render();
1436 let window_line = block
1437 .lines()
1438 .find(|l| l.trim_start().starts_with("window:"))
1439 .unwrap_or_else(|| panic!("expected a window: line in block: {block}"));
1440 assert!(
1441 window_line.contains("matched no rows"),
1442 "window line reports the empty window: {window_line:?}"
1443 );
1444 assert!(
1445 window_line.contains("time_column") && window_line.contains("days_window"),
1446 "window line points at the window config to check: {window_line:?}"
1447 );
1448 assert!(
1450 !block.lines().any(|l| l.trim_start().starts_with("cursor:")),
1451 "no cursor line for a non-cursor strategy: {block}"
1452 );
1453 }
1454
1455 #[test]
1456 fn time_window_skip_line_only_for_skipped_time_window() {
1457 assert_eq!(
1459 time_window_skip_line("timewindow", Some("source returned 0 rows")),
1460 Some("rolling time window matched no rows — check `time_column`/`days_window`".into())
1461 );
1462 assert_eq!(
1464 time_window_skip_line("incremental", Some("source returned 0 rows")),
1465 None
1466 );
1467 assert_eq!(
1468 time_window_skip_line("full", Some("source returned 0 rows")),
1469 None
1470 );
1471 assert_eq!(time_window_skip_line("timewindow", None), None);
1473 }
1474}