1use super::{ArtifactStore, AuditStore, BaselineStore, StorageHealth};
7use crate::error::StoreError;
8use crate::models::{
9 AuditAction, AuditEvent, AuditResourceType, BaselineRecord, BaselineSource, BaselineVersion,
10 DecisionRecord, ListAuditEventsQuery, ListAuditEventsResponse, ListBaselinesQuery,
11 ListBaselinesResponse, ListDecisionsQuery, ListDecisionsResponse, ListVerdictsQuery,
12 ListVerdictsResponse, PaginationInfo, PruneDecisionsResponse, VerdictRecord,
13};
14use crate::server::PostgresPoolConfig;
15use async_trait::async_trait;
16use perfgate_types::{MetricStatus, VerdictStatus};
17use sqlx::{Connection, Executor, PgPool, Row, postgres::PgPoolOptions};
18use std::sync::Arc;
19use std::time::Duration;
20use tracing::{info, warn};
21
22#[derive(Debug, Clone)]
24pub struct PostgresStore {
25 pool: PgPool,
26 artifacts: Option<Arc<dyn ArtifactStore>>,
27}
28
29const MAX_RETRIES: u32 = 3;
31
32const INITIAL_BACKOFF: Duration = Duration::from_millis(250);
34
35fn is_transient(err: &sqlx::Error) -> bool {
38 match err {
39 sqlx::Error::PoolTimedOut => true,
40 sqlx::Error::PoolClosed => false,
41 sqlx::Error::Io(_) => true,
42 sqlx::Error::Database(db_err) => {
43 db_err
47 .code()
48 .map(|c| c.starts_with("08") || c.starts_with("57P"))
49 .unwrap_or(false)
50 }
51 _ => {
52 let msg = err.to_string().to_lowercase();
53 msg.contains("connection refused")
54 || msg.contains("connection reset")
55 || msg.contains("broken pipe")
56 || msg.contains("timed out")
57 }
58 }
59}
60
61fn postgres_schema_statements() -> [&'static str; 14] {
62 [
63 r#"
64 CREATE TABLE IF NOT EXISTS baselines (
65 id VARCHAR(64) PRIMARY KEY,
66 project VARCHAR(255) NOT NULL,
67 benchmark VARCHAR(255) NOT NULL,
68 version VARCHAR(64) NOT NULL,
69 schema_id VARCHAR(64) NOT NULL,
70 git_ref VARCHAR(255),
71 git_sha VARCHAR(40),
72 receipt JSONB,
73 artifact_path TEXT,
74 metadata JSONB NOT NULL,
75 tags JSONB NOT NULL,
76 created_at TIMESTAMPTZ NOT NULL,
77 updated_at TIMESTAMPTZ NOT NULL,
78 content_hash VARCHAR(64) NOT NULL,
79 source VARCHAR(32) NOT NULL,
80 deleted BOOLEAN NOT NULL DEFAULT FALSE,
81 UNIQUE (project, benchmark, version)
82 )
83 "#,
84 r#"
85 CREATE INDEX IF NOT EXISTS idx_baselines_project_benchmark
86 ON baselines(project, benchmark)
87 "#,
88 r#"
89 CREATE TABLE IF NOT EXISTS verdicts (
90 id VARCHAR(64) PRIMARY KEY,
91 schema_id VARCHAR(64) NOT NULL,
92 project VARCHAR(255) NOT NULL,
93 benchmark VARCHAR(255) NOT NULL,
94 run_id VARCHAR(255) NOT NULL,
95 status VARCHAR(32) NOT NULL,
96 counts JSONB NOT NULL,
97 reasons JSONB NOT NULL,
98 git_ref VARCHAR(255),
99 git_sha VARCHAR(40),
100 wall_ms_cv DOUBLE PRECISION,
101 flakiness_score DOUBLE PRECISION,
102 created_at TIMESTAMPTZ NOT NULL
103 )
104 "#,
105 r#"
106 CREATE INDEX IF NOT EXISTS idx_verdicts_project_benchmark
107 ON verdicts(project, benchmark)
108 "#,
109 r#"
110 CREATE INDEX IF NOT EXISTS idx_verdicts_created_at
111 ON verdicts(created_at)
112 "#,
113 r#"
114 CREATE TABLE IF NOT EXISTS decisions (
115 id VARCHAR(64) PRIMARY KEY,
116 schema_id VARCHAR(64) NOT NULL,
117 project VARCHAR(255) NOT NULL,
118 scenario VARCHAR(255),
119 status VARCHAR(32) NOT NULL,
120 verdict VARCHAR(32) NOT NULL,
121 accepted_rules JSONB NOT NULL DEFAULT '[]'::jsonb,
122 review_required BOOLEAN NOT NULL DEFAULT FALSE,
123 review_reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
124 git_ref VARCHAR(255),
125 git_sha VARCHAR(40),
126 scenario_receipt JSONB,
127 tradeoff_receipt JSONB NOT NULL,
128 artifact_index JSONB,
129 created_at TIMESTAMPTZ NOT NULL
130 )
131 "#,
132 r#"
133 CREATE INDEX IF NOT EXISTS idx_decisions_project_created
134 ON decisions(project, created_at DESC)
135 "#,
136 r#"
137 CREATE INDEX IF NOT EXISTS idx_decisions_project_scenario
138 ON decisions(project, scenario)
139 "#,
140 r#"
141 CREATE TABLE IF NOT EXISTS audit_events (
142 id VARCHAR(64) PRIMARY KEY,
143 timestamp TIMESTAMPTZ NOT NULL,
144 actor VARCHAR(255) NOT NULL,
145 action VARCHAR(32) NOT NULL,
146 resource_type VARCHAR(32) NOT NULL,
147 resource_id VARCHAR(255) NOT NULL,
148 project VARCHAR(255) NOT NULL,
149 metadata JSONB NOT NULL DEFAULT '{}'
150 )
151 "#,
152 r#"
153 CREATE INDEX IF NOT EXISTS idx_audit_events_project
154 ON audit_events(project)
155 "#,
156 r#"
157 CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp
158 ON audit_events(timestamp DESC)
159 "#,
160 r#"
161 CREATE INDEX IF NOT EXISTS idx_audit_events_action
162 ON audit_events(action)
163 "#,
164 "ALTER TABLE verdicts ADD COLUMN IF NOT EXISTS wall_ms_cv DOUBLE PRECISION",
165 "ALTER TABLE verdicts ADD COLUMN IF NOT EXISTS flakiness_score DOUBLE PRECISION",
166 ]
167}
168
169impl PostgresStore {
170 pub async fn new(
177 url: &str,
178 artifacts: Option<Arc<dyn ArtifactStore>>,
179 pool_config: &PostgresPoolConfig,
180 ) -> Result<Self, StoreError> {
181 let stmt_timeout_ms = pool_config.statement_timeout.as_millis() as u64;
182
183 let pool = PgPoolOptions::new()
184 .max_connections(pool_config.max_connections)
185 .min_connections(pool_config.min_connections)
186 .idle_timeout(pool_config.idle_timeout)
187 .max_lifetime(pool_config.max_lifetime)
188 .acquire_timeout(pool_config.acquire_timeout)
189 .after_connect(move |conn, _meta| {
190 Box::pin(async move {
191 conn.execute(format!("SET statement_timeout = '{}'", stmt_timeout_ms).as_str())
192 .await?;
193 Ok(())
194 })
195 })
196 .before_acquire(|conn, _meta| {
197 Box::pin(async move {
198 conn.ping().await?;
200 Ok(true)
201 })
202 })
203 .connect(url)
204 .await
205 .map_err(|e| StoreError::ConnectionError(e.to_string()))?;
206
207 info!(
208 max = pool_config.max_connections,
209 min = pool_config.min_connections,
210 idle_timeout_s = pool_config.idle_timeout.as_secs(),
211 max_lifetime_s = pool_config.max_lifetime.as_secs(),
212 acquire_timeout_s = pool_config.acquire_timeout.as_secs(),
213 statement_timeout_ms = stmt_timeout_ms,
214 "PostgreSQL connection pool configured"
215 );
216
217 let store = Self { pool, artifacts };
218 store.init_schema().await?;
219 Ok(store)
220 }
221
222 fn pg_pool_metrics(&self) -> crate::models::PoolMetrics {
224 let size = self.pool.size();
225 let num_idle = self.pool.num_idle() as u32;
226 crate::models::PoolMetrics {
227 idle: num_idle,
228 active: size.saturating_sub(num_idle),
229 max: self.pool.options().get_max_connections(),
230 }
231 }
232
233 pub(crate) async fn with_retry<F, Fut, T>(&self, operation: F) -> Result<T, StoreError>
239 where
240 F: Fn(PgPool) -> Fut,
241 Fut: std::future::Future<Output = Result<T, sqlx::Error>>,
242 {
243 let mut last_err = None;
244 let mut backoff = INITIAL_BACKOFF;
245
246 for attempt in 0..=MAX_RETRIES {
247 match operation(self.pool.clone()).await {
248 Ok(val) => return Ok(val),
249 Err(e) if is_transient(&e) && attempt < MAX_RETRIES => {
250 warn!(
251 attempt = attempt + 1,
252 max = MAX_RETRIES,
253 backoff_ms = backoff.as_millis() as u64,
254 error = %e,
255 "Transient database error, retrying"
256 );
257 tokio::time::sleep(backoff).await;
258 backoff *= 2;
259 last_err = Some(e);
260 }
261 Err(e) => {
262 return Err(StoreError::QueryError(e.to_string()));
263 }
264 }
265 }
266
267 Err(StoreError::QueryError(
268 last_err
269 .map(|e| e.to_string())
270 .unwrap_or_else(|| "Unknown error after retries".to_string()),
271 ))
272 }
273
274 async fn init_schema(&self) -> Result<(), StoreError> {
275 let statements = postgres_schema_statements();
276
277 for statement in statements {
278 sqlx::query(statement)
279 .execute(&self.pool)
280 .await
281 .map_err(|e| {
282 StoreError::ConnectionError(format!("Failed to init schema: {}", e))
283 })?;
284 }
285
286 Ok(())
287 }
288
289 async fn store_artifact(&self, record: &BaselineRecord) -> Result<Option<String>, StoreError> {
290 if let Some(store) = &self.artifacts {
291 let path = format!(
292 "{}/{}/{}.json",
293 record.project, record.benchmark, record.version
294 );
295 let data =
296 serde_json::to_vec(&record.receipt).map_err(StoreError::SerializationError)?;
297 store.put(&path, data).await?;
298 Ok(Some(path))
299 } else {
300 Ok(None)
301 }
302 }
303
304 fn row_to_record(
305 row: sqlx::postgres::PgRow,
306 ) -> Result<(BaselineRecord, Option<String>), StoreError> {
307 let artifact_path: Option<String> = row.get("artifact_path");
308
309 let receipt = if let Some(receipt_json) = row.get::<Option<serde_json::Value>, _>("receipt")
310 {
311 serde_json::from_value(receipt_json).map_err(StoreError::SerializationError)?
312 } else {
313 serde_json::from_value(serde_json::json!({
315 "schema": "perfgate.run.v1",
316 "tool": {"name": "placeholder", "version": "0"},
317 "run": {
318 "id": "placeholder",
319 "started_at": "1970-01-01T00:00:00Z",
320 "ended_at": "1970-01-01T00:00:00Z",
321 "host": {"os": "unknown", "arch": "unknown"}
322 },
323 "bench": {
324 "name": "placeholder",
325 "command": [],
326 "repeat": 0,
327 "warmup": 0
328 },
329 "samples": [],
330 "stats": {
331 "wall_ms": {"median": 0, "min": 0, "max": 0}
332 }
333 }))
334 .unwrap()
335 };
336
337 let metadata_json: serde_json::Value = row.get("metadata");
338 let metadata =
339 serde_json::from_value(metadata_json).map_err(StoreError::SerializationError)?;
340
341 let tags_json: serde_json::Value = row.get("tags");
342 let tags = serde_json::from_value(tags_json).map_err(StoreError::SerializationError)?;
343
344 let source_str: String = row.get("source");
345 let source = serde_json::from_value(serde_json::Value::String(source_str))
346 .unwrap_or(BaselineSource::Upload);
347
348 let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
349 let updated_at: chrono::DateTime<chrono::Utc> = row.get("updated_at");
350
351 Ok((
352 BaselineRecord {
353 schema: row.get("schema_id"),
354 id: row.get("id"),
355 project: row.get("project"),
356 benchmark: row.get("benchmark"),
357 version: row.get("version"),
358 git_ref: row.get("git_ref"),
359 git_sha: row.get("git_sha"),
360 receipt,
361 metadata,
362 tags,
363 created_at,
364 updated_at,
365 content_hash: row.get("content_hash"),
366 source,
367 deleted: row.get("deleted"),
368 },
369 artifact_path,
370 ))
371 }
372
373 async fn load_artifact(
374 &self,
375 path: Option<String>,
376 mut record: BaselineRecord,
377 ) -> Result<BaselineRecord, StoreError> {
378 if let (Some(store), Some(path)) = (&self.artifacts, path) {
379 let data = store.get(&path).await?;
380 record.receipt =
381 serde_json::from_slice(&data).map_err(StoreError::SerializationError)?;
382 }
383 Ok(record)
384 }
385}
386
387#[async_trait]
388impl BaselineStore for PostgresStore {
389 async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
390 let artifact_path = self.store_artifact(record).await?;
391
392 let receipt_json = if artifact_path.is_none() {
393 Some(serde_json::to_value(&record.receipt).map_err(StoreError::SerializationError)?)
394 } else {
395 None
396 };
397
398 let metadata_json =
399 serde_json::to_value(&record.metadata).map_err(StoreError::SerializationError)?;
400 let tags_json =
401 serde_json::to_value(&record.tags).map_err(StoreError::SerializationError)?;
402 let source_json =
403 serde_json::to_value(&record.source).map_err(StoreError::SerializationError)?;
404 let source_str = source_json.as_str().unwrap_or("upload");
405
406 let sql = r#"
407 INSERT INTO baselines (
408 id, project, benchmark, version, schema_id,
409 git_ref, git_sha, receipt, artifact_path, metadata, tags,
410 created_at, updated_at, content_hash, source, deleted
411 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
412 "#;
413
414 let result = sqlx::query(sql)
415 .bind(&record.id)
416 .bind(&record.project)
417 .bind(&record.benchmark)
418 .bind(&record.version)
419 .bind(&record.schema)
420 .bind(&record.git_ref)
421 .bind(&record.git_sha)
422 .bind(receipt_json)
423 .bind(artifact_path)
424 .bind(metadata_json)
425 .bind(tags_json)
426 .bind(record.created_at)
427 .bind(record.updated_at)
428 .bind(&record.content_hash)
429 .bind(source_str)
430 .bind(record.deleted)
431 .execute(&self.pool)
432 .await;
433
434 match result {
435 Ok(_) => Ok(()),
436 Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
437 StoreError::already_exists(&record.project, &record.benchmark, &record.version),
438 ),
439 Err(e) => Err(StoreError::QueryError(e.to_string())),
440 }
441 }
442
443 async fn get(
444 &self,
445 project: &str,
446 benchmark: &str,
447 version: &str,
448 ) -> Result<Option<BaselineRecord>, StoreError> {
449 let sql = "SELECT * FROM baselines WHERE project = $1 AND benchmark = $2 AND version = $3 AND deleted = FALSE";
450
451 let row_opt = sqlx::query(sql)
452 .bind(project)
453 .bind(benchmark)
454 .bind(version)
455 .fetch_optional(&self.pool)
456 .await
457 .map_err(|e| StoreError::QueryError(e.to_string()))?;
458
459 match row_opt {
460 Some(row) => {
461 let (record, artifact_path) = Self::row_to_record(row)?;
462 Ok(Some(self.load_artifact(artifact_path, record).await?))
463 }
464 None => Ok(None),
465 }
466 }
467
468 async fn get_latest(
469 &self,
470 project: &str,
471 benchmark: &str,
472 ) -> Result<Option<BaselineRecord>, StoreError> {
473 let sql = "SELECT * FROM baselines WHERE project = $1 AND benchmark = $2 AND deleted = FALSE ORDER BY created_at DESC LIMIT 1";
474
475 let row_opt = sqlx::query(sql)
476 .bind(project)
477 .bind(benchmark)
478 .fetch_optional(&self.pool)
479 .await
480 .map_err(|e| StoreError::QueryError(e.to_string()))?;
481
482 match row_opt {
483 Some(row) => {
484 let (record, artifact_path) = Self::row_to_record(row)?;
485 Ok(Some(self.load_artifact(artifact_path, record).await?))
486 }
487 None => Ok(None),
488 }
489 }
490
491 async fn list(
492 &self,
493 project: &str,
494 query: &ListBaselinesQuery,
495 ) -> Result<ListBaselinesResponse, StoreError> {
496 let mut sql =
497 String::from("SELECT * FROM baselines WHERE project = $1 AND deleted = FALSE");
498
499 if let Some(bench) = &query.benchmark {
500 sql.push_str(" AND benchmark = '");
501 sql.push_str(&bench.replace('\'', "''"));
502 sql.push('\'');
503 }
504
505 sql.push_str(" ORDER BY created_at DESC");
506
507 let limit = query.limit.min(100) as i64;
508 sql.push_str(&format!(" LIMIT {}", limit + 1));
509
510 let offset = query.offset as i64;
511 sql.push_str(&format!(" OFFSET {}", offset));
512
513 let rows = sqlx::query(&sql)
514 .bind(project)
515 .fetch_all(&self.pool)
516 .await
517 .map_err(|e| StoreError::QueryError(e.to_string()))?;
518
519 let has_more = rows.len() > limit as usize;
520 let take_count = if has_more { limit as usize } else { rows.len() };
521
522 let mut baselines = Vec::with_capacity(take_count);
523 for row in rows.into_iter().take(take_count) {
524 let (mut record, artifact_path) = Self::row_to_record(row)?;
525 if query.include_receipt {
526 record = self.load_artifact(artifact_path, record).await?;
527 }
528 baselines.push(record.into());
529 }
530
531 let count_sql = "SELECT COUNT(*) FROM baselines WHERE project = $1 AND deleted = FALSE";
533 let mut count_query = String::from(count_sql);
534 if let Some(bench) = &query.benchmark {
535 count_query.push_str(" AND benchmark = '");
536 count_query.push_str(&bench.replace('\'', "''"));
537 count_query.push('\'');
538 }
539 let total_row = sqlx::query(&count_query)
540 .bind(project)
541 .fetch_one(&self.pool)
542 .await
543 .map_err(|e| StoreError::QueryError(e.to_string()))?;
544
545 let total: i64 = total_row.get(0);
546
547 let pagination = PaginationInfo {
548 limit: limit as u32,
549 offset: query.offset,
550 total: total as u64,
551 has_more,
552 };
553
554 Ok(ListBaselinesResponse {
555 baselines,
556 pagination,
557 })
558 }
559
560 async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
561 let receipt_json =
562 serde_json::to_value(&record.receipt).map_err(StoreError::SerializationError)?;
563 let metadata_json =
564 serde_json::to_value(&record.metadata).map_err(StoreError::SerializationError)?;
565 let tags_json =
566 serde_json::to_value(&record.tags).map_err(StoreError::SerializationError)?;
567
568 let sql = r#"
569 UPDATE baselines
570 SET schema_id = $1, git_ref = $2, git_sha = $3, receipt = $4,
571 metadata = $5, tags = $6, updated_at = $7, content_hash = $8
572 WHERE project = $9 AND benchmark = $10 AND version = $11 AND deleted = FALSE
573 "#;
574
575 let result = sqlx::query(sql)
576 .bind(&record.schema)
577 .bind(&record.git_ref)
578 .bind(&record.git_sha)
579 .bind(receipt_json)
580 .bind(metadata_json)
581 .bind(tags_json)
582 .bind(record.updated_at)
583 .bind(&record.content_hash)
584 .bind(&record.project)
585 .bind(&record.benchmark)
586 .bind(&record.version)
587 .execute(&self.pool)
588 .await
589 .map_err(|e| StoreError::QueryError(e.to_string()))?;
590
591 if result.rows_affected() == 0 {
592 return Err(StoreError::not_found(
593 &record.project,
594 &record.benchmark,
595 &record.version,
596 ));
597 }
598
599 Ok(())
600 }
601
602 async fn delete(
603 &self,
604 project: &str,
605 benchmark: &str,
606 version: &str,
607 ) -> Result<bool, StoreError> {
608 let sql = "UPDATE baselines SET deleted = TRUE, updated_at = NOW() WHERE project = $1 AND benchmark = $2 AND version = $3 AND deleted = FALSE";
609
610 let result = sqlx::query(sql)
611 .bind(project)
612 .bind(benchmark)
613 .bind(version)
614 .execute(&self.pool)
615 .await
616 .map_err(|e| StoreError::QueryError(e.to_string()))?;
617
618 Ok(result.rows_affected() > 0)
619 }
620
621 async fn hard_delete(
622 &self,
623 project: &str,
624 benchmark: &str,
625 version: &str,
626 ) -> Result<bool, StoreError> {
627 let sql = "DELETE FROM baselines WHERE project = $1 AND benchmark = $2 AND version = $3";
628
629 let result = sqlx::query(sql)
630 .bind(project)
631 .bind(benchmark)
632 .bind(version)
633 .execute(&self.pool)
634 .await
635 .map_err(|e| StoreError::QueryError(e.to_string()))?;
636
637 Ok(result.rows_affected() > 0)
638 }
639
640 async fn list_versions(
641 &self,
642 project: &str,
643 benchmark: &str,
644 ) -> Result<Vec<BaselineVersion>, StoreError> {
645 let sql = "SELECT version, created_at, git_ref, git_sha, source FROM baselines WHERE project = $1 AND benchmark = $2 AND deleted = FALSE ORDER BY created_at DESC";
646
647 let rows = sqlx::query(sql)
648 .bind(project)
649 .bind(benchmark)
650 .fetch_all(&self.pool)
651 .await
652 .map_err(|e| StoreError::QueryError(e.to_string()))?;
653
654 let mut versions = Vec::with_capacity(rows.len());
655 for row in rows {
656 let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
657 let source_str: String = row.get("source");
658 let source = serde_json::from_value(serde_json::Value::String(source_str))
659 .unwrap_or(BaselineSource::Upload);
660
661 versions.push(BaselineVersion {
662 version: row.get("version"),
663 created_at,
664 git_ref: row.get("git_ref"),
665 git_sha: row.get("git_sha"),
666 created_by: None,
667 is_current: false, source,
669 });
670 }
671
672 if let Some(first) = versions.first_mut() {
673 first.is_current = true;
674 }
675
676 Ok(versions)
677 }
678
679 async fn health_check(&self) -> Result<StorageHealth, StoreError> {
680 self.with_retry(|pool| async move { sqlx::query("SELECT 1").execute(&pool).await })
681 .await?;
682 Ok(StorageHealth::Healthy)
683 }
684
685 fn backend_type(&self) -> &'static str {
686 "postgres"
687 }
688
689 fn pool_metrics(&self) -> Option<crate::models::PoolMetrics> {
690 Some(self.pg_pool_metrics())
691 }
692
693 async fn create_verdict(&self, record: &VerdictRecord) -> Result<(), StoreError> {
694 let sql = r#"
695 INSERT INTO verdicts (
696 id, schema_id, project, benchmark, run_id, status, counts, reasons,
697 git_ref, git_sha, wall_ms_cv, flakiness_score, created_at
698 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
699 "#;
700
701 let counts_json =
702 serde_json::to_value(&record.counts).map_err(StoreError::SerializationError)?;
703 let reasons_json =
704 serde_json::to_value(&record.reasons).map_err(StoreError::SerializationError)?;
705 let status_str = record.status.as_str();
706
707 sqlx::query(sql)
708 .bind(&record.id)
709 .bind(&record.schema)
710 .bind(&record.project)
711 .bind(&record.benchmark)
712 .bind(&record.run_id)
713 .bind(status_str)
714 .bind(counts_json)
715 .bind(reasons_json)
716 .bind(&record.git_ref)
717 .bind(&record.git_sha)
718 .bind(record.wall_ms_cv)
719 .bind(record.flakiness_score)
720 .bind(record.created_at)
721 .execute(&self.pool)
722 .await
723 .map_err(|e| StoreError::QueryError(e.to_string()))?;
724
725 Ok(())
726 }
727
728 async fn list_verdicts(
729 &self,
730 project: &str,
731 query: &ListVerdictsQuery,
732 ) -> Result<ListVerdictsResponse, StoreError> {
733 let mut sql = "SELECT * FROM verdicts WHERE project = $1".to_string();
734 let mut params_count = 1;
735
736 if let Some(_bench) = &query.benchmark {
737 params_count += 1;
738 sql.push_str(&format!(" AND benchmark = ${}", params_count));
739 }
740
741 if let Some(_status) = &query.status {
742 params_count += 1;
743 sql.push_str(&format!(" AND status = ${}", params_count));
744 }
745
746 if let Some(_since) = &query.since {
747 params_count += 1;
748 sql.push_str(&format!(" AND created_at >= ${}", params_count));
749 }
750
751 if let Some(_until) = &query.until {
752 params_count += 1;
753 sql.push_str(&format!(" AND created_at <= ${}", params_count));
754 }
755
756 sql.push_str(" ORDER BY created_at DESC");
757
758 params_count += 1;
760 sql.push_str(&format!(" LIMIT ${}", params_count));
761 params_count += 1;
762 sql.push_str(&format!(" OFFSET ${}", params_count));
763
764 let mut q = sqlx::query(&sql).bind(project);
765
766 if let Some(bench) = &query.benchmark {
767 q = q.bind(bench);
768 }
769 if let Some(status) = &query.status {
770 q = q.bind(status.as_str());
771 }
772 if let Some(since) = &query.since {
773 q = q.bind(since);
774 }
775 if let Some(until) = &query.until {
776 q = q.bind(until);
777 }
778
779 q = q.bind(query.limit as i64);
780 q = q.bind(query.offset as i64);
781
782 let rows = q
783 .fetch_all(&self.pool)
784 .await
785 .map_err(|e| StoreError::QueryError(e.to_string()))?;
786
787 let mut verdicts = Vec::with_capacity(rows.len());
788 for row in rows {
789 verdicts.push(self.row_to_verdict(row)?);
790 }
791
792 let count_sql = "SELECT COUNT(*) FROM verdicts WHERE project = $1";
794 let total: i64 = sqlx::query_scalar(count_sql)
795 .bind(project)
796 .fetch_one(&self.pool)
797 .await
798 .map_err(|e| StoreError::QueryError(e.to_string()))?;
799
800 Ok(ListVerdictsResponse {
801 verdicts,
802 pagination: PaginationInfo {
803 total: total as u64,
804 offset: query.offset,
805 limit: query.limit,
806 has_more: (query.offset + query.limit as u64) < total as u64,
807 },
808 })
809 }
810
811 async fn create_decision(&self, record: &DecisionRecord) -> Result<(), StoreError> {
812 let sql = r#"
813 INSERT INTO decisions (
814 id, schema_id, project, scenario, status, verdict, accepted_rules,
815 review_required, review_reasons, git_ref, git_sha, scenario_receipt,
816 tradeoff_receipt, artifact_index, created_at
817 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
818 "#;
819
820 let accepted_rules =
821 serde_json::to_value(&record.accepted_rules).map_err(StoreError::SerializationError)?;
822 let review_reasons =
823 serde_json::to_value(&record.review_reasons).map_err(StoreError::SerializationError)?;
824 let scenario_receipt = record
825 .scenario_receipt
826 .as_ref()
827 .map(serde_json::to_value)
828 .transpose()
829 .map_err(StoreError::SerializationError)?;
830 let tradeoff_receipt = serde_json::to_value(&record.tradeoff_receipt)
831 .map_err(StoreError::SerializationError)?;
832 let artifact_index = record
833 .artifact_index
834 .as_ref()
835 .map(serde_json::to_value)
836 .transpose()
837 .map_err(StoreError::SerializationError)?;
838
839 sqlx::query(sql)
840 .bind(&record.id)
841 .bind(&record.schema)
842 .bind(&record.project)
843 .bind(&record.scenario)
844 .bind(record.status.as_str())
845 .bind(record.verdict.as_str())
846 .bind(accepted_rules)
847 .bind(record.review_required)
848 .bind(review_reasons)
849 .bind(&record.git_ref)
850 .bind(&record.git_sha)
851 .bind(scenario_receipt)
852 .bind(tradeoff_receipt)
853 .bind(artifact_index)
854 .bind(record.created_at)
855 .execute(&self.pool)
856 .await
857 .map_err(|e| StoreError::QueryError(e.to_string()))?;
858
859 Ok(())
860 }
861
862 async fn latest_decision(&self, project: &str) -> Result<Option<DecisionRecord>, StoreError> {
863 let row = sqlx::query(
864 "SELECT * FROM decisions WHERE project = $1 ORDER BY created_at DESC LIMIT 1",
865 )
866 .bind(project)
867 .fetch_optional(&self.pool)
868 .await
869 .map_err(|e| StoreError::QueryError(e.to_string()))?;
870
871 row.map(|row| self.row_to_decision(row)).transpose()
872 }
873
874 async fn list_decisions(
875 &self,
876 project: &str,
877 query: &ListDecisionsQuery,
878 ) -> Result<ListDecisionsResponse, StoreError> {
879 let mut filter_sql = " FROM decisions WHERE project = $1".to_string();
880 let mut params_count = 1;
881
882 if query.scenario.is_some() {
883 params_count += 1;
884 filter_sql.push_str(&format!(" AND scenario = ${}", params_count));
885 }
886 if query.status.is_some() {
887 params_count += 1;
888 filter_sql.push_str(&format!(" AND status = ${}", params_count));
889 }
890 if query.verdict.is_some() {
891 params_count += 1;
892 filter_sql.push_str(&format!(" AND verdict = ${}", params_count));
893 }
894 if query.review_required.is_some() {
895 params_count += 1;
896 filter_sql.push_str(&format!(" AND review_required = ${}", params_count));
897 }
898 if let Some(accepted) = query.accepted {
899 if accepted {
900 filter_sql.push_str(" AND jsonb_array_length(accepted_rules) > 0");
901 } else {
902 filter_sql.push_str(" AND jsonb_array_length(accepted_rules) = 0");
903 }
904 }
905 if query.rule.is_some() {
906 params_count += 1;
907 filter_sql.push_str(&format!(" AND accepted_rules @> ${}::jsonb", params_count));
908 }
909
910 let count_sql = format!("SELECT COUNT(*){}", filter_sql);
911 let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql).bind(project);
912 if let Some(scenario) = &query.scenario {
913 count_query = count_query.bind(scenario);
914 }
915 if let Some(status) = query.status {
916 count_query = count_query.bind(status.as_str());
917 }
918 if let Some(verdict) = query.verdict {
919 count_query = count_query.bind(verdict.as_str());
920 }
921 if let Some(review_required) = query.review_required {
922 count_query = count_query.bind(review_required);
923 }
924 if let Some(rule) = &query.rule {
925 count_query = count_query.bind(serde_json::json!([rule]));
926 }
927 let total = count_query
928 .fetch_one(&self.pool)
929 .await
930 .map_err(|e| StoreError::QueryError(e.to_string()))?;
931
932 params_count += 1;
933 let limit_param = params_count;
934 params_count += 1;
935 let offset_param = params_count;
936 let sql = format!(
937 "SELECT *{} ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
938 filter_sql, limit_param, offset_param
939 );
940
941 let mut q = sqlx::query(&sql).bind(project);
942 if let Some(scenario) = &query.scenario {
943 q = q.bind(scenario);
944 }
945 if let Some(status) = query.status {
946 q = q.bind(status.as_str());
947 }
948 if let Some(verdict) = query.verdict {
949 q = q.bind(verdict.as_str());
950 }
951 if let Some(review_required) = query.review_required {
952 q = q.bind(review_required);
953 }
954 if let Some(rule) = &query.rule {
955 q = q.bind(serde_json::json!([rule]));
956 }
957 q = q.bind(query.limit as i64).bind(query.offset as i64);
958
959 let rows = q
960 .fetch_all(&self.pool)
961 .await
962 .map_err(|e| StoreError::QueryError(e.to_string()))?;
963
964 let mut decisions = Vec::with_capacity(rows.len());
965 for row in rows {
966 decisions.push(self.row_to_decision(row)?);
967 }
968
969 Ok(ListDecisionsResponse {
970 decisions,
971 pagination: PaginationInfo {
972 total: total as u64,
973 offset: query.offset,
974 limit: query.limit,
975 has_more: (query.offset + query.limit as u64) < total as u64,
976 },
977 })
978 }
979
980 async fn prune_decisions(
981 &self,
982 project: &str,
983 older_than: chrono::DateTime<chrono::Utc>,
984 dry_run: bool,
985 ) -> Result<PruneDecisionsResponse, StoreError> {
986 let ids = sqlx::query_scalar::<_, String>(
987 "SELECT id FROM decisions WHERE project = $1 AND created_at < $2 ORDER BY created_at ASC",
988 )
989 .bind(project)
990 .bind(older_than)
991 .fetch_all(&self.pool)
992 .await
993 .map_err(|e| StoreError::QueryError(e.to_string()))?;
994 let matched = ids.len() as u64;
995
996 let deleted = if dry_run {
997 0
998 } else {
999 sqlx::query("DELETE FROM decisions WHERE project = $1 AND created_at < $2")
1000 .bind(project)
1001 .bind(older_than)
1002 .execute(&self.pool)
1003 .await
1004 .map_err(|e| StoreError::QueryError(e.to_string()))?
1005 .rows_affected()
1006 };
1007
1008 Ok(PruneDecisionsResponse {
1009 project: project.to_string(),
1010 older_than,
1011 dry_run,
1012 matched,
1013 deleted,
1014 decision_ids: ids,
1015 })
1016 }
1017}
1018
1019impl PostgresStore {
1020 fn row_to_verdict(&self, row: sqlx::postgres::PgRow) -> Result<VerdictRecord, StoreError> {
1021 let status_str: String = row.get("status");
1022 let status = match status_str.as_str() {
1023 "pass" => VerdictStatus::Pass,
1024 "warn" => VerdictStatus::Warn,
1025 "fail" => VerdictStatus::Fail,
1026 "skip" => VerdictStatus::Skip,
1027 _ => VerdictStatus::Pass, };
1029
1030 let counts_json: serde_json::Value = row.get("counts");
1031 let counts = serde_json::from_value(counts_json).map_err(StoreError::SerializationError)?;
1032
1033 let reasons_json: serde_json::Value = row.get("reasons");
1034 let reasons =
1035 serde_json::from_value(reasons_json).map_err(StoreError::SerializationError)?;
1036
1037 Ok(VerdictRecord {
1038 schema: row.get("schema_id"), id: row.get("id"),
1040 project: row.get("project"),
1041 benchmark: row.get("benchmark"),
1042 run_id: row.get("run_id"),
1043 status,
1044 counts,
1045 reasons,
1046 git_ref: row.get("git_ref"),
1047 git_sha: row.get("git_sha"),
1048 wall_ms_cv: row.get("wall_ms_cv"),
1049 flakiness_score: row.get("flakiness_score"),
1050 created_at: row.get("created_at"),
1051 })
1052 }
1053
1054 fn row_to_decision(&self, row: sqlx::postgres::PgRow) -> Result<DecisionRecord, StoreError> {
1055 let status_str: String = row.get("status");
1056 let verdict_str: String = row.get("verdict");
1057 let accepted_rules_json: serde_json::Value = row.get("accepted_rules");
1058 let review_reasons_json: serde_json::Value = row.get("review_reasons");
1059 let scenario_receipt_json: Option<serde_json::Value> = row.get("scenario_receipt");
1060 let tradeoff_receipt_json: serde_json::Value = row.get("tradeoff_receipt");
1061 let artifact_index_json: Option<serde_json::Value> = row.get("artifact_index");
1062
1063 Ok(DecisionRecord {
1064 schema: row.get("schema_id"),
1065 id: row.get("id"),
1066 project: row.get("project"),
1067 scenario: row.get("scenario"),
1068 status: parse_metric_status(&status_str),
1069 verdict: parse_verdict_status(&verdict_str),
1070 accepted_rules: serde_json::from_value(accepted_rules_json)
1071 .map_err(StoreError::SerializationError)?,
1072 review_required: row.get("review_required"),
1073 review_reasons: serde_json::from_value(review_reasons_json)
1074 .map_err(StoreError::SerializationError)?,
1075 git_ref: row.get("git_ref"),
1076 git_sha: row.get("git_sha"),
1077 scenario_receipt: scenario_receipt_json
1078 .map(serde_json::from_value)
1079 .transpose()
1080 .map_err(StoreError::SerializationError)?,
1081 tradeoff_receipt: serde_json::from_value(tradeoff_receipt_json)
1082 .map_err(StoreError::SerializationError)?,
1083 artifact_index: artifact_index_json
1084 .map(serde_json::from_value)
1085 .transpose()
1086 .map_err(StoreError::SerializationError)?,
1087 created_at: row.get("created_at"),
1088 })
1089 }
1090}
1091
1092fn parse_metric_status(status: &str) -> MetricStatus {
1093 match status {
1094 "pass" => MetricStatus::Pass,
1095 "warn" => MetricStatus::Warn,
1096 "fail" => MetricStatus::Fail,
1097 "skip" => MetricStatus::Skip,
1098 _ => MetricStatus::Warn,
1099 }
1100}
1101
1102fn parse_verdict_status(status: &str) -> VerdictStatus {
1103 match status {
1104 "pass" => VerdictStatus::Pass,
1105 "warn" => VerdictStatus::Warn,
1106 "fail" => VerdictStatus::Fail,
1107 "skip" => VerdictStatus::Skip,
1108 _ => VerdictStatus::Warn,
1109 }
1110}
1111
1112#[async_trait]
1113impl AuditStore for PostgresStore {
1114 async fn log_event(&self, event: &AuditEvent) -> Result<(), StoreError> {
1115 let metadata_json =
1116 serde_json::to_value(&event.metadata).map_err(StoreError::SerializationError)?;
1117
1118 let sql = r#"
1119 INSERT INTO audit_events (
1120 id, timestamp, actor, action, resource_type, resource_id, project, metadata
1121 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1122 "#;
1123
1124 sqlx::query(sql)
1125 .bind(&event.id)
1126 .bind(event.timestamp)
1127 .bind(&event.actor)
1128 .bind(event.action.to_string())
1129 .bind(event.resource_type.to_string())
1130 .bind(&event.resource_id)
1131 .bind(&event.project)
1132 .bind(metadata_json)
1133 .execute(&self.pool)
1134 .await
1135 .map_err(|e| StoreError::QueryError(e.to_string()))?;
1136
1137 Ok(())
1138 }
1139
1140 async fn list_events(
1141 &self,
1142 query: &ListAuditEventsQuery,
1143 ) -> Result<ListAuditEventsResponse, StoreError> {
1144 let mut sql = "SELECT * FROM audit_events WHERE TRUE".to_string();
1145 let mut params_count = 0;
1146
1147 if query.project.is_some() {
1148 params_count += 1;
1149 sql.push_str(&format!(" AND project = ${}", params_count));
1150 }
1151 if query.action.is_some() {
1152 params_count += 1;
1153 sql.push_str(&format!(" AND action = ${}", params_count));
1154 }
1155 if query.resource_type.is_some() {
1156 params_count += 1;
1157 sql.push_str(&format!(" AND resource_type = ${}", params_count));
1158 }
1159 if query.actor.is_some() {
1160 params_count += 1;
1161 sql.push_str(&format!(" AND actor = ${}", params_count));
1162 }
1163 if query.since.is_some() {
1164 params_count += 1;
1165 sql.push_str(&format!(" AND timestamp >= ${}", params_count));
1166 }
1167 if query.until.is_some() {
1168 params_count += 1;
1169 sql.push_str(&format!(" AND timestamp <= ${}", params_count));
1170 }
1171
1172 sql.push_str(" ORDER BY timestamp DESC");
1173
1174 params_count += 1;
1175 sql.push_str(&format!(" LIMIT ${}", params_count));
1176 params_count += 1;
1177 sql.push_str(&format!(" OFFSET ${}", params_count));
1178
1179 let mut q = sqlx::query(&sql);
1180
1181 if let Some(ref project) = query.project {
1182 q = q.bind(project);
1183 }
1184 if let Some(ref action) = query.action {
1185 q = q.bind(action);
1186 }
1187 if let Some(ref resource_type) = query.resource_type {
1188 q = q.bind(resource_type);
1189 }
1190 if let Some(ref actor) = query.actor {
1191 q = q.bind(actor);
1192 }
1193 if let Some(ref since) = query.since {
1194 q = q.bind(since);
1195 }
1196 if let Some(ref until) = query.until {
1197 q = q.bind(until);
1198 }
1199
1200 q = q.bind(query.limit as i64);
1201 q = q.bind(query.offset as i64);
1202
1203 let rows = q
1204 .fetch_all(&self.pool)
1205 .await
1206 .map_err(|e| StoreError::QueryError(e.to_string()))?;
1207
1208 let mut events = Vec::with_capacity(rows.len());
1209 for row in rows {
1210 let action_str: String = row.get("action");
1211 let action = action_str
1212 .parse::<AuditAction>()
1213 .unwrap_or(AuditAction::Create);
1214
1215 let resource_type_str: String = row.get("resource_type");
1216 let resource_type = resource_type_str
1217 .parse::<AuditResourceType>()
1218 .unwrap_or(AuditResourceType::Baseline);
1219
1220 let metadata_json: serde_json::Value = row.get("metadata");
1221
1222 events.push(AuditEvent {
1223 id: row.get("id"),
1224 timestamp: row.get("timestamp"),
1225 actor: row.get("actor"),
1226 action,
1227 resource_type,
1228 resource_id: row.get("resource_id"),
1229 project: row.get("project"),
1230 metadata: metadata_json,
1231 });
1232 }
1233
1234 let count_sql = "SELECT COUNT(*) FROM audit_events WHERE TRUE";
1236 let total: i64 = sqlx::query_scalar(count_sql)
1237 .fetch_one(&self.pool)
1238 .await
1239 .map_err(|e| StoreError::QueryError(e.to_string()))?;
1240
1241 Ok(ListAuditEventsResponse {
1242 events,
1243 pagination: PaginationInfo {
1244 total: total as u64,
1245 offset: query.offset,
1246 limit: query.limit,
1247 has_more: (query.offset + query.limit as u64) < total as u64,
1248 },
1249 })
1250 }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255 use super::*;
1256
1257 #[test]
1258 fn test_is_transient_pool_timed_out() {
1259 assert!(is_transient(&sqlx::Error::PoolTimedOut));
1260 }
1261
1262 #[test]
1263 fn test_is_transient_pool_closed() {
1264 assert!(!is_transient(&sqlx::Error::PoolClosed));
1265 }
1266
1267 #[test]
1268 fn test_is_transient_io_error() {
1269 let err = sqlx::Error::Io(std::io::Error::new(
1270 std::io::ErrorKind::ConnectionRefused,
1271 "connection refused",
1272 ));
1273 assert!(is_transient(&err));
1274 }
1275
1276 #[test]
1277 fn test_is_transient_non_transient() {
1278 let err = sqlx::Error::ColumnNotFound("missing".to_string());
1279 assert!(!is_transient(&err));
1280 }
1281
1282 #[test]
1283 fn test_postgres_schema_ids_fit_generated_ids() {
1284 let generated_id = crate::models::generate_ulid();
1285 assert!(generated_id.len() <= 64);
1286
1287 for table in ["baselines", "verdicts", "decisions"] {
1288 let table_ddl = postgres_schema_statements()
1289 .into_iter()
1290 .find(|statement| {
1291 statement.contains(&format!("CREATE TABLE IF NOT EXISTS {}", table))
1292 })
1293 .expect("table schema should be present");
1294 assert!(table_ddl.contains("id VARCHAR(64) PRIMARY KEY"));
1295 assert!(!table_ddl.contains("id VARCHAR(26) PRIMARY KEY"));
1296 }
1297 }
1298}