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