1use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use a3s_box_core::config::{BoxConfig, PoolConfig};
12use a3s_box_core::error::{BoxError, Result};
13use a3s_box_core::event::{BoxEvent, EventEmitter};
14use tokio::sync::{watch, Mutex, OwnedSemaphorePermit, Semaphore};
15use tokio::task::{JoinHandle, JoinSet};
16
17use crate::pool::scaler::PoolScaler;
18use crate::vm::VmManager;
19
20struct WarmVm {
22 vm: VmManager,
24 created_at: Instant,
26}
27
28type BootVmFuture<'a> = Pin<Box<dyn Future<Output = Result<VmManager>> + Send + 'a>>;
29
30struct BootMetricGuard {
33 metrics: Option<crate::prom::RuntimeMetrics>,
34}
35
36impl BootMetricGuard {
37 fn new(metrics: Option<crate::prom::RuntimeMetrics>) -> Self {
38 if let Some(metrics) = &metrics {
39 metrics.warm_pool_boots_inflight.inc();
40 }
41 Self { metrics }
42 }
43}
44
45impl Drop for BootMetricGuard {
46 fn drop(&mut self) {
47 if let Some(metrics) = &self.metrics {
48 metrics.warm_pool_boots_inflight.dec();
49 }
50 }
51}
52
53async fn acquire_boot_permits(
57 boot_limiter: Arc<Semaphore>,
58 global_boot_limiter: Option<Arc<Semaphore>>,
59) -> Result<(OwnedSemaphorePermit, Option<OwnedSemaphorePermit>)> {
60 let pool_permit = boot_limiter
61 .acquire_owned()
62 .await
63 .map_err(|_| BoxError::PoolError("Warm-pool boot limiter closed".to_string()))?;
64 let global_permit = match global_boot_limiter {
65 Some(limiter) => Some(limiter.acquire_owned().await.map_err(|_| {
66 BoxError::PoolError("Warm-pool global boot limiter closed".to_string())
67 })?),
68 None => None,
69 };
70 Ok((pool_permit, global_permit))
71}
72
73#[derive(Debug, Clone)]
75pub struct PoolStats {
76 pub idle_count: usize,
78 pub total_created: u64,
80 pub total_acquired: u64,
82 pub total_released: u64,
84 pub total_evicted: u64,
86}
87
88pub struct WarmPool {
104 config: PoolConfig,
106 box_config: BoxConfig,
108 idle: Arc<Mutex<Vec<WarmVm>>>,
110 stats: Arc<Mutex<PoolStats>>,
112 event_emitter: EventEmitter,
114 replenish_handle: Mutex<Option<JoinHandle<()>>>,
121 shutdown_tx: watch::Sender<bool>,
123 shutdown_rx: watch::Receiver<bool>,
125 scaler: Option<Arc<Mutex<PoolScaler>>>,
127 metrics: Option<crate::prom::RuntimeMetrics>,
129 boot_limiter: Arc<Semaphore>,
131 global_boot_limiter: Option<Arc<Semaphore>>,
133 template: Arc<Mutex<TemplateState>>,
139}
140
141#[derive(Clone)]
144struct PoolTemplate {
145 mem_file: String,
146 state_file: String,
147 rootfs_cache_key: Option<String>,
148}
149
150const MAX_TEMPLATE_BUILD_FAILURES: u32 = 3;
158
159const MAX_DRAIN_CONCURRENCY: usize = 4;
164
165const EPHEMERAL_DRAIN_TIMEOUT_MS: u64 = 2_000;
170
171enum TemplateState {
173 Unbuilt,
175 Ready(PoolTemplate),
177 Failing(u32),
181 Unavailable,
185}
186
187#[derive(Clone, Copy)]
188enum InitialFill {
189 Eager,
191 FirstReady,
193}
194
195struct BootBatch<'a> {
201 snapshot_fork: bool,
202 box_config: &'a BoxConfig,
203 event_emitter: &'a EventEmitter,
204 template: &'a Arc<Mutex<TemplateState>>,
205 needed: usize,
206 max_concurrent_boots: usize,
207 metrics: Option<crate::prom::RuntimeMetrics>,
208 boot_limiter: Arc<Semaphore>,
209 global_boot_limiter: Option<Arc<Semaphore>>,
210}
211
212impl WarmPool {
213 pub async fn start(
218 config: PoolConfig,
219 box_config: BoxConfig,
220 event_emitter: EventEmitter,
221 ) -> Result<Self> {
222 Self::start_with_metrics(config, box_config, event_emitter, None).await
223 }
224
225 pub async fn start_with_metrics(
231 config: PoolConfig,
232 box_config: BoxConfig,
233 event_emitter: EventEmitter,
234 metrics: Option<crate::prom::RuntimeMetrics>,
235 ) -> Result<Self> {
236 Self::start_with_metrics_and_boot_limiter(config, box_config, event_emitter, metrics, None)
237 .await
238 }
239
240 pub async fn start_with_metrics_and_boot_limiter(
247 config: PoolConfig,
248 box_config: BoxConfig,
249 event_emitter: EventEmitter,
250 metrics: Option<crate::prom::RuntimeMetrics>,
251 global_boot_limiter: Option<Arc<Semaphore>>,
252 ) -> Result<Self> {
253 Self::start_with_metrics_and_boot_limiter_with_fill(
254 config,
255 box_config,
256 event_emitter,
257 metrics,
258 global_boot_limiter,
259 InitialFill::Eager,
260 )
261 .await
262 }
263
264 pub async fn start_with_metrics_and_boot_limiter_first_ready(
272 config: PoolConfig,
273 box_config: BoxConfig,
274 event_emitter: EventEmitter,
275 metrics: Option<crate::prom::RuntimeMetrics>,
276 global_boot_limiter: Option<Arc<Semaphore>>,
277 ) -> Result<Self> {
278 Self::start_with_metrics_and_boot_limiter_with_fill(
279 config,
280 box_config,
281 event_emitter,
282 metrics,
283 global_boot_limiter,
284 InitialFill::FirstReady,
285 )
286 .await
287 }
288
289 async fn start_with_metrics_and_boot_limiter_with_fill(
290 config: PoolConfig,
291 box_config: BoxConfig,
292 event_emitter: EventEmitter,
293 metrics: Option<crate::prom::RuntimeMetrics>,
294 global_boot_limiter: Option<Arc<Semaphore>>,
295 initial_fill: InitialFill,
296 ) -> Result<Self> {
297 if config.max_size == 0 {
298 return Err(BoxError::PoolError(
299 "Pool max_size must be greater than 0".to_string(),
300 ));
301 }
302 if config.min_idle > config.max_size {
303 return Err(BoxError::PoolError(format!(
304 "Pool min_idle ({}) cannot exceed max_size ({})",
305 config.min_idle, config.max_size
306 )));
307 }
308 if config.max_concurrent_boots == 0 {
309 return Err(BoxError::PoolError(
310 "Pool max_concurrent_boots must be greater than 0".to_string(),
311 ));
312 }
313
314 let idle = Arc::new(Mutex::new(Vec::with_capacity(config.max_size)));
315 let stats = Arc::new(Mutex::new(PoolStats {
316 idle_count: 0,
317 total_created: 0,
318 total_acquired: 0,
319 total_released: 0,
320 total_evicted: 0,
321 }));
322 let (shutdown_tx, shutdown_rx) = watch::channel(false);
323
324 let scaler = if config.scaling.enabled {
325 Some(Arc::new(Mutex::new(PoolScaler::new(
326 config.scaling.clone(),
327 config.min_idle,
328 config.max_size,
329 ))))
330 } else {
331 None
332 };
333
334 let boot_limiter = Arc::new(Semaphore::new(config.max_concurrent_boots));
335 let pool = Self {
336 config,
337 box_config,
338 idle,
339 stats,
340 event_emitter,
341 replenish_handle: Mutex::new(None),
342 shutdown_tx,
343 shutdown_rx,
344 scaler,
345 metrics,
346 boot_limiter,
347 global_boot_limiter,
348 template: Arc::new(Mutex::new(TemplateState::Unbuilt)),
349 };
350
351 if let Some(metrics) = &pool.metrics {
352 metrics.warm_pool_capacity.set(pool.config.max_size as i64);
353 }
354
355 let initial_target = match initial_fill {
358 InitialFill::Eager => pool.config.min_idle,
359 InitialFill::FirstReady => pool.config.min_idle.min(1),
360 };
361 let initial_fill_started = Instant::now();
362 pool.fill_to_target(initial_target).await;
363 if let Some(metrics) = &pool.metrics {
364 metrics
365 .warm_pool_initial_fill_duration
366 .observe(initial_fill_started.elapsed().as_secs_f64());
367 }
368
369 let handle = pool.spawn_maintenance_loop();
371 *pool.replenish_handle.lock().await = Some(handle);
372
373 tracing::info!(
374 min_idle = pool.config.min_idle,
375 max_size = pool.config.max_size,
376 idle_ttl_secs = pool.config.idle_ttl_secs,
377 "Warm pool started"
378 );
379
380 Ok(pool)
381 }
382
383 pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
385 metrics.warm_pool_capacity.set(self.config.max_size as i64);
386 metrics.warm_pool_size.set(
387 self.idle
388 .try_lock()
389 .map(|idle| idle.len() as i64)
390 .unwrap_or_default(),
391 );
392 self.metrics = Some(metrics);
393 }
394
395 fn sync_idle_metric(metrics: Option<&crate::prom::RuntimeMetrics>, idle_count: usize) {
396 if let Some(metrics) = metrics {
397 metrics.warm_pool_size.set(idle_count as i64);
398 }
399 }
400
401 pub async fn acquire(&self) -> Result<VmManager> {
406 {
408 let mut idle = self.idle.lock().await;
409 if let Some(warm_vm) = idle.pop() {
410 let mut stats = self.stats.lock().await;
411 stats.total_acquired += 1;
412 stats.idle_count = idle.len();
413
414 if let Some(ref scaler) = self.scaler {
416 scaler.lock().await.record_acquire(true);
417 }
418
419 if let Some(ref m) = self.metrics {
420 m.warm_pool_hits.inc();
421 m.warm_pool_size.set(idle.len() as i64);
422 }
423
424 self.event_emitter.emit(BoxEvent::with_string(
425 "pool.vm.acquired",
426 format!("Acquired VM {} from pool", warm_vm.vm.box_id()),
427 ));
428
429 tracing::debug!(
430 box_id = %warm_vm.vm.box_id(),
431 idle_remaining = idle.len(),
432 "Acquired VM from warm pool"
433 );
434
435 return Ok(warm_vm.vm);
436 }
437 }
438
439 tracing::info!("No idle VM in pool, booting on demand");
441
442 if let Some(ref scaler) = self.scaler {
444 scaler.lock().await.record_acquire(false);
445 }
446
447 if let Some(ref m) = self.metrics {
448 m.warm_pool_misses.inc();
449 }
450
451 let vm = self.boot_new_vm().await?;
452
453 let mut stats = self.stats.lock().await;
454 stats.total_acquired += 1;
455
456 Ok(vm)
457 }
458
459 pub async fn release(&self, vm: VmManager) -> Result<()> {
463 let mut idle = self.idle.lock().await;
464
465 if *self.shutdown_rx.borrow() {
470 drop(idle);
471 let mut vm = vm;
472 vm.destroy().await?;
473 return Ok(());
474 }
475
476 if idle.len() >= self.config.max_size {
477 drop(idle); let mut vm = vm;
480 vm.destroy().await?;
481
482 tracing::debug!(
483 box_id = %vm.box_id(),
484 "Pool full, destroyed released VM"
485 );
486 return Ok(());
487 }
488
489 let box_id = vm.box_id().to_string();
490 idle.push(WarmVm {
491 vm,
492 created_at: Instant::now(),
493 });
494
495 let mut stats = self.stats.lock().await;
496 stats.total_released += 1;
497 stats.idle_count = idle.len();
498
499 if let Some(ref m) = self.metrics {
500 m.warm_pool_size.set(idle.len() as i64);
501 }
502
503 self.event_emitter.emit(BoxEvent::with_string(
504 "pool.vm.released",
505 format!("Released VM {} back to pool", box_id),
506 ));
507
508 tracing::debug!(
509 box_id = %box_id,
510 idle_count = idle.len(),
511 "Released VM back to warm pool"
512 );
513
514 Ok(())
515 }
516
517 pub async fn stats(&self) -> PoolStats {
519 self.stats.lock().await.clone()
520 }
521
522 pub async fn idle_count(&self) -> usize {
524 self.idle.lock().await.len()
525 }
526
527 pub fn signal_shutdown(&self) {
531 let _ = self.shutdown_tx.send(true);
532 tracing::info!("Warm pool shutdown signaled");
533 }
534
535 pub async fn drain(&mut self) -> Result<()> {
537 let _ = self.shutdown_tx.send(true);
539
540 if let Some(handle) = self.replenish_handle.lock().await.take() {
542 let _ = handle.await;
543 }
544
545 let idle_vms = {
549 let mut idle = self.idle.lock().await;
550 let idle_vms = idle.drain(..).collect::<Vec<_>>();
551 Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
552 idle_vms
553 };
554 let count = idle_vms.len();
555
556 Self::destroy_vms(idle_vms, None, "drain").await;
557
558 let mut stats = self.stats.lock().await;
559 stats.idle_count = 0;
560
561 self.event_emitter.emit(BoxEvent::empty("pool.drained"));
562
563 tracing::info!(destroyed = count, "Warm pool drained");
564
565 Ok(())
566 }
567
568 pub async fn drain_idle(&self) -> Result<()> {
573 self.signal_shutdown();
577 if let Some(handle) = self.replenish_handle.lock().await.take() {
578 let _ = handle.await;
579 }
580
581 let idle_vms = {
585 let mut idle = self.idle.lock().await;
586 let idle_vms = idle.drain(..).collect::<Vec<_>>();
587 Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
588 idle_vms
589 };
590 let count = idle_vms.len();
591 let timeout_ms = (!self.box_config.persistent).then_some(EPHEMERAL_DRAIN_TIMEOUT_MS);
592 Self::destroy_vms(idle_vms, timeout_ms, "drain_idle").await;
593 self.stats.lock().await.idle_count = 0;
594 tracing::info!(destroyed = count, "Warm pool idle VMs drained");
595 Ok(())
596 }
597
598 async fn destroy_vms(vms: Vec<WarmVm>, timeout_ms: Option<u64>, operation: &'static str) {
602 if vms.is_empty() {
603 return;
604 }
605
606 let concurrency = vms.len().min(MAX_DRAIN_CONCURRENCY);
607 let mut pending = vms.into_iter();
608 let mut tasks = JoinSet::new();
609
610 for _ in 0..concurrency {
611 if let Some(warm_vm) = pending.next() {
612 tasks.spawn(Self::destroy_one(warm_vm, timeout_ms));
613 }
614 }
615
616 while let Some(result) = tasks.join_next().await {
617 match result {
618 Ok((box_id, Ok(()))) => {
619 tracing::debug!(%box_id, operation, "Destroyed pooled VM");
620 }
621 Ok((box_id, Err(error))) => {
622 tracing::warn!(%box_id, %error, operation, "Failed to destroy pooled VM");
623 }
624 Err(error) => {
625 tracing::warn!(%error, operation, "Pooled VM teardown task failed");
626 }
627 }
628
629 if let Some(warm_vm) = pending.next() {
630 tasks.spawn(Self::destroy_one(warm_vm, timeout_ms));
631 }
632 }
633 }
634
635 async fn destroy_one(warm_vm: WarmVm, timeout_ms: Option<u64>) -> (String, Result<()>) {
636 let box_id = warm_vm.vm.box_id().to_string();
637 let mut vm = warm_vm.vm;
638 let result = match timeout_ms {
639 Some(timeout_ms) => vm.destroy_with_timeout(timeout_ms).await,
640 None => vm.destroy().await,
641 };
642 (box_id, result)
643 }
644
645 async fn remove_idle_vms(&self, box_ids: &[String]) {
650 let indices_to_remove: Vec<usize> = {
652 let idle = self.idle.lock().await;
653 idle.iter()
654 .enumerate()
655 .filter(|(_, wm)| box_ids.iter().any(|id| id == wm.vm.box_id()))
656 .map(|(i, _)| i)
657 .collect()
658 };
659
660 if indices_to_remove.is_empty() {
661 return;
662 }
663
664 let mut to_destroy: Vec<WarmVm> = Vec::new();
667 {
668 let mut idle = self.idle.lock().await;
669 for idx in indices_to_remove.into_iter().rev() {
670 if idx < idle.len() {
671 let warm_vm = idle.remove(idx);
672 to_destroy.push(warm_vm);
673 }
674 }
675 }
676
677 {
679 let idle_count = self.idle.lock().await.len();
680 if let Ok(mut stats) = self.stats.try_lock() {
681 stats.idle_count = idle_count;
682 }
683 Self::sync_idle_metric(self.metrics.as_ref(), idle_count);
684 }
685
686 Self::destroy_vms(to_destroy, None, "fill rollback").await;
688 }
689
690 async fn boot_new_vm(&self) -> Result<VmManager> {
692 let _boot_permits =
693 acquire_boot_permits(self.boot_limiter.clone(), self.global_boot_limiter.clone())
694 .await?;
695 let _boot_guard = BootMetricGuard::new(self.metrics.clone());
696 let result = Self::boot_or_restore(
697 self.config.snapshot_fork,
698 &self.box_config,
699 &self.event_emitter,
700 &self.template,
701 )
702 .await;
703 if result.is_err() {
704 if let Some(metrics) = &self.metrics {
705 metrics.warm_pool_boot_failures_total.inc();
706 }
707 }
708 let vm = result?;
709
710 let mut stats = self.stats.lock().await;
711 stats.total_created += 1;
712
713 self.event_emitter.emit(BoxEvent::with_string(
714 "pool.vm.created",
715 format!("Booted new VM {}", vm.box_id()),
716 ));
717
718 Ok(vm)
719 }
720
721 fn boot_or_restore<'a>(
724 snapshot_fork: bool,
725 box_config: &'a BoxConfig,
726 event_emitter: &'a EventEmitter,
727 template: &'a Arc<Mutex<TemplateState>>,
728 ) -> BootVmFuture<'a> {
729 Box::pin(async move {
730 if snapshot_fork && crate::vm::native_snapshot_fork_supported() {
731 match Self::ensure_template(box_config, event_emitter, template).await {
736 Ok(tpl) => {
737 let mut cfg = box_config.clone();
738 cfg.snapshot_mem_file = Some(tpl.mem_file.clone());
739 cfg.restore_from = Some(tpl.state_file.clone());
740 cfg.snapshot_sock = None;
741 let mut vm = VmManager::new(cfg, event_emitter.clone());
742 vm.restore_rootfs_cache_key = tpl.rootfs_cache_key.clone();
743 let restored = async {
744 vm.boot().await?;
745 vm.wait_for_exec_available(std::time::Duration::from_secs(120))
746 .await
747 }
748 .await;
749 match restored {
750 Ok(()) => return Ok(vm),
751 Err(error) => {
752 let _ = vm.destroy_with_timeout(2000).await;
753 tracing::warn!(
754 %error,
755 "snapshot-fork restore failed; cold-booting this pool VM"
756 );
757 }
758 }
759 }
760 Err(error) => {
761 tracing::debug!(%error, "snapshot-fork unavailable; cold-booting this pool VM");
762 }
763 }
764 } else if snapshot_fork {
765 tracing::debug!(
766 "snapshot-fork is unavailable on this build; cold-booting without snapshot side effects"
767 );
768 }
769 let mut vm = VmManager::new(box_config.clone(), event_emitter.clone());
770 vm.boot().await?;
771 vm.wait_for_exec_available(std::time::Duration::from_secs(120))
772 .await?;
773 Ok(vm)
774 })
775 }
776
777 async fn boot_batch(batch: BootBatch<'_>) -> Vec<Result<VmManager>> {
785 if batch.needed == 0 {
786 return Vec::new();
787 }
788
789 let limit = bounded_boot_limit(batch.needed, batch.max_concurrent_boots);
790 let mut set = tokio::task::JoinSet::new();
791 let mut launched = 0usize;
792 let mut results = Vec::with_capacity(batch.needed);
793
794 while launched < batch.needed || !set.is_empty() {
795 while launched < batch.needed && set.len() < limit {
796 let config = batch.box_config.clone();
797 let emitter = batch.event_emitter.clone();
798 let shared_template = Arc::clone(batch.template);
799 let boot_metrics = batch.metrics.clone();
800 let pool_boot_limiter = batch.boot_limiter.clone();
801 let daemon_boot_limiter = batch.global_boot_limiter.clone();
802 let snapshot_fork = batch.snapshot_fork;
803 set.spawn(async move {
804 let _boot_permits =
805 acquire_boot_permits(pool_boot_limiter, daemon_boot_limiter).await?;
806 let _boot_guard = BootMetricGuard::new(boot_metrics);
807 WarmPool::boot_or_restore(snapshot_fork, &config, &emitter, &shared_template)
808 .await
809 });
810 launched += 1;
811 }
812
813 if let Some(result) = set.join_next().await {
814 let result = match result {
815 Ok(result) => result,
816 Err(error) => Err(BoxError::PoolError(format!(
817 "Warm-pool boot task failed: {error}"
818 ))),
819 };
820 if result.is_err() {
821 if let Some(metrics) = &batch.metrics {
822 metrics.warm_pool_boot_failures_total.inc();
823 }
824 }
825 results.push(result);
826 }
827 }
828
829 results
830 }
831
832 async fn ensure_template(
839 box_config: &BoxConfig,
840 event_emitter: &EventEmitter,
841 template: &Arc<Mutex<TemplateState>>,
842 ) -> Result<PoolTemplate> {
843 if !crate::vm::native_snapshot_fork_supported() {
844 return Err(BoxError::PoolError(
845 "snapshot-fork requires the Linux x86_64 KVM build".to_string(),
846 ));
847 }
848 let mut guard = template.lock().await;
849 let prior_failures = match &*guard {
850 TemplateState::Ready(t) => return Ok(t.clone()),
851 TemplateState::Unavailable => {
852 return Err(BoxError::PoolError(
853 "snapshot-fork template unavailable (native VM snapshot unsupported)"
854 .to_string(),
855 ));
856 }
857 TemplateState::Failing(n) => *n,
859 TemplateState::Unbuilt => 0,
860 };
861
862 match Self::build_template(box_config, event_emitter).await {
863 Ok(tpl) => {
864 *guard = TemplateState::Ready(tpl.clone());
865 event_emitter.emit(BoxEvent::with_string(
866 "pool.template.built",
867 format!(
868 "Snapshot-fork template built for image {}",
869 box_config.image
870 ),
871 ));
872 Ok(tpl)
873 }
874 Err(error) => {
875 let failures = prior_failures + 1;
880 if failures >= MAX_TEMPLATE_BUILD_FAILURES {
881 tracing::warn!(
882 %error, failures,
883 "snapshot-fork template build failed repeatedly; marking \
884 unavailable — the warm pool will cold-boot"
885 );
886 *guard = TemplateState::Unavailable;
887 } else {
888 tracing::warn!(
889 %error, failures,
890 "snapshot-fork template build failed; will retry on a later fill"
891 );
892 *guard = TemplateState::Failing(failures);
893 }
894 Err(error)
895 }
896 }
897 }
898
899 async fn build_template(
902 box_config: &BoxConfig,
903 event_emitter: &EventEmitter,
904 ) -> Result<PoolTemplate> {
905 let dir = a3s_box_core::dirs_home().join("pool").join(format!(
906 "tpl-{:016x}",
907 crate::vm::fnv1a_hash(&box_config.image)
908 ));
909 std::fs::create_dir_all(&dir).map_err(BoxError::IoError)?;
910
911 let lock_target = dir.clone();
918 let _lock =
919 tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_target))
920 .await
921 .map_err(|e| BoxError::PoolError(format!("Template lock task failed: {e}")))?
922 .map_err(|e| BoxError::PoolError(format!("Failed to lock template dir: {e}")))?;
923
924 let mem_file = dir.join("template.ram");
925 let sock = dir.join("template.sock");
926 let state_file = dir.join("template.state");
927 let _ = std::fs::remove_file(&sock);
928
929 let mut cfg = box_config.clone();
931 cfg.snapshot_mem_file = Some(mem_file.to_string_lossy().into_owned());
932 cfg.snapshot_sock = Some(sock.to_string_lossy().into_owned());
933 cfg.restore_from = None;
934 let mut src = VmManager::new(cfg, event_emitter.clone());
935 src.boot().await?;
936 let rootfs_cache_key = match src.current_rootfs_cache_key() {
937 Ok(key) => key,
938 Err(error) => {
939 let _ = src.destroy_with_timeout(2000).await;
940 return Err(error);
941 }
942 };
943
944 let snapshot = Self::trigger_snapshot(&sock, &state_file).await;
953 let _ = src.destroy_with_timeout(2000).await;
954 snapshot?;
955
956 Ok(PoolTemplate {
957 mem_file: mem_file.to_string_lossy().into_owned(),
958 state_file: state_file.to_string_lossy().into_owned(),
959 rootfs_cache_key,
960 })
961 }
962
963 #[cfg(unix)]
969 async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
970 use tokio::io::{AsyncReadExt, AsyncWriteExt};
971 let mut stream = None;
973 for _ in 0..200 {
974 match tokio::net::UnixStream::connect(sock).await {
975 Ok(s) => {
976 stream = Some(s);
977 break;
978 }
979 Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
980 }
981 }
982 let mut stream = stream.ok_or_else(|| {
983 BoxError::PoolError(format!("snapshot socket {} never appeared", sock.display()))
984 })?;
985 let cmd = format!("snapshot {}\n", state_file.display());
986 stream
987 .write_all(cmd.as_bytes())
988 .await
989 .map_err(BoxError::IoError)?;
990 let mut buf = [0u8; 64];
991 let n = stream.read(&mut buf).await.map_err(BoxError::IoError)?;
992 let reply = String::from_utf8_lossy(&buf[..n]);
993 if reply.trim() == "ok" {
994 Ok(())
995 } else {
996 Err(BoxError::PoolError(format!(
997 "snapshot trigger failed: {}",
998 reply.trim()
999 )))
1000 }
1001 }
1002
1003 #[cfg(not(unix))]
1007 async fn trigger_snapshot(
1008 _sock: &std::path::Path,
1009 _state_file: &std::path::Path,
1010 ) -> Result<()> {
1011 Err(BoxError::PoolError(
1012 "snapshot-fork is only supported on Linux/KVM hosts".to_string(),
1013 ))
1014 }
1015
1016 async fn fill_to_target(&self, target: usize) {
1018 let current = self.idle.lock().await.len();
1019 let needed = target.saturating_sub(current);
1020
1021 if needed == 0 {
1022 return;
1023 }
1024
1025 tracing::debug!(current, needed, target, "Replenishing warm pool");
1026
1027 let mut added_ids: Vec<String> = Vec::new();
1029 let mut failed = false;
1030 let results = Self::boot_batch(BootBatch {
1031 snapshot_fork: self.config.snapshot_fork,
1032 box_config: &self.box_config,
1033 event_emitter: &self.event_emitter,
1034 template: &self.template,
1035 needed,
1036 max_concurrent_boots: self.config.max_concurrent_boots,
1037 metrics: self.metrics.clone(),
1038 boot_limiter: self.boot_limiter.clone(),
1039 global_boot_limiter: self.global_boot_limiter.clone(),
1040 })
1041 .await;
1042
1043 for result in results {
1044 match result {
1045 Ok(vm) => {
1046 let box_id = vm.box_id().to_string();
1047 let mut idle = self.idle.lock().await;
1048 idle.push(WarmVm {
1049 vm,
1050 created_at: Instant::now(),
1051 });
1052 Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
1053 let mut stats = self.stats.lock().await;
1054 stats.total_created += 1;
1055 stats.idle_count = idle.len();
1056 added_ids.push(box_id.clone());
1057
1058 self.event_emitter.emit(BoxEvent::with_string(
1059 "pool.vm.created",
1060 format!("Booted new VM {box_id}"),
1061 ));
1062 tracing::debug!(box_id = %box_id, "Added VM to warm pool");
1063 }
1064 Err(error) => {
1065 failed = true;
1066 tracing::warn!(error = %error, "Failed to boot VM for warm pool");
1067 }
1068 }
1069 }
1070
1071 if failed && !added_ids.is_empty() {
1072 tracing::info!(
1073 count = added_ids.len(),
1074 "Cleaning up VMs added before pool fill failed"
1075 );
1076 self.remove_idle_vms(&added_ids).await;
1077 }
1078
1079 self.event_emitter.emit(BoxEvent::empty("pool.replenish"));
1080 }
1081
1082 fn spawn_maintenance_loop(&self) -> JoinHandle<()> {
1089 let idle = Arc::clone(&self.idle);
1090 let stats = Arc::clone(&self.stats);
1091 let config = self.config.clone();
1092 let box_config = self.box_config.clone();
1093 let event_emitter = self.event_emitter.clone();
1094 let mut shutdown_rx = self.shutdown_rx.clone();
1095 let scaler = self.scaler.clone();
1096 let template = Arc::clone(&self.template);
1097 let metrics = self.metrics.clone();
1098 let boot_limiter = self.boot_limiter.clone();
1099 let global_boot_limiter = self.global_boot_limiter.clone();
1100
1101 tokio::spawn(async move {
1102 let check_interval = std::time::Duration::from_secs(
1103 if config.idle_ttl_secs > 0 {
1105 (config.idle_ttl_secs / 5).max(5)
1106 } else {
1107 30
1108 },
1109 );
1110 let mut maintenance = tokio::time::interval(check_interval);
1111 maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1114
1115 let mut effective_min_idle = config.min_idle;
1117 let mut replenish_failures = 0u32;
1121 let mut next_replenish_at = Instant::now();
1122
1123 loop {
1124 tokio::select! {
1125 result = shutdown_rx.changed() => {
1126 if result.is_ok() && *shutdown_rx.borrow() {
1127 tracing::debug!("Pool maintenance loop shutting down");
1128 break;
1129 }
1130 }
1131 _ = maintenance.tick() => {
1132 if config.idle_ttl_secs > 0 {
1134 Self::evict_expired_static(
1135 &idle,
1136 &stats,
1137 &event_emitter,
1138 metrics.as_ref(),
1139 config.idle_ttl_secs,
1140 ).await;
1141 }
1142
1143 if let Some(ref scaler) = scaler {
1145 let mut s = scaler.lock().await;
1146 let decision = s.evaluate();
1147 let new_min = s.current_min_idle();
1148 if new_min != effective_min_idle {
1149 tracing::info!(
1150 old_min_idle = effective_min_idle,
1151 new_min_idle = new_min,
1152 ?decision,
1153 "Autoscaler adjusted min_idle"
1154 );
1155 event_emitter.emit(BoxEvent::with_string(
1156 "pool.autoscale",
1157 format!(
1158 "min_idle adjusted {} → {} ({:?})",
1159 effective_min_idle, new_min, decision
1160 ),
1161 ));
1162 effective_min_idle = new_min;
1163 }
1164 }
1165
1166 let current = idle.lock().await.len();
1168 if current < effective_min_idle && Instant::now() >= next_replenish_at {
1169 let needed = effective_min_idle - current;
1170 tracing::debug!(current, needed, min_idle = effective_min_idle, "Replenishing warm pool");
1171
1172 let results = Self::boot_batch(BootBatch {
1175 snapshot_fork: config.snapshot_fork,
1176 box_config: &box_config,
1177 event_emitter: &event_emitter,
1178 template: &template,
1179 needed,
1180 max_concurrent_boots: config.max_concurrent_boots,
1181 metrics: metrics.clone(),
1182 boot_limiter: boot_limiter.clone(),
1183 global_boot_limiter: global_boot_limiter.clone(),
1184 })
1185 .await;
1186 let mut batch_failed = false;
1187 for result in results {
1188 match result {
1189 Ok(mut vm) => {
1190 let box_id = vm.box_id().to_string();
1191 let mut pool = idle.lock().await;
1202 if *shutdown_rx.borrow() {
1203 drop(pool);
1204 tracing::debug!(
1205 box_id = %box_id,
1206 "Pool shutting down mid-replenish; destroying freshly-booted VM"
1207 );
1208 let _ = vm.destroy_with_timeout(2000).await;
1209 continue;
1210 }
1211 pool.push(WarmVm {
1212 vm,
1213 created_at: Instant::now(),
1214 });
1215 Self::sync_idle_metric(metrics.as_ref(), pool.len());
1216 let mut s = stats.lock().await;
1217 s.total_created += 1;
1218 s.idle_count = pool.len();
1219 drop(s);
1220 drop(pool);
1221
1222 event_emitter.emit(BoxEvent::with_string(
1223 "pool.vm.created",
1224 format!("Replenished VM {}", box_id),
1225 ));
1226 }
1227 Err(error) => {
1228 batch_failed = true;
1229 tracing::warn!(error = %error, "Failed to replenish warm pool");
1230 }
1231 }
1232 }
1233
1234 if batch_failed {
1235 replenish_failures = replenish_failures.saturating_add(1);
1236 let delay = replenish_backoff_delay(
1237 replenish_failures,
1238 check_interval,
1239 );
1240 next_replenish_at = Instant::now() + delay;
1241 tracing::warn!(
1242 failures = replenish_failures,
1243 retry_in_secs = delay.as_secs(),
1244 "Backing off warm-pool replenishment after boot failure"
1245 );
1246 } else {
1247 replenish_failures = 0;
1248 next_replenish_at = Instant::now();
1249 }
1250
1251 event_emitter.emit(BoxEvent::empty("pool.replenish"));
1252 }
1253 }
1254 }
1255 }
1256 })
1257 }
1258
1259 async fn evict_expired_static(
1261 idle: &Arc<Mutex<Vec<WarmVm>>>,
1262 stats: &Arc<Mutex<PoolStats>>,
1263 event_emitter: &EventEmitter,
1264 metrics: Option<&crate::prom::RuntimeMetrics>,
1265 idle_ttl_secs: u64,
1266 ) {
1267 let ttl = std::time::Duration::from_secs(idle_ttl_secs);
1268
1269 let mut pool = idle.lock().await;
1270 let mut kept = Vec::new();
1271 let mut expired = Vec::new();
1272
1273 for warm_vm in pool.drain(..) {
1274 if warm_vm.created_at.elapsed() > ttl {
1275 expired.push(warm_vm);
1276 } else {
1277 kept.push(warm_vm);
1278 }
1279 }
1280 *pool = kept;
1281 let after_count = pool.len();
1282 drop(pool);
1283
1284 let evicted_count = expired.len();
1285 Self::sync_idle_metric(metrics, after_count);
1286 Self::destroy_vms(expired, None, "eviction").await;
1287
1288 if evicted_count > 0 {
1289 let mut s = stats.lock().await;
1290 s.total_evicted += evicted_count as u64;
1291 s.idle_count = after_count;
1292
1293 event_emitter.emit(BoxEvent::with_string(
1294 "pool.vm.evicted",
1295 format!("Evicted {} expired VMs", evicted_count),
1296 ));
1297 }
1298 }
1299}
1300
1301fn bounded_boot_limit(needed: usize, max_concurrent_boots: usize) -> usize {
1307 needed.min(max_concurrent_boots.max(1))
1308}
1309
1310fn replenish_backoff_delay(failures: u32, check_interval: Duration) -> Duration {
1317 let exponent = failures.saturating_sub(1).min(8);
1318 let multiplier = 1u64 << exponent;
1319 let delay_secs = check_interval
1320 .as_secs()
1321 .max(1)
1322 .saturating_mul(multiplier)
1323 .min(300);
1324 Duration::from_secs(delay_secs)
1325}
1326
1327#[cfg(test)]
1328mod shutdown_tests;
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use a3s_box_core::config::PoolConfig;
1334
1335 fn test_pool_config(min_idle: usize, max_size: usize) -> PoolConfig {
1336 PoolConfig {
1337 enabled: true,
1338 min_idle,
1339 max_size,
1340 idle_ttl_secs: 300,
1341 ..Default::default()
1342 }
1343 }
1344
1345 fn test_event_emitter() -> EventEmitter {
1346 EventEmitter::new(100)
1347 }
1348
1349 #[test]
1350 fn boot_or_restore_future_stays_heap_indirected() {
1351 let config = BoxConfig::default();
1352 let emitter = test_event_emitter();
1353 let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
1354 let future = WarmPool::boot_or_restore(false, &config, &emitter, &template);
1355
1356 assert!(
1357 std::mem::size_of_val(&future) <= 2 * std::mem::size_of::<usize>(),
1358 "boot_or_restore future must remain pointer-sized so pool misses fit on Tokio worker stacks; got {} bytes",
1359 std::mem::size_of_val(&future)
1360 );
1361 }
1362
1363 #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
1364 #[tokio::test]
1365 async fn unsupported_snapshot_fork_is_rejected_before_template_construction() {
1366 let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
1367 let result =
1368 WarmPool::ensure_template(&BoxConfig::default(), &test_event_emitter(), &template)
1369 .await;
1370 let error = match result {
1371 Ok(_) => panic!("unsupported host unexpectedly built a snapshot template"),
1372 Err(error) => error.to_string(),
1373 };
1374
1375 assert!(error.contains("Linux x86_64 KVM"), "{error}");
1376 assert!(matches!(&*template.lock().await, TemplateState::Unbuilt));
1377 }
1378
1379 #[tokio::test]
1382 async fn test_pool_rejects_zero_max_size() {
1383 let config = test_pool_config(0, 0);
1384 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1385 match result {
1386 Err(e) => assert!(e.to_string().contains("max_size must be greater than 0")),
1387 Ok(_) => panic!("Expected error for zero max_size"),
1388 }
1389 }
1390
1391 #[tokio::test]
1392 async fn test_pool_rejects_min_idle_exceeds_max() {
1393 let config = test_pool_config(10, 5);
1394 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1395 match result {
1396 Err(e) => assert!(e.to_string().contains("cannot exceed max_size")),
1397 Ok(_) => panic!("Expected error for min_idle > max_size"),
1398 }
1399 }
1400
1401 #[tokio::test]
1402 async fn test_pool_rejects_zero_max_concurrent_boots() {
1403 let mut config = test_pool_config(0, 1);
1404 config.max_concurrent_boots = 0;
1405 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1406 match result {
1407 Err(error) => assert!(error.to_string().contains("max_concurrent_boots")),
1408 Ok(_) => panic!("Expected error for zero max_concurrent_boots"),
1409 }
1410 }
1411
1412 #[tokio::test]
1413 async fn acquire_boot_permits_releases_both_scopes() {
1414 let pool_limiter = Arc::new(Semaphore::new(1));
1415 let global_limiter = Arc::new(Semaphore::new(1));
1416 let (pool_permit, global_permit) =
1417 acquire_boot_permits(pool_limiter.clone(), Some(global_limiter.clone()))
1418 .await
1419 .expect("both boot limiters should grant a permit");
1420 assert_eq!(pool_limiter.available_permits(), 0);
1421 assert_eq!(global_limiter.available_permits(), 0);
1422 drop((pool_permit, global_permit));
1423 assert_eq!(pool_limiter.available_permits(), 1);
1424 assert_eq!(global_limiter.available_permits(), 1);
1425 }
1426
1427 #[test]
1428 fn boot_batch_limit_is_bounded_and_never_deadlocks() {
1429 assert_eq!(bounded_boot_limit(0, 2), 0);
1430 assert_eq!(bounded_boot_limit(8, 2), 2);
1431 assert_eq!(bounded_boot_limit(2, 8), 2);
1432 assert_eq!(bounded_boot_limit(8, 0), 1);
1433 }
1434
1435 #[test]
1436 fn replenish_backoff_is_exponential_and_capped() {
1437 let base = Duration::from_secs(5);
1438 assert_eq!(replenish_backoff_delay(0, base), Duration::from_secs(5));
1439 assert_eq!(replenish_backoff_delay(1, base), Duration::from_secs(5));
1440 assert_eq!(replenish_backoff_delay(2, base), Duration::from_secs(10));
1441 assert_eq!(replenish_backoff_delay(7, base), Duration::from_secs(300));
1442 assert_eq!(replenish_backoff_delay(20, base), Duration::from_secs(300));
1443 }
1444
1445 #[test]
1446 fn boot_metric_guard_balances_inflight_gauge() {
1447 let metrics = crate::prom::RuntimeMetrics::new();
1448 {
1449 let _guard = BootMetricGuard::new(Some(metrics.clone()));
1450 assert_eq!(metrics.warm_pool_boots_inflight.get(), 1);
1451 }
1452 assert_eq!(metrics.warm_pool_boots_inflight.get(), 0);
1453 }
1454
1455 #[test]
1458 fn test_pool_stats_default() {
1459 let stats = PoolStats {
1460 idle_count: 0,
1461 total_created: 0,
1462 total_acquired: 0,
1463 total_released: 0,
1464 total_evicted: 0,
1465 };
1466 assert_eq!(stats.idle_count, 0);
1467 assert_eq!(stats.total_created, 0);
1468 }
1469
1470 #[test]
1471 fn test_pool_stats_clone() {
1472 let stats = PoolStats {
1473 idle_count: 3,
1474 total_created: 10,
1475 total_acquired: 7,
1476 total_released: 5,
1477 total_evicted: 2,
1478 };
1479 let cloned = stats.clone();
1480 assert_eq!(cloned.idle_count, 3);
1481 assert_eq!(cloned.total_created, 10);
1482 assert_eq!(cloned.total_acquired, 7);
1483 assert_eq!(cloned.total_released, 5);
1484 assert_eq!(cloned.total_evicted, 2);
1485 }
1486
1487 #[test]
1488 fn test_pool_stats_debug() {
1489 let stats = PoolStats {
1490 idle_count: 1,
1491 total_created: 2,
1492 total_acquired: 3,
1493 total_released: 4,
1494 total_evicted: 5,
1495 };
1496 let debug = format!("{:?}", stats);
1497 assert!(debug.contains("idle_count"));
1498 assert!(debug.contains("total_created"));
1499 }
1500
1501 #[test]
1504 fn test_pool_config_roundtrip() {
1505 let config = PoolConfig {
1506 enabled: true,
1507 min_idle: 3,
1508 max_size: 10,
1509 idle_ttl_secs: 600,
1510 ..Default::default()
1511 };
1512
1513 let json = serde_json::to_string(&config).unwrap();
1514 let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
1515
1516 assert!(parsed.enabled);
1517 assert_eq!(parsed.min_idle, 3);
1518 assert_eq!(parsed.max_size, 10);
1519 assert_eq!(parsed.idle_ttl_secs, 600);
1520 }
1521
1522 #[test]
1523 fn test_pool_config_default_values() {
1524 let config = PoolConfig::default();
1525 assert!(!config.enabled);
1526 assert_eq!(config.min_idle, 1);
1527 assert_eq!(config.max_size, 5);
1528 assert_eq!(config.idle_ttl_secs, 300);
1529 }
1530
1531 #[test]
1532 fn test_pool_config_deserialization_with_defaults() {
1533 let json = r#"{"enabled": true}"#;
1534 let config: PoolConfig = serde_json::from_str(json).unwrap();
1535 assert!(config.enabled);
1536 assert_eq!(config.min_idle, 1);
1537 assert_eq!(config.max_size, 5);
1538 assert_eq!(config.idle_ttl_secs, 300);
1539 }
1540
1541 #[tokio::test]
1544 async fn test_pool_accepts_min_idle_equals_max() {
1545 let config = test_pool_config(3, 3);
1546 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1549 match result {
1551 Err(e) => assert!(!e.to_string().contains("cannot exceed max_size")),
1552 Ok(mut pool) => {
1553 let _ = pool.drain().await;
1554 }
1555 }
1556 }
1557
1558 #[tokio::test]
1559 async fn test_pool_accepts_min_idle_zero() {
1560 let config = test_pool_config(0, 5);
1561 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1563 match result {
1564 Ok(mut pool) => {
1565 assert_eq!(pool.idle_count().await, 0);
1567 let stats = pool.stats().await;
1568 assert_eq!(stats.idle_count, 0);
1569 assert_eq!(stats.total_created, 0);
1570 let _ = pool.drain().await;
1571 }
1572 Err(e) => {
1573 assert!(!e.to_string().contains("max_size"));
1575 assert!(!e.to_string().contains("min_idle"));
1576 }
1577 }
1578 }
1579
1580 #[tokio::test]
1583 async fn test_pool_stats_initial() {
1584 let config = test_pool_config(0, 5);
1585 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1586 if let Ok(mut pool) = result {
1587 let stats = pool.stats().await;
1588 assert_eq!(stats.idle_count, 0);
1589 assert_eq!(stats.total_created, 0);
1590 assert_eq!(stats.total_acquired, 0);
1591 assert_eq!(stats.total_released, 0);
1592 assert_eq!(stats.total_evicted, 0);
1593 let _ = pool.drain().await;
1594 }
1595 }
1596
1597 #[tokio::test]
1598 async fn test_pool_idle_count_initial() {
1599 let config = test_pool_config(0, 5);
1600 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1601 if let Ok(mut pool) = result {
1602 assert_eq!(pool.idle_count().await, 0);
1603 let _ = pool.drain().await;
1604 }
1605 }
1606
1607 #[tokio::test]
1608 async fn test_pool_drain_empty_pool() {
1609 let config = test_pool_config(0, 5);
1610 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1611 if let Ok(mut pool) = result {
1612 let drain_result = pool.drain().await;
1614 assert!(drain_result.is_ok());
1615
1616 let stats = pool.stats().await;
1617 assert_eq!(stats.idle_count, 0);
1618 }
1619 }
1620
1621 #[tokio::test]
1622 async fn test_pool_drain_emits_event() {
1623 let emitter = test_event_emitter();
1624 let mut receiver = emitter.subscribe();
1625 let config = test_pool_config(0, 5);
1626
1627 let result = WarmPool::start(config, BoxConfig::default(), emitter).await;
1628 if let Ok(mut pool) = result {
1629 pool.drain().await.unwrap();
1630
1631 let mut found_drain_event = false;
1633 while let Ok(event) = receiver.try_recv() {
1635 if event.key == "pool.drained" {
1636 found_drain_event = true;
1637 }
1638 }
1639 assert!(found_drain_event, "Expected pool.drained event");
1640 }
1641 }
1642
1643 #[tokio::test]
1644 async fn test_pool_acquire_from_empty_pool_fails_without_shim() {
1645 let config = test_pool_config(0, 5);
1646 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1647 if let Ok(pool) = result {
1648 let acquire_result = pool.acquire().await;
1651 assert!(acquire_result.is_err());
1652 }
1653 }
1654
1655 #[test]
1658 #[allow(clippy::unnecessary_min_or_max)]
1659 fn test_maintenance_check_interval_with_ttl() {
1660 let interval = if 300_u64 > 0 {
1662 (300_u64 / 5).max(5)
1663 } else {
1664 30
1665 };
1666 assert_eq!(interval, 60);
1667 }
1668
1669 #[test]
1670 #[allow(clippy::unnecessary_min_or_max)]
1671 fn test_maintenance_check_interval_short_ttl() {
1672 let interval = if 10_u64 > 0 { (10_u64 / 5).max(5) } else { 30 };
1674 assert_eq!(interval, 5);
1675 }
1676
1677 #[test]
1678 #[allow(clippy::unnecessary_min_or_max)]
1679 fn test_maintenance_check_interval_very_short_ttl() {
1680 let interval = if 1_u64 > 0 { (1_u64 / 5).max(5) } else { 30 };
1682 assert_eq!(interval, 5);
1683 }
1684
1685 #[test]
1686 #[allow(
1687 clippy::absurd_extreme_comparisons,
1688 clippy::erasing_op,
1689 clippy::unnecessary_min_or_max,
1690 unused_comparisons
1691 )]
1692 fn test_maintenance_check_interval_no_ttl() {
1693 let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
1695 assert_eq!(interval, 30);
1696 }
1697
1698 #[test]
1701 fn test_warm_vm_created_at_is_recent() {
1702 let before = Instant::now();
1703 let created_at = Instant::now();
1704 let after = Instant::now();
1705
1706 assert!(created_at >= before);
1707 assert!(created_at <= after);
1708 }
1709
1710 #[test]
1713 fn test_pool_stats_all_fields() {
1714 let stats = PoolStats {
1715 idle_count: 10,
1716 total_created: 100,
1717 total_acquired: 80,
1718 total_released: 70,
1719 total_evicted: 15,
1720 };
1721
1722 assert_eq!(stats.idle_count, 10);
1723 assert_eq!(stats.total_created, 100);
1724 assert_eq!(stats.total_acquired, 80);
1725 assert_eq!(stats.total_released, 70);
1726 assert_eq!(stats.total_evicted, 15);
1727
1728 let debug = format!("{:?}", stats);
1730 assert!(debug.contains("10"));
1731 assert!(debug.contains("100"));
1732 assert!(debug.contains("80"));
1733 assert!(debug.contains("70"));
1734 assert!(debug.contains("15"));
1735 }
1736
1737 #[tokio::test]
1744 async fn test_pool_set_metrics_attaches() {
1745 let config = test_pool_config(0, 5);
1746 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1747 match result {
1748 Ok(mut pool) => {
1749 let metrics = crate::prom::RuntimeMetrics::new();
1750 pool.set_metrics(metrics.clone());
1751 assert!(pool.metrics.is_some());
1752 assert_eq!(metrics.warm_pool_hits.get(), 0);
1754 assert_eq!(metrics.warm_pool_misses.get(), 0);
1755 assert_eq!(metrics.warm_pool_size.get(), 0);
1756 let _ = pool.drain().await;
1757 }
1758 Err(_) => {
1759 }
1761 }
1762 }
1763
1764 #[tokio::test]
1765 async fn test_pool_start_with_metrics_installs_sink_before_fill() {
1766 let config = test_pool_config(0, 5);
1767 let metrics = crate::prom::RuntimeMetrics::new();
1768 let result = WarmPool::start_with_metrics(
1769 config,
1770 BoxConfig::default(),
1771 test_event_emitter(),
1772 Some(metrics.clone()),
1773 )
1774 .await;
1775
1776 match result {
1777 Ok(mut pool) => {
1778 assert!(pool.metrics.is_some());
1779 assert_eq!(metrics.warm_pool_capacity.get(), 5);
1780 assert_eq!(
1781 metrics.warm_pool_initial_fill_duration.get_sample_count(),
1782 1
1783 );
1784 let _ = pool.drain().await;
1785 }
1786 Err(_) => {
1787 }
1790 }
1791 }
1792}