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