Skip to main content

bge_m3_embedding_server/embedder/
pool.rs

1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `EmbedPool` async wrapper around the worker thread pool.
16
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicUsize, Ordering};
20
21use anyhow::Result;
22use tokio::sync::{Mutex, mpsc, oneshot};
23use tokio::task::JoinHandle;
24use tracing::{Instrument, info, info_span};
25
26use super::math::median_usize;
27use super::trt_warmup::shard_shapes;
28use super::types::{DualEmbedding, EmbedRequest, EmbedStats, ProbeResult, SparseEmbedding};
29use super::worker::{WorkerConfig, run_worker};
30use crate::config::EpSelection;
31
32/// Sender half of the engine propagation broadcast channel.
33type PropTx = tokio::sync::broadcast::Sender<(usize, usize)>;
34
35/// Awaits worker `id`'s readiness signal, but also resolves immediately if the
36/// worker's `JoinHandle` finishes before signaling readiness.
37///
38/// This is the defensive half of the leader-failure fix: `EmbedPool::spawn`
39/// itself owns the original `ready_tx`, which it cannot drop until after every
40/// follower has been spawned. If the worker panics — or for any future reason
41/// drops its `ready_tx` clone before sending — `ready_rx.recv()` would never
42/// see `None` (the init task's own clone is still alive) and the init future
43/// would park forever. Selecting on the `JoinHandle` guarantees we always make
44/// progress when the worker exits, regardless of whether the worker had a
45/// chance to send its outcome.
46///
47/// Returns the worker's `rss_delta` on success. Both branches are propagated
48/// as a typed `Result<usize>` so callers can short-circuit with `?` and avoid
49/// duplicating error-construction logic at every spawn site.
50async fn await_worker_signal(
51    id: usize,
52    handle: &mut JoinHandle<Result<()>>,
53    ready_rx: &mut mpsc::Receiver<Result<usize>>,
54) -> Result<usize> {
55    tokio::select! {
56        // Bias toward the explicit ready signal: if the worker both sent
57        // `Err(...)` and then returned `Err(...)`, both branches are ready and
58        // the in-band failure message is more actionable than the join error.
59        biased;
60        msg = ready_rx.recv() => match msg {
61            Some(Ok(delta)) => Ok(delta),
62            Some(Err(e)) => Err(anyhow::anyhow!("Worker {id} failed to load models: {e}")),
63            None => Err(anyhow::anyhow!(
64                "Worker {id} exited before signaling readiness"
65            )),
66        },
67        join_res = handle => match join_res {
68            Ok(Ok(())) => Err(anyhow::anyhow!(
69                "Worker {id} exited cleanly without signaling readiness"
70            )),
71            Ok(Err(e)) => Err(anyhow::anyhow!(
72                "Worker {id} exited with error before signaling ready: {e}"
73            )),
74            Err(panic_err) => Err(anyhow::anyhow!(
75                "Worker {id} panicked before signaling ready: {panic_err}"
76            )),
77        },
78    }
79}
80
81/// Async handle to the embedding worker thread pool.
82///
83/// Wraps a bounded `mpsc` channel shared by `n` `spawn_blocking` worker threads.
84/// Each worker owns its own ORT session and tokenizer; the pool dispatches
85/// `EmbedRequest` variants to whichever worker is free next.
86///
87/// Clone is cheap — the underlying channel sender and atomic counters are
88/// reference-counted.
89#[derive(Clone)]
90pub struct EmbedPool {
91    tx: mpsc::Sender<EmbedRequest>,
92    live_workers: Arc<AtomicUsize>,
93    /// Number of workers that currently have model instances loaded in memory.
94    loaded_workers: Arc<AtomicUsize>,
95    /// Median RSS delta (bytes) measured across all workers during sequential
96    /// model load.
97    ///
98    /// Workers load one at a time (leader first, then followers in sequence).
99    /// Each reports its own RSS before/after `load_models()` via `ready_tx`.
100    /// The pool stores the median once all workers have signaled ready — robust
101    /// to one outlier from page-cache settling or ORT arena init jitter.
102    ///
103    /// Used by `run_readiness_probe` to correctly deduct the model-weight
104    /// footprint from the available workspace before computing per-worker
105    /// budget. Returns `0` on non-Linux targets where RSS measurement is
106    /// unavailable, or before the init task has completed.
107    model_rss_per_worker_bytes: Arc<AtomicUsize>,
108    /// Broadcast sender for cross-worker TRT engine cache propagation.
109    ///
110    /// When `Some`, after any worker writes a new engine plan to EFS, a
111    /// `(batch, seq)` shape notification is broadcast to every subscribed
112    /// worker so they eagerly run `trt_prewarm` (~1-3s fast disk-load) instead
113    /// of paying full JIT cost on the next real request. `None` when
114    /// `BGE_M3_ENGINE_PROPAGATION_ENABLED=0`.
115    engine_propagation_tx: Option<PropTx>,
116}
117
118impl EmbedPool {
119    /// Broadcasts a `(batch, seq)` shape notification to all subscribed peer
120    /// workers, signaling that the TRT engine plan for this shape is now on EFS.
121    ///
122    /// Workers receive the notification in their main loop drain and run
123    /// `trt_prewarm` against their own session for a ~1-3s fast disk-load
124    /// rather than paying full JIT cost on the next real request for this shape.
125    ///
126    /// `send` returns `Err` only when there are zero subscribers (all workers
127    /// gone). This is non-fatal; the engine plan is still on EFS.
128    pub fn broadcast_engine_ready(&self, shape: (usize, usize)) {
129        if let Some(tx) = &self.engine_propagation_tx {
130            let _ = tx.send(shape);
131        }
132    }
133
134    /// Spawns `n` embedding worker threads and returns the pool plus an init
135    /// handle that resolves once all workers have finished loading their models.
136    ///
137    /// When a GPU execution provider (`cuda` or `tensorrt`) is selected, `n` is
138    /// clamped to `config.gpu_count`: each worker is pinned to a distinct CUDA
139    /// device (`device_id = worker_index % gpu_count`). For TRT, the full
140    /// warmup shape list is sharded across workers via a stride partition so
141    /// each GPU compiles a disjoint subset in parallel, then shares the results
142    /// via the EFS engine cache.
143    #[allow(clippy::too_many_lines)]
144    pub fn spawn(
145        n: usize,
146        cache_dir: PathBuf,
147        config: WorkerConfig,
148    ) -> (Self, JoinHandle<Result<()>>) {
149        let gpu_count = config.gpu_count.max(1);
150        let n = if config.ep != EpSelection::Cpu && n > gpu_count {
151            tracing::warn!(
152                requested = n,
153                clamped = gpu_count,
154                ep = %config.ep,
155                gpu_count,
156                "BGE_M3_WORKERS exceeds BGE_M3_GPU_COUNT for GPU EP — clamping. \
157                 Set BGE_M3_GPU_COUNT to match the number of GPU devices on this instance."
158            );
159            gpu_count
160        } else {
161            n
162        };
163        let capacity = n * 4;
164        let (tx, rx) = mpsc::channel::<EmbedRequest>(capacity);
165        let rx = Arc::new(Mutex::new(rx));
166
167        // The engine propagation broadcast channel is created by the caller
168        // (lib.rs) and passed in via config.engine_propagation_tx. Some(tx)
169        // means propagation is enabled; None means disabled. This eliminates
170        // the redundant engine_propagation_enabled bool (ARC-5).
171        let engine_propagation_tx_for_pool = config.engine_propagation_tx.clone();
172        // Clone for capture into the async init task (used by make_worker_config).
173        let engine_propagation_tx_for_init = config.engine_propagation_tx.clone();
174
175        // Channel carries Result<usize> where the Ok variant is the RSS delta
176        // (bytes) measured by each worker around load_models().
177        let (ready_tx, mut ready_rx) = mpsc::channel::<Result<usize>>(n);
178
179        let live_workers = Arc::new(AtomicUsize::new(n));
180        let loaded_workers = Arc::new(AtomicUsize::new(0));
181        let model_rss_per_worker_bytes = Arc::new(AtomicUsize::new(0));
182        let live_workers_for_init = Arc::clone(&live_workers);
183        let loaded_workers_for_init = Arc::clone(&loaded_workers);
184        let model_rss_for_init = Arc::clone(&model_rss_per_worker_bytes);
185
186        let init_handle = tokio::task::spawn(
187            async move {
188                let mut worker_handles = Vec::with_capacity(n);
189
190                let spawn_worker = |id: usize,
191                                    ready_tx_clone: mpsc::Sender<Result<usize>>,
192                                    worker_config: WorkerConfig|
193                 -> JoinHandle<Result<()>> {
194                    let rx_clone = Arc::clone(&rx);
195                    let cache_dir_clone = cache_dir.clone();
196                    let live_for_worker = Arc::clone(&live_workers_for_init);
197                    let loaded_for_worker = Arc::clone(&loaded_workers_for_init);
198                    tokio::task::spawn_blocking(move || {
199                        run_worker(
200                            id,
201                            cache_dir_clone,
202                            rx_clone,
203                            ready_tx_clone,
204                            live_for_worker,
205                            loaded_for_worker,
206                            worker_config,
207                        )
208                    })
209                };
210
211                // Build a per-worker config: assign CUDA device ID and, for TRT
212                // EP with multiple workers, shard the warmup shapes across GPUs.
213                // The device_id is computed as `worker_index % gpu_count` so
214                // workers round-robin across available GPUs. Shard partition is
215                // stride-based so the most expensive shapes land on different
216                // workers — see `trt_warmup::shard_shapes` for the rationale.
217                let make_worker_config = |id: usize| -> WorkerConfig {
218                    let mut wc = config.clone();
219                    wc.device_id =
220                        u32::try_from(id).unwrap_or(0) % u32::try_from(gpu_count).unwrap_or(1);
221                    // engine_propagation_tx is inherited from config.clone() for
222                    // disabled (None) workers. For enabled workers, re-clone from
223                    // the init-task's captured sender so each worker's tx clone
224                    // shares the same underlying channel.
225                    wc.engine_propagation_tx
226                        .clone_from(&engine_propagation_tx_for_init);
227                    if config.ep == EpSelection::TensorRt
228                        && n > 1
229                        && !config.trt_warmup_shapes.is_empty()
230                    {
231                        wc.trt_warmup_shapes = shard_shapes(&config.trt_warmup_shapes, id, n);
232                        info!(
233                            worker_id = id,
234                            gpu_device = wc.device_id,
235                            shard_shapes = wc.trt_warmup_shapes.len(),
236                            total_shapes = config.trt_warmup_shapes.len(),
237                            total_workers = n,
238                            "TRT multi-GPU: worker assigned GPU device with warmup shard"
239                        );
240                    }
241                    wc
242                };
243
244                // Collect per-worker RSS deltas for median aggregation.
245                // Median is robust to one outlier from transient kernel snapshot
246                // quirk (page-cache settling, ORT arena init jitter) while still
247                // using all N independent measurements.
248                let mut rss_deltas: Vec<usize> = Vec::with_capacity(n);
249
250                // --- Phase 1: spawn leader worker (may download models) ---
251                let mut leader_handle = spawn_worker(0, ready_tx.clone(), make_worker_config(0));
252                let leader_msg = await_worker_signal(0, &mut leader_handle, &mut ready_rx).await?;
253                worker_handles.push(leader_handle);
254                loaded_workers_for_init.fetch_add(1, Ordering::AcqRel);
255                rss_deltas.push(leader_msg);
256                info!(
257                    rss_delta_mb = leader_msg / (1024 * 1024),
258                    "Leader worker ready, model cache warm (1/{n})"
259                );
260
261                // --- Phase 2: spawn follower workers one at a time.
262                //
263                // Workers load sequentially: spawn one, await its ready signal,
264                // then spawn the next. This ensures each worker's RSS delta
265                // (pre/post load_models) reflects only that worker's ORT session
266                // allocation — not the cumulative effect of other workers loading
267                // in parallel. Parallel loading caused an RSS measurement
268                // contamination bug: all followers read post_load_rss after most
269                // other sessions had already mmap'd, producing an inflated
270                // rss_delta ≈ N × model_size and driving per_worker_workspace to 0.
271                //
272                // Startup cost: ~4-6s per worker × 6 followers ≈ 24-36s total,
273                // well within the configured startPeriod (300s).
274                for id in 1..n {
275                    let mut handle = spawn_worker(id, ready_tx.clone(), make_worker_config(id));
276                    let delta = await_worker_signal(id, &mut handle, &mut ready_rx).await?;
277                    worker_handles.push(handle);
278                    loaded_workers_for_init.fetch_add(1, Ordering::AcqRel);
279                    rss_deltas.push(delta);
280                    info!(
281                        rss_delta_mb = delta / (1024 * 1024),
282                        "Follower worker signaled ready ({}/{n})",
283                        id + 1
284                    );
285                }
286
287                drop(ready_tx);
288                drop(worker_handles);
289
290                // Compute and store the median delta as the per-worker model footprint.
291                let median = median_usize(&mut rss_deltas);
292                model_rss_for_init.store(median, Ordering::Release);
293                info!(
294                    median_rss_mb = median / (1024 * 1024),
295                    samples = rss_deltas.len(),
296                    "All workers ready — per-worker model RSS median computed"
297                );
298
299                Ok(())
300            }
301            .instrument(info_span!("embed_pool")),
302        );
303
304        (
305            Self {
306                tx,
307                live_workers,
308                loaded_workers,
309                model_rss_per_worker_bytes,
310                engine_propagation_tx: engine_propagation_tx_for_pool,
311            },
312            init_handle,
313        )
314    }
315
316    /// Runs dense (float32) embedding inference on `texts`.
317    ///
318    /// # Errors
319    ///
320    /// - Returns `Err` if the worker channel has closed (pool shut down).
321    /// - Returns `Err` if the worker drops the reply sender before responding.
322    /// - Returns `Err` if the ORT session fails during inference.
323    pub async fn dense(&self, texts: Vec<String>) -> Result<(Vec<Vec<f32>>, EmbedStats)> {
324        let (reply_tx, reply_rx) = oneshot::channel();
325        self.tx
326            .send(EmbedRequest::Dense {
327                texts,
328                reply: reply_tx,
329            })
330            .await
331            .map_err(|_| anyhow::anyhow!("EmbedPool channel closed"))?;
332        reply_rx
333            .await
334            .map_err(|_| anyhow::anyhow!("Worker dropped reply sender"))?
335    }
336
337    /// Runs sparse (SPLADE-style) embedding inference on `texts`.
338    ///
339    /// # Errors
340    ///
341    /// - Returns `Err` if the worker channel has closed (pool shut down).
342    /// - Returns `Err` if the worker drops the reply sender before responding.
343    /// - Returns `Err` if the ORT session fails during inference.
344    pub async fn sparse(&self, texts: Vec<String>) -> Result<(Vec<SparseEmbedding>, EmbedStats)> {
345        let (reply_tx, reply_rx) = oneshot::channel();
346        self.tx
347            .send(EmbedRequest::Sparse {
348                texts,
349                reply: reply_tx,
350            })
351            .await
352            .map_err(|_| anyhow::anyhow!("EmbedPool channel closed"))?;
353        reply_rx
354            .await
355            .map_err(|_| anyhow::anyhow!("Worker dropped reply sender"))?
356    }
357
358    /// Runs a single forward pass that yields both dense and sparse embeddings.
359    ///
360    /// Equivalent to calling [`Self::dense`] and [`Self::sparse`] back-to-back,
361    /// but uses one `session.run()` per chunk instead of two — at near-zero
362    /// marginal GPU cost.
363    ///
364    /// # Errors
365    ///
366    /// - Returns `Err` if the worker channel has closed (pool shut down).
367    /// - Returns `Err` if the worker drops the reply sender before responding.
368    /// - Returns `Err` if the ORT session fails during inference.
369    pub async fn both(&self, texts: Vec<String>) -> Result<(Vec<DualEmbedding>, EmbedStats)> {
370        let (reply_tx, reply_rx) = oneshot::channel();
371        self.tx
372            .send(EmbedRequest::Both {
373                texts,
374                reply: reply_tx,
375            })
376            .await
377            .map_err(|_| anyhow::anyhow!("EmbedPool channel closed"))?;
378        reply_rx
379            .await
380            .map_err(|_| anyhow::anyhow!("Worker dropped reply sender"))?
381    }
382
383    /// Sends a probe request to a single worker and returns the result.
384    /// Only called during init before `ready` is set.
385    pub(crate) async fn probe(&self, texts: Vec<String>) -> Result<ProbeResult> {
386        let (reply_tx, reply_rx) = oneshot::channel();
387        self.tx
388            .send(EmbedRequest::Probe {
389                texts,
390                reply: reply_tx,
391            })
392            .await
393            .map_err(|_| anyhow::anyhow!("EmbedPool channel closed"))?;
394        reply_rx
395            .await
396            .map_err(|_| anyhow::anyhow!("Worker dropped reply sender"))?
397    }
398
399    #[must_use]
400    /// Returns the number of worker threads currently alive (not yet exited).
401    pub fn live_worker_count(&self) -> usize {
402        self.live_workers.load(Ordering::Acquire)
403    }
404
405    #[must_use]
406    /// Returns the number of workers that currently have model instances loaded in memory.
407    ///
408    /// A worker transitions from loaded to unloaded after the [`crate::config::Config::idle_timeout`]
409    /// elapses with no incoming requests, and back to loaded on the next request.
410    pub fn loaded_worker_count(&self) -> usize {
411        self.loaded_workers.load(Ordering::Acquire)
412    }
413
414    /// Returns the number of requests currently queued but not yet picked up
415    /// by a worker. Uses the channel's current vs max capacity.
416    #[must_use]
417    pub fn queue_depth(&self) -> usize {
418        self.tx.max_capacity().saturating_sub(self.tx.capacity())
419    }
420
421    /// Returns the median RSS delta (bytes) measured across all workers during
422    /// sequential model load.
423    ///
424    /// This is the per-worker model-weight footprint used by
425    /// `run_readiness_probe` to compute the per-worker workspace budget.
426    /// Returns `0` on non-Linux targets where RSS measurement is unavailable,
427    /// or before the init task has completed.
428    #[must_use]
429    pub fn model_rss_per_worker_bytes(&self) -> usize {
430        self.model_rss_per_worker_bytes.load(Ordering::Acquire)
431    }
432
433    /// Sends an adaptive warmup request to an available worker.
434    ///
435    /// The worker calls `trt_prewarm` for `(batch, seq)` and replies on `ack`
436    /// with the compile duration in milliseconds, or an error on failure.
437    /// On non-TRT workers the reply is `Ok(0)` immediately.
438    ///
439    /// # Errors
440    ///
441    /// Returns `Err(())` if the worker channel has closed (pool shut down).
442    pub async fn send_adaptive_warmup(
443        &self,
444        batch: usize,
445        seq: usize,
446        ack: tokio::sync::oneshot::Sender<anyhow::Result<u64>>,
447    ) -> Result<(), ()> {
448        self.tx
449            .send(EmbedRequest::AdaptiveWarmup { batch, seq, ack })
450            .await
451            .map_err(|_| ())
452    }
453
454    /// Returns a clone of the `Arc<AtomicUsize>` backing `live_worker_count`.
455    ///
456    /// Used by the warmup-only path in `lib.rs` to poll worker exit progress
457    /// AFTER the [`EmbedPool`] itself has been dropped. Dropping the pool
458    /// closes the request channel, which signals workers to break out of
459    /// their receive loops and drop their ORT sessions; the live counter is
460    /// the only readable signal that those drop paths have completed.
461    ///
462    /// Returning a clone of the raw `Arc` (rather than a snapshot) lets the
463    /// caller hold a reference across the drop boundary without having to
464    /// keep the pool's other state alive.
465    #[must_use]
466    pub fn live_workers_for_shutdown(&self) -> Arc<AtomicUsize> {
467        Arc::clone(&self.live_workers)
468    }
469}
470
471// ---------------------------------------------------------------------------
472// Test helpers (cfg(test)-gated)
473// ---------------------------------------------------------------------------
474
475#[cfg(test)]
476impl EmbedPool {
477    pub(crate) fn closed_for_test() -> Self {
478        let (tx, rx) = mpsc::channel::<EmbedRequest>(1);
479        drop(rx);
480        Self {
481            tx,
482            live_workers: Arc::new(AtomicUsize::new(0)),
483            loaded_workers: Arc::new(AtomicUsize::new(0)),
484            model_rss_per_worker_bytes: Arc::new(AtomicUsize::new(0)),
485            engine_propagation_tx: None,
486        }
487    }
488
489    pub(crate) fn with_fixed_responses(
490        dense_fixture: Vec<Vec<f32>>,
491        sparse_fixture: Vec<SparseEmbedding>,
492    ) -> Self {
493        let (tx, mut rx) = mpsc::channel::<EmbedRequest>(8);
494        let dense = Arc::new(dense_fixture);
495        let sparse = Arc::new(sparse_fixture);
496        let dense_both = Arc::clone(&dense);
497        let sparse_both = Arc::clone(&sparse);
498        tokio::spawn(async move {
499            while let Some(req) = rx.recv().await {
500                match req {
501                    EmbedRequest::Dense { reply, .. } => {
502                        let _ = reply.send(Ok(((*dense).clone(), EmbedStats::default())));
503                    }
504                    EmbedRequest::Sparse { reply, .. } => {
505                        let _ = reply.send(Ok(((*sparse).clone(), EmbedStats::default())));
506                    }
507                    EmbedRequest::Both { reply, .. } => {
508                        // Pair dense_fixture[i] with sparse_fixture[i] elementwise.
509                        // Truncate to the shorter of the two so the test fixture is
510                        // self-consistent.
511                        let pairs: Vec<DualEmbedding> = dense_both
512                            .iter()
513                            .zip(sparse_both.iter())
514                            .map(|(d, s)| DualEmbedding {
515                                dense: d.clone(),
516                                sparse: s.clone(),
517                            })
518                            .collect();
519                        let _ = reply.send(Ok((pairs, EmbedStats::default())));
520                    }
521                    EmbedRequest::Probe { reply, .. } => {
522                        let _ = reply.send(Ok(ProbeResult {
523                            rss_before: 0,
524                            rss_after: 0,
525                        }));
526                    }
527                    EmbedRequest::AdaptiveWarmup { ack, .. } => {
528                        let _ = ack.send(Ok(0));
529                    }
530                }
531            }
532        });
533        Self {
534            tx,
535            live_workers: Arc::new(AtomicUsize::new(1)),
536            loaded_workers: Arc::new(AtomicUsize::new(1)),
537            model_rss_per_worker_bytes: Arc::new(AtomicUsize::new(0)),
538            engine_propagation_tx: None,
539        }
540    }
541
542    pub(crate) fn idle_for_test() -> Self {
543        let (tx, _rx) = mpsc::channel::<EmbedRequest>(1);
544        Self {
545            tx,
546            live_workers: Arc::new(AtomicUsize::new(1)),
547            loaded_workers: Arc::new(AtomicUsize::new(0)),
548            model_rss_per_worker_bytes: Arc::new(AtomicUsize::new(0)),
549            engine_propagation_tx: None,
550        }
551    }
552
553    /// Creates an [`EmbedPool`] whose `queue_depth()` reports `1`.
554    ///
555    /// The channel is pre-filled with one dummy request before the receiver is
556    /// handed off to a background holder task, so `queue_depth()` returns `1`
557    /// for the lifetime of the pool.  Only valid inside a tokio test context.
558    pub(crate) fn busy_for_test() -> Self {
559        let (tx, rx) = mpsc::channel::<EmbedRequest>(1);
560        // Fill the single slot while the receiver is still in scope.
561        let (reply_tx, _) = tokio::sync::oneshot::channel();
562        let _ = tx.try_send(EmbedRequest::Dense {
563            texts: vec![],
564            reply: reply_tx,
565        });
566        // Hold the receiver alive so the channel is not disconnected.
567        tokio::spawn(async move {
568            let _rx = rx;
569            std::future::pending::<()>().await;
570        });
571        Self {
572            tx,
573            live_workers: Arc::new(AtomicUsize::new(1)),
574            loaded_workers: Arc::new(AtomicUsize::new(1)),
575            model_rss_per_worker_bytes: Arc::new(AtomicUsize::new(0)),
576            engine_propagation_tx: None,
577        }
578    }
579
580    /// Creates an [`EmbedPool`] backed by `with_fixed_responses` with the
581    /// provided broadcast sender wired in.
582    ///
583    /// Used by propagation tests that need to verify `broadcast_engine_ready`
584    /// without spawning real workers.
585    pub(crate) fn for_propagation_test(
586        dense_fixture: Vec<Vec<f32>>,
587        sparse_fixture: Vec<SparseEmbedding>,
588        engine_tx: PropTx,
589    ) -> Self {
590        let pool = Self::with_fixed_responses(dense_fixture, sparse_fixture);
591        Self {
592            tx: pool.tx,
593            live_workers: pool.live_workers,
594            loaded_workers: pool.loaded_workers,
595            model_rss_per_worker_bytes: pool.model_rss_per_worker_bytes,
596            engine_propagation_tx: Some(engine_tx),
597        }
598    }
599
600    /// Creates an [`EmbedPool`] where `AdaptiveWarmup` returns `Ok(compile_ms)`
601    /// with a non-zero value (simulating a TRT worker that compiled an engine).
602    ///
603    /// Used by broadcast tests that need to verify `broadcast_engine_ready` IS
604    /// called when `compile_ms > 0` (i.e. a real TRT compile occurred).
605    pub(crate) fn for_trt_propagation_test(engine_tx: PropTx, compile_ms: u64) -> Self {
606        let (tx, mut rx) = mpsc::channel::<EmbedRequest>(8);
607        tokio::spawn(async move {
608            while let Some(req) = rx.recv().await {
609                match req {
610                    EmbedRequest::AdaptiveWarmup { ack, .. } => {
611                        let _ = ack.send(Ok(compile_ms));
612                    }
613                    EmbedRequest::Dense { reply, .. } => {
614                        let _ = reply.send(Ok((vec![], EmbedStats::default())));
615                    }
616                    EmbedRequest::Sparse { reply, .. } => {
617                        let _ = reply.send(Ok((vec![], EmbedStats::default())));
618                    }
619                    EmbedRequest::Both { reply, .. } => {
620                        let _ = reply.send(Ok((vec![], EmbedStats::default())));
621                    }
622                    EmbedRequest::Probe { reply, .. } => {
623                        let _ = reply.send(Ok(ProbeResult {
624                            rss_before: 0,
625                            rss_after: 0,
626                        }));
627                    }
628                }
629            }
630        });
631        Self {
632            tx,
633            live_workers: Arc::new(AtomicUsize::new(1)),
634            loaded_workers: Arc::new(AtomicUsize::new(1)),
635            model_rss_per_worker_bytes: Arc::new(AtomicUsize::new(0)),
636            engine_propagation_tx: Some(engine_tx),
637        }
638    }
639
640    /// Returns the raw `Arc<AtomicUsize>` backing `model_rss_per_worker_bytes`.
641    ///
642    /// Test-only; allows injecting a specific value to assert aggregation logic
643    /// without running actual model loads.
644    pub(crate) fn model_rss_per_worker_bytes_atomic(&self) -> Arc<AtomicUsize> {
645        Arc::clone(&self.model_rss_per_worker_bytes)
646    }
647}
648
649#[cfg(test)]
650mod tests;
651
652#[cfg(test)]
653mod adaptive_warmup_tests {
654    use super::*;
655
656    /// `send_adaptive_warmup` must return `Err(())` when the channel receiver
657    /// has been dropped (pool shut down).  `closed_for_test()` drops the
658    /// receiver immediately after channel creation, so the very first send
659    /// observes a closed channel.
660    #[tokio::test]
661    async fn send_adaptive_warmup_returns_err_when_channel_closed() {
662        let pool = EmbedPool::closed_for_test();
663        let (ack_tx, _ack_rx) = oneshot::channel();
664        let result = pool.send_adaptive_warmup(1, 128, ack_tx).await;
665        assert!(
666            result.is_err(),
667            "send_adaptive_warmup must return Err(()) when channel is closed"
668        );
669    }
670}