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
@@ -22,8 +22,9 @@ from ..database.models import (
ServiceProvider,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -205,11 +206,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
# Load app-level timezone for quiet-hours evaluation.
app_tz = await get_app_timezone(session)
# Snapshot the data we need
# Snapshot the data we need. These reads happen INSIDE the open
# session so we get fresh attribute values; once the block exits, the
# ORM instances become detached and any unfetched attribute access
# would raise. Pulling primitives here is the deliberate isolation
# boundary between the DB phase and the network phase.
provider_type = provider.type
provider_config = dict(provider.config)
provider_name = provider.name
tracker_name = tracker.name
tracker_user_id = tracker.user_id
tracker_filters = dict(tracker.filters) if tracker.filters else {}
collection_ids = list(tracker.collection_ids or [])
@@ -317,6 +323,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
)
session.add(new_ts)
# Capture the event_log row id alongside each event so the dispatch
# loop below can stamp a "dispatch_status=deferred" pointer onto the
# row if quiet hours suppresses it.
event_log_id_by_event: dict[int, int] = {}
for event in events:
assets_count = event.added_count or event.removed_count or 0
details: dict[str, Any] = {
@@ -352,6 +362,8 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
details=details,
)
session.add(log)
await session.flush()
event_log_id_by_event[id(event)] = log.id
await session.commit()
@@ -377,21 +389,54 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
asset_cache=asset_cache,
session=shared_session,
)
from .deferred_dispatch import defer_event, is_deferrable
from .scheduler import schedule_deferred_drain
from ..database.models import EventLog as _EventLog
for event in events:
_LOGGER.info(
"Dispatching event %s for %s (added=%d removed=%d)",
event.event_type.value, event.collection_name,
event.added_count, event.removed_count,
)
event_log_id = event_log_id_by_event.get(id(event))
# Group targets by tracking-config identity so each unique TC
# gets one event-transform pass; targets sharing a TC dispatch
# together (preserves the gather-fan-out inside the dispatcher).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
# Track defers in a single dict so we can persist them in one
# session + commit at the end of the iteration. ``load_link_data``
# emits multiple entries per broadcast link (one per child) sharing
# the same parent ``link_id``; the deferred row is one-per-link, so
# ``dict`` keying by ``link_id`` naturally dedupes.
defers_for_event: dict[int, datetime] = {}
scheduled_until: datetime | None = None
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter")
continue
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.QUIET_HOURS:
if is_deferrable(event.event_type.value) and outcome.quiet_hours_end_at is not None:
link_id = ld.get("link_id")
if link_id is not None:
# Per-link earliest fire_at wins if a future
# iteration ever supplies a different end.
prior = defers_for_event.get(link_id)
if prior is None or outcome.quiet_hours_end_at < prior:
defers_for_event[link_id] = outcome.quiet_hours_end_at
_LOGGER.info(
" Deferred until %s (quiet hours)",
outcome.quiet_hours_end_at.isoformat() if outcome.quiet_hours_end_at else "?",
)
else:
_LOGGER.info(
" Suppressed (quiet hours; event type not deferrable)",
)
continue
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
_LOGGER.info(" Skipped by tracking config filter")
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
@@ -410,6 +455,47 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
# Persist defers + stamp the event_log row + schedule drains in a
# single transaction. This keeps the "deferred" pill on the
# dashboard consistent with the existence of pending rows even if
# the process is killed mid-way (either both land or neither does).
if defers_for_event:
async with AsyncSession(engine) as defer_session:
for link_id, fire_at in defers_for_event.items():
await defer_event(
defer_session,
event=event,
user_id=tracker_user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
fire_at=fire_at,
)
if scheduled_until is None or fire_at < scheduled_until:
scheduled_until = fire_at
# Stamp event_log row inside the SAME session so the
# "deferred until" pill is only visible if the rows
# actually persist.
if event_log_id is not None and scheduled_until is not None:
el = await defer_session.get(_EventLog, event_log_id)
if el is not None:
existing = dict(el.details or {})
if not existing.get("dispatch_status"):
existing["dispatch_status"] = "deferred"
existing["deferred_until"] = scheduled_until.isoformat()
el.details = existing
defer_session.add(el)
await defer_session.commit()
# Drain job registration is best-effort: a failure here just
# delays delivery until the next scan/restart, not data loss.
for fire_at in {*defers_for_event.values()}:
try:
schedule_deferred_drain(fire_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to schedule deferred drain for %s", fire_at,
)
for tc, target_configs in groups.values():
if not target_configs:
continue