Source code for ascribe_link.job_registry

"""Job registry for dynamic specimen progress tracking.

Jobs are ephemeral — stored in-memory, not persisted. Each job tracks
status, a bounded deque of progress messages, and the underlying
asyncio.Task running the specimen function.
"""
from __future__ import annotations

import asyncio
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from typing import Any, Literal

from ascribe_link.progress import ProgressMessage

Status = Literal["running", "done", "error"]


[docs] @dataclass class Job: """A single specimen-load job. Fields are mutated from the job's own asyncio.Task (single-appender for `messages`) and read from poll handlers. `collections.deque` is safe for single-appender / multi-reader without explicit locking; poll handlers snapshot `(next_seq, list(messages))` before serializing. """ id: str specimen_id: str params: dict[str, Any] room_id: str status: Status = "running" messages: deque[ProgressMessage] = field( default_factory=lambda: deque(maxlen=50) ) next_seq: int = 0 result: Any = None error: str | None = None task: asyncio.Task | None = None created_at: float = field(default_factory=time.monotonic) finished_at: float | None = None # For federated jobs: the (worker_id, worker_job_id) to proxy to. federated_to: tuple[str, str] | None = None
[docs] def append_message(self, text: str) -> ProgressMessage: """Append a new progress message and bump next_seq.""" msg = ProgressMessage(seq=self.next_seq, text=text, ts=time.time()) self.messages.append(msg) self.next_seq += 1 return msg
[docs] def messages_since(self, since: int) -> list[ProgressMessage]: """Return only messages with seq > since (kept in the deque).""" return [m for m in self.messages if m.seq > since]
[docs] class JobRegistry: """In-memory job store with TTL sweeping for completed jobs.""" def __init__(self, ttl_seconds: float = 300.0) -> None: self._jobs: dict[str, Job] = {} self._lock = asyncio.Lock() self._ttl = ttl_seconds
[docs] async def create( self, specimen_id: str, params: dict[str, Any], room_id: str, ) -> Job: """Create a new running job and register it atomically.""" job_id = uuid.uuid4().hex job = Job( id=job_id, specimen_id=specimen_id, params=params, room_id=room_id, ) 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 delete(self, job_id: str) -> None: async with self._lock: self._jobs.pop(job_id, None)
[docs] async def sweep_expired(self) -> int: """Remove jobs whose finished_at is older than ttl_seconds. Running jobs (status == "running") are never swept even if old. Returns the number of jobs removed. """ now = time.monotonic() removed = 0 async with self._lock: for job_id in list(self._jobs.keys()): job = self._jobs[job_id] if job.finished_at is None: continue if now - job.finished_at > self._ttl: del self._jobs[job_id] removed += 1 return removed
[docs] async def run_sweeper(self, interval: float = 30.0) -> None: """Background task — sweep expired jobs every `interval` seconds.""" while True: try: await asyncio.sleep(interval) await self.sweep_expired() except asyncio.CancelledError: raise except Exception: # Never let a sweep failure kill the sweeper. pass