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}