Spaces:
Running
Running
| """ | |
| Embedding Batch Workers β Async batching infrastructure for embedding inference. | |
| Architecture: | |
| - Single asyncio.Queue for all embedding jobs. | |
| - Dedicated async worker coroutines drain the queue in micro-batches. | |
| - Workers offload heavy inference to a thread via run_in_executor. | |
| - A shared in-memory job_store dict tracks job status + results. | |
| - Warmup loop periodically does a dummy forward pass to keep OpenMP threads alive. | |
| """ | |
| import asyncio | |
| import time | |
| import uuid | |
| from loguru import logger | |
| from typing import Any | |
| from .constants import BATCH_MAX_SIZE, BATCH_WINDOW_S, WARMUP_INTERVAL_S | |
| from .schemas import EmbeddingJob | |
| # βββββββββββββββββββββββ Job Store ββββββββββββββββββββββββ | |
| job_store: dict[str, dict[str, Any]] = {} | |
| """ | |
| { | |
| "<job_id>": { | |
| "status": "pending" | "processing" | "done" | "error", | |
| "result": <list[list[float]] for EmbeddingJob> | None, | |
| "error": <str> | None, | |
| } | |
| } | |
| """ | |
| def create_job() -> str: | |
| """Create a new pending job and return its ID.""" | |
| job_id = str(uuid.uuid4()) | |
| job_store[job_id] = {"status": "pending", "result": None, "error": None} | |
| return job_id | |
| # βββββββββββββββββββββββ Request-in-Flight Gate ββββββββββββββββββββββββ | |
| _request_in_flight_count = 0 | |
| def set_request_in_flight(active: bool): | |
| """Increment/decrement in-flight counter. Thread-safe enough for a gate.""" | |
| global _request_in_flight_count | |
| if active: | |
| _request_in_flight_count += 1 | |
| else: | |
| _request_in_flight_count = max(0, _request_in_flight_count - 1) | |
| def is_request_in_flight() -> bool: | |
| return _request_in_flight_count > 0 | |
| # βββββββββββββββββββββββ Queue ββββββββββββββββββββββββ | |
| embedding_queue: asyncio.Queue[EmbeddingJob] = asyncio.Queue() | |
| # βββββββββββββββββββββββ Workers ββββββββββββββββββββββββ | |
| async def embedding_worker(): | |
| """ | |
| Drains up to {BATCH_MAX_SIZE} embedding jobs every {BATCH_WINDOW_S * 1000:.0f}ms. | |
| One SentenceTransformer forward pass per batch: | |
| 1. Collect texts from all jobs in the batch | |
| 2. get_embedder().embed_documents(all_texts) β raw [B, D] embeddings | |
| 3. Distribute results back to individual jobs | |
| Results are written into job_store and each job's done Event is set. | |
| """ | |
| from src.rag.rag import get_embedder | |
| loop = asyncio.get_event_loop() | |
| while True: | |
| # Wait for at least one job | |
| first_job: EmbeddingJob = await embedding_queue.get() | |
| batch: list[EmbeddingJob] = [first_job] | |
| # Collect up to 7 more within the time window | |
| deadline = loop.time() + BATCH_WINDOW_S | |
| while len(batch) < BATCH_MAX_SIZE: | |
| remaining = deadline - loop.time() | |
| if remaining <= 0: | |
| break | |
| try: | |
| job = await asyncio.wait_for(embedding_queue.get(), timeout=remaining) | |
| batch.append(job) | |
| except asyncio.TimeoutError: | |
| break | |
| try: | |
| set_request_in_flight(True) | |
| # Gather all texts from all jobs in the batch | |
| all_texts: list[str] = [] | |
| text_counts: list[int] = [] | |
| for job in batch: | |
| all_texts.extend(job.texts) | |
| text_counts.append(len(job.texts)) | |
| # Single forward pass for the entire batch | |
| embedder = get_embedder() | |
| all_embeddings = await loop.run_in_executor( | |
| None, embedder.embed_documents, all_texts | |
| ) | |
| # Distribute results back to individual jobs | |
| idx = 0 | |
| for i, job in enumerate(batch): | |
| n = text_counts[i] | |
| job_result = all_embeddings[idx: idx + n] | |
| idx += n | |
| # Use .get() or setdefault to avoid KeyError if initialization was missed | |
| if job.job_id not in job_store: | |
| job_store[job.job_id] = {"status": "pending", "result": None, "error": None} | |
| job_store[job.job_id].update({ | |
| "status": "done", | |
| "result": job_result | |
| }) | |
| job.done.set() | |
| except Exception as e: | |
| logger.error(f"Embedding batch failed: {e}", exc_info=True) | |
| for job in batch: | |
| if job.job_id not in job_store: | |
| job_store[job.job_id] = {"status": "error", "result": None, "error": str(e)} | |
| else: | |
| job_store[job.job_id].update({ | |
| "status": "error", | |
| "error": str(e) | |
| }) | |
| # Critical: always set the event so the request doesn't hang | |
| if not job.done.is_set(): | |
| job.done.set() | |
| finally: | |
| set_request_in_flight(False) | |
| # βββββββββββββββββββββββ Warmup Loop ββββββββββββββββββββββββ | |
| async def _warmup_loop(): | |
| """ | |
| Periodically does a dummy forward pass to prevent OpenMP/MKL thread pool | |
| spin-down during idle periods. | |
| Skipped entirely if a real request is in flight. | |
| """ | |
| from src.rag.rag import warmup_embedder | |
| loop = asyncio.get_event_loop() | |
| while True: | |
| await asyncio.sleep(WARMUP_INTERVAL_S) | |
| if is_request_in_flight(): | |
| continue | |
| t0 = time.monotonic() | |
| try: | |
| await loop.run_in_executor(None, warmup_embedder) | |
| except Exception as e: | |
| logger.warning(f"Warmup cycle error (non-fatal): {e}") | |
| continue | |
| elapsed_ms = (time.monotonic() - t0) * 1000 | |
| logger.info(f"Warmup cycle done ({elapsed_ms:.0f}ms)") | |
| # βββββββββββββββββββββββ Startup ββββββββββββββββββββββββ | |
| _workers_started = False | |
| def start_workers(): | |
| """ | |
| Launch all async worker coroutines. Call once during app startup. | |
| - 1 embedding worker (batched SentenceTransformer inference) | |
| - 1 warmup loop (keeps OpenMP threads alive) | |
| """ | |
| global _workers_started | |
| if _workers_started: | |
| return | |
| _workers_started = True | |
| # Use only 1 worker to save RAM on this environment | |
| asyncio.create_task(embedding_worker(), name="embedding_worker_0") | |
| asyncio.create_task(_warmup_loop(), name="warmup_loop") | |
| logger.info("Batch workers started (embedding worker + warmup loop)") | |