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;
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: Option<JoinHandle<()>>,
116 shutdown_tx: watch::Sender<bool>,
118 shutdown_rx: watch::Receiver<bool>,
120 scaler: Option<Arc<Mutex<PoolScaler>>>,
122 metrics: Option<crate::prom::RuntimeMetrics>,
124 boot_limiter: Arc<Semaphore>,
126 global_boot_limiter: Option<Arc<Semaphore>>,
128 template: Arc<Mutex<TemplateState>>,
134}
135
136#[derive(Clone)]
139struct PoolTemplate {
140 mem_file: String,
141 state_file: String,
142 rootfs_cache_key: Option<String>,
143}
144
145const MAX_TEMPLATE_BUILD_FAILURES: u32 = 3;
153
154enum TemplateState {
156 Unbuilt,
158 Ready(PoolTemplate),
160 Failing(u32),
164 Unavailable,
168}
169
170#[derive(Clone, Copy)]
171enum InitialFill {
172 Eager,
174 FirstReady,
176}
177
178impl WarmPool {
179 pub async fn start(
184 config: PoolConfig,
185 box_config: BoxConfig,
186 event_emitter: EventEmitter,
187 ) -> Result<Self> {
188 Self::start_with_metrics(config, box_config, event_emitter, None).await
189 }
190
191 pub async fn start_with_metrics(
197 config: PoolConfig,
198 box_config: BoxConfig,
199 event_emitter: EventEmitter,
200 metrics: Option<crate::prom::RuntimeMetrics>,
201 ) -> Result<Self> {
202 Self::start_with_metrics_and_boot_limiter(config, box_config, event_emitter, metrics, None)
203 .await
204 }
205
206 pub async fn start_with_metrics_and_boot_limiter(
213 config: PoolConfig,
214 box_config: BoxConfig,
215 event_emitter: EventEmitter,
216 metrics: Option<crate::prom::RuntimeMetrics>,
217 global_boot_limiter: Option<Arc<Semaphore>>,
218 ) -> Result<Self> {
219 Self::start_with_metrics_and_boot_limiter_with_fill(
220 config,
221 box_config,
222 event_emitter,
223 metrics,
224 global_boot_limiter,
225 InitialFill::Eager,
226 )
227 .await
228 }
229
230 pub async fn start_with_metrics_and_boot_limiter_first_ready(
238 config: PoolConfig,
239 box_config: BoxConfig,
240 event_emitter: EventEmitter,
241 metrics: Option<crate::prom::RuntimeMetrics>,
242 global_boot_limiter: Option<Arc<Semaphore>>,
243 ) -> Result<Self> {
244 Self::start_with_metrics_and_boot_limiter_with_fill(
245 config,
246 box_config,
247 event_emitter,
248 metrics,
249 global_boot_limiter,
250 InitialFill::FirstReady,
251 )
252 .await
253 }
254
255 async fn start_with_metrics_and_boot_limiter_with_fill(
256 config: PoolConfig,
257 box_config: BoxConfig,
258 event_emitter: EventEmitter,
259 metrics: Option<crate::prom::RuntimeMetrics>,
260 global_boot_limiter: Option<Arc<Semaphore>>,
261 initial_fill: InitialFill,
262 ) -> Result<Self> {
263 if config.max_size == 0 {
264 return Err(BoxError::PoolError(
265 "Pool max_size must be greater than 0".to_string(),
266 ));
267 }
268 if config.min_idle > config.max_size {
269 return Err(BoxError::PoolError(format!(
270 "Pool min_idle ({}) cannot exceed max_size ({})",
271 config.min_idle, config.max_size
272 )));
273 }
274 if config.max_concurrent_boots == 0 {
275 return Err(BoxError::PoolError(
276 "Pool max_concurrent_boots must be greater than 0".to_string(),
277 ));
278 }
279
280 let idle = Arc::new(Mutex::new(Vec::with_capacity(config.max_size)));
281 let stats = Arc::new(Mutex::new(PoolStats {
282 idle_count: 0,
283 total_created: 0,
284 total_acquired: 0,
285 total_released: 0,
286 total_evicted: 0,
287 }));
288 let (shutdown_tx, shutdown_rx) = watch::channel(false);
289
290 let scaler = if config.scaling.enabled {
291 Some(Arc::new(Mutex::new(PoolScaler::new(
292 config.scaling.clone(),
293 config.min_idle,
294 config.max_size,
295 ))))
296 } else {
297 None
298 };
299
300 let boot_limiter = Arc::new(Semaphore::new(config.max_concurrent_boots));
301 let mut pool = Self {
302 config,
303 box_config,
304 idle,
305 stats,
306 event_emitter,
307 replenish_handle: None,
308 shutdown_tx,
309 shutdown_rx,
310 scaler,
311 metrics,
312 boot_limiter,
313 global_boot_limiter,
314 template: Arc::new(Mutex::new(TemplateState::Unbuilt)),
315 };
316
317 if let Some(metrics) = &pool.metrics {
318 metrics.warm_pool_capacity.set(pool.config.max_size as i64);
319 }
320
321 let initial_target = match initial_fill {
324 InitialFill::Eager => pool.config.min_idle,
325 InitialFill::FirstReady => pool.config.min_idle.min(1),
326 };
327 let initial_fill_started = Instant::now();
328 pool.fill_to_target(initial_target).await;
329 if let Some(metrics) = &pool.metrics {
330 metrics
331 .warm_pool_initial_fill_duration
332 .observe(initial_fill_started.elapsed().as_secs_f64());
333 }
334
335 let handle = pool.spawn_maintenance_loop();
337 pool.replenish_handle = Some(handle);
338
339 tracing::info!(
340 min_idle = pool.config.min_idle,
341 max_size = pool.config.max_size,
342 idle_ttl_secs = pool.config.idle_ttl_secs,
343 "Warm pool started"
344 );
345
346 Ok(pool)
347 }
348
349 pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
351 metrics.warm_pool_capacity.set(self.config.max_size as i64);
352 metrics.warm_pool_size.set(
353 self.idle
354 .try_lock()
355 .map(|idle| idle.len() as i64)
356 .unwrap_or_default(),
357 );
358 self.metrics = Some(metrics);
359 }
360
361 fn sync_idle_metric(metrics: Option<&crate::prom::RuntimeMetrics>, idle_count: usize) {
362 if let Some(metrics) = metrics {
363 metrics.warm_pool_size.set(idle_count as i64);
364 }
365 }
366
367 pub async fn acquire(&self) -> Result<VmManager> {
372 {
374 let mut idle = self.idle.lock().await;
375 if let Some(warm_vm) = idle.pop() {
376 let mut stats = self.stats.lock().await;
377 stats.total_acquired += 1;
378 stats.idle_count = idle.len();
379
380 if let Some(ref scaler) = self.scaler {
382 scaler.lock().await.record_acquire(true);
383 }
384
385 if let Some(ref m) = self.metrics {
386 m.warm_pool_hits.inc();
387 m.warm_pool_size.set(idle.len() as i64);
388 }
389
390 self.event_emitter.emit(BoxEvent::with_string(
391 "pool.vm.acquired",
392 format!("Acquired VM {} from pool", warm_vm.vm.box_id()),
393 ));
394
395 tracing::debug!(
396 box_id = %warm_vm.vm.box_id(),
397 idle_remaining = idle.len(),
398 "Acquired VM from warm pool"
399 );
400
401 return Ok(warm_vm.vm);
402 }
403 }
404
405 tracing::info!("No idle VM in pool, booting on demand");
407
408 if let Some(ref scaler) = self.scaler {
410 scaler.lock().await.record_acquire(false);
411 }
412
413 if let Some(ref m) = self.metrics {
414 m.warm_pool_misses.inc();
415 }
416
417 let vm = self.boot_new_vm().await?;
418
419 let mut stats = self.stats.lock().await;
420 stats.total_acquired += 1;
421
422 Ok(vm)
423 }
424
425 pub async fn release(&self, vm: VmManager) -> Result<()> {
429 let mut idle = self.idle.lock().await;
430
431 if *self.shutdown_rx.borrow() {
436 drop(idle);
437 let mut vm = vm;
438 vm.destroy().await?;
439 return Ok(());
440 }
441
442 if idle.len() >= self.config.max_size {
443 drop(idle); let mut vm = vm;
446 vm.destroy().await?;
447
448 tracing::debug!(
449 box_id = %vm.box_id(),
450 "Pool full, destroyed released VM"
451 );
452 return Ok(());
453 }
454
455 let box_id = vm.box_id().to_string();
456 idle.push(WarmVm {
457 vm,
458 created_at: Instant::now(),
459 });
460
461 let mut stats = self.stats.lock().await;
462 stats.total_released += 1;
463 stats.idle_count = idle.len();
464
465 if let Some(ref m) = self.metrics {
466 m.warm_pool_size.set(idle.len() as i64);
467 }
468
469 self.event_emitter.emit(BoxEvent::with_string(
470 "pool.vm.released",
471 format!("Released VM {} back to pool", box_id),
472 ));
473
474 tracing::debug!(
475 box_id = %box_id,
476 idle_count = idle.len(),
477 "Released VM back to warm pool"
478 );
479
480 Ok(())
481 }
482
483 pub async fn stats(&self) -> PoolStats {
485 self.stats.lock().await.clone()
486 }
487
488 pub async fn idle_count(&self) -> usize {
490 self.idle.lock().await.len()
491 }
492
493 pub fn signal_shutdown(&self) {
497 let _ = self.shutdown_tx.send(true);
498 tracing::info!("Warm pool shutdown signaled");
499 }
500
501 pub async fn drain(&mut self) -> Result<()> {
503 let _ = self.shutdown_tx.send(true);
505
506 if let Some(handle) = self.replenish_handle.take() {
508 let _ = handle.await;
509 }
510
511 let idle_vms = {
515 let mut idle = self.idle.lock().await;
516 let idle_vms = idle.drain(..).collect::<Vec<_>>();
517 Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
518 idle_vms
519 };
520 let count = idle_vms.len();
521
522 for warm_vm in idle_vms {
523 let mut vm = warm_vm.vm;
524 if let Err(e) = vm.destroy().await {
525 tracing::warn!(
526 box_id = %vm.box_id(),
527 error = %e,
528 "Failed to destroy pooled VM during drain"
529 );
530 }
531 }
532
533 let mut stats = self.stats.lock().await;
534 stats.idle_count = 0;
535
536 self.event_emitter.emit(BoxEvent::empty("pool.drained"));
537
538 tracing::info!(destroyed = count, "Warm pool drained");
539
540 Ok(())
541 }
542
543 pub async fn drain_idle(&self) -> Result<()> {
548 let idle_vms = {
552 let mut idle = self.idle.lock().await;
553 let idle_vms = idle.drain(..).collect::<Vec<_>>();
554 Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
555 idle_vms
556 };
557 let count = idle_vms.len();
558 for warm_vm in idle_vms {
559 let mut vm = warm_vm.vm;
560 if let Err(e) = vm.destroy().await {
561 tracing::warn!(
562 box_id = %vm.box_id(),
563 error = %e,
564 "Failed to destroy pooled VM during drain_idle"
565 );
566 }
567 }
568 self.stats.lock().await.idle_count = 0;
569 tracing::info!(destroyed = count, "Warm pool idle VMs drained");
570 Ok(())
571 }
572
573 async fn remove_idle_vms(&self, box_ids: &[String]) {
578 let indices_to_remove: Vec<usize> = {
580 let idle = self.idle.lock().await;
581 idle.iter()
582 .enumerate()
583 .filter(|(_, wm)| box_ids.iter().any(|id| id == wm.vm.box_id()))
584 .map(|(i, _)| i)
585 .collect()
586 };
587
588 if indices_to_remove.is_empty() {
589 return;
590 }
591
592 let mut to_destroy: Vec<WarmVm> = Vec::new();
595 {
596 let mut idle = self.idle.lock().await;
597 for idx in indices_to_remove.into_iter().rev() {
598 if idx < idle.len() {
599 let warm_vm = idle.remove(idx);
600 to_destroy.push(warm_vm);
601 }
602 }
603 }
604
605 {
607 let idle_count = self.idle.lock().await.len();
608 if let Ok(mut stats) = self.stats.try_lock() {
609 stats.idle_count = idle_count;
610 }
611 Self::sync_idle_metric(self.metrics.as_ref(), idle_count);
612 }
613
614 for warm_vm in to_destroy {
616 let box_id = warm_vm.vm.box_id().to_string();
617 let mut vm = warm_vm.vm;
618 if let Err(e) = vm.destroy().await {
619 tracing::warn!(
620 box_id = %box_id,
621 error = %e,
622 "Failed to destroy VM during pool fill rollback"
623 );
624 } else {
625 tracing::debug!(box_id = %box_id, "Destroyed VM during pool fill rollback");
626 }
627 }
628 }
629
630 async fn boot_new_vm(&self) -> Result<VmManager> {
632 let _boot_permits =
633 acquire_boot_permits(self.boot_limiter.clone(), self.global_boot_limiter.clone())
634 .await?;
635 let _boot_guard = BootMetricGuard::new(self.metrics.clone());
636 let result = Self::boot_or_restore(
637 self.config.snapshot_fork,
638 &self.box_config,
639 &self.event_emitter,
640 &self.template,
641 )
642 .await;
643 if result.is_err() {
644 if let Some(metrics) = &self.metrics {
645 metrics.warm_pool_boot_failures_total.inc();
646 }
647 }
648 let vm = result?;
649
650 let mut stats = self.stats.lock().await;
651 stats.total_created += 1;
652
653 self.event_emitter.emit(BoxEvent::with_string(
654 "pool.vm.created",
655 format!("Booted new VM {}", vm.box_id()),
656 ));
657
658 Ok(vm)
659 }
660
661 fn boot_or_restore<'a>(
664 snapshot_fork: bool,
665 box_config: &'a BoxConfig,
666 event_emitter: &'a EventEmitter,
667 template: &'a Arc<Mutex<TemplateState>>,
668 ) -> BootVmFuture<'a> {
669 Box::pin(async move {
670 if snapshot_fork && crate::vm::native_snapshot_fork_supported() {
671 match Self::ensure_template(box_config, event_emitter, template).await {
676 Ok(tpl) => {
677 let mut cfg = box_config.clone();
678 cfg.snapshot_mem_file = Some(tpl.mem_file.clone());
679 cfg.restore_from = Some(tpl.state_file.clone());
680 cfg.snapshot_sock = None;
681 let mut vm = VmManager::new(cfg, event_emitter.clone());
682 vm.restore_rootfs_cache_key = tpl.rootfs_cache_key.clone();
683 let restored = async {
684 vm.boot().await?;
685 vm.wait_for_exec_available(std::time::Duration::from_secs(120))
686 .await
687 }
688 .await;
689 match restored {
690 Ok(()) => return Ok(vm),
691 Err(error) => {
692 let _ = vm.destroy_with_timeout(2000).await;
693 tracing::warn!(
694 %error,
695 "snapshot-fork restore failed; cold-booting this pool VM"
696 );
697 }
698 }
699 }
700 Err(error) => {
701 tracing::debug!(%error, "snapshot-fork unavailable; cold-booting this pool VM");
702 }
703 }
704 } else if snapshot_fork {
705 tracing::debug!(
706 "snapshot-fork is unavailable on this build; cold-booting without snapshot side effects"
707 );
708 }
709 let mut vm = VmManager::new(box_config.clone(), event_emitter.clone());
710 vm.boot().await?;
711 vm.wait_for_exec_available(std::time::Duration::from_secs(120))
712 .await?;
713 Ok(vm)
714 })
715 }
716
717 async fn boot_batch(
725 snapshot_fork: bool,
726 box_config: &BoxConfig,
727 event_emitter: &EventEmitter,
728 template: &Arc<Mutex<TemplateState>>,
729 needed: usize,
730 max_concurrent_boots: usize,
731 metrics: Option<crate::prom::RuntimeMetrics>,
732 boot_limiter: Arc<Semaphore>,
733 global_boot_limiter: Option<Arc<Semaphore>>,
734 ) -> Vec<Result<VmManager>> {
735 if needed == 0 {
736 return Vec::new();
737 }
738
739 let limit = bounded_boot_limit(needed, max_concurrent_boots);
740 let mut set = tokio::task::JoinSet::new();
741 let mut launched = 0usize;
742 let mut results = Vec::with_capacity(needed);
743
744 while launched < needed || !set.is_empty() {
745 while launched < needed && set.len() < limit {
746 let config = box_config.clone();
747 let emitter = event_emitter.clone();
748 let shared_template = Arc::clone(template);
749 let boot_metrics = metrics.clone();
750 let pool_boot_limiter = boot_limiter.clone();
751 let daemon_boot_limiter = global_boot_limiter.clone();
752 set.spawn(async move {
753 let _boot_permits =
754 acquire_boot_permits(pool_boot_limiter, daemon_boot_limiter).await?;
755 let _boot_guard = BootMetricGuard::new(boot_metrics);
756 WarmPool::boot_or_restore(snapshot_fork, &config, &emitter, &shared_template)
757 .await
758 });
759 launched += 1;
760 }
761
762 if let Some(result) = set.join_next().await {
763 let result = match result {
764 Ok(result) => result,
765 Err(error) => Err(BoxError::PoolError(format!(
766 "Warm-pool boot task failed: {error}"
767 ))),
768 };
769 if result.is_err() {
770 if let Some(metrics) = &metrics {
771 metrics.warm_pool_boot_failures_total.inc();
772 }
773 }
774 results.push(result);
775 }
776 }
777
778 results
779 }
780
781 async fn ensure_template(
788 box_config: &BoxConfig,
789 event_emitter: &EventEmitter,
790 template: &Arc<Mutex<TemplateState>>,
791 ) -> Result<PoolTemplate> {
792 if !crate::vm::native_snapshot_fork_supported() {
793 return Err(BoxError::PoolError(
794 "snapshot-fork requires the Linux x86_64 KVM build".to_string(),
795 ));
796 }
797 let mut guard = template.lock().await;
798 let prior_failures = match &*guard {
799 TemplateState::Ready(t) => return Ok(t.clone()),
800 TemplateState::Unavailable => {
801 return Err(BoxError::PoolError(
802 "snapshot-fork template unavailable (native VM snapshot unsupported)"
803 .to_string(),
804 ));
805 }
806 TemplateState::Failing(n) => *n,
808 TemplateState::Unbuilt => 0,
809 };
810
811 match Self::build_template(box_config, event_emitter).await {
812 Ok(tpl) => {
813 *guard = TemplateState::Ready(tpl.clone());
814 event_emitter.emit(BoxEvent::with_string(
815 "pool.template.built",
816 format!(
817 "Snapshot-fork template built for image {}",
818 box_config.image
819 ),
820 ));
821 Ok(tpl)
822 }
823 Err(error) => {
824 let failures = prior_failures + 1;
829 if failures >= MAX_TEMPLATE_BUILD_FAILURES {
830 tracing::warn!(
831 %error, failures,
832 "snapshot-fork template build failed repeatedly; marking \
833 unavailable — the warm pool will cold-boot"
834 );
835 *guard = TemplateState::Unavailable;
836 } else {
837 tracing::warn!(
838 %error, failures,
839 "snapshot-fork template build failed; will retry on a later fill"
840 );
841 *guard = TemplateState::Failing(failures);
842 }
843 Err(error)
844 }
845 }
846 }
847
848 async fn build_template(
851 box_config: &BoxConfig,
852 event_emitter: &EventEmitter,
853 ) -> Result<PoolTemplate> {
854 let dir = a3s_box_core::dirs_home().join("pool").join(format!(
855 "tpl-{:016x}",
856 crate::vm::fnv1a_hash(&box_config.image)
857 ));
858 std::fs::create_dir_all(&dir).map_err(BoxError::IoError)?;
859
860 let lock_target = dir.clone();
867 let _lock =
868 tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_target))
869 .await
870 .map_err(|e| BoxError::PoolError(format!("Template lock task failed: {e}")))?
871 .map_err(|e| BoxError::PoolError(format!("Failed to lock template dir: {e}")))?;
872
873 let mem_file = dir.join("template.ram");
874 let sock = dir.join("template.sock");
875 let state_file = dir.join("template.state");
876 let _ = std::fs::remove_file(&sock);
877
878 let mut cfg = box_config.clone();
880 cfg.snapshot_mem_file = Some(mem_file.to_string_lossy().into_owned());
881 cfg.snapshot_sock = Some(sock.to_string_lossy().into_owned());
882 cfg.restore_from = None;
883 let mut src = VmManager::new(cfg, event_emitter.clone());
884 src.boot().await?;
885 let rootfs_cache_key = match src.current_rootfs_cache_key() {
886 Ok(key) => key,
887 Err(error) => {
888 let _ = src.destroy_with_timeout(2000).await;
889 return Err(error);
890 }
891 };
892
893 let snapshot = Self::trigger_snapshot(&sock, &state_file).await;
902 let _ = src.destroy_with_timeout(2000).await;
903 snapshot?;
904
905 Ok(PoolTemplate {
906 mem_file: mem_file.to_string_lossy().into_owned(),
907 state_file: state_file.to_string_lossy().into_owned(),
908 rootfs_cache_key,
909 })
910 }
911
912 #[cfg(unix)]
918 async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
919 use tokio::io::{AsyncReadExt, AsyncWriteExt};
920 let mut stream = None;
922 for _ in 0..200 {
923 match tokio::net::UnixStream::connect(sock).await {
924 Ok(s) => {
925 stream = Some(s);
926 break;
927 }
928 Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
929 }
930 }
931 let mut stream = stream.ok_or_else(|| {
932 BoxError::PoolError(format!("snapshot socket {} never appeared", sock.display()))
933 })?;
934 let cmd = format!("snapshot {}\n", state_file.display());
935 stream
936 .write_all(cmd.as_bytes())
937 .await
938 .map_err(BoxError::IoError)?;
939 let mut buf = [0u8; 64];
940 let n = stream.read(&mut buf).await.map_err(BoxError::IoError)?;
941 let reply = String::from_utf8_lossy(&buf[..n]);
942 if reply.trim() == "ok" {
943 Ok(())
944 } else {
945 Err(BoxError::PoolError(format!(
946 "snapshot trigger failed: {}",
947 reply.trim()
948 )))
949 }
950 }
951
952 #[cfg(not(unix))]
956 async fn trigger_snapshot(
957 _sock: &std::path::Path,
958 _state_file: &std::path::Path,
959 ) -> Result<()> {
960 Err(BoxError::PoolError(
961 "snapshot-fork is only supported on Linux/KVM hosts".to_string(),
962 ))
963 }
964
965 async fn fill_to_target(&self, target: usize) {
967 let current = self.idle.lock().await.len();
968 let needed = target.saturating_sub(current);
969
970 if needed == 0 {
971 return;
972 }
973
974 tracing::debug!(current, needed, target, "Replenishing warm pool");
975
976 let mut added_ids: Vec<String> = Vec::new();
978 let mut failed = false;
979 let results = Self::boot_batch(
980 self.config.snapshot_fork,
981 &self.box_config,
982 &self.event_emitter,
983 &self.template,
984 needed,
985 self.config.max_concurrent_boots,
986 self.metrics.clone(),
987 self.boot_limiter.clone(),
988 self.global_boot_limiter.clone(),
989 )
990 .await;
991
992 for result in results {
993 match result {
994 Ok(vm) => {
995 let box_id = vm.box_id().to_string();
996 let mut idle = self.idle.lock().await;
997 idle.push(WarmVm {
998 vm,
999 created_at: Instant::now(),
1000 });
1001 Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
1002 let mut stats = self.stats.lock().await;
1003 stats.total_created += 1;
1004 stats.idle_count = idle.len();
1005 added_ids.push(box_id.clone());
1006
1007 self.event_emitter.emit(BoxEvent::with_string(
1008 "pool.vm.created",
1009 format!("Booted new VM {box_id}"),
1010 ));
1011 tracing::debug!(box_id = %box_id, "Added VM to warm pool");
1012 }
1013 Err(error) => {
1014 failed = true;
1015 tracing::warn!(error = %error, "Failed to boot VM for warm pool");
1016 }
1017 }
1018 }
1019
1020 if failed && !added_ids.is_empty() {
1021 tracing::info!(
1022 count = added_ids.len(),
1023 "Cleaning up VMs added before pool fill failed"
1024 );
1025 self.remove_idle_vms(&added_ids).await;
1026 }
1027
1028 self.event_emitter.emit(BoxEvent::empty("pool.replenish"));
1029 }
1030
1031 fn spawn_maintenance_loop(&self) -> JoinHandle<()> {
1038 let idle = Arc::clone(&self.idle);
1039 let stats = Arc::clone(&self.stats);
1040 let config = self.config.clone();
1041 let box_config = self.box_config.clone();
1042 let event_emitter = self.event_emitter.clone();
1043 let mut shutdown_rx = self.shutdown_rx.clone();
1044 let scaler = self.scaler.clone();
1045 let template = Arc::clone(&self.template);
1046 let metrics = self.metrics.clone();
1047 let boot_limiter = self.boot_limiter.clone();
1048 let global_boot_limiter = self.global_boot_limiter.clone();
1049
1050 tokio::spawn(async move {
1051 let check_interval = std::time::Duration::from_secs(
1052 if config.idle_ttl_secs > 0 {
1054 (config.idle_ttl_secs / 5).max(5)
1055 } else {
1056 30
1057 },
1058 );
1059 let mut maintenance = tokio::time::interval(check_interval);
1060 maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1063
1064 let mut effective_min_idle = config.min_idle;
1066 let mut replenish_failures = 0u32;
1070 let mut next_replenish_at = Instant::now();
1071
1072 loop {
1073 tokio::select! {
1074 result = shutdown_rx.changed() => {
1075 if result.is_ok() && *shutdown_rx.borrow() {
1076 tracing::debug!("Pool maintenance loop shutting down");
1077 break;
1078 }
1079 }
1080 _ = maintenance.tick() => {
1081 if config.idle_ttl_secs > 0 {
1083 Self::evict_expired_static(
1084 &idle,
1085 &stats,
1086 &event_emitter,
1087 metrics.as_ref(),
1088 config.idle_ttl_secs,
1089 ).await;
1090 }
1091
1092 if let Some(ref scaler) = scaler {
1094 let mut s = scaler.lock().await;
1095 let decision = s.evaluate();
1096 let new_min = s.current_min_idle();
1097 if new_min != effective_min_idle {
1098 tracing::info!(
1099 old_min_idle = effective_min_idle,
1100 new_min_idle = new_min,
1101 ?decision,
1102 "Autoscaler adjusted min_idle"
1103 );
1104 event_emitter.emit(BoxEvent::with_string(
1105 "pool.autoscale",
1106 format!(
1107 "min_idle adjusted {} → {} ({:?})",
1108 effective_min_idle, new_min, decision
1109 ),
1110 ));
1111 effective_min_idle = new_min;
1112 }
1113 }
1114
1115 let current = idle.lock().await.len();
1117 if current < effective_min_idle && Instant::now() >= next_replenish_at {
1118 let needed = effective_min_idle - current;
1119 tracing::debug!(current, needed, min_idle = effective_min_idle, "Replenishing warm pool");
1120
1121 let results = Self::boot_batch(
1124 config.snapshot_fork,
1125 &box_config,
1126 &event_emitter,
1127 &template,
1128 needed,
1129 config.max_concurrent_boots,
1130 metrics.clone(),
1131 boot_limiter.clone(),
1132 global_boot_limiter.clone(),
1133 )
1134 .await;
1135 let mut batch_failed = false;
1136 for result in results {
1137 match result {
1138 Ok(mut vm) => {
1139 let box_id = vm.box_id().to_string();
1140 let mut pool = idle.lock().await;
1151 if *shutdown_rx.borrow() {
1152 drop(pool);
1153 tracing::debug!(
1154 box_id = %box_id,
1155 "Pool shutting down mid-replenish; destroying freshly-booted VM"
1156 );
1157 let _ = vm.destroy_with_timeout(2000).await;
1158 continue;
1159 }
1160 pool.push(WarmVm {
1161 vm,
1162 created_at: Instant::now(),
1163 });
1164 Self::sync_idle_metric(metrics.as_ref(), pool.len());
1165 let mut s = stats.lock().await;
1166 s.total_created += 1;
1167 s.idle_count = pool.len();
1168 drop(s);
1169 drop(pool);
1170
1171 event_emitter.emit(BoxEvent::with_string(
1172 "pool.vm.created",
1173 format!("Replenished VM {}", box_id),
1174 ));
1175 }
1176 Err(error) => {
1177 batch_failed = true;
1178 tracing::warn!(error = %error, "Failed to replenish warm pool");
1179 }
1180 }
1181 }
1182
1183 if batch_failed {
1184 replenish_failures = replenish_failures.saturating_add(1);
1185 let delay = replenish_backoff_delay(
1186 replenish_failures,
1187 check_interval,
1188 );
1189 next_replenish_at = Instant::now() + delay;
1190 tracing::warn!(
1191 failures = replenish_failures,
1192 retry_in_secs = delay.as_secs(),
1193 "Backing off warm-pool replenishment after boot failure"
1194 );
1195 } else {
1196 replenish_failures = 0;
1197 next_replenish_at = Instant::now();
1198 }
1199
1200 event_emitter.emit(BoxEvent::empty("pool.replenish"));
1201 }
1202 }
1203 }
1204 }
1205 })
1206 }
1207
1208 async fn evict_expired_static(
1210 idle: &Arc<Mutex<Vec<WarmVm>>>,
1211 stats: &Arc<Mutex<PoolStats>>,
1212 event_emitter: &EventEmitter,
1213 metrics: Option<&crate::prom::RuntimeMetrics>,
1214 idle_ttl_secs: u64,
1215 ) {
1216 let ttl = std::time::Duration::from_secs(idle_ttl_secs);
1217
1218 let mut pool = idle.lock().await;
1219 let mut kept = Vec::new();
1220 let mut expired = Vec::new();
1221
1222 for warm_vm in pool.drain(..) {
1223 if warm_vm.created_at.elapsed() > ttl {
1224 expired.push(warm_vm);
1225 } else {
1226 kept.push(warm_vm);
1227 }
1228 }
1229 *pool = kept;
1230 let after_count = pool.len();
1231 drop(pool);
1232
1233 let evicted_count = expired.len();
1234 Self::sync_idle_metric(metrics, after_count);
1235 for warm_vm in expired {
1236 let mut vm = warm_vm.vm;
1237 let _ = vm.destroy().await;
1238 }
1239
1240 if evicted_count > 0 {
1241 let mut s = stats.lock().await;
1242 s.total_evicted += evicted_count as u64;
1243 s.idle_count = after_count;
1244
1245 event_emitter.emit(BoxEvent::with_string(
1246 "pool.vm.evicted",
1247 format!("Evicted {} expired VMs", evicted_count),
1248 ));
1249 }
1250 }
1251}
1252
1253fn bounded_boot_limit(needed: usize, max_concurrent_boots: usize) -> usize {
1259 needed.min(max_concurrent_boots.max(1))
1260}
1261
1262fn replenish_backoff_delay(failures: u32, check_interval: Duration) -> Duration {
1269 let exponent = failures.saturating_sub(1).min(8);
1270 let multiplier = 1u64 << exponent;
1271 let delay_secs = check_interval
1272 .as_secs()
1273 .max(1)
1274 .saturating_mul(multiplier)
1275 .min(300);
1276 Duration::from_secs(delay_secs)
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use a3s_box_core::config::PoolConfig;
1283
1284 fn test_pool_config(min_idle: usize, max_size: usize) -> PoolConfig {
1285 PoolConfig {
1286 enabled: true,
1287 min_idle,
1288 max_size,
1289 idle_ttl_secs: 300,
1290 ..Default::default()
1291 }
1292 }
1293
1294 fn test_event_emitter() -> EventEmitter {
1295 EventEmitter::new(100)
1296 }
1297
1298 #[test]
1299 fn boot_or_restore_future_stays_heap_indirected() {
1300 let config = BoxConfig::default();
1301 let emitter = test_event_emitter();
1302 let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
1303 let future = WarmPool::boot_or_restore(false, &config, &emitter, &template);
1304
1305 assert!(
1306 std::mem::size_of_val(&future) <= 2 * std::mem::size_of::<usize>(),
1307 "boot_or_restore future must remain pointer-sized so pool misses fit on Tokio worker stacks; got {} bytes",
1308 std::mem::size_of_val(&future)
1309 );
1310 }
1311
1312 #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
1313 #[tokio::test]
1314 async fn unsupported_snapshot_fork_is_rejected_before_template_construction() {
1315 let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
1316 let result =
1317 WarmPool::ensure_template(&BoxConfig::default(), &test_event_emitter(), &template)
1318 .await;
1319 let error = match result {
1320 Ok(_) => panic!("unsupported host unexpectedly built a snapshot template"),
1321 Err(error) => error.to_string(),
1322 };
1323
1324 assert!(error.contains("Linux x86_64 KVM"), "{error}");
1325 assert!(matches!(&*template.lock().await, TemplateState::Unbuilt));
1326 }
1327
1328 #[tokio::test]
1331 async fn test_pool_rejects_zero_max_size() {
1332 let config = test_pool_config(0, 0);
1333 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1334 match result {
1335 Err(e) => assert!(e.to_string().contains("max_size must be greater than 0")),
1336 Ok(_) => panic!("Expected error for zero max_size"),
1337 }
1338 }
1339
1340 #[tokio::test]
1341 async fn test_pool_rejects_min_idle_exceeds_max() {
1342 let config = test_pool_config(10, 5);
1343 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1344 match result {
1345 Err(e) => assert!(e.to_string().contains("cannot exceed max_size")),
1346 Ok(_) => panic!("Expected error for min_idle > max_size"),
1347 }
1348 }
1349
1350 #[tokio::test]
1351 async fn test_pool_rejects_zero_max_concurrent_boots() {
1352 let mut config = test_pool_config(0, 1);
1353 config.max_concurrent_boots = 0;
1354 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1355 match result {
1356 Err(error) => assert!(error.to_string().contains("max_concurrent_boots")),
1357 Ok(_) => panic!("Expected error for zero max_concurrent_boots"),
1358 }
1359 }
1360
1361 #[tokio::test]
1362 async fn acquire_boot_permits_releases_both_scopes() {
1363 let pool_limiter = Arc::new(Semaphore::new(1));
1364 let global_limiter = Arc::new(Semaphore::new(1));
1365 let (pool_permit, global_permit) =
1366 acquire_boot_permits(pool_limiter.clone(), Some(global_limiter.clone()))
1367 .await
1368 .expect("both boot limiters should grant a permit");
1369 assert_eq!(pool_limiter.available_permits(), 0);
1370 assert_eq!(global_limiter.available_permits(), 0);
1371 drop((pool_permit, global_permit));
1372 assert_eq!(pool_limiter.available_permits(), 1);
1373 assert_eq!(global_limiter.available_permits(), 1);
1374 }
1375
1376 #[test]
1377 fn boot_batch_limit_is_bounded_and_never_deadlocks() {
1378 assert_eq!(bounded_boot_limit(0, 2), 0);
1379 assert_eq!(bounded_boot_limit(8, 2), 2);
1380 assert_eq!(bounded_boot_limit(2, 8), 2);
1381 assert_eq!(bounded_boot_limit(8, 0), 1);
1382 }
1383
1384 #[test]
1385 fn replenish_backoff_is_exponential_and_capped() {
1386 let base = Duration::from_secs(5);
1387 assert_eq!(replenish_backoff_delay(0, base), Duration::from_secs(5));
1388 assert_eq!(replenish_backoff_delay(1, base), Duration::from_secs(5));
1389 assert_eq!(replenish_backoff_delay(2, base), Duration::from_secs(10));
1390 assert_eq!(replenish_backoff_delay(7, base), Duration::from_secs(300));
1391 assert_eq!(replenish_backoff_delay(20, base), Duration::from_secs(300));
1392 }
1393
1394 #[test]
1395 fn boot_metric_guard_balances_inflight_gauge() {
1396 let metrics = crate::prom::RuntimeMetrics::new();
1397 {
1398 let _guard = BootMetricGuard::new(Some(metrics.clone()));
1399 assert_eq!(metrics.warm_pool_boots_inflight.get(), 1);
1400 }
1401 assert_eq!(metrics.warm_pool_boots_inflight.get(), 0);
1402 }
1403
1404 #[test]
1407 fn test_pool_stats_default() {
1408 let stats = PoolStats {
1409 idle_count: 0,
1410 total_created: 0,
1411 total_acquired: 0,
1412 total_released: 0,
1413 total_evicted: 0,
1414 };
1415 assert_eq!(stats.idle_count, 0);
1416 assert_eq!(stats.total_created, 0);
1417 }
1418
1419 #[test]
1420 fn test_pool_stats_clone() {
1421 let stats = PoolStats {
1422 idle_count: 3,
1423 total_created: 10,
1424 total_acquired: 7,
1425 total_released: 5,
1426 total_evicted: 2,
1427 };
1428 let cloned = stats.clone();
1429 assert_eq!(cloned.idle_count, 3);
1430 assert_eq!(cloned.total_created, 10);
1431 assert_eq!(cloned.total_acquired, 7);
1432 assert_eq!(cloned.total_released, 5);
1433 assert_eq!(cloned.total_evicted, 2);
1434 }
1435
1436 #[test]
1437 fn test_pool_stats_debug() {
1438 let stats = PoolStats {
1439 idle_count: 1,
1440 total_created: 2,
1441 total_acquired: 3,
1442 total_released: 4,
1443 total_evicted: 5,
1444 };
1445 let debug = format!("{:?}", stats);
1446 assert!(debug.contains("idle_count"));
1447 assert!(debug.contains("total_created"));
1448 }
1449
1450 #[test]
1453 fn test_pool_config_roundtrip() {
1454 let config = PoolConfig {
1455 enabled: true,
1456 min_idle: 3,
1457 max_size: 10,
1458 idle_ttl_secs: 600,
1459 ..Default::default()
1460 };
1461
1462 let json = serde_json::to_string(&config).unwrap();
1463 let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
1464
1465 assert!(parsed.enabled);
1466 assert_eq!(parsed.min_idle, 3);
1467 assert_eq!(parsed.max_size, 10);
1468 assert_eq!(parsed.idle_ttl_secs, 600);
1469 }
1470
1471 #[test]
1472 fn test_pool_config_default_values() {
1473 let config = PoolConfig::default();
1474 assert!(!config.enabled);
1475 assert_eq!(config.min_idle, 1);
1476 assert_eq!(config.max_size, 5);
1477 assert_eq!(config.idle_ttl_secs, 300);
1478 }
1479
1480 #[test]
1481 fn test_pool_config_deserialization_with_defaults() {
1482 let json = r#"{"enabled": true}"#;
1483 let config: PoolConfig = serde_json::from_str(json).unwrap();
1484 assert!(config.enabled);
1485 assert_eq!(config.min_idle, 1);
1486 assert_eq!(config.max_size, 5);
1487 assert_eq!(config.idle_ttl_secs, 300);
1488 }
1489
1490 #[tokio::test]
1493 async fn test_pool_accepts_min_idle_equals_max() {
1494 let config = test_pool_config(3, 3);
1495 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1498 match result {
1500 Err(e) => assert!(!e.to_string().contains("cannot exceed max_size")),
1501 Ok(mut pool) => {
1502 let _ = pool.drain().await;
1503 }
1504 }
1505 }
1506
1507 #[tokio::test]
1508 async fn test_pool_accepts_min_idle_zero() {
1509 let config = test_pool_config(0, 5);
1510 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1512 match result {
1513 Ok(mut pool) => {
1514 assert_eq!(pool.idle_count().await, 0);
1516 let stats = pool.stats().await;
1517 assert_eq!(stats.idle_count, 0);
1518 assert_eq!(stats.total_created, 0);
1519 let _ = pool.drain().await;
1520 }
1521 Err(e) => {
1522 assert!(!e.to_string().contains("max_size"));
1524 assert!(!e.to_string().contains("min_idle"));
1525 }
1526 }
1527 }
1528
1529 #[tokio::test]
1532 async fn test_pool_stats_initial() {
1533 let config = test_pool_config(0, 5);
1534 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1535 if let Ok(mut pool) = result {
1536 let stats = pool.stats().await;
1537 assert_eq!(stats.idle_count, 0);
1538 assert_eq!(stats.total_created, 0);
1539 assert_eq!(stats.total_acquired, 0);
1540 assert_eq!(stats.total_released, 0);
1541 assert_eq!(stats.total_evicted, 0);
1542 let _ = pool.drain().await;
1543 }
1544 }
1545
1546 #[tokio::test]
1547 async fn test_pool_idle_count_initial() {
1548 let config = test_pool_config(0, 5);
1549 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1550 if let Ok(mut pool) = result {
1551 assert_eq!(pool.idle_count().await, 0);
1552 let _ = pool.drain().await;
1553 }
1554 }
1555
1556 #[tokio::test]
1557 async fn test_pool_drain_empty_pool() {
1558 let config = test_pool_config(0, 5);
1559 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1560 if let Ok(mut pool) = result {
1561 let drain_result = pool.drain().await;
1563 assert!(drain_result.is_ok());
1564
1565 let stats = pool.stats().await;
1566 assert_eq!(stats.idle_count, 0);
1567 }
1568 }
1569
1570 #[tokio::test]
1571 async fn test_pool_drain_emits_event() {
1572 let emitter = test_event_emitter();
1573 let mut receiver = emitter.subscribe();
1574 let config = test_pool_config(0, 5);
1575
1576 let result = WarmPool::start(config, BoxConfig::default(), emitter).await;
1577 if let Ok(mut pool) = result {
1578 pool.drain().await.unwrap();
1579
1580 let mut found_drain_event = false;
1582 while let Ok(event) = receiver.try_recv() {
1584 if event.key == "pool.drained" {
1585 found_drain_event = true;
1586 }
1587 }
1588 assert!(found_drain_event, "Expected pool.drained event");
1589 }
1590 }
1591
1592 #[tokio::test]
1593 async fn test_pool_acquire_from_empty_pool_fails_without_shim() {
1594 let config = test_pool_config(0, 5);
1595 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1596 if let Ok(pool) = result {
1597 let acquire_result = pool.acquire().await;
1600 assert!(acquire_result.is_err());
1601 }
1602 }
1603
1604 #[test]
1607 #[allow(clippy::unnecessary_min_or_max)]
1608 fn test_maintenance_check_interval_with_ttl() {
1609 let interval = if 300_u64 > 0 {
1611 (300_u64 / 5).max(5)
1612 } else {
1613 30
1614 };
1615 assert_eq!(interval, 60);
1616 }
1617
1618 #[test]
1619 #[allow(clippy::unnecessary_min_or_max)]
1620 fn test_maintenance_check_interval_short_ttl() {
1621 let interval = if 10_u64 > 0 { (10_u64 / 5).max(5) } else { 30 };
1623 assert_eq!(interval, 5);
1624 }
1625
1626 #[test]
1627 #[allow(clippy::unnecessary_min_or_max)]
1628 fn test_maintenance_check_interval_very_short_ttl() {
1629 let interval = if 1_u64 > 0 { (1_u64 / 5).max(5) } else { 30 };
1631 assert_eq!(interval, 5);
1632 }
1633
1634 #[test]
1635 #[allow(
1636 clippy::absurd_extreme_comparisons,
1637 clippy::erasing_op,
1638 clippy::unnecessary_min_or_max,
1639 unused_comparisons
1640 )]
1641 fn test_maintenance_check_interval_no_ttl() {
1642 let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
1644 assert_eq!(interval, 30);
1645 }
1646
1647 #[test]
1650 fn test_warm_vm_created_at_is_recent() {
1651 let before = Instant::now();
1652 let created_at = Instant::now();
1653 let after = Instant::now();
1654
1655 assert!(created_at >= before);
1656 assert!(created_at <= after);
1657 }
1658
1659 #[test]
1662 fn test_pool_stats_all_fields() {
1663 let stats = PoolStats {
1664 idle_count: 10,
1665 total_created: 100,
1666 total_acquired: 80,
1667 total_released: 70,
1668 total_evicted: 15,
1669 };
1670
1671 assert_eq!(stats.idle_count, 10);
1672 assert_eq!(stats.total_created, 100);
1673 assert_eq!(stats.total_acquired, 80);
1674 assert_eq!(stats.total_released, 70);
1675 assert_eq!(stats.total_evicted, 15);
1676
1677 let debug = format!("{:?}", stats);
1679 assert!(debug.contains("10"));
1680 assert!(debug.contains("100"));
1681 assert!(debug.contains("80"));
1682 assert!(debug.contains("70"));
1683 assert!(debug.contains("15"));
1684 }
1685
1686 #[tokio::test]
1693 async fn test_pool_set_metrics_attaches() {
1694 let config = test_pool_config(0, 5);
1695 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1696 match result {
1697 Ok(mut pool) => {
1698 let metrics = crate::prom::RuntimeMetrics::new();
1699 pool.set_metrics(metrics.clone());
1700 assert!(pool.metrics.is_some());
1701 assert_eq!(metrics.warm_pool_hits.get(), 0);
1703 assert_eq!(metrics.warm_pool_misses.get(), 0);
1704 assert_eq!(metrics.warm_pool_size.get(), 0);
1705 let _ = pool.drain().await;
1706 }
1707 Err(_) => {
1708 }
1710 }
1711 }
1712
1713 #[tokio::test]
1714 async fn test_pool_start_with_metrics_installs_sink_before_fill() {
1715 let config = test_pool_config(0, 5);
1716 let metrics = crate::prom::RuntimeMetrics::new();
1717 let result = WarmPool::start_with_metrics(
1718 config,
1719 BoxConfig::default(),
1720 test_event_emitter(),
1721 Some(metrics.clone()),
1722 )
1723 .await;
1724
1725 match result {
1726 Ok(mut pool) => {
1727 assert!(pool.metrics.is_some());
1728 assert_eq!(metrics.warm_pool_capacity.get(), 5);
1729 assert_eq!(
1730 metrics.warm_pool_initial_fill_duration.get_sample_count(),
1731 1
1732 );
1733 let _ = pool.drain().await;
1734 }
1735 Err(_) => {
1736 }
1739 }
1740 }
1741}