Worker & Progress
Summary
Long-running work runs in a background worker. Jobs are durable documents that
move through an explicit status machine. Progress is streamed to clients.
The status machine
| pending -> processing -> completed
\-> failed (retried up to N times)
\-> paused (waiting for human input)
|
pending — queued, eligible for pickup.
processing — claimed by a worker.
paused — blocked on a human-in-the-loop question.
failed — error; retry_count increments until a max, then skipped.
completed — terminal success.
The poll loop
| async def poll(resources):
backoff = MIN_POLL
while True:
jobs = await job_svc.search(filter={"status": "pending"}, limit=100)
backoff = MIN_POLL if jobs else min(backoff * 2, MAX_POLL)
for job in jobs:
if (job.retry_count or 0) >= MAX_RETRIES:
continue
await job_svc.update_status(job.id, "processing")
try:
await process(job, resources)
except PauseForResolution:
await job_svc.update_status(job.id, "paused")
except Exception as e:
await job_svc.update_status(
job.id, "failed", error=str(e),
retry_count=(job.retry_count or 0) + 1,
)
await asyncio.sleep(backoff)
|
Tunables live in environment variables: WORKER_POLL_MIN, WORKER_POLL_MAX,
WORKER_MAX_RETRIES.
Progress events
A worker publishes structured progress events to a bus (a port). The HTTP
edge exposes a streaming endpoint that replays events for a given job id.
| class ProgressEvent(BaseModel):
job_id: str
step: str
status: str # started | progress | completed | skipped | paused
message: dict | None = None
|
The bus is an in-memory adapter by default; because it is a port, it can be
swapped for a durable broker. Emit from the orchestrator at each stage:
| await self.bus.publish(ProgressEvent(job_id=jid, step="normalize",
status="completed", message={"count": n}))
|
Streaming endpoint
| async def stream_job_events(request, id):
bus = request.app.state.db_resources.get("progress_bus")
if bus is None:
return {"events": []}
async def gen():
sent = 0
while True:
events = bus.get_events(id)
for e in events[sent:]:
yield f"data: {e.model_dump_json(exclude_none=True)}\n\n"
sent += 1
await asyncio.sleep(0.5)
return StreamingResponse(gen(), media_type="text/event-stream")
|
Known limitation. The default in-memory bus is per-process. If the worker
and the API run as separate processes, events published by the worker are not
visible to the API. For production, back the bus with a shared broker. The
port boundary makes this a one-adapter change.