feat(scheduler): every background run is a durable row

What a job last did lived in the API process's memory and was gone at
every restart; the audit log kept a run only when it was manual, failed
or changed something. Now each run is a job_runs row written at its
start and its end in its own session: who started it, its outcome as
data, what it spent of the YouTube quota (quota_events.job_run_id) and
the lines it logged (log_events.run_id, filterable in the log console).

- a run a stopped process left open is marked interrupted at startup
- "run now" is a one-shot job on the scheduler, which now always starts
  (interval jobs only with SCHEDULER_ENABLED); the manual actor is reset
  after the run, since pool threads are reused
- the dashboard's last run comes from job_runs, so it survives a deploy
- runs leave the audit log: migration 0109 moves the old job_run rows
  over (and back on downgrade); the audit page's run folding goes
- job_run_retention_days (default 30) prunes old runs in audit_gc
This commit is contained in:
2026-10-17 23:08:55 +02:00
parent 4b1f6d7ec2
commit 45c0ee7d58
31 changed files with 870 additions and 489 deletions
+126 -124
View File
@@ -6,11 +6,12 @@ import threading
from datetime import datetime, timezone
from typing import Callable
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.jobstores.base import ConflictingIdError
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from app import audit, debugsession, logs, logsink, progress, quota
from app.audit import AuditAction
from app import audit, debugsession, jobruns, logs, logsink, progress, quota
from app.config import settings
from app.db import SessionLocal
from app.downloads.gc import run_download_gc
@@ -20,6 +21,7 @@ from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.tmdb import run_people_enrich
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
from app.notifications import create_notification
from app.runstatus import RunStatus, RunTrigger
from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all
from app.sync.explore import purge_ephemeral
@@ -37,10 +39,9 @@ from app.sync.runner import (
log = logs.get_logger(__name__)
_scheduler: BackgroundScheduler | None = None
# Per-job run activity, kept in-memory for the admin Scheduler dashboard to read (the
# scheduler lives in the same single-worker process as the API, so a request can read it
# directly). Ephemeral by design — it reflects "what the scheduler is doing right now and
# how the last runs went" since this process started; durable history isn't the goal here.
# What is running RIGHT NOW, per job: the single-run claim and the live progress. In memory on
# purpose — the scheduler lives in the API's one process, and progress changes every second. What a
# run did is durable instead: its `job_runs` row (app/jobruns.py), which outlives a restart.
_activity: dict[str, dict] = {}
_activity_lock = threading.Lock()
@@ -130,49 +131,6 @@ def _notify_done(
log.exception("failed to write completion notification for job %s", job_id)
def _run_changed(result) -> bool:
"""Did an automatic job run actually do something worth an audit row? (No-op polls don't.)
A "change" = a truthy NUMERIC count; status-string dicts like {"skipped": "disabled"} (Plex
off — the default) are no-ops, not changes, so they don't flood the trail every interval."""
if result is None:
return False
if isinstance(result, dict):
# A phased job (backfill's recent/deep, people_enrich's bios/portraits) nests a result per
# phase: a count inside a phase is a change too.
return any(
_run_changed(v) if isinstance(v, dict) else isinstance(v, (int, float)) and bool(v)
for v in result.values()
)
if isinstance(result, (int, float)):
return result != 0
return bool(result)
def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
"""Write ONE run-summary audit row per job run (never per-item). Manual runs (an admin
clicked "run now" → actor_id set) and errors are always logged; automatic runs only when
they changed something, so routine no-op polls don't spam the trail. Best-effort."""
if actor_id is None and status == "ok" and not _run_changed(result):
return
# The result as data too: the audit log names it in the reader's language, as the inbox
# does; `summary` stays the English line of the row's own summary column.
data = _result_data(result)
try:
audit.record(
db,
actor_id,
AuditAction.JOB_RUN,
target_type="job",
target_id=job_id,
summary=f"{job_id}: {status}" + (f" — {summary}" if summary else ""),
after={"status": status, "summary": summary, **({"result": data} if data else {})},
)
db.commit()
except Exception:
db.rollback()
log.exception("failed to write audit row for job %s", job_id)
def _job(name: str, fn) -> None:
# The one place both run paths converge (manual trigger AND scheduled fire), so claim the run
# HERE, atomically: whichever reaches this first wins, the other skips. This is the real
@@ -180,11 +138,29 @@ def _job(name: str, fn) -> None:
if not _claim_running(name):
log.info("job %s already running — skipping this trigger", name)
return
db = SessionLocal()
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
_record(
name, last_started=datetime.now(timezone.utc).isoformat(), last_error=None, progress=None
) # running already claimed
trigger = RunTrigger.SCHEDULED if actor_id is None else RunTrigger.MANUAL
_record(name, progress=None) # running already claimed
db = SessionLocal()
try:
try:
run_id = jobruns.start(name, trigger, actor_id)
except Exception:
# No row to tell it in: the database is what failed. The log is the record.
log.exception("job %s not started: its run could not be recorded", name)
return
with logs.bind(run_id=run_id):
_run(db, name, fn, run_id, actor_id)
finally:
# ALWAYS release the claim, even if the body raised before its own status update — so a
# failure can never wedge the job as permanently "running".
_record(name, running=False, progress=None)
db.close()
def _run(db, name: str, fn, run_id: int, actor_id: int | None) -> None:
"""The run itself, inside its `job_runs` row: the pause-skip, the work, and its outcome — on
the row, and in the triggering admin's inbox for a manual run."""
def sink(current, total, phase):
_record(name, progress={"current": current, "total": total, "phase": phase})
@@ -192,34 +168,18 @@ def _job(name: str, fn) -> None:
try:
if is_sync_paused(db):
log.info("job %s skipped (sync paused)", name)
_record(
name,
status="skipped",
last_finished=datetime.now(timezone.utc).isoformat(),
last_result="paused",
result=_PAUSED,
progress=None,
)
jobruns.finish(run_id, RunStatus.SKIPPED, summary="sync paused", result=_PAUSED)
if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED)
_audit_run(db, actor_id, name, "skipped", "sync paused", _PAUSED)
return
with progress.bind(sink):
result = fn(db)
summary = _summarize(result)
data = _result_data(result)
log.info("job %s -> %s", name, result)
_record(
name,
status="ok",
last_finished=datetime.now(timezone.utc).isoformat(),
last_result=summary,
result=data,
progress=None,
)
jobruns.finish(run_id, RunStatus.OK, summary=summary, result=data)
if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary, data)
_audit_run(db, actor_id, name, "ok", summary, result)
except Exception as exc:
db.rollback()
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
@@ -229,22 +189,9 @@ def _job(name: str, fn) -> None:
failure = (
{"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
)
_record(
name,
status="error",
last_finished=datetime.now(timezone.utc).isoformat(),
last_error=err,
result=failure,
progress=None,
)
jobruns.finish(run_id, RunStatus.ERROR, error=err, failure=failure)
if actor_id is not None:
_notify_done(db, actor_id, name, "error", err, failure)
_audit_run(db, actor_id, name, "error", err, failure)
finally:
# ALWAYS release the claim, even if the body raised before its own status update — so a
# failure can never wedge the job as permanently "running".
_record(name, running=False)
db.close()
# A skipped run's result, in the same shape as a job's own `{"skipped": …}`.
@@ -271,11 +218,17 @@ def _summarize(result) -> str | None:
return str(result)
def scheduler_snapshot() -> dict:
"""What the scheduler is doing, for the admin dashboard. `running_here` is False in
environments where the scheduler is disabled (e.g. local dev shares the prod-ish DB but
runs no scheduler), so the UI can say so while still showing DB-derived queue/quota."""
running_here = _scheduler is not None
def _iso(ts: datetime | None) -> str | None:
return ts.astimezone(timezone.utc).isoformat() if ts else None
def scheduler_snapshot(db) -> dict:
"""What the scheduler is doing, for the admin dashboard: each job's live state from this process
and its last run from `job_runs`, so a restart no longer blanks "when did it last run, and how
did it go". `running_here` is False where the scheduler runs no interval jobs
(SCHEDULER_ENABLED=false), so the UI can say so while still showing the DB-derived history,
queue and quota."""
running_here = _scheduler is not None and settings.scheduler_enabled
next_runs: dict[str, str | None] = {}
live_intervals: dict[str, int] = {}
if _scheduler is not None:
@@ -287,27 +240,41 @@ def scheduler_snapshot() -> dict:
live_intervals[job.id] = round(iv.total_seconds() / 60)
with _activity_lock:
acts = {k: dict(v) for k, v in _activity.items()}
last = jobruns.last_runs(db)
jobs = []
for job_id, default_interval in JOB_INTERVALS.items():
a = acts.get(job_id, {})
run = last.get(job_id)
# A run still going has no outcome yet: only an ended run says how it went.
ended = run if run is not None and run.status != RunStatus.RUNNING else None
jobs.append(
{
"id": job_id,
"interval_minutes": live_intervals.get(job_id, default_interval),
"next_run": next_runs.get(job_id),
"running": a.get("running", False),
"status": a.get("status"),
"last_started": a.get("last_started"),
"last_finished": a.get("last_finished"),
"last_result": a.get("last_result"),
"result": a.get("result"),
"last_error": a.get("last_error"),
"status": ended.status if ended else None,
"last_started": _iso(run.started_at) if run else None,
"last_finished": _iso(run.finished_at) if run else None,
"last_result": ended.summary if ended else None,
# The outcome as data: the result, or on a failed run the failure the job named.
"result": _outcome_data(ended),
"last_error": ended.error if ended else None,
"last_trigger": run.trigger if run else None,
"last_run_id": run.id if run else None,
"quota_units": ended.quota_units if ended else None,
"progress": a.get("progress"),
}
)
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
def _outcome_data(run) -> dict | None:
if run is None:
return None
return run.failure if run.status == RunStatus.ERROR else run.result
def _rss_job() -> None:
_job("rss_poll", run_rss_poll)
@@ -408,15 +375,17 @@ def _people_enrich_job() -> None:
def _audit_gc_job() -> None:
# The history cleanup: audit-log rows past audit_retention_days and YouTube quota history past
# quota_retention_days (0 = keep forever, each). Pure DB, no quota.
# The history cleanup: audit-log rows past audit_retention_days, YouTube quota history past
# quota_retention_days and finished runs past job_run_retention_days (0 = keep forever, each).
# Pure DB, no quota.
def work(db):
from app import sysconfig
reaped = audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))
quota_reaped = quota.gc(db, sysconfig.get_int(db, "quota_retention_days"))
runs_reaped = jobruns.gc(db, sysconfig.get_int(db, "job_run_retention_days"))
db.commit()
return {"reaped": reaped, "quota_reaped": quota_reaped}
return {"reaped": reaped, "quota_reaped": quota_reaped, "runs_reaped": runs_reaped}
_job("audit_gc", work)
@@ -485,15 +454,29 @@ def running_job_ids() -> set[str]:
return {jid for jid, a in _activity.items() if a.get("running")}
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job immediately in a background thread, independent of its interval schedule.
The wrapper handles its own DB session, activity tracking and pause-skip, so the live
dashboard reflects the run. Returns "started", "already_running", or raises KeyError for
an unknown job. Refusing a concurrent run keeps a manual trigger from overlapping a
scheduled run (APScheduler enforces max_instances=1 for the scheduled side).
def _as_actor(fn: Callable[[], None], actor_id: int | None) -> Callable[[], None]:
"""`fn` run as `actor_id`'s manual run. The actor is RESET afterwards: the executor's pool
threads are reused and keep their context from task to task, so an actor left set would make
the next scheduled run that thread picks up look like this admin's manual run."""
`actor_id` (the admin who clicked "run now") is stashed in a contextvar inside the new
thread so the run posts a completion notification to that admin's inbox."""
def run() -> None:
token = _manual_actor.set(actor_id)
try:
fn()
finally:
_manual_actor.reset(token)
return run
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job now, independent of its interval schedule: a one-shot job on the same scheduler,
so a manual run shares the scheduled runs' executor and shutdown — no thread of its own. The
wrapper handles its own DB session, run row and pause-skip. Returns "started",
"already_running", or raises KeyError for an unknown job.
`actor_id` (the admin who clicked "run now") is set in a contextvar inside the run's thread, so
the run is recorded as theirs and posts a completion notification to their inbox."""
fn = JOB_FUNCS.get(job_id)
if fn is None:
raise KeyError(job_id)
@@ -502,13 +485,18 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
# _job claims, sees it's taken, and skips — no double run.
if _is_running(job_id):
return "already_running"
sched = _scheduler
if sched is None:
# Only between shutdown and exit: start_scheduler runs at every API startup.
raise RuntimeError("the scheduler is not running")
def run() -> None:
if actor_id is not None:
_manual_actor.set(actor_id)
fn()
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
try:
# One id per job: a second click before the first one-shot has even started is the same
# run, not a queued second one. misfire_grace_time=None: a busy pool delays it, never drops it.
sched.add_job(_as_actor(fn, actor_id), "date", id=f"{job_id}@now", misfire_grace_time=None)
# silent-ok: not a failure — the answer is the 409 the admin sees ("already running").
except ConflictingIdError:
return "already_running"
return "started"
@@ -521,22 +509,36 @@ def trigger_all(actor_id: int | None = None) -> list[str]:
return started
# Room for every job at once (a "run all") beside a few ad-hoc runs: APScheduler's default pool of
# 10 would queue a scheduled fire behind long manual runs.
_POOL_SIZE = len(JOB_FUNCS) + 8
def start_scheduler() -> None:
"""Start this process's scheduler. It always starts, so "run now" works on every install; only
with SCHEDULER_ENABLED does it also register the recurring interval jobs. Before anything can
run, every run a previous process left open is closed as interrupted."""
global _scheduler
if not settings.scheduler_enabled or _scheduler is not None:
if _scheduler is not None:
return
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
db = SessionLocal()
try:
iv = load_intervals(db)
finally:
db.close()
scheduler = BackgroundScheduler(timezone="UTC")
for job_id, fn in JOB_FUNCS.items():
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
left = jobruns.interrupt_leftovers()
if left:
log.warning("%s run(s) left running by the previous process marked interrupted", left)
scheduler = BackgroundScheduler(
timezone="UTC", executors={"default": ThreadPoolExecutor(_POOL_SIZE)}
)
if settings.scheduler_enabled:
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
db = SessionLocal()
try:
iv = load_intervals(db)
finally:
db.close()
for job_id, fn in JOB_FUNCS.items():
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
scheduler.start()
_scheduler = scheduler
log.info("scheduler started")
log.info("scheduler started (interval jobs %s)", "on" if settings.scheduler_enabled else "off")
def shutdown_scheduler() -> None: