Skip to main content

sqlite_graphrag/embedder/
batch.rs

1//! Batch sizing, bounded parallel fan-out and entity-embed cache.
2//!
3//! G42/S2–S3 / G44 / G56: adaptive batch sizes, `Arc<Semaphore>` permit
4//! accounting, OpenRouter REST batch fan-out, and the process-wide entity
5//! embedding cache. Split from the root embedder module (R-SRP-01) so the
6//! single-vector paths stay independent of the multi-text orchestration.
7
8use super::{
9    clone_client, embed_passage, embed_passages_controlled, get_embedder,
10    is_openrouter_initialized, shared_runtime, LlmBackendKind, OPENROUTER_CLIENT,
11};
12use crate::errors::AppError;
13use crate::extract::llm_embedding::LlmEmbedding;
14use parking_lot::Mutex;
15use std::path::Path;
16use std::sync::Arc;
17use std::sync::OnceLock;
18use tokio::sync::{mpsc, Semaphore};
19use tokio::task::JoinSet;
20use tokio_util::sync::CancellationToken;
21
22/// Calibration base: chunk (long-text) batch size per LLM call at the
23/// calibration dimensionality (G42/S2). Use [`chunk_embed_batch_size`]
24/// for the dim-adaptive value (G44).
25pub const CHUNK_EMBED_BATCH_SIZE: usize = 8;
26
27/// Calibration base: entity-name (short-text) batch size per LLM call at
28/// the calibration dimensionality (G42/S2). Use [`entity_embed_batch_size`]
29/// for the dim-adaptive value (G44).
30pub const ENTITY_EMBED_BATCH_SIZE: usize = 25;
31
32/// Dimensionality the batch bases above were calibrated against (G44).
33pub const EMBED_BATCH_CALIBRATION_DIM: usize = 64;
34
35/// G44: scales a calibration-base batch size to the active dimensionality,
36/// keeping the float budget per LLM call constant (~512 floats for chunks,
37/// ~1600 for entity names — the budgets empirically validated at dim 64).
38/// Fixed batches of 8 at 384 dims asked for ~3072 floats per response:
39/// claude returned partial coverage (3 of 8 items, caught by the G42/C5
40/// check) and codex timed out at 300s. `base.max(1)` keeps the function
41/// total — `clamp` panics when the upper bound is below the lower one.
42pub(crate) fn adaptive_batch_for_dim(base: usize, dim: usize) -> usize {
43    let base = base.max(1);
44    (base * EMBED_BATCH_CALIBRATION_DIM / dim.max(1)).clamp(1, base)
45}
46
47/// Dim-adaptive batch size for chunk (long-text) embedding calls (G44).
48pub fn chunk_embed_batch_size() -> usize {
49    let dim = crate::constants::embedding_dim();
50    let batch = adaptive_batch_for_dim(CHUNK_EMBED_BATCH_SIZE, dim);
51    tracing::debug!(
52        dim,
53        base = CHUNK_EMBED_BATCH_SIZE,
54        batch,
55        "adaptive chunk batch size (G44)"
56    );
57    batch
58}
59
60/// Dim-adaptive batch size for entity-name (short-text) embedding calls (G44).
61pub fn entity_embed_batch_size() -> usize {
62    let dim = crate::constants::embedding_dim();
63    let batch = adaptive_batch_for_dim(ENTITY_EMBED_BATCH_SIZE, dim);
64    tracing::debug!(
65        dim,
66        base = ENTITY_EMBED_BATCH_SIZE,
67        batch,
68        "adaptive entity batch size (G44)"
69    );
70    batch
71}
72
73/// Embed passages controlled local.
74pub fn embed_passages_controlled_local(
75    models_dir: &Path,
76    texts: &[&str],
77    token_counts: &[usize],
78) -> Result<Vec<Vec<f32>>, AppError> {
79    let embedder = get_embedder(models_dir)?;
80    embed_passages_controlled(embedder, texts, token_counts)
81}
82
83/// G42/S3: embeds `texts` through the bounded parallel fan-out and
84/// returns vectors in input order.
85pub fn embed_passages_parallel_local(
86    models_dir: &Path,
87    texts: &[String],
88    parallelism: usize,
89    batch_size: usize,
90) -> Result<Vec<Vec<f32>>, AppError> {
91    let embedder = get_embedder(models_dir)?;
92    embed_texts_parallel(embedder, texts, parallelism, batch_size)
93}
94
95/// GAP-OPENROUTER-REST-CONCURRENCY: result of one bounded fan-out chunk —
96/// the chunk index paired with the batch embedding result, used to restore
97/// input order after out-of-order `JoinSet` completion.
98type EmbedChunkResult = (usize, Result<Vec<Vec<f32>>, AppError>);
99
100/// GAP-OPENROUTER-REST-CONCURRENCY: reassembles the flat vector list in
101/// input order from chunk parts produced out-of-order by the bounded
102/// `JoinSet` fan-out. Sorts by chunk index, then flattens, so the result
103/// matches the original `texts` order exactly.
104pub(crate) fn reassemble_ordered(mut parts: Vec<(usize, Vec<Vec<f32>>)>) -> Vec<Vec<f32>> {
105    parts.sort_by_key(|(idx, _)| *idx);
106    parts.into_iter().flat_map(|(_, v)| v).collect()
107}
108
109/// v1.0.93 (GAP-OR-INGEST): embeds multiple passages with
110/// `EmbeddingBackendChoice` awareness. When the resolved chain starts
111/// with `OpenRouter` and the client is initialised, uses the HTTP batch
112/// API (`embed_batch`) instead of subprocess fan-out — no LLM slot
113/// consumed, ~200ms per batch vs ~15s per subprocess cold-start.
114/// Falls back to `embed_passages_parallel_local` for LLM backends.
115pub fn embed_passages_parallel_with_embedding_choice(
116    models_dir: &Path,
117    texts: &[String],
118    parallelism: usize,
119    batch_size: usize,
120    embedding_backend: crate::cli::EmbeddingBackendChoice,
121    llm_backend: crate::cli::LlmBackendChoice,
122) -> Result<Vec<Vec<f32>>, AppError> {
123    let chain = embedding_backend.to_chain(llm_backend);
124    if chain.first() == Some(&LlmBackendKind::OpenRouter) && is_openrouter_initialized() {
125        let client = OPENROUTER_CLIENT.get().ok_or_else(|| {
126            AppError::Embedding(
127                crate::i18n::validation::embedding_openrouter_client_not_initialised(),
128            )
129        })?;
130
131        // GAP-OPENROUTER-REST-CONCURRENCY: reuse the caller's `parallelism`
132        // as a bounded fan-out width, clamped to a Cloudflare-safe range.
133        // Small inputs stay serial — a single batch is one REST call, so the
134        // JoinSet overhead would only add latency.
135        let k = parallelism.clamp(1, 16);
136        if texts.len() <= 32 || k == 1 {
137            let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
138            // GAP-001 (v1.1.04): canonical nested-runtime guard.
139            let vecs = match tokio::runtime::Handle::try_current() {
140                Ok(handle) => tokio::task::block_in_place(|| {
141                    handle.block_on(client.embed_batch(&refs, client.default_input_type()))
142                })?,
143                Err(_) => shared_runtime()?
144                    .block_on(client.embed_batch(&refs, client.default_input_type()))?,
145            };
146            return Ok(vecs);
147        }
148
149        // `client` is a `&'static OpenRouterClient` (OPENROUTER_CLIENT is a
150        // static OnceLock), so it is Copy + Send + 'static and moves freely
151        // into each spawned task. Chunk contents are cloned into owned
152        // `Vec<String>` because `texts` is only borrowed.
153        //
154        // GAP-001 (v1.1.04): canonical nested-runtime guard. The async block
155        // borrows `client`, `texts` and `k`, all of which remain valid for
156        // both branches.
157        let fan_out = async move {
158            let mut set: JoinSet<EmbedChunkResult> = JoinSet::new();
159            let mut parts: Vec<(usize, Vec<Vec<f32>>)> = Vec::new();
160
161            for (idx, chunk) in texts.chunks(32).enumerate() {
162                if set.len() >= k {
163                    if let Some(joined) = set.join_next().await {
164                        let (cidx, res) = joined.map_err(|e| {
165                            AppError::Embedding(
166                                crate::i18n::validation::embedding_task_join_error(e),
167                            )
168                        })?;
169                        parts.push((cidx, res?));
170                    }
171                }
172                let owned: Vec<String> = chunk.to_vec();
173                set.spawn(async move {
174                    let refs: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
175                    // `EmbedChunkResult` carries `AppError` (retry_class is
176                    // only consumed by callers that match `EmbedError`
177                    // directly, e.g. the enrich re-embed path).
178                    let r = client
179                        .embed_batch(&refs, client.default_input_type())
180                        .await
181                        .map_err(AppError::from);
182                    (idx, r)
183                });
184            }
185
186            while let Some(joined) = set.join_next().await {
187                let (cidx, res) = joined.map_err(|e| {
188                    AppError::Embedding(crate::i18n::validation::embedding_task_join_error(e))
189                })?;
190                parts.push((cidx, res?));
191            }
192
193            Ok::<Vec<Vec<f32>>, AppError>(reassemble_ordered(parts))
194        };
195        let vecs = match tokio::runtime::Handle::try_current() {
196            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(fan_out))?,
197            Err(_) => shared_runtime()?.block_on(fan_out)?,
198        };
199        Ok(vecs)
200    } else {
201        embed_passages_parallel_local(models_dir, texts, parallelism, batch_size)
202    }
203}
204
205/// G56: in-process cache for entity embeddings keyed by `(model, text)`.
206///
207/// Schema v13 is immutable: `entity_embeddings` does not have a `text`
208/// column, so a pure DB-side cache would require a schema bump. Instead
209/// we keep a process-wide LRU-style map that survives within one CLI
210/// invocation. The hit rate is high in `ingest` (re-embedding the same
211/// canonical entity across thousands of memories) and modest in `remember`
212/// (typical single-memory invocations).
213///
214/// Key: `blake3(model || "\0" || text)`. Value: `Arc<Vec<f32>>` so the
215/// collector can drop the map entry while a `Vec` is still in flight.
216type EntityEmbedCacheMap = std::collections::HashMap<u64, Arc<Vec<f32>>>;
217
218static ENTITY_EMBED_CACHE: OnceLock<parking_lot::Mutex<EntityEmbedCacheMap>> = OnceLock::new();
219
220pub(crate) fn entity_embed_cache() -> &'static parking_lot::Mutex<EntityEmbedCacheMap> {
221    ENTITY_EMBED_CACHE.get_or_init(|| parking_lot::Mutex::new(std::collections::HashMap::new()))
222}
223
224pub(crate) fn entity_cache_key(model: &str, text: &str) -> u64 {
225    let mut hasher = blake3::Hasher::new();
226    hasher.update(model.as_bytes());
227    hasher.update(b"\0");
228    hasher.update(text.as_bytes());
229    let h = hasher.finalize();
230    let bytes = h.as_bytes();
231    u64::from_le_bytes([
232        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
233    ])
234}
235
236/// G56: embeds entity-name texts through a process-wide cache.
237///
238/// Skips any `(model, text)` pair already produced in this CLI invocation
239/// and only spawns subprocesses for the cache misses. Returns vectors in
240/// the same order as `texts`.
241///
242/// Designed for entity-name batches (short texts). For chunk embeds use
243/// [`embed_passages_parallel_local`] directly — chunks are unique per
244/// memory and cache hit rate is negligible.
245pub fn embed_entity_texts_cached(
246    models_dir: &Path,
247    texts: &[String],
248    parallelism: usize,
249    embedding_backend: crate::cli::EmbeddingBackendChoice,
250    llm_backend: crate::cli::LlmBackendChoice,
251) -> Result<(Vec<Vec<f32>>, EmbedCacheStats), AppError> {
252    if texts.is_empty() {
253        return Ok((Vec::new(), EmbedCacheStats::default()));
254    }
255    // GAP-OR-ENTITY-EMBED: resolve the SAME chain the chunk path uses so the
256    // entity embedding honours `--embedding-backend`/`--llm-backend` instead
257    // of always forcing the codex subprocess (the old G56 code path).
258    let chain = embedding_backend.to_chain(llm_backend);
259
260    // `none` short-circuit: when the resolved chain is exactly `[None]`
261    // (`--embedding-backend llm --llm-backend none`) skip every backend and
262    // return empty vectors WITHOUT spawning a subprocess. Empties are never
263    // cached so a later call with a real backend in the same process is not
264    // poisoned; they count as misses for stats parity with the chunk path.
265    if chain.as_slice() == [LlmBackendKind::None] {
266        let out: Vec<Vec<f32>> = texts.iter().map(|_| Vec::new()).collect();
267        return Ok((
268            out,
269            EmbedCacheStats {
270                requested: texts.len(),
271                hits: 0,
272                misses: texts.len(),
273            },
274        ));
275    }
276
277    // Cache model label reflects the EFFECTIVE embedding backend. When the
278    // chain actually routes through OpenRouter, vectors carry that model's
279    // dim/MRL profile and must never collide with codex-produced vectors;
280    // for the local path we keep the prior `model_label()` so the in-process
281    // cache key is unchanged (no regression — this cache is process-local).
282    let routed_openrouter =
283        chain.first() == Some(&LlmBackendKind::OpenRouter) && is_openrouter_initialized();
284    let model = if routed_openrouter {
285        format!("openrouter:{}", crate::constants::embedding_dim())
286    } else {
287        get_embedder(models_dir)?.lock().model_label()
288    };
289    let cache = entity_embed_cache();
290    let mut hits: Vec<Option<Arc<Vec<f32>>>> = vec![None; texts.len()];
291    let mut miss_indices: Vec<usize> = Vec::with_capacity(texts.len());
292    {
293        let guard = cache.lock();
294        for (i, text) in texts.iter().enumerate() {
295            let key = entity_cache_key(&model, text);
296            if let Some(v) = guard.get(&key) {
297                hits[i] = Some(Arc::clone(v));
298            } else {
299                miss_indices.push(i);
300            }
301        }
302    }
303    let miss_count = miss_indices.len();
304    if miss_count > 0 {
305        let miss_texts: Vec<String> = miss_indices.iter().map(|&i| texts[i].clone()).collect();
306        // GAP-OR-ENTITY-EMBED: route misses through the backend-aware batch
307        // helper (same one the chunk path uses). With OpenRouter this hits the
308        // REST `embed_batch` (~200ms) instead of the codex subprocess (~120s).
309        let miss_vecs = embed_passages_parallel_with_embedding_choice(
310            models_dir,
311            &miss_texts,
312            parallelism,
313            entity_embed_batch_size(),
314            embedding_backend,
315            llm_backend,
316        )?;
317        let mut guard = cache.lock();
318        for (slot, &orig_idx) in miss_indices.iter().enumerate() {
319            let vec = Arc::new(miss_vecs[slot].clone());
320            let key = entity_cache_key(&model, &texts[orig_idx]);
321            guard.insert(key, Arc::clone(&vec));
322            hits[orig_idx] = Some(vec);
323        }
324    }
325    let mut out = Vec::with_capacity(texts.len());
326    for hit in hits.into_iter() {
327        let v = hit.ok_or_else(|| {
328            AppError::Embedding(crate::i18n::validation::embedding_entity_cache_null())
329        })?;
330        out.push((*v).clone());
331    }
332    Ok((
333        out,
334        EmbedCacheStats {
335            requested: texts.len(),
336            hits: texts.len() - miss_count,
337            misses: miss_count,
338        },
339    ))
340}
341
342/// G56: stats snapshot returned by [`embed_entity_texts_cached`].
343#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize)]
344pub struct EmbedCacheStats {
345    /// Requested.
346    pub requested: usize,
347    /// Hits.
348    pub hits: usize,
349    /// Misses.
350    pub misses: usize,
351}
352
353impl EmbedCacheStats {
354    /// Hit rate as a fraction in `[0.0, 1.0]`. Returns 0.0 when nothing was requested.
355    pub fn hit_rate(&self) -> f64 {
356        if self.requested == 0 {
357            0.0
358        } else {
359            self.hits as f64 / self.requested as f64
360        }
361    }
362}
363
364/// G42/S3 core: bounded parallel batch embedding.
365///
366/// - texts are grouped into batches of `batch_size` (one LLM call per
367///   batch, G42/S2);
368/// - at most `effective_permits(parallelism)` LLM subprocesses run
369///   simultaneously (`Arc<Semaphore>` + `acquire_owned`, BLOCO 2);
370/// - results stream through a BOUNDED mpsc channel so the caller-side
371///   collector applies backpressure and can persist incrementally
372///   (BLOCO 5);
373/// - the global `CancellationToken` aborts in-flight work on the first
374///   signal; subprocesses die with their futures via `kill_on_drop`
375///   (BLOCO 6).
376pub fn embed_texts_parallel(
377    embedder: &Mutex<LlmEmbedding>,
378    texts: &[String],
379    parallelism: usize,
380    batch_size: usize,
381) -> Result<Vec<Vec<f32>>, AppError> {
382    let mut slots: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
383    embed_texts_parallel_with(embedder, texts, parallelism, batch_size, |idx, v| {
384        slots[idx] = Some(v.to_vec());
385        Ok(())
386    })?;
387    let mut out = Vec::with_capacity(slots.len());
388    for (idx, slot) in slots.into_iter().enumerate() {
389        out.push(slot.ok_or_else(|| {
390            AppError::Embedding(crate::i18n::validation::embedding_fanout_lost_index(idx))
391        })?);
392    }
393    Ok(out)
394}
395
396/// Like [`embed_texts_parallel`] but invokes `on_result` as soon as each
397/// embedding arrives (BLOCO 5: incremental persistence — a kill loses at
398/// most the in-flight batches, never the already-delivered items).
399pub fn embed_texts_parallel_with(
400    embedder: &Mutex<LlmEmbedding>,
401    texts: &[String],
402    parallelism: usize,
403    batch_size: usize,
404    mut on_result: impl FnMut(usize, &[f32]) -> Result<(), AppError>,
405) -> Result<(), AppError> {
406    if texts.is_empty() {
407        return Ok(());
408    }
409    let dim = crate::constants::embedding_dim();
410    if texts.len() == 1 {
411        let v = embed_passage(embedder, &texts[0])?;
412        return on_result(0, &v);
413    }
414
415    let client = clone_client(embedder);
416    let permits = effective_permits(parallelism);
417    let batches = build_batches(texts, batch_size.max(1));
418    let token = crate::cancel_token().clone();
419
420    let work = move |batch: Vec<(usize, String)>| {
421        let client = client.clone();
422        async move {
423            client
424                .embed_batch_async(crate::constants::PASSAGE_PREFIX, &batch)
425                .await
426        }
427    };
428
429    let fan_out = run_bounded(batches, permits, dim, token, work, &mut on_result);
430    match tokio::runtime::Handle::try_current() {
431        Ok(handle) => tokio::task::block_in_place(|| handle.block_on(fan_out)),
432        Err(_) => shared_runtime()?.block_on(fan_out),
433    }
434}
435
436/// Groups `(global_index, text)` pairs into batches of `batch_size`.
437pub(crate) fn build_batches(texts: &[String], batch_size: usize) -> Vec<Vec<(usize, String)>> {
438    texts
439        .iter()
440        .cloned()
441        .enumerate()
442        .collect::<Vec<_>>()
443        .chunks(batch_size)
444        .map(|c| c.to_vec())
445        .collect()
446}
447
448/// G42/S3 BLOCO 2: effective permit count.
449///
450/// `permits = clamp(requested, 1, 32) ∧ cpus ∧ ram_livre*0.5/RSS` — see
451/// the module docs for the measured RSS rationale.
452pub fn effective_permits(requested: usize) -> usize {
453    let cpus = std::thread::available_parallelism()
454        .map(|n| n.get())
455        .unwrap_or(4);
456    let by_ram = ((crate::memory_guard::available_memory_mb() / 2)
457        / crate::constants::LLM_WORKER_RSS_MB)
458        .max(1) as usize;
459    requested.clamp(1, 32).min(cpus).min(by_ram).max(1)
460}
461
462/// Bounded fan-out engine. Generic over the per-batch work so the
463/// concurrency contract is testable without spawning real LLMs.
464///
465/// Cancel safety (BLOCO 6/10): every task races its work against
466/// `token.cancelled()` inside `tokio::select!`; both branches are
467/// cancel-safe (the work future owns its subprocess via `kill_on_drop`,
468/// and `cancelled()` is pure). On collector-side errors the `JoinSet`
469/// is shut down, which drops in-flight futures and kills their
470/// subprocesses.
471pub(crate) async fn run_bounded<F, Fut>(
472    batches: Vec<Vec<(usize, String)>>,
473    permits: usize,
474    dim: usize,
475    token: CancellationToken,
476    work: F,
477    on_result: &mut impl FnMut(usize, &[f32]) -> Result<(), AppError>,
478) -> Result<(), AppError>
479where
480    F: Fn(Vec<(usize, String)>) -> Fut + Clone + Send + 'static,
481    Fut: std::future::Future<Output = Result<Vec<(usize, Vec<f32>)>, AppError>> + Send,
482{
483    let total_batches = batches.len();
484    let semaphore = Arc::new(Semaphore::new(permits));
485    // BLOCO 5: bounded channel — producers block when the collector is
486    // behind (backpressure); PROIBIDO unbounded_channel between stages.
487    let (tx, mut rx) = mpsc::channel::<Result<Vec<(usize, Vec<f32>)>, AppError>>(permits * 2);
488    let mut set: JoinSet<()> = JoinSet::new();
489
490    for (batch_idx, batch) in batches.into_iter().enumerate() {
491        let sem = Arc::clone(&semaphore);
492        let token = token.clone();
493        let tx = tx.clone();
494        let work = work.clone();
495        set.spawn(async move {
496            let wait_start = std::time::Instant::now();
497            // acquire_owned: RAII permit moved into the task; returned
498            // on every exit path INCLUDING panic (BLOCO 2).
499            let Ok(_permit) = sem.acquire_owned().await else {
500                let _ = tx
501                    .send(Err(AppError::Embedding(
502                        crate::i18n::validation::embedding_semaphore_closed(),
503                    )))
504                    .await;
505                return;
506            };
507            let permit_wait_ms = wait_start.elapsed().as_millis() as u64;
508            let work_start = std::time::Instant::now();
509            // ADR-0034: when `SQLITE_GRAPHRAG_IGNORE_SHUTDOWN=1` is set the
510            // cancellation arm is dropped and the batch runs to completion.
511            // This unblocks audit/test invocations whose `SHUTDOWN` flag was
512            // contaminated by an earlier signal handler in the same process
513            // tree. Production code never sees this branch.
514            let outcome = if crate::should_obey_shutdown() {
515                tokio::select! {
516                    res = work(batch) => res,
517                    _ = token.cancelled() => Err(AppError::Embedding(
518                        crate::i18n::validation::embedding_cancelled_by_shutdown(),
519                    )),
520                }
521            } else {
522                work(batch).await
523            };
524            // BLOCO 8: permit wait time logged SEPARATELY from work time.
525            tracing::debug!(
526                target: "embedding",
527                batch_idx,
528                permit_wait_ms,
529                work_ms = work_start.elapsed().as_millis() as u64,
530                ok = outcome.is_ok(),
531                "embedding batch finished"
532            );
533            let _ = tx.send(outcome).await;
534        });
535    }
536    drop(tx);
537
538    let mut completed = 0usize;
539    let mut failed = 0usize;
540    let mut cancelled = 0usize;
541    let mut first_error: Option<AppError> = None;
542
543    while let Some(message) = rx.recv().await {
544        match message {
545            Ok(items) => {
546                completed += 1;
547                if first_error.is_none() {
548                    for (idx, v) in items {
549                        if v.len() != dim {
550                            first_error = Some(AppError::Embedding(
551                                crate::i18n::validation::embedding_llm_item_dims(
552                                    v.len(),
553                                    idx,
554                                    dim,
555                                ),
556                            ));
557                            break;
558                        }
559                        if let Err(e) = on_result(idx, &v) {
560                            first_error = Some(e);
561                            break;
562                        }
563                    }
564                    if first_error.is_some() {
565                        // Abort remaining work: dropped futures kill
566                        // their subprocesses via kill_on_drop (BLOCO 6).
567                        set.shutdown().await;
568                    }
569                }
570            }
571            Err(e) => {
572                if matches!(&e, AppError::Embedding(msg) if msg.contains("cancelled")) {
573                    cancelled += 1;
574                } else {
575                    failed += 1;
576                }
577                if first_error.is_none() {
578                    first_error = Some(e);
579                    set.shutdown().await;
580                }
581            }
582        }
583    }
584
585    // Drain the JoinSet: surface panics distinctly (panic handling —
586    // JoinError::is_panic tratado em todo join_next, BLOCO 9).
587    while let Some(join_result) = set.join_next().await {
588        if let Err(join_err) = join_result {
589            if join_err.is_panic() {
590                failed += 1;
591                if first_error.is_none() {
592                    first_error = Some(AppError::Embedding(
593                        crate::i18n::validation::embedding_task_panicked(join_err),
594                    ));
595                }
596            } else {
597                cancelled += 1;
598            }
599        }
600    }
601
602    // v1.0.85 (ADR-0043 hygiene): the fan-out summary event moved
603    // from `tracing::info!` to `tracing::debug!` and the
604    // `available_permits` field was removed — the user prohibited
605    // pool-state telemetry (slot_pool_stats / slot_wait_ms) and
606    // decorative `tracing::info!` events. The remaining counters
607    // (total_batches / completed / failed / cancelled) describe the
608    // progress of the operation itself, not the slot pool, and
609    // remain visible to operators running with `RUST_LOG=debug` or
610    // `-vvv`.
611    tracing::debug!(
612        target: "embedding",
613        total_batches,
614        completed,
615        failed,
616        cancelled,
617        "embedding fan-out finished"
618    );
619
620    match first_error {
621        Some(e) => Err(e),
622        None => Ok(()),
623    }
624}