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 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 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 pub async fn latest_decision(&self, project: &str) -> Result<DecisionRecord, ClientError> {
197 self.client.latest_decision(project).await
198 }
199
200 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 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 pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
219 self.client.health_check().await
220 }
221
222 pub async fn is_healthy(&self) -> bool {
224 self.client.is_healthy().await
225 }
226
227 pub async fn create_key(
229 &self,
230 request: &CreateKeyRequest,
231 ) -> Result<CreateKeyResponse, ClientError> {
232 self.client.create_key(request).await
233 }
234
235 pub async fn list_keys(&self) -> Result<ListKeysResponse, ClientError> {
237 self.client.list_keys().await
238 }
239
240 pub async fn revoke_key(&self, id: &str) -> Result<RevokeKeyResponse, ClientError> {
242 self.client.revoke_key(id).await
243 }
244
245 pub fn has_fallback(&self) -> bool {
247 self.fallback.is_some()
248 }
249}
250
251#[derive(Debug)]
253pub struct LocalFallbackStorage {
254 dir: PathBuf,
255}
256
257impl LocalFallbackStorage {
258 pub fn new(dir: PathBuf) -> Self {
260 Self { dir }
261 }
262
263 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 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 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 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 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 pub async fn save_baseline(
351 &self,
352 project: &str,
353 request: &UploadBaselineRequest,
354 ) -> Result<UploadBaselineResponse, ClientError> {
355 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 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 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 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 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 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 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 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 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}