tensor_wasm_exec/instance_pool.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Pre-instantiated instance pool (roadmap feature #5, T37 implementation).
5//!
6//! Pre-spawns N instances per `(tenant_id, module_hash)` tuple and draws
7//! from a per-tuple [`crossbeam_channel`] on `acquire`. Closes the
8//! `Instance::new_async` cost on the warm invocation path. The pooling
9//! allocator + MPK backend already exists (see `EngineConfig::PoolingMpk`);
10//! this module sits on top of it, addressing the instantiation-path cost
11//! that the memory backend does not.
12//!
13//! ## Channel layout
14//!
15//! Each entry in `InstancePool::pools` is a `(Sender, Receiver)` pair
16//! bounded at `warm_instances_per_tuple` (floored at 1 — a capacity-0
17//! `crossbeam_channel` is rendezvous-shaped and would deadlock the
18//! release-to-channel path). The first [`InstancePool::acquire`] call
19//! for a `(tenant, module_hash)` key spawns `warm_instances_per_tuple`
20//! detached instances and seeds the channel with them. Subsequent
21//! `acquire` calls drain the channel via `try_recv` (wait-free, O(1));
22//! on empty they fall through to [`TensorWasmExecutor::spawn_instance`]
23//! up to the executor's existing `max_instances` ceiling.
24//!
25//! Tenant + module isolation is enforced by the key: instance A drawn
26//! by tenant T1 from module M never lands in tenant T2's channel, nor
27//! in T1's channel for module M'. Both halves of the key are part of
28//! the `PoolKey` tuple.
29//!
30//! ## Reset trade-off
31//!
32//! On [`InstancePool::release`], the spent instance is **discarded** and
33//! a fresh one is spawned from the cached [`wasmtime::Module`] to take
34//! its place in the channel. We pay for the wasmtime instantiate
35//! (`Instance::new_async`) but skip the Cranelift compile entirely
36//! (the module is already in the executor's per-module LRU cache).
37//! Rationale:
38//!
39//! - Linear memory must return to its post-`start` snapshot, with no
40//! guest-written bytes leaking across tenants or invocations. The
41//! cheapest way to guarantee this without a per-module bytecode-level
42//! snapshot is to drop the [`wasmtime::Store`] and re-instantiate.
43//! - Mutable globals, table state, and any allocated WASI fds beyond
44//! stdio are scrubbed for free by the same drop.
45//! - The compile step (`Module::from_binary` → Cranelift) is by far the
46//! expensive part of the spawn path on multi-MiB modules; the
47//! `Instance::new_async` cost on a cached module is small but
48//! non-zero (store construction, limiter wiring, `start` execution).
49//!
50//! If the replacement reset exceeds ~10 ms (the deadline-aware drop
51//! threshold), the new instance is dropped rather than enqueued —
52//! cheaper to let the next `acquire` spawn fresh than to block invoke
53//! behind a slow reset.
54//!
55//! ## Streaming spawns are not recycled
56//!
57//! When [`crate::executor::SpawnConfig::streaming`] is set (T34 SSE
58//! `/invoke-stream`), the streaming context carries a one-shot
59//! `mpsc::Sender` whose receiver is drained for the duration of the
60//! response and then dropped. There is no way to "reset" a streaming
61//! context for a subsequent invoke; the gateway constructs a fresh
62//! channel for each call. [`InstancePool::release`] therefore drops
63//! streaming instances unconditionally — they are never returned to
64//! the warm channel.
65
66use std::sync::atomic::{AtomicUsize, Ordering};
67use std::sync::Arc;
68use std::time::{Duration, Instant};
69
70use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
71use dashmap::DashMap;
72use tensor_wasm_core::types::{InstanceId, TenantId};
73use tracing::warn;
74
75use crate::executor::{ExecError, SpawnConfig, TensorWasmExecutor};
76use crate::instance::TensorWasmInstance;
77
78/// BLAKE3 digest of a Wasm module's bytes. Re-exported so embedders that
79/// hold module hashes outside the pool (e.g. for diagnostic logging)
80/// share the same shape.
81pub type ModuleHash = [u8; 32];
82
83/// Hard cap on how long a single reset+enqueue may take before the
84/// replacement instance is dropped instead of being placed in the
85/// channel. Chosen as a deadline-aware "fast enough or skip" threshold:
86/// at the bench loop's hot path, blocking invoke for more than 10 ms on
87/// a pool replenishment defeats the latency win the pool exists for.
88///
89/// Pool drops that exceed this budget log a `warn!` so operators see
90/// the regression — a sustained miss usually indicates Cranelift cache
91/// thrash or pathological `start`-function work.
92const POOL_RESET_DEADLINE: Duration = Duration::from_millis(10);
93
94/// Pool configuration. One pool per executor.
95#[derive(Debug, Clone, Default)]
96#[non_exhaustive]
97pub struct InstancePoolConfig {
98 /// Pre-spawn N instances per (tenant, module-hash) tuple. Default 0
99 /// = pool disabled (every `acquire` falls through to
100 /// [`TensorWasmExecutor::spawn_instance`]).
101 ///
102 /// The pool's channel capacity is `max(warm_instances_per_tuple, 1)`
103 /// — a rendezvous channel (capacity 0) would deadlock the release
104 /// path, so we floor the channel size at 1 even when pre-warming is
105 /// disabled. With a 0 setting the warm channel is empty at startup
106 /// and the first `release` populates it; this gives the second
107 /// `acquire` of the same tuple a warm draw at the cost of paying
108 /// cold-start on the first.
109 pub warm_instances_per_tuple: usize,
110
111 /// Maximum number of pre-spawned instances across all tuples. Pools
112 /// honour this cap before spawning a new tuple. Default 0 = unlimited
113 /// (within the executor's `max_instances`).
114 pub max_total_warm: usize,
115}
116
117impl InstancePoolConfig {
118 /// Construct a pool config. Kept available for foreign-crate callers
119 /// even though [`InstancePoolConfig`] is `#[non_exhaustive]`.
120 pub fn new(warm_instances_per_tuple: usize, max_total_warm: usize) -> Self {
121 Self {
122 warm_instances_per_tuple,
123 max_total_warm,
124 }
125 }
126}
127
128/// Key for the per-tuple warm-pool map. Both halves are load-bearing:
129/// the tenant id enforces tenant isolation (T2 must never observe an
130/// instance previously used by T1), and the module hash enforces module
131/// isolation (a tenant's module A invoke must never observe an instance
132/// compiled from module B).
133#[derive(Debug, Clone, PartialEq, Eq, Hash)]
134struct PoolKey {
135 tenant_id: TenantId,
136 module_hash: ModuleHash,
137}
138
139/// Per-tuple entry: a bounded sender/receiver pair plus a cached
140/// [`wasmtime::Module`] so the reset path skips re-compilation.
141struct PoolEntry {
142 sender: Sender<TensorWasmInstance>,
143 receiver: Receiver<TensorWasmInstance>,
144 module: wasmtime::Module,
145 /// Capacity the channel was constructed with (mirrors
146 /// `config.warm_instances_per_tuple` floored at 1). Held for
147 /// observability — `try_send` already returns `TrySendError::Full`
148 /// so the runtime path does not consult this field.
149 #[allow(dead_code)]
150 capacity: usize,
151}
152
153/// Pre-instantiated instance pool. Backed by a per-tuple
154/// `crossbeam_channel` of pre-spawned instances; see module docs for the
155/// channel layout, reset trade-off, and streaming-spawn carve-out.
156pub struct InstancePool {
157 cfg: InstancePoolConfig,
158 /// Per-(tenant, module-hash) channel registry. [`DashMap`] gives
159 /// wait-free reads on the hot acquire path: `try_recv` is a single
160 /// atomic operation against the per-entry channel.
161 pools: Arc<DashMap<PoolKey, Arc<PoolEntry>>>,
162 /// Per-key single-flight guard for the first-build pre-spawn loop.
163 /// Concurrent first-`acquire`s for the same `(tenant, module_hash)`
164 /// share one [`tokio::sync::OnceCell`]: exactly one runs the
165 /// (`await`-heavy) pre-spawn loop while the losers park on
166 /// `get_or_try_init` and observe the winner's [`PoolEntry`] instead
167 /// of redundantly Cranelift-compiling/instantiating their own
168 /// (thundering-herd fix). The shard write-guard on this map is only
169 /// held long enough to clone the `Arc<OnceCell>` — never across the
170 /// `await` inside the initializer — so it cannot deadlock the build.
171 inflight: Arc<DashMap<PoolKey, Arc<tokio::sync::OnceCell<Arc<PoolEntry>>>>>,
172 /// Total warm-and-in-channel count across all entries. Counts UP on
173 /// pre-spawn / release-enqueue and DOWN on acquire-from-channel /
174 /// reset-failure-drop. Operators observe this via
175 /// [`Self::warm_count`].
176 warm_total: Arc<AtomicUsize>,
177 /// Total successful pool draws (hits + misses). Inc'd on every
178 /// [`Self::acquire`] regardless of outcome; the
179 /// [`Self::hit_count`] / [`Self::miss_count`] split below records
180 /// which path was taken.
181 draws_total: Arc<AtomicUsize>,
182 /// Subset of `draws_total` that hit a warm instance in the channel.
183 hit_count: Arc<AtomicUsize>,
184 /// Subset of `draws_total` that fell through to a fresh spawn.
185 miss_count: Arc<AtomicUsize>,
186 /// Number of releases that resulted in the instance being dropped
187 /// (channel full, reset failed, streaming carve-out, or deadline
188 /// exceeded). Distinct from the executor's
189 /// `instance_terminations_total`: terminations come from the
190 /// registry path, drops come from the pool path.
191 drops_total: Arc<AtomicUsize>,
192}
193
194impl InstancePool {
195 /// Construct a new pool with the given config.
196 pub fn new(cfg: InstancePoolConfig) -> Self {
197 Self {
198 cfg,
199 pools: Arc::new(DashMap::new()),
200 inflight: Arc::new(DashMap::new()),
201 warm_total: Arc::new(AtomicUsize::new(0)),
202 draws_total: Arc::new(AtomicUsize::new(0)),
203 hit_count: Arc::new(AtomicUsize::new(0)),
204 miss_count: Arc::new(AtomicUsize::new(0)),
205 drops_total: Arc::new(AtomicUsize::new(0)),
206 }
207 }
208
209 /// Effective channel capacity per tuple. Floored at 1 so the bounded
210 /// channel does not degenerate to a rendezvous shape (which would
211 /// deadlock the release path).
212 fn channel_capacity(&self) -> usize {
213 self.cfg.warm_instances_per_tuple.max(1)
214 }
215
216 /// Acquire a pre-spawned instance or fall through to a fresh spawn.
217 ///
218 /// Algorithm (T37):
219 /// 1. Compute the `(tenant, module_hash)` key — building the entry
220 /// on first observation by compiling the module (via the
221 /// executor's cached path), allocating a bounded channel of the
222 /// configured size, and pre-spawning `warm_instances_per_tuple`
223 /// detached instances into it.
224 /// 2. `try_recv` on the per-tuple channel. On hit, register the
225 /// drawn instance with the executor and return a
226 /// [`PooledInstance`] handle.
227 /// 3. On miss, fall through to [`TensorWasmExecutor::spawn_instance`]
228 /// so the call still proceeds (admission control still applies —
229 /// the executor's `max_instances` cap fires here if saturated).
230 ///
231 /// Streaming spawns ([`SpawnConfig::streaming`] set) bypass the
232 /// warm-channel try_recv and always fall through to a fresh spawn:
233 /// the streaming context is one-shot (the gateway-held receiver is
234 /// drained per call), so the warm channel never holds streaming
235 /// instances.
236 pub async fn acquire(
237 &self,
238 executor: &TensorWasmExecutor,
239 wasm: &[u8],
240 cfg: SpawnConfig,
241 ) -> Result<PooledInstance, ExecError> {
242 self.draws_total.fetch_add(1, Ordering::Relaxed);
243
244 // Streaming carve-out: never serve a streaming spawn from the
245 // warm channel. The gateway built a fresh channel for THIS
246 // invoke; reusing a stale streaming-state instance would
247 // either drop the new emit-chunk calls on the floor or leak
248 // the gateway's receiver across requests. Fall straight through
249 // to spawn_instance.
250 if cfg.streaming.is_some() {
251 self.miss_count.fetch_add(1, Ordering::Relaxed);
252 let id = executor.spawn_instance(cfg, wasm).await?;
253 return Ok(PooledInstance {
254 inner: Some(id),
255 origin: None,
256 });
257 }
258
259 // Input carve-out (M-1): never serve an input-bearing spawn from
260 // the warm channel. Warm instances bake the FIRST caller's
261 // `SpawnConfig::input` into their `InstanceState` at build time;
262 // `register_pooled_instance` resets only the `instance_id`, never
263 // re-staging this caller's `input`. Reusing a warm instance for a
264 // spawn that carries its own input would let request N+1 observe
265 // request N's staged input bytes via the guest input channel
266 // (same-tenant input bleed). Fall straight through to
267 // `spawn_instance` so this caller's `input` is staged onto a
268 // fresh instance, and stamp `origin: None` so `release` drops it
269 // rather than recycling its input-tainted state — mirroring the
270 // streaming carve-out above.
271 if !cfg.input.is_empty() {
272 self.miss_count.fetch_add(1, Ordering::Relaxed);
273 let id = executor.spawn_instance(cfg, wasm).await?;
274 return Ok(PooledInstance {
275 inner: Some(id),
276 origin: None,
277 });
278 }
279
280 // Compute the digest now so we can stamp the origin on the
281 // returned handle. `blake3::hash` is SIMD-fast; the executor's
282 // module cache uses the same key shape.
283 let module_hash: ModuleHash = *blake3::hash(wasm).as_bytes();
284
285 // Ensure the per-tuple entry exists (and is pre-warmed). This
286 // returns the cached Module alongside the channel handles so
287 // the reset-on-release path can re-instantiate without paying
288 // for another compile.
289 let entry = self.ensure_entry(executor, wasm, &cfg, module_hash).await?;
290
291 // Try the warm channel first. `try_recv` is wait-free.
292 match entry.receiver.try_recv() {
293 Ok(inst) => {
294 self.warm_total.fetch_sub(1, Ordering::Relaxed);
295 self.hit_count.fetch_add(1, Ordering::Relaxed);
296 let id = executor.register_pooled_instance(inst)?;
297 Ok(PooledInstance {
298 inner: Some(id),
299 origin: Some(module_hash),
300 })
301 }
302 Err(_empty) => {
303 // Warm pool exhausted (or never seeded with
304 // `warm_instances_per_tuple = 0`). Fall through to a
305 // fresh spawn — the executor's `max_instances`
306 // admission control still applies here.
307 self.miss_count.fetch_add(1, Ordering::Relaxed);
308 let id = executor.spawn_instance(cfg, wasm).await?;
309 Ok(PooledInstance {
310 inner: Some(id),
311 origin: Some(module_hash),
312 })
313 }
314 }
315 }
316
317 /// Release an instance back to the pool.
318 ///
319 /// The instance is detached from the executor's registry (the slot
320 /// stays charged for the moment) and dropped — its linear memory
321 /// could be holding sensitive guest state, so "reset" here means
322 /// "re-instantiate", never "rewind". A fresh replacement is then
323 /// spawned from the cached [`wasmtime::Module`] and `try_send`'d to
324 /// the channel; on full / send-error / streaming carve-out / reset
325 /// deadline exceeded, the replacement is dropped and the slot is
326 /// released.
327 ///
328 /// `pooled` carries the originating module hash so the release path
329 /// dispatches to the correct per-tuple channel without re-hashing
330 /// (the release path no longer has the wasm bytes on hand).
331 pub async fn release(
332 &self,
333 executor: &TensorWasmExecutor,
334 pooled: PooledInstance,
335 cfg: &SpawnConfig,
336 ) {
337 let id = pooled.id();
338 let origin = pooled.origin();
339 // Defuse the handle so its `Drop` is a no-op — we own the
340 // lifecycle from here.
341 let _ = pooled.into_inner();
342
343 // Detach the spent instance from the registry. The slot stays
344 // charged — we'll release it explicitly below after deciding
345 // whether to enqueue a replacement.
346 let spent = match executor.detach_pooled_instance(id).await {
347 Some(inst) => inst,
348 None => {
349 // Already gone — nothing to release. This is a
350 // double-release race (e.g. the auto-terminate guard
351 // fired). Skip silently; the slot accounting is the
352 // executor's responsibility on the terminate path.
353 return;
354 }
355 };
356
357 // Drop the spent instance BEFORE we attempt to spawn a
358 // replacement so its slot is freed first — under tight
359 // `max_instances`, charging the replacement before releasing
360 // the spent one would trip CapacityExhausted.
361 drop(spent);
362 executor.release_instance_slot(cfg.tenant_id);
363
364 // Streaming spawns are never recycled — count the drop and
365 // stop here. See module docs.
366 if cfg.streaming.is_some() || origin.is_none() {
367 self.drops_total.fetch_add(1, Ordering::Relaxed);
368 return;
369 }
370 let module_hash = origin.expect("origin is Some by the check above");
371
372 let key = PoolKey {
373 tenant_id: cfg.tenant_id,
374 module_hash,
375 };
376 let entry = match self.pools.get(&key) {
377 Some(e) => e.value().clone(),
378 None => {
379 // Entry vanished (shutdown / config reload race). The
380 // spent slot is already released; nothing to do.
381 self.drops_total.fetch_add(1, Ordering::Relaxed);
382 return;
383 }
384 };
385
386 // Global cap check: don't replenish past `max_total_warm`.
387 if self.cfg.max_total_warm > 0
388 && self.warm_total.load(Ordering::Relaxed) >= self.cfg.max_total_warm
389 {
390 self.drops_total.fetch_add(1, Ordering::Relaxed);
391 return;
392 }
393
394 // Spawn the replacement under the reset-deadline budget.
395 // Re-instantiating from a cached module skips the Cranelift
396 // compile entirely; only the `Instance::new_async` cost
397 // remains. If it overruns the budget we drop the replacement
398 // and let the next acquire spawn fresh on its own time.
399 let start = Instant::now();
400 let replacement = match executor
401 .rebuild_pooled_from_module(cfg, &entry.module)
402 .await
403 {
404 Ok(inst) => inst,
405 Err(err) => {
406 // Reset failed — defensible failure modes are
407 // `CapacityExhausted` (we lost the race to a peer
408 // acquire), `EpochTickerNotRunning` (the ticker died
409 // mid-flight), or a wasmtime trap inside `start`.
410 warn!(
411 target: "tensor_wasm_exec::instance_pool",
412 error = %err,
413 "pool reset failed; replacement instance dropped",
414 );
415 self.drops_total.fetch_add(1, Ordering::Relaxed);
416 return;
417 }
418 };
419 let elapsed = start.elapsed();
420 if elapsed > POOL_RESET_DEADLINE {
421 warn!(
422 target: "tensor_wasm_exec::instance_pool",
423 elapsed_ms = elapsed.as_millis() as u64,
424 budget_ms = POOL_RESET_DEADLINE.as_millis() as u64,
425 "pool reset exceeded deadline; replacement instance dropped to keep invoke latency bounded",
426 );
427 drop(replacement);
428 executor.release_instance_slot(cfg.tenant_id);
429 self.drops_total.fetch_add(1, Ordering::Relaxed);
430 return;
431 }
432
433 // Enqueue the fresh instance. On `Full` the channel is at
434 // capacity — drop the replacement (the channel cannot hold
435 // more than `warm_instances_per_tuple`).
436 match entry.sender.try_send(replacement) {
437 Ok(()) => {
438 self.warm_total.fetch_add(1, Ordering::Relaxed);
439 }
440 Err(TrySendError::Full(dropped)) | Err(TrySendError::Disconnected(dropped)) => {
441 drop(dropped);
442 executor.release_instance_slot(cfg.tenant_id);
443 self.drops_total.fetch_add(1, Ordering::Relaxed);
444 }
445 }
446 }
447
448 /// Ensure the per-tuple entry exists, creating it and pre-warming
449 /// it on the first observation. The cached [`wasmtime::Module`] is
450 /// produced as a side-effect (via the executor's per-module LRU
451 /// cache) so subsequent reset calls don't re-run Cranelift.
452 async fn ensure_entry(
453 &self,
454 executor: &TensorWasmExecutor,
455 wasm: &[u8],
456 cfg: &SpawnConfig,
457 module_hash: ModuleHash,
458 ) -> Result<Arc<PoolEntry>, ExecError> {
459 // On the hot path the entry already exists and we return early
460 // without touching the module compiler.
461 let key = PoolKey {
462 tenant_id: cfg.tenant_id,
463 module_hash,
464 };
465 if let Some(entry) = self.pools.get(&key) {
466 return Ok(entry.value().clone());
467 }
468
469 // First observation for this tuple — pre-warm under a per-key
470 // single-flight guard. Two concurrent first-`acquire`s both miss
471 // the `pools.get` fast path above; without this guard they would
472 // each run the full pre-spawn loop (Cranelift-compiling and
473 // instantiating `warm_n` instances apiece) and the loser would
474 // then have to drain + refund its wasted work. Instead we share
475 // one `OnceCell` per key so only the winner runs the build; the
476 // losers park on `get_or_try_init` and observe the winner's
477 // `PoolEntry`.
478 //
479 // The shard write-guard on `inflight` is dropped immediately
480 // after we clone the `Arc<OnceCell>` (the `entry`/`or_insert`
481 // call below borrows the shard only for that statement), so it is
482 // never held across the `await` inside the initializer — no
483 // shard-guard-across-await deadlock.
484 let cell = self
485 .inflight
486 .entry(key.clone())
487 .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new()))
488 .value()
489 .clone();
490
491 // `get_or_try_init` runs the initializer on exactly one task per
492 // `OnceCell`; concurrent callers await its completion and clone
493 // the resulting `Arc<PoolEntry>`. On initializer error every
494 // waiter observes the same `Err` and no `PoolEntry` is cached, so
495 // a later `acquire` retries the build cleanly.
496 let entry = cell
497 .get_or_try_init(|| self.build_entry(executor, wasm, cfg, key.clone()))
498 .await?
499 .clone();
500
501 // The entry now lives in `pools` (the fast path will find it) and
502 // in the cell. Drop the single-flight cell so `inflight` does not
503 // grow unbounded; a straggler that already cloned this `Arc` is
504 // unaffected, and a fresh caller hits the `pools` fast path.
505 self.inflight.remove(&key);
506
507 Ok(entry)
508 }
509
510 /// Build and register the per-tuple [`PoolEntry`]: allocate the
511 /// bounded channel, run the pre-spawn loop, and insert into `pools`.
512 /// Invoked at most once per key via the `inflight` `OnceCell`, so the
513 /// slot/`warm_total` accounting here is driven by a single builder —
514 /// there is no lost-race refund to perform. The previous
515 /// double-build defence (drain-local-channel + refund slots on a lost
516 /// `DashEntry` race) is therefore gone: a second builder can no
517 /// longer exist for the same key.
518 async fn build_entry(
519 &self,
520 executor: &TensorWasmExecutor,
521 wasm: &[u8],
522 cfg: &SpawnConfig,
523 key: PoolKey,
524 ) -> Result<Arc<PoolEntry>, ExecError> {
525 // We use `build_pooled_instance` for the first instance so the
526 // compiled module + hash are returned, then
527 // `rebuild_pooled_from_module` for the rest (no need to
528 // re-compute the hash or re-check the cache).
529 let warm_n = self.cfg.warm_instances_per_tuple;
530 // Effective channel capacity must accommodate `warm_n` plus at
531 // least the slot we'll fill on a single release.
532 let cap = self.channel_capacity();
533 let (sender, receiver) = bounded::<TensorWasmInstance>(cap);
534
535 let mut module_opt: Option<wasmtime::Module> = None;
536 for i in 0..warm_n {
537 // Global cap check: respect `max_total_warm`.
538 if self.cfg.max_total_warm > 0
539 && self.warm_total.load(Ordering::Relaxed) >= self.cfg.max_total_warm
540 {
541 break;
542 }
543 if i == 0 {
544 let (inst, module, _) = executor.build_pooled_instance(cfg, wasm).await?;
545 // Single builder per key + a channel sized at `cap >=
546 // warm_n` means `try_send` cannot fail here for capacity
547 // reasons; treat any error defensively by releasing the
548 // slot we charged so admission control stays balanced.
549 if sender.try_send(inst).is_err() {
550 executor.release_instance_slot(cfg.tenant_id);
551 } else {
552 self.warm_total.fetch_add(1, Ordering::Relaxed);
553 }
554 module_opt = Some(module);
555 } else {
556 let module = module_opt.as_ref().expect("module captured on i==0");
557 let inst = executor.rebuild_pooled_from_module(cfg, module).await?;
558 if sender.try_send(inst).is_err() {
559 executor.release_instance_slot(cfg.tenant_id);
560 } else {
561 self.warm_total.fetch_add(1, Ordering::Relaxed);
562 }
563 }
564 }
565 // If `warm_n == 0` we never compiled — do it now so the cached
566 // Module is available for the reset path.
567 let module = match module_opt {
568 Some(m) => m,
569 None => {
570 // `build_pooled_instance` charges a slot we don't
571 // want here (we're only after the module). Use the
572 // module-cache path directly instead.
573 //
574 // Note: this branch only runs when
575 // `warm_instances_per_tuple == 0`. We still need a
576 // cached module to fuel the reset path on the first
577 // release after a miss-spawn.
578 //
579 // The executor's `compile_module_cached` is async
580 // because Cranelift compile is dispatched to a
581 // blocking pool; we call through `build_pooled_instance`
582 // here too and immediately release the slot + drop the
583 // instance — simplest path that keeps every internal
584 // invariant honest.
585 let (inst, module, _) = executor.build_pooled_instance(cfg, wasm).await?;
586 drop(inst);
587 executor.release_instance_slot(cfg.tenant_id);
588 module
589 }
590 };
591 let entry = Arc::new(PoolEntry {
592 sender,
593 receiver,
594 module,
595 capacity: cap,
596 });
597 // Single-flight: this is the only builder for `key`, so the
598 // insert is uncontended. Use `entry()` purely to insert; a
599 // pre-existing value would be a logic error (the `OnceCell`
600 // already serialises us), so overwrite-by-insert is fine.
601 self.pools.insert(key, entry.clone());
602 Ok(entry)
603 }
604
605 /// Number of currently warm (in-channel) instances across all tuples.
606 pub fn warm_count(&self) -> usize {
607 self.warm_total.load(Ordering::Relaxed)
608 }
609
610 /// Total successful pool draws (hits + misses). Monotonic counter.
611 pub fn draws_total(&self) -> usize {
612 self.draws_total.load(Ordering::Relaxed)
613 }
614
615 /// Draws that hit a warm instance in the channel. Monotonic counter.
616 pub fn hit_count(&self) -> usize {
617 self.hit_count.load(Ordering::Relaxed)
618 }
619
620 /// Draws that fell through to a fresh spawn. Monotonic counter.
621 pub fn miss_count(&self) -> usize {
622 self.miss_count.load(Ordering::Relaxed)
623 }
624
625 /// Number of pool drops (channel full, reset failure, streaming
626 /// carve-out, or reset deadline exceeded). Monotonic counter.
627 pub fn drops_total(&self) -> usize {
628 self.drops_total.load(Ordering::Relaxed)
629 }
630
631 /// Borrow the pool's configuration.
632 pub fn config(&self) -> &InstancePoolConfig {
633 &self.cfg
634 }
635
636 /// Drain every warm channel and release its instances. Called by
637 /// embedders that want to free pool-held wasmtime resources without
638 /// dropping the executor (e.g. on a config reload).
639 ///
640 /// Idempotent — calling on an already-empty pool is a no-op. Slot
641 /// accounting on the executor is restored: every instance pulled
642 /// out is dropped, and the corresponding slot is released.
643 pub fn shutdown(&self, executor: &TensorWasmExecutor) {
644 for entry in self.pools.iter() {
645 let tenant = entry.key().tenant_id;
646 while let Ok(inst) = entry.value().receiver.try_recv() {
647 drop(inst);
648 executor.release_instance_slot(tenant);
649 self.warm_total.fetch_sub(1, Ordering::Relaxed);
650 self.drops_total.fetch_add(1, Ordering::Relaxed);
651 }
652 }
653 }
654}
655
656/// RAII wrapper around a pool-drawn instance.
657///
658/// On the success path the caller's `invoke` loop calls
659/// [`InstancePool::release`] explicitly before this handle drops, which
660/// is what returns the instance (via reset + fresh-replacement) to the
661/// warm channel. If the handle is dropped *without* an explicit
662/// release — caller panic, future cancellation, etc. — the instance
663/// remains registered with the executor and the existing auto-terminate
664/// drop-guard (api S-20) in `call_export_with_args_then_terminate`
665/// catches it. A bare-drop without an outer terminate guard leaks the
666/// registry entry; T37 keeps that case out of scope.
667///
668/// The handle carries the originating module hash so
669/// [`InstancePool::release`] can dispatch to the correct per-tuple
670/// channel without re-hashing the wasm bytes (which the release path
671/// no longer has on hand).
672pub struct PooledInstance {
673 inner: Option<InstanceId>,
674 /// Module hash for the (tenant, module) tuple this instance was
675 /// drawn from. `None` for the streaming carve-out path, which
676 /// never returns to a channel — release simply drops these.
677 origin: Option<ModuleHash>,
678}
679
680impl PooledInstance {
681 /// Borrow the underlying [`InstanceId`].
682 pub fn id(&self) -> InstanceId {
683 self.inner.expect("PooledInstance was already returned")
684 }
685
686 /// Take ownership of the underlying [`InstanceId`] (caller becomes
687 /// responsible for the lifecycle).
688 pub fn into_inner(mut self) -> InstanceId {
689 self.inner
690 .take()
691 .expect("PooledInstance was already returned")
692 }
693
694 /// Module hash this handle was drawn from (when known). Streaming
695 /// spawns return `None` — they bypass the warm channel.
696 pub(crate) fn origin(&self) -> Option<ModuleHash> {
697 self.origin
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[test]
706 fn default_config_is_disabled() {
707 let cfg = InstancePoolConfig::default();
708 assert_eq!(cfg.warm_instances_per_tuple, 0);
709 assert_eq!(cfg.max_total_warm, 0);
710 }
711
712 #[test]
713 fn warm_count_starts_at_zero() {
714 let pool = InstancePool::new(InstancePoolConfig::default());
715 assert_eq!(pool.warm_count(), 0);
716 }
717
718 #[test]
719 fn config_round_trips() {
720 let pool = InstancePool::new(InstancePoolConfig {
721 warm_instances_per_tuple: 4,
722 max_total_warm: 32,
723 });
724 assert_eq!(pool.config().warm_instances_per_tuple, 4);
725 assert_eq!(pool.config().max_total_warm, 32);
726 }
727
728 #[test]
729 fn channel_capacity_floor_at_one() {
730 // A `warm_instances_per_tuple = 0` config must still allocate a
731 // capacity-1 channel — a rendezvous channel would deadlock the
732 // release path.
733 let pool = InstancePool::new(InstancePoolConfig::default());
734 assert_eq!(pool.channel_capacity(), 1);
735 }
736}