Two reviewers in a row read the admin card's selection against `run_deep_backfill`'s, saw it was missing the job's `recent_synced_at` filter, and called it a card that counts channels the job will never touch. It is the opposite: the card is the same backlog figure /api/sync/my-status shows the user, and a channel still missing its recent pull is one the same job tick fetches first. Narrowing it would hide genuinely queued work. Written down where the next reader will look.
198 lines
6.8 KiB
Python
198 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, 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.explore import followed_channel
|
|
from app.sync.runner import estimate_deep_backfill
|
|
from app.routes._common import not_found
|
|
|
|
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 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 not_found("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(
|
|
# 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,
|
|
},
|
|
}
|