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}