Skip to main content

perfgate_server/storage/
memory.rs

1//! In-memory storage implementation for testing and development.
2
3use async_trait::async_trait;
4use std::collections::BTreeMap;
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8use super::{AuditStore, BaselineStore, StorageHealth};
9use crate::error::StoreError;
10use crate::models::{
11    AuditEvent, BaselineRecord, BaselineSummary, BaselineVersion, DecisionRecord,
12    ListAuditEventsQuery, ListAuditEventsResponse, ListBaselinesQuery, ListBaselinesResponse,
13    ListDecisionsQuery, ListDecisionsResponse, ListVerdictsQuery, ListVerdictsResponse,
14    PaginationInfo, PruneDecisionsResponse, VerdictRecord,
15};
16
17/// In-memory storage backend for baselines.
18#[derive(Debug, Default)]
19pub struct InMemoryStore {
20    #[allow(clippy::type_complexity)]
21    baselines: Arc<RwLock<BTreeMap<(String, String, String), BaselineRecord>>>,
22    verdicts: Arc<RwLock<Vec<VerdictRecord>>>,
23    decisions: Arc<RwLock<Vec<DecisionRecord>>>,
24    audit_events: Arc<RwLock<Vec<AuditEvent>>>,
25}
26
27impl InMemoryStore {
28    /// Creates a new empty in-memory store.
29    pub fn new() -> Self {
30        Self {
31            baselines: Arc::new(RwLock::new(BTreeMap::new())),
32            verdicts: Arc::new(RwLock::new(Vec::new())),
33            decisions: Arc::new(RwLock::new(Vec::new())),
34            audit_events: Arc::new(RwLock::new(Vec::new())),
35        }
36    }
37
38    fn key(project: &str, benchmark: &str, version: &str) -> (String, String, String) {
39        (
40            project.to_string(),
41            benchmark.to_string(),
42            version.to_string(),
43        )
44    }
45}
46
47#[async_trait]
48impl BaselineStore for InMemoryStore {
49    async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
50        let key = Self::key(&record.project, &record.benchmark, &record.version);
51        let mut baselines = self.baselines.write().await;
52
53        if baselines.contains_key(&key) {
54            return Err(StoreError::AlreadyExists(format!(
55                "project={}, benchmark={}, version={}",
56                record.project, record.benchmark, record.version
57            )));
58        }
59
60        baselines.insert(key, record.clone());
61        Ok(())
62    }
63
64    async fn get(
65        &self,
66        project: &str,
67        benchmark: &str,
68        version: &str,
69    ) -> Result<Option<BaselineRecord>, StoreError> {
70        let key = Self::key(project, benchmark, version);
71        let baselines = self.baselines.read().await;
72        Ok(baselines.get(&key).filter(|r| !r.deleted).cloned())
73    }
74
75    async fn get_latest(
76        &self,
77        project: &str,
78        benchmark: &str,
79    ) -> Result<Option<BaselineRecord>, StoreError> {
80        let baselines = self.baselines.read().await;
81        let latest = baselines
82            .values()
83            .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
84            .max_by_key(|r| r.created_at);
85        Ok(latest.cloned())
86    }
87
88    #[allow(clippy::collapsible_if)]
89    async fn list(
90        &self,
91        project: &str,
92        query: &ListBaselinesQuery,
93    ) -> Result<ListBaselinesResponse, StoreError> {
94        let baselines = self.baselines.read().await;
95        let parsed_tags = query.parsed_tags();
96
97        let mut filtered: Vec<_> = baselines
98            .values()
99            .filter(|r| {
100                // Base filters: project match and not deleted
101                if r.project != project || r.deleted {
102                    return false;
103                }
104
105                // Exact benchmark match
106                if let Some(ref b) = query.benchmark {
107                    if &r.benchmark != b {
108                        return false;
109                    }
110                }
111
112                // Benchmark name prefix match
113                if let Some(ref p) = query.benchmark_prefix {
114                    if !r.benchmark.starts_with(p) {
115                        return false;
116                    }
117                }
118
119                // Exact git reference match
120                if let Some(ref gr) = query.git_ref {
121                    if r.git_ref.as_deref() != Some(gr) {
122                        return false;
123                    }
124                }
125
126                // Exact git SHA match
127                if let Some(ref gs) = query.git_sha {
128                    if r.git_sha.as_deref() != Some(gs) {
129                        return false;
130                    }
131                }
132
133                // Filter by creation time (since)
134                if let Some(since) = query.since {
135                    if r.created_at < since {
136                        return false;
137                    }
138                }
139
140                // Filter by creation time (until)
141                if let Some(until) = query.until {
142                    if r.created_at > until {
143                        return false;
144                    }
145                }
146
147                // Filter by tags (AND logic: all required tags must be present)
148                if !parsed_tags.is_empty() {
149                    for tag in &parsed_tags {
150                        if !r.tags.contains(tag) {
151                            return false;
152                        }
153                    }
154                }
155
156                true
157            })
158            .collect();
159
160        filtered.sort_by_key(|b| std::cmp::Reverse(b.created_at));
161
162        let total = filtered.len() as u64;
163        let offset = query.offset as usize;
164        let limit = query.limit as usize;
165
166        let paginated: Vec<_> = filtered
167            .into_iter()
168            .skip(offset)
169            .take(limit)
170            .map(|r| {
171                let mut summary: BaselineSummary = r.clone().into();
172                if query.include_receipt {
173                    summary.receipt = Some(r.receipt.clone());
174                }
175                summary
176            })
177            .collect();
178
179        let has_more = (offset + paginated.len()) < total as usize;
180
181        Ok(ListBaselinesResponse {
182            baselines: paginated,
183            pagination: PaginationInfo {
184                total,
185                limit: query.limit,
186                offset: query.offset,
187                has_more,
188            },
189        })
190    }
191
192    async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
193        let key = Self::key(&record.project, &record.benchmark, &record.version);
194        let mut baselines = self.baselines.write().await;
195
196        if !baselines.contains_key(&key) {
197            return Err(StoreError::NotFound(format!(
198                "project={}, benchmark={}, version={}",
199                record.project, record.benchmark, record.version
200            )));
201        }
202
203        baselines.insert(key, record.clone());
204        Ok(())
205    }
206
207    async fn delete(
208        &self,
209        project: &str,
210        benchmark: &str,
211        version: &str,
212    ) -> Result<bool, StoreError> {
213        let key = Self::key(project, benchmark, version);
214        let mut baselines = self.baselines.write().await;
215
216        if let Some(record) = baselines.get_mut(&key) {
217            if record.deleted {
218                return Ok(false);
219            }
220            record.deleted = true;
221            return Ok(true);
222        }
223
224        Ok(false)
225    }
226
227    async fn hard_delete(
228        &self,
229        project: &str,
230        benchmark: &str,
231        version: &str,
232    ) -> Result<bool, StoreError> {
233        let key = Self::key(project, benchmark, version);
234        let mut baselines = self.baselines.write().await;
235        Ok(baselines.remove(&key).is_some())
236    }
237
238    async fn list_versions(
239        &self,
240        project: &str,
241        benchmark: &str,
242    ) -> Result<Vec<BaselineVersion>, StoreError> {
243        let baselines = self.baselines.read().await;
244
245        let mut versions: Vec<_> = baselines
246            .values()
247            .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
248            .map(|r| BaselineVersion {
249                version: r.version.clone(),
250                git_ref: r.git_ref.clone(),
251                git_sha: r.git_sha.clone(),
252                created_at: r.created_at,
253                created_by: None,
254                is_current: false,
255                source: r.source.clone(),
256            })
257            .collect();
258
259        versions.sort_by_key(|b| std::cmp::Reverse(b.created_at));
260
261        if let Some(first) = versions.first_mut() {
262            first.is_current = true;
263        }
264
265        Ok(versions)
266    }
267
268    async fn health_check(&self) -> Result<StorageHealth, StoreError> {
269        Ok(StorageHealth::Healthy)
270    }
271
272    fn backend_type(&self) -> &'static str {
273        "memory"
274    }
275
276    async fn create_verdict(&self, record: &VerdictRecord) -> Result<(), StoreError> {
277        let mut verdicts = self.verdicts.write().await;
278        verdicts.push(record.clone());
279        Ok(())
280    }
281
282    async fn list_verdicts(
283        &self,
284        project: &str,
285        query: &ListVerdictsQuery,
286    ) -> Result<ListVerdictsResponse, StoreError> {
287        let verdicts = self.verdicts.read().await;
288
289        let mut filtered: Vec<_> = verdicts
290            .iter()
291            .filter(|r| {
292                if r.project != project {
293                    return false;
294                }
295
296                if let Some(ref b) = query.benchmark
297                    && &r.benchmark != b
298                {
299                    return false;
300                }
301
302                if let Some(ref s) = query.status
303                    && &r.status != s
304                {
305                    return false;
306                }
307
308                if let Some(since) = query.since
309                    && r.created_at < since
310                {
311                    return false;
312                }
313
314                if let Some(until) = query.until
315                    && r.created_at > until
316                {
317                    return false;
318                }
319
320                true
321            })
322            .cloned()
323            .collect();
324
325        filtered.sort_by_key(|b| std::cmp::Reverse(b.created_at));
326
327        let total = filtered.len() as u64;
328        let offset = query.offset as usize;
329        let limit = query.limit as usize;
330
331        let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
332
333        let has_more = (offset + paginated.len()) < total as usize;
334
335        Ok(ListVerdictsResponse {
336            verdicts: paginated,
337            pagination: PaginationInfo {
338                total,
339                limit: query.limit,
340                offset: query.offset,
341                has_more,
342            },
343        })
344    }
345
346    async fn create_decision(&self, record: &DecisionRecord) -> Result<(), StoreError> {
347        let mut decisions = self.decisions.write().await;
348        decisions.push(record.clone());
349        Ok(())
350    }
351
352    async fn latest_decision(&self, project: &str) -> Result<Option<DecisionRecord>, StoreError> {
353        let decisions = self.decisions.read().await;
354        Ok(decisions
355            .iter()
356            .filter(|record| record.project == project)
357            .max_by_key(|record| record.created_at)
358            .cloned())
359    }
360
361    async fn list_decisions(
362        &self,
363        project: &str,
364        query: &ListDecisionsQuery,
365    ) -> Result<ListDecisionsResponse, StoreError> {
366        let decisions = self.decisions.read().await;
367        let mut filtered: Vec<_> = decisions
368            .iter()
369            .filter(|record| {
370                if record.project != project {
371                    return false;
372                }
373                if let Some(ref scenario) = query.scenario
374                    && record.scenario.as_deref() != Some(scenario)
375                {
376                    return false;
377                }
378                if let Some(status) = query.status
379                    && record.status != status
380                {
381                    return false;
382                }
383                if let Some(verdict) = query.verdict
384                    && record.verdict != verdict
385                {
386                    return false;
387                }
388                if let Some(review_required) = query.review_required
389                    && record.review_required != review_required
390                {
391                    return false;
392                }
393                if let Some(accepted) = query.accepted {
394                    let has_accepted_tradeoff = !record.accepted_rules.is_empty();
395                    if has_accepted_tradeoff != accepted {
396                        return false;
397                    }
398                }
399                if let Some(ref rule) = query.rule
400                    && !record
401                        .accepted_rules
402                        .iter()
403                        .any(|accepted| accepted == rule)
404                {
405                    return false;
406                }
407                true
408            })
409            .cloned()
410            .collect();
411
412        filtered.sort_by_key(|record| std::cmp::Reverse(record.created_at));
413        let total = filtered.len() as u64;
414        let offset = query.offset as usize;
415        let limit = query.limit as usize;
416        let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
417        let has_more = (offset + paginated.len()) < total as usize;
418
419        Ok(ListDecisionsResponse {
420            decisions: paginated,
421            pagination: PaginationInfo {
422                total,
423                limit: query.limit,
424                offset: query.offset,
425                has_more,
426            },
427        })
428    }
429
430    async fn prune_decisions(
431        &self,
432        project: &str,
433        older_than: chrono::DateTime<chrono::Utc>,
434        dry_run: bool,
435    ) -> Result<PruneDecisionsResponse, StoreError> {
436        let mut decisions = self.decisions.write().await;
437        let decision_ids: Vec<String> = decisions
438            .iter()
439            .filter(|record| record.project == project && record.created_at < older_than)
440            .map(|record| record.id.clone())
441            .collect();
442        let matched = decision_ids.len() as u64;
443
444        let deleted = if dry_run {
445            0
446        } else {
447            decisions
448                .retain(|record| !(record.project == project && record.created_at < older_than));
449            matched
450        };
451
452        Ok(PruneDecisionsResponse {
453            project: project.to_string(),
454            older_than,
455            dry_run,
456            matched,
457            deleted,
458            decision_ids,
459        })
460    }
461}
462
463#[async_trait]
464impl AuditStore for InMemoryStore {
465    async fn log_event(&self, event: &AuditEvent) -> Result<(), StoreError> {
466        let mut events = self.audit_events.write().await;
467        events.push(event.clone());
468        Ok(())
469    }
470
471    async fn list_events(
472        &self,
473        query: &ListAuditEventsQuery,
474    ) -> Result<ListAuditEventsResponse, StoreError> {
475        let events = self.audit_events.read().await;
476
477        let mut filtered: Vec<_> = events
478            .iter()
479            .filter(|e| {
480                if let Some(ref project) = query.project
481                    && &e.project != project
482                {
483                    return false;
484                }
485
486                if let Some(ref action) = query.action
487                    && e.action.to_string() != *action
488                {
489                    return false;
490                }
491
492                if let Some(ref resource_type) = query.resource_type
493                    && e.resource_type.to_string() != *resource_type
494                {
495                    return false;
496                }
497
498                if let Some(ref actor) = query.actor
499                    && &e.actor != actor
500                {
501                    return false;
502                }
503
504                if let Some(since) = query.since
505                    && e.timestamp < since
506                {
507                    return false;
508                }
509
510                if let Some(until) = query.until
511                    && e.timestamp > until
512                {
513                    return false;
514                }
515
516                true
517            })
518            .cloned()
519            .collect();
520
521        filtered.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
522
523        let total = filtered.len() as u64;
524        let offset = query.offset as usize;
525        let limit = query.limit as usize;
526
527        let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
528
529        let has_more = (offset + paginated.len()) < total as usize;
530
531        Ok(ListAuditEventsResponse {
532            events: paginated,
533            pagination: PaginationInfo {
534                total,
535                limit: query.limit,
536                offset: query.offset,
537                has_more,
538            },
539        })
540    }
541}