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    /// Checks server health.
178    pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
179        self.client.health_check().await
180    }
181
182    /// Returns true if the server is healthy.
183    pub async fn is_healthy(&self) -> bool {
184        self.client.is_healthy().await
185    }
186
187    /// Checks if fallback storage is available.
188    pub fn has_fallback(&self) -> bool {
189        self.fallback.is_some()
190    }
191}
192
193/// Local filesystem fallback storage.
194#[derive(Debug)]
195pub struct LocalFallbackStorage {
196    dir: PathBuf,
197}
198
199impl LocalFallbackStorage {
200    /// Creates a new local fallback storage.
201    pub fn new(dir: PathBuf) -> Self {
202        Self { dir }
203    }
204
205    /// Gets the latest baseline from local storage.
206    pub async fn get_latest_baseline(
207        &self,
208        project: &str,
209        benchmark: &str,
210    ) -> Result<BaselineRecord, ClientError> {
211        let project_dir = self.dir.join(project);
212
213        let mut entries = match fs::read_dir(&project_dir).await {
214            Ok(entries) => entries,
215            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
216                // Directory doesn't exist means no baselines
217                return Err(ClientError::NotFoundError(format!(
218                    "No baseline found for {}/{}",
219                    project, benchmark
220                )));
221            }
222            Err(e) => {
223                return Err(ClientError::FallbackError(format!(
224                    "Failed to read directory: {}",
225                    e
226                )));
227            }
228        };
229
230        let mut latest: Option<(String, BaselineRecord)> = None;
231
232        while let Some(entry) = entries
233            .next_entry()
234            .await
235            .map_err(|e| ClientError::FallbackError(format!("Failed to read entry: {}", e)))?
236        {
237            let file_name = entry.file_name();
238            let name = file_name.to_string_lossy();
239
240            // Check if file matches pattern
241            if name.starts_with(&format!("{}-", benchmark)) && name.ends_with(".json") {
242                let path = entry.path();
243                let content = fs::read_to_string(&path).await.map_err(|e| {
244                    ClientError::FallbackError(format!("Failed to read file: {}", e))
245                })?;
246
247                let record: BaselineRecord =
248                    serde_json::from_str(&content).map_err(ClientError::ParseError)?;
249
250                // Compare by created_at timestamp
251                match &latest {
252                    None => latest = Some((name.to_string(), record)),
253                    Some((_, existing)) => {
254                        if record.created_at > existing.created_at {
255                            latest = Some((name.to_string(), record));
256                        }
257                    }
258                }
259            }
260        }
261
262        latest.map(|(_, record)| record).ok_or_else(|| {
263            ClientError::NotFoundError(format!("No baseline found for {}/{}", project, benchmark))
264        })
265    }
266
267    /// Gets a specific baseline version from local storage.
268    pub async fn get_baseline_version(
269        &self,
270        project: &str,
271        benchmark: &str,
272        version: &str,
273    ) -> Result<BaselineRecord, ClientError> {
274        let file_name = format!("{}-{}.json", benchmark, version);
275        let path = self.dir.join(project).join(&file_name);
276
277        let content = fs::read_to_string(&path).await.map_err(|e| {
278            if e.kind() == std::io::ErrorKind::NotFound {
279                ClientError::NotFoundError(format!(
280                    "Baseline {}/{} not found in fallback storage",
281                    benchmark, version
282                ))
283            } else {
284                ClientError::FallbackError(format!("Failed to read file: {}", e))
285            }
286        })?;
287
288        serde_json::from_str(&content).map_err(ClientError::ParseError)
289    }
290
291    /// Saves a baseline to local storage.
292    pub async fn save_baseline(
293        &self,
294        project: &str,
295        request: &UploadBaselineRequest,
296    ) -> Result<UploadBaselineResponse, ClientError> {
297        // Ensure directory exists
298        let project_dir = self.dir.join(project);
299        fs::create_dir_all(&project_dir).await.map_err(|e| {
300            ClientError::FallbackError(format!("Failed to create directory: {}", e))
301        })?;
302
303        // Generate version if not provided
304        let version = request
305            .version
306            .clone()
307            .unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string());
308
309        // Create a baseline record
310        let now = chrono::Utc::now();
311        let record = BaselineRecord {
312            schema: "perfgate.baseline.v1".to_string(),
313            id: format!("local_{}", uuid::Uuid::new_v4()),
314            project: project.to_string(),
315            benchmark: request.benchmark.clone(),
316            version: version.clone(),
317            git_ref: request.git_ref.clone(),
318            git_sha: request.git_sha.clone(),
319            receipt: request.receipt.clone(),
320            metadata: request.metadata.clone(),
321            tags: request.tags.clone(),
322            created_at: now,
323            updated_at: now,
324            content_hash: "local".to_string(),
325            source: BaselineSource::Upload,
326            deleted: false,
327        };
328
329        // Write to file
330        let file_name = format!("{}-{}.json", request.benchmark, version);
331        let path = project_dir.join(&file_name);
332        let content = serde_json::to_string_pretty(&record).map_err(ClientError::ParseError)?;
333
334        fs::write(&path, content)
335            .await
336            .map_err(|e| ClientError::FallbackError(format!("Failed to write file: {}", e)))?;
337
338        debug!(
339            project = %project,
340            benchmark = %request.benchmark,
341            version = %version,
342            path = %path.display(),
343            "Saved baseline to local fallback storage"
344        );
345
346        Ok(UploadBaselineResponse {
347            id: record.id,
348            benchmark: request.benchmark.clone(),
349            version,
350            created_at: now,
351            etag: "\"local\"".to_string(),
352        })
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::config::{ClientConfig, RetryConfig};
360    use perfgate_types::{BenchMeta, HostInfo, RunMeta, RunReceipt, Stats, ToolInfo, U64Summary};
361    use tempfile::tempdir;
362    use wiremock::matchers::{method, path};
363    use wiremock::{Mock, MockServer, ResponseTemplate};
364
365    fn create_test_receipt(benchmark: &str) -> RunReceipt {
366        RunReceipt {
367            schema: "perfgate.run.v1".to_string(),
368            tool: ToolInfo {
369                name: "perfgate".to_string(),
370                version: "0.1.0".to_string(),
371            },
372            run: RunMeta {
373                id: "test".to_string(),
374                started_at: "2026-01-01T00:00:00Z".to_string(),
375                ended_at: "2026-01-01T00:01:00Z".to_string(),
376                host: HostInfo {
377                    os: "linux".to_string(),
378                    arch: "x86_64".to_string(),
379                    cpu_count: Some(8),
380                    memory_bytes: Some(16000000000),
381                    hostname_hash: None,
382                },
383            },
384            bench: BenchMeta {
385                name: benchmark.to_string(),
386                cwd: None,
387                command: vec!["./bench.sh".to_string()],
388                repeat: 5,
389                warmup: 1,
390                work_units: None,
391                timeout_ms: None,
392            },
393            samples: vec![],
394            stats: Stats {
395                wall_ms: U64Summary::new(100, 100, 100),
396                cpu_ms: None,
397                page_faults: None,
398                ctx_switches: None,
399                max_rss_kb: None,
400                io_read_bytes: None,
401                io_write_bytes: None,
402                network_packets: None,
403                energy_uj: None,
404                binary_bytes: None,
405                throughput_per_s: None,
406            },
407        }
408    }
409
410    fn create_test_upload_request(benchmark: &str) -> UploadBaselineRequest {
411        UploadBaselineRequest {
412            benchmark: benchmark.to_string(),
413            version: Some("v1.0.0".to_string()),
414            git_ref: None,
415            git_sha: None,
416            receipt: create_test_receipt(benchmark),
417            metadata: Default::default(),
418            tags: vec![],
419            normalize: false,
420        }
421    }
422
423    #[tokio::test]
424    async fn test_fallback_get_latest_from_server() {
425        let mock_server = MockServer::start().await;
426        let temp_dir = tempdir().unwrap();
427
428        Mock::given(method("GET"))
429            .and(path("/projects/test-project/baselines/my-bench/latest"))
430            .respond_with(ResponseTemplate::new(200).set_body_json(BaselineRecord {
431                schema: "perfgate.baseline.v1".to_string(),
432                id: "bl_123".to_string(),
433                project: "test-project".to_string(),
434                benchmark: "my-bench".to_string(),
435                version: "v1.0.0".to_string(),
436                git_ref: None,
437                git_sha: None,
438                receipt: create_test_receipt("my-bench"),
439                metadata: Default::default(),
440                tags: vec![],
441                created_at: chrono::Utc::now(),
442                updated_at: chrono::Utc::now(),
443                content_hash: "abc123".to_string(),
444                source: BaselineSource::Upload,
445                deleted: false,
446            }))
447            .mount(&mock_server)
448            .await;
449
450        let config = ClientConfig::new(mock_server.uri())
451            .with_retry(RetryConfig {
452                max_retries: 0,
453                ..Default::default()
454            })
455            .with_fallback(FallbackStorage::local(temp_dir.path()));
456
457        let client = BaselineClient::new(config).unwrap();
458        let fallback_client = FallbackClient::new(client, None);
459
460        let result = fallback_client
461            .get_latest_baseline("test-project", "my-bench")
462            .await
463            .unwrap();
464
465        assert_eq!(result.id, "bl_123");
466    }
467
468    #[tokio::test]
469    async fn test_fallback_get_latest_from_local() {
470        let temp_dir = tempdir().unwrap();
471
472        // Create a local baseline file
473        let project_dir = temp_dir.path().join("test-project");
474        fs::create_dir_all(&project_dir).await.unwrap();
475
476        let record = BaselineRecord {
477            schema: "perfgate.baseline.v1".to_string(),
478            id: "local_123".to_string(),
479            project: "test-project".to_string(),
480            benchmark: "my-bench".to_string(),
481            version: "v1.0.0".to_string(),
482            git_ref: None,
483            git_sha: None,
484            receipt: create_test_receipt("my-bench"),
485            metadata: Default::default(),
486            tags: vec![],
487            created_at: chrono::Utc::now(),
488            updated_at: chrono::Utc::now(),
489            content_hash: "abc123".to_string(),
490            source: BaselineSource::Upload,
491            deleted: false,
492        };
493
494        let file_path = project_dir.join("my-bench-v1.0.0.json");
495        fs::write(&file_path, serde_json::to_string_pretty(&record).unwrap())
496            .await
497            .unwrap();
498
499        // Use a non-existent server to trigger fallback
500        let config = ClientConfig::new("http://localhost:59999")
501            .with_retry(RetryConfig {
502                max_retries: 0,
503                ..Default::default()
504            })
505            .with_fallback(FallbackStorage::local(temp_dir.path()));
506
507        let client = BaselineClient::new(config).unwrap();
508        let fallback_client =
509            FallbackClient::new(client, Some(FallbackStorage::local(temp_dir.path())));
510
511        let result = fallback_client
512            .get_latest_baseline("test-project", "my-bench")
513            .await
514            .unwrap();
515
516        assert_eq!(result.id, "local_123");
517    }
518
519    #[tokio::test]
520    async fn test_fallback_save_to_local() {
521        let temp_dir = tempdir().unwrap();
522
523        // Use a non-existent server to trigger fallback
524        let config = ClientConfig::new("http://localhost:59999")
525            .with_retry(RetryConfig {
526                max_retries: 0,
527                ..Default::default()
528            })
529            .with_fallback(FallbackStorage::local(temp_dir.path()));
530
531        let client = BaselineClient::new(config).unwrap();
532        let fallback_client =
533            FallbackClient::new(client, Some(FallbackStorage::local(temp_dir.path())));
534
535        let request = create_test_upload_request("my-bench");
536        let response = fallback_client
537            .upload_baseline("test-project", &request)
538            .await
539            .unwrap();
540
541        assert!(response.id.starts_with("local_"));
542        assert_eq!(response.benchmark, "my-bench");
543
544        // Verify file was created
545        let project_dir = temp_dir.path().join("test-project");
546        let file_path = project_dir.join("my-bench-v1.0.0.json");
547        assert!(file_path.exists());
548    }
549
550    #[tokio::test]
551    async fn test_fallback_not_found_error() {
552        let temp_dir = tempdir().unwrap();
553
554        // Use a non-existent server to trigger fallback
555        let config = ClientConfig::new("http://localhost:59999")
556            .with_retry(RetryConfig {
557                max_retries: 0,
558                ..Default::default()
559            })
560            .with_fallback(FallbackStorage::local(temp_dir.path()));
561
562        let client = BaselineClient::new(config).unwrap();
563        let fallback_client =
564            FallbackClient::new(client, Some(FallbackStorage::local(temp_dir.path())));
565
566        let result = fallback_client
567            .get_latest_baseline("test-project", "nonexistent")
568            .await;
569
570        assert!(matches!(result, Err(ClientError::NotFoundError(_))));
571    }
572}