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.
154 lines
5.3 KiB
Python
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,
|
|
},
|
|
}
|