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