use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use a3s_box_core::config::{BoxConfig, PoolConfig};
use a3s_box_core::error::{BoxError, Result};
use a3s_box_core::event::{BoxEvent, EventEmitter};
use tokio::sync::{watch, Mutex, OwnedSemaphorePermit, Semaphore};
use tokio::task::{JoinHandle, JoinSet};
use crate::pool::scaler::PoolScaler;
use crate::vm::VmManager;
struct WarmVm {
vm: VmManager,
created_at: Instant,
}
type BootVmFuture<'a> = Pin<Box<dyn Future<Output = Result<VmManager>> + Send + 'a>>;
struct BootMetricGuard {
metrics: Option<crate::prom::RuntimeMetrics>,
}
impl BootMetricGuard {
fn new(metrics: Option<crate::prom::RuntimeMetrics>) -> Self {
if let Some(metrics) = &metrics {
metrics.warm_pool_boots_inflight.inc();
}
Self { metrics }
}
}
impl Drop for BootMetricGuard {
fn drop(&mut self) {
if let Some(metrics) = &self.metrics {
metrics.warm_pool_boots_inflight.dec();
}
}
}
async fn acquire_boot_permits(
boot_limiter: Arc<Semaphore>,
global_boot_limiter: Option<Arc<Semaphore>>,
) -> Result<(OwnedSemaphorePermit, Option<OwnedSemaphorePermit>)> {
let pool_permit = boot_limiter
.acquire_owned()
.await
.map_err(|_| BoxError::PoolError("Warm-pool boot limiter closed".to_string()))?;
let global_permit = match global_boot_limiter {
Some(limiter) => Some(limiter.acquire_owned().await.map_err(|_| {
BoxError::PoolError("Warm-pool global boot limiter closed".to_string())
})?),
None => None,
};
Ok((pool_permit, global_permit))
}
#[derive(Debug, Clone)]
pub struct PoolStats {
pub idle_count: usize,
pub total_created: u64,
pub total_acquired: u64,
pub total_released: u64,
pub total_evicted: u64,
}
pub struct WarmPool {
config: PoolConfig,
box_config: BoxConfig,
idle: Arc<Mutex<Vec<WarmVm>>>,
stats: Arc<Mutex<PoolStats>>,
event_emitter: EventEmitter,
replenish_handle: Mutex<Option<JoinHandle<()>>>,
shutdown_tx: watch::Sender<bool>,
shutdown_rx: watch::Receiver<bool>,
scaler: Option<Arc<Mutex<PoolScaler>>>,
metrics: Option<crate::prom::RuntimeMetrics>,
boot_limiter: Arc<Semaphore>,
global_boot_limiter: Option<Arc<Semaphore>>,
template: Arc<Mutex<TemplateState>>,
}
#[derive(Clone)]
struct PoolTemplate {
mem_file: String,
state_file: String,
rootfs_cache_key: Option<String>,
}
const MAX_TEMPLATE_BUILD_FAILURES: u32 = 3;
const MAX_DRAIN_CONCURRENCY: usize = 4;
const EPHEMERAL_DRAIN_TIMEOUT_MS: u64 = 2_000;
enum TemplateState {
Unbuilt,
Ready(PoolTemplate),
Failing(u32),
Unavailable,
}
#[derive(Clone, Copy)]
enum InitialFill {
Eager,
FirstReady,
}
struct BootBatch<'a> {
snapshot_fork: bool,
box_config: &'a BoxConfig,
event_emitter: &'a EventEmitter,
template: &'a Arc<Mutex<TemplateState>>,
needed: usize,
max_concurrent_boots: usize,
metrics: Option<crate::prom::RuntimeMetrics>,
boot_limiter: Arc<Semaphore>,
global_boot_limiter: Option<Arc<Semaphore>>,
}
impl WarmPool {
pub async fn start(
config: PoolConfig,
box_config: BoxConfig,
event_emitter: EventEmitter,
) -> Result<Self> {
Self::start_with_metrics(config, box_config, event_emitter, None).await
}
pub async fn start_with_metrics(
config: PoolConfig,
box_config: BoxConfig,
event_emitter: EventEmitter,
metrics: Option<crate::prom::RuntimeMetrics>,
) -> Result<Self> {
Self::start_with_metrics_and_boot_limiter(config, box_config, event_emitter, metrics, None)
.await
}
pub async fn start_with_metrics_and_boot_limiter(
config: PoolConfig,
box_config: BoxConfig,
event_emitter: EventEmitter,
metrics: Option<crate::prom::RuntimeMetrics>,
global_boot_limiter: Option<Arc<Semaphore>>,
) -> Result<Self> {
Self::start_with_metrics_and_boot_limiter_with_fill(
config,
box_config,
event_emitter,
metrics,
global_boot_limiter,
InitialFill::Eager,
)
.await
}
pub async fn start_with_metrics_and_boot_limiter_first_ready(
config: PoolConfig,
box_config: BoxConfig,
event_emitter: EventEmitter,
metrics: Option<crate::prom::RuntimeMetrics>,
global_boot_limiter: Option<Arc<Semaphore>>,
) -> Result<Self> {
Self::start_with_metrics_and_boot_limiter_with_fill(
config,
box_config,
event_emitter,
metrics,
global_boot_limiter,
InitialFill::FirstReady,
)
.await
}
async fn start_with_metrics_and_boot_limiter_with_fill(
config: PoolConfig,
box_config: BoxConfig,
event_emitter: EventEmitter,
metrics: Option<crate::prom::RuntimeMetrics>,
global_boot_limiter: Option<Arc<Semaphore>>,
initial_fill: InitialFill,
) -> Result<Self> {
if config.max_size == 0 {
return Err(BoxError::PoolError(
"Pool max_size must be greater than 0".to_string(),
));
}
if config.min_idle > config.max_size {
return Err(BoxError::PoolError(format!(
"Pool min_idle ({}) cannot exceed max_size ({})",
config.min_idle, config.max_size
)));
}
if config.max_concurrent_boots == 0 {
return Err(BoxError::PoolError(
"Pool max_concurrent_boots must be greater than 0".to_string(),
));
}
let idle = Arc::new(Mutex::new(Vec::with_capacity(config.max_size)));
let stats = Arc::new(Mutex::new(PoolStats {
idle_count: 0,
total_created: 0,
total_acquired: 0,
total_released: 0,
total_evicted: 0,
}));
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let scaler = if config.scaling.enabled {
Some(Arc::new(Mutex::new(PoolScaler::new(
config.scaling.clone(),
config.min_idle,
config.max_size,
))))
} else {
None
};
let boot_limiter = Arc::new(Semaphore::new(config.max_concurrent_boots));
let pool = Self {
config,
box_config,
idle,
stats,
event_emitter,
replenish_handle: Mutex::new(None),
shutdown_tx,
shutdown_rx,
scaler,
metrics,
boot_limiter,
global_boot_limiter,
template: Arc::new(Mutex::new(TemplateState::Unbuilt)),
};
if let Some(metrics) = &pool.metrics {
metrics.warm_pool_capacity.set(pool.config.max_size as i64);
}
let initial_target = match initial_fill {
InitialFill::Eager => pool.config.min_idle,
InitialFill::FirstReady => pool.config.min_idle.min(1),
};
let initial_fill_started = Instant::now();
pool.fill_to_target(initial_target).await;
if let Some(metrics) = &pool.metrics {
metrics
.warm_pool_initial_fill_duration
.observe(initial_fill_started.elapsed().as_secs_f64());
}
let handle = pool.spawn_maintenance_loop();
*pool.replenish_handle.lock().await = Some(handle);
tracing::info!(
min_idle = pool.config.min_idle,
max_size = pool.config.max_size,
idle_ttl_secs = pool.config.idle_ttl_secs,
"Warm pool started"
);
Ok(pool)
}
pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
metrics.warm_pool_capacity.set(self.config.max_size as i64);
metrics.warm_pool_size.set(
self.idle
.try_lock()
.map(|idle| idle.len() as i64)
.unwrap_or_default(),
);
self.metrics = Some(metrics);
}
fn sync_idle_metric(metrics: Option<&crate::prom::RuntimeMetrics>, idle_count: usize) {
if let Some(metrics) = metrics {
metrics.warm_pool_size.set(idle_count as i64);
}
}
pub async fn acquire(&self) -> Result<VmManager> {
{
let mut idle = self.idle.lock().await;
if let Some(warm_vm) = idle.pop() {
let mut stats = self.stats.lock().await;
stats.total_acquired += 1;
stats.idle_count = idle.len();
if let Some(ref scaler) = self.scaler {
scaler.lock().await.record_acquire(true);
}
if let Some(ref m) = self.metrics {
m.warm_pool_hits.inc();
m.warm_pool_size.set(idle.len() as i64);
}
self.event_emitter.emit(BoxEvent::with_string(
"pool.vm.acquired",
format!("Acquired VM {} from pool", warm_vm.vm.box_id()),
));
tracing::debug!(
box_id = %warm_vm.vm.box_id(),
idle_remaining = idle.len(),
"Acquired VM from warm pool"
);
return Ok(warm_vm.vm);
}
}
tracing::info!("No idle VM in pool, booting on demand");
if let Some(ref scaler) = self.scaler {
scaler.lock().await.record_acquire(false);
}
if let Some(ref m) = self.metrics {
m.warm_pool_misses.inc();
}
let vm = self.boot_new_vm().await?;
let mut stats = self.stats.lock().await;
stats.total_acquired += 1;
Ok(vm)
}
pub async fn release(&self, vm: VmManager) -> Result<()> {
let mut idle = self.idle.lock().await;
if *self.shutdown_rx.borrow() {
drop(idle);
let mut vm = vm;
vm.destroy().await?;
return Ok(());
}
if idle.len() >= self.config.max_size {
drop(idle); let mut vm = vm;
vm.destroy().await?;
tracing::debug!(
box_id = %vm.box_id(),
"Pool full, destroyed released VM"
);
return Ok(());
}
let box_id = vm.box_id().to_string();
idle.push(WarmVm {
vm,
created_at: Instant::now(),
});
let mut stats = self.stats.lock().await;
stats.total_released += 1;
stats.idle_count = idle.len();
if let Some(ref m) = self.metrics {
m.warm_pool_size.set(idle.len() as i64);
}
self.event_emitter.emit(BoxEvent::with_string(
"pool.vm.released",
format!("Released VM {} back to pool", box_id),
));
tracing::debug!(
box_id = %box_id,
idle_count = idle.len(),
"Released VM back to warm pool"
);
Ok(())
}
pub async fn stats(&self) -> PoolStats {
self.stats.lock().await.clone()
}
pub async fn idle_count(&self) -> usize {
self.idle.lock().await.len()
}
pub fn signal_shutdown(&self) {
let _ = self.shutdown_tx.send(true);
tracing::info!("Warm pool shutdown signaled");
}
pub async fn drain(&mut self) -> Result<()> {
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.replenish_handle.lock().await.take() {
let _ = handle.await;
}
let idle_vms = {
let mut idle = self.idle.lock().await;
let idle_vms = idle.drain(..).collect::<Vec<_>>();
Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
idle_vms
};
let count = idle_vms.len();
Self::destroy_vms(idle_vms, None, "drain").await;
let mut stats = self.stats.lock().await;
stats.idle_count = 0;
self.event_emitter.emit(BoxEvent::empty("pool.drained"));
tracing::info!(destroyed = count, "Warm pool drained");
Ok(())
}
pub async fn drain_idle(&self) -> Result<()> {
self.signal_shutdown();
if let Some(handle) = self.replenish_handle.lock().await.take() {
let _ = handle.await;
}
let idle_vms = {
let mut idle = self.idle.lock().await;
let idle_vms = idle.drain(..).collect::<Vec<_>>();
Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
idle_vms
};
let count = idle_vms.len();
let timeout_ms = (!self.box_config.persistent).then_some(EPHEMERAL_DRAIN_TIMEOUT_MS);
Self::destroy_vms(idle_vms, timeout_ms, "drain_idle").await;
self.stats.lock().await.idle_count = 0;
tracing::info!(destroyed = count, "Warm pool idle VMs drained");
Ok(())
}
async fn destroy_vms(vms: Vec<WarmVm>, timeout_ms: Option<u64>, operation: &'static str) {
if vms.is_empty() {
return;
}
let concurrency = vms.len().min(MAX_DRAIN_CONCURRENCY);
let mut pending = vms.into_iter();
let mut tasks = JoinSet::new();
for _ in 0..concurrency {
if let Some(warm_vm) = pending.next() {
tasks.spawn(Self::destroy_one(warm_vm, timeout_ms));
}
}
while let Some(result) = tasks.join_next().await {
match result {
Ok((box_id, Ok(()))) => {
tracing::debug!(%box_id, operation, "Destroyed pooled VM");
}
Ok((box_id, Err(error))) => {
tracing::warn!(%box_id, %error, operation, "Failed to destroy pooled VM");
}
Err(error) => {
tracing::warn!(%error, operation, "Pooled VM teardown task failed");
}
}
if let Some(warm_vm) = pending.next() {
tasks.spawn(Self::destroy_one(warm_vm, timeout_ms));
}
}
}
async fn destroy_one(warm_vm: WarmVm, timeout_ms: Option<u64>) -> (String, Result<()>) {
let box_id = warm_vm.vm.box_id().to_string();
let mut vm = warm_vm.vm;
let result = match timeout_ms {
Some(timeout_ms) => vm.destroy_with_timeout(timeout_ms).await,
None => vm.destroy().await,
};
(box_id, result)
}
async fn remove_idle_vms(&self, box_ids: &[String]) {
let indices_to_remove: Vec<usize> = {
let idle = self.idle.lock().await;
idle.iter()
.enumerate()
.filter(|(_, wm)| box_ids.iter().any(|id| id == wm.vm.box_id()))
.map(|(i, _)| i)
.collect()
};
if indices_to_remove.is_empty() {
return;
}
let mut to_destroy: Vec<WarmVm> = Vec::new();
{
let mut idle = self.idle.lock().await;
for idx in indices_to_remove.into_iter().rev() {
if idx < idle.len() {
let warm_vm = idle.remove(idx);
to_destroy.push(warm_vm);
}
}
}
{
let idle_count = self.idle.lock().await.len();
if let Ok(mut stats) = self.stats.try_lock() {
stats.idle_count = idle_count;
}
Self::sync_idle_metric(self.metrics.as_ref(), idle_count);
}
Self::destroy_vms(to_destroy, None, "fill rollback").await;
}
async fn boot_new_vm(&self) -> Result<VmManager> {
let _boot_permits =
acquire_boot_permits(self.boot_limiter.clone(), self.global_boot_limiter.clone())
.await?;
let _boot_guard = BootMetricGuard::new(self.metrics.clone());
let result = Self::boot_or_restore(
self.config.snapshot_fork,
&self.box_config,
&self.event_emitter,
&self.template,
)
.await;
if result.is_err() {
if let Some(metrics) = &self.metrics {
metrics.warm_pool_boot_failures_total.inc();
}
}
let vm = result?;
let mut stats = self.stats.lock().await;
stats.total_created += 1;
self.event_emitter.emit(BoxEvent::with_string(
"pool.vm.created",
format!("Booted new VM {}", vm.box_id()),
));
Ok(vm)
}
fn boot_or_restore<'a>(
snapshot_fork: bool,
box_config: &'a BoxConfig,
event_emitter: &'a EventEmitter,
template: &'a Arc<Mutex<TemplateState>>,
) -> BootVmFuture<'a> {
Box::pin(async move {
if snapshot_fork && crate::vm::native_snapshot_fork_supported() {
match Self::ensure_template(box_config, event_emitter, template).await {
Ok(tpl) => {
let mut cfg = box_config.clone();
cfg.snapshot_mem_file = Some(tpl.mem_file.clone());
cfg.restore_from = Some(tpl.state_file.clone());
cfg.snapshot_sock = None;
let mut vm = VmManager::new(cfg, event_emitter.clone());
vm.restore_rootfs_cache_key = tpl.rootfs_cache_key.clone();
let restored = async {
vm.boot().await?;
vm.wait_for_exec_available(std::time::Duration::from_secs(120))
.await
}
.await;
match restored {
Ok(()) => return Ok(vm),
Err(error) => {
let _ = vm.destroy_with_timeout(2000).await;
tracing::warn!(
%error,
"snapshot-fork restore failed; cold-booting this pool VM"
);
}
}
}
Err(error) => {
tracing::debug!(%error, "snapshot-fork unavailable; cold-booting this pool VM");
}
}
} else if snapshot_fork {
tracing::debug!(
"snapshot-fork is unavailable on this build; cold-booting without snapshot side effects"
);
}
let mut vm = VmManager::new(box_config.clone(), event_emitter.clone());
vm.boot().await?;
vm.wait_for_exec_available(std::time::Duration::from_secs(120))
.await?;
Ok(vm)
})
}
async fn boot_batch(batch: BootBatch<'_>) -> Vec<Result<VmManager>> {
if batch.needed == 0 {
return Vec::new();
}
let limit = bounded_boot_limit(batch.needed, batch.max_concurrent_boots);
let mut set = tokio::task::JoinSet::new();
let mut launched = 0usize;
let mut results = Vec::with_capacity(batch.needed);
while launched < batch.needed || !set.is_empty() {
while launched < batch.needed && set.len() < limit {
let config = batch.box_config.clone();
let emitter = batch.event_emitter.clone();
let shared_template = Arc::clone(batch.template);
let boot_metrics = batch.metrics.clone();
let pool_boot_limiter = batch.boot_limiter.clone();
let daemon_boot_limiter = batch.global_boot_limiter.clone();
let snapshot_fork = batch.snapshot_fork;
set.spawn(async move {
let _boot_permits =
acquire_boot_permits(pool_boot_limiter, daemon_boot_limiter).await?;
let _boot_guard = BootMetricGuard::new(boot_metrics);
WarmPool::boot_or_restore(snapshot_fork, &config, &emitter, &shared_template)
.await
});
launched += 1;
}
if let Some(result) = set.join_next().await {
let result = match result {
Ok(result) => result,
Err(error) => Err(BoxError::PoolError(format!(
"Warm-pool boot task failed: {error}"
))),
};
if result.is_err() {
if let Some(metrics) = &batch.metrics {
metrics.warm_pool_boot_failures_total.inc();
}
}
results.push(result);
}
}
results
}
async fn ensure_template(
box_config: &BoxConfig,
event_emitter: &EventEmitter,
template: &Arc<Mutex<TemplateState>>,
) -> Result<PoolTemplate> {
if !crate::vm::native_snapshot_fork_supported() {
return Err(BoxError::PoolError(
"snapshot-fork requires the Linux x86_64 KVM build".to_string(),
));
}
let mut guard = template.lock().await;
let prior_failures = match &*guard {
TemplateState::Ready(t) => return Ok(t.clone()),
TemplateState::Unavailable => {
return Err(BoxError::PoolError(
"snapshot-fork template unavailable (native VM snapshot unsupported)"
.to_string(),
));
}
TemplateState::Failing(n) => *n,
TemplateState::Unbuilt => 0,
};
match Self::build_template(box_config, event_emitter).await {
Ok(tpl) => {
*guard = TemplateState::Ready(tpl.clone());
event_emitter.emit(BoxEvent::with_string(
"pool.template.built",
format!(
"Snapshot-fork template built for image {}",
box_config.image
),
));
Ok(tpl)
}
Err(error) => {
let failures = prior_failures + 1;
if failures >= MAX_TEMPLATE_BUILD_FAILURES {
tracing::warn!(
%error, failures,
"snapshot-fork template build failed repeatedly; marking \
unavailable — the warm pool will cold-boot"
);
*guard = TemplateState::Unavailable;
} else {
tracing::warn!(
%error, failures,
"snapshot-fork template build failed; will retry on a later fill"
);
*guard = TemplateState::Failing(failures);
}
Err(error)
}
}
}
async fn build_template(
box_config: &BoxConfig,
event_emitter: &EventEmitter,
) -> Result<PoolTemplate> {
let dir = a3s_box_core::dirs_home().join("pool").join(format!(
"tpl-{:016x}",
crate::vm::fnv1a_hash(&box_config.image)
));
std::fs::create_dir_all(&dir).map_err(BoxError::IoError)?;
let lock_target = dir.clone();
let _lock =
tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_target))
.await
.map_err(|e| BoxError::PoolError(format!("Template lock task failed: {e}")))?
.map_err(|e| BoxError::PoolError(format!("Failed to lock template dir: {e}")))?;
let mem_file = dir.join("template.ram");
let sock = dir.join("template.sock");
let state_file = dir.join("template.state");
let _ = std::fs::remove_file(&sock);
let mut cfg = box_config.clone();
cfg.snapshot_mem_file = Some(mem_file.to_string_lossy().into_owned());
cfg.snapshot_sock = Some(sock.to_string_lossy().into_owned());
cfg.restore_from = None;
let mut src = VmManager::new(cfg, event_emitter.clone());
src.boot().await?;
let rootfs_cache_key = match src.current_rootfs_cache_key() {
Ok(key) => key,
Err(error) => {
let _ = src.destroy_with_timeout(2000).await;
return Err(error);
}
};
let snapshot = Self::trigger_snapshot(&sock, &state_file).await;
let _ = src.destroy_with_timeout(2000).await;
snapshot?;
Ok(PoolTemplate {
mem_file: mem_file.to_string_lossy().into_owned(),
state_file: state_file.to_string_lossy().into_owned(),
rootfs_cache_key,
})
}
#[cfg(unix)]
async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = None;
for _ in 0..200 {
match tokio::net::UnixStream::connect(sock).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
}
}
let mut stream = stream.ok_or_else(|| {
BoxError::PoolError(format!("snapshot socket {} never appeared", sock.display()))
})?;
let cmd = format!("snapshot {}\n", state_file.display());
stream
.write_all(cmd.as_bytes())
.await
.map_err(BoxError::IoError)?;
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).await.map_err(BoxError::IoError)?;
let reply = String::from_utf8_lossy(&buf[..n]);
if reply.trim() == "ok" {
Ok(())
} else {
Err(BoxError::PoolError(format!(
"snapshot trigger failed: {}",
reply.trim()
)))
}
}
#[cfg(not(unix))]
async fn trigger_snapshot(
_sock: &std::path::Path,
_state_file: &std::path::Path,
) -> Result<()> {
Err(BoxError::PoolError(
"snapshot-fork is only supported on Linux/KVM hosts".to_string(),
))
}
async fn fill_to_target(&self, target: usize) {
let current = self.idle.lock().await.len();
let needed = target.saturating_sub(current);
if needed == 0 {
return;
}
tracing::debug!(current, needed, target, "Replenishing warm pool");
let mut added_ids: Vec<String> = Vec::new();
let mut failed = false;
let results = Self::boot_batch(BootBatch {
snapshot_fork: self.config.snapshot_fork,
box_config: &self.box_config,
event_emitter: &self.event_emitter,
template: &self.template,
needed,
max_concurrent_boots: self.config.max_concurrent_boots,
metrics: self.metrics.clone(),
boot_limiter: self.boot_limiter.clone(),
global_boot_limiter: self.global_boot_limiter.clone(),
})
.await;
for result in results {
match result {
Ok(vm) => {
let box_id = vm.box_id().to_string();
let mut idle = self.idle.lock().await;
idle.push(WarmVm {
vm,
created_at: Instant::now(),
});
Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
let mut stats = self.stats.lock().await;
stats.total_created += 1;
stats.idle_count = idle.len();
added_ids.push(box_id.clone());
self.event_emitter.emit(BoxEvent::with_string(
"pool.vm.created",
format!("Booted new VM {box_id}"),
));
tracing::debug!(box_id = %box_id, "Added VM to warm pool");
}
Err(error) => {
failed = true;
tracing::warn!(error = %error, "Failed to boot VM for warm pool");
}
}
}
if failed && !added_ids.is_empty() {
tracing::info!(
count = added_ids.len(),
"Cleaning up VMs added before pool fill failed"
);
self.remove_idle_vms(&added_ids).await;
}
self.event_emitter.emit(BoxEvent::empty("pool.replenish"));
}
fn spawn_maintenance_loop(&self) -> JoinHandle<()> {
let idle = Arc::clone(&self.idle);
let stats = Arc::clone(&self.stats);
let config = self.config.clone();
let box_config = self.box_config.clone();
let event_emitter = self.event_emitter.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
let scaler = self.scaler.clone();
let template = Arc::clone(&self.template);
let metrics = self.metrics.clone();
let boot_limiter = self.boot_limiter.clone();
let global_boot_limiter = self.global_boot_limiter.clone();
tokio::spawn(async move {
let check_interval = std::time::Duration::from_secs(
if config.idle_ttl_secs > 0 {
(config.idle_ttl_secs / 5).max(5)
} else {
30
},
);
let mut maintenance = tokio::time::interval(check_interval);
maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut effective_min_idle = config.min_idle;
let mut replenish_failures = 0u32;
let mut next_replenish_at = Instant::now();
loop {
tokio::select! {
result = shutdown_rx.changed() => {
if result.is_ok() && *shutdown_rx.borrow() {
tracing::debug!("Pool maintenance loop shutting down");
break;
}
}
_ = maintenance.tick() => {
if config.idle_ttl_secs > 0 {
Self::evict_expired_static(
&idle,
&stats,
&event_emitter,
metrics.as_ref(),
config.idle_ttl_secs,
).await;
}
if let Some(ref scaler) = scaler {
let mut s = scaler.lock().await;
let decision = s.evaluate();
let new_min = s.current_min_idle();
if new_min != effective_min_idle {
tracing::info!(
old_min_idle = effective_min_idle,
new_min_idle = new_min,
?decision,
"Autoscaler adjusted min_idle"
);
event_emitter.emit(BoxEvent::with_string(
"pool.autoscale",
format!(
"min_idle adjusted {} → {} ({:?})",
effective_min_idle, new_min, decision
),
));
effective_min_idle = new_min;
}
}
let current = idle.lock().await.len();
if current < effective_min_idle && Instant::now() >= next_replenish_at {
let needed = effective_min_idle - current;
tracing::debug!(current, needed, min_idle = effective_min_idle, "Replenishing warm pool");
let results = Self::boot_batch(BootBatch {
snapshot_fork: config.snapshot_fork,
box_config: &box_config,
event_emitter: &event_emitter,
template: &template,
needed,
max_concurrent_boots: config.max_concurrent_boots,
metrics: metrics.clone(),
boot_limiter: boot_limiter.clone(),
global_boot_limiter: global_boot_limiter.clone(),
})
.await;
let mut batch_failed = false;
for result in results {
match result {
Ok(mut vm) => {
let box_id = vm.box_id().to_string();
let mut pool = idle.lock().await;
if *shutdown_rx.borrow() {
drop(pool);
tracing::debug!(
box_id = %box_id,
"Pool shutting down mid-replenish; destroying freshly-booted VM"
);
let _ = vm.destroy_with_timeout(2000).await;
continue;
}
pool.push(WarmVm {
vm,
created_at: Instant::now(),
});
Self::sync_idle_metric(metrics.as_ref(), pool.len());
let mut s = stats.lock().await;
s.total_created += 1;
s.idle_count = pool.len();
drop(s);
drop(pool);
event_emitter.emit(BoxEvent::with_string(
"pool.vm.created",
format!("Replenished VM {}", box_id),
));
}
Err(error) => {
batch_failed = true;
tracing::warn!(error = %error, "Failed to replenish warm pool");
}
}
}
if batch_failed {
replenish_failures = replenish_failures.saturating_add(1);
let delay = replenish_backoff_delay(
replenish_failures,
check_interval,
);
next_replenish_at = Instant::now() + delay;
tracing::warn!(
failures = replenish_failures,
retry_in_secs = delay.as_secs(),
"Backing off warm-pool replenishment after boot failure"
);
} else {
replenish_failures = 0;
next_replenish_at = Instant::now();
}
event_emitter.emit(BoxEvent::empty("pool.replenish"));
}
}
}
}
})
}
async fn evict_expired_static(
idle: &Arc<Mutex<Vec<WarmVm>>>,
stats: &Arc<Mutex<PoolStats>>,
event_emitter: &EventEmitter,
metrics: Option<&crate::prom::RuntimeMetrics>,
idle_ttl_secs: u64,
) {
let ttl = std::time::Duration::from_secs(idle_ttl_secs);
let mut pool = idle.lock().await;
let mut kept = Vec::new();
let mut expired = Vec::new();
for warm_vm in pool.drain(..) {
if warm_vm.created_at.elapsed() > ttl {
expired.push(warm_vm);
} else {
kept.push(warm_vm);
}
}
*pool = kept;
let after_count = pool.len();
drop(pool);
let evicted_count = expired.len();
Self::sync_idle_metric(metrics, after_count);
Self::destroy_vms(expired, None, "eviction").await;
if evicted_count > 0 {
let mut s = stats.lock().await;
s.total_evicted += evicted_count as u64;
s.idle_count = after_count;
event_emitter.emit(BoxEvent::with_string(
"pool.vm.evicted",
format!("Evicted {} expired VMs", evicted_count),
));
}
}
}
fn bounded_boot_limit(needed: usize, max_concurrent_boots: usize) -> usize {
needed.min(max_concurrent_boots.max(1))
}
fn replenish_backoff_delay(failures: u32, check_interval: Duration) -> Duration {
let exponent = failures.saturating_sub(1).min(8);
let multiplier = 1u64 << exponent;
let delay_secs = check_interval
.as_secs()
.max(1)
.saturating_mul(multiplier)
.min(300);
Duration::from_secs(delay_secs)
}
#[cfg(test)]
mod shutdown_tests;
#[cfg(test)]
mod tests {
use super::*;
use a3s_box_core::config::PoolConfig;
fn test_pool_config(min_idle: usize, max_size: usize) -> PoolConfig {
PoolConfig {
enabled: true,
min_idle,
max_size,
idle_ttl_secs: 300,
..Default::default()
}
}
fn test_event_emitter() -> EventEmitter {
EventEmitter::new(100)
}
#[test]
fn boot_or_restore_future_stays_heap_indirected() {
let config = BoxConfig::default();
let emitter = test_event_emitter();
let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
let future = WarmPool::boot_or_restore(false, &config, &emitter, &template);
assert!(
std::mem::size_of_val(&future) <= 2 * std::mem::size_of::<usize>(),
"boot_or_restore future must remain pointer-sized so pool misses fit on Tokio worker stacks; got {} bytes",
std::mem::size_of_val(&future)
);
}
#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
#[tokio::test]
async fn unsupported_snapshot_fork_is_rejected_before_template_construction() {
let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
let result =
WarmPool::ensure_template(&BoxConfig::default(), &test_event_emitter(), &template)
.await;
let error = match result {
Ok(_) => panic!("unsupported host unexpectedly built a snapshot template"),
Err(error) => error.to_string(),
};
assert!(error.contains("Linux x86_64 KVM"), "{error}");
assert!(matches!(&*template.lock().await, TemplateState::Unbuilt));
}
#[tokio::test]
async fn test_pool_rejects_zero_max_size() {
let config = test_pool_config(0, 0);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
match result {
Err(e) => assert!(e.to_string().contains("max_size must be greater than 0")),
Ok(_) => panic!("Expected error for zero max_size"),
}
}
#[tokio::test]
async fn test_pool_rejects_min_idle_exceeds_max() {
let config = test_pool_config(10, 5);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
match result {
Err(e) => assert!(e.to_string().contains("cannot exceed max_size")),
Ok(_) => panic!("Expected error for min_idle > max_size"),
}
}
#[tokio::test]
async fn test_pool_rejects_zero_max_concurrent_boots() {
let mut config = test_pool_config(0, 1);
config.max_concurrent_boots = 0;
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
match result {
Err(error) => assert!(error.to_string().contains("max_concurrent_boots")),
Ok(_) => panic!("Expected error for zero max_concurrent_boots"),
}
}
#[tokio::test]
async fn acquire_boot_permits_releases_both_scopes() {
let pool_limiter = Arc::new(Semaphore::new(1));
let global_limiter = Arc::new(Semaphore::new(1));
let (pool_permit, global_permit) =
acquire_boot_permits(pool_limiter.clone(), Some(global_limiter.clone()))
.await
.expect("both boot limiters should grant a permit");
assert_eq!(pool_limiter.available_permits(), 0);
assert_eq!(global_limiter.available_permits(), 0);
drop((pool_permit, global_permit));
assert_eq!(pool_limiter.available_permits(), 1);
assert_eq!(global_limiter.available_permits(), 1);
}
#[test]
fn boot_batch_limit_is_bounded_and_never_deadlocks() {
assert_eq!(bounded_boot_limit(0, 2), 0);
assert_eq!(bounded_boot_limit(8, 2), 2);
assert_eq!(bounded_boot_limit(2, 8), 2);
assert_eq!(bounded_boot_limit(8, 0), 1);
}
#[test]
fn replenish_backoff_is_exponential_and_capped() {
let base = Duration::from_secs(5);
assert_eq!(replenish_backoff_delay(0, base), Duration::from_secs(5));
assert_eq!(replenish_backoff_delay(1, base), Duration::from_secs(5));
assert_eq!(replenish_backoff_delay(2, base), Duration::from_secs(10));
assert_eq!(replenish_backoff_delay(7, base), Duration::from_secs(300));
assert_eq!(replenish_backoff_delay(20, base), Duration::from_secs(300));
}
#[test]
fn boot_metric_guard_balances_inflight_gauge() {
let metrics = crate::prom::RuntimeMetrics::new();
{
let _guard = BootMetricGuard::new(Some(metrics.clone()));
assert_eq!(metrics.warm_pool_boots_inflight.get(), 1);
}
assert_eq!(metrics.warm_pool_boots_inflight.get(), 0);
}
#[test]
fn test_pool_stats_default() {
let stats = PoolStats {
idle_count: 0,
total_created: 0,
total_acquired: 0,
total_released: 0,
total_evicted: 0,
};
assert_eq!(stats.idle_count, 0);
assert_eq!(stats.total_created, 0);
}
#[test]
fn test_pool_stats_clone() {
let stats = PoolStats {
idle_count: 3,
total_created: 10,
total_acquired: 7,
total_released: 5,
total_evicted: 2,
};
let cloned = stats.clone();
assert_eq!(cloned.idle_count, 3);
assert_eq!(cloned.total_created, 10);
assert_eq!(cloned.total_acquired, 7);
assert_eq!(cloned.total_released, 5);
assert_eq!(cloned.total_evicted, 2);
}
#[test]
fn test_pool_stats_debug() {
let stats = PoolStats {
idle_count: 1,
total_created: 2,
total_acquired: 3,
total_released: 4,
total_evicted: 5,
};
let debug = format!("{:?}", stats);
assert!(debug.contains("idle_count"));
assert!(debug.contains("total_created"));
}
#[test]
fn test_pool_config_roundtrip() {
let config = PoolConfig {
enabled: true,
min_idle: 3,
max_size: 10,
idle_ttl_secs: 600,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.min_idle, 3);
assert_eq!(parsed.max_size, 10);
assert_eq!(parsed.idle_ttl_secs, 600);
}
#[test]
fn test_pool_config_default_values() {
let config = PoolConfig::default();
assert!(!config.enabled);
assert_eq!(config.min_idle, 1);
assert_eq!(config.max_size, 5);
assert_eq!(config.idle_ttl_secs, 300);
}
#[test]
fn test_pool_config_deserialization_with_defaults() {
let json = r#"{"enabled": true}"#;
let config: PoolConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert_eq!(config.min_idle, 1);
assert_eq!(config.max_size, 5);
assert_eq!(config.idle_ttl_secs, 300);
}
#[tokio::test]
async fn test_pool_accepts_min_idle_equals_max() {
let config = test_pool_config(3, 3);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
match result {
Err(e) => assert!(!e.to_string().contains("cannot exceed max_size")),
Ok(mut pool) => {
let _ = pool.drain().await;
}
}
}
#[tokio::test]
async fn test_pool_accepts_min_idle_zero() {
let config = test_pool_config(0, 5);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
match result {
Ok(mut pool) => {
assert_eq!(pool.idle_count().await, 0);
let stats = pool.stats().await;
assert_eq!(stats.idle_count, 0);
assert_eq!(stats.total_created, 0);
let _ = pool.drain().await;
}
Err(e) => {
assert!(!e.to_string().contains("max_size"));
assert!(!e.to_string().contains("min_idle"));
}
}
}
#[tokio::test]
async fn test_pool_stats_initial() {
let config = test_pool_config(0, 5);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
if let Ok(mut pool) = result {
let stats = pool.stats().await;
assert_eq!(stats.idle_count, 0);
assert_eq!(stats.total_created, 0);
assert_eq!(stats.total_acquired, 0);
assert_eq!(stats.total_released, 0);
assert_eq!(stats.total_evicted, 0);
let _ = pool.drain().await;
}
}
#[tokio::test]
async fn test_pool_idle_count_initial() {
let config = test_pool_config(0, 5);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
if let Ok(mut pool) = result {
assert_eq!(pool.idle_count().await, 0);
let _ = pool.drain().await;
}
}
#[tokio::test]
async fn test_pool_drain_empty_pool() {
let config = test_pool_config(0, 5);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
if let Ok(mut pool) = result {
let drain_result = pool.drain().await;
assert!(drain_result.is_ok());
let stats = pool.stats().await;
assert_eq!(stats.idle_count, 0);
}
}
#[tokio::test]
async fn test_pool_drain_emits_event() {
let emitter = test_event_emitter();
let mut receiver = emitter.subscribe();
let config = test_pool_config(0, 5);
let result = WarmPool::start(config, BoxConfig::default(), emitter).await;
if let Ok(mut pool) = result {
pool.drain().await.unwrap();
let mut found_drain_event = false;
while let Ok(event) = receiver.try_recv() {
if event.key == "pool.drained" {
found_drain_event = true;
}
}
assert!(found_drain_event, "Expected pool.drained event");
}
}
#[tokio::test]
async fn test_pool_acquire_from_empty_pool_fails_without_shim() {
let config = test_pool_config(0, 5);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
if let Ok(pool) = result {
let acquire_result = pool.acquire().await;
assert!(acquire_result.is_err());
}
}
#[test]
#[allow(clippy::unnecessary_min_or_max)]
fn test_maintenance_check_interval_with_ttl() {
let interval = if 300_u64 > 0 {
(300_u64 / 5).max(5)
} else {
30
};
assert_eq!(interval, 60);
}
#[test]
#[allow(clippy::unnecessary_min_or_max)]
fn test_maintenance_check_interval_short_ttl() {
let interval = if 10_u64 > 0 { (10_u64 / 5).max(5) } else { 30 };
assert_eq!(interval, 5);
}
#[test]
#[allow(clippy::unnecessary_min_or_max)]
fn test_maintenance_check_interval_very_short_ttl() {
let interval = if 1_u64 > 0 { (1_u64 / 5).max(5) } else { 30 };
assert_eq!(interval, 5);
}
#[test]
#[allow(
clippy::absurd_extreme_comparisons,
clippy::erasing_op,
clippy::unnecessary_min_or_max,
unused_comparisons
)]
fn test_maintenance_check_interval_no_ttl() {
let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
assert_eq!(interval, 30);
}
#[test]
fn test_warm_vm_created_at_is_recent() {
let before = Instant::now();
let created_at = Instant::now();
let after = Instant::now();
assert!(created_at >= before);
assert!(created_at <= after);
}
#[test]
fn test_pool_stats_all_fields() {
let stats = PoolStats {
idle_count: 10,
total_created: 100,
total_acquired: 80,
total_released: 70,
total_evicted: 15,
};
assert_eq!(stats.idle_count, 10);
assert_eq!(stats.total_created, 100);
assert_eq!(stats.total_acquired, 80);
assert_eq!(stats.total_released, 70);
assert_eq!(stats.total_evicted, 15);
let debug = format!("{:?}", stats);
assert!(debug.contains("10"));
assert!(debug.contains("100"));
assert!(debug.contains("80"));
assert!(debug.contains("70"));
assert!(debug.contains("15"));
}
#[tokio::test]
async fn test_pool_set_metrics_attaches() {
let config = test_pool_config(0, 5);
let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
match result {
Ok(mut pool) => {
let metrics = crate::prom::RuntimeMetrics::new();
pool.set_metrics(metrics.clone());
assert!(pool.metrics.is_some());
assert_eq!(metrics.warm_pool_hits.get(), 0);
assert_eq!(metrics.warm_pool_misses.get(), 0);
assert_eq!(metrics.warm_pool_size.get(), 0);
let _ = pool.drain().await;
}
Err(_) => {
}
}
}
#[tokio::test]
async fn test_pool_start_with_metrics_installs_sink_before_fill() {
let config = test_pool_config(0, 5);
let metrics = crate::prom::RuntimeMetrics::new();
let result = WarmPool::start_with_metrics(
config,
BoxConfig::default(),
test_event_emitter(),
Some(metrics.clone()),
)
.await;
match result {
Ok(mut pool) => {
assert!(pool.metrics.is_some());
assert_eq!(metrics.warm_pool_capacity.get(), 5);
assert_eq!(
metrics.warm_pool_initial_fill_duration.get_sample_count(),
1
);
let _ = pool.drain().await;
}
Err(_) => {
}
}
}
}