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}