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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user