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 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 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 pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
179 self.client.health_check().await
180 }
181
182 pub async fn is_healthy(&self) -> bool {
184 self.client.is_healthy().await
185 }
186
187 pub fn has_fallback(&self) -> bool {
189 self.fallback.is_some()
190 }
191}
192
193#[derive(Debug)]
195pub struct LocalFallbackStorage {
196 dir: PathBuf,
197}
198
199impl LocalFallbackStorage {
200 pub fn new(dir: PathBuf) -> Self {
202 Self { dir }
203 }
204
205 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 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 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 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 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 pub async fn save_baseline(
293 &self,
294 project: &str,
295 request: &UploadBaselineRequest,
296 ) -> Result<UploadBaselineResponse, ClientError> {
297 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 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 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 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 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 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 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 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 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}