Eng-Musa commited on
Commit
4c6ffbd
·
1 Parent(s): 7f331eb

make excutor process one cv at a time

Browse files
__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=2)
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
- from marker.models import create_model_dict # v1.x API
327
- self._marker_models = create_model_dict()
328
- logger.info("Marker models loaded and cached.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED",
10
- "result": <ProcessorResponse payload dict> | None,
11
- "error": <str> | None,
 
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
- """Background task: process CV then POST result to Java callback endpoint."""
 
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
- conversion = await asyncio.to_thread(self.converter.convert, tmp_path)
 
 
 
 
 
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 asyncio.to_thread(chunk_cv, conversion.markdown)
 
 
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
- """Manages an asyncio Queue with bounded concurrent workers."""
126
-
127
- def __init__(self, worker: CvWorker, concurrency: int = 2):
 
 
 
 
 
 
 
 
 
128
  self.worker = worker
129
  self.concurrency = concurrency
130
- self.queue = asyncio.Queue()
131
- self.tasks = []
132
 
133
- async def start(self):
134
- """Starts the background worker tasks."""
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} concurrent workers.")
139
 
140
- async def stop(self):
141
- """Cancels all running workers."""
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 pulls tasks from the queue and processes them."""
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 e:
163
- print(f"[QUEUE] Unhandled error in worker loop: {e}")
164
-
165
- async def enqueue(self, task: CvTask):
166
- """Adds a new task to the queue."""
167
- await self.queue.put(task)
 
 
 
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()}")