Files
siftlode/backend/app/routes/scheduler.py
T
peter 5229fd96be refactor(catalog): the HTTP layer reads through queries and writes through the service
Channels, search and sync do no database work in their routes any more: reads go through
catalog/queries, writes through catalog/service, and a live search's ingest is catalog/search.
What a catalog route needs from Reader (a playlist's channels, a user's tag links, the feed
projection of results) it asks reader/queries and reader/service. The scheduler and the runs page
read channel titles and the work queue through catalog/queries. Catalog is fenced below its HTTP
layer: its owner filters go through userscope, it reads no other unit's tables, and its ingest
jobs commit only where they are named.
2026-10-19 02:32:20 +02:00

154 lines
5.3 KiB
Python

"""Admin Scheduler dashboard: a live view of what the background scheduler is doing —
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
and the shared quota picture."""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app import audit, quota, state, sysconfig
from app.audit import AuditAction
from app.auth import admin_user
from app.config import settings
from app.db import get_db
from app.models import User
from app.catalog import queries as catalog_queries
from app.scheduler import (
JOB_INTERVALS,
MAX_INTERVAL,
MIN_INTERVAL,
reschedule,
scheduler_snapshot,
stage_interval,
trigger_all,
trigger_job,
)
from app.sync.runner import estimate_deep_backfill
from app.routes._common import not_found
from app.refusal import refused
from app.refusal import setting_refused
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
class JobIntervalIn(BaseModel):
interval_minutes: int
@router.patch("/jobs/{job_id}")
def set_job_interval(
job_id: str,
body: JobIntervalIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Change how often a scheduler job runs (minutes). Stored as an override together with its
audit row, then applied to the live scheduler."""
if job_id not in JOB_INTERVALS:
raise not_found("job")
minutes = body.interval_minutes
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
applied = stage_interval(db, job_id, minutes)
audit.record(
db,
admin.id,
AuditAction.SCHEDULER_INTERVAL,
target_type="job",
target_id=job_id,
summary=f"{job_id} → {applied} min/run",
after={"interval_minutes": applied},
)
db.commit()
reschedule(job_id, applied)
return {"ok": True, "id": job_id, "interval_minutes": applied}
@router.post("/jobs/{job_id}/run")
def run_job_now(
job_id: str,
user: User = Depends(admin_user),
) -> dict:
"""Trigger a job to run immediately (in a background thread), independent of its
interval. 409 if it's already running, 404 for an unknown job. On completion the
triggering admin gets an inbox notification with the result."""
try:
result = trigger_job(job_id, actor_id=user.id)
except KeyError:
raise not_found("job") from None
if result == "already_running":
raise refused(409, "job_running")
return {"ok": True, "id": job_id, "started": True}
@router.post("/run-all")
def run_all_now(user: User = Depends(admin_user)) -> dict:
"""Trigger every job that isn't already running. Returns the ids actually started."""
return {"ok": True, "started": trigger_all(actor_id=user.id)}
class MaintenanceSettingsIn(BaseModel):
revalidate_batch: int
@router.patch("/maintenance")
def set_maintenance_settings(
body: MaintenanceSettingsIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
run). Persisted via sysconfig; the job reads it each run."""
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
try:
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
except sysconfig.InvalidSetting as exc:
raise setting_refused(exc) from None
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
audit.record(
db,
admin.id,
AuditAction.MAINTENANCE_TUNE,
summary=f"revalidate batch = {new}",
after={"revalidate_batch": new},
)
db.commit()
return {"ok": True, "revalidate_batch": new}
@router.get("")
def get_scheduler(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
snap = scheduler_snapshot(db)
# Work still queued (shared catalog, so these are instance-wide, not per-user).
deep_eta = estimate_deep_backfill(db, catalog_queries.deep_backlog(db))
queue = catalog_queries.work_queue(db)
# The whole opted-in BACKLOG, deliberately not `run_deep_backfill`'s next-pass selection
# (which also wants `recent_synced_at`): the same figure /api/sync/my-status shows the user,
# and a channel missing its recent pull is one the same job tick fetches first, not one the
# job will never touch. Narrowing it here would under-report work that is genuinely queued.
queue["channels_deep_pending"] = deep_eta["channels_pending"]
queue["deep_eta_seconds"] = deep_eta["eta_seconds"]
used = quota.units_used_today(db)
quota_info = {
"used_today": used,
"remaining_today": quota.remaining_today(db),
"daily_budget": sysconfig.get_int(db, "quota_daily_budget"),
"backfill_reserve": sysconfig.get_int(db, "backfill_quota_reserve"),
}
maint = sysconfig.spec("maintenance_revalidate_batch")
return {
**snap,
"paused": state.is_sync_paused(db),
"queue": queue,
"quota": quota_info,
"maintenance": {
"revalidate_batch": sysconfig.get_int(db, "maintenance_revalidate_batch"),
"revalidate_batch_default": settings.maintenance_revalidate_batch,
"min": maint.min,
"max": maint.max,
},
}