Files
FabledGrimoire/src/scribe/services/db_maintenance_scheduler.py
T
bvandeusenandClaude Fable 5 7d48eb0b1b
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
refactor(services): one periodic-task shape, one APScheduler job shape, one token hash, one summary rule — the services pass of the shape audit (#2830, milestone 296)
- background.start_periodic(interval, work, label=) replaces the three hand-rolled
  while-True/sleep/try loops in logging, auth and notifications.
- services/scheduler.ScheduledJob replaces the four private BackgroundScheduler
  copies in recurrence/version_pinning/trash/db_maintenance schedulers; public
  start_/stop_/reschedule_ surfaces unchanged.
- api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x.
- auth.is_registration_open reads via settings.get_admin_setting; notification
  prefs read via settings.get_setting; _fire_share_email uses _get_user_email.
- projects.get_project_summary / milestones.get_project_milestone_summary are
  now the one-id view of their batch siblings instead of a second copy of the
  queries; sharing.best_permission_by (was _deduplicate_by_permission) is the
  one rank-dedup, now also used by list_projects_for_user.
- backup: the row builders for every section both exporters carry are named
  functions, so a column added to one export cannot silently miss the other.
- iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom
  and db_maintenance._iso across services; backup keeps its explicit shape.
- trash.py hoists the sql_delete/timedelta imports it re-imported per function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:34:28 -04:00

80 lines
2.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE).
One ScheduledJob (services/scheduler.py). Scheduled for 04:00 UTC by default
— after the 03:30 trash purge — so it collects the dead tuples that night's
delete sweeps leave behind.
Two things are operator-tunable from the admin Settings card:
- db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling
it needs no reschedule.
- db_maintenance_hour ("0".."23", UTC) — the cron hour. Changing it calls
reschedule_db_maintenance() so the live job moves without a restart.
"""
from __future__ import annotations
import asyncio
import logging
from apscheduler.triggers.cron import CronTrigger
from scribe.services.scheduler import ScheduledJob
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__)
_JOB_ID = "db_maintenance_vacuum"
_DEFAULT_HOUR = 4
async def get_maintenance_hour() -> int:
"""The configured run-hour (UTC, 0–23), clamped; default 04:00."""
raw = await get_admin_setting("db_maintenance_hour", str(_DEFAULT_HOUR))
try:
hour = int(raw)
except (TypeError, ValueError):
return _DEFAULT_HOUR
return hour if 0 <= hour <= 23 else _DEFAULT_HOUR
async def is_maintenance_enabled() -> bool:
"""Whether the scheduled run is enabled (default on)."""
return (await get_admin_setting("db_maintenance_enabled", "true")) != "false"
async def _run_maintenance() -> None:
if not await is_maintenance_enabled():
logger.debug("db maintenance: disabled, skipping scheduled run")
return
from scribe.services.db_maintenance import run_maintenance
await run_maintenance()
_JOB = ScheduledJob(_JOB_ID, _run_maintenance, label="DB maintenance")
def _trigger(hour: int) -> CronTrigger:
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
return CronTrigger(hour=hour, minute=0, timezone="UTC")
def start_db_maintenance_scheduler(
loop: asyncio.AbstractEventLoop, hour: int = _DEFAULT_HOUR
) -> None:
"""Start the daily job. `hour` is the configured UTC run-hour, resolved by
the caller (which has an async context) via get_maintenance_hour() — passed
in rather than read here so we never block the event loop at startup. The
job's enabled-gate is re-checked at every fire, so only the hour is needed
up front."""
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_JOB.start(loop, _trigger(hour), describe=f"daily {hour:02d}:00 UTC")
def reschedule_db_maintenance(hour: int) -> None:
"""Move the live job to a new UTC hour (called when the admin changes it)."""
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_JOB.reschedule(_trigger(hour), describe=f"{hour:02d}:00 UTC")
def stop_db_maintenance_scheduler() -> None:
_JOB.stop()