structured-data-extractor / src /api /batch_store.py
aditya0103's picture
v3: streaming SSE + async batch API (12 new tests, 138 total)
0962ea2
Raw
History Blame Contribute Delete
6.64 kB
"""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
# How many extractions run concurrently across all jobs. Small on purpose —
# OpenAI structured-outputs strict mode is billed on tokens, and we already
# saw ~5s latency per call; five in flight is enough parallelism for a
# batch of 100 to finish in ~100s.
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, # may be None for pending/running
"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()
# Bounded concurrency across ALL jobs — OpenAI rate limits are per-org,
# not per-job. Lazily materialised so the semaphore lives on the correct
# event loop.
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]], # (filename, bytes) pairs
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:
# Called while holding the lock — flip the job to \"done\" once every item is settled.
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