Skip to content

Commit 728b8bc

Browse files
dshoen619claude
andcommitted
fix: correct debounce semantics to match OPAL's actual call graph (PER-15248)
Review of the initial implementation found that three of DebouncedTrigger's documented invariants were false against OPAL's real behaviour, because both updaters are fire-and-forget underneath: trigger_update_policy is a single put onto an unbounded asyncio.Queue whose consumer swallows exceptions, and get_base_policy_data awaits only a config GET before handing the per-entry fetches to a task pool. So `await run()` returns on DISPATCH, not completion. Corrections: - _last_fired -> _last_dispatched, and the "a failed pull does not burn the window" guarantee is removed. It was never achievable at this layer: a reload that fails in a background task still consumes the window. - The in-flight guard no longer claims to cover multi-minute pulls, and is now unconditional - window_seconds <= 0 disables only the time window, never the single-flight property. - Trailing edge: a trigger coalesced by the in-flight guard now causes exactly one follow-up dispatch, so it is not silently dropped. Capped at two dispatches per call so a sustained hammer cannot become a reload loop. Trailing failures are logged and swallowed - the caller executing the re-run already had its own dispatch succeed and must not be handed someone else's 500. A trigger coalesced into a failed dispatch stays pending instead of being discarded. - The /data-updater/trigger docstring claimed a 200 previously meant the fetch had COMPLETED. It never did; corrected. Also: - Routes return {"status": "ok", "triggered": bool} so callers and metrics can distinguish a dispatch from a coalesce. Documented that `false` is a success and must not be retried, since retrying re-creates the amplification this change exists to dampen. - Handler docstrings were being published as the operation description in the customer-facing /openapi.json and /scalar explorer, leaking internal notes including "replaces OpalClient's ungated handler". Replaced with explicit summary=/description= written for that audience. - TRIGGER_DEBOUNCE_SECONDS is clamped to [0, 300] and the effective value is logged at startup. clamp_window coerces defensively rather than raising: confi.float's cast_from_json is no_cast, so a remote-config override arrives verbatim, and null or "30" would otherwise abort startup. - Coalesce logging is INFO on the first suppression per dispatch and DEBUG thereafter, so the mitigation does not amplify log volume under the exact hammering it absorbs. - Config description corrected: the restart requirement comes from remote config being fetched once at startup, not from the window being read once. Tests: new test_debounce_unit.py covers DebouncedTrigger directly (burst collapse, cancellation, trailing edge, clamp_window edges, coalesce logging). Route-audit now asserts exactly one route per trigger path and PDP ownership, which the previous last-wins dict lookup could not catch. test_opal_trigger_auth gets an autouse fixture so per-instance debounce state cannot leak between tests in that module. 155 passed; ruff check and format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f92bb46 commit 728b8bc

8 files changed

Lines changed: 901 additions & 131 deletions

horizon/config.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -292,13 +292,19 @@ def parse_plugins(value: Any) -> dict[str, dict[str, int | bool | str]]:
292292
"TRIGGER_DEBOUNCE_SECONDS",
293293
10.0,
294294
description=(
295-
"Minimum number of seconds between forced full reloads triggered via the API trigger "
296-
"routes (/policy-updater/trigger, /data-updater/trigger and their legacy /update_policy* "
297-
"aliases). Triggers arriving within the window - or while a forced reload is already in "
298-
"flight - coalesce into the in-flight/most-recent pull instead of amplifying load onto the "
299-
"control plane. Set to 0 to disable debouncing (every trigger forces a fresh reload). "
295+
"Debounce window, in seconds, for forced full reloads triggered via the API trigger routes "
296+
"(/policy-updater/trigger, /data-updater/trigger and their legacy /update_policy* aliases). "
297+
"A trigger arriving within this many seconds of the last one - or while a forced reload is "
298+
"already in flight - is coalesced instead of amplifying load onto the control plane, so data "
299+
"served by this PDP may lag a forced trigger by up to this many seconds. Not a hard floor "
300+
"between reloads: a trigger coalesced into an in-flight reload causes one immediate follow-up "
301+
"reload once that one finishes, so a single request can dispatch at most two. Set to 0 to "
302+
"disable the time window; concurrent triggers are still collapsed into a single in-flight "
303+
"reload. Negative, non-numeric and non-finite values also disable it. Clamped to at most 300s; "
304+
"the effective value is logged at startup whenever it differs from what was configured. "
300305
"Remote-config overridable fleet-wide, so ops can raise it (e.g. to 30-60s under a degraded "
301-
"control plane) without shipping a release."
306+
"control plane) without shipping a release - but the remote config is fetched once during "
307+
"startup, so a change needs a PDP restart to take effect."
302308
),
303309
)
304310

