Download src/knowledge_ingest/store.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 9.58 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/src/knowledge_ingest/store.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/src/knowledge_ingest/store.py
-
curl -L -o store.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/src/knowledge_ingest/store.py
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") | |
| 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) | |
| 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, | |
| ) | |
| 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 | |