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