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 pub error_class: Option<String>,
79 pub cursor_min: Option<String>,
81 pub cursor_max: Option<String>,
82 pub key_descriptor_json: Option<String>,
85 pub offending_value: Option<String>,
88 pub server_context_json: Option<String>,
91}
92
93impl StateStore {
98 #[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 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 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 #[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 #[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 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 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 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 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 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 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 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}