ishaq101's picture
/fix parsing and term extract (#21)
f07443e
Raw History Blame Contribute Delete
9.58 kB
"""JobStore — the seam ingestion job state is read and written through.
Two implementations. `PostgresJobStore` is the real one and is what the service
uses: the `knowledge_jobs` table was created 2026-09-07 and jobs must survive a
restart, because a batch runs for minutes and an in-memory job lost to a redeploy
leaves the caller polling an id that will never resolve. `InMemoryJobStore`
remains for tests and for running the service without a database.
**This store deliberately does NOT follow the never-throw convention.** The
seams listed in CLAUDE.md §5.4 exist so a *chat turn* degrades to soft output
rather than 500ing at the user. An ingestion job is the opposite case: it is
explicitly asynchronous, the caller polls for its outcome, and a job that
silently lost its rows while reporting success is strictly worse than one that
reports `failed`. Persistence failures here propagate and settle the job.
⚠️ `InMemoryJobStore` state does not survive a restart, which is why it is not
the default. A lost job is re-triggerable and content-addressing means the retry
re-spends nothing, but the caller is left polling an id that no longer exists.
"""
from __future__ import annotations
import asyncio
from typing import Protocol, runtime_checkable
from sqlalchemy import select
from src.db.postgres.connection import AsyncSessionLocal
from src.db.postgres.models import KnowledgeJobRow
from src.knowledge_ingest.models import DocumentResult, IngestJob, SkippedDocument
from src.middlewares.logging import get_logger
logger = get_logger("knowledge_job_store")
@runtime_checkable
class JobStore(Protocol):
"""Create, read and update ingestion jobs.
`get` returns None for an unknown id — the caller turns that into a 404
rather than the store guessing at an HTTP shape.
"""
async def create(self, job: IngestJob) -> IngestJob: ...
async def get(self, job_id: str) -> IngestJob | None: ...
async def save(self, job: IngestJob) -> None: ...
async def list_for_user(self, user_id: str, limit: int = 20) -> list[IngestJob]: ...
async def find_active(self, scope_id: str) -> IngestJob | None: ...
class InMemoryJobStore:
"""Process-local job state, guarded by a lock.
The lock matters for `find_active`: two clicks arriving together must not
both conclude that no job is running and start one each. Single-flight is a
hard requirement — two concurrent runs mean two 1.2 GB GLiNER models
resident at once (KNOWLEDGE_SURFACE_PLAN.md §4, N10d).
"""
def __init__(self) -> None:
self._jobs: dict[str, IngestJob] = {}
self._lock = asyncio.Lock()
async def create(self, job: IngestJob) -> IngestJob:
async with self._lock:
self._jobs[job.id] = job
logger.info(
"ingest_job_created",
extra={"job_id": job.id, "scope_id": job.scope_id, "n": job.n_requested},
)
return job
async def get(self, job_id: str) -> IngestJob | None:
return self._jobs.get(job_id)
async def save(self, job: IngestJob) -> None:
self._jobs[job.id] = job
async def list_for_user(self, user_id: str, limit: int = 20) -> list[IngestJob]:
jobs = [j for j in self._jobs.values() if j.user_id == user_id]
jobs.sort(key=lambda j: j.created_at, reverse=True)
return jobs[:limit]
async def find_active(self, scope_id: str) -> IngestJob | None:
"""The in-flight job for a scope, if any — the single-flight check.
Scope-wide rather than per-user on purpose: the GLiNER model and the
executor are process resources, so two users in one company clicking at
once is exactly the case that must serialise.
"""
for job in self._jobs.values():
if job.scope_id == scope_id and not job.is_terminal:
return job
return None
class PostgresJobStore:
"""Job state in `knowledge_jobs` - the durable implementation (N4).
Replaces `InMemoryJobStore` now that the table exists. The distinction that
matters is restart survival: a batch runs for minutes, and an in-memory job
lost to a redeploy leaves the caller polling an id that will never resolve.
`IngestJob` mirrors the table field for field, so the mapping here is
mechanical in both directions and there is no second definition of a job to
keep in step.
"""
async def _to_row(self, session, job: IngestJob) -> KnowledgeJobRow | None:
return await session.get(KnowledgeJobRow, job.id)
@staticmethod
def _from_row(row: KnowledgeJobRow) -> IngestJob:
"""Row to model. `getattr` throughout: Harry's migration may add columns,
and nothing here may assume it has not (CLAUDE.md 2.2)."""
return IngestJob(
id=row.id,
scope_id=row.scope_id,
user_id=row.user_id,
status=row.status,
stage=row.stage,
review_mode=row.review_mode or "expert",
document_ids=list(row.document_ids or []),
n_requested=row.n_requested or 0,
n_succeeded=row.n_succeeded or 0,
n_failed=row.n_failed or 0,
results=[DocumentResult(**r) for r in (row.results or [])],
skipped=[SkippedDocument(**s) for s in (getattr(row, "skipped", None) or [])],
estimated_cost=row.estimated_cost,
error_message=row.error_message,
created_at=row.created_at,
started_at=row.started_at,
completed_at=row.completed_at,
)
@staticmethod
def _apply(row: KnowledgeJobRow, job: IngestJob) -> None:
row.status = job.status
row.stage = job.stage
row.document_ids = list(job.document_ids)
row.n_requested = job.n_requested
row.n_succeeded = job.n_succeeded
row.n_failed = job.n_failed
row.results = [r.model_dump() for r in job.results]
row.estimated_cost = job.estimated_cost
row.error_message = job.error_message
row.started_at = job.started_at
row.completed_at = job.completed_at
async def create(self, job: IngestJob) -> IngestJob:
# `skipped` is deliberately NOT persisted: it is a property of the
# request (which documents were declined and why), not of the batch's
# progress, and the column does not exist. It survives in the 202
# response and in the in-process job for the life of the request.
row = KnowledgeJobRow(
id=job.id,
scope_id=job.scope_id,
user_id=job.user_id,
status=job.status,
stage=job.stage,
review_mode=job.review_mode,
document_ids=list(job.document_ids),
n_requested=job.n_requested,
n_succeeded=job.n_succeeded,
n_failed=job.n_failed,
results=[r.model_dump() for r in job.results],
estimated_cost=job.estimated_cost,
error_message=job.error_message,
started_at=job.started_at,
completed_at=job.completed_at,
)
async with AsyncSessionLocal() as session:
session.add(row)
await session.commit()
logger.info(
"ingest_job_created",
extra={"job_id": job.id, "scope_id": job.scope_id, "n": job.n_requested},
)
return job
async def get(self, job_id: str) -> IngestJob | None:
async with AsyncSessionLocal() as session:
row = await session.get(KnowledgeJobRow, job_id)
return self._from_row(row) if row is not None else None
async def save(self, job: IngestJob) -> None:
async with AsyncSessionLocal() as session:
row = await session.get(KnowledgeJobRow, job.id)
if row is None:
raise LookupError(f"no ingestion job {job.id}")
self._apply(row, job)
await session.commit()
async def list_for_user(self, user_id: str, limit: int = 20) -> list[IngestJob]:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(KnowledgeJobRow)
.where(KnowledgeJobRow.user_id == user_id)
.order_by(KnowledgeJobRow.created_at.desc())
.limit(limit)
)
return [self._from_row(r) for r in result.scalars().all()]
async def find_active(self, scope_id: str) -> IngestJob | None:
"""The in-flight job for a scope - the single-flight check.
Scope-wide rather than per-user: the GLiNER model and the executor are
process resources, so two colleagues clicking at once is exactly the
case that must serialise.
⚠️ A job left `running` by a hard crash blocks the scope until it is
settled, because nothing else can distinguish it from one still working.
Acceptable while ingestion is admin-frequency; a lease or a heartbeat is
the fix if that ever stops being true.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(KnowledgeJobRow)
.where(
KnowledgeJobRow.scope_id == scope_id,
KnowledgeJobRow.status.in_(("queued", "running")),
)
.order_by(KnowledgeJobRow.created_at.desc())
.limit(1)
)
row = result.scalars().first()
return self._from_row(row) if row is not None else None