feat(scheduler): admin run-now/run-all triggers + live progress + completion notices
Add per-job "Run now" buttons and a "Start all now" button to the admin Scheduler dashboard (admin-gated endpoints). Triggers run the job in a background thread independent of its interval, refusing a concurrent run (409). While running, the long jobs (maintenance, enrich, backfill, shorts) report live progress through a decoupled contextvar sink, shown as a progress bar on the job row via the existing 4s poll. A manually-triggered run posts a completion notification to the triggering admin's inbox (scheduled runs stay silent to avoid spam); the inbox renders the "scheduler" type trilingually from type+data. While here, give the maintenance job its missing dashboard label/description in all three languages.
This commit is contained in:
parent
3a0789ebe7
commit
ed4194a8d3
15 changed files with 344 additions and 30 deletions
40
backend/app/progress.py
Normal file
40
backend/app/progress.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Lightweight, decoupled progress reporting for long-running scheduler jobs.
|
||||
|
||||
A job's work function calls `report(current, total, phase)`; the scheduler binds a sink (via
|
||||
`bind()`) that forwards it into the in-memory activity the admin dashboard polls. A contextvar
|
||||
holds the sink, so the sync code stays unaware of the scheduler and concurrent jobs (each in
|
||||
its own thread/context) never cross wires. When no sink is bound (e.g. a manual sync endpoint),
|
||||
`report` is a cheap no-op.
|
||||
"""
|
||||
import contextvars
|
||||
from typing import Callable, Optional
|
||||
|
||||
Sink = Callable[[int, Optional[int], Optional[str]], None]
|
||||
|
||||
_sink: contextvars.ContextVar[Optional[Sink]] = contextvars.ContextVar(
|
||||
"progress_sink", default=None
|
||||
)
|
||||
|
||||
|
||||
def report(current: int, total: int | None = None, phase: str | None = None) -> None:
|
||||
"""Report job progress to the bound sink, if any. `total=None` means indeterminate."""
|
||||
sink = _sink.get()
|
||||
if sink is not None:
|
||||
sink(current, total, phase)
|
||||
|
||||
|
||||
class bind:
|
||||
"""Context manager that binds a progress sink for the duration of a job run."""
|
||||
|
||||
def __init__(self, sink: Sink):
|
||||
self._sink = sink
|
||||
self._token = None
|
||||
|
||||
def __enter__(self) -> "bind":
|
||||
self._token = _sink.set(self._sink)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
if self._token is not None:
|
||||
_sink.reset(self._token)
|
||||
return False
|
||||
Loading…
Add table
Add a link
Reference in a new issue