1use std::sync::Arc;
40use std::time::{Duration, Instant};
41
42use async_trait::async_trait;
43use tokio::sync::Mutex;
44
45#[async_trait]
56pub trait Warmer: Send + Sync {
57 fn name(&self) -> &str;
59
60 fn description(&self) -> &str {
62 "Cache warmer"
63 }
64
65 fn timeout(&self) -> Duration {
69 Duration::from_secs(30)
70 }
71
72 async fn warm(&self) -> Result<(), WarmupError>;
78}
79
80#[derive(Debug, Clone)]
86pub enum WarmupError {
87 Io(String),
89 Serialize(String),
91 Cache(String),
93 Database(String),
95 Custom(String),
97}
98
99impl std::fmt::Display for WarmupError {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 Self::Io(msg) => write!(f, "IO error: {}", msg),
103 Self::Serialize(msg) => write!(f, "Serialize error: {}", msg),
104 Self::Cache(msg) => write!(f, "Cache error: {}", msg),
105 Self::Database(msg) => write!(f, "Database error: {}", msg),
106 Self::Custom(msg) => write!(f, "{}", msg),
107 }
108 }
109}
110
111impl std::error::Error for WarmupError {}
112
113impl From<std::io::Error> for WarmupError {
114 fn from(e: std::io::Error) -> Self {
115 Self::Io(e.to_string())
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum WarmupStatus {
126 Success,
128 Failed,
130 Timeout,
132 Skipped,
134}
135
136impl std::fmt::Display for WarmupStatus {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 Self::Success => write!(f, "success"),
140 Self::Failed => write!(f, "failed"),
141 Self::Timeout => write!(f, "timeout"),
142 Self::Skipped => write!(f, "skipped"),
143 }
144 }
145}
146
147#[derive(Debug, Clone)]
149pub struct WarmupItem {
150 pub name: String,
152 pub description: String,
154 pub status: WarmupStatus,
156 pub duration_ms: u64,
158 pub error: Option<String>,
160}
161
162impl WarmupItem {
163 pub fn success(
165 name: impl Into<String>,
166 description: impl Into<String>,
167 duration_ms: u64,
168 ) -> Self {
169 Self {
170 name: name.into(),
171 description: description.into(),
172 status: WarmupStatus::Success,
173 duration_ms,
174 error: None,
175 }
176 }
177
178 pub fn failed(
180 name: impl Into<String>,
181 description: impl Into<String>,
182 duration_ms: u64,
183 error: impl Into<String>,
184 ) -> Self {
185 Self {
186 name: name.into(),
187 description: description.into(),
188 status: WarmupStatus::Failed,
189 duration_ms,
190 error: Some(error.into()),
191 }
192 }
193
194 pub fn timeout(
196 name: impl Into<String>,
197 description: impl Into<String>,
198 duration_ms: u64,
199 ) -> Self {
200 Self {
201 name: name.into(),
202 description: description.into(),
203 status: WarmupStatus::Timeout,
204 duration_ms,
205 error: Some(format!("Timed out after {}ms", duration_ms)),
206 }
207 }
208
209 pub fn is_success(&self) -> bool {
211 self.status == WarmupStatus::Success
212 }
213}
214
215#[derive(Debug, Clone, Default)]
217pub struct WarmupReport {
218 pub items: Vec<WarmupItem>,
220 pub total_duration_ms: u64,
222}
223
224impl WarmupReport {
225 pub fn new() -> Self {
227 Self::default()
228 }
229
230 pub fn add_item(&mut self, item: WarmupItem) {
232 self.items.push(item);
233 }
234
235 pub fn success_count(&self) -> usize {
237 self.items.iter().filter(|i| i.is_success()).count()
238 }
239
240 pub fn failed_count(&self) -> usize {
242 self.items
243 .iter()
244 .filter(|i| i.status == WarmupStatus::Failed)
245 .count()
246 }
247
248 pub fn timeout_count(&self) -> usize {
250 self.items
251 .iter()
252 .filter(|i| i.status == WarmupStatus::Timeout)
253 .count()
254 }
255
256 pub fn total_count(&self) -> usize {
258 self.items.len()
259 }
260
261 pub fn all_success(&self) -> bool {
263 !self.items.is_empty() && self.failed_count() == 0 && self.timeout_count() == 0
264 }
265
266 pub fn summary(&self) -> String {
268 let mut s = String::new();
269 s.push_str(&format!(
270 "Cache warmup: {}/{} succeeded, {} failed, {} timeout (total {}ms)\n",
271 self.success_count(),
272 self.total_count(),
273 self.failed_count(),
274 self.timeout_count(),
275 self.total_duration_ms
276 ));
277 for item in &self.items {
278 s.push_str(&format!(
279 " - {:<20} {:<10} {}ms",
280 item.name, item.status, item.duration_ms
281 ));
282 if let Some(err) = &item.error {
283 s.push_str(&format!(" ({})", err));
284 }
285 s.push('\n');
286 }
287 s
288 }
289}
290
291pub struct WarmupPipeline {
300 warmers: Vec<Box<dyn Warmer>>,
301 global_timeout: Duration,
303}
304
305impl WarmupPipeline {
306 pub fn new() -> Self {
308 Self {
309 warmers: Vec::new(),
310 global_timeout: Duration::from_secs(300),
311 }
312 }
313
314 pub fn register(&mut self, warmer: Box<dyn Warmer>) -> &mut Self {
316 self.warmers.push(warmer);
317 self
318 }
319
320 pub fn with_global_timeout(mut self, timeout: Duration) -> Self {
322 self.global_timeout = timeout;
323 self
324 }
325
326 pub fn count(&self) -> usize {
328 self.warmers.len()
329 }
330
331 pub fn names(&self) -> Vec<&str> {
333 self.warmers.iter().map(|w| w.name()).collect()
334 }
335
336 pub async fn warm_all(&self) -> WarmupReport {
341 let mut report = WarmupReport::new();
342 let total_start = Instant::now();
343
344 for warmer in &self.warmers {
345 let item = self.warm_one(warmer.as_ref()).await;
346 report.add_item(item);
347 }
348
349 report.total_duration_ms = total_start.elapsed().as_millis() as u64;
350 report
351 }
352
353 pub async fn warm_all_parallel(&self) -> WarmupReport {
358 let total_start = Instant::now();
359
360 let futures: Vec<_> = self
361 .warmers
362 .iter()
363 .map(|w| self.warm_one_async(w.as_ref()))
364 .collect();
365
366 let items = futures::future::join_all(futures).await;
367
368 let mut report = WarmupReport::new();
369 for item in items {
370 report.add_item(item);
371 }
372 report.total_duration_ms = total_start.elapsed().as_millis() as u64;
373 report
374 }
375
376 async fn warm_one(&self, warmer: &dyn Warmer) -> WarmupItem {
378 self.warm_one_async(warmer).await
379 }
380
381 async fn warm_one_async(&self, warmer: &dyn Warmer) -> WarmupItem {
383 let name = warmer.name().to_string();
384 let description = warmer.description().to_string();
385 let timeout = warmer.timeout();
386 let start = Instant::now();
387
388 let result = tokio::time::timeout(timeout, warmer.warm()).await;
389
390 let duration_ms = start.elapsed().as_millis() as u64;
391
392 match result {
393 Ok(Ok(())) => WarmupItem::success(name, description, duration_ms),
394 Ok(Err(e)) => WarmupItem::failed(name, description, duration_ms, e.to_string()),
395 Err(_) => WarmupItem::timeout(name, description, duration_ms),
396 }
397 }
398
399 pub async fn warm_one_by_name(&self, name: &str) -> WarmupReport {
403 let mut report = WarmupReport::new();
404 let total_start = Instant::now();
405
406 if let Some(warmer) = self.warmers.iter().find(|w| w.name() == name) {
407 let item = self.warm_one(warmer.as_ref()).await;
408 report.add_item(item);
409 } else {
410 report.add_item(WarmupItem {
411 name: name.to_string(),
412 description: "Not found".to_string(),
413 status: WarmupStatus::Skipped,
414 duration_ms: 0,
415 error: Some(format!("Warmer '{}' not registered", name)),
416 });
417 }
418
419 report.total_duration_ms = total_start.elapsed().as_millis() as u64;
420 report
421 }
422}
423
424impl Default for WarmupPipeline {
425 fn default() -> Self {
426 Self::new()
427 }
428}
429
430pub struct DeploymentHook {
441 pipeline: Arc<Mutex<WarmupPipeline>>,
442}
443
444impl DeploymentHook {
445 pub fn new(pipeline: WarmupPipeline) -> Self {
447 Self {
448 pipeline: Arc::new(Mutex::new(pipeline)),
449 }
450 }
451
452 pub async fn pre_warmup(&self) -> WarmupReport {
456 let pipeline = self.pipeline.lock().await;
457 pipeline.warm_all().await
458 }
459
460 pub async fn post_deploy(&self) -> WarmupReport {
464 let pipeline = self.pipeline.lock().await;
465 pipeline.warm_all().await
466 }
467
468 pub async fn rollback(&self) -> WarmupReport {
472 let pipeline = self.pipeline.lock().await;
473 pipeline.warm_all().await
474 }
475
476 pub fn pipeline(&self) -> Arc<Mutex<WarmupPipeline>> {
478 self.pipeline.clone()
479 }
480}
481
482pub struct NoopWarmer {
488 name: String,
489 delay_ms: u64,
490 should_fail: bool,
491}
492
493impl NoopWarmer {
494 pub fn new(name: impl Into<String>, delay_ms: u64, should_fail: bool) -> Self {
502 Self {
503 name: name.into(),
504 delay_ms,
505 should_fail,
506 }
507 }
508
509 pub fn success(name: impl Into<String>) -> Self {
511 Self::new(name, 0, false)
512 }
513
514 pub fn failing(name: impl Into<String>) -> Self {
516 Self::new(name, 0, true)
517 }
518
519 pub fn delayed(name: impl Into<String>, delay_ms: u64) -> Self {
521 Self::new(name, delay_ms, false)
522 }
523}
524
525#[async_trait]
526impl Warmer for NoopWarmer {
527 fn name(&self) -> &str {
528 &self.name
529 }
530
531 fn description(&self) -> &str {
532 "Noop warmer for testing"
533 }
534
535 async fn warm(&self) -> Result<(), WarmupError> {
536 if self.delay_ms > 0 {
537 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
538 }
539 if self.should_fail {
540 return Err(WarmupError::Custom("Simulated failure".to_string()));
541 }
542 Ok(())
543 }
544}
545
546#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
559 fn test_warmup_error_display() {
560 assert_eq!(
561 WarmupError::Io("file not found".to_string()).to_string(),
562 "IO error: file not found"
563 );
564 assert_eq!(
565 WarmupError::Serialize("invalid json".to_string()).to_string(),
566 "Serialize error: invalid json"
567 );
568 assert_eq!(
569 WarmupError::Cache("write failed".to_string()).to_string(),
570 "Cache error: write failed"
571 );
572 assert_eq!(
573 WarmupError::Database("connection refused".to_string()).to_string(),
574 "Database error: connection refused"
575 );
576 assert_eq!(
577 WarmupError::Custom("custom".to_string()).to_string(),
578 "custom"
579 );
580 }
581
582 #[test]
583 fn test_warmup_error_from_io() {
584 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
585 let warmup_err: WarmupError = io_err.into();
586 assert!(matches!(warmup_err, WarmupError::Io(_)));
587 }
588
589 #[test]
594 fn test_warmup_status_display() {
595 assert_eq!(WarmupStatus::Success.to_string(), "success");
596 assert_eq!(WarmupStatus::Failed.to_string(), "failed");
597 assert_eq!(WarmupStatus::Timeout.to_string(), "timeout");
598 assert_eq!(WarmupStatus::Skipped.to_string(), "skipped");
599 }
600
601 #[test]
606 fn test_warmup_item_success() {
607 let item = WarmupItem::success("config", "Config warmer", 100);
608 assert_eq!(item.name, "config");
609 assert_eq!(item.status, WarmupStatus::Success);
610 assert_eq!(item.duration_ms, 100);
611 assert!(item.error.is_none());
612 assert!(item.is_success());
613 }
614
615 #[test]
616 fn test_warmup_item_failed() {
617 let item = WarmupItem::failed("route", "Route warmer", 50, "missing file");
618 assert_eq!(item.status, WarmupStatus::Failed);
619 assert_eq!(item.error, Some("missing file".to_string()));
620 assert!(!item.is_success());
621 }
622
623 #[test]
624 fn test_warmup_item_timeout() {
625 let item = WarmupItem::timeout("db", "DB warmer", 30000);
626 assert_eq!(item.status, WarmupStatus::Timeout);
627 assert!(item.error.unwrap().contains("Timed out"));
628 }
629
630 #[test]
635 fn test_warmup_report_empty() {
636 let report = WarmupReport::new();
637 assert_eq!(report.total_count(), 0);
638 assert_eq!(report.success_count(), 0);
639 assert_eq!(report.failed_count(), 0);
640 assert_eq!(report.timeout_count(), 0);
641 assert!(!report.all_success());
642 }
643
644 #[test]
645 fn test_warmup_report_all_success() {
646 let mut report = WarmupReport::new();
647 report.add_item(WarmupItem::success("a", "A", 10));
648 report.add_item(WarmupItem::success("b", "B", 20));
649 report.total_duration_ms = 30;
650
651 assert_eq!(report.total_count(), 2);
652 assert_eq!(report.success_count(), 2);
653 assert_eq!(report.failed_count(), 0);
654 assert!(report.all_success());
655 }
656
657 #[test]
658 fn test_warmup_report_mixed() {
659 let mut report = WarmupReport::new();
660 report.add_item(WarmupItem::success("a", "A", 10));
661 report.add_item(WarmupItem::failed("b", "B", 20, "err"));
662 report.add_item(WarmupItem::timeout("c", "C", 30000));
663
664 assert_eq!(report.total_count(), 3);
665 assert_eq!(report.success_count(), 1);
666 assert_eq!(report.failed_count(), 1);
667 assert_eq!(report.timeout_count(), 1);
668 assert!(!report.all_success());
669 }
670
671 #[test]
672 fn test_warmup_report_summary_contains_status() {
673 let mut report = WarmupReport::new();
674 report.add_item(WarmupItem::success("config", "Config", 100));
675 report.add_item(WarmupItem::failed("route", "Route", 50, "missing"));
676 report.total_duration_ms = 150;
677
678 let summary = report.summary();
679 assert!(summary.contains("1/2 succeeded"));
680 assert!(summary.contains("1 failed"));
681 assert!(summary.contains("config"));
682 assert!(summary.contains("success"));
683 assert!(summary.contains("route"));
684 assert!(summary.contains("failed"));
685 assert!(summary.contains("missing"));
686 }
687
688 #[tokio::test]
693 async fn test_pipeline_empty() {
694 let pipeline = WarmupPipeline::new();
695 assert_eq!(pipeline.count(), 0);
696
697 let report = pipeline.warm_all().await;
698 assert_eq!(report.total_count(), 0);
699 assert!(report.items.is_empty());
700 }
701
702 #[tokio::test]
703 async fn test_pipeline_single_success() {
704 let mut pipeline = WarmupPipeline::new();
705 pipeline.register(Box::new(NoopWarmer::success("config")));
706
707 let report = pipeline.warm_all().await;
708 assert_eq!(report.total_count(), 1);
709 assert_eq!(report.success_count(), 1);
710 assert!(report.all_success());
711 assert_eq!(report.items[0].name, "config");
712 }
713
714 #[tokio::test]
715 async fn test_pipeline_mixed_results() {
716 let mut pipeline = WarmupPipeline::new();
717 pipeline.register(Box::new(NoopWarmer::success("success_warmer")));
718 pipeline.register(Box::new(NoopWarmer::failing("failing_warmer")));
719
720 let report = pipeline.warm_all().await;
721 assert_eq!(report.total_count(), 2);
722 assert_eq!(report.success_count(), 1);
723 assert_eq!(report.failed_count(), 1);
724 assert!(!report.all_success());
725
726 assert_eq!(report.items[0].name, "success_warmer");
727 assert_eq!(report.items[0].status, WarmupStatus::Success);
728 assert_eq!(report.items[1].name, "failing_warmer");
729 assert_eq!(report.items[1].status, WarmupStatus::Failed);
730 assert!(report.items[1]
731 .error
732 .as_ref()
733 .unwrap()
734 .contains("Simulated failure"));
735 }
736
737 #[tokio::test]
738 async fn test_pipeline_failure_does_not_stop_others() {
739 let mut pipeline = WarmupPipeline::new();
740 pipeline.register(Box::new(NoopWarmer::failing("first_fails")));
741 pipeline.register(Box::new(NoopWarmer::success("second_success")));
742
743 let report = pipeline.warm_all().await;
744 assert_eq!(report.total_count(), 2);
745 assert_eq!(report.success_count(), 1);
746 assert_eq!(report.failed_count(), 1);
747 }
748
749 #[tokio::test]
750 async fn test_pipeline_timeout() {
751 let mut pipeline = WarmupPipeline::new();
752
753 struct SlowWarmer;
755 #[async_trait]
756 impl Warmer for SlowWarmer {
757 fn name(&self) -> &str {
758 "slow"
759 }
760 fn timeout(&self) -> Duration {
761 Duration::from_millis(100)
762 }
763 async fn warm(&self) -> Result<(), WarmupError> {
764 tokio::time::sleep(Duration::from_millis(500)).await;
765 Ok(())
766 }
767 }
768
769 pipeline.register(Box::new(SlowWarmer));
770
771 let report = pipeline.warm_all().await;
772 assert_eq!(report.total_count(), 1);
773 assert_eq!(report.timeout_count(), 1);
774 assert_eq!(report.items[0].status, WarmupStatus::Timeout);
775 }
776
777 #[tokio::test]
778 async fn test_pipeline_parallel() {
779 let mut pipeline = WarmupPipeline::new();
780 pipeline.register(Box::new(NoopWarmer::delayed("a", 100)));
781 pipeline.register(Box::new(NoopWarmer::delayed("b", 100)));
782 pipeline.register(Box::new(NoopWarmer::delayed("c", 100)));
783
784 let serial_report = pipeline.warm_all().await;
786 assert!(serial_report.total_duration_ms >= 250);
787
788 let parallel_report = pipeline.warm_all_parallel().await;
790 assert!(parallel_report.total_duration_ms < 200);
791 assert_eq!(parallel_report.success_count(), 3);
792 }
793
794 #[tokio::test]
795 async fn test_pipeline_warm_one_by_name_found() {
796 let mut pipeline = WarmupPipeline::new();
797 pipeline.register(Box::new(NoopWarmer::success("config")));
798 pipeline.register(Box::new(NoopWarmer::success("route")));
799
800 let report = pipeline.warm_one_by_name("config").await;
801 assert_eq!(report.total_count(), 1);
802 assert_eq!(report.items[0].name, "config");
803 assert_eq!(report.items[0].status, WarmupStatus::Success);
804 }
805
806 #[tokio::test]
807 async fn test_pipeline_warm_one_by_name_not_found() {
808 let pipeline = WarmupPipeline::new();
809
810 let report = pipeline.warm_one_by_name("nonexistent").await;
811 assert_eq!(report.total_count(), 1);
812 assert_eq!(report.items[0].status, WarmupStatus::Skipped);
813 assert!(report.items[0]
814 .error
815 .as_ref()
816 .unwrap()
817 .contains("not registered"));
818 }
819
820 #[tokio::test]
821 async fn test_pipeline_names() {
822 let mut pipeline = WarmupPipeline::new();
823 pipeline.register(Box::new(NoopWarmer::success("a")));
824 pipeline.register(Box::new(NoopWarmer::success("b")));
825
826 let names = pipeline.names();
827 assert_eq!(names, vec!["a", "b"]);
828 }
829
830 #[tokio::test]
831 async fn test_pipeline_with_global_timeout() {
832 let pipeline = WarmupPipeline::new().with_global_timeout(Duration::from_secs(60));
833 assert_eq!(pipeline.count(), 0);
834 }
835
836 #[tokio::test]
841 async fn test_deployment_hook_pre_warmup() {
842 let mut pipeline = WarmupPipeline::new();
843 pipeline.register(Box::new(NoopWarmer::success("config")));
844
845 let hook = DeploymentHook::new(pipeline);
846 let report = hook.pre_warmup().await;
847
848 assert_eq!(report.success_count(), 1);
849 }
850
851 #[tokio::test]
852 async fn test_deployment_hook_post_deploy() {
853 let mut pipeline = WarmupPipeline::new();
854 pipeline.register(Box::new(NoopWarmer::success("config")));
855 pipeline.register(Box::new(NoopWarmer::success("route")));
856
857 let hook = DeploymentHook::new(pipeline);
858 let report = hook.post_deploy().await;
859
860 assert_eq!(report.success_count(), 2);
861 }
862
863 #[tokio::test]
864 async fn test_deployment_hook_rollback() {
865 let mut pipeline = WarmupPipeline::new();
866 pipeline.register(Box::new(NoopWarmer::success("cleanup")));
867
868 let hook = DeploymentHook::new(pipeline);
869 let report = hook.rollback().await;
870
871 assert_eq!(report.success_count(), 1);
872 }
873
874 #[tokio::test]
875 async fn test_deployment_hook_pipeline_handle() {
876 let pipeline = WarmupPipeline::new();
877 let hook = DeploymentHook::new(pipeline);
878
879 let handle = hook.pipeline();
880 let p = handle.lock().await;
881 assert_eq!(p.count(), 0);
882 }
883
884 #[tokio::test]
889 async fn test_noop_warmer_success() {
890 let warmer = NoopWarmer::success("test");
891 assert_eq!(warmer.name(), "test");
892 assert_eq!(warmer.description(), "Noop warmer for testing");
893 let result = warmer.warm().await;
894 assert!(result.is_ok());
895 }
896
897 #[tokio::test]
898 async fn test_noop_warmer_failing() {
899 let warmer = NoopWarmer::failing("test");
900 let result = warmer.warm().await;
901 assert!(result.is_err());
902 assert!(result
903 .unwrap_err()
904 .to_string()
905 .contains("Simulated failure"));
906 }
907
908 #[tokio::test]
909 async fn test_noop_warmer_delayed() {
910 let warmer = NoopWarmer::delayed("test", 50);
911 let start = Instant::now();
912 warmer.warm().await.unwrap();
913 assert!(start.elapsed().as_millis() >= 40);
914 }
915
916 #[test]
917 fn test_noop_warmer_default_timeout() {
918 let warmer = NoopWarmer::success("test");
919 assert_eq!(warmer.timeout(), Duration::from_secs(30));
920 }
921
922 #[tokio::test]
927 async fn test_end_to_end_deployment_scenario() {
928 let mut pipeline = WarmupPipeline::new();
930 pipeline.register(Box::new(NoopWarmer::success("config")));
931 pipeline.register(Box::new(NoopWarmer::success("route")));
932 pipeline.register(Box::new(NoopWarmer::failing("dict")));
933
934 let hook = DeploymentHook::new(pipeline);
935
936 let report = hook.pre_warmup().await;
938
939 assert_eq!(report.total_count(), 3);
941 assert_eq!(report.success_count(), 2);
942 assert_eq!(report.failed_count(), 1);
943
944 let summary = report.summary();
946 assert!(summary.contains("2/3 succeeded"));
947 assert!(summary.contains("1 failed"));
948
949 let failed_item = report
951 .items
952 .iter()
953 .find(|i| i.status == WarmupStatus::Failed)
954 .unwrap();
955 assert_eq!(failed_item.name, "dict");
956 }
957}