1use crate::error::Result;
2
3use super::{StateConn, StateStore, pg_sql};
4
5#[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#[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 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 pub longest_chunk_ms: Option<i64>,
68 pub chunk_key: Option<String>,
72}
73
74impl StateStore {
79 #[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 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 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 #[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 #[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 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 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 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 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 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 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 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}