Round-3 review of the S3 config-fold, all in routes/scheduler.py: - stale docstring: "Persisted to app_state" -> "Persisted via sysconfig" (the batch moved stores in B-core-11; the comment didn't follow). - drop the manual min/max check in the PATCH — sysconfig.set_value already validates against the same spec bounds; catch its ValueError -> 400, matching the config PATCH pattern (bounds enforced in one place). - fetch sysconfig.spec(...) once into a local in the GET instead of twice. No behaviour change beyond the 400 message wording (untested path). 368 pass.
180 lines
6.1 KiB
Python
180 lines
6.1 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, HTTPException
|
|
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.runner import estimate_deep_backfill
|
|
|
|
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 HTTPException(status_code=404, detail="Unknown job")
|
|
minutes = body.interval_minutes
|
|
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
|
)
|
|
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 {"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 HTTPException(status_code=404, detail="Unknown job")
|
|
if result == "already_running":
|
|
raise HTTPException(status_code=409, detail="Job is already running")
|
|
return {"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 {"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 (TypeError, ValueError) as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
|
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 {"revalidate_batch": new}
|
|
|
|
|
|
@router.get("")
|
|
def get_scheduler(
|
|
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
snap = scheduler_snapshot()
|
|
|
|
# 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(
|
|
select(func.count()).select_from(Channel).where(Channel.recent_synced_at.is_(None))
|
|
)
|
|
or 0,
|
|
"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,
|
|
},
|
|
}
|