horizon/debounce.py

Lines changed: 161 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,94 +6,215 @@
66
forced reload amplifies straight onto the shared control plane - exactly when a
77
degraded control plane can least afford it. ``DebouncedTrigger`` holds the small
88
amount of coalescing *state* for one logical updater and decides, per call, whether
9-
to actually run the reload or collapse it into a recent/in-flight one.
9+
to actually dispatch the reload or collapse it into a recent/in-flight one.
1010
1111
The class holds only state + policy; the actual reload work is passed in per call
1212
(``run``). That lets the canonical and legacy-alias routes share a single instance
1313
per updater (so an alternating canonical/legacy hammer still coalesces - both hit
1414
the same control-plane resource) while each supplies its own ``run`` closure and
1515
its own log context.
16+
17+
WHAT ``run`` ACTUALLY DOES - read this before reasoning about the guards below.
18+
Both updaters are fire-and-forget underneath, so ``await run()`` returns once the
19+
reload has been *dispatched*, NOT once it has completed:
20+
21+
* policy - ``PolicyUpdater.trigger_update_policy`` is a single ``await queue.put(...)``
22+
onto an unbounded ``asyncio.Queue``. It cannot block and cannot fail. The real pull
23+
runs later in ``PolicyUpdater.handle_policy_updates``, which swallows every exception.
24+
* data - ``DataUpdater.get_base_policy_data`` awaits ``_stop_polling_update_tasks()``
25+
and one data-source config GET, then hands the per-entry fetches to
26+
``TasksPool.add_task`` (i.e. ``asyncio.create_task``). The retries/backoff configured
27+
via ``DATA_UPDATER_CONN_RETRY`` live inside those spawned tasks.
28+
29+
Two consequences that the guards below cannot paper over:
30+
31+
1. ``_last_dispatched`` is a DISPATCH timestamp. A reload that later fails in the
32+
background still consumes the window. There is no "only on success" guarantee to
33+
be had at this layer without an upstream OPAL change.
34+
2. The in-flight window covers the dispatch only - a queue put (policy), or the config
35+
GET plus task hand-off (data). It is still worth having: that config GET has no
36+
explicit timeout and falls back to aiohttp's 5-minute default, so it genuinely can
37+
stall, and the guard is what stops concurrent triggers from piling up behind it.
1638
"""
1739

40+
import math
1841
import time
1942
from collections.abc import Awaitable, Callable
2043

2144
from loguru import logger
2245

46+
# Upper bound for the debounce window. The value is remote-config overridable from the
47+
# control plane, so an unclamped fat-finger (a stray "600000") would wedge every forced
48+
# reload fleet-wide with no way to force a sync short of a rollout. Five minutes is far
49+
# beyond any legitimate tuning range (ops guidance tops out around 60s).
50+
MAX_DEBOUNCE_SECONDS: float = 300.0
51+
52+
53+
def clamp_window(window_seconds: float) -> float:
54+
"""Normalise a configured debounce window into the range the debouncer honours.
55+
56+
Coerces defensively rather than trusting the type. ``confi.float`` casts from the
57+
ENVIRONMENT but its ``cast_from_json`` is ``no_cast``, so a remote config override from
58+
the control plane lands on the attribute VERBATIM - ``null`` and ``"30"`` both reach here
59+
unconverted. This function runs at startup as well as per request, so raising on a
60+
fat-fingered override would turn a bad config value into a PDP that will not boot.
61+
A numeric string is honoured; anything genuinely uninterpretable, and any non-finite
62+
value, collapses to 0 (debouncing disabled). The caller logs a warning when the
63+
effective window differs from what was configured.
64+
"""
65+
try:
66+
window = float(window_seconds)
67+
except (TypeError, ValueError):
68+
return 0.0
69+
if not math.isfinite(window):
70+
return 0.0
71+
return min(max(window, 0.0), MAX_DEBOUNCE_SECONDS)
72+
2373

2474
class DebouncedTrigger:
2575
"""Coalesces forced-reload triggers for one logical updater (policy or data).
2676
2777
Semantics of :meth:`trigger` (in evaluation order):
2878
29-
* ``window_seconds <= 0`` -> passthrough (debounce disabled): always run.
30-
* A reload is already **in flight** -> coalesce regardless of the window. Under a
31-
degraded control plane a single forced pull can run for minutes (the PDP configures
32-
many retries with exponential backoff), so a pure time-window check would still admit
33-
a *concurrent* full pull every ``window_seconds`` - the in-flight guard is what prevents
34-
that pile-up.
35-
* Otherwise, if the last successful reload was **within the window** -> coalesce.
36-
* Otherwise -> run, recording the completion time **only on success**.
37-
38-
Recording ``_last_fired`` only on success is deliberate: a failed pull must not burn the
39-
window (a legitimate retry within ``window_seconds`` must still fire), and the exception is
40-
re-raised so the route surfaces it.
79+
* A reload is already **in flight** -> coalesce, and arm the trailing edge. This
80+
guard is unconditional: it applies even when ``window_seconds`` is 0, because
81+
"no time-based damping" should still never mean "two concurrent full pulls".
82+
* Otherwise, if ``window_seconds > 0`` and the last dispatch was **within the
83+
window** -> coalesce. No trailing edge here; staleness is bounded by
84+
``window_seconds`` by construction, which is the entire point of the knob.
85+
* Otherwise -> dispatch, recording the dispatch time.
86+
87+
**Trailing edge.** A trigger coalesced by the in-flight guard would otherwise be
88+
lost: the reload it collapsed into may have already read its data before the
89+
caller's change landed, and nothing would schedule a follow-up. Since an in-flight
90+
dispatch has no bounded duration, that staleness would be unbounded too. So the
91+
dispatching call re-runs **once** if any trigger arrived while it was running,
92+
capping the work at two dispatches per call.
93+
94+
Be precise about what that does and does not promise. It bounds the damage from the
95+
in-flight guard; it is NOT a guarantee that every trigger is eventually served. This
96+
class never schedules future work - it only ever runs inside a caller's request - so
97+
a trigger arriving during the trailing run is coalesced and simply waits for somebody
98+
to trigger again. Closing that last gap needs a background timer task with its own
99+
lifecycle, which is deliberately out of scope here.
41100
"""
42101

43102
def __init__(self, name: str) -> None:
44103
# Short label used purely for logs, e.g. "policy" / "data".
45104
self._name = name
46-
# Monotonic seconds of the last *successful* reload; ``None`` until the first one.
105+
# Monotonic seconds of the last *dispatch*; ``None`` until the first one.
47106
# Deliberately ``None`` and NEVER ``0.0``: ``time.monotonic()`` is ~seconds since boot
48107
# on Linux, so a ``0.0`` sentinel would read as "fired at boot" and silently coalesce
49108
# the very first real trigger on a freshly booted host.
50-
self._last_fired: float | None = None
51-
# True while a reload is running under this instance (see the in-flight guard).
109+
self._last_dispatched: float | None = None
110+
# True while a dispatch is running under this instance (see the in-flight guard).
52111
self._in_flight: bool = False
112+
# When the current dispatch started, so a coalesce log can report how long the
113+
# thing it is collapsing into has been running.
114+
self._in_flight_since: float | None = None
115+
# Set when the in-flight guard coalesces a trigger; consumed by the trailing edge.
116+
self._pending: bool = False
117+
# Coalesced-since-last-dispatch counter. Keeps the log quiet under the exact
118+
# hammering this class exists to absorb: the first suppression per dispatch logs
119+
# at INFO, the rest at DEBUG, and the dispatch logs the total.
120+
self._coalesced: int = 0
53121

54122
async def trigger(self, run: Callable[[], Awaitable[None]], window_seconds: float) -> bool:
55-
"""Run ``run`` (a coroutine factory doing the forced reload) unless it can be coalesced.
123+
"""Dispatch ``run`` (a coroutine factory doing the forced reload) unless it can be coalesced.
56124
57-
Returns ``True`` if ``run`` was awaited, ``False`` if the trigger was coalesced into a
125+
Returns ``True`` if ``run`` was dispatched, ``False`` if the trigger was coalesced into a
58126
recent/in-flight reload. A coalesced trigger is an immediate no-op success from the
59127
caller's perspective - it does NOT await the in-flight reload.
60128
"""
61-
# 1. Debounce disabled -> passthrough. Any exception from ``run`` propagates.
62-
if window_seconds <= 0:
63-
await run()
64-
return True
129+
window_seconds = clamp_window(window_seconds)
65130

