Skip to main content

perfgate_client/
fallback.rs

1//! Fallback storage implementation.
2//!
3//! This module provides fallback storage when the server is unavailable.
4//! It wraps the `BaselineClient` and falls back to local file storage on errors.
5
6use crate::client::BaselineClient;
7use crate::config::FallbackStorage;
8use crate::error::ClientError;
9use crate::types::*;
10use std::path::PathBuf;
11use tokio::fs;
12use tracing::debug;
13
14/// Client with fallback storage support.
15///
16/// This client wraps the main `BaselineClient` and provides automatic
17/// fallback to local storage when the server is unavailable.
18#[derive(Debug)]
19pub struct FallbackClient {
20    client: BaselineClient,
21    fallback: Option<LocalFallbackStorage>,
22}
23
24impl FallbackClient {
25    /// Creates a new fallback client.
26    pub fn new(client: BaselineClient, fallback: Option<FallbackStorage>) -> Self {
27        let local_fallback = fallback.map(|f| match f {
28            FallbackStorage::Local { dir } => LocalFallbackStorage::new(dir),
29        });
30
31        Self {
32            client,
33            fallback: local_fallback,
34        }
35    }
36
37    /// Gets the underlying client.
38    pub fn inner(&self) -> &BaselineClient {
39        &self.client
40    }
41
42    /// Gets the latest baseline with fallback support.
43    ///
44    /// First tries the server, then falls back to local storage if available.
45    pub async fn get_latest_baseline(
46        &self,
47        project: &str,
48        benchmark: &str,
49    ) -> Result<BaselineRecord, ClientError> {
50        match self.client.get_latest_baseline(project, benchmark).await {
51            Ok(record) => Ok(record),
52            Err(e) if e.is_connection_error() => {
53                if let Some(fallback) = &self.fallback {
54                    debug!(
55                        project = %project,
56                        benchmark = %benchmark,
57                        "Server unavailable, falling back to local storage"
58                    );
59                    fallback.get_latest_baseline(project, benchmark).await
60                } else {
61                    Err(e)
62                }
63            }
64            Err(e) => Err(e),
65        }
66    }
67
68    /// Gets a specific baseline version with fallback support.
69    pub async fn get_baseline_version(
70        &self,
71        project: &str,
72        benchmark: &str,
73        version: &str,
74    ) -> Result<BaselineRecord, ClientError> {
75        match self
76            .client
77            .get_baseline_version(project, benchmark, version)
78            .await
79        {
80            Ok(record) => Ok(record),
81            Err(e) if e.is_connection_error() => {
82                if let Some(fallback) = &self.fallback {
83                    debug!(
84                        project = %project,
85                        benchmark = %benchmark,
86                        version = %version,
87                        "Server unavailable, falling back to local storage"
88                    );
89                    fallback
90                        .get_baseline_version(project, benchmark, version)
91                        .await
92                } else {
93                    Err(e)
94                }
95            }
96            Err(e) => Err(e),
97        }
98    }
99
100    /// Uploads a baseline with fallback support.
101    ///
102    /// If the server is unavailable and fallback is configured, saves to local storage.
103    pub async fn upload_baseline(
104        &self,
105        project: &str,
106        request: &UploadBaselineRequest,
107    ) -> Result<UploadBaselineResponse, ClientError> {
108        match self.client.upload_baseline(project, request).await {
109            Ok(response) => Ok(response),
110            Err(e) if e.is_connection_error() => {
111                if let Some(fallback) = &self.fallback {
112                    debug!(
113                        project = %project,
114                        benchmark = %request.benchmark,
115                        "Server unavailable, saving to local fallback storage"
116                    );
117                    fallback.save_baseline(project, request).await
118                } else {
119                    Err(e)
120                }
121            }
122            Err(e) => Err(e),
123        }
124    }
125
126    /// Lists baselines (server only, no fallback).
127    pub async fn list_baselines(
128        &self,
129        project: &str,
130        query: &ListBaselinesQuery,
131    ) -> Result<ListBaselinesResponse, ClientError> {
132        self.client.list_baselines(project, query).await
133    }
134
135    /// Deletes a baseline (server only, no fallback).
136    pub async fn delete_baseline(
137        &self,
138        project: &str,
139        benchmark: &str,
140        version: &str,
141    ) -> Result<(), ClientError> {
142        self.client
143            .delete_baseline(project, benchmark, version)
144            .await
145    }
146
147    /// Promotes a baseline (server only, no fallback).
148    pub async fn promote_baseline(
149        &self,
150        project: &str,
151        benchmark: &str,
152        request: &PromoteBaselineRequest,
153    ) -> Result<PromoteBaselineResponse, ClientError> {
154        self.client
155            .promote_baseline(project, benchmark, request)
156            .await
157    }
158
159    /// Submits a benchmark verdict (server only, no fallback).
160    pub async fn submit_verdict(
161        &self,
162        project: &str,
163        request: &SubmitVerdictRequest,
164    ) -> Result<VerdictRecord, ClientError> {
165        self.client.submit_verdict(project, request).await
166    }
167
168    /// Lists verdicts (server only, no fallback).
169    pub async fn list_verdicts(
170        &self,
171        project: &str,
172        query: &ListVerdictsQuery,
173    ) -> Result<ListVerdictsResponse, ClientError> {
174        self.client.list_verdicts(project, query).await
175    }
176
177    /// Uploads a performance decision (server only, no fallback).
178    pub async fn upload_decision(
179        &self,
180        project: &str,
181        request: &UploadDecisionRequest,
182    ) -> Result<DecisionRecord, ClientError> {
183        self.client.upload_decision(project, request).await
184    }
185
186    /// Lists performance decisions (server only, no fallback).
187    pub async fn list_decisions(
188        &self,
189        project: &str,
190        query: &ListDecisionsQuery,
191    ) -> Result<ListDecisionsResponse, ClientError> {
192        self.client.list_decisions(project, query).await
193    }
194
195    /// Gets the latest performance decision (server only, no fallback).
196    pub async fn latest_decision(&self, project: &str) -> Result<DecisionRecord, ClientError> {
197        self.client.latest_decision(project).await
198    }
199
200    /// Prunes performance decisions (server only, no fallback).
201    pub async fn prune_decisions(
202        &self,
203        project: &str,
204        request: &PruneDecisionsRequest,
205    ) -> Result<PruneDecisionsResponse, ClientError> {
206        self.client.prune_decisions(project, request).await
207    }
208
209    /// Lists audit events (server only, no fallback).
210    pub async fn list_audit_events(
211        &self,
212        query: &ListAuditEventsQuery,
213    ) -> Result<ListAuditEventsResponse, ClientError> {
214        self.client.list_audit_events(query).await
215    }
216
217    /// Checks server health.
218    pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
219        self.client.health_check().await
220    }
221
222    /// Returns true if the server is healthy.
223    pub async fn is_healthy(&self) -> bool {
224        self.client.is_healthy().await
225    }
226
227    /// Creates an API key (server only, no fallback).
228    pub async fn create_key(
229        &self,
230        request: &CreateKeyRequest,
231    ) -> Result<CreateKeyResponse, ClientError> {
232        self.client.create_key(request).await
233    }
234
235    /// Lists API keys (server only, no fallback).
236    pub async fn list_keys(&self) -> Result<ListKeysResponse, ClientError> {
237        self.client.list_keys().await
238    }
239
240    /// Revokes an API key (server only, no fallback).
241    pub async fn revoke_key(&self, id: &str) -> Result<RevokeKeyResponse, ClientError> {
242        self.client.revoke_key(id).await
243    }
244
245    /// Checks if fallback storage is available.
246    pub fn has_fallback(&self) -> bool {
247        self.fallback.is_some()
248    }
249}
250
251/// Local filesystem fallback storage.
252#[derive(Debug)]
253pub struct LocalFallbackStorage {
254    dir: PathBuf,
255}
256
257impl LocalFallbackStorage {
258    /// Creates a new local fallback storage.
259    pub fn new(dir: PathBuf) -> Self {
260        Self { dir }
261    }
262
263    /// Gets the latest baseline from local storage.
264    pub async fn get_latest_baseline(
265        &self,
266        project: &str,
267        benchmark: &str,
268    ) -> Result<BaselineRecord, ClientError> {
269        let project_dir = self.dir.join(project);
270
271        let mut entries = match fs::read_dir(&project_dir).await {
272            Ok(entries) => entries,
273            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
274                // Directory doesn't exist means no baselines
275                return Err(ClientError::NotFoundError(format!(
276                    "No baseline found for {}/{}",
277                    project, benchmark
278                )));
279            }
280            Err(e) => {
281                return Err(ClientError::FallbackError(format!(
282                    "Failed to read directory: {}",
283                    e
284                )));
285            }
286        };
287
288        let mut latest: Option<(String, BaselineRecord)> = None;
289
290        while let Some(entry) = entries
291            .next_entry()
292            .await
293            .map_err(|e| ClientError::FallbackError(format!("Failed to read entry: {}", e)))?
294        {
295            let file_name = entry.file_name();
296            let name = file_name.to_string_lossy();
297
298            // Check if file matches pattern
299            if name.starts_with(&format!("{}-", benchmark)) && name.ends_with(".json") {
300                let path = entry.path();
301                let content = fs::read_to_string(&path).await.map_err(|e| {
302                    ClientError::FallbackError(format!("Failed to read file: {}", e))
303                })?;
304
305                let record: BaselineRecord =
306                    serde_json::from_str(&content).map_err(ClientError::ParseError)?;
307
308                // Compare by created_at timestamp
309                match &latest {
310                    None => latest = Some((name.to_string(), record)),
311                    Some((_, existing)) => {
312                        if record.created_at > existing.created_at {
313                            latest = Some((name.to_string(), record));
314                        }
315                    }
316                }
317            }
318        }
319
320        latest.map(|(_, record)| record).ok_or_else(|| {
321            ClientError::NotFoundError(format!("No baseline found for {}/{}", project, benchmark))
322        })
323    }
324
325    /// Gets a specific baseline version from local storage.
326    pub async fn get_baseline_version(
327        &self,
328        project: &str,
329        benchmark: &str,
330        version: &str,
331    ) -> Result<BaselineRecord, ClientError> {
332        let file_name = format!("{}-{}.json", benchmark, version);
333        let path = self.dir.join(project).join(&file_name);
334
335        let content = fs::read_to_string(&path).await.map_err(|e| {
336            if e.kind() == std::io::ErrorKind::NotFound {
337                ClientError::NotFoundError(format!(
338                    "Baseline {}/{} not found in fallback storage",
339                    benchmark, version
340                ))
341            } else {
342                ClientError::FallbackError(format!("Failed to read file: {}", e))
343            }
344        })?;
345
346        serde_json::from_str(&content).map_err(ClientError::ParseError)
347    }
348
349    /// Saves a baseline to local storage.
350    pub async fn save_baseline(
351        &self,
352        project: &str,
353        request: &UploadBaselineRequest,
354    ) -> Result<UploadBaselineResponse, ClientError> {
355        // Ensure directory exists
356        let project_dir = self.dir.join(project);
357        fs::create_dir_all(&project_dir).await.map_err(|e| {
358            ClientError::FallbackError(format!("Failed to create directory: {}", e))
359        })?;
360
361        // Generate version if not provided
362        let version = request
363            .version
364            .clone()
365            .unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string());
366
367        // Create a baseline record
368        let now = chrono::Utc::now();
369        let record = BaselineRecord {
370            schema: "perfgate.baseline.v1".to_string(),
371            id: format!("local_{}", uuid::Uuid::new_v4()),
372            project: project.to_string(),
373            benchmark: request.benchmark.clone(),
374            version: version.clone(),
375            git_ref: request.git_ref.clone(),
376            git_sha: request.git_sha.clone(),
377            receipt: request.receipt.clone(),
378            metadata: request.metadata.clone(),
379            tags: request.tags.clone(),
380            created_at: now,
381            updated_at: now,
382            content_hash: "local".to_string(),
383            source: BaselineSource::Upload,
384            deleted: false,
385        };
386
387        // Write to file
388        let file_name = format!("{}-{}.json", request.benchmark, version);
389        let path = project_dir.join(&file_name);
390        let content = serde_json::to_string_pretty(&record).map_err(ClientError::ParseError)?;
391
392        fs::write(&path, content)
393            .await
394            .map_err(|e| ClientError::FallbackError(format!("Failed to write file: {}", e)))?;
395
396        debug!(
397            project = %project,
398            benchmark = %request.benchmark,
399            version = %version,
400            path = %path.display(),
401            "Saved baseline to local fallback storage"
402        );
403
404        Ok(UploadBaselineResponse {
405            id: record.id,
406            benchmark: request.benchmark.clone(),
407            version,
408            created_at: now,
409            etag: "\"local\"".to_string(),
410        })
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::config::{ClientConfig, RetryConfig};
418    use perfgate_types::{BenchMeta, HostInfo, RunMeta, RunReceipt, Stats, ToolInfo, U64Summary};
419    use tempfile::tempdir;
420    use wiremock::matchers::{method, path};
421    use wiremock::{Mock, MockServer, ResponseTemplate};
422
423    fn create_test_receipt(benchmark: &str) -> RunReceipt {
424        RunReceipt {
425            schema: "perfgate.run.v1".to_string(),
426            tool: ToolInfo {
427                name: "perfgate".to_string(),
428                version: "0.1.0".to_string(),
429            },
430            run: RunMeta {
431                id: "test".to_string(),
432                started_at: "2026-01-01T00:00:00Z".to_string(),
433                ended_at: "2026-01-01T00:01:00Z".to_string(),
434                host: HostInfo {
435                    os: "linux".to_string(),
436                    arch: "x86_64".to_string(),
437                    cpu_count: Some(8),
438                    memory_bytes: Some(16000000000),
439                    hostname_hash: None,
440                },
441            },
442            bench: BenchMeta {
443                name: benchmark.to_string(),
444                cwd: None,
445                command: vec!["./bench.sh".to_string()],
446                repeat: 5,
447                warmup: 1,
448                work_units: None,
449                timeout_ms: None,
450            },
451            samples: vec![],
452            stats: Stats {
453                wall_ms: U64Summary::new(100, 100, 100),
454                cpu_ms: None,
455                page_faults: None,
456                ctx_switches: None,
457                max_rss_kb: None,
458                io_read_bytes: None,
459                io_write_bytes: None,
460                network_packets: None,
461                energy_uj: None,
462                binary_bytes: None,
463                throughput_per_s: None,
464            },
465        }
466    }
467
468    fn create_test_upload_request(benchmark: &str) -> UploadBaselineRequest {
469        UploadBaselineRequest {
470            benchmark: benchmark.to_string(),
471            version: Some("v1.0.0".to_string()),
472            git_ref: None,
473            git_sha: None,
474            receipt: create_test_receipt(benchmark),
475            metadata: Default::default(),
476            tags: vec![],
477            normalize: false,
478        }
479    }
480
481    #[tokio::test]
482    async fn test_fallback_get_latest_from_server() {
483        let mock_server = MockServer::start().await;
484        let temp_dir = tempdir().unwrap();
485
486        Mock::given(method("GET"))
487            .and(path("/projects/test-project/baselines/my-bench/latest"))
488            .respond_with(ResponseTemplate::new(200).set_body_json(BaselineRecord {
489                schema: "perfgate.baseline.v1".to_string(),
490                id: "bl_123".to_string(),
491                project: "test-project".to_string(),
492                benchmark: "my-bench".to_string(),
493                version: "v1.0.0".to_string(),
494                git_ref: None,
495                git_sha: None,
496                receipt: create_test_receipt("my-bench"),
497                metadata: Default::default(),
498                tags: vec![],
499                created_at: chrono::Utc::now(),
500                updated_at: chrono::Utc::now(),
501                content_hash: "abc123".to_string(),
502                source: BaselineSource::Upload,
503                deleted: false,
504            }))
505            .mount(&mock_server)
506            .await;
507
508        let config = ClientConfig::new(mock_server.uri())
509            .with_retry(RetryConfig {
510                max_retries: 0,
511                ..Default::default()
512            })
513            .with_fallback(FallbackStorage::local(temp_dir.path()));
514
515        let client = BaselineClient::new(config).unwrap();
516        let fallback_client = FallbackClient::new(client, None);
517
518        let result = fallback_client
519            .get_latest_baseline("test-project", "my-bench")
520            .await
521            .unwrap();
522
523        assert_eq!(result.id, "bl_123");
524    }
525
526    #[tokio::test]
527    async fn test_fallback_get_latest_from_local() {
528        let temp_dir = tempdir().unwrap();
529
530        // Create a local baseline file
531        let project_dir = temp_dir.path().join("test-project");
532        fs::create_dir_all(&project_dir).await.unwrap();
533
534        let record = BaselineRecord {
535            schema: "perfgate.baseline.v1".to_string(),
536            id: "local_123".to_string(),
537            project: "test-project".to_string(),
538            benchmark: "my-bench".to_string(),
539            version: "v1.0.0".to_string(),
540            git_ref: None,
541            git_sha: None,
542            receipt: create_test_receipt("my-bench"),
543            metadata: Default::default(),
544            tags: vec![],
545            created_at: chrono::Utc::now(),
546            updated_at: chrono::Utc::now(),
547            content_hash: "abc123".to_string(),
548            source: BaselineSource::Upload,
549            deleted: false,
550        };
551
552        let file_path = project_dir.join("my-bench-v1.0.0.json");
553        fs::write(&file_path, serde_json::to_string_pretty(&record).unwrap())
554            .await
555            .unwrap();
556
557        // Use a non-existent server to trigger fallback
558        let config = ClientConfig::new("http://localhost:59999")
559            .with_retry(RetryConfig {
560                max_retries: 0,
561                ..Default::default()
562            })
563            .with_fallback(FallbackStorage::local(temp_dir.path()));
564
565        let client = BaselineClient::new(config).unwrap();
566        let fallback_client =
567            FallbackClient::new(client, Some(FallbackStorage::local(temp_dir.path())));
568
569        let result = fallback_client
570            .get_latest_baseline("test-project", "my-bench")
571            .await
572            .unwrap();
573
574        assert_eq!(result.id, "local_123");
575    }
576
577    #[tokio::test]
578    async fn test_fallback_save_to_local() {
579        let temp_dir = tempdir().unwrap();
580
581        // Use a non-existent server to trigger fallback
582        let config = ClientConfig::new("http://localhost:59999")
583            .with_retry(RetryConfig {
584                max_retries: 0,
585                ..Default::default()
586            })
587            .with_fallback(FallbackStorage::local(temp_dir.path()));
588
589        let client = BaselineClient::new(config).unwrap();
590        let fallback_client =
591            FallbackClient::new(client, Some(FallbackStorage::local(temp_dir.path())));
592
593        let request = create_test_upload_request("my-bench");
594        let response = fallback_client
595            .upload_baseline("test-project", &request)
596            .await
597            .unwrap();
598
599        assert!(response.id.starts_with("local_"));
600        assert_eq!(response.benchmark, "my-bench");
601
602        // Verify file was created
603        let project_dir = temp_dir.path().join("test-project");
604        let file_path = project_dir.join("my-bench-v1.0.0.json");
605        assert!(file_path.exists());
606    }
607
608    #[tokio::test]
609    async fn test_fallback_not_found_error() {
610        let temp_dir = tempdir().unwrap();
611
612        // Use a non-existent server to trigger fallback
613        let config = ClientConfig::new("http://localhost:59999")
614            .with_retry(RetryConfig {
615                max_retries: 0,
616                ..Default::default()
617            })
618            .with_fallback(FallbackStorage::local(temp_dir.path()));
619
620        let client = BaselineClient::new(config).unwrap();
621        let fallback_client =
622            FallbackClient::new(client, Some(FallbackStorage::local(temp_dir.path())));
623
624        let result = fallback_client
625            .get_latest_baseline("test-project", "nonexistent")
626            .await;
627
628        assert!(matches!(result, Err(ClientError::NotFoundError(_))));
629    }
630}