1use 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#[derive(Debug)]
19pub struct FallbackClient {
20 client: BaselineClient,
21 fallback: Option<LocalFallbackStorage>,
22}
23
24impl FallbackClient {
25 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 pub fn inner(&self) -> &BaselineClient {
39 &self.client
40 }
41
42 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 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 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 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 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 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 pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
161 self.client.health_check().await
162 }
163
164 pub async fn is_healthy(&self) -> bool {
166 self.client.is_healthy().await
167 }
168
169 pub fn has_fallback(&self) -> bool {
171 self.fallback.is_some()
172 }
173}
174
175#[derive(Debug)]
177pub struct LocalFallbackStorage {
178 dir: PathBuf,
179}
180
181impl LocalFallbackStorage {
182 pub fn new(dir: PathBuf) -> Self {
184 Self { dir }
185 }
186
187 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 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 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 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 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 pub async fn save_baseline(
275 &self,
276 project: &str,
277 request: &UploadBaselineRequest,
278 ) -> Result<UploadBaselineResponse, ClientError> {
279 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 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 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 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 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 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 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 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 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}