Skip to main content

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}