Skip to main content

a3s_box_runtime/pool/
warm_pool.rs

1//! WarmPool — Pre-warmed pool of ready-to-use MicroVMs.
2//!
3//! Maintains a set of pre-booted VMs in `Ready` state so that
4//! `acquire()` can return a VM instantly without waiting for boot.
5
6use std::sync::Arc;
7use std::time::Instant;
8
9use a3s_box_core::config::{BoxConfig, PoolConfig};
10use a3s_box_core::error::{BoxError, Result};
11use a3s_box_core::event::{BoxEvent, EventEmitter};
12use tokio::sync::watch;
13use tokio::sync::Mutex;
14use tokio::task::JoinHandle;
15
16use crate::pool::scaler::PoolScaler;
17use crate::vm::VmManager;
18
19/// A pre-warmed VM waiting in the pool.
20struct WarmVm {
21    /// The ready VM manager instance.
22    vm: VmManager,
23    /// When this VM was added to the pool.
24    created_at: Instant,
25}
26
27/// Statistics about the warm pool.
28#[derive(Debug, Clone)]
29pub struct PoolStats {
30    /// Number of idle VMs ready for acquisition.
31    pub idle_count: usize,
32    /// Total number of VMs created by this pool (including acquired ones).
33    pub total_created: u64,
34    /// Total number of VMs acquired from the pool.
35    pub total_acquired: u64,
36    /// Total number of VMs released back to the pool.
37    pub total_released: u64,
38    /// Total number of VMs evicted due to idle TTL.
39    pub total_evicted: u64,
40}
41
42/// A pre-warmed pool of ready-to-use MicroVMs.
43///
44/// The pool maintains `min_idle` VMs in `Ready` state. When a VM is
45/// acquired, the pool spawns a replacement in the background. Idle VMs
46/// that exceed `idle_ttl_secs` are automatically evicted.
47///
48/// # Usage
49///
50/// ```ignore
51/// let pool = WarmPool::start(pool_config, box_config, emitter).await?;
52/// let vm = pool.acquire().await?;  // Instant if pool has capacity
53/// // ... use vm ...
54/// pool.release(vm).await?;         // Return to pool or destroy
55/// pool.drain().await?;             // Graceful shutdown
56/// ```
57pub struct WarmPool {
58    /// Pool configuration.
59    config: PoolConfig,
60    /// Base BoxConfig template for creating new VMs.
61    box_config: BoxConfig,
62    /// Idle VMs ready for acquisition.
63    idle: Arc<Mutex<Vec<WarmVm>>>,
64    /// Pool statistics.
65    stats: Arc<Mutex<PoolStats>>,
66    /// Event emitter for pool lifecycle events.
67    event_emitter: EventEmitter,
68    /// Background replenishment task handle.
69    replenish_handle: Option<JoinHandle<()>>,
70    /// Shutdown signal sender.
71    shutdown_tx: watch::Sender<bool>,
72    /// Shutdown signal receiver (cloned for background task).
73    shutdown_rx: watch::Receiver<bool>,
74    /// Autoscaler for dynamic min_idle adjustment (None if scaling disabled).
75    scaler: Option<Arc<Mutex<PoolScaler>>>,
76    /// Prometheus metrics (optional).
77    metrics: Option<crate::prom::RuntimeMetrics>,
78    /// Snapshot-fork template state (built lazily on first fill when
79    /// `config.snapshot_fork`): the file-backed RAM image + state file every other
80    /// pool VM restores from. Caches an `Unavailable` verdict so a build failure
81    /// (native VM snapshot unsupported on this build) is not re-attempted on every
82    /// fill — the pool cold-boots instead.
83    template: Arc<Mutex<TemplateState>>,
84}
85
86/// A built snapshot-fork template: the shared RAM image + state file that pool VMs
87/// restore from (MAP_PRIVATE CoW of the RAM file).
88#[derive(Clone)]
89struct PoolTemplate {
90    mem_file: String,
91    state_file: String,
92}
93
94/// How many consecutive template-build failures are tolerated before the
95/// verdict becomes permanently `Unavailable`. A transient failure (host
96/// resource pressure, a source VM slow to bind its snapshot socket) presents
97/// identically to "snapshot unsupported by this libkrun build" ("snapshot
98/// socket never appeared"), so a bounded retry avoids permanently downgrading
99/// the whole pool to cold-boot on a one-off hiccup, while still giving up on a
100/// genuinely-unsupported host after a few attempts.
101const MAX_TEMPLATE_BUILD_FAILURES: u32 = 3;
102
103/// Cached state of the snapshot-fork template.
104enum TemplateState {
105    /// Not built yet — the first snapshot-fork fill attempts the build.
106    Unbuilt,
107    /// Built and ready; pool VMs restore from it.
108    Ready(PoolTemplate),
109    /// The last build failed but is still retryable; carries the consecutive
110    /// failure count. A later fill retries until it reaches
111    /// `MAX_TEMPLATE_BUILD_FAILURES`, then it becomes `Unavailable`.
112    Failing(u32),
113    /// The build failed permanently (native VM snapshot unavailable on this
114    /// build/platform, or too many consecutive failures). Cached so it is not
115    /// retried — `boot_or_restore` cold-boots instead.
116    Unavailable,
117}
118
119impl WarmPool {
120    /// Create and start the warm pool.
121    ///
122    /// Spawns `min_idle` VMs in the background and starts the
123    /// replenishment/eviction loop.
124    pub async fn start(
125        config: PoolConfig,
126        box_config: BoxConfig,
127        event_emitter: EventEmitter,
128    ) -> Result<Self> {
129        if config.max_size == 0 {
130            return Err(BoxError::PoolError(
131                "Pool max_size must be greater than 0".to_string(),
132            ));
133        }
134        if config.min_idle > config.max_size {
135            return Err(BoxError::PoolError(format!(
136                "Pool min_idle ({}) cannot exceed max_size ({})",
137                config.min_idle, config.max_size
138            )));
139        }
140
141        let idle = Arc::new(Mutex::new(Vec::with_capacity(config.max_size)));
142        let stats = Arc::new(Mutex::new(PoolStats {
143            idle_count: 0,
144            total_created: 0,
145            total_acquired: 0,
146            total_released: 0,
147            total_evicted: 0,
148        }));
149        let (shutdown_tx, shutdown_rx) = watch::channel(false);
150
151        let scaler = if config.scaling.enabled {
152            Some(Arc::new(Mutex::new(PoolScaler::new(
153                config.scaling.clone(),
154                config.min_idle,
155                config.max_size,
156            ))))
157        } else {
158            None
159        };
160
161        let mut pool = Self {
162            config,
163            box_config,
164            idle,
165            stats,
166            event_emitter,
167            replenish_handle: None,
168            shutdown_tx,
169            shutdown_rx,
170            scaler,
171            metrics: None,
172            template: Arc::new(Mutex::new(TemplateState::Unbuilt)),
173        };
174
175        // Initial fill
176        pool.fill_to_min().await;
177
178        // Start background maintenance loop
179        let handle = pool.spawn_maintenance_loop();
180        pool.replenish_handle = Some(handle);
181
182        tracing::info!(
183            min_idle = pool.config.min_idle,
184            max_size = pool.config.max_size,
185            idle_ttl_secs = pool.config.idle_ttl_secs,
186            "Warm pool started"
187        );
188
189        Ok(pool)
190    }
191
192    /// Attach Prometheus metrics to this pool.
193    pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
194        metrics.warm_pool_capacity.set(self.config.max_size as i64);
195        self.metrics = Some(metrics);
196    }
197
198    /// Acquire a ready VM from the pool.
199    ///
200    /// If an idle VM is available, returns it immediately.
201    /// Otherwise, boots a new VM on demand (slower path).
202    pub async fn acquire(&self) -> Result<VmManager> {
203        // Try to pop an idle VM
204        {
205            let mut idle = self.idle.lock().await;
206            if let Some(warm_vm) = idle.pop() {
207                let mut stats = self.stats.lock().await;
208                stats.total_acquired += 1;
209                stats.idle_count = idle.len();
210
211                // Record hit for autoscaler
212                if let Some(ref scaler) = self.scaler {
213                    scaler.lock().await.record_acquire(true);
214                }
215
216                if let Some(ref m) = self.metrics {
217                    m.warm_pool_hits.inc();
218                    m.warm_pool_size.set(idle.len() as i64);
219                }
220
221                self.event_emitter.emit(BoxEvent::with_string(
222                    "pool.vm.acquired",
223                    format!("Acquired VM {} from pool", warm_vm.vm.box_id()),
224                ));
225
226                tracing::debug!(
227                    box_id = %warm_vm.vm.box_id(),
228                    idle_remaining = idle.len(),
229                    "Acquired VM from warm pool"
230                );
231
232                return Ok(warm_vm.vm);
233            }
234        }
235
236        // No idle VM available — boot one on demand (miss)
237        tracing::info!("No idle VM in pool, booting on demand");
238
239        // Record miss for autoscaler
240        if let Some(ref scaler) = self.scaler {
241            scaler.lock().await.record_acquire(false);
242        }
243
244        if let Some(ref m) = self.metrics {
245            m.warm_pool_misses.inc();
246        }
247
248        let vm = self.boot_new_vm().await?;
249
250        let mut stats = self.stats.lock().await;
251        stats.total_acquired += 1;
252
253        Ok(vm)
254    }
255
256    /// Release a VM back to the pool.
257    ///
258    /// If the pool is at capacity, the VM is destroyed instead.
259    pub async fn release(&self, vm: VmManager) -> Result<()> {
260        let mut idle = self.idle.lock().await;
261
262        // Don't return a VM to a pool that is shutting down: drain_idle has (or
263        // soon will have) cleared `idle` and won't run again, so a push here leaks
264        // the VM (no Drop reaper). Checked under the idle lock so it is atomic with
265        // a concurrent drain_idle. Destroy the VM instead.
266        if *self.shutdown_rx.borrow() {
267            drop(idle);
268            let mut vm = vm;
269            vm.destroy().await?;
270            return Ok(());
271        }
272
273        if idle.len() >= self.config.max_size {
274            // Pool is full — destroy the VM
275            drop(idle); // Release lock before async destroy
276            let mut vm = vm;
277            vm.destroy().await?;
278
279            tracing::debug!(
280                box_id = %vm.box_id(),
281                "Pool full, destroyed released VM"
282            );
283            return Ok(());
284        }
285
286        let box_id = vm.box_id().to_string();
287        idle.push(WarmVm {
288            vm,
289            created_at: Instant::now(),
290        });
291
292        let mut stats = self.stats.lock().await;
293        stats.total_released += 1;
294        stats.idle_count = idle.len();
295
296        if let Some(ref m) = self.metrics {
297            m.warm_pool_size.set(idle.len() as i64);
298        }
299
300        self.event_emitter.emit(BoxEvent::with_string(
301            "pool.vm.released",
302            format!("Released VM {} back to pool", box_id),
303        ));
304
305        tracing::debug!(
306            box_id = %box_id,
307            idle_count = idle.len(),
308            "Released VM back to warm pool"
309        );
310
311        Ok(())
312    }
313
314    /// Get current pool statistics.
315    pub async fn stats(&self) -> PoolStats {
316        self.stats.lock().await.clone()
317    }
318
319    /// Get the number of idle VMs currently in the pool.
320    pub async fn idle_count(&self) -> usize {
321        self.idle.lock().await.len()
322    }
323
324    /// Signal the pool to shutdown. This signals the background task to stop
325    /// replenishing and sets the shutdown flag. VMs will continue to exist
326    /// until the pool is drained or dropped.
327    pub fn signal_shutdown(&self) {
328        let _ = self.shutdown_tx.send(true);
329        tracing::info!("Warm pool shutdown signaled");
330    }
331
332    /// Gracefully drain all VMs and stop the pool.
333    pub async fn drain(&mut self) -> Result<()> {
334        // Signal shutdown to background task
335        let _ = self.shutdown_tx.send(true);
336
337        // Wait for background task to finish
338        if let Some(handle) = self.replenish_handle.take() {
339            let _ = handle.await;
340        }
341
342        // Destroy all idle VMs
343        let mut idle = self.idle.lock().await;
344        let count = idle.len();
345
346        for warm_vm in idle.drain(..) {
347            let mut vm = warm_vm.vm;
348            if let Err(e) = vm.destroy().await {
349                tracing::warn!(
350                    box_id = %vm.box_id(),
351                    error = %e,
352                    "Failed to destroy pooled VM during drain"
353                );
354            }
355        }
356
357        let mut stats = self.stats.lock().await;
358        stats.idle_count = 0;
359
360        self.event_emitter.emit(BoxEvent::empty("pool.drained"));
361
362        tracing::info!(destroyed = count, "Warm pool drained");
363
364        Ok(())
365    }
366
367    /// Destroy all idle VMs without consuming the pool (`&self`), so it can be
368    /// shut down from behind an `Arc` (e.g. a daemon serving concurrent requests).
369    /// Pair with [`Self::signal_shutdown`] first to stop the background replenisher;
370    /// its task then exits on its own (it watches the shutdown channel).
371    pub async fn drain_idle(&self) -> Result<()> {
372        let mut idle = self.idle.lock().await;
373        let count = idle.len();
374        for warm_vm in idle.drain(..) {
375            let mut vm = warm_vm.vm;
376            if let Err(e) = vm.destroy().await {
377                tracing::warn!(
378                    box_id = %vm.box_id(),
379                    error = %e,
380                    "Failed to destroy pooled VM during drain_idle"
381                );
382            }
383        }
384        self.stats.lock().await.idle_count = 0;
385        tracing::info!(destroyed = count, "Warm pool idle VMs drained");
386        Ok(())
387    }
388
389    /// Remove and destroy specific idle VMs by their box IDs.
390    ///
391    /// Used when `fill_to_min` partially fails and needs to rollback
392    /// successfully added VMs.
393    async fn remove_idle_vms(&self, box_ids: &[String]) {
394        // First pass: collect indices of VMs to remove
395        let indices_to_remove: Vec<usize> = {
396            let idle = self.idle.lock().await;
397            idle.iter()
398                .enumerate()
399                .filter(|(_, wm)| box_ids.iter().any(|id| id == wm.vm.box_id()))
400                .map(|(i, _)| i)
401                .collect()
402        };
403
404        if indices_to_remove.is_empty() {
405            return;
406        }
407
408        // Second pass: remove and collect VMs to destroy
409        // We do this in reverse order to avoid index shifting issues
410        let mut to_destroy: Vec<WarmVm> = Vec::new();
411        {
412            let mut idle = self.idle.lock().await;
413            for idx in indices_to_remove.into_iter().rev() {
414                if idx < idle.len() {
415                    let warm_vm = idle.remove(idx);
416                    to_destroy.push(warm_vm);
417                }
418            }
419        }
420
421        // Update stats before destroying (approximate, since VMs still exist in to_destroy)
422        {
423            let idle_count = self.idle.lock().await.len();
424            if let Ok(mut stats) = self.stats.try_lock() {
425                stats.idle_count = idle_count;
426            }
427        }
428
429        // Destroy collected VMs (outside of pool lock)
430        for warm_vm in to_destroy {
431            let box_id = warm_vm.vm.box_id().to_string();
432            let mut vm = warm_vm.vm;
433            if let Err(e) = vm.destroy().await {
434                tracing::warn!(
435                    box_id = %box_id,
436                    error = %e,
437                    "Failed to destroy VM during fill_to_min rollback"
438                );
439            } else {
440                tracing::debug!(box_id = %box_id, "Destroyed VM during fill_to_min rollback");
441            }
442        }
443    }
444
445    /// Boot a new VM using the pool's template config.
446    async fn boot_new_vm(&self) -> Result<VmManager> {
447        let vm = Self::boot_or_restore(
448            self.config.snapshot_fork,
449            &self.box_config,
450            &self.event_emitter,
451            &self.template,
452        )
453        .await?;
454
455        let mut stats = self.stats.lock().await;
456        stats.total_created += 1;
457
458        self.event_emitter.emit(BoxEvent::with_string(
459            "pool.vm.created",
460            format!("Booted new VM {}", vm.box_id()),
461        ));
462
463        Ok(vm)
464    }
465
466    /// Fill one slot: restore from the snapshot-fork template when enabled, else cold
467    /// boot. Static so both `boot_new_vm` and the background replenish task use it.
468    async fn boot_or_restore(
469        snapshot_fork: bool,
470        box_config: &BoxConfig,
471        event_emitter: &EventEmitter,
472        template: &Arc<Mutex<TemplateState>>,
473    ) -> Result<VmManager> {
474        if snapshot_fork {
475            // Try the snapshot-fork template. If it can't be built (native VM
476            // snapshot unavailable — the verdict is cached so this is attempted at
477            // most once), fall back to a normal cold boot so the warm pool still
478            // fills rather than failing outright.
479            match Self::ensure_template(box_config, event_emitter, template).await {
480                Ok(tpl) => {
481                    let mut cfg = box_config.clone();
482                    cfg.snapshot_mem_file = Some(tpl.mem_file.clone());
483                    cfg.restore_from = Some(tpl.state_file.clone());
484                    cfg.snapshot_sock = None;
485                    let mut vm = VmManager::new(cfg, event_emitter.clone());
486                    vm.boot().await?;
487                    return Ok(vm);
488                }
489                Err(error) => {
490                    tracing::debug!(%error, "snapshot-fork unavailable; cold-booting this pool VM");
491                }
492            }
493        }
494        let mut vm = VmManager::new(box_config.clone(), event_emitter.clone());
495        vm.boot().await?;
496        Ok(vm)
497    }
498
499    /// Get the snapshot-fork template, building it once lazily. Concurrent callers
500    /// wait on the lock and reuse the first result — a built template OR a cached
501    /// `Unavailable` verdict, so a failed build (native VM snapshot unsupported on
502    /// this build) is attempted at most once rather than re-tried (and re-timed-out)
503    /// on every pool fill. Returns `Err` when unavailable so `boot_or_restore` cold
504    /// boots instead.
505    async fn ensure_template(
506        box_config: &BoxConfig,
507        event_emitter: &EventEmitter,
508        template: &Arc<Mutex<TemplateState>>,
509    ) -> Result<PoolTemplate> {
510        let mut guard = template.lock().await;
511        let prior_failures = match &*guard {
512            TemplateState::Ready(t) => return Ok(t.clone()),
513            TemplateState::Unavailable => {
514                return Err(BoxError::PoolError(
515                    "snapshot-fork template unavailable (native VM snapshot unsupported)"
516                        .to_string(),
517                ));
518            }
519            // Unbuilt or a still-retryable prior failure: (re)attempt the build.
520            TemplateState::Failing(n) => *n,
521            TemplateState::Unbuilt => 0,
522        };
523
524        match Self::build_template(box_config, event_emitter).await {
525            Ok(tpl) => {
526                *guard = TemplateState::Ready(tpl.clone());
527                event_emitter.emit(BoxEvent::with_string(
528                    "pool.template.built",
529                    format!(
530                        "Snapshot-fork template built for image {}",
531                        box_config.image
532                    ),
533                ));
534                Ok(tpl)
535            }
536            Err(error) => {
537                // Bounded retry: a transient failure presents identically to
538                // "snapshot unsupported", so only give up permanently after a few
539                // consecutive failures rather than downgrading the pool to
540                // cold-boot forever on a one-off hiccup.
541                let failures = prior_failures + 1;
542                if failures >= MAX_TEMPLATE_BUILD_FAILURES {
543                    tracing::warn!(
544                        %error, failures,
545                        "snapshot-fork template build failed repeatedly; marking \
546                         unavailable — the warm pool will cold-boot"
547                    );
548                    *guard = TemplateState::Unavailable;
549                } else {
550                    tracing::warn!(
551                        %error, failures,
552                        "snapshot-fork template build failed; will retry on a later fill"
553                    );
554                    *guard = TemplateState::Failing(failures);
555                }
556                Err(error)
557            }
558        }
559    }
560
561    /// Cold-boot one source VM with file-backed RAM + a trigger socket, snapshot it,
562    /// and tear it down — leaving the RAM image + state file as the template.
563    async fn build_template(
564        box_config: &BoxConfig,
565        event_emitter: &EventEmitter,
566    ) -> Result<PoolTemplate> {
567        let dir = a3s_box_core::dirs_home().join("pool").join(format!(
568            "tpl-{:016x}",
569            crate::vm::fnv1a_hash(&box_config.image)
570        ));
571        std::fs::create_dir_all(&dir).map_err(BoxError::IoError)?;
572
573        // Cross-process lock on the per-image template dir. The dir is keyed only
574        // by the image hash, so two processes building the same image's template
575        // would write the same template.ram/template.state concurrently and
576        // corrupt them. Held (via a Send File handle) across the boot+snapshot
577        // awaits below; acquired off-runtime so a contended flock doesn't block a
578        // worker thread.
579        let lock_target = dir.clone();
580        let _lock =
581            tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_target))
582                .await
583                .map_err(|e| BoxError::PoolError(format!("Template lock task failed: {e}")))?
584                .map_err(|e| BoxError::PoolError(format!("Failed to lock template dir: {e}")))?;
585
586        let mem_file = dir.join("template.ram");
587        let sock = dir.join("template.sock");
588        let state_file = dir.join("template.state");
589        let _ = std::fs::remove_file(&sock);
590
591        // Cold-boot the source as a snapshot TEMPLATE (file-backed RAM + trigger sock).
592        let mut cfg = box_config.clone();
593        cfg.snapshot_mem_file = Some(mem_file.to_string_lossy().into_owned());
594        cfg.snapshot_sock = Some(sock.to_string_lossy().into_owned());
595        cfg.restore_from = None;
596        let mut src = VmManager::new(cfg, event_emitter.clone());
597        src.boot().await?;
598
599        // Trigger the snapshot over libkrun's socket, then tear down the source (it is
600        // left paused by the snapshot; the RAM + state files are the template).
601        //
602        // Destroy the source UNCONDITIONALLY: `trigger_snapshot` fails on any
603        // libkrun without snapshot support (the common case), and `?`-ing out
604        // here would leak the fully-booted source VM (shim process, overlay
605        // mount, box dir, sockets) — neither VmManager nor ShimHandler reaps on
606        // drop. Capture the result, tear down, then propagate.
607        let snapshot = Self::trigger_snapshot(&sock, &state_file).await;
608        let _ = src.destroy_with_timeout(2000).await;
609        snapshot?;
610
611        Ok(PoolTemplate {
612            mem_file: mem_file.to_string_lossy().into_owned(),
613            state_file: state_file.to_string_lossy().into_owned(),
614        })
615    }
616
617    /// Send a `snapshot <state>` request to libkrun's per-template trigger socket and
618    /// wait for the `ok` reply (the socket appears once the template's vCPUs run).
619    ///
620    /// Snapshot-fork is a Linux/KVM (Unix) feature; on non-Unix hosts the trigger
621    /// socket does not exist, so this is unavailable (see the `not(unix)` stub).
622    #[cfg(unix)]
623    async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
624        use tokio::io::{AsyncReadExt, AsyncWriteExt};
625        // The socket is bound by libkrun after the guest starts; poll briefly.
626        let mut stream = None;
627        for _ in 0..200 {
628            match tokio::net::UnixStream::connect(sock).await {
629                Ok(s) => {
630                    stream = Some(s);
631                    break;
632                }
633                Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
634            }
635        }
636        let mut stream = stream.ok_or_else(|| {
637            BoxError::PoolError(format!("snapshot socket {} never appeared", sock.display()))
638        })?;
639        let cmd = format!("snapshot {}\n", state_file.display());
640        stream
641            .write_all(cmd.as_bytes())
642            .await
643            .map_err(BoxError::IoError)?;
644        let mut buf = [0u8; 64];
645        let n = stream.read(&mut buf).await.map_err(BoxError::IoError)?;
646        let reply = String::from_utf8_lossy(&buf[..n]);
647        if reply.trim() == "ok" {
648            Ok(())
649        } else {
650            Err(BoxError::PoolError(format!(
651                "snapshot trigger failed: {}",
652                reply.trim()
653            )))
654        }
655    }
656
657    /// Non-Unix stub: snapshot-fork relies on libkrun's Unix trigger socket and KVM
658    /// state save/restore, neither of which exist on Windows. `--snapshot-fork` is
659    /// Linux/KVM-only, so this path is never reached there in practice.
660    #[cfg(not(unix))]
661    async fn trigger_snapshot(
662        _sock: &std::path::Path,
663        _state_file: &std::path::Path,
664    ) -> Result<()> {
665        Err(BoxError::PoolError(
666            "snapshot-fork is only supported on Linux/KVM hosts".to_string(),
667        ))
668    }
669
670    /// Fill the pool to the minimum idle count.
671    async fn fill_to_min(&self) {
672        let current = self.idle.lock().await.len();
673        let needed = self.config.min_idle.saturating_sub(current);
674
675        if needed == 0 {
676            return;
677        }
678
679        tracing::debug!(
680            current,
681            needed,
682            min_idle = self.config.min_idle,
683            "Replenishing warm pool"
684        );
685
686        // Track VMs added in this fill attempt so we can clean up on failure.
687        let mut added_ids: Vec<String> = Vec::new();
688
689        for _ in 0..needed {
690            match self.boot_new_vm().await {
691                Ok(vm) => {
692                    let box_id = vm.box_id().to_string();
693                    let mut idle = self.idle.lock().await;
694                    idle.push(WarmVm {
695                        vm,
696                        created_at: Instant::now(),
697                    });
698                    let mut stats = self.stats.lock().await;
699                    stats.idle_count = idle.len();
700                    added_ids.push(box_id.clone());
701
702                    tracing::debug!(box_id = %box_id, "Added VM to warm pool");
703                }
704                Err(e) => {
705                    tracing::warn!(error = %e, "Failed to boot VM for warm pool");
706                    // Clean up any VMs that were successfully added before this failure.
707                    if !added_ids.is_empty() {
708                        tracing::info!(
709                            count = added_ids.len(),
710                            "Cleaning up VMs added before fill_to_min failed"
711                        );
712                        self.remove_idle_vms(&added_ids).await;
713                    }
714                    break;
715                }
716            }
717        }
718
719        self.event_emitter.emit(BoxEvent::empty("pool.replenish"));
720    }
721
722    /// Spawn the background maintenance loop.
723    ///
724    /// Periodically checks for:
725    /// 1. Autoscaler evaluation → adjust min_idle dynamically
726    /// 2. Pool below min_idle → replenish
727    /// 3. Idle VMs past TTL → evict
728    fn spawn_maintenance_loop(&self) -> JoinHandle<()> {
729        let idle = Arc::clone(&self.idle);
730        let stats = Arc::clone(&self.stats);
731        let config = self.config.clone();
732        let box_config = self.box_config.clone();
733        let event_emitter = self.event_emitter.clone();
734        let mut shutdown_rx = self.shutdown_rx.clone();
735        let scaler = self.scaler.clone();
736        let template = Arc::clone(&self.template);
737
738        tokio::spawn(async move {
739            let check_interval = std::time::Duration::from_secs(
740                // Check every 1/5 of TTL, minimum 5 seconds
741                if config.idle_ttl_secs > 0 {
742                    (config.idle_ttl_secs / 5).max(5)
743                } else {
744                    30
745                },
746            );
747
748            // Dynamic min_idle starts from config, adjusted by scaler
749            let mut effective_min_idle = config.min_idle;
750
751            loop {
752                tokio::select! {
753                    result = shutdown_rx.changed() => {
754                        if result.is_ok() && *shutdown_rx.borrow() {
755                            tracing::debug!("Pool maintenance loop shutting down");
756                            break;
757                        }
758                    }
759                    _ = tokio::time::sleep(check_interval) => {
760                        // Evict expired VMs
761                        if config.idle_ttl_secs > 0 {
762                            Self::evict_expired_static(
763                                &idle,
764                                &stats,
765                                &event_emitter,
766                                config.idle_ttl_secs,
767                            ).await;
768                        }
769
770                        // Evaluate autoscaler
771                        if let Some(ref scaler) = scaler {
772                            let mut s = scaler.lock().await;
773                            let decision = s.evaluate();
774                            let new_min = s.current_min_idle();
775                            if new_min != effective_min_idle {
776                                tracing::info!(
777                                    old_min_idle = effective_min_idle,
778                                    new_min_idle = new_min,
779                                    ?decision,
780                                    "Autoscaler adjusted min_idle"
781                                );
782                                event_emitter.emit(BoxEvent::with_string(
783                                    "pool.autoscale",
784                                    format!(
785                                        "min_idle adjusted {} → {} ({:?})",
786                                        effective_min_idle, new_min, decision
787                                    ),
788                                ));
789                                effective_min_idle = new_min;
790                            }
791                        }
792
793                        // Replenish if below effective min_idle
794                        let current = idle.lock().await.len();
795                        if current < effective_min_idle {
796                            let needed = effective_min_idle - current;
797                            tracing::debug!(current, needed, min_idle = effective_min_idle, "Replenishing warm pool");
798
799                            // Fill the `needed` slots CONCURRENTLY rather than one
800                            // boot at a time — a snapshot-fork restore (or even a cold
801                            // boot) overlaps its readiness wait, so a batch fills in
802                            // roughly one boot's time instead of N×. For snapshot-fork
803                            // the first task builds the template under ensure_template's
804                            // lock; the rest wait then restore in parallel.
805                            let mut set = tokio::task::JoinSet::new();
806                            for _ in 0..needed {
807                                let sf = config.snapshot_fork;
808                                let bc = box_config.clone();
809                                let ee = event_emitter.clone();
810                                let tpl = Arc::clone(&template);
811                                set.spawn(async move {
812                                    WarmPool::boot_or_restore(sf, &bc, &ee, &tpl).await
813                                });
814                            }
815                            while let Some(joined) = set.join_next().await {
816                                match joined {
817                                    Ok(Ok(mut vm)) => {
818                                        let box_id = vm.box_id().to_string();
819                                        // If shutdown landed while this batch was
820                                        // booting, drain_idle has already cleared
821                                        // `idle` and will not run again, so a VM
822                                        // pushed now leaks (no Drop reaper). Destroy
823                                        // it instead. Acquire the idle lock FIRST and
824                                        // re-check shutdown UNDER it: drain_idle drains
825                                        // while holding this same lock (always after
826                                        // signal_shutdown), so the check-and-push is
827                                        // atomic against it — closing the TOCTOU window
828                                        // that an unlocked `borrow()` check left open.
829                                        let mut pool = idle.lock().await;
830                                        if *shutdown_rx.borrow() {
831                                            drop(pool);
832                                            tracing::debug!(
833                                                box_id = %box_id,
834                                                "Pool shutting down mid-replenish; destroying freshly-booted VM"
835                                            );
836                                            let _ = vm.destroy_with_timeout(2000).await;
837                                            continue;
838                                        }
839                                        pool.push(WarmVm {
840                                            vm,
841                                            created_at: Instant::now(),
842                                        });
843                                        let mut s = stats.lock().await;
844                                        s.total_created += 1;
845                                        s.idle_count = pool.len();
846                                        drop(s);
847                                        drop(pool);
848
849                                        event_emitter.emit(BoxEvent::with_string(
850                                            "pool.vm.created",
851                                            format!("Replenished VM {}", box_id),
852                                        ));
853                                    }
854                                    Ok(Err(e)) => {
855                                        tracing::warn!(error = %e, "Failed to replenish warm pool");
856                                    }
857                                    Err(e) => {
858                                        tracing::warn!(error = %e, "Replenish task join error");
859                                    }
860                                }
861                            }
862
863                            event_emitter.emit(BoxEvent::empty("pool.replenish"));
864                        }
865                    }
866                }
867            }
868        })
869    }
870
871    /// Static version of evict_expired for use in the spawned task.
872    async fn evict_expired_static(
873        idle: &Arc<Mutex<Vec<WarmVm>>>,
874        stats: &Arc<Mutex<PoolStats>>,
875        event_emitter: &EventEmitter,
876        idle_ttl_secs: u64,
877    ) {
878        let ttl = std::time::Duration::from_secs(idle_ttl_secs);
879
880        let mut pool = idle.lock().await;
881        let mut kept = Vec::new();
882        let mut expired = Vec::new();
883
884        for warm_vm in pool.drain(..) {
885            if warm_vm.created_at.elapsed() > ttl {
886                expired.push(warm_vm);
887            } else {
888                kept.push(warm_vm);
889            }
890        }
891        *pool = kept;
892        let after_count = pool.len();
893        drop(pool);
894
895        let evicted_count = expired.len();
896        for warm_vm in expired {
897            let mut vm = warm_vm.vm;
898            let _ = vm.destroy().await;
899        }
900
901        if evicted_count > 0 {
902            let mut s = stats.lock().await;
903            s.total_evicted += evicted_count as u64;
904            s.idle_count = after_count;
905
906            event_emitter.emit(BoxEvent::with_string(
907                "pool.vm.evicted",
908                format!("Evicted {} expired VMs", evicted_count),
909            ));
910        }
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use a3s_box_core::config::PoolConfig;
918
919    fn test_pool_config(min_idle: usize, max_size: usize) -> PoolConfig {
920        PoolConfig {
921            enabled: true,
922            min_idle,
923            max_size,
924            idle_ttl_secs: 300,
925            ..Default::default()
926        }
927    }
928
929    fn test_event_emitter() -> EventEmitter {
930        EventEmitter::new(100)
931    }
932
933    // --- PoolConfig validation tests ---
934
935    #[tokio::test]
936    async fn test_pool_rejects_zero_max_size() {
937        let config = test_pool_config(0, 0);
938        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
939        match result {
940            Err(e) => assert!(e.to_string().contains("max_size must be greater than 0")),
941            Ok(_) => panic!("Expected error for zero max_size"),
942        }
943    }
944
945    #[tokio::test]
946    async fn test_pool_rejects_min_idle_exceeds_max() {
947        let config = test_pool_config(10, 5);
948        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
949        match result {
950            Err(e) => assert!(e.to_string().contains("cannot exceed max_size")),
951            Ok(_) => panic!("Expected error for min_idle > max_size"),
952        }
953    }
954
955    // --- PoolStats tests ---
956
957    #[test]
958    fn test_pool_stats_default() {
959        let stats = PoolStats {
960            idle_count: 0,
961            total_created: 0,
962            total_acquired: 0,
963            total_released: 0,
964            total_evicted: 0,
965        };
966        assert_eq!(stats.idle_count, 0);
967        assert_eq!(stats.total_created, 0);
968    }
969
970    #[test]
971    fn test_pool_stats_clone() {
972        let stats = PoolStats {
973            idle_count: 3,
974            total_created: 10,
975            total_acquired: 7,
976            total_released: 5,
977            total_evicted: 2,
978        };
979        let cloned = stats.clone();
980        assert_eq!(cloned.idle_count, 3);
981        assert_eq!(cloned.total_created, 10);
982        assert_eq!(cloned.total_acquired, 7);
983        assert_eq!(cloned.total_released, 5);
984        assert_eq!(cloned.total_evicted, 2);
985    }
986
987    #[test]
988    fn test_pool_stats_debug() {
989        let stats = PoolStats {
990            idle_count: 1,
991            total_created: 2,
992            total_acquired: 3,
993            total_released: 4,
994            total_evicted: 5,
995        };
996        let debug = format!("{:?}", stats);
997        assert!(debug.contains("idle_count"));
998        assert!(debug.contains("total_created"));
999    }
1000
1001    // --- PoolConfig serialization tests ---
1002
1003    #[test]
1004    fn test_pool_config_roundtrip() {
1005        let config = PoolConfig {
1006            enabled: true,
1007            min_idle: 3,
1008            max_size: 10,
1009            idle_ttl_secs: 600,
1010            ..Default::default()
1011        };
1012
1013        let json = serde_json::to_string(&config).unwrap();
1014        let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
1015
1016        assert!(parsed.enabled);
1017        assert_eq!(parsed.min_idle, 3);
1018        assert_eq!(parsed.max_size, 10);
1019        assert_eq!(parsed.idle_ttl_secs, 600);
1020    }
1021
1022    #[test]
1023    fn test_pool_config_default_values() {
1024        let config = PoolConfig::default();
1025        assert!(!config.enabled);
1026        assert_eq!(config.min_idle, 1);
1027        assert_eq!(config.max_size, 5);
1028        assert_eq!(config.idle_ttl_secs, 300);
1029    }
1030
1031    #[test]
1032    fn test_pool_config_deserialization_with_defaults() {
1033        let json = r#"{"enabled": true}"#;
1034        let config: PoolConfig = serde_json::from_str(json).unwrap();
1035        assert!(config.enabled);
1036        assert_eq!(config.min_idle, 1);
1037        assert_eq!(config.max_size, 5);
1038        assert_eq!(config.idle_ttl_secs, 300);
1039    }
1040
1041    // --- PoolConfig validation edge cases ---
1042
1043    #[tokio::test]
1044    async fn test_pool_accepts_min_idle_equals_max() {
1045        let config = test_pool_config(3, 3);
1046        // This should be accepted (min_idle == max_size is valid)
1047        // It will fail at boot (no shim), but config validation should pass
1048        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1049        // The error should be about VM boot, not config validation
1050        match result {
1051            Err(e) => assert!(!e.to_string().contains("cannot exceed max_size")),
1052            Ok(mut pool) => {
1053                let _ = pool.drain().await;
1054            }
1055        }
1056    }
1057
1058    #[tokio::test]
1059    async fn test_pool_accepts_min_idle_zero() {
1060        let config = test_pool_config(0, 5);
1061        // min_idle=0 means no pre-warming, should be valid
1062        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1063        match result {
1064            Ok(mut pool) => {
1065                // Pool should start with 0 idle VMs
1066                assert_eq!(pool.idle_count().await, 0);
1067                let stats = pool.stats().await;
1068                assert_eq!(stats.idle_count, 0);
1069                assert_eq!(stats.total_created, 0);
1070                let _ = pool.drain().await;
1071            }
1072            Err(e) => {
1073                // If it fails, it should NOT be a config validation error
1074                assert!(!e.to_string().contains("max_size"));
1075                assert!(!e.to_string().contains("min_idle"));
1076            }
1077        }
1078    }
1079
1080    // --- WarmPool internal state tests (using min_idle=0 to avoid boot) ---
1081
1082    #[tokio::test]
1083    async fn test_pool_stats_initial() {
1084        let config = test_pool_config(0, 5);
1085        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1086        if let Ok(mut pool) = result {
1087            let stats = pool.stats().await;
1088            assert_eq!(stats.idle_count, 0);
1089            assert_eq!(stats.total_created, 0);
1090            assert_eq!(stats.total_acquired, 0);
1091            assert_eq!(stats.total_released, 0);
1092            assert_eq!(stats.total_evicted, 0);
1093            let _ = pool.drain().await;
1094        }
1095    }
1096
1097    #[tokio::test]
1098    async fn test_pool_idle_count_initial() {
1099        let config = test_pool_config(0, 5);
1100        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1101        if let Ok(mut pool) = result {
1102            assert_eq!(pool.idle_count().await, 0);
1103            let _ = pool.drain().await;
1104        }
1105    }
1106
1107    #[tokio::test]
1108    async fn test_pool_drain_empty_pool() {
1109        let config = test_pool_config(0, 5);
1110        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1111        if let Ok(mut pool) = result {
1112            // Draining an empty pool should succeed without error
1113            let drain_result = pool.drain().await;
1114            assert!(drain_result.is_ok());
1115
1116            let stats = pool.stats().await;
1117            assert_eq!(stats.idle_count, 0);
1118        }
1119    }
1120
1121    #[tokio::test]
1122    async fn test_pool_drain_emits_event() {
1123        let emitter = test_event_emitter();
1124        let mut receiver = emitter.subscribe();
1125        let config = test_pool_config(0, 5);
1126
1127        let result = WarmPool::start(config, BoxConfig::default(), emitter).await;
1128        if let Ok(mut pool) = result {
1129            pool.drain().await.unwrap();
1130
1131            // Check that pool.drained event was emitted
1132            let mut found_drain_event = false;
1133            // Drain all events from the receiver
1134            while let Ok(event) = receiver.try_recv() {
1135                if event.key == "pool.drained" {
1136                    found_drain_event = true;
1137                }
1138            }
1139            assert!(found_drain_event, "Expected pool.drained event");
1140        }
1141    }
1142
1143    #[tokio::test]
1144    async fn test_pool_acquire_from_empty_pool_fails_without_shim() {
1145        let config = test_pool_config(0, 5);
1146        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1147        if let Ok(pool) = result {
1148            // Acquire from empty pool should try to boot a VM, which will fail
1149            // because there's no shim binary available in test environment
1150            let acquire_result = pool.acquire().await;
1151            assert!(acquire_result.is_err());
1152        }
1153    }
1154
1155    // --- Maintenance loop check interval calculation ---
1156
1157    #[test]
1158    #[allow(clippy::unnecessary_min_or_max)]
1159    fn test_maintenance_check_interval_with_ttl() {
1160        // TTL = 300s → check every 60s (300/5)
1161        let interval = if 300_u64 > 0 {
1162            (300_u64 / 5).max(5)
1163        } else {
1164            30
1165        };
1166        assert_eq!(interval, 60);
1167    }
1168
1169    #[test]
1170    #[allow(clippy::unnecessary_min_or_max)]
1171    fn test_maintenance_check_interval_short_ttl() {
1172        // TTL = 10s → check every 5s (min 5)
1173        let interval = if 10_u64 > 0 { (10_u64 / 5).max(5) } else { 30 };
1174        assert_eq!(interval, 5);
1175    }
1176
1177    #[test]
1178    #[allow(clippy::unnecessary_min_or_max)]
1179    fn test_maintenance_check_interval_very_short_ttl() {
1180        // TTL = 1s → check every 5s (min 5)
1181        let interval = if 1_u64 > 0 { (1_u64 / 5).max(5) } else { 30 };
1182        assert_eq!(interval, 5);
1183    }
1184
1185    #[test]
1186    #[allow(
1187        clippy::absurd_extreme_comparisons,
1188        clippy::erasing_op,
1189        clippy::unnecessary_min_or_max,
1190        unused_comparisons
1191    )]
1192    fn test_maintenance_check_interval_no_ttl() {
1193        // TTL = 0 → check every 30s
1194        let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
1195        assert_eq!(interval, 30);
1196    }
1197
1198    // --- WarmVm struct tests ---
1199
1200    #[test]
1201    fn test_warm_vm_created_at_is_recent() {
1202        let before = Instant::now();
1203        let created_at = Instant::now();
1204        let after = Instant::now();
1205
1206        assert!(created_at >= before);
1207        assert!(created_at <= after);
1208    }
1209
1210    // --- PoolStats field coverage ---
1211
1212    #[test]
1213    fn test_pool_stats_all_fields() {
1214        let stats = PoolStats {
1215            idle_count: 10,
1216            total_created: 100,
1217            total_acquired: 80,
1218            total_released: 70,
1219            total_evicted: 15,
1220        };
1221
1222        assert_eq!(stats.idle_count, 10);
1223        assert_eq!(stats.total_created, 100);
1224        assert_eq!(stats.total_acquired, 80);
1225        assert_eq!(stats.total_released, 70);
1226        assert_eq!(stats.total_evicted, 15);
1227
1228        // Verify debug output contains all fields
1229        let debug = format!("{:?}", stats);
1230        assert!(debug.contains("10"));
1231        assert!(debug.contains("100"));
1232        assert!(debug.contains("80"));
1233        assert!(debug.contains("70"));
1234        assert!(debug.contains("15"));
1235    }
1236
1237    // Note: Full integration tests for acquire/release/drain with actual VMs
1238    // require a working VM runtime (shim binary + libkrun). These are tested
1239    // in integration tests with the full box environment. The unit tests here
1240    // validate configuration, statistics, error handling, and pool lifecycle
1241    // with min_idle=0 (no VM boot required).
1242
1243    #[tokio::test]
1244    async fn test_pool_set_metrics_attaches() {
1245        let config = test_pool_config(0, 5);
1246        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1247        match result {
1248            Ok(mut pool) => {
1249                let metrics = crate::prom::RuntimeMetrics::new();
1250                pool.set_metrics(metrics.clone());
1251                assert!(pool.metrics.is_some());
1252                // Metrics start at zero
1253                assert_eq!(metrics.warm_pool_hits.get(), 0);
1254                assert_eq!(metrics.warm_pool_misses.get(), 0);
1255                assert_eq!(metrics.warm_pool_size.get(), 0);
1256                let _ = pool.drain().await;
1257            }
1258            Err(_) => {
1259                // Boot failure is acceptable in unit test environment
1260            }
1261        }
1262    }
1263}