feat: deferred dispatch, release-check provider, settings polish

- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
This commit is contained in:
2026-05-12 02:58:07 +03:00
parent bb5afcc222
commit ba199f24bd
47 changed files with 5627 additions and 290 deletions
@@ -153,6 +153,16 @@ async def start_scheduler() -> None:
# Load scheduled backup job if enabled
await _load_backup_job()
# Re-arm any deferred-dispatch drains that were pending across restart.
from .deferred_dispatch import load_pending_drain_jobs
await load_pending_drain_jobs()
# And install the periodic safety-net catch-up scan.
_schedule_drain_catchup()
# Schedule the upstream release-check probe.
await _schedule_release_check()
def _schedule_event_cleanup() -> None:
"""Schedule a daily job to delete EventLog entries older than 90 days."""
@@ -1079,6 +1089,129 @@ async def unschedule_backup() -> None:
_LOGGER.info("Unscheduled backup job")
# ---------------------------------------------------------------------------
# Deferred-dispatch drain
# ---------------------------------------------------------------------------
#
# When ``defer_event`` enqueues a quiet-hours notification, the calling site
# asks us to add a one-shot ``date`` job at ``quiet_hours_end_at``. We key the
# job id by the minute-rounded end time so multiple defers that share the same
# window-end share a single drain job (idempotent via ``replace_existing``).
#
# At fire time the job runs ``drain_deferred_due`` which scans all pending
# rows and dispatches whatever is ready.
#
# A periodic catch-up scan runs every ``_DRAIN_CATCHUP_INTERVAL_SECONDS`` as
# the safety net for failure modes the one-shot job can't cover:
# * APScheduler's misfire grace exceeded (event loop blocked past fire_at;
# the date job is silently discarded by the scheduler)
# * Process killed between the deferred-row DB commit and the
# ``schedule_deferred_drain`` call — row exists, job doesn't
# * Clock drift / DST seam edge cases
_DEFERRED_DRAIN_PREFIX = "deferred_drain_"
_DEFERRED_DRAIN_CATCHUP_JOB = "deferred_drain_catchup"
# Generous so a temporarily-blocked event loop doesn't make the scheduler
# discard our drain job. Once discarded the deferred rows would wait for the
# next process restart or the catch-up scan below — survivable but visibly
# late from the user's perspective.
_DEFERRED_DRAIN_MISFIRE_GRACE_SECONDS = 3600
# 5 min trade-off between "promptness of late delivery" and "extra DB churn".
# The scan is a single indexed lookup on (status, fire_at).
_DRAIN_CATCHUP_INTERVAL_SECONDS = 300
def _drain_job_id_for(fire_at_utc: datetime) -> str:
return f"{_DEFERRED_DRAIN_PREFIX}{fire_at_utc.strftime('%Y%m%d%H%M')}"
def schedule_deferred_drain(fire_at_utc: datetime) -> None:
"""Add an idempotent one-shot drain job for ``fire_at_utc``.
Past times schedule a near-immediate firing (now+1s) — the drain query
handles ``fire_at <= now`` regardless of which job fired, so a near-miss
still picks up the work.
"""
from datetime import datetime, timezone
if fire_at_utc.tzinfo is None:
fire_at_utc = fire_at_utc.replace(tzinfo=timezone.utc)
scheduler = get_scheduler()
job_id = _drain_job_id_for(fire_at_utc)
run_at = fire_at_utc
if run_at <= datetime.now(timezone.utc):
from datetime import timedelta
run_at = datetime.now(timezone.utc) + timedelta(seconds=1)
scheduler.add_job(
_run_deferred_drain,
"date",
run_date=run_at,
id=job_id,
args=[fire_at_utc.isoformat()],
replace_existing=True,
max_instances=1,
# Override the global 5-min grace — see module-level comment.
misfire_grace_time=_DEFERRED_DRAIN_MISFIRE_GRACE_SECONDS,
)
_LOGGER.debug("Scheduled deferred drain %s (fire_at=%s)", job_id, fire_at_utc.isoformat())
def _schedule_drain_catchup() -> None:
"""Install the periodic catch-up scan. See module comment."""
from apscheduler.triggers.interval import IntervalTrigger
scheduler = get_scheduler()
if scheduler.get_job(_DEFERRED_DRAIN_CATCHUP_JOB):
return
scheduler.add_job(
_run_deferred_drain_catchup,
IntervalTrigger(seconds=_DRAIN_CATCHUP_INTERVAL_SECONDS),
id=_DEFERRED_DRAIN_CATCHUP_JOB,
replace_existing=True,
max_instances=1,
coalesce=True,
)
_LOGGER.info(
"Scheduled deferred-dispatch catch-up scan every %ds",
_DRAIN_CATCHUP_INTERVAL_SECONDS,
)
async def _run_deferred_drain(fire_at_iso: str) -> None:
"""APScheduler entry point — log the original fire_at then drain due rows.
The ``fire_at_iso`` arg is only used for logging; the drain itself picks
up every pending row whose ``fire_at`` has passed.
"""
from .deferred_dispatch import drain_deferred_due
try:
stats = await drain_deferred_due()
_LOGGER.info("Deferred drain (fire_at=%s) stats: %s", fire_at_iso, stats)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Deferred drain (fire_at=%s) failed: %s", fire_at_iso, err)
async def _run_deferred_drain_catchup() -> None:
"""Periodic safety-net drain — see module comment.
Distinct from the per-fire-at job only in cadence and log line; calls the
same ``drain_deferred_due`` which is a no-op when nothing is due.
"""
from .deferred_dispatch import drain_deferred_due
try:
stats = await drain_deferred_due()
# Quiet at debug level when nothing happened — every 5 min is too
# noisy at info on an idle system.
if stats.get("fired") or stats.get("dropped") or stats.get("errors"):
_LOGGER.info("Deferred catch-up stats: %s", stats)
else:
_LOGGER.debug("Deferred catch-up stats: %s", stats)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Deferred catch-up drain failed: %s", err)
async def _run_scheduled_backup() -> None:
"""Run a scheduled backup (called by APScheduler)."""
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
@@ -1116,3 +1249,66 @@ async def _run_scheduled_backup() -> None:
except Exception as e:
_LOGGER.error("Scheduled backup failed: %s", e)
# --- Release-check probe -----------------------------------------------------
_RELEASE_CHECK_JOB_ID = "upstream_release_check"
_RELEASE_CHECK_ONESHOT_JOB_ID = "upstream_release_check_oneshot"
_RELEASE_CHECK_ONESHOT_DELAY_SECONDS = 30
async def _schedule_release_check() -> None:
"""Register the interval + one-shot release-check jobs.
Reads the configured interval from AppSettings at startup. Idempotent —
APScheduler de-dupes via ``replace_existing=True``.
"""
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime, timedelta, timezone
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.app_settings import get_setting
from ..database.engine import get_engine
from .release_check import parse_interval_hours, run_check
async with AsyncSession(get_engine()) as session:
raw = await get_setting(session, "release_check_interval_hours")
interval_hours = parse_interval_hours(raw)
scheduler = get_scheduler()
scheduler.add_job(
run_check,
IntervalTrigger(hours=interval_hours),
id=_RELEASE_CHECK_JOB_ID,
replace_existing=True,
max_instances=1,
)
# One-shot probe shortly after start so admins see a fresh status without
# waiting for the first interval tick. Mirrors the chat-title sync.
scheduler.add_job(
run_check,
"date",
run_date=datetime.now(timezone.utc) + timedelta(seconds=_RELEASE_CHECK_ONESHOT_DELAY_SECONDS),
id=_RELEASE_CHECK_ONESHOT_JOB_ID,
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Scheduled release-check every %sh (one-shot in %ss)",
interval_hours, _RELEASE_CHECK_ONESHOT_DELAY_SECONDS)
async def reschedule_release_check() -> None:
"""Re-arm the release-check job after settings changed.
Called from the PUT /settings handler when the interval or provider config
changes. Removes the existing interval job, lets ``_schedule_release_check``
re-read the setting and rebuild it, and queues a fresh one-shot so the new
config takes effect within seconds rather than at the next interval tick.
"""
scheduler = get_scheduler()
if scheduler.get_job(_RELEASE_CHECK_JOB_ID):
scheduler.remove_job(_RELEASE_CHECK_JOB_ID)
if scheduler.get_job(_RELEASE_CHECK_ONESHOT_JOB_ID):
scheduler.remove_job(_RELEASE_CHECK_ONESHOT_JOB_ID)
await _schedule_release_check()