The audit log showed a failed RSS poll as the server's English sentence under a Hungarian UI. A job can now raise progress.JobFailed(reason, message, **params): the scheduler stores the reason and params as the run's data (dashboard, manual-run notice, audit row) beside the English line, and the client renders scheduler.failure.<reason>. The RSS outage names its first channel and the reason code (HTTP 404 vs unreachable). An upstream's own error text stays as raised (diagnostic).
55 lines
2.0 KiB
Python
55 lines
2.0 KiB
Python
"""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 JobFailed(RuntimeError):
|
|
"""A job's own failure, said so the reader's language can name it (E31): the client reads
|
|
`scheduler.failure.<reason>` and interpolates `params` (keep them to values the reader may see —
|
|
numbers, ids, short codes). The message is the English line the log and `last_error` keep. An
|
|
upstream's own error (a timeout, a refused connection) is not one of these: it stays as raised,
|
|
being diagnostic."""
|
|
|
|
def __init__(self, reason: str, message: str, **params: object):
|
|
super().__init__(message)
|
|
self.reason = reason
|
|
self.params = params
|
|
|
|
|
|
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
|