Spaces:
Paused
Paused
File size: 9,953 Bytes
9d16a7f 0e84916 9d16a7f 4c6ffbd 9d16a7f 7f331eb 9d16a7f aa5d8fe 9d16a7f 4c6ffbd 9d16a7f aa5d8fe 9d16a7f 2d765ca 9d16a7f aa5d8fe 9d16a7f aa5d8fe 2d765ca 9d16a7f aa5d8fe 9d16a7f aa5d8fe 4c6ffbd aa5d8fe 9d16a7f 2d765ca 9d16a7f aa5d8fe 4c6ffbd aa5d8fe 4c6ffbd aa5d8fe 2d765ca aa5d8fe 9d16a7f 2d765ca aa5d8fe 9d16a7f 2d765ca 9d16a7f 2d765ca 9d16a7f aa5d8fe 2d765ca aa5d8fe 2d765ca aa5d8fe 9d16a7f 2d765ca 9d16a7f 2d765ca 9d16a7f aa5d8fe 9d16a7f aa5d8fe 9d16a7f 7f331eb 4c6ffbd 7f331eb 2d765ca 7f331eb 4c6ffbd 7f331eb 4c6ffbd 2d765ca 7f331eb 2d765ca 2ab506e 7f331eb 4c6ffbd 7f331eb 2d765ca 7f331eb 4c6ffbd 7f331eb 4c6ffbd 7f331eb 4c6ffbd 7f331eb aa5d8fe 4c6ffbd aa5d8fe 7f331eb 4c6ffbd 7f331eb 4c6ffbd 2ab506e 2d765ca 2ab506e 4c6ffbd aa5d8fe 4c6ffbd 860fadf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | from __future__ import annotations
import asyncio
import tempfile
import httpx
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from dataclasses import dataclass
from services import job_store
from services.cv_chunker import chunk_cv
from services.cv_converter import CVConverter
from services.job_matcher import JobMatcher
# ---------------------------------------------------------------------------
# Dedicated single-thread executor for ML work.
#
# Why not asyncio's default thread pool?
# asyncio.to_thread() uses the loop's default ThreadPoolExecutor which is
# shared with ALL other coroutines in the process (file I/O, HTTP clients,
# etc.). When two heavy ML tasks run simultaneously they can saturate that
# shared pool, starving incoming HTTP requests of threads and making the
# server appear frozen even though the event loop is technically free.
#
# A dedicated pool with max_workers=1 means:
# • ML work is 100% isolated — never competes with HTTP request threads.
# • Only one Marker/chunk_cv call runs at a time (model is not thread-safe).
# • The asyncio default pool stays free for file reads, httpx, etc.
# ---------------------------------------------------------------------------
_ML_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ml-worker")
class CvWorker:
"""Handles background tasks for CV processing."""
def __init__(self, converter: CVConverter, matcher: JobMatcher):
self.converter = converter
self.matcher = matcher
async def run_cv_processing(
self,
job_id: str,
file_bytes: bytes,
filename: str,
callback_url: str,
callback_secret: str = None,
) -> None:
"""Process a CV and POST the result back to the Java callback endpoint."""
loop = asyncio.get_running_loop()
tmp_path: Optional[Path] = None
try:
t_start = datetime.now(timezone.utc)
suffix = Path(filename).suffix.lower() if filename else ".pdf"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(file_bytes)
tmp_path = Path(tmp.name)
# Conversion — CPU-bound, isolated executor
t_conv_start = datetime.now(timezone.utc)
conversion = await loop.run_in_executor(
_ML_EXECUTOR, self.converter.convert, tmp_path
)
t_conv_end = datetime.now(timezone.utc)
conversion_ms = int((t_conv_end - t_conv_start).total_seconds() * 1000)
if not conversion.success:
error_msg = conversion.error or "Conversion failed"
job_store.set_failed(job_id, error_msg)
await self.post_callback(
callback_url,
{
"message": error_msg,
"statusCode": 422,
"payload": None,
},
callback_secret,
)
return
# chunk_cv with embedder — CPU-bound, isolated executor
t_chunk_start = datetime.now(timezone.utc)
chunks = await loop.run_in_executor(
_ML_EXECUTOR,
lambda: chunk_cv(conversion.markdown, embedder=self.matcher._embed),
)
t_chunk_end = datetime.now(timezone.utc)
chunking_ms = int((t_chunk_end - t_chunk_start).total_seconds() * 1000)
t_end = datetime.now(timezone.utc)
total_ms = int((t_end - t_start).total_seconds() * 1000)
payload = {
"markdown": conversion.markdown,
"cv_title": chunks["cv_title"],
"seniority": chunks["seniority"],
"years_experience": chunks["years_experience"],
"category": chunks["category"],
"completeness_score": chunks["completeness_score"],
"chunks": {
"summary": chunks["chunks"]["summary"],
"contact": chunks["chunks"]["contact"],
"links": chunks["chunks"]["links"],
"skills": chunks["chunks"]["skills"],
"experience": chunks["chunks"]["experience"],
"education": chunks["chunks"]["education"],
"projects": chunks["chunks"]["projects"],
"awards": chunks["chunks"]["awards"],
},
"section_embeddings": chunks.get("section_embeddings"),
"file_type": conversion.file_type,
"method_used": conversion.method_used,
"is_scanned": conversion.is_scanned,
"page_count": conversion.page_count,
"warnings": conversion.warnings,
"timing": {
"conversion_ms": conversion_ms,
"chunking_ms": chunking_ms,
"total_ms": total_ms,
},
"processed_at": t_end.isoformat(),
}
job_store.set_completed(job_id, payload)
await self.post_callback(
callback_url,
{
"message": "CV processed successfully",
"statusCode": 200,
"payload": payload,
},
callback_secret,
)
except Exception as exc:
error_msg = f"Processing error: {exc}"
job_store.set_failed(job_id, error_msg)
await self.post_callback(
callback_url,
{
"message": error_msg,
"statusCode": 500,
"payload": None,
},
callback_secret,
)
finally:
if tmp_path is not None:
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
async def post_callback(
self,
callback_url: str,
body: dict,
callback_secret: str = None,
) -> None:
"""POST the processing result back to Java."""
try:
headers = {}
if callback_secret:
headers["X-Callback-Secret"] = callback_secret
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(callback_url, json=body, headers=headers)
response.raise_for_status()
except Exception as exc:
print(f"[CV-ASYNC] Failed to POST callback to {callback_url}: {exc}")
@dataclass
class CvTask:
job_id: str
file_bytes: bytes
filename: str
callback_url: str
callback_secret: str = None
class QueueManager:
"""
Manages an asyncio Queue with a single background worker.
concurrency=1 is intentional — the Marker ML model and the
sentence-transformer are NOT thread-safe and must not run in
parallel on the same process. Jobs queue up and are processed
one at a time. The dedicated _ML_EXECUTOR above ensures ML work
never blocks the asyncio event loop — HTTP endpoints (job-status,
new submissions) stay responsive even while a CV is being processed.
"""
def __init__(self, worker: CvWorker, concurrency: int = 1):
self.worker = worker
self.concurrency = concurrency
self.queue: asyncio.Queue = asyncio.Queue()
self.tasks: list = []
self._active_ids: set[str] = set()
async def start(self) -> None:
for _ in range(self.concurrency):
task = asyncio.create_task(self._worker_loop())
self.tasks.append(task)
print(
f"[QUEUE] Started {self.concurrency} worker(s) — one CV processed at a time."
)
async def stop(self) -> None:
for task in self.tasks:
task.cancel()
await asyncio.gather(*self.tasks, return_exceptions=True)
_ML_EXECUTOR.shutdown(wait=False)
print("[QUEUE] Stopped all workers.")
async def _worker_loop(self) -> None:
while True:
try:
task: CvTask = await self.queue.get()
job_store.set_processing(task.job_id)
self._active_ids.discard(task.job_id)
print(
f"[QUEUE] Worker picked up job {task.job_id}. "
f"Remaining in queue: {self.queue.qsize()}"
)
await self.worker.run_cv_processing(
task.job_id,
task.file_bytes,
task.filename,
task.callback_url,
task.callback_secret,
)
self.queue.task_done()
except asyncio.CancelledError:
break
except Exception as exc:
print(f"[QUEUE] Unhandled error in worker loop: {exc}")
async def enqueue(self, task: CvTask) -> None:
if task.job_id in self._active_ids:
print(
f"[QUEUE] Duplicate submission rejected: job {task.job_id} is already queued."
)
raise ValueError(f"Job {task.job_id} is already in the processing queue.")
self._active_ids.add(task.job_id)
job_store.set_queued(task.job_id)
await self.queue.put(task)
print(f"[QUEUE] Job {task.job_id} enqueued. Queue depth: {self.queue.qsize()}")
def get_queue_position(self, job_id: str) -> Optional[int]:
try:
for idx, task in enumerate(list(self.queue._queue)):
if task.job_id == job_id:
return idx + 1
except Exception as exc:
print(f"[QUEUE] Error checking queue position: {exc}")
return None
|