Files
siftlode/backend/app/routes/scheduler.py
T
peter 45c0ee7d58 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
2026-10-17 23:08:55 +02:00

197 lines
6.8 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 import and_, func, or_, select
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 Channel, Subscription, User, Video
from app.scheduler import (
JOB_INTERVALS,
MAX_INTERVAL,
MIN_INTERVAL,
apply_interval,
scheduler_snapshot,
trigger_all,
trigger_job,
)
from app.sync.explore import followed_channel
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). Persisted as an override and applied
to the live scheduler immediately."""
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 = apply_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()
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_requested = (
select(Subscription.id)
.where(
Subscription.channel_id == Channel.id,
Subscription.deep_requested.is_(True),
)
.exists()
)
deep_channels = (
db.execute(select(Channel).where(Channel.backfill_done.is_(False), deep_requested))
.scalars()
.all()
)
deep_eta = estimate_deep_backfill(db, deep_channels)
queue = {
"channels_recent_pending": db.scalar(
# The same selection run_recent_backfill works through, or the card counts channels
# the job will never touch and never reaches zero.
select(func.count())
.select_from(Channel)
.where(Channel.recent_synced_at.is_(None), followed_channel())
)
or 0,
# 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.
"channels_deep_pending": deep_eta["channels_pending"],
"deep_eta_seconds": deep_eta["eta_seconds"],
"videos_pending_enrich": db.scalar(
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
)
or 0,
"videos_pending_shorts": db.scalar(
select(func.count())
.select_from(Video)
.where(Video.shorts_probed.is_(False), Video.enriched_at.is_not(None))
)
or 0,
"videos_live_refresh": db.scalar(
select(func.count())
.select_from(Video)
.where(
or_(
Video.live_status.in_(("live", "upcoming")),
and_(Video.live_status == "was_live", Video.duration_seconds.is_(None)),
)
)
)
or 0,
}
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,
},
}