Rejigging async jobs

This commit is contained in:
Ian Roddis committed 2026-08-05 16:21:31 -03:00
1 parent 2a0b8fcab2
commit 2e7976e382
7 files changed
+225 -86

No files matched your search

+2
View File
@@ -14,6 +14,7 @@ from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
from app.models.job import Job
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
@@ -40,6 +41,7 @@ __all__ = [
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
"Job",
"Manufacturer",
"PartColour",
"PartType",
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Job(Base):
"""Tracks a long-running admin operation (sync, import, fit recompute).
Persisted (rather than kept in a module-level dict) so that any worker
process/replica handling a status-poll request can see progress recorded
by whichever process is actually running the job.
"""
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
kind: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running"
) # running | done | error
message: Mapped[str] = mapped_column(Text, nullable=False, default="Working…")
result: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
+74 -52
View File
@@ -1,11 +1,12 @@
"""Lightweight in-process tracking for long-running admin operations.
"""DB-backed tracking for long-running admin operations.
Jobs live in a module-level dict, so this only works correctly for a single
OS process (the app's current deployment: one uvicorn worker, no multi-process
or multi-replica setup — see ``entrypoint.sh`` / ``docker-compose.yml``). If
the app is ever scaled to multiple workers/replicas, this needs to move to
shared storage (a DB table, Redis) so a poll request landing on a different
process can still see the job.
Jobs are persisted to the ``jobs`` table rather than kept in a module-level
dict, so that a status-poll request landing on a *different* worker
process/replica than the one running the job can still see its progress —
required once the app runs with more than one uvicorn worker or replica
(a module-level dict is process-local and would report "not found" for a job
started elsewhere, even while that job is still running and writing to the
shared database).
"""
from __future__ import annotations
@@ -13,62 +14,83 @@ from __future__ import annotations
import asyncio
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
_MAX_FINISHED_AGE_SECONDS = 3600
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
from app.models.job import Job
_MAX_FINISHED_AGE = timedelta(hours=1)
@dataclass
class Job:
id: str
kind: str
status: str = "running" # running | done | error
message: str = "Working…"
result: object | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
finished_at: datetime | None = None
async def get(db: AsyncSession, job_id: str) -> Job | None:
return await db.get(Job, job_id)
_jobs: dict[str, Job] = {}
async def start(kind: str, run: Callable[[str], Awaitable[None]]) -> Job:
"""Create a job row, launch ``run(job_id)`` in the background, and return
the job immediately.
def get(job_id: str) -> Job | None:
return _jobs.get(job_id)
def _evict_stale() -> None:
now = datetime.now(UTC)
stale = [
job_id
for job_id, job in _jobs.items()
if job.finished_at
and (now - job.finished_at).total_seconds() > _MAX_FINISHED_AGE_SECONDS
]
for job_id in stale:
del _jobs[job_id]
def start(kind: str, run: Callable[[Job], Awaitable[None]]) -> Job:
"""Create a job, launch ``run(job)`` in the background, and return it immediately.
``run`` is responsible for updating ``job.status``/``job.message``/``job.result``
to reflect the outcome, including catching its own errors. Any exception that
escapes anyway is caught here as a last resort so the job doesn't hang forever
in "running" state.
``run`` is responsible for calling ``mark_done``/``mark_error`` to record
the outcome, including on its own errors. Any exception that escapes
anyway is caught here as a last resort so the job doesn't hang forever in
"running" state.
"""
_evict_stale()
job = Job(id=uuid.uuid4().hex, kind=kind)
_jobs[job.id] = job
async with AsyncSessionLocal() as db:
await _evict_stale(db)
job = Job(id=uuid.uuid4().hex, kind=kind, status="running", message="Working…")
db.add(job)
await db.commit()
async def _runner() -> None:
try:
await run(job)
await run(job.id)
except Exception as exc:
job.status = "error"
job.message = f"{kind} failed: {exc}"
finally:
job.finished_at = datetime.now(UTC)
await mark_error(job.id, f"{kind} failed: {exc}")
asyncio.create_task(_runner())
return job
async def mark_done(job_id: str, message: str, result: dict | None = None) -> None:
await _update(job_id, status="done", message=message, result=result)
async def mark_error(job_id: str, message: str) -> None:
await _update(job_id, status="error", message=message)
async def set_message(job_id: str, message: str) -> None:
await _update(job_id, message=message)
async def _update(
job_id: str,
*,
status: str | None = None,
message: str | None = None,
result: dict | None = None,
) -> None:
async with AsyncSessionLocal() as db:
job = await db.get(Job, job_id)
if job is None:
return
if status is not None:
job.status = status
if status in ("done", "error"):
job.finished_at = datetime.now(UTC)
if message is not None:
job.message = message
if result is not None:
job.result = result
await db.commit()
async def _evict_stale(db: AsyncSession) -> None:
cutoff = datetime.now(UTC) - _MAX_FINISHED_AGE
await db.execute(
delete(Job).where(Job.finished_at.is_not(None), Job.finished_at < cutoff)
)
await db.commit()
+45 -33
View File
@@ -1511,13 +1511,12 @@ async def upload_scope_import(
data = await file.read()
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
try:
result = await scope_import_service.import_from_bytes(db, data)
except Exception as exc: # noqa: BLE001
job.status = "error"
job.message = f"Could not read spreadsheet: {exc}"
await jobs.mark_error(job_id, f"Could not read spreadsheet: {exc}")
return
actor_row = await user_crud.get(db, actor_id)
await audit_crud.log(
@@ -1532,10 +1531,16 @@ async def upload_scope_import(
f"{result.errors} errors"
),
)
job.status = "done"
job.result = result
await jobs.mark_done(
job_id,
message=(
f"Scope import complete: {result.created} created, "
f"{result.updated} updated."
),
result=result.model_dump(mode="json"),
)
job = jobs.start("scope_import", _run)
job = await jobs.start("scope_import", _run)
return templates.TemplateResponse(
request, "admin/_job_progress.html", _scope_import_progress_ctx(job)
)
@@ -1545,9 +1550,10 @@ async def upload_scope_import(
async def scope_import_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
return templates.TemplateResponse(
request,
@@ -1718,13 +1724,12 @@ async def sync_augmented_parts(
):
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
try:
result = await augmented_part_sync_service.sync(db)
except Exception as exc:
job.status = "error"
job.message = f"Sync failed: {exc}"
await jobs.mark_error(job_id, f"Sync failed: {exc}")
return
actor_row = await user_crud.get(db, actor_id)
await audit_crud.log(
@@ -1738,18 +1743,21 @@ async def sync_augmented_parts(
f"{result.skipped} skipped (no type match)"
),
)
job.status = "done"
job.message = (
f"Sync complete: {result.created} created, {result.deleted} deleted"
+ (
f", {result.skipped} skipped (type not recognised)"
if result.skipped
else ""
)
+ "."
await jobs.mark_done(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.deleted} deleted"
+ (
f", {result.skipped} skipped (type not recognised)"
if result.skipped
else ""
)
+ "."
),
)
job = jobs.start("augmented_part_sync", _run)
job = await jobs.start("augmented_part_sync", _run)
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
@@ -1768,7 +1776,7 @@ async def augmented_parts_sync_job_status(
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
form_ctx = await _augmented_part_form_ctx(db)
return templates.TemplateResponse(
@@ -2483,7 +2491,7 @@ async def recompute_augmented_part_fits(
):
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
result = await fit_matching_service.recompute_for_part(
db, augmented_part_id
@@ -2500,10 +2508,11 @@ async def recompute_augmented_part_fits(
f"{result.created} created, {result.removed} removed"
),
)
job.status = "done"
job.message = f"{result.created} created, {result.removed} removed."
await jobs.mark_done(
job_id, f"{result.created} created, {result.removed} removed."
)
job = jobs.start("fit_recompute_part", _run)
job = await jobs.start("fit_recompute_part", _run)
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
@@ -2527,7 +2536,7 @@ async def augmented_part_fits_recompute_job_status(
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
ctx = await _augmented_part_fits_ctx(
db,
@@ -2774,7 +2783,7 @@ async def recompute_all_fits(
):
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
result = await fit_matching_service.recompute_all(db)
actor_row = await user_crud.get(db, actor_id)
@@ -2789,13 +2798,15 @@ async def recompute_all_fits(
f"{result.removed} removed"
),
)
job.status = "done"
job.message = (
f"Recompute complete: {result.created} created, "
f"{result.removed} removed."
await jobs.mark_done(
job_id,
message=(
f"Recompute complete: {result.created} created, "
f"{result.removed} removed."
),
)
job = jobs.start("fit_recompute_all", _run)
job = await jobs.start("fit_recompute_all", _run)
return templates.TemplateResponse(
request,
"admin/_job_progress_inline.html",
@@ -2813,9 +2824,10 @@ async def recompute_all_fits(
async def recompute_all_fits_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("fits:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
return HTMLResponse("Recompute job not found — it may have expired.")
if job.status == "running":