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