feat: wire tracking-config display filters + per-tracker adaptive polling

Display filters (Immich tracking config):
- favorites_only drops events with no favorited new assets, or filters
  added_assets to favorites only
- assets_order_by/assets_order sort the rendered list
  (date / name / rating / random / none)
- max_assets_to_show caps rendered+attached media (default 5 -> 10)
- include_tags strips people from event extras and tags from each asset
- include_asset_details strips city/country/state/lat/lon/is_favorite/
  rating/description; load-bearing fields (thumbhash, file_size,
  playback_size, cache keys) preserved
- New apply_tracking_display_filters helper in dispatch_helpers; wired
  into watcher, webhooks, scheduled/periodic/memory, and manual
  test-dispatch
- Targets sharing a TrackingConfig dispatch together; targets with
  different TCs each see their own shaped event

Adaptive polling:
- Replace NotificationTracker.batch_duration with adaptive_max_skip
- Per-tracker opt-in: NULL/0 disables back-off (every tick runs);
  positive N caps the skip factor at (N-1)-in-N after long idle
- Scheduler caches the cap in module state for the tick fast-path
- Migration adds the new column; API schemas/responses, frontend types,
  i18n, and the tracker form updated to match
This commit is contained in:
2026-04-24 21:12:10 +03:00
parent 187b889c45
commit ab621b6abc
19 changed files with 367 additions and 72 deletions
@@ -49,21 +49,44 @@ _scheduler: AsyncIOScheduler | None = None
# than one tick — but the steady-state HTTP cost for a fleet of idle
# trackers drops by ~75%.
#
# Opt-in per tracker via the ``adaptive_max_skip`` column:
# * NULL or 0 → adaptive polling disabled, every tick runs (default)
# * 2 → skip at most 1-in-2 ticks after long idle
# * 3, 4, ... → up to (N-1)-in-N skipping
# Thresholds are intentionally conservative: a tracker polling every 30 s
# needs 5 min of silence before we halve its effective rate, and 15 min
# before we quarter it. Any caller can disable adaptive behavior by passing
# ``adaptive=False`` in the tracker filters dict (checked in ``_poll_tracker``).
# before we quarter it.
# ---------------------------------------------------------------------------
_ADAPTIVE_HALVE_THRESHOLD = 10 # consecutive empty ticks → 1-in-2
_ADAPTIVE_QUARTER_THRESHOLD = 30 # consecutive empty ticks → 1-in-4
_ADAPTIVE_MAX_SKIP = 4 # hard cap on skip factor
# Per-tracker adaptive state, keyed by tracker_id. Rebuilt on process
# restart — a short warmup period is fine and avoids persisting what is
# effectively a performance heuristic.
_adaptive_state: dict[int, dict[str, int]] = {}
# Per-tracker cap on the skip factor, mirrored from the DB column at
# schedule time. Absence of an entry (or 0) means adaptive polling is off
# for that tracker — ``_adaptive_should_skip`` returns False immediately.
_adaptive_max_skip: dict[int, int] = {}
def set_adaptive_max_skip(tracker_id: int, max_skip: int | None) -> None:
"""Register/clear the adaptive cap for a tracker.
Called by the scheduling helpers so the tick-fast-path in
``_adaptive_should_skip`` doesn't need to re-query the DB. Values ≤ 1
disable back-off for the tracker — every scheduled tick runs.
"""
if max_skip and max_skip > 1:
_adaptive_max_skip[tracker_id] = int(max_skip)
else:
_adaptive_max_skip.pop(tracker_id, None)
# Opting in/out mid-session should drop any prior counters so the
# new behavior applies from the next tick, not N ticks later.
_adaptive_state.pop(tracker_id, None)
def _compute_jitter(interval_seconds: int) -> int:
"""Return a jitter bound (in seconds) suitable for an IntervalTrigger.
@@ -387,9 +410,11 @@ async def _load_tracker_jobs() -> None:
replace_existing=True,
max_instances=1,
)
set_adaptive_max_skip(tracker.id, tracker.adaptive_max_skip)
_LOGGER.info(
"Scheduled tracker %d (%s) every %ds (jitter ±%ds)",
"Scheduled tracker %d (%s) every %ds (jitter ±%ds, adaptive_max_skip=%s)",
tracker.id, tracker.name, tracker.scan_interval, jitter,
tracker.adaptive_max_skip,
)
@@ -429,14 +454,21 @@ async def schedule_tracker(
tracker_id: int,
interval: int,
cron_expression: str | None = None,
adaptive_max_skip: int | None = None,
) -> None:
"""Add or update a scheduler job for a tracker."""
"""Add or update a scheduler job for a tracker.
``adaptive_max_skip`` mirrors the DB column and is registered with the
adaptive module-state so tick-time skip decisions don't re-query the DB.
Pass ``None`` or ``0`` to disable back-off for the tracker.
"""
scheduler = get_scheduler()
job_id = f"tracker_{tracker_id}"
# A reschedule typically follows a config edit or enable/disable flip —
# drop adaptive back-off so the first tick after the change runs promptly.
reset_adaptive_state(tracker_id)
set_adaptive_max_skip(tracker_id, adaptive_max_skip)
# Remove existing job first to allow trigger type changes
if scheduler.get_job(job_id):
@@ -461,7 +493,8 @@ async def schedule_tracker(
replace_existing=True,
)
_LOGGER.info(
"Scheduled tracker %d every %ds (jitter ±%ds)", tracker_id, interval, jitter,
"Scheduled tracker %d every %ds (jitter ±%ds, adaptive_max_skip=%s)",
tracker_id, interval, jitter, adaptive_max_skip,
)
@@ -470,6 +503,7 @@ async def unschedule_tracker(tracker_id: int) -> None:
scheduler = get_scheduler()
job_id = f"tracker_{tracker_id}"
reset_adaptive_state(tracker_id)
_adaptive_max_skip.pop(tracker_id, None)
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
_LOGGER.info("Unscheduled tracker %d", tracker_id)
@@ -478,10 +512,12 @@ async def unschedule_tracker(tracker_id: int) -> None:
def _adaptive_should_skip(tracker_id: int) -> bool:
"""Return True when the adaptive heuristic says to skip this tick.
Run-length skip: if we're in 1-in-K mode, skip (K-1) ticks between each
real poll. Stateless about the *current* tick counter except for the
``tick_counter`` we bump here.
Short-circuits to False for trackers without a registered cap (adaptive
off). Otherwise: if we're in 1-in-K mode, skip (K-1) ticks between each
real poll.
"""
if tracker_id not in _adaptive_max_skip:
return False
state = _adaptive_state.get(tracker_id)
if not state:
return False
@@ -494,7 +530,14 @@ def _adaptive_should_skip(tracker_id: int) -> bool:
def _adaptive_update(tracker_id: int, events_detected: int) -> None:
"""Update the adaptive counter after a real tick ran."""
"""Update the adaptive counter after a real tick ran.
No-op when the tracker has adaptive polling disabled — otherwise we'd
build up empty counters for trackers that will never use them.
"""
cap = _adaptive_max_skip.get(tracker_id)
if not cap or cap <= 1:
return
state = _adaptive_state.setdefault(
tracker_id, {"empty_count": 0, "skip_every": 1, "tick_counter": 0}
)
@@ -510,20 +553,22 @@ def _adaptive_update(tracker_id: int, events_detected: int) -> None:
return
state["empty_count"] = state.get("empty_count", 0) + 1
target_quarter = min(cap, 4)
if (
state["empty_count"] >= _ADAPTIVE_QUARTER_THRESHOLD
and state["skip_every"] < _ADAPTIVE_MAX_SKIP
and state["skip_every"] < target_quarter
):
state["skip_every"] = _ADAPTIVE_MAX_SKIP
state["skip_every"] = target_quarter
_LOGGER.info(
"Adaptive polling: tracker %d idle for %d ticks, skipping 3 of 4",
"Adaptive polling: tracker %d idle for %d ticks, skipping %d of %d",
tracker_id, state["empty_count"],
target_quarter - 1, target_quarter,
)
elif (
state["empty_count"] >= _ADAPTIVE_HALVE_THRESHOLD
and state["skip_every"] < 2
and state["skip_every"] < min(cap, 2)
):
state["skip_every"] = 2
state["skip_every"] = min(cap, 2)
_LOGGER.info(
"Adaptive polling: tracker %d idle for %d ticks, skipping every other",
tracker_id, state["empty_count"],
@@ -535,7 +580,8 @@ def reset_adaptive_state(tracker_id: int) -> None:
Used by API callers that make changes requiring the tracker to run
promptly on the next scheduled tick (enable/disable, config edits,
manual "check now" actions).
manual "check now" actions). Does NOT clear the configured cap — use
``set_adaptive_max_skip(..., None)`` for that.
"""
_adaptive_state.pop(tracker_id, None)