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