Skip to main content

rivet/state/
metrics.rs

1use crate::error::Result;
2
3use super::{StateConn, StateStore, pg_sql};
4
5/// One row from `export_metrics`.
6#[derive(Debug)]
7#[allow(dead_code)]
8pub struct ExportMetric {
9    pub export_name: String,
10    pub run_id: Option<String>,
11    pub run_at: String,
12    pub duration_ms: i64,
13    pub total_rows: i64,
14    pub peak_rss_mb: Option<i64>,
15    pub status: String,
16    pub error_message: Option<String>,
17    pub tuning_profile: Option<String>,
18    pub format: Option<String>,
19    pub mode: Option<String>,
20    pub files_produced: i64,
21    pub bytes_written: i64,
22    pub retries: i64,
23    pub validated: Option<bool>,
24    pub schema_changed: Option<bool>,
25}
26
27/// Every column written to one `export_metrics` row.
28///
29/// Bundles the original 15 metrics with the v9 additions (source harm,
30/// completeness, memory, config dimensions) so a new metric is a struct field +
31/// a column, not another positional argument to a 30-arg function. `Default`
32/// lets a call site fill only the signals it actually has; the run path builds
33/// the whole thing via `pipeline::job::build_metric_row`.
34#[derive(Debug, Default, Clone)]
35pub struct MetricRow {
36    pub export_name: String,
37    pub run_id: String,
38    pub duration_ms: i64,
39    pub total_rows: i64,
40    pub peak_rss_mb: Option<i64>,
41    pub status: String,
42    pub error_message: Option<String>,
43    pub tuning_profile: Option<String>,
44    pub format: Option<String>,
45    pub mode: Option<String>,
46    pub files_produced: i64,
47    pub bytes_written: i64,
48    pub retries: i64,
49    pub validated: Option<bool>,
50    pub schema_changed: Option<bool>,
51    // ── v9: post-pilot analysis signals ──
52    pub files_committed: i64,
53    pub reconciled: Option<bool>,
54    pub source_count: Option<i64>,
55    pub quality_passed: Option<bool>,
56    pub pg_temp_bytes_delta: Option<i64>,
57    pub batch_size: i64,
58    pub batch_size_memory_mb: Option<i64>,
59    pub skip_reason: Option<String>,
60    pub schema_fingerprint: Option<String>,
61    pub chunk_size: Option<i64>,
62    pub parallel: Option<i64>,
63    pub source_type: Option<String>,
64    pub destination_type: Option<String>,
65    pub rivet_version: Option<String>,
66    // ── v10: timing ──
67    pub longest_chunk_ms: Option<i64>,
68    // ── v12: chunking diagnostics — which key was chunked (the resolved strategy
69    // is already the `mode` column; whether the key is a PK is a follow-up that
70    // needs a run-time probe, so it is not derived-from-strategy here) ──
71    pub chunk_key: Option<String>,
72    // ── v18: failure forensics — populated so a `status='failed'` row reconstructs
73    // the failure without the source DB (export_schema is success-only). See
74    // `pipeline::job::build_metric_row` for the per-field write points. ──
75    /// Stable error CLASS (`keyset_unreadable_key` | `statement_timeout` |
76    /// `parallel_checkpoint` | `schema_drift` | …) classified from `error_message`,
77    /// so failures group/trend without `LIKE '%…%'`.
78    pub error_class: Option<String>,
79    /// The key RANGE the run covered/died in (from the summary cursor bounds).
80    pub cursor_min: Option<String>,
81    pub cursor_max: Option<String>,
82    /// The keyset/chunk key's SHAPE as JSON (`{key, db_type, signed, is_primary_key,
83    /// …}`) — the answer to "was the key indexed / unsigned" without the source.
84    pub key_descriptor_json: Option<String>,
85    /// The raw value that could not be read/advanced (e.g. a u64 past i64::MAX) —
86    /// turns "unsupported type" into a concrete number.
87    pub offending_value: Option<String>,
88    /// Source SERVER context as JSON (`{version, max_execution_time, sql_mode,
89    /// time_zone, …}`) — the ERROR 3024 timeout is unexplainable without the limit.
90    pub server_context_json: Option<String>,
91}
92
93/// Metrics store — reads and writes `export_metrics`.
94///
95/// Invariant I4 (Metric After Verdict) governs when `record_metric` is called:
96/// only after the terminal run outcome is determined.
97impl StateStore {
98    /// Back-compat shim: the original 15-field metric. Fills the v9 columns with
99    /// defaults (NULL) and delegates to [`record_metric_full`]. The production
100    /// run/apply path now builds a full [`MetricRow`]; this shim remains for the
101    /// unit + integration tests that only assert the core signals.
102    ///
103    /// `#[allow(dead_code)]`: the only non-test caller migrated to
104    /// `record_metric_full`, and the bin/lib dead-code pass can't see the uses
105    /// in `tests/*` (same reason `RunSummary::stub_for_testing` carries it).
106    #[allow(clippy::too_many_arguments, dead_code)]
107    pub fn record_metric(
108        &self,
109        export_name: &str,
110        run_id: &str,
111        duration_ms: i64,
112        total_rows: i64,
113        peak_rss_mb: Option<i64>,
114        status: &str,
115        error_message: Option<&str>,
116        tuning_profile: Option<&str>,
117        format: Option<&str>,
118        mode: Option<&str>,
119        files_produced: i64,
120        bytes_written: i64,
121        retries: i64,
122        validated: Option<bool>,
123        schema_changed: Option<bool>,
124    ) -> Result<()> {
125        self.record_metric_full(&MetricRow {
126            export_name: export_name.to_string(),
127            run_id: run_id.to_string(),
128            duration_ms,
129            total_rows,
130            peak_rss_mb,
131            status: status.to_string(),
132            error_message: error_message.map(str::to_string),
133            tuning_profile: tuning_profile.map(str::to_string),
134            format: format.map(str::to_string),
135            mode: mode.map(str::to_string),
136            files_produced,
137            bytes_written,
138            retries,
139            validated,
140            schema_changed,
141            ..Default::default()
142        })
143    }
144
145    /// Insert one fully-populated `export_metrics` row (all v1 + v9 columns).
146    /// The production run/apply path builds the complete [`MetricRow`]; the
147    /// 15-field [`record_metric`] shim covers callers with only the core signals.
148    pub fn record_metric_full(&self, m: &MetricRow) -> Result<()> {
149        let now = chrono::Utc::now().to_rfc3339();
150        let sql = "INSERT INTO export_metrics (
151             export_name, run_id, run_at, duration_ms, total_rows, peak_rss_mb,
152             status, error_message, tuning_profile, format, mode,
153             files_produced, bytes_written, retries, validated, schema_changed,
154             files_committed, reconciled, source_count, quality_passed, pg_temp_bytes_delta,
155             batch_size, batch_size_memory_mb, skip_reason, schema_fingerprint,
156             chunk_size, parallel, source_type, destination_type, rivet_version,
157             longest_chunk_ms, chunk_key,
158             error_class, cursor_min, cursor_max, key_descriptor_json, offending_value,
159             server_context_json)
160             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
161             ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31,
162             ?32, ?33, ?34, ?35, ?36, ?37, ?38)";
163        match &self.conn {
164            StateConn::Sqlite(c) => {
165                c.execute(
166                    sql,
167                    rusqlite::params![
168                        m.export_name,
169                        m.run_id,
170                        now,
171                        m.duration_ms,
172                        m.total_rows,
173                        m.peak_rss_mb,
174                        m.status,
175                        m.error_message,
176                        m.tuning_profile,
177                        m.format,
178                        m.mode,
179                        m.files_produced,
180                        m.bytes_written,
181                        m.retries,
182                        m.validated,
183                        m.schema_changed,
184                        m.files_committed,
185                        m.reconciled,
186                        m.source_count,
187                        m.quality_passed,
188                        m.pg_temp_bytes_delta,
189                        m.batch_size,
190                        m.batch_size_memory_mb,
191                        m.skip_reason,
192                        m.schema_fingerprint,
193                        m.chunk_size,
194                        m.parallel,
195                        m.source_type,
196                        m.destination_type,
197                        m.rivet_version,
198                        m.longest_chunk_ms,
199                        m.chunk_key,
200                        m.error_class,
201                        m.cursor_min,
202                        m.cursor_max,
203                        m.key_descriptor_json,
204                        m.offending_value,
205                        m.server_context_json
206                    ],
207                )?;
208            }
209            StateConn::Postgres(client) => {
210                let mut c = client.borrow_mut();
211                c.execute(
212                    &pg_sql(sql),
213                    &[
214                        &m.export_name,
215                        &m.run_id,
216                        &now,
217                        &m.duration_ms,
218                        &m.total_rows,
219                        &m.peak_rss_mb,
220                        &m.status,
221                        &m.error_message,
222                        &m.tuning_profile,
223                        &m.format,
224                        &m.mode,
225                        &m.files_produced,
226                        &m.bytes_written,
227                        &m.retries,
228                        &m.validated,
229                        &m.schema_changed,
230                        &m.files_committed,
231                        &m.reconciled,
232                        &m.source_count,
233                        &m.quality_passed,
234                        &m.pg_temp_bytes_delta,
235                        &m.batch_size,
236                        &m.batch_size_memory_mb,
237                        &m.skip_reason,
238                        &m.schema_fingerprint,
239                        &m.chunk_size,
240                        &m.parallel,
241                        &m.source_type,
242                        &m.destination_type,
243                        &m.rivet_version,
244                        &m.longest_chunk_ms,
245                        &m.chunk_key,
246                        &m.error_class,
247                        &m.cursor_min,
248                        &m.cursor_max,
249                        &m.key_descriptor_json,
250                        &m.offending_value,
251                        &m.server_context_json,
252                    ],
253                )?;
254            }
255        }
256        Ok(())
257    }
258
259    /// Record per-run source-harm deltas (Tier 2): one row per counter into
260    /// `export_harm`, keyed on `run_id`. Best-effort observability — the caller
261    /// logs and ignores any error; a missing harm row never affects the run
262    /// verdict. No-op for an empty delta set (e.g. a non-Postgres source whose
263    /// probe was skipped, or a counter set that didn't move).
264    pub fn record_harm(
265        &self,
266        run_id: &str,
267        export_name: &str,
268        deltas: &[(String, i64)],
269    ) -> Result<()> {
270        if deltas.is_empty() {
271            return Ok(());
272        }
273        let now = chrono::Utc::now().to_rfc3339();
274        let sql = "INSERT INTO export_harm (run_id, export_name, metric, delta, recorded_at) \
275                   VALUES (?1, ?2, ?3, ?4, ?5)";
276        match &self.conn {
277            StateConn::Sqlite(c) => {
278                for (metric, delta) in deltas {
279                    c.execute(
280                        sql,
281                        rusqlite::params![run_id, export_name, metric, delta, now],
282                    )?;
283                }
284            }
285            StateConn::Postgres(client) => {
286                let mut c = client.borrow_mut();
287                for (metric, delta) in deltas {
288                    c.execute(&pg_sql(sql), &[&run_id, &export_name, metric, delta, &now])?;
289                }
290            }
291        }
292        Ok(())
293    }
294
295    /// Test-only read of the `export_harm` rows for a run, `(metric, delta)`
296    /// sorted by metric — lets tests trace the harm signal through the table.
297    #[cfg(test)]
298    pub(crate) fn harm_rows_for_test(&self, run_id: &str) -> Vec<(String, i64)> {
299        match &self.conn {
300            StateConn::Sqlite(c) => {
301                let mut stmt = c
302                    .prepare(
303                        "SELECT metric, delta FROM export_harm WHERE run_id = ?1 ORDER BY metric",
304                    )
305                    .expect("prepare export_harm read");
306                let rows = stmt
307                    .query_map([run_id], |r| {
308                        Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
309                    })
310                    .expect("query export_harm");
311                rows.filter_map(|r| r.ok()).collect()
312            }
313            _ => Vec::new(),
314        }
315    }
316
317    /// Test-only raw scalar read of a v9 metric column the typed `get_metrics`
318    /// path intentionally doesn't surface — lets tests pin that the wide INSERT
319    /// mapped each field to the right column (catches a positional param swap).
320    #[cfg(test)]
321    pub(crate) fn metric_scalar_i64(&self, run_id: &str, column: &str) -> Option<i64> {
322        match &self.conn {
323            StateConn::Sqlite(c) => c
324                .query_row(
325                    &format!("SELECT {column} FROM export_metrics WHERE run_id = ?1"),
326                    [run_id],
327                    |r| r.get::<_, Option<i64>>(0),
328                )
329                .ok()
330                .flatten(),
331            _ => None,
332        }
333    }
334
335    pub fn get_metrics(
336        &self,
337        export_name: Option<&str>,
338        limit: usize,
339    ) -> Result<Vec<ExportMetric>> {
340        let cols = "export_name, run_id, run_at, duration_ms, total_rows, peak_rss_mb,
341                    status, error_message, tuning_profile, format, mode,
342                    files_produced, bytes_written, retries, validated, schema_changed";
343
344        let limit_i64 = limit as i64;
345        match &self.conn {
346            StateConn::Sqlite(c) => {
347                let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(
348                    name,
349                ) = export_name
350                {
351                    (
352                        "SELECT export_name, run_id, run_at, duration_ms, total_rows, peak_rss_mb, \
353                             status, error_message, tuning_profile, format, mode, \
354                             files_produced, bytes_written, retries, validated, schema_changed \
355                             FROM export_metrics WHERE export_name = ?1 ORDER BY id DESC LIMIT ?2",
356                        vec![Box::new(name.to_string()), Box::new(limit_i64)],
357                    )
358                } else {
359                    (
360                        "SELECT export_name, run_id, run_at, duration_ms, total_rows, peak_rss_mb, \
361                             status, error_message, tuning_profile, format, mode, \
362                             files_produced, bytes_written, retries, validated, schema_changed \
363                             FROM export_metrics ORDER BY id DESC LIMIT ?1",
364                        vec![Box::new(limit_i64)],
365                    )
366                };
367                let mut stmt = c.prepare(sql)?;
368                let params_refs: Vec<&dyn rusqlite::types::ToSql> =
369                    params.iter().map(|p| p.as_ref()).collect();
370                let rows = stmt.query_map(params_refs.as_slice(), |row| {
371                    Ok(ExportMetric {
372                        export_name: row.get(0)?,
373                        run_id: row.get(1)?,
374                        run_at: row.get(2)?,
375                        duration_ms: row.get(3)?,
376                        total_rows: row.get(4)?,
377                        peak_rss_mb: row.get(5)?,
378                        status: row.get(6)?,
379                        error_message: row.get(7)?,
380                        tuning_profile: row.get(8)?,
381                        format: row.get(9)?,
382                        mode: row.get(10)?,
383                        files_produced: row.get::<_, Option<i64>>(11)?.unwrap_or(0),
384                        bytes_written: row.get::<_, Option<i64>>(12)?.unwrap_or(0),
385                        retries: row.get::<_, Option<i64>>(13)?.unwrap_or(0),
386                        validated: row.get(14)?,
387                        schema_changed: row.get(15)?,
388                    })
389                })?;
390                rows.collect::<std::result::Result<Vec<_>, _>>()
391                    .map_err(Into::into)
392            }
393            StateConn::Postgres(client) => {
394                // Single borrow for the duration of this call; safe because all Postgres
395                // operations in StateStore are sequential (no re-entrant borrows).
396                let mut c = client.borrow_mut();
397                let rows = if let Some(name) = export_name {
398                    c.query(
399                        &format!("SELECT {} FROM export_metrics WHERE export_name = $1 ORDER BY id DESC LIMIT $2", cols),
400                        &[&name, &limit_i64],
401                    )?
402                } else {
403                    c.query(
404                        &format!(
405                            "SELECT {} FROM export_metrics ORDER BY id DESC LIMIT $1",
406                            cols
407                        ),
408                        &[&limit_i64],
409                    )?
410                };
411                Ok(rows
412                    .iter()
413                    .map(|row| ExportMetric {
414                        export_name: row.get(0),
415                        run_id: row.get(1),
416                        run_at: row.get(2),
417                        duration_ms: row.get(3),
418                        total_rows: row.get(4),
419                        peak_rss_mb: row.get(5),
420                        status: row.get(6),
421                        error_message: row.get(7),
422                        tuning_profile: row.get(8),
423                        format: row.get(9),
424                        mode: row.get(10),
425                        files_produced: row.get::<_, Option<i64>>(11).unwrap_or(0),
426                        bytes_written: row.get::<_, Option<i64>>(12).unwrap_or(0),
427                        retries: row.get::<_, Option<i64>>(13).unwrap_or(0),
428                        validated: row.get(14),
429                        schema_changed: row.get(15),
430                    })
431                    .collect())
432            }
433        }
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    fn store() -> StateStore {
442        StateStore::open_in_memory().expect("in-memory store")
443    }
444
445    #[test]
446    fn record_and_query_metrics() {
447        let s = store();
448        s.record_metric(
449            "orders",
450            "run_001",
451            1200,
452            50000,
453            Some(142),
454            "success",
455            None,
456            Some("safe"),
457            Some("parquet"),
458            Some("full"),
459            1,
460            4096,
461            0,
462            Some(true),
463            Some(false),
464        )
465        .unwrap();
466        s.record_metric(
467            "orders",
468            "run_002",
469            300,
470            0,
471            Some(30),
472            "failed",
473            Some("timeout"),
474            Some("safe"),
475            Some("parquet"),
476            Some("full"),
477            0,
478            0,
479            2,
480            None,
481            None,
482        )
483        .unwrap();
484
485        let metrics = s.get_metrics(Some("orders"), 10).unwrap();
486        assert_eq!(metrics.len(), 2);
487        assert_eq!(metrics[0].status, "failed");
488        assert_eq!(metrics[0].run_id.as_deref(), Some("run_002"));
489        assert_eq!(metrics[0].retries, 2);
490        assert_eq!(metrics[1].total_rows, 50000);
491        assert_eq!(metrics[1].run_id.as_deref(), Some("run_001"));
492        assert_eq!(metrics[1].files_produced, 1);
493        assert_eq!(metrics[1].bytes_written, 4096);
494        assert_eq!(metrics[1].validated, Some(true));
495        assert_eq!(metrics[1].schema_changed, Some(false));
496    }
497
498    #[test]
499    fn record_metric_full_persists_v9_columns_in_order() {
500        let s = store();
501        s.record_metric_full(&MetricRow {
502            export_name: "orders".into(),
503            run_id: "r1".into(),
504            duration_ms: 1200,
505            total_rows: 50_000,
506            status: "success".into(),
507            // v9 signals, chosen as distinct values so a positional param swap
508            // in the 30-column INSERT shows up as a wrong-column read below.
509            files_committed: 11,
510            source_count: Some(50_000),
511            pg_temp_bytes_delta: Some(1_048_576),
512            batch_size: 32_000,
513            chunk_size: Some(100_000),
514            parallel: Some(4),
515            longest_chunk_ms: Some(1_839),
516            ..Default::default()
517        })
518        .unwrap();
519
520        // Core read path is unchanged.
521        let got = s.get_metrics(Some("orders"), 1).unwrap();
522        assert_eq!(got.len(), 1);
523        assert_eq!(got[0].total_rows, 50_000);
524        assert_eq!(got[0].run_id.as_deref(), Some("r1"));
525
526        // v9 columns round-trip to the right column (chunk_size=100000 vs
527        // parallel=4 pinned separately so a swap of the two Option<i64> params
528        // can't pass).
529        assert_eq!(s.metric_scalar_i64("r1", "files_committed"), Some(11));
530        assert_eq!(s.metric_scalar_i64("r1", "source_count"), Some(50_000));
531        assert_eq!(
532            s.metric_scalar_i64("r1", "pg_temp_bytes_delta"),
533            Some(1_048_576)
534        );
535        assert_eq!(s.metric_scalar_i64("r1", "batch_size"), Some(32_000));
536        assert_eq!(s.metric_scalar_i64("r1", "chunk_size"), Some(100_000));
537        assert_eq!(s.metric_scalar_i64("r1", "parallel"), Some(4));
538        assert_eq!(s.metric_scalar_i64("r1", "longest_chunk_ms"), Some(1_839));
539    }
540
541    #[test]
542    fn record_harm_round_trips_per_counter_rows() {
543        // Traces the Tier 2 signal through SQLite: v11 migration creates
544        // export_harm, record_harm writes one row per counter, the read returns
545        // them. (The live source-probe that produces these deltas needs a real
546        // DB; this validates the storage path it lands in.)
547        let s = store();
548        let deltas = vec![
549            ("pg_tup_returned".to_string(), 1_000_000),
550            ("pg_blks_read".to_string(), 2_048),
551            ("pg_temp_files".to_string(), 3),
552        ];
553        s.record_harm("run-h", "content_items", &deltas).unwrap();
554
555        // One row per counter, keyed on run_id (read sorted by metric).
556        assert_eq!(
557            s.harm_rows_for_test("run-h"),
558            vec![
559                ("pg_blks_read".to_string(), 2_048),
560                ("pg_temp_files".to_string(), 3),
561                ("pg_tup_returned".to_string(), 1_000_000),
562            ]
563        );
564
565        // Empty delta set (probe skipped / counters unmoved) → no rows, no error.
566        s.record_harm("run-empty", "x", &[]).unwrap();
567        assert!(s.harm_rows_for_test("run-empty").is_empty());
568    }
569
570    #[test]
571    fn query_metrics_all_exports() {
572        let s = store();
573        s.record_metric(
574            "orders", "r1", 100, 1000, None, "success", None, None, None, None, 1, 500, 0, None,
575            None,
576        )
577        .unwrap();
578        s.record_metric(
579            "users", "r2", 200, 2000, None, "success", None, None, None, None, 1, 800, 0, None,
580            None,
581        )
582        .unwrap();
583
584        let metrics = s.get_metrics(None, 10).unwrap();
585        assert_eq!(metrics.len(), 2);
586    }
587
588    #[test]
589    fn metrics_limit_works() {
590        let s = store();
591        for i in 0..10 {
592            s.record_metric(
593                "t",
594                &format!("r{}", i),
595                i * 100,
596                i,
597                None,
598                "success",
599                None,
600                None,
601                None,
602                None,
603                0,
604                0,
605                0,
606                None,
607                None,
608            )
609            .unwrap();
610        }
611        let metrics = s.get_metrics(Some("t"), 3).unwrap();
612        assert_eq!(metrics.len(), 3);
613    }
614}