vector-auditor / src /job_queue.py
AkshayM
Add privacy flag to upload — PII runs only when privacy=True (NDA mode)
82a91b9
Raw
History Blame Contribute Delete
9.1 kB
"""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."""
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