wrolpi/wrolpi/jobs.py
lrnselfreliance 69167391c7
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
api: run the perpetual loops in one Sanic-managed process (#718)
* api: run the perpetual loops in one Sanic-managed process

The singleton background loops (file worker, downloads, switches, jobs,
bulk tag, flag checks, update check) now run in a dedicated process that
Sanic's Worker Manager owns: `run_perpetual_process` is registered with
`app.manager.manage()` from `main_process_ready`.  The manager guarantees
a single instance and SIGTERMs it on shutdown.  No server worker runs the
loops, so the owner-pid / heartbeat / watchdog claim scheme is deleted
rather than hardened.

`perpetual_signal` keeps its name and is now a plain registrar.  A new
`per_worker_task` registrar covers loops every server worker must run for
itself: syncing its log level (previously only the owner did, so the other
workers never picked up level changes) and a health check that asks the
manager to restart the perpetual process when it is FAILED (Sanic marks a
dead managed process but does not restart it).

Downloads are started as background tasks instead of Sanic signals; the
perpetual process has no server, so no finalized signal router.  The
update check and the upgrade-check endpoint run git in a thread via
`refresh_update_status`.  /api/status lists the perpetual process next
to the servers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perpetual: survive a predecessor's death, exit cleanly, coordinate restarts

Review fixes for the managed perpetual process.

Shutdown: cancel_background_tasks / cancel_refresh_tasks gather with
return_exceptions, so a cancelled child's CancelledError no longer aborts
the shutdown that called them (the process exited 1 with a download in
flight).

Restart after a crash: reconcile_after_predecessor heals shared locks a
dead process left held, fails RUNNING Jobs owned by a dead pid, clears
processing_domains, renews pending Downloads, and resets the file-worker
status before the loops start.  N server workers issue one restart
request per cooldown through a shared timestamp in shared_ctx.

The log-level sync now also runs in the perpetual process.  Manage() is
registered through register_perpetual_process so it is testable; the
entry point refuses to run with no loops registered (spawn).

Tests exercise the real entry point in a forked process with a live
background task under SIGINT and SIGTERM, Sanic's real WorkerManager
marking a crashed managed process FAILED and reviving it on restart,
Sanic's process naming and ProcessState enum, the listener registration,
and the reconcile step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perpetual: import the jobs helper inside its reconcile step

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 07:53:53 -06:00

473 lines
17 KiB
Python

"""In-memory FIFO queue of background Jobs.
A Job is a call of a *registered* function with JSON-able kwargs. Any Sanic worker may enqueue a
Job; the perpetual process runs them one at a time, in order. Python logging emitted
while a Job runs, and the output of subprocesses started through `JobContext.run_command`, are
captured into the Job's log. A caller may wait for a Job, poll it, or cancel it.
Jobs are never written to disk: when the API restarts they are lost.
>>> @register_job('example')
>>> async def example(path: str):
>>> job = get_current_job()
>>> job.set_progress(50)
>>> await job.run_command(('ls', path))
>>> job_id = enqueue_job('example', description='List a directory', path='/tmp')
>>> await wait_for_job(job_id)
Functions are registered by name (rather than enqueued as objects) because kwargs and the call
must cross the process boundary between Sanic workers; a name and JSON kwargs always can.
"""
import asyncio
import contextvars
import inspect
import json
import logging
import multiprocessing
import os
import pathlib
import queue
import time
import uuid
from collections import deque
from functools import partial
from http import HTTPStatus
from typing import Callable, Dict, List, Optional
from sanic import Blueprint
from sanic.request import Request
from sanic_ext.extensions.openapi import openapi
from wrolpi.api_utils import api_app, json_response, perpetual_signal
from wrolpi.common import logger
from wrolpi.dates import now
from wrolpi.errors import UnknownJob, InvalidJob
from wrolpi.events import Events
from wrolpi.schema import JSONErrorResponse
from wrolpi.vars import PYTEST
logger = logger.getChild(__name__)
JOB_HANDLERS: Dict[str, Callable] = dict()
PENDING = 'pending'
RUNNING = 'running'
COMPLETE = 'complete'
FAILED = 'failed'
CANCELLED = 'cancelled'
FINISHED_STATUSES = (COMPLETE, FAILED, CANCELLED)
# Only the tail of a Job's log is kept; ffmpeg alone can emit thousands of lines.
JOB_LOG_LINES = 200
# Finished Jobs kept for the UI/API before the oldest are forgotten.
JOB_HISTORY_LIMIT = 100
# Pending Jobs allowed at once; `enqueue_job` refuses past this so a runaway client cannot grow
# shared memory without bound.
JOB_PENDING_LIMIT = 100
# How often the running Job checks whether it has been cancelled from another process.
JOB_CANCEL_POLL_SECONDS = 0.05 if PYTEST else 1
# The Job log lives in this process; it is copied to the shared dict at most this often.
JOB_LOG_FLUSH_SECONDS = 0 if PYTEST else 0.5
def _jobs() -> dict:
try:
return api_app.shared_ctx.jobs
except AttributeError:
raise RuntimeError('Sanic shared_ctx has not been initialized. If testing, use `async_client` fixture.')
def _queue():
return api_app.shared_ctx.jobs_queue
def _lock() -> multiprocessing.Lock:
return api_app.shared_ctx.jobs_lock
def register_job(name: str):
"""Register a function that can be run as a Job by `name`.
The function may be sync or async, and is called with the kwargs given to `enqueue_job`. Use
`get_current_job()` inside the function for logging, progress, subprocesses, and cancellation.
The wrapped function gains an `enqueue(description=None, **kwargs)` method.
"""
def wrapper(func: Callable):
if name in JOB_HANDLERS and not PYTEST:
raise RuntimeError(f'register_job: job name already taken {name}')
JOB_HANDLERS[name] = func
setattr(func, 'enqueue', partial(enqueue_job, name))
return func
return wrapper
def enqueue_job(name: str, description: str = None, **kwargs) -> str:
"""Add a Job to the end of the queue. Returns its id.
@raise InvalidJob: when `name` is not registered, or kwargs are not JSON serializable.
"""
if name not in JOB_HANDLERS:
raise InvalidJob(f'No job is registered with the name {name!r}')
try:
json.dumps(kwargs)
except (TypeError, ValueError) as e:
raise InvalidJob(f'Job kwargs must be JSON serializable: {e}')
job_id = f'{name}-{uuid.uuid4().hex[:8]}'
record = dict(
id=job_id,
name=name,
description=description or name,
kwargs=kwargs,
status=PENDING,
created_at=now().isoformat(),
started_at=None,
finished_at=None,
error=None,
progress=None,
result=None,
log=[],
pid=None,
cancel_requested=False,
)
with _lock():
pending = sum(1 for i in _jobs().values() if i['status'] == PENDING)
if pending >= JOB_PENDING_LIMIT:
raise InvalidJob(f'Too many pending jobs ({pending}); try again once some have finished')
_jobs()[job_id] = record
_prune_finished_jobs()
_queue().put_nowait(job_id)
logger.info(f'enqueue_job: {job_id} ({description or name})')
return job_id
def get_job(job_id: str) -> dict:
"""@raise UnknownJob: when no Job has this id."""
record = _jobs().get(job_id)
if not record:
raise UnknownJob(f'Job {job_id} does not exist')
return dict(record)
def get_jobs() -> List[dict]:
"""All Jobs, oldest first."""
return sorted((dict(i) for i in _jobs().values()), key=lambda i: i['created_at'])
def _update_job(job_id: str, **changes):
"""Manager dict values are copies; a change must be written back as a whole record."""
with _lock():
record = _jobs().get(job_id)
if record is None:
return
record = dict(record)
record.update(changes)
_jobs()[job_id] = record
def _prune_finished_jobs():
"""Forget the oldest finished Jobs beyond `JOB_HISTORY_LIMIT`. The caller holds the lock."""
jobs = _jobs()
finished = sorted((i for i in jobs.values() if i['status'] in FINISHED_STATUSES),
key=lambda i: i['finished_at'] or '')
for record in finished[:max(0, len(finished) - JOB_HISTORY_LIMIT)]:
del jobs[record['id']]
def cancel_job(job_id: str) -> dict:
"""Cancel a pending Job immediately, or ask the running Job to stop.
A running Job's handler is cancelled (its `run_command` subprocess is killed) the next time the
worker polls. Finished Jobs are unchanged.
"""
with _lock():
record = _jobs().get(job_id)
if not record:
raise UnknownJob(f'Job {job_id} does not exist')
record = dict(record)
if record['status'] == PENDING:
record.update(status=CANCELLED, finished_at=now().isoformat())
logger.info(f'cancel_job: cancelled pending {job_id}')
elif record['status'] == RUNNING:
record['cancel_requested'] = True
logger.info(f'cancel_job: requested cancel of running {job_id}')
_jobs()[job_id] = record
return record
def _claim_job(job_id: str) -> Optional[dict]:
"""Move a Job from PENDING to RUNNING in one step under the lock; returns the running record,
or None when the Job is no longer pending (cancelled while queued, or already claimed)."""
with _lock():
record = _jobs().get(job_id)
if not record or record['status'] != PENDING:
return None
record = dict(record)
record.update(status=RUNNING, started_at=now().isoformat(), pid=os.getpid())
_jobs()[job_id] = record
return record
def fail_orphaned_jobs() -> int:
"""Mark RUNNING Jobs owned by another (dead) process as FAILED. Called by the perpetual process when it
starts: only it runs Jobs, so any RUNNING record not carrying its pid belongs to a predecessor that died
mid-Job. Returns how many were failed."""
count = 0
with _lock():
for job_id, record in list(_jobs().items()):
if record['status'] == RUNNING and record.get('pid') != os.getpid():
record = dict(record)
record.update(status=FAILED, finished_at=now().isoformat(),
error='The process running this Job exited before it finished')
_jobs()[job_id] = record
count += 1
if count:
logger.warning(f'fail_orphaned_jobs: failed {count} Job(s) left running by a previous process')
return count
def _cancel_requested(job_id: str) -> bool:
record = _jobs().get(job_id)
return bool(record and record.get('cancel_requested'))
class JobContext:
"""The running Job, available to its handler via `get_current_job()`."""
def __init__(self, job_id: str, name: str):
self.id = job_id
self.name = name
self._log: deque = deque(maxlen=JOB_LOG_LINES)
self._last_flush = 0.0
self._dirty = False
def log(self, line: str):
"""Append a line to the Job's log. Must never call `logger` (the log handler would recurse)."""
self._log.append(str(line).rstrip())
self._dirty = True
if time.monotonic() - self._last_flush >= JOB_LOG_FLUSH_SECONDS:
self.flush()
def flush(self):
if not self._dirty:
return
self._last_flush = time.monotonic()
self._dirty = False
_update_job(self.id, log=list(self._log))
def set_progress(self, percent: Optional[float]):
"""Report progress 0-100 to the UI."""
if percent is not None:
percent = max(0.0, min(100.0, float(percent)))
_update_job(self.id, progress=percent)
def cancel_requested(self) -> bool:
return _cancel_requested(self.id)
async def run_command(self, cmd: tuple[str | pathlib.Path, ...], stdout_callback: Callable = None, **kwargs):
"""`wrolpi.cmd.run_command` whose stdout lines (live) and stderr tail (on exit) are
captured into this Job's log. `stdout_callback` still receives each stdout line."""
from wrolpi.cmd import run_command
def _callback(line: str):
self.log(line)
if stdout_callback:
stdout_callback(line)
# Its own session: killing the command kills anything it spawned, as elsewhere in WROLPi.
kwargs.setdefault('start_new_session', True)
result = await run_command(cmd, stdout_callback=_callback, **kwargs)
if result.stderr:
for line in result.stderr.decode(errors='replace').splitlines()[-JOB_LOG_LINES:]:
if line.strip():
self.log(line)
self.flush()
if result.cancelled:
# `run_command` swallows the cancellation and returns; a handler checking return codes
# must not mistake a killed command for a normal exit.
raise asyncio.CancelledError(f'Command was cancelled: {cmd[0]}')
return result
_current_job: contextvars.ContextVar[Optional[JobContext]] = contextvars.ContextVar('current_job', default=None)
def get_current_job() -> Optional[JobContext]:
"""The Job whose handler is running in this task, or None outside of a Job."""
return _current_job.get()
class JobLogHandler(logging.Handler):
"""Copies log records emitted inside a running Job into that Job's log.
The context variable is set on the task that runs the handler, so only records from the Job
(and tasks it spawns) are captured, not those of the other coroutines in this process."""
def emit(self, record: logging.LogRecord):
job = _current_job.get()
if job is None:
return
try:
job.log(self.format(record))
except Exception:
self.handleError(record)
_log_handler = JobLogHandler()
_log_handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s:%(message)s'))
def _ensure_log_handler():
"""Sanic/pytest dictConfig replaces root handlers and would drop JobLogHandler.
Attach to this module's logger (not only root) so capture survives that reset.
"""
for target in (logging.getLogger(), logger):
if not any(isinstance(i, JobLogHandler) for i in target.handlers):
target.addHandler(_log_handler)
_ensure_log_handler()
def _json_safe(value):
try:
json.dumps(value)
return value
except (TypeError, ValueError):
return str(value)
async def _run_job(job_id: str):
"""Run one Job to completion (or failure/cancellation) in this process."""
_ensure_log_handler()
record = _claim_job(job_id)
if not record:
# Cancelled while it was waiting in the queue.
return
name = record['name']
context = JobContext(job_id, name)
handler = JOB_HANDLERS.get(name)
if handler is None:
_update_job(job_id, status=FAILED, error=f'No handler is registered for {name!r}',
finished_at=now().isoformat())
return
token = _current_job.set(context)
try:
async def call():
result = handler(**record['kwargs'])
if inspect.isawaitable(result):
result = await result
return result
# The task copies the current context, so the handler (and any tasks it creates) see this Job.
task = asyncio.create_task(call())
while not task.done():
await asyncio.wait({task}, timeout=JOB_CANCEL_POLL_SECONDS)
if not task.done() and _cancel_requested(job_id):
task.cancel()
try:
await task
except BaseException: # noqa Includes CancelledError; the Job is what was cancelled.
pass
context.log('Cancelled')
context.flush()
_update_job(job_id, status=CANCELLED, finished_at=now().isoformat())
logger.warning(f'Job {job_id} was cancelled')
Events.send_user_notify(f'Cancelled: {record["description"]}')
return
try:
result = task.result()
except asyncio.CancelledError:
# The worker itself is shutting down; leave the record honest.
_update_job(job_id, status=CANCELLED, finished_at=now().isoformat())
raise
except Exception as e:
logger.error(f'Job {job_id} failed', exc_info=e)
# Do not rely on JobLogHandler for this line: Sanic/pytest dictConfig can
# strip root handlers (especially under xdist on CI), so the traceback
# never reaches the Job log even though `error` is set.
context.log(f'{type(e).__name__}: {e}')
context.flush()
_update_job(job_id, status=FAILED, error=str(e) or repr(e),
log=list(context._log), finished_at=now().isoformat())
Events.send_user_notify(f'Failed: {record["description"]}')
return
context.flush()
_update_job(job_id, status=COMPLETE, result=_json_safe(result), progress=100.0,
finished_at=now().isoformat())
logger.info(f'Job {job_id} completed')
Events.send_user_notify(f'Completed: {record["description"]}')
finally:
context.flush()
_current_job.reset(token)
async def process_job_queue() -> int:
"""Run every queued Job, in order. Returns how many were run."""
count = 0
while True:
try:
job_id = _queue().get_nowait()
except queue.Empty:
return count
count += 1
await _run_job(job_id)
@perpetual_signal(sleep=0.5)
async def job_worker():
"""The single consumer of the Job queue; runs in the perpetual process only."""
try:
job_id = _queue().get_nowait()
except queue.Empty:
return
await _run_job(job_id)
async def wait_for_job(job_id: str, timeout: float = 300) -> dict:
"""Wait for a Job to finish; returns its record.
In tests (no perpetual loop) the queue is drained by this waiter so the Job actually runs.
@raise TimeoutError: when the Job has not finished in time.
"""
start = time.time()
while time.time() - start < timeout:
record = get_job(job_id)
if record['status'] in FINISHED_STATUSES:
return record
if PYTEST:
await process_job_queue()
await asyncio.sleep(0.01 if PYTEST else 0.5)
raise TimeoutError(f'Job {job_id} did not finish in time')
jobs_bp = Blueprint('Jobs', url_prefix='/api/jobs')
@jobs_bp.get('/')
@openapi.description('List all Jobs, oldest first. Jobs are not saved; an API restart forgets them.')
async def get_jobs_request(_: Request):
return json_response({'jobs': get_jobs()})
@jobs_bp.get('/<job_id:str>')
@openapi.description('Get one Job, including its log tail.')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_job_request(_: Request, job_id: str):
return json_response({'job': get_job(job_id)})
@jobs_bp.delete('/<job_id:str>')
@openapi.description('Cancel a pending or running Job. Allowed in WROL Mode: it only stops work.')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def cancel_job_request(_: Request, job_id: str):
return json_response({'job': cancel_job(job_id)})