|
6 | 6 | forced reload amplifies straight onto the shared control plane - exactly when a |
7 | 7 | degraded control plane can least afford it. ``DebouncedTrigger`` holds the small |
8 | 8 | 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. |
10 | 10 |
|
11 | 11 | The class holds only state + policy; the actual reload work is passed in per call |
12 | 12 | (``run``). That lets the canonical and legacy-alias routes share a single instance |
13 | 13 | per updater (so an alternating canonical/legacy hammer still coalesces - both hit |
14 | 14 | the same control-plane resource) while each supplies its own ``run`` closure and |
15 | 15 | 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. |
16 | 38 | """ |
17 | 39 |
|
| 40 | +import math |
18 | 41 | import time |
19 | 42 | from collections.abc import Awaitable, Callable |
20 | 43 |
|
21 | 44 | from loguru import logger |
22 | 45 |
|
| 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 | + |
23 | 73 |
|
24 | 74 | class DebouncedTrigger: |
25 | 75 | """Coalesces forced-reload triggers for one logical updater (policy or data). |
26 | 76 |
|
27 | 77 | Semantics of :meth:`trigger` (in evaluation order): |
28 | 78 |
|
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. |
41 | 100 | """ |
42 | 101 |
|
43 | 102 | def __init__(self, name: str) -> None: |
44 | 103 | # Short label used purely for logs, e.g. "policy" / "data". |
45 | 104 | 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. |
47 | 106 | # Deliberately ``None`` and NEVER ``0.0``: ``time.monotonic()`` is ~seconds since boot |
48 | 107 | # on Linux, so a ``0.0`` sentinel would read as "fired at boot" and silently coalesce |
49 | 108 | # 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). |
52 | 111 | 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 |
53 | 121 |
|
54 | 122 | 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. |
56 | 124 |
|
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 |
58 | 126 | recent/in-flight reload. A coalesced trigger is an immediate no-op success from the |
59 | 127 | caller's perspective - it does NOT await the in-flight reload. |
60 | 128 | """ |
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) |
65 | 130 |
|
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. |
67 | 133 | 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()) |
72 | 136 | return False |
73 | 137 |
|
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 |
78 | 142 | 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 |
84 | 145 | ) |
85 | 146 | return False |
86 | 147 |
|
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 |
88 | 149 | # --workers -> exactly one event loop): there is NO ``await`` between the guards |
89 | 150 | # above and this set, so the check-then-set is atomic and needs no lock. A second |
90 | 151 | # 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. |
92 | 153 | 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 |
93 | 160 | try: |
94 | 161 | 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) |
97 | 166 | return True |
98 | 167 | finally: |
99 | 168 | 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