Skip to main content

rivet/
journal.rs

1//! **Layer: Observability**
2// Query methods and event fields are intentionally defined for future consumers
3// (storage, CLI inspection, notifications).  Suppress dead_code for this module.
4#![allow(dead_code)]
5//!
6//! `RunJournal` is the canonical in-memory record of everything that happened
7//! during a pipeline run.  It accumulates typed, timestamped events and can
8//! answer the four observability questions from the Epic 10 DoD:
9//!
10//! | Question               | Method               |
11//! |------------------------|----------------------|
12//! | What was planned?      | `plan_snapshot()`    |
13//! | What happened?         | `files()`, `retries()`, `chunk_events()` |
14//! | What degraded?         | `quality_issues()`, `schema_changes()`, `warnings()` |
15//! | What was the outcome?  | `final_outcome()`    |
16//!
17//! `RunJournal` is currently embedded in `RunSummary` so that all pipeline
18//! modules — which already hold `&mut RunSummary` — can record events without
19//! signature changes.  A future epic will invert the relationship so that
20//! `RunSummary` is derived from `RunJournal`.
21
22//!
23//! This module is the canonical home for journal types. It deliberately has no
24//! dependencies on `plan`, `state`, or `pipeline` so that storage (state) and
25//! orchestration (pipeline) can both depend on it without creating a cycle.
26//! The `From<&ResolvedRunPlan>` conversion lives in `pipeline/summary.rs`
27//! beside the call site that needs it.
28
29use chrono::{DateTime, Utc};
30use serde::{Deserialize, Serialize};
31
32// ─── Plan snapshot ───────────────────────────────────────────────────────────
33
34/// Owned, serialisable snapshot of the resolved execution plan, captured at
35/// the moment a run starts.  Answers "what was planned?" without requiring the
36/// original `ResolvedRunPlan` to remain in scope.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PlanSnapshot {
39    pub export_name: String,
40    pub base_query: String,
41    pub strategy: String,
42    pub format: String,
43    pub compression: String,
44    pub destination_type: String,
45    pub tuning_profile: String,
46    pub batch_size: usize,
47    pub validate: bool,
48    pub reconcile: bool,
49    pub resume: bool,
50    /// Column that paged the export — the keyset `chunk_by_key` or the range
51    /// `chunk_column`; `None` for full/incremental. Persisted so a post-mortem
52    /// from the state DB alone knows WHICH column drove the read (the chunk
53    /// tables keep only chunk *values*, never the column name). `#[serde(default)]`
54    /// so a journal written before this field still deserializes as `None`.
55    #[serde(default)]
56    pub chunk_key: Option<String>,
57    /// Whether crash-resume via a checkpoint was enabled for this run (keyset or
58    /// chunked `chunk_checkpoint`). Persisted so a post-mortem knows whether a
59    /// crashed run could have resumed instead of restarting. `#[serde(default)]`
60    /// so a pre-existing journal deserializes as `false`.
61    #[serde(default)]
62    pub resumable: bool,
63}
64
65// ─── Events ──────────────────────────────────────────────────────────────────
66
67/// A single typed event emitted during a pipeline run.
68///
69/// Variants are grouped by DoD question:
70/// - *Planned* — `PlanResolved`, `PlanWarning`
71/// - *Happened* — `FileWritten`, `ChunkStarted`, `ChunkCompleted`, `ChunkFailed`, `RetryAttempted`
72/// - *Degraded* — `QualityIssue`, `SchemaChanged`, `Warning`
73/// - *Succeeded* — `ValidationResult`, `ReconciliationResult`
74/// - *Outcome* — `RunCompleted`
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub enum RunEvent {
77    // ── Planned ──────────────────────────────────────────────
78    /// Emitted once at the start of a run with a snapshot of the resolved plan.
79    PlanResolved(PlanSnapshot),
80    /// A plan validation diagnostic at `Warning` or `Degraded` level.
81    PlanWarning { rule: String, message: String },
82
83    // ── Happened ─────────────────────────────────────────────
84    /// One output file was successfully written to the destination.
85    FileWritten {
86        file_name: String,
87        rows: i64,
88        bytes: u64,
89        part_index: usize,
90    },
91    /// A chunk task transitioned from `pending` to `running`.
92    ChunkStarted {
93        chunk_index: i64,
94        start_key: String,
95        end_key: String,
96    },
97    /// A chunk task completed successfully.
98    ChunkCompleted {
99        chunk_index: i64,
100        rows: i64,
101        file_name: Option<String>,
102    },
103    /// A chunk task failed (may be retried up to `max_chunk_attempts`).
104    ChunkFailed {
105        chunk_index: i64,
106        error: String,
107        attempt: i64,
108    },
109    /// The pipeline is about to retry after a transient error.
110    RetryAttempted {
111        attempt: u32,
112        reason: String,
113        backoff_ms: u64,
114    },
115
116    // ── Degraded ─────────────────────────────────────────────
117    /// One quality rule fired (severity: `"FAIL"` or `"WARN"`).
118    QualityIssue { severity: String, message: String },
119    /// The output schema differs from the previously stored snapshot.
120    SchemaChanged {
121        /// Columns added since the last run, formatted as `"name (type)"`.
122        added: Vec<String>,
123        /// Column names removed since the last run.
124        removed: Vec<String>,
125        /// `(column, old_type, new_type)` for columns whose type changed.
126        type_changed: Vec<(String, String, String)>,
127    },
128    /// A non-fatal warning that does not fit another variant.
129    Warning { context: String, message: String },
130    /// The OPT-2 concurrency governor changed the active parallelism in
131    /// response to source pressure. `from`/`to` are permit counts; `reason`
132    /// describes the trigger (e.g. `"pressure rising: backed off"`).
133    ParallelismAdjusted {
134        from: usize,
135        to: usize,
136        reason: String,
137    },
138
139    // ── Succeeded ────────────────────────────────────────────
140    /// Output file row-count validation completed.
141    ValidationResult { passed: bool },
142    /// Source COUNT(*) reconciliation completed.
143    ReconciliationResult {
144        source_count: i64,
145        exported_rows: i64,
146        matched: bool,
147    },
148
149    // ── Outcome ──────────────────────────────────────────────
150    /// Terminal event — the run has reached its final state.
151    RunCompleted {
152        status: String,
153        error_message: Option<String>,
154        duration_ms: i64,
155    },
156}
157
158// ─── Journal entry ───────────────────────────────────────────────────────────
159
160/// A timestamped wrapper around a `RunEvent`.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct JournalEntry {
163    pub recorded_at: DateTime<Utc>,
164    pub event: RunEvent,
165}
166
167// ─── RunJournal ──────────────────────────────────────────────────────────────
168
169/// Canonical in-memory record of a pipeline run.
170///
171/// Accumulated during execution via `record()`.  Query methods let callers
172/// answer the four DoD questions without iterating `entries` directly.
173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
174pub struct RunJournal {
175    pub run_id: String,
176    pub export_name: String,
177    /// All events in insertion order.
178    pub entries: Vec<JournalEntry>,
179}
180
181impl RunJournal {
182    pub fn new(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
183        Self {
184            run_id: run_id.into(),
185            export_name: export_name.into(),
186            entries: Vec::new(),
187        }
188    }
189
190    /// Append an event with the current UTC timestamp.
191    pub fn record(&mut self, event: RunEvent) {
192        self.entries.push(JournalEntry {
193            recorded_at: Utc::now(),
194            event,
195        });
196    }
197
198    // ── What was planned? ─────────────────────────────────────
199
200    /// Returns the plan snapshot recorded at the start of the run.
201    pub fn plan_snapshot(&self) -> Option<&PlanSnapshot> {
202        self.entries.iter().find_map(|e| {
203            if let RunEvent::PlanResolved(s) = &e.event {
204                Some(s)
205            } else {
206                None
207            }
208        })
209    }
210
211    // ── What happened? ────────────────────────────────────────
212
213    /// All `FileWritten` entries, in the order files were committed.
214    pub fn files(&self) -> Vec<&JournalEntry> {
215        self.entries
216            .iter()
217            .filter(|e| matches!(e.event, RunEvent::FileWritten { .. }))
218            .collect()
219    }
220
221    /// All `RetryAttempted` entries.
222    pub fn retries(&self) -> Vec<&JournalEntry> {
223        self.entries
224            .iter()
225            .filter(|e| matches!(e.event, RunEvent::RetryAttempted { .. }))
226            .collect()
227    }
228
229    /// All chunk lifecycle entries (`ChunkStarted`, `ChunkCompleted`, `ChunkFailed`).
230    pub fn chunk_events(&self) -> Vec<&JournalEntry> {
231        self.entries
232            .iter()
233            .filter(|e| {
234                matches!(
235                    e.event,
236                    RunEvent::ChunkStarted { .. }
237                        | RunEvent::ChunkCompleted { .. }
238                        | RunEvent::ChunkFailed { .. }
239                )
240            })
241            .collect()
242    }
243
244    /// The longest single-chunk wall time in milliseconds, if derivable.
245    ///
246    /// Pairs each `ChunkStarted` with its `ChunkCompleted` by `chunk_index`
247    /// (robust to the interleaving the parallel runners produce) and returns the
248    /// largest gap. This is the #5 source-harm lever — how long one chunk query,
249    /// and the snapshot / locks it holds, stayed open — made measurable.
250    ///
251    /// `None` when no pair is found: a non-chunked run, or a parallel runner that
252    /// records `ChunkCompleted` in a single post-scope batch (so no real
253    /// per-chunk start time exists). The sequential and checkpoint paths
254    /// timestamp each event as it happens, so they yield true per-chunk timings.
255    pub fn longest_chunk_ms(&self) -> Option<i64> {
256        let mut started: std::collections::HashMap<i64, DateTime<Utc>> =
257            std::collections::HashMap::new();
258        let mut max_ms: Option<i64> = None;
259        for e in &self.entries {
260            match &e.event {
261                RunEvent::ChunkStarted { chunk_index, .. } => {
262                    started.insert(*chunk_index, e.recorded_at);
263                }
264                RunEvent::ChunkCompleted { chunk_index, .. } => {
265                    if let Some(start) = started.get(chunk_index) {
266                        let ms = (e.recorded_at - *start).num_milliseconds();
267                        if ms >= 0 {
268                            max_ms = Some(max_ms.map_or(ms, |m| m.max(ms)));
269                        }
270                    }
271                }
272                _ => {}
273            }
274        }
275        max_ms
276    }
277
278    // ── What degraded? ────────────────────────────────────────
279
280    /// All `QualityIssue` entries (both FAIL and WARN severity).
281    pub fn quality_issues(&self) -> Vec<&JournalEntry> {
282        self.entries
283            .iter()
284            .filter(|e| matches!(e.event, RunEvent::QualityIssue { .. }))
285            .collect()
286    }
287
288    /// All `SchemaChanged` entries.
289    pub fn schema_changes(&self) -> Vec<&JournalEntry> {
290        self.entries
291            .iter()
292            .filter(|e| matches!(e.event, RunEvent::SchemaChanged { .. }))
293            .collect()
294    }
295
296    /// All `Warning` and `PlanWarning` entries.
297    pub fn warnings(&self) -> Vec<&JournalEntry> {
298        self.entries
299            .iter()
300            .filter(|e| {
301                matches!(
302                    e.event,
303                    RunEvent::Warning { .. } | RunEvent::PlanWarning { .. }
304                )
305            })
306            .collect()
307    }
308
309    // ── What was the final outcome? ───────────────────────────
310
311    /// The last `RunCompleted` entry, or `None` if the run has not yet finished.
312    pub fn final_outcome(&self) -> Option<&JournalEntry> {
313        self.entries
314            .iter()
315            .rev()
316            .find(|e| matches!(e.event, RunEvent::RunCompleted { .. }))
317    }
318}
319
320#[cfg(test)]
321impl RunJournal {
322    /// Test-only: append a paired `ChunkStarted` + `ChunkCompleted` for
323    /// `chunk_index` whose wall-clock span is exactly `dur_ms`, stamped with
324    /// explicit timestamps (`record()` stamps `Utc::now`, which a test can't
325    /// control). Lets a caller make `longest_chunk_ms()` deterministic without
326    /// reaching into the private-by-convention `entries` Vec.
327    pub(crate) fn push_test_chunk_span(&mut self, chunk_index: i64, dur_ms: i64) {
328        let base = Utc::now();
329        self.entries.push(JournalEntry {
330            recorded_at: base,
331            event: RunEvent::ChunkStarted {
332                chunk_index,
333                start_key: "0".into(),
334                end_key: "1".into(),
335            },
336        });
337        self.entries.push(JournalEntry {
338            recorded_at: base + chrono::Duration::milliseconds(dur_ms),
339            event: RunEvent::ChunkCompleted {
340                chunk_index,
341                rows: 1,
342                file_name: None,
343            },
344        });
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn journal() -> RunJournal {
353        RunJournal::new("run-1", "orders")
354    }
355
356    fn snap() -> PlanSnapshot {
357        PlanSnapshot {
358            export_name: "orders".into(),
359            base_query: "SELECT 1".into(),
360            strategy: "snapshot".into(),
361            format: "parquet".into(),
362            compression: "zstd".into(),
363            destination_type: "local".into(),
364            tuning_profile: "balanced".into(),
365            batch_size: 1000,
366            validate: false,
367            reconcile: false,
368            resume: false,
369            chunk_key: None,
370            resumable: false,
371        }
372    }
373
374    // ── construction ────────────────────────────────────────────────────────
375
376    #[test]
377    fn new_journal_is_empty() {
378        let j = journal();
379        assert_eq!(j.run_id, "run-1");
380        assert_eq!(j.export_name, "orders");
381        assert!(j.entries.is_empty());
382    }
383
384    // ── record ───────────────────────────────────────────────────────────────
385
386    #[test]
387    fn record_appends_entry() {
388        let mut j = journal();
389        j.record(RunEvent::Warning {
390            context: "test".into(),
391            message: "w".into(),
392        });
393        assert_eq!(j.entries.len(), 1);
394    }
395
396    #[test]
397    fn record_multiple_entries_in_order() {
398        let mut j = journal();
399        j.record(RunEvent::Warning {
400            context: "a".into(),
401            message: "1".into(),
402        });
403        j.record(RunEvent::Warning {
404            context: "b".into(),
405            message: "2".into(),
406        });
407        assert_eq!(j.entries.len(), 2);
408    }
409
410    // ── plan_snapshot ────────────────────────────────────────────────────────
411
412    #[test]
413    fn plan_snapshot_none_when_empty() {
414        assert!(journal().plan_snapshot().is_none());
415    }
416
417    #[test]
418    fn plan_snapshot_returns_first_resolved() {
419        let mut j = journal();
420        j.record(RunEvent::PlanResolved(snap()));
421        let s = j.plan_snapshot().unwrap();
422        assert_eq!(s.export_name, "orders");
423        assert_eq!(s.batch_size, 1000);
424    }
425
426    // ── files ────────────────────────────────────────────────────────────────
427
428    #[test]
429    fn files_empty_when_no_file_written() {
430        let mut j = journal();
431        j.record(RunEvent::Warning {
432            context: "x".into(),
433            message: "y".into(),
434        });
435        assert!(j.files().is_empty());
436    }
437
438    #[test]
439    fn files_returns_file_written_entries() {
440        let mut j = journal();
441        j.record(RunEvent::FileWritten {
442            file_name: "f.parquet".into(),
443            rows: 100,
444            bytes: 4096,
445            part_index: 0,
446        });
447        j.record(RunEvent::Warning {
448            context: "x".into(),
449            message: "y".into(),
450        });
451        j.record(RunEvent::FileWritten {
452            file_name: "g.parquet".into(),
453            rows: 50,
454            bytes: 2048,
455            part_index: 1,
456        });
457        assert_eq!(j.files().len(), 2);
458    }
459
460    // ── retries ──────────────────────────────────────────────────────────────
461
462    #[test]
463    fn retries_empty_when_none_recorded() {
464        assert!(journal().retries().is_empty());
465    }
466
467    #[test]
468    fn retries_returns_retry_attempted_entries() {
469        let mut j = journal();
470        j.record(RunEvent::RetryAttempted {
471            attempt: 1,
472            reason: "timeout".into(),
473            backoff_ms: 500,
474        });
475        j.record(RunEvent::RetryAttempted {
476            attempt: 2,
477            reason: "timeout".into(),
478            backoff_ms: 1000,
479        });
480        assert_eq!(j.retries().len(), 2);
481    }
482
483    // ── chunk_events ─────────────────────────────────────────────────────────
484
485    #[test]
486    fn chunk_events_collects_all_three_variant_types() {
487        let mut j = journal();
488        j.record(RunEvent::ChunkStarted {
489            chunk_index: 0,
490            start_key: "0".into(),
491            end_key: "100".into(),
492        });
493        j.record(RunEvent::ChunkCompleted {
494            chunk_index: 0,
495            rows: 100,
496            file_name: None,
497        });
498        j.record(RunEvent::ChunkFailed {
499            chunk_index: 1,
500            error: "err".into(),
501            attempt: 1,
502        });
503        j.record(RunEvent::Warning {
504            context: "x".into(),
505            message: "y".into(),
506        });
507        assert_eq!(j.chunk_events().len(), 3);
508    }
509
510    // ── longest_chunk_ms ──────────────────────────────────────────────────────
511
512    #[test]
513    fn longest_chunk_ms_pairs_started_and_completed_by_index() {
514        use chrono::Duration;
515        // Construct entries with explicit timestamps (record() stamps Utc::now,
516        // which we can't control). chunk 0 = 200ms, chunk 1 = 800ms; starts and
517        // completes interleave the way the parallel runner would order them.
518        let base = Utc::now();
519        let mut j = journal();
520        let push = |j: &mut RunJournal, off_ms: i64, event: RunEvent| {
521            j.entries.push(JournalEntry {
522                recorded_at: base + Duration::milliseconds(off_ms),
523                event,
524            });
525        };
526        let started = |i: i64| RunEvent::ChunkStarted {
527            chunk_index: i,
528            start_key: "0".into(),
529            end_key: "1".into(),
530        };
531        let done = |i: i64| RunEvent::ChunkCompleted {
532            chunk_index: i,
533            rows: 1,
534            file_name: None,
535        };
536        push(&mut j, 0, started(0));
537        push(&mut j, 50, started(1));
538        push(&mut j, 200, done(0)); // chunk 0: 200ms
539        push(&mut j, 850, done(1)); // chunk 1: 850 - 50 = 800ms (the max)
540        assert_eq!(j.longest_chunk_ms(), Some(800));
541    }
542
543    #[test]
544    fn longest_chunk_ms_none_without_paired_start() {
545        // The parallel post-scope batch shape: ChunkCompleted with no matching
546        // ChunkStarted → no real per-chunk timing → None (honest, not a bogus 0).
547        let mut j = journal();
548        j.record(RunEvent::ChunkCompleted {
549            chunk_index: 0,
550            rows: 1,
551            file_name: None,
552        });
553        assert!(j.longest_chunk_ms().is_none());
554        assert!(
555            journal().longest_chunk_ms().is_none(),
556            "empty journal → None"
557        );
558    }
559
560    // ── quality_issues ───────────────────────────────────────────────────────
561
562    #[test]
563    fn quality_issues_filters_correctly() {
564        let mut j = journal();
565        j.record(RunEvent::QualityIssue {
566            severity: "FAIL".into(),
567            message: "null check".into(),
568        });
569        j.record(RunEvent::Warning {
570            context: "x".into(),
571            message: "y".into(),
572        });
573        assert_eq!(j.quality_issues().len(), 1);
574    }
575
576    // ── schema_changes ───────────────────────────────────────────────────────
577
578    #[test]
579    fn schema_changes_filters_correctly() {
580        let mut j = journal();
581        j.record(RunEvent::SchemaChanged {
582            added: vec!["new_col (Int64)".into()],
583            removed: vec![],
584            type_changed: vec![],
585        });
586        assert_eq!(j.schema_changes().len(), 1);
587    }
588
589    // ── warnings ─────────────────────────────────────────────────────────────
590
591    #[test]
592    fn warnings_includes_both_warning_and_plan_warning() {
593        let mut j = journal();
594        j.record(RunEvent::Warning {
595            context: "ctx".into(),
596            message: "w1".into(),
597        });
598        j.record(RunEvent::PlanWarning {
599            rule: "r".into(),
600            message: "w2".into(),
601        });
602        j.record(RunEvent::QualityIssue {
603            severity: "WARN".into(),
604            message: "q".into(),
605        });
606        assert_eq!(j.warnings().len(), 2);
607    }
608
609    // ── final_outcome ─────────────────────────────────────────────────────────
610
611    #[test]
612    fn final_outcome_none_when_not_completed() {
613        let mut j = journal();
614        j.record(RunEvent::Warning {
615            context: "x".into(),
616            message: "y".into(),
617        });
618        assert!(j.final_outcome().is_none());
619    }
620
621    #[test]
622    fn final_outcome_returns_last_run_completed() {
623        let mut j = journal();
624        j.record(RunEvent::RunCompleted {
625            status: "success".into(),
626            error_message: None,
627            duration_ms: 1234,
628        });
629        j.record(RunEvent::Warning {
630            context: "x".into(),
631            message: "y".into(),
632        });
633        j.record(RunEvent::RunCompleted {
634            status: "failed".into(),
635            error_message: Some("err".into()),
636            duration_ms: 5678,
637        });
638        let outcome = j.final_outcome().unwrap();
639        if let RunEvent::RunCompleted { status, .. } = &outcome.event {
640            assert_eq!(status, "failed");
641        } else {
642            panic!("expected RunCompleted");
643        }
644    }
645}