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 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 #[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 #[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 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 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 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 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 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 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 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}