Files
siftlode/backend/app/scheduler.py
T
peter fad656988a fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone
Backend
- A run's outcome write sits outside the job's own try: a database blip after a run that
  succeeded no longer records it as failed (the row waits for the next start's sweep).
- An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does).
- The Scheduler's ad-hoc row says running while any backfill runs, not only the newest.
- recent_runs reads each job's last runs off its index instead of ranking the whole table.
- An interval change is stored with its audit row in one transaction, then rescheduled.
- One API instance per database is the stated contract (.env.example, jobruns).
- Turning full history on for a channel already complete records no run.
- Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are
  test-held to the enums; the account filter is one helper for the four activity reads.
- An audit row says how many of its request's log lines are still kept.

Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
  Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
  opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
  frame with the other tabs; an empty list is announced; each day's quota is readable.
2026-10-18 15:38:32 +02:00

649 lines
26 KiB
Python

"""Background scheduler: free RSS detection, enrichment of new videos, and quota-aware
recent-first / deep backfill. Runs inside the API process (single worker)."""
import contextvars
import threading
from datetime import 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, jobruns, logs, logsink, progress, quota, sysconfig
from app.config import settings
from app.db import SessionLocal
from app.downloads.gc import run_download_gc
from app.models import Channel
from app.plex.sync import sync as run_plex_sync
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
from app.sync.maintenance import run_maintenance
from app.sync.playlists import sync_all_playlists
from app.utils import iso
from app.sync.runner import (
run_deep_backfill,
run_enrich,
run_recent_backfill,
run_rss_poll,
run_shorts,
run_subscription_resync,
)
log = logs.get_logger(__name__)
_scheduler: BackgroundScheduler | None = None
# 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()
# Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that
# run — and only that run, not the recurring scheduled ones — posts a completion notification
# to their inbox. Unset (None) for scheduled runs.
_manual_actor: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"manual_actor", default=None
)
# Each interval job's env/config default period (minutes). The admin can override any of these at
# runtime from the Scheduler page — a sysconfig key per job (`sysconfig.interval_key`), applied
# live; see load_intervals.
JOB_INTERVALS: dict[str, int] = {
job: getattr(settings, attr) for job, attr in sysconfig.JOB_INTERVAL_DEFAULTS.items()
}
MIN_INTERVAL = sysconfig.MIN_INTERVAL
MAX_INTERVAL = sysconfig.MAX_INTERVAL
def load_intervals(db) -> dict[str, int]:
"""Effective per-job intervals: each job's sysconfig override, else its env/config default."""
return {job: sysconfig.get_int(db, sysconfig.interval_key(job)) for job in JOB_INTERVALS}
def stage_interval(db, job_id: str, minutes: int) -> int:
"""Stage a job's interval override (clamped) in `db`; returns the value. The caller commits it
with its audit row, then calls `reschedule` — the live job follows only what was stored."""
if job_id not in JOB_INTERVALS:
raise KeyError(job_id)
minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes)))
sysconfig.set_value(db, sysconfig.interval_key(job_id), minutes)
return minutes
def reschedule(job_id: str, minutes: int) -> None:
"""Move the live interval job to `minutes` (if this process registered it)."""
if _scheduler is not None and _scheduler.get_job(job_id) is not None:
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
log.info("rescheduled job %s to every %s min", job_id, minutes)
def _record(name: str, **fields) -> None:
with _activity_lock:
_activity.setdefault(name, {}).update(fields)
def _notify_done(
db,
actor_id: int,
job_id: str,
status: RunStatus,
summary: str | None,
result: dict | None = None,
) -> None:
"""Post a completion notice to the triggering admin's inbox (manual runs only). Best
effort — a notification failure must never mask the job's own outcome."""
try:
create_notification(
db,
user_id=actor_id,
type="scheduler",
title=f"{job_id}: {status}", # English fallback; UI renders translated from data
body=summary,
data={"job_id": job_id, "status": status, "summary": summary, "result": result},
)
except Exception:
db.rollback()
log.exception("failed to write completion notification 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
# single-run guard — trigger_job's pre-check is only a fast best-effort answer for the UI.
if not _claim_running(name):
log.info("job %s already running — skipping this trigger", name)
return
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
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})
# Only the work is inside the try: writing its outcome is not the job, and a write that fails
# must not turn a run that succeeded into a failed one (see _close).
result = None
try:
paused = is_sync_paused(db)
if not paused:
with progress.bind(sink):
result = fn(db)
except Exception as exc:
db.rollback()
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
err, failure = _failure(exc)
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
if actor_id is not None:
_notify_done(db, actor_id, name, RunStatus.ERROR, err, failure)
return
if paused:
log.info("job %s skipped (sync paused)", name)
_close(run_id, RunStatus.SKIPPED, summary="sync paused", result=_PAUSED)
if actor_id is not None:
_notify_done(db, actor_id, name, RunStatus.SKIPPED, "sync paused", _PAUSED)
return
summary, data = _finish_ok(name, run_id, result)
if actor_id is not None:
_notify_done(db, actor_id, name, RunStatus.OK, summary, data)
def _close(run_id: int, status: RunStatus, **outcome) -> None:
"""Write a run's outcome. Its own failure (the database) is the log's to tell, never the job's:
the row then stays `running` until the next start's sweep closes it as interrupted."""
try:
jobruns.finish(run_id, status, **outcome)
except Exception:
log.exception("run %s: its outcome (%s) could not be recorded", run_id, status)
def _finish_ok(name: str, run_id: int, result) -> tuple[str | None, dict | None]:
"""Close a run that returned: its result on the row, as data and as the English one-liner."""
summary = _summarize(result)
data = _result_data(result)
log.info("job %s -> %s", name, result)
_close(run_id, RunStatus.OK, summary=summary, result=data)
return summary, data
def _failure(exc: Exception) -> tuple[str, dict | None]:
"""A raised run's English error line, and the failure the job named (progress.JobFailed) as
data, as a result is, so the client says it in the reader's language."""
err = str(exc) or exc.__class__.__name__
failure = {"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
return err, failure
# A skipped run's result, in the same shape as a job's own `{"skipped": …}`.
_PAUSED = {"skipped": "sync paused"}
def _result_data(result) -> dict | None:
"""A job's result as the client names it in the reader's language: the dict itself (counts,
a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line
`_summarize` is the English fallback."""
if isinstance(result, dict):
return result or None
if isinstance(result, int) and not isinstance(result, bool):
return {"enriched": result}
return None
def _summarize(result) -> str | None:
"""Compact one-line rendering of a job's return value for the dashboard."""
if result is None:
return None
if isinstance(result, dict):
return ", ".join(f"{k}={v}" for k, v in result.items()) or None
return str(result)
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 runs from `job_runs`, so a restart no longer blanks "when did it last run, and how
did it go"; and the same for the ad-hoc runs users start on one target. `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:
for job in _scheduler.get_jobs():
nr = getattr(job, "next_run_time", None)
next_runs[job.id] = nr.astimezone(timezone.utc).isoformat() if nr else None
iv = getattr(job.trigger, "interval", None)
if iv is not None:
live_intervals[job.id] = round(iv.total_seconds() / 60)
with _activity_lock:
acts = {k: dict(v) for k, v in _activity.items()}
recent = jobruns.recent_runs(db, [*JOB_INTERVALS, *AD_HOC_JOBS], RECENT_RUNS)
jobs = []
for job_id, default_interval in JOB_INTERVALS.items():
a = acts.get(job_id, {})
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),
"progress": a.get("progress"),
**_last_runs(recent.get(job_id, [])),
}
)
# Work a user starts on one target: no interval, no "run now" (it needs its target), but its
# runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the
# target's own (sync/runner), so "running" is what its rows say: two channels can backfill at
# once, so a run still going counts even when a newer one already ended, and the row names it.
backfills = recent.get(CHANNEL_BACKFILL, [])
going = jobruns.running_one(db, CHANNEL_BACKFILL)
shown = going or (backfills[0] if backfills else None)
ad_hoc = [
{
"id": CHANNEL_BACKFILL,
"running": going is not None,
"target": shown.target if shown else None,
"target_title": (
db.scalar(select(Channel.title).where(Channel.id == shown.target))
if shown is not None and shown.target
else None
),
**_last_runs(backfills),
}
]
return {
"running_here": running_here,
"enabled": settings.scheduler_enabled,
"jobs": jobs,
"ad_hoc": ad_hoc,
}
# How many past runs a Scheduler row shows as dots; the full history is the run list.
RECENT_RUNS = 3
def _last_runs(runs: list) -> dict:
"""A row's run fields from its newest runs (newest first): how the last one went, and the few
before it for the history dots."""
run = runs[0] if runs else None
# 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
return {
"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,
"recent": [_run_brief(r) for r in runs],
}
def _run_brief(run) -> dict:
return {
"id": run.id,
"status": run.status,
"trigger": run.trigger,
"started_at": iso(run.started_at),
"finished_at": iso(run.finished_at),
}
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)
def _enrich_job() -> None:
def work(db):
with quota.attribute(None, quota.QuotaAction.VIDEOS_ENRICH):
return run_enrich(db)
_job("enrich", work)
def _backfill_job() -> None:
# Recent-first for not-yet-synced channels, then deep backfill for the rest. All
# background spend is attributed to the system (no actor), split by action.
def work(db):
with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
recent = run_recent_backfill(db, max_channels=25)
with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
deep = run_deep_backfill(db, max_channels=10)
return {"recent": recent, "deep": deep}
_job("backfill", work)
def _autotag_job() -> None:
_job("autotag", lambda db: run_autotag_all(db, only_missing=True))
def _shorts_job() -> None:
_job("shorts", run_shorts)
def _subscriptions_job() -> None:
def work(db):
with quota.attribute(None, quota.QuotaAction.SUBSCRIPTIONS_RESYNC):
return run_subscription_resync(db)
_job("subscriptions", work)
def _playlist_sync_job() -> None:
# Mirror each read-scope user's YouTube playlists; per-user quota attribution is
# handled inside sync_all_playlists.
_job("playlist_sync", sync_all_playlists)
def _maintenance_job() -> None:
def work(db):
with quota.attribute(None, quota.QuotaAction.MAINTENANCE_REVALIDATE):
return run_maintenance(db)
_job("maintenance", work)
def _explore_cleanup_job() -> None:
# Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after
# their grace periods. 0 quota (pure DB), so no quota attribution.
_job("explore_cleanup", purge_ephemeral)
def _download_gc_job() -> None:
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction — and
# the week-ahead warning before an API token runs out (`apitokens.warn_expiring`).
# Pure disk/DB work (no YouTube quota).
_job("download_gc", run_download_gc)
def _plex_sync_job() -> None:
# Mirror the enabled Plex library sections into the local catalog. Pure metadata (no YouTube
# quota); a no-op when the Plex module is disabled.
_job("plex_sync", run_plex_sync)
def _plex_watch_sync_job() -> None:
# Incremental Plex→Siftlode watch-state pull (history + on-deck) + re-push of unsynced local
# states. Cheap; a no-op when Plex is disabled or no owner link has sync enabled.
_job("plex_watch_sync", run_plex_watch_sync)
def _plex_watch_reconcile_job() -> None:
# Full watch-state reconcile (whole-section rescan) — catches un-watches the incremental feed
# can't. Heavier, so it runs far less often (daily default).
_job("plex_watch_reconcile", run_plex_watch_reconcile)
def _tmdb_enrich_job() -> None:
# Enrich a batch of mirrored Plex titles from TMDB (keywords/crew/budget/revenue/TMDb score/
# recommendations/backdrops), matched by guid. External metadata (no YouTube quota); a no-op when
# the TMDB key is unset or the Plex catalog is empty.
_job("tmdb_enrich", run_tmdb_enrich)
def _people_enrich_job() -> None:
# Make the people local: a batch of person bios from TMDB and a batch of portraits resized
# through Plex, so a person page and a cast strip never wait on an outside service.
_job("people_enrich", run_people_enrich)
def _audit_gc_job() -> None:
# 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):
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, "runs_reaped": runs_reaped}
_job("audit_gc", work)
def _log_gc_job() -> None:
# Prune log_events rows older than log_retention_days — the limit the privacy policy states, so
# this runs hourly rather than daily. Pure DB, no quota.
def work(db):
days = sysconfig.get_int(db, "log_retention_days")
reaped = logsink.gc(db, days)
# Debug sessions go with the rows they bundle; an open one past its cap is closed.
closed = debugsession.sweep(db)
sessions = debugsession.gc(db, days)
db.commit()
return {"reaped": reaped, "sessions_closed": closed, "sessions_reaped": sessions}
_job("log_gc", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = {
"rss_poll": _rss_job,
"enrich": _enrich_job,
"backfill": _backfill_job,
"autotag": _autotag_job,
"shorts": _shorts_job,
"subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job,
"explore_cleanup": _explore_cleanup_job,
"download_gc": _download_gc_job,
"plex_sync": _plex_sync_job,
"plex_watch_sync": _plex_watch_sync_job,
"plex_watch_reconcile": _plex_watch_reconcile_job,
"tmdb_enrich": _tmdb_enrich_job,
"people_enrich": _people_enrich_job,
"audit_gc": _audit_gc_job,
"log_gc": _log_gc_job,
}
def _is_running(job_id: str) -> bool:
with _activity_lock:
return bool(_activity.get(job_id, {}).get("running"))
def _claim_running(job_id: str) -> bool:
"""Atomically claim a job as running: True if it was idle (and marks it running now), False if
it was already running. Holding the lock across BOTH the check and the set is what makes the
single-run guard race-free — called once at the top of _job so a manual trigger and a scheduled
fire (or two rapid triggers) converge here and exactly one wins. Released in _job's finally."""
with _activity_lock:
if _activity.get(job_id, {}).get("running"):
return False
_activity.setdefault(job_id, {})["running"] = True
return True
def running_job_ids() -> set[str]:
"""Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the
header's sync indicator — reflect real activity instead of just pending-work counts."""
with _activity_lock:
return {jid for jid, a in _activity.items() if a.get("running")}
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."""
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)
# Best-effort fast answer for the UI; the authoritative single-run guard is _job's atomic claim
# (so a manual trigger and a scheduled fire can't both run). A race here just means the loser's
# _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")
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"
def trigger_all(actor_id: int | None = None) -> list[str]:
"""Trigger every job that isn't already running; returns the ids actually started."""
started = []
for job_id in JOB_FUNCS:
if trigger_job(job_id, actor_id) == "started":
started.append(job_id)
return started
# Work a user starts on one target outside the job list — today the "get full history" backfill of
# one channel. It runs on the same scheduler as a recorded run (trigger `ad_hoc`), so what it did and
# spent is in `job_runs` like any job's, not a background task only the log ever heard of.
CHANNEL_BACKFILL = "channel_backfill"
# Every ad-hoc job id: the page names each one (a frontend contract test reads this tuple).
AD_HOC_JOBS = (CHANNEL_BACKFILL,)
def run_ad_hoc(job: str, fn: Callable[[], dict], *, actor_id: int, target: str) -> None:
"""Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its
own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so
a second request before the first has even started is that same run."""
if job not in AD_HOC_JOBS:
raise KeyError(job) # an unnamed job would show on the page as its bare id
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")
try:
sched.add_job(
_ad_hoc,
"date",
args=(job, fn, actor_id, target),
id=f"{job}@{target}",
misfire_grace_time=None,
)
# silent-ok: not a failure — the run for this target is already queued, and it does this work.
except ConflictingIdError:
log.info("ad-hoc %s for %s already queued", job, target)
def _ad_hoc(job: str, fn: Callable[[], dict], actor_id: int, target: str) -> None:
try:
run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target)
except Exception:
# No row to tell it in: the database is what failed. The log is the record.
log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target)
return
with logs.bind(run_id=run_id):
# As in _run: only the work is inside the try.
try:
result = fn()
except Exception as exc:
log.exception("job %s failed", job, extra=logs.event("scheduler.job.failed", job=job))
err, failure = _failure(exc)
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
return
_finish_ok(job, run_id, result)
# 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 — one API process serves a
database (app/jobruns.py), so an open run here is a dead process's."""
global _scheduler
if _scheduler is not None:
return
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 (interval jobs %s)", "on" if settings.scheduler_enabled else "off")
def shutdown_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None