Spaces:
Running
Running
| """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, | |
| } | |
| 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.""" | |
| 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 | |
| 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 | |