66-
# 2. In-flight guard: collapse concurrent triggers into the one already running.
131+
# 1. In-flight guard: collapse concurrent triggers into the one already running, and
132+
# arm the trailing edge so this trigger is honoured rather than dropped.
67133
if self._in_flight:
68-
logger.info(
69-
"Coalescing {} reload trigger: a forced reload is already in flight; collapsing into it.",
70-
self._name,
71-
)
134+
self._pending = True
135+
self._note_coalesced("a forced reload is already in flight ({:.1f}s so far)", self._in_flight_age())
72136
return False
73137

74-
# 3. Window guard: collapse triggers that arrive within the debounce window of the
75-
# last successful reload.
76-
if self._last_fired is not None:
77-
elapsed = time.monotonic() - self._last_fired
138+
# 2. Window guard: collapse triggers that arrive within the debounce window of the
139+
# last dispatch. Skipped entirely when the window is disabled (<= 0).
140+
if window_seconds > 0 and self._last_dispatched is not None:
141+
elapsed = time.monotonic() - self._last_dispatched
78142
if elapsed < window_seconds:
79-
logger.info(
80-
"Coalescing {} reload trigger: within the {:g}s debounce window ({:.1f}s remaining).",
81-
self._name,
82-
window_seconds,
83-
window_seconds - elapsed,
143+
self._note_coalesced(
144+
"within the {:g}s debounce window ({:.1f}s remaining)", window_seconds, window_seconds - elapsed
84145
)
85146
return False
86147

87-
# 4. Fire. Single-worker assumption (the Rust supervisor spawns uvicorn with no
148+
# 3. Dispatch. Single-worker assumption (the Rust supervisor spawns uvicorn with no
88149
# --workers -> exactly one event loop): there is NO ``await`` between the guards
89150
# above and this set, so the check-then-set is atomic and needs no lock. A second
90151
# trigger cannot interleave until we ``await run()`` below, by which point
91-
# ``_in_flight`` is already True and step 2 will coalesce it.
152+
# ``_in_flight`` is already True and step 1 will coalesce it.
92153
self._in_flight = True
154+
self._in_flight_since = time.monotonic()
155+
# Clear before running, so anything arriving from here on counts as "arrived during
156+
# this dispatch". NOT cleared in the finally below: if ``run`` raises, a trigger that
157+
# was coalesced into this failed dispatch must stay pending rather than be discarded -
158+
# the next dispatch clears it right here, at the point where it actually serves it.
159+
self._pending = False
93160
try:
94161
await run()
95-
# Record completion time only on success so a failed pull does not burn the window.
96-
self._last_fired = time.monotonic()
162+
self._last_dispatched = time.monotonic()
163+
self._log_dispatched()
164+
if self._pending:
165+
await self._run_trailing(run)
97166
return True
98167
finally:
99168
self._in_flight = False
169+
self._in_flight_since = None
170+
171+
async def _run_trailing(self, run: Callable[[], Awaitable[None]]) -> None:
172+
"""Re-dispatch once, for triggers that arrived while the first dispatch was running.
173+
174+
Failures are logged and swallowed, never propagated. The caller executing this re-run
175+
already had its OWN dispatch succeed; handing it a 500 caused by somebody else's
176+
trigger would be both confusing and wrong (its request did what it asked). Losing the
177+
trailing reload is the lesser evil, and it is logged at ERROR.
178+
"""
179+
self._pending = False
180+
logger.info(
181+
"Re-running {} reload (trailing edge): a trigger arrived while the previous one was in flight.",
182+
self._name,
183+
)
184+
try:
185+
await run()
186+
except Exception: # noqa: BLE001
187+
logger.opt(exception=True).error(
188+
"Trailing {} reload failed. The triggers it was serving were not applied; "
189+
"the next trigger after the debounce window will retry.",
190+
self._name,
191+
)
192+
return
193+
self._last_dispatched = time.monotonic()
194+
self._log_dispatched()
195+
196+
def _log_dispatched(self) -> None:
197+
"""Report how many triggers the dispatch that just completed absorbed."""
198+
if not self._coalesced:
199+
return
200+
logger.info("Dispatched {} reload, absorbing {} coalesced trigger(s).", self._name, self._coalesced)
201+
# Reset only here, on a dispatch that actually completed. A dispatch that raised leaves
202+
# the count standing, so the triggers it failed to serve are still attributed to the
203+
# dispatch that eventually does serve them.
204+
self._coalesced = 0
205+
206+
def _in_flight_age(self) -> float:
207+
"""Seconds the current dispatch has been running (0.0 when nothing is in flight)."""
208+
if self._in_flight_since is None:
209+
return 0.0
210+
return time.monotonic() - self._in_flight_since
211+
212+
def _note_coalesced(self, reason: str, *args: float) -> None:
213+
"""Count a coalesced trigger and log it, loudly the first time and quietly thereafter."""
214+
self._coalesced += 1
215+
message = "Coalescing {} reload trigger: " + reason + "."
216+
# Only the first suppression per dispatch is worth an INFO line - under a hammer, one
217+
# INFO per suppressed request would make the mitigation amplify log volume into the
218+
# (unbounded, enqueue=True) logzio sink. The dispatch line reports the total.
219+
log = logger.info if self._coalesced == 1 else logger.debug
220+
log(message, self._name, *args)

0 commit comments

Comments
 (0)