| """In-memory async job store for batch extraction. |
| |
| Design intent |
| ------------- |
| Batch extraction submits N documents in one request, returns a job_id |
| immediately, and lets the caller poll for status. To keep the architecture |
| simple (no Redis, no RQ, no external state), the job store is an in-process |
| dict guarded by an asyncio lock. That means: |
| |
| - Jobs live in the memory of one uvicorn worker. Restart wipes them. That is |
| the tradeoff we\'ve accepted at this scale — jobs finish in seconds to |
| minutes, and losing an in-flight job to a restart is acceptable for a |
| portfolio project. Prod would swap this for Redis without touching the API. |
| - The concurrency cap is enforced by a single asyncio.Semaphore initialized |
| at import time. We deliberately size it small (default 5) to stay well |
| under OpenAI\'s per-org rate limits while multiple jobs run in parallel. |
| |
| Public API |
| ---------- |
| store = get_job_store() |
| job = await store.create_job(items) # returns Job with id + pending status |
| await store.mark_running(job_id, index) |
| await store.set_result(job_id, index, result_dict) |
| await store.set_error(job_id, index, err_str) |
| job = await store.get(job_id) # snapshot for the GET endpoint |
| """ |
| from __future__ import annotations |
|
|
| import asyncio |
| import uuid |
| from dataclasses import dataclass |
| from datetime import datetime, timezone |
| from typing import Any, Literal |
|
|
| |
| |
| |
| |
| BATCH_CONCURRENCY = 5 |
|
|
|
|
| ItemStatus = Literal["pending", "running", "done", "error"] |
| JobStatus = Literal["pending", "running", "done"] |
|
|
|
|
| @dataclass |
| class BatchItem: |
| """One document inside a batch job.""" |
| index: int |
| filename: str |
| status: ItemStatus = "pending" |
| result: dict[str, Any] | None = None |
| metrics: dict[str, Any] | None = None |
| error: str | None = None |
|
|
|
|
| @dataclass |
| class Job: |
| """A batch job — a collection of items processed with bounded concurrency.""" |
| id: str |
| doc_type: str |
| model: str | None |
| items: list[BatchItem] |
| created_at: str |
| status: JobStatus = "pending" |
|
|
| @property |
| def progress(self) -> dict[str, int]: |
| return { |
| "total": len(self.items), |
| "done": sum(1 for i in self.items if i.status == "done"), |
| "errors": sum(1 for i in self.items if i.status == "error"), |
| "pending": sum(1 for i in self.items if i.status == "pending"), |
| "running": sum(1 for i in self.items if i.status == "running"), |
| } |
|
|
| def snapshot(self) -> dict[str, Any]: |
| """Serialize the job for the GET endpoint — small, safe, JSON-friendly.""" |
| return { |
| "job_id": self.id, |
| "doc_type": self.doc_type, |
| "model": self.model, |
| "status": self.status, |
| "progress": self.progress, |
| "created_at": self.created_at, |
| "items": [ |
| { |
| "index": it.index, |
| "filename": it.filename, |
| "status": it.status, |
| "error": it.error, |
| "result": it.result, |
| "metrics": it.metrics, |
| } |
| for it in self.items |
| ], |
| } |
|
|
|
|
| class JobStore: |
| """Async-safe in-memory store. One instance per process.""" |
|
|
| def __init__(self) -> None: |
| self._jobs: dict[str, Job] = {} |
| self._lock = asyncio.Lock() |
| |
| |
| |
| self._semaphore: asyncio.Semaphore | None = None |
|
|
| @property |
| def semaphore(self) -> asyncio.Semaphore: |
| if self._semaphore is None: |
| self._semaphore = asyncio.Semaphore(BATCH_CONCURRENCY) |
| return self._semaphore |
|
|
| async def create_job( |
| self, |
| items: list[tuple[str, bytes]], |
| doc_type: str, |
| model: str | None, |
| ) -> Job: |
| job_id = uuid.uuid4().hex[:16] |
| items_list = [ |
| BatchItem(index=i, filename=fn) for i, (fn, _b) in enumerate(items) |
| ] |
| job = Job( |
| id=job_id, |
| doc_type=doc_type, |
| model=model, |
| items=items_list, |
| created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), |
| ) |
| async with self._lock: |
| self._jobs[job_id] = job |
| return job |
|
|
| async def get(self, job_id: str) -> Job | None: |
| async with self._lock: |
| return self._jobs.get(job_id) |
|
|
| async def mark_running(self, job_id: str, index: int) -> None: |
| async with self._lock: |
| job = self._jobs.get(job_id) |
| if job is None: |
| return |
| job.items[index].status = "running" |
| job.status = "running" |
|
|
| async def set_result( |
| self, |
| job_id: str, |
| index: int, |
| result: dict[str, Any], |
| metrics: dict[str, Any], |
| ) -> None: |
| async with self._lock: |
| job = self._jobs.get(job_id) |
| if job is None: |
| return |
| item = job.items[index] |
| item.status = "done" |
| item.result = result |
| item.metrics = metrics |
| self._maybe_finalize(job) |
|
|
| async def set_error(self, job_id: str, index: int, error: str) -> None: |
| async with self._lock: |
| job = self._jobs.get(job_id) |
| if job is None: |
| return |
| item = job.items[index] |
| item.status = "error" |
| item.error = error |
| self._maybe_finalize(job) |
|
|
| def _maybe_finalize(self, job: Job) -> None: |
| |
| if all(i.status in ("done", "error") for i in job.items): |
| job.status = "done" |
|
|
|
|
| _STORE: JobStore | None = None |
|
|
|
|
| def get_job_store() -> JobStore: |
| """Singleton JobStore. Overridden in tests via dependency_overrides.""" |
| global _STORE |
| if _STORE is None: |
| _STORE = JobStore() |
| return _STORE |
|
|
|
|
| def reset_job_store() -> None: |
| """Only for tests — drop the singleton so each test gets a fresh store.""" |
| global _STORE |
| _STORE = None |
|
|