File size: 10,718 Bytes
167c070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7587052
167c070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82a91b9
167c070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e047b8
 
 
 
f731db5
 
 
167c070
 
 
 
 
 
 
 
3e047b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167c070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc2fe9f
 
167c070
 
 
 
 
 
 
 
fc2fe9f
 
167c070
fc2fe9f
167c070
 
 
fc2fe9f
 
167c070
 
 
 
 
 
 
 
 
 
 
 
 
fc2fe9f
167c070
fc2fe9f
167c070
 
fc2fe9f
167c070
 
 
 
 
da3be01
 
167c070
 
fc2fe9f
167c070
 
fc2fe9f
 
 
167c070
 
 
fc2fe9f
da3be01
167c070
 
fc2fe9f
 
 
 
 
 
 
 
 
 
 
 
167c070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""Background-job queue (in-memory) with crash recovery.

Stage machine:
  uploading β†’ extracting β†’ chunking β†’ embedding β†’ indexing β†’ completed
                                                              β†˜ failed
  duplicate | skipped | stuck are terminal error-like states
"""
import asyncio
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Awaitable, Callable, Optional

logger = logging.getLogger("rga_auditor.jobs")

PROCESSOR_TIMEOUT_S = float(os.getenv("JOB_PROCESSOR_TIMEOUT", "300"))
WORKER_POLL_INTERVAL_S = float(os.getenv("JOB_POLL_INTERVAL", "1.0"))
MAX_CONCURRENT_JOBS = int(os.getenv("JOB_MAX_CONCURRENT", "5"))

# Allowed stages
STAGE_UPLOADING = "uploading"
STAGE_EXTRACTING = "extracting"
STAGE_CHUNKING = "chunking"
STAGE_EMBEDDING = "embedding"
STAGE_INDEXING = "indexing"
STAGE_COMPLETED = "completed"
STAGE_FAILED = "failed"
STAGE_DUPLICATE = "duplicate"
STAGE_SKIPPED = "skipped"
STAGE_STUCK = "stuck"

ALLOWED_STAGES = {
    STAGE_UPLOADING, STAGE_EXTRACTING, STAGE_CHUNKING, STAGE_EMBEDDING,
    STAGE_INDEXING, STAGE_COMPLETED, STAGE_FAILED, STAGE_DUPLICATE,
    STAGE_SKIPPED, STAGE_STUCK,
}


@dataclass
class JobRecord:
    id: str = field(default_factory=lambda: uuid.uuid4().hex)
    user_id: str = ""
    document_id: str = ""
    filename: str = ""
    content_path: str = ""
    privacy: bool = False
    stage: str = STAGE_UPLOADING
    progress: int = 0
    attempts: int = 0
    max_attempts: int = 3
    error: Optional[str] = None
    created_at: datetime = field(default_factory=datetime.utcnow)
    started_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None
    next_run_at: datetime = field(default_factory=datetime.utcnow)

    def to_status_dict(self) -> dict:
        return {
            "id": self.id,
            "filename": self.filename,
            "stage": self.stage,
            "progress": self.progress,
            "error": self.error,
            "document_id": self.document_id,
            "user_id": self.user_id,
            "created_at": self.created_at.isoformat() + "Z" if self.created_at else None,
            "updated_at": (self.updated_at or self.created_at).isoformat() + "Z" if (self.updated_at or self.created_at) else None,
        }


ProcessorFn = Callable[["JobRecord"], Awaitable[None]]


class InMemoryJobQueue:
    def __init__(self) -> None:
        self._jobs: dict[str, JobRecord] = {}
        self._lock = asyncio.Lock()
        self._wake = asyncio.Event()
        self._wake.set()

    async def enqueue(self, record: JobRecord) -> None:
        async with self._lock:
            self._jobs[record.id] = record
            self._wake.set()

    async def update(self, job_id: str, **fields) -> Optional[JobRecord]:
        async with self._lock:
            r = self._jobs.get(job_id)
            if r is None:
                return None
            for k, v in fields.items():
                if hasattr(r, k):
                    setattr(r, k, v)
            r.updated_at = datetime.utcnow()
            return r

    async def claim(self) -> Optional[JobRecord]:
        async with self._lock:
            now = datetime.utcnow()
            for r in self._jobs.values():
                if r.stage == STAGE_UPLOADING and r.next_run_at <= now:
                    r.stage = STAGE_EXTRACTING
                    r.progress = max(r.progress, 10)
                    r.started_at = now
                    r.updated_at = now
                    r.attempts += 1
                    return r
        return None

    async def complete(self, record: JobRecord) -> None:
        async with self._lock:
            r = self._jobs.get(record.id)
            if r is None:
                return
            r.stage = STAGE_COMPLETED
            r.progress = 100
            r.updated_at = datetime.utcnow()
            self._wake.set()

    async def fail(self, record: JobRecord, error: str) -> None:
        async with self._lock:
            r = self._jobs.get(record.id)
            if r is None:
                return
            r.error = error[:1000]
            r.updated_at = datetime.utcnow()
            if r.attempts >= r.max_attempts:
                r.stage = STAGE_STUCK
            else:
                r.stage = STAGE_UPLOADING
                r.progress = 0
                r.next_run_at = datetime.utcnow() + timedelta(seconds=2 ** r.attempts)
            self._wake.set()

    async def requeue_processing(self) -> int:
        """On startup, any non-terminal jobs are stale β€” re-queue them.
        Also requeue DLQ items that have been stuck for >1 hour with retries < 3.
        """
        from datetime import datetime
        from src.database.repository import list_dlq, push_dlq
        from src.api.main import JobRecord
        from dateutil.parser import isoparse
        async with self._lock:
            n = 0
            for r in self._jobs.values():
                if r.stage not in (STAGE_COMPLETED, STAGE_FAILED, STAGE_DUPLICATE, STAGE_SKIPPED, STAGE_STUCK):
                    r.stage = STAGE_UPLOADING
                    r.progress = 0
                    r.next_run_at = datetime.utcnow()
                    n += 1
            # DLQ auto-retry: requeue items stuck >1hr with <3 retries
            try:
                dlq_items = await list_dlq(None, limit=50)
                for item in dlq_items:
                    retry_count = item.get("retry_count", 0)
                    created_at = item.get("created_at")
                    if retry_count < 3 and created_at:
                        age_hours = (datetime.utcnow() - isoparse(created_at)).total_seconds() / 3600
                        if age_hours > 1:
                            # Re-enqueue as new job
                            record = JobRecord(
                                user_id=item.get("user_id", ""),
                                document_id=item.get("document_id", ""),
                                filename=item.get("filename", ""),
                                content_path="",
                                privacy=False,
                            )
                            self._jobs[record.id] = record
                            n += 1
                            # Increment retry count in DLQ
                            await push_dlq(None, item["source"], item["error"], item["payload"], item["filename"], retry_count=retry_count + 1)
            except Exception as e:
                logger.warning("DLQ auto-retry check failed: %s", e)
            return n

    async def get(self, job_id: str) -> Optional[JobRecord]:
        async with self._lock:
            return self._jobs.get(job_id)

    async def size(self) -> int:
        async with self._lock:
            return sum(1 for r in self._jobs.values() if r.stage in (STAGE_UPLOADING, STAGE_EXTRACTING, STAGE_CHUNKING, STAGE_EMBEDDING, STAGE_INDEXING))


class JobQueueWorker:
    """Background task that polls a queue and runs a processor on each job."""

    def __init__(self, queue: InMemoryJobQueue, processor: Optional[ProcessorFn] = None) -> None:
        self.queue = queue
        self.processor = processor
        self._task: Optional[asyncio.Task] = None
        self._stop = asyncio.Event()
        self._sem = asyncio.Semaphore(MAX_CONCURRENT_JOBS)
        self._active_tasks: set[asyncio.Task] = set()

    def set_processor(self, fn: ProcessorFn) -> None:
        self.processor = fn

    async def start(self) -> None:
        if self._task is not None:
            return
        await self.queue.requeue_processing()
        self._sem = asyncio.Semaphore(MAX_CONCURRENT_JOBS)
        self._active_tasks: set[asyncio.Task] = set()
        self._task = asyncio.create_task(self._run(), name="job-queue-worker")
        logger.info("Job queue worker started (max_concurrent=%d)", MAX_CONCURRENT_JOBS)

    async def stop(self) -> None:
        self._stop.set()
        for t in list(self._active_tasks):
            t.cancel()
        if self._task is not None:
            self._task.cancel()
            try:
                await self._task
            except (asyncio.CancelledError, Exception):
                pass
        logger.info("Job queue worker stopped")

    async def enqueue(self, record: JobRecord) -> None:
        await self.queue.enqueue(record)

    async def _run(self) -> None:
        while not self._stop.is_set():
            self._active_tasks = {t for t in self._active_tasks if not t.done()}
            try:
                await self._sem.acquire()
                record = await self.queue.claim()
                if record is None:
                    self._sem.release()
                    try:
                        await asyncio.wait_for(self.queue._wake.wait(), timeout=WORKER_POLL_INTERVAL_S)
                    except asyncio.TimeoutError:
                        pass
                    self.queue._wake.clear()
                    if await self.queue.size() > 0:
                        self.queue._wake.set()
                    continue
                if self.processor is None:
                    self._sem.release()
                    await self.queue.fail(record, "no processor registered")
                    continue
                task = asyncio.create_task(self._process_one(record), name=f"job-{record.id}")
                self._active_tasks.add(task)
                task.add_done_callback(lambda _t: self._sem.release())
            except asyncio.CancelledError:
                break
            except Exception as e:
                self._sem.release()
                logger.exception("worker loop error")
                await asyncio.sleep(1.0)

    async def _process_one(self, record: JobRecord) -> None:
        try:
            await asyncio.wait_for(self.processor(record), timeout=PROCESSOR_TIMEOUT_S)
            await self.queue.complete(record)
        except asyncio.TimeoutError:
            await self.queue.fail(record, f"timeout after {PROCESSOR_TIMEOUT_S}s")
        except asyncio.CancelledError:
            await self.queue.fail(record, "worker cancelled")
        except Exception as e:
            logger.exception("job %s failed", record.id)
            await self.queue.fail(record, f"{type(e).__name__}: {e}"[:1000])


_queue: Optional[InMemoryJobQueue] = None
_worker: Optional[JobQueueWorker] = None


def get_queue() -> InMemoryJobQueue:
    global _queue
    if _queue is None:
        _queue = InMemoryJobQueue()
    return _queue


def get_worker() -> JobQueueWorker:
    global _worker
    if _worker is None:
        _worker = JobQueueWorker(get_queue())
    return _worker