Spaces:
Paused
Paused
make excutor process one cv at a time
Browse files- __pycache__/main.cpython-312.pyc +0 -0
- main.py +1 -1
- services/__pycache__/cv_converter.cpython-312.pyc +0 -0
- services/__pycache__/job_store.cpython-312.pyc +0 -0
- services/__pycache__/workers.cpython-312.pyc +0 -0
- services/cv_converter.py +34 -4
- services/job_store.py +17 -4
- services/workers.py +70 -27
__pycache__/main.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ
|
|
|
main.py
CHANGED
|
@@ -27,7 +27,7 @@ from services.workers import CvWorker, QueueManager, CvTask
|
|
| 27 |
converter = CVConverter()
|
| 28 |
matcher = JobMatcher() # SentenceTransformer loaded once at startup
|
| 29 |
cv_worker = CvWorker(converter)
|
| 30 |
-
queue_manager = QueueManager(cv_worker, concurrency=
|
| 31 |
|
| 32 |
@asynccontextmanager
|
| 33 |
async def lifespan(app: FastAPI):
|
|
|
|
| 27 |
converter = CVConverter()
|
| 28 |
matcher = JobMatcher() # SentenceTransformer loaded once at startup
|
| 29 |
cv_worker = CvWorker(converter)
|
| 30 |
+
queue_manager = QueueManager(cv_worker, concurrency=1)
|
| 31 |
|
| 32 |
@asynccontextmanager
|
| 33 |
async def lifespan(app: FastAPI):
|
services/__pycache__/cv_converter.cpython-312.pyc
CHANGED
|
Binary files a/services/__pycache__/cv_converter.cpython-312.pyc and b/services/__pycache__/cv_converter.cpython-312.pyc differ
|
|
|
services/__pycache__/job_store.cpython-312.pyc
CHANGED
|
Binary files a/services/__pycache__/job_store.cpython-312.pyc and b/services/__pycache__/job_store.cpython-312.pyc differ
|
|
|
services/__pycache__/workers.cpython-312.pyc
CHANGED
|
Binary files a/services/__pycache__/workers.cpython-312.pyc and b/services/__pycache__/workers.cpython-312.pyc differ
|
|
|
services/cv_converter.py
CHANGED
|
@@ -316,22 +316,52 @@ class CVConverter:
|
|
| 316 |
return True, 0, []
|
| 317 |
|
| 318 |
def _load_marker_models(self):
|
| 319 |
-
"""Lazy-load and cache Marker model dict (once per process).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
if self._marker_models is None:
|
| 321 |
logger.info(
|
| 322 |
"Loading Marker models for the first time "
|
| 323 |
"(this may take ~10–30 s)…"
|
| 324 |
)
|
| 325 |
try:
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
except ImportError as exc:
|
| 330 |
raise ImportError(
|
| 331 |
"Marker is not installed. Fix: pip install marker-pdf"
|
| 332 |
) from exc
|
| 333 |
return self._marker_models
|
| 334 |
|
|
|
|
| 335 |
def _run_marker(self, path: Path, force_ocr: bool = False) -> str:
|
| 336 |
"""
|
| 337 |
Execute Marker and return the raw Markdown string.
|
|
|
|
| 316 |
return True, 0, []
|
| 317 |
|
| 318 |
def _load_marker_models(self):
|
| 319 |
+
"""Lazy-load and cache Marker model dict (once per process).
|
| 320 |
+
|
| 321 |
+
PyTorch lazy / meta-device initialization can cause:
|
| 322 |
+
'Cannot copy out of meta tensor; no data!'
|
| 323 |
+
when Marker calls model.to(device) on a model that was built on the
|
| 324 |
+
meta device. We patch torch.nn.Module.to to fall back to to_empty()
|
| 325 |
+
in that case, then restore the original after loading completes.
|
| 326 |
+
"""
|
| 327 |
if self._marker_models is None:
|
| 328 |
logger.info(
|
| 329 |
"Loading Marker models for the first time "
|
| 330 |
"(this may take ~10–30 s)…"
|
| 331 |
)
|
| 332 |
try:
|
| 333 |
+
import torch
|
| 334 |
+
|
| 335 |
+
_original_to = torch.nn.Module.to
|
| 336 |
+
|
| 337 |
+
def _safe_to(module, *args, **kwargs):
|
| 338 |
+
try:
|
| 339 |
+
return _original_to(module, *args, **kwargs)
|
| 340 |
+
except RuntimeError as exc:
|
| 341 |
+
if "Cannot copy out of meta tensor" in str(exc):
|
| 342 |
+
device = args[0] if args else kwargs.get("device", "cpu")
|
| 343 |
+
logger.debug(
|
| 344 |
+
"Meta-tensor detected — using to_empty(device=%s)", device
|
| 345 |
+
)
|
| 346 |
+
return module.to_empty(device=device)
|
| 347 |
+
raise
|
| 348 |
+
|
| 349 |
+
torch.nn.Module.to = _safe_to
|
| 350 |
+
try:
|
| 351 |
+
from marker.models import create_model_dict
|
| 352 |
+
self._marker_models = create_model_dict()
|
| 353 |
+
logger.info("Marker models loaded and cached.")
|
| 354 |
+
finally:
|
| 355 |
+
# Always restore the original .to even if loading fails
|
| 356 |
+
torch.nn.Module.to = _original_to
|
| 357 |
+
|
| 358 |
except ImportError as exc:
|
| 359 |
raise ImportError(
|
| 360 |
"Marker is not installed. Fix: pip install marker-pdf"
|
| 361 |
) from exc
|
| 362 |
return self._marker_models
|
| 363 |
|
| 364 |
+
|
| 365 |
def _run_marker(self, path: Path, force_ocr: bool = False) -> str:
|
| 366 |
"""
|
| 367 |
Execute Marker and return the raw Markdown string.
|
services/job_store.py
CHANGED
|
@@ -6,9 +6,10 @@ an in-memory dict is sufficient. Jobs are keyed by job_id (string UUID).
|
|
| 6 |
|
| 7 |
Each entry shape:
|
| 8 |
{
|
| 9 |
-
"status":
|
| 10 |
-
"
|
| 11 |
-
"
|
|
|
|
| 12 |
}
|
| 13 |
"""
|
| 14 |
|
|
@@ -19,17 +20,29 @@ from typing import Optional
|
|
| 19 |
|
| 20 |
_lock = threading.Lock()
|
| 21 |
_store: dict[str, dict] = {}
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def create_job(job_id: str) -> None:
|
| 25 |
with _lock:
|
| 26 |
-
_store[job_id] = {"status": "PENDING", "result": None, "error": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def set_processing(job_id: str) -> None:
|
| 30 |
with _lock:
|
| 31 |
if job_id in _store:
|
| 32 |
_store[job_id]["status"] = "PROCESSING"
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def set_completed(job_id: str, result: dict) -> None:
|
|
|
|
| 6 |
|
| 7 |
Each entry shape:
|
| 8 |
{
|
| 9 |
+
"status": "PENDING" | "QUEUED" | "PROCESSING" | "COMPLETED" | "FAILED",
|
| 10 |
+
"queue_position": int | None, # position in queue (1 = next to process)
|
| 11 |
+
"result": <ProcessorResponse payload dict> | None,
|
| 12 |
+
"error": <str> | None,
|
| 13 |
}
|
| 14 |
"""
|
| 15 |
|
|
|
|
| 20 |
|
| 21 |
_lock = threading.Lock()
|
| 22 |
_store: dict[str, dict] = {}
|
| 23 |
+
_queue_counter = 0 # monotonic counter to assign queue positions
|
| 24 |
|
| 25 |
|
| 26 |
def create_job(job_id: str) -> None:
|
| 27 |
with _lock:
|
| 28 |
+
_store[job_id] = {"status": "PENDING", "queue_position": None, "result": None, "error": None}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def set_queued(job_id: str) -> None:
|
| 32 |
+
"""Mark job as waiting in the Python processing queue."""
|
| 33 |
+
global _queue_counter
|
| 34 |
+
with _lock:
|
| 35 |
+
_queue_counter += 1
|
| 36 |
+
if job_id in _store:
|
| 37 |
+
_store[job_id]["status"] = "QUEUED"
|
| 38 |
+
_store[job_id]["queue_position"] = _queue_counter
|
| 39 |
|
| 40 |
|
| 41 |
def set_processing(job_id: str) -> None:
|
| 42 |
with _lock:
|
| 43 |
if job_id in _store:
|
| 44 |
_store[job_id]["status"] = "PROCESSING"
|
| 45 |
+
_store[job_id]["queue_position"] = None # no longer in queue
|
| 46 |
|
| 47 |
|
| 48 |
def set_completed(job_id: str, result: dict) -> None:
|
services/workers.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import tempfile
|
| 5 |
import httpx
|
|
|
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Optional
|
|
@@ -12,6 +13,26 @@ from services import job_store
|
|
| 12 |
from services.cv_chunker import chunk_cv
|
| 13 |
from services.cv_converter import CVConverter
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
class CvWorker:
|
| 16 |
"""Handles background tasks for CV processing."""
|
| 17 |
|
|
@@ -26,11 +47,10 @@ class CvWorker:
|
|
| 26 |
callback_url: str,
|
| 27 |
callback_secret: str = None
|
| 28 |
) -> None:
|
| 29 |
-
"""
|
|
|
|
| 30 |
tmp_path: Optional[Path] = None
|
| 31 |
try:
|
| 32 |
-
job_store.set_processing(job_id)
|
| 33 |
-
|
| 34 |
start_time = datetime.now(timezone.utc).isoformat()
|
| 35 |
|
| 36 |
suffix = Path(filename).suffix.lower() if filename else ".pdf"
|
|
@@ -38,7 +58,12 @@ class CvWorker:
|
|
| 38 |
tmp.write(file_bytes)
|
| 39 |
tmp_path = Path(tmp.name)
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
if not conversion.success:
|
| 44 |
error_msg = conversion.error or "Conversion failed"
|
|
@@ -50,7 +75,9 @@ class CvWorker:
|
|
| 50 |
}, callback_secret)
|
| 51 |
return
|
| 52 |
|
| 53 |
-
chunks = await
|
|
|
|
|
|
|
| 54 |
end_time = datetime.now(timezone.utc).isoformat()
|
| 55 |
|
| 56 |
payload = {
|
|
@@ -105,14 +132,14 @@ class CvWorker:
|
|
| 105 |
headers = {}
|
| 106 |
if callback_secret:
|
| 107 |
headers["X-Callback-Secret"] = callback_secret
|
| 108 |
-
|
| 109 |
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 110 |
response = await client.post(callback_url, json=body, headers=headers)
|
| 111 |
response.raise_for_status()
|
| 112 |
except Exception as exc:
|
| 113 |
-
# Log but don't crash — Java will see job stay in PROCESSING and can time out
|
| 114 |
print(f"[CV-ASYNC] Failed to POST callback to {callback_url}: {exc}")
|
| 115 |
|
|
|
|
| 116 |
@dataclass
|
| 117 |
class CvTask:
|
| 118 |
job_id: str
|
|
@@ -121,47 +148,63 @@ class CvTask:
|
|
| 121 |
callback_url: str
|
| 122 |
callback_secret: str = None
|
| 123 |
|
|
|
|
| 124 |
class QueueManager:
|
| 125 |
-
"""
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
self.worker = worker
|
| 129 |
self.concurrency = concurrency
|
| 130 |
-
self.queue = asyncio.Queue()
|
| 131 |
-
self.tasks = []
|
| 132 |
|
| 133 |
-
async def start(self):
|
| 134 |
-
"""
|
| 135 |
for _ in range(self.concurrency):
|
| 136 |
task = asyncio.create_task(self._worker_loop())
|
| 137 |
self.tasks.append(task)
|
| 138 |
-
print(f"[QUEUE] Started {self.concurrency}
|
| 139 |
|
| 140 |
-
async def stop(self):
|
| 141 |
-
"""
|
| 142 |
for task in self.tasks:
|
| 143 |
task.cancel()
|
| 144 |
await asyncio.gather(*self.tasks, return_exceptions=True)
|
|
|
|
| 145 |
print("[QUEUE] Stopped all workers.")
|
| 146 |
|
| 147 |
-
async def _worker_loop(self):
|
| 148 |
-
"""Continuously
|
| 149 |
while True:
|
| 150 |
try:
|
| 151 |
-
task = await self.queue.get()
|
|
|
|
|
|
|
|
|
|
| 152 |
await self.worker.run_cv_processing(
|
| 153 |
task.job_id,
|
| 154 |
task.file_bytes,
|
| 155 |
task.filename,
|
| 156 |
task.callback_url,
|
| 157 |
-
task.callback_secret
|
| 158 |
)
|
| 159 |
self.queue.task_done()
|
| 160 |
except asyncio.CancelledError:
|
| 161 |
break
|
| 162 |
-
except Exception as
|
| 163 |
-
print(f"[QUEUE] Unhandled error in worker loop: {
|
| 164 |
-
|
| 165 |
-
async def enqueue(self, task: CvTask):
|
| 166 |
-
"""
|
| 167 |
-
|
|
|
|
|
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import tempfile
|
| 5 |
import httpx
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Optional
|
|
|
|
| 13 |
from services.cv_chunker import chunk_cv
|
| 14 |
from services.cv_converter import CVConverter
|
| 15 |
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Dedicated single-thread executor for ML work.
|
| 19 |
+
#
|
| 20 |
+
# Why not asyncio's default thread pool?
|
| 21 |
+
# asyncio.to_thread() uses the loop's default ThreadPoolExecutor which is
|
| 22 |
+
# shared with ALL other coroutines in the process (file I/O, HTTP clients,
|
| 23 |
+
# etc.). When two heavy ML tasks run simultaneously they can saturate that
|
| 24 |
+
# shared pool, starving incoming HTTP requests of threads and making the
|
| 25 |
+
# server appear frozen even though the event loop is technically free.
|
| 26 |
+
#
|
| 27 |
+
# A dedicated pool with max_workers=1 means:
|
| 28 |
+
# • ML work is 100% isolated — never competes with HTTP request threads.
|
| 29 |
+
# • Only one Marker/chunk_cv call runs at a time (model is not thread-safe).
|
| 30 |
+
# • The asyncio default pool stays free for file reads, httpx, etc.
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
_ML_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ml-worker")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
class CvWorker:
|
| 37 |
"""Handles background tasks for CV processing."""
|
| 38 |
|
|
|
|
| 47 |
callback_url: str,
|
| 48 |
callback_secret: str = None
|
| 49 |
) -> None:
|
| 50 |
+
"""Process a CV and POST the result back to Java callback endpoint."""
|
| 51 |
+
loop = asyncio.get_running_loop()
|
| 52 |
tmp_path: Optional[Path] = None
|
| 53 |
try:
|
|
|
|
|
|
|
| 54 |
start_time = datetime.now(timezone.utc).isoformat()
|
| 55 |
|
| 56 |
suffix = Path(filename).suffix.lower() if filename else ".pdf"
|
|
|
|
| 58 |
tmp.write(file_bytes)
|
| 59 |
tmp_path = Path(tmp.name)
|
| 60 |
|
| 61 |
+
# Run blocking ML work in the dedicated single-thread executor.
|
| 62 |
+
# This keeps the asyncio event loop free to handle all other
|
| 63 |
+
# incoming requests (job-status polls, new CV submissions, etc.)
|
| 64 |
+
conversion = await loop.run_in_executor(
|
| 65 |
+
_ML_EXECUTOR, self.converter.convert, tmp_path
|
| 66 |
+
)
|
| 67 |
|
| 68 |
if not conversion.success:
|
| 69 |
error_msg = conversion.error or "Conversion failed"
|
|
|
|
| 75 |
}, callback_secret)
|
| 76 |
return
|
| 77 |
|
| 78 |
+
chunks = await loop.run_in_executor(
|
| 79 |
+
_ML_EXECUTOR, chunk_cv, conversion.markdown
|
| 80 |
+
)
|
| 81 |
end_time = datetime.now(timezone.utc).isoformat()
|
| 82 |
|
| 83 |
payload = {
|
|
|
|
| 132 |
headers = {}
|
| 133 |
if callback_secret:
|
| 134 |
headers["X-Callback-Secret"] = callback_secret
|
| 135 |
+
|
| 136 |
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 137 |
response = await client.post(callback_url, json=body, headers=headers)
|
| 138 |
response.raise_for_status()
|
| 139 |
except Exception as exc:
|
|
|
|
| 140 |
print(f"[CV-ASYNC] Failed to POST callback to {callback_url}: {exc}")
|
| 141 |
|
| 142 |
+
|
| 143 |
@dataclass
|
| 144 |
class CvTask:
|
| 145 |
job_id: str
|
|
|
|
| 148 |
callback_url: str
|
| 149 |
callback_secret: str = None
|
| 150 |
|
| 151 |
+
|
| 152 |
class QueueManager:
|
| 153 |
+
"""
|
| 154 |
+
Manages an asyncio Queue with a single background worker.
|
| 155 |
+
|
| 156 |
+
concurrency=1 is intentional — the Marker ML model and the
|
| 157 |
+
sentence-transformer are NOT thread-safe and must not run in
|
| 158 |
+
parallel on the same process. Jobs queue up and are processed
|
| 159 |
+
one at a time. The dedicated _ML_EXECUTOR above ensures ML work
|
| 160 |
+
never blocks the asyncio event loop — HTTP endpoints (job-status,
|
| 161 |
+
new submissions) stay responsive even while a CV is being processed.
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
def __init__(self, worker: CvWorker, concurrency: int = 1):
|
| 165 |
self.worker = worker
|
| 166 |
self.concurrency = concurrency
|
| 167 |
+
self.queue: asyncio.Queue = asyncio.Queue()
|
| 168 |
+
self.tasks: list = []
|
| 169 |
|
| 170 |
+
async def start(self) -> None:
|
| 171 |
+
"""Spawn background worker coroutines."""
|
| 172 |
for _ in range(self.concurrency):
|
| 173 |
task = asyncio.create_task(self._worker_loop())
|
| 174 |
self.tasks.append(task)
|
| 175 |
+
print(f"[QUEUE] Started {self.concurrency} worker(s) — one CV processed at a time.")
|
| 176 |
|
| 177 |
+
async def stop(self) -> None:
|
| 178 |
+
"""Cancel all running workers cleanly."""
|
| 179 |
for task in self.tasks:
|
| 180 |
task.cancel()
|
| 181 |
await asyncio.gather(*self.tasks, return_exceptions=True)
|
| 182 |
+
_ML_EXECUTOR.shutdown(wait=False)
|
| 183 |
print("[QUEUE] Stopped all workers.")
|
| 184 |
|
| 185 |
+
async def _worker_loop(self) -> None:
|
| 186 |
+
"""Continuously pull tasks from the queue and process them one by one."""
|
| 187 |
while True:
|
| 188 |
try:
|
| 189 |
+
task: CvTask = await self.queue.get() # suspends here — event loop stays free
|
| 190 |
+
job_store.set_processing(task.job_id)
|
| 191 |
+
print(f"[QUEUE] Worker picked up job {task.job_id}. "
|
| 192 |
+
f"Remaining in queue: {self.queue.qsize()}")
|
| 193 |
await self.worker.run_cv_processing(
|
| 194 |
task.job_id,
|
| 195 |
task.file_bytes,
|
| 196 |
task.filename,
|
| 197 |
task.callback_url,
|
| 198 |
+
task.callback_secret,
|
| 199 |
)
|
| 200 |
self.queue.task_done()
|
| 201 |
except asyncio.CancelledError:
|
| 202 |
break
|
| 203 |
+
except Exception as exc:
|
| 204 |
+
print(f"[QUEUE] Unhandled error in worker loop: {exc}")
|
| 205 |
+
|
| 206 |
+
async def enqueue(self, task: CvTask) -> None:
|
| 207 |
+
"""Add a task to the queue and mark it QUEUED in the store immediately."""
|
| 208 |
+
job_store.set_queued(task.job_id)
|
| 209 |
+
await self.queue.put(task) # asyncio.Queue with no maxsize — never blocks
|
| 210 |
+
print(f"[QUEUE] Job {task.job_id} enqueued. Queue depth: {self.queue.qsize()}")
|