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        for (metric, delta) in deltas {
277            self.execute(
278                sql,
279                &[
280                    run_id.into(),
281                    export_name.into(),
282                    metric.as_str().into(),
283                    (*delta).into(),
284                    now.as_str().into(),
285                ],
286            )?;
287        }
288        Ok(())
289    }
290
291    /// Test-only read of the `export_harm` rows for a run, `(metric, delta)`
292    /// sorted by metric — lets tests trace the harm signal through the table.
293    #[cfg(test)]
294    pub(crate) fn harm_rows_for_test(&self, run_id: &str) -> Vec<(String, i64)> {
295        match &self.conn {
296            StateConn::Sqlite(c) => {
297                let mut stmt = c
298                    .prepare(
299                        "SELECT metric, delta FROM export_harm WHERE run_id = ?1 ORDER BY metric",
300                    )
301                    .expect("prepare export_harm read");
302                let rows = stmt
303                    .query_map([run_id], |r| {
304                        Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
305                    })
306                    .expect("query export_harm");
307                rows.filter_map(|r| r.ok()).collect()
308            }
309            _ => Vec::new(),
310        }
311    }
312
313    /// Test-only raw scalar read of a v9 metric column the typed `get_metrics`
314    /// path intentionally doesn't surface — lets tests pin that the wide INSERT
315    /// mapped each field to the right column (catches a positional param swap).
316    #[cfg(test)]
317    pub(crate) fn metric_scalar_i64(&self, run_id: &str, column: &str) -> Option<i64> {
318        match &self.conn {
319            StateConn::Sqlite(c) => c
320                .query_row(
321                    &format!("SELECT {column} FROM export_metrics WHERE run_id = ?1"),
322                    [run_id],
323                    |r| r.get::<_, Option<i64>>(0),
324                )
325                .ok()
326                .flatten(),
327            _ => None,
328        }
329    }
330
331    pub fn get_metrics(
332        &self,
333        export_name: Option<&str>,
334        limit: usize,
335    ) -> Result<Vec<ExportMetric>> {
336        // One projection, written once for both backends (was duplicated per arm).
337        let extract = |r: &dyn super::row::StateRow| ExportMetric {
338            export_name: r.text(0),
339            run_id: r.opt_text(1),
340            run_at: r.text(2),
341            duration_ms: r.i64(3),
342            total_rows: r.i64(4),
343            peak_rss_mb: r.opt_i64(5),
344            status: r.text(6),
345            error_message: r.opt_text(7),
346            tuning_profile: r.opt_text(8),
347            format: r.opt_text(9),
348            mode: r.opt_text(10),
349            files_produced: r.opt_i64(11).unwrap_or(0),
350            bytes_written: r.opt_i64(12).unwrap_or(0),
351            retries: r.opt_i64(13).unwrap_or(0),
352            validated: r.opt_bool(14),
353            schema_changed: r.opt_bool(15),
354        };
355        let cols = "export_name, run_id, run_at, duration_ms, total_rows, peak_rss_mb, \
356                    status, error_message, tuning_profile, format, mode, \
357                    files_produced, bytes_written, retries, validated, schema_changed";
358        match export_name {
359            Some(name) => self.query(
360                &format!(
361                    "SELECT {cols} FROM export_metrics WHERE export_name = ?1 ORDER BY id DESC LIMIT ?2"
362                ),
363                &[name.into(), (limit as i64).into()],
364                extract,
365            ),
366            None => self.query(
367                &format!("SELECT {cols} FROM export_metrics ORDER BY id DESC LIMIT ?1"),
368                &[(limit as i64).into()],
369                extract,
370            ),
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn store() -> StateStore {
380        StateStore::open_in_memory().expect("in-memory store")
381    }
382
383    #[test]
384    fn record_and_query_metrics() {
385        let s = store();
386        s.record_metric(
387            "orders",
388            "run_001",
389            1200,
390            50000,
391            Some(142),
392            "success",
393            None,
394            Some("safe"),
395            Some("parquet"),
396            Some("full"),
397            1,
398            4096,
399            0,
400            Some(true),
401            Some(false),
402        )
403        .unwrap();
404        s.record_metric(
405            "orders",
406            "run_002",
407            300,
408            0,
409            Some(30),
410            "failed",
411            Some("timeout"),
412            Some("safe"),
413            Some("parquet"),
414            Some("full"),
415            0,
416            0,
417            2,
418            None,
419            None,
420        )
421        .unwrap();
422
423        let metrics = s.get_metrics(Some("orders"), 10).unwrap();
424        assert_eq!(metrics.len(), 2);
425        assert_eq!(metrics[0].status, "failed");
426        assert_eq!(metrics[0].run_id.as_deref(), Some("run_002"));
427        assert_eq!(metrics[0].retries, 2);
428        assert_eq!(metrics[1].total_rows, 50000);
429        assert_eq!(metrics[1].run_id.as_deref(), Some("run_001"));
430        assert_eq!(metrics[1].files_produced, 1);
431        assert_eq!(metrics[1].bytes_written, 4096);
432        assert_eq!(metrics[1].validated, Some(true));
433        assert_eq!(metrics[1].schema_changed, Some(false));
434    }
435
436    #[test]
437    fn record_metric_full_persists_v9_columns_in_order() {
438        let s = store();
439        s.record_metric_full(&MetricRow {
440            export_name: "orders".into(),
441            run_id: "r1".into(),
442            duration_ms: 1200,
443            total_rows: 50_000,
444            status: "success".into(),
445            // v9 signals, chosen as distinct values so a positional param swap
446            // in the 30-column INSERT shows up as a wrong-column read below.
447            files_committed: 11,
448            source_count: Some(50_000),
449            pg_temp_bytes_delta: Some(1_048_576),
450            batch_size: 32_000,
451            chunk_size: Some(100_000),
452            parallel: Some(4),
453            longest_chunk_ms: Some(1_839),
454            ..Default::default()
455        })
456        .unwrap();
457
458        // Core read path is unchanged.
459        let got = s.get_metrics(Some("orders"), 1).unwrap();
460        assert_eq!(got.len(), 1);
461        assert_eq!(got[0].total_rows, 50_000);
462        assert_eq!(got[0].run_id.as_deref(), Some("r1"));
463
464        // v9 columns round-trip to the right column (chunk_size=100000 vs
465        // parallel=4 pinned separately so a swap of the two Option<i64> params
466        // can't pass).
467        assert_eq!(s.metric_scalar_i64("r1", "files_committed"), Some(11));
468        assert_eq!(s.metric_scalar_i64("r1", "source_count"), Some(50_000));
469        assert_eq!(
470            s.metric_scalar_i64("r1", "pg_temp_bytes_delta"),
471            Some(1_048_576)
472        );
473        assert_eq!(s.metric_scalar_i64("r1", "batch_size"), Some(32_000));
474        assert_eq!(s.metric_scalar_i64("r1", "chunk_size"), Some(100_000));
475        assert_eq!(s.metric_scalar_i64("r1", "parallel"), Some(4));
476        assert_eq!(s.metric_scalar_i64("r1", "longest_chunk_ms"), Some(1_839));
477    }
478
479    #[test]
480    fn record_harm_round_trips_per_counter_rows() {
481        // Traces the Tier 2 signal through SQLite: v11 migration creates
482        // export_harm, record_harm writes one row per counter, the read returns
483        // them. (The live source-probe that produces these deltas needs a real
484        // DB; this validates the storage path it lands in.)
485        let s = store();
486        let deltas = vec![
487            ("pg_tup_returned".to_string(), 1_000_000),
488            ("pg_blks_read".to_string(), 2_048),
489            ("pg_temp_files".to_string(), 3),
490        ];
491        s.record_harm("run-h", "content_items", &deltas).unwrap();
492
493        // One row per counter, keyed on run_id (read sorted by metric).
494        assert_eq!(
495            s.harm_rows_for_test("run-h"),
496            vec![
497                ("pg_blks_read".to_string(), 2_048),
498                ("pg_temp_files".to_string(), 3),
499                ("pg_tup_returned".to_string(), 1_000_000),
500            ]
501        );
502
503        // Empty delta set (probe skipped / counters unmoved) → no rows, no error.
504        s.record_harm("run-empty", "x", &[]).unwrap();
505        assert!(s.harm_rows_for_test("run-empty").is_empty());
506    }
507
508    #[test]
509    fn query_metrics_all_exports() {
510        let s = store();
511        s.record_metric(
512            "orders", "r1", 100, 1000, None, "success", None, None, None, None, 1, 500, 0, None,
513            None,
514        )
515        .unwrap();
516        s.record_metric(
517            "users", "r2", 200, 2000, None, "success", None, None, None, None, 1, 800, 0, None,
518            None,
519        )
520        .unwrap();
521
522        let metrics = s.get_metrics(None, 10).unwrap();
523        assert_eq!(metrics.len(), 2);
524    }
525
526    #[test]
527    fn metrics_limit_works() {
528        let s = store();
529        for i in 0..10 {
530            s.record_metric(
531                "t",
532                &format!("r{}", i),
533                i * 100,
534                i,
535                None,
536                "success",
537                None,
538                None,
539                None,
540                None,
541                0,
542                0,
543                0,
544                None,
545                None,
546            )
547            .unwrap();
548        }
549        let metrics = s.get_metrics(Some("t"), 3).unwrap();
550        assert_eq!(metrics.len(), 3);
551    }
552}