| """Keyed wakeup registry for the long-poll (`wait=`) endpoints. |
| |
| A pure in-process primitive: it knows nothing about messages, only about |
| *keys* (plain strings such as ``inbox:{handle}`` / ``channel:{name}``). A |
| waiter parks on a set of keys; a writer that has just committed a record wakes |
| every waiter registered under the affected keys. All state here is cache β |
| restart-safe by loss (DESIGN.md Β§1); a lost wakeup only reverts an agent to the |
| plain poll it would have run anyway. |
| |
| Two thread contexts touch this registry and the single ``threading.Lock`` |
| serialises them: |
| |
| - **Waiters** live on the event loop (async routes call ``register`` / |
| ``Subscription.wait`` / ``unregister``). ``register`` captures the running |
| loop so foreign threads can post work back to it. |
| - **Wakers** run in Starlette's threadpool β the message-write path is a sync |
| route. ``wake`` therefore cannot touch a ``Future`` directly (futures are not |
| thread-safe); it sets a latch under the lock and bridges to the loop with |
| ``loop.call_soon_threadsafe`` on the future captured at register time. |
| |
| Correctness rests on the *latch-first* rule: a wake sets ``_latch`` under the |
| lock and only then resolves the current future (if the waiter is parked); |
| ``wait`` clears the latch under the lock before it parks. So a wake landing in |
| the gap between two ``wait`` calls, or before the first, is absorbed by the |
| latch and the next ``wait`` returns immediately β no wakeup is lost. Extra |
| wakes (double wake, or a wake racing the wait's own timeout) are idempotent: |
| the future check re-tests ``done()`` and the latch just stays set. |
| |
| The lock is held only for O(waiters) bookkeeping and never across an ``await`` |
| or a ``call_soon_threadsafe`` (those happen after the ``with`` block). |
| |
| The registry is also the only party that knows whether *anyone* is watching a |
| handle, so it keeps a per-owner ``last_poll`` stamp for the digest's |
| ``watching`` block (WATCH_DESIGN.md Β§4.5) and counters for ``/v1/healthz``. |
| """ |
| from __future__ import annotations |
|
|
| import asyncio |
| import logging |
| import random |
| import threading |
| import time |
| from typing import Callable, Iterable |
|
|
|
|
| log = logging.getLogger(__name__) |
|
|
| |
| |
| |
| |
| _DEGRADED_HOLD_S = (5.0, 15.0) |
|
|
|
|
| class Subscription: |
| """A single parked waiter, re-armable across many ``wait`` calls. |
| |
| Two flavours never enter the registry's maps and can therefore never be |
| signalled β ``wait`` reports the reason instead: |
| |
| - **over cap** β handed out when the global cap is reached. It paces itself |
| (see ``wait``) rather than answering instantly. |
| - **evicted** β the owner's oldest, detached because a newer connection for |
| the same handle arrived. It returns at once (as-if-timed-out) so an |
| abandoned long-poll self-heals without the newest waiter waiting on it. |
| |
| ``unregister`` on either is a no-op. |
| """ |
|
|
| def __init__( |
| self, |
| lock: threading.Lock, |
| owner: str, |
| keys: frozenset[str], |
| loop: asyncio.AbstractEventLoop, |
| *, |
| over_cap: bool, |
| ): |
| self.owner = owner |
| self.keys = keys |
| self._lock = lock |
| self._loop = loop |
| self._over_cap = over_cap |
| self._evicted = False |
| self._degraded = over_cap |
| self._active = not over_cap |
| self._latch = False |
| self._future: asyncio.Future | None = None |
|
|
| @property |
| def over_cap(self) -> bool: |
| return self._over_cap |
|
|
| @property |
| def evicted(self) -> bool: |
| with self._lock: |
| return self._evicted |
|
|
| async def wait(self, timeout: float) -> bool: |
| """Await a signal. ``True`` = signalled, ``False`` = timed out, evicted, |
| or degraded. Consumes a pending latch immediately; ``timeout <= 0`` |
| never parks and just reports the current latch state. |
| |
| An over-cap subscription is never in the registry, so no wake can ever |
| reach it β but it does NOT return instantly. eq2 did, and its degraded |
| clients hot-looped at ~2s, so degradation *increased* load exactly when |
| the server was full. Instead the request is held for a jittered |
| ``min(timeout, U(5, 15))``s with no registry entry (Β§3.2.1): one |
| degraded client then costs ~1 req/10s at β€15s delivery latency, and the |
| jitter keeps a crowd of them from re-polling in lockstep. |
| """ |
| hold: float | None = None |
| with self._lock: |
| if self._over_cap: |
| hold = max(0.0, min(timeout, random.uniform(*_DEGRADED_HOLD_S))) |
| elif self._degraded: |
| return False |
| elif self._latch: |
| self._latch = False |
| return True |
| elif timeout <= 0: |
| return False |
| else: |
| |
| fut = self._loop.create_future() |
| self._future = fut |
| if hold is not None: |
| |
| |
| if hold > 0: |
| await asyncio.sleep(hold) |
| return False |
| try: |
| await asyncio.wait_for(fut, timeout) |
| except asyncio.TimeoutError: |
| |
| |
| pass |
| finally: |
| with self._lock: |
| self._future = None |
| with self._lock: |
| if self._degraded: |
| return False |
| if self._latch: |
| self._latch = False |
| return True |
| return False |
|
|
|
|
| class Notifier: |
| """Registry of subscriptions keyed by string, with per-owner and global |
| caps supplied at construction (like the other in-memory singletons).""" |
|
|
| def __init__( |
| self, |
| *, |
| max_waiters_per_owner: int, |
| max_waiters_total: int, |
| wake_spread_s: float, |
| wake_spread_threshold: int, |
| clock: Callable[[], float] = time.monotonic, |
| ): |
| self._lock = threading.Lock() |
| self._by_key: dict[str, set[Subscription]] = {} |
| |
| self._by_owner: dict[str, list[Subscription]] = {} |
| self._total = 0 |
| self._max_per_owner = max_waiters_per_owner |
| self._max_total = max_waiters_total |
| |
| |
| |
| self._wake_spread_s = wake_spread_s |
| self._wake_spread_threshold = wake_spread_threshold |
| self._clock = clock |
| |
| |
| |
| |
| self._last_poll: dict[str, tuple[float, str]] = {} |
| |
| |
| |
| self._parks = 0 |
| self._wakes = 0 |
| self._evictions = 0 |
| self._degradations = 0 |
|
|
| def register(self, owner: str, keys: set[str]) -> Subscription: |
| """Register a waiter under every key in ``keys``; ``owner`` is the |
| polling handle, used only for cap accounting. Must be called from the |
| event loop β the running loop is captured for foreign-thread wakes. |
| |
| Per-owner cap exceeded -> evict this owner's OLDEST subscription (its |
| ``wait`` returns ``False`` as if timed out, self-healing an abandoned |
| long-poll so the newest connection is the live one). Global cap |
| exceeded -> return an over-cap, untracked subscription that paces |
| itself (the endpoint falls back to a slowed plain poll rather than |
| erroring under load). |
| """ |
| loop = asyncio.get_running_loop() |
| keyset = frozenset(keys) |
| evicted: list[tuple[asyncio.AbstractEventLoop, asyncio.Future]] = [] |
| with self._lock: |
| owned = self._by_owner.get(owner) |
| while owned is not None and len(owned) >= self._max_per_owner: |
| oldest = owned[0] |
| oldest._degraded = True |
| oldest._evicted = True |
| fut = self._detach_locked(oldest) |
| if fut is not None: |
| evicted.append((oldest._loop, fut)) |
| self._evictions += 1 |
| owned = self._by_owner.get(owner) |
| over_cap = self._total >= self._max_total |
| sub = Subscription(self._lock, owner, keyset, loop, over_cap=over_cap) |
| if over_cap: |
| self._degradations += 1 |
| else: |
| self._by_owner.setdefault(owner, []).append(sub) |
| for key in keyset: |
| self._by_key.setdefault(key, set()).add(sub) |
| self._total += 1 |
| self._parks += 1 |
| live = self._total |
| |
| |
| if evicted: |
| log.info( |
| "longpoll: evicted %d stale waiter(s) for owner=%s (per-owner cap %d)", |
| len(evicted), owner, self._max_per_owner, |
| ) |
| if over_cap: |
| log.warning( |
| "longpoll: global waiter cap reached (%d/%d) β owner=%s degraded to a " |
| "paced poll (no registry slot, held ~%.0f-%.0fs)", |
| live, self._max_total, owner, *_DEGRADED_HOLD_S, |
| ) |
| self._flush(evicted) |
| return sub |
|
|
| def unregister(self, sub: Subscription) -> None: |
| """Remove a subscription from the registry. Idempotent, and a no-op for |
| over-cap/evicted subscriptions (never a KeyError).""" |
| with self._lock: |
| self._detach_locked(sub) |
|
|
| def wake(self, keys: Iterable[str]) -> int: |
| """Signal every subscription registered under any of ``keys``. Thread- |
| safe: safe to call from the threadpool while waiters live on the loop. |
| Returns the number of subscriptions signalled.""" |
| with self._lock: |
| targets: set[Subscription] = set() |
| for key in keys: |
| bucket = self._by_key.get(key) |
| if bucket: |
| targets.update(bucket) |
| pending = self._arm_locked(targets) |
| self._wakes += len(targets) |
| self._flush(pending, spread_s=self._wake_spread_s) |
| return len(targets) |
|
|
| def wake_all(self) -> int: |
| """Broadcast: signal every registered subscription. Returns the count.""" |
| with self._lock: |
| targets: set[Subscription] = set() |
| for bucket in self._by_key.values(): |
| targets.update(bucket) |
| pending = self._arm_locked(targets) |
| self._wakes += len(targets) |
| self._flush(pending, spread_s=self._wake_spread_s) |
| return len(targets) |
|
|
| |
|
|
| def note_poll(self, owner: str, mode: str) -> None: |
| """Record that ``owner`` just opened a ``wait>0`` poll in ``mode`` |
| (updates|inbox|feed). The server side of "is anyone watching this |
| handle?" β the one liveness signal that survives total client amnesia |
| (WATCH_DESIGN.md Β§4.5/Β§6).""" |
| with self._lock: |
| self._last_poll[owner] = (self._clock(), mode) |
|
|
| def last_poll(self, owner: str) -> tuple[float, str] | None: |
| """(age in seconds, mode) of ``owner``'s most recent ``wait>0`` poll, or |
| ``None`` if this process has never seen one.""" |
| with self._lock: |
| seen = self._last_poll.get(owner) |
| if seen is None: |
| return None |
| return max(0.0, self._clock() - seen[0]), seen[1] |
|
|
| def all_last_poll(self) -> dict[str, tuple[float, str]]: |
| """``{owner: (age in seconds, mode)}`` for every handle this process has |
| ever served a ``wait>0`` poll for β the whole presence map in ONE lock |
| acquisition, for ``GET /v1/watching``. |
| |
| The aggregate exists because the per-handle answer is the wrong shape for |
| the only consumer that wants all of them: a dashboard drawing a dot per |
| agent would otherwise have to ask for one full digest per registered |
| handle every poll, computing inbox records, channel summaries and a |
| leaderboard N times over to read N entries out of this dict. Same hint |
| semantics as ``last_poll``: an absent handle means nobody is watching it. |
| """ |
| with self._lock: |
| now = self._clock() |
| return { |
| owner: (max(0.0, now - stamp), mode) |
| for owner, (stamp, mode) in self._last_poll.items() |
| } |
|
|
| def stats(self) -> dict[str, int]: |
| """Counters + the live waiter gauge, for /v1/healthz.""" |
| with self._lock: |
| return { |
| "waiters": self._total, |
| "owners": len(self._by_owner), |
| "parks": self._parks, |
| "wakes": self._wakes, |
| "evictions": self._evictions, |
| "degradations": self._degradations, |
| } |
|
|
| |
|
|
| def _arm_locked( |
| self, targets: set[Subscription] |
| ) -> list[tuple[asyncio.AbstractEventLoop, asyncio.Future]]: |
| pending: list[tuple[asyncio.AbstractEventLoop, asyncio.Future]] = [] |
| for sub in targets: |
| sub._latch = True |
| fut = sub._future |
| if fut is not None and not fut.done(): |
| pending.append((sub._loop, fut)) |
| return pending |
|
|
| def _detach_locked(self, sub: Subscription) -> asyncio.Future | None: |
| """Drop ``sub`` from every map. Returns its live parked future (if any) |
| so the caller can resolve it after releasing the lock; ``None`` if the |
| sub was already inactive (over-cap/evicted/unregistered).""" |
| if not sub._active: |
| return None |
| sub._active = False |
| for key in sub.keys: |
| bucket = self._by_key.get(key) |
| if bucket is not None: |
| bucket.discard(sub) |
| if not bucket: |
| del self._by_key[key] |
| owned = self._by_owner.get(sub.owner) |
| if owned is not None: |
| try: |
| owned.remove(sub) |
| except ValueError: |
| pass |
| if not owned: |
| del self._by_owner[sub.owner] |
| self._total -= 1 |
| fut = sub._future |
| if fut is not None and not fut.done(): |
| return fut |
| return None |
|
|
| def _flush( |
| self, |
| pending: list[tuple[asyncio.AbstractEventLoop, asyncio.Future]], |
| *, |
| spread_s: float = 0.0, |
| ) -> None: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if spread_s > 0.0 and len(pending) > self._wake_spread_threshold: |
| for loop, fut in pending: |
| offset = random.uniform(0.0, spread_s) |
| |
| loop.call_soon_threadsafe(loop.call_later, offset, _resolve_future, fut) |
| else: |
| for loop, fut in pending: |
| loop.call_soon_threadsafe(_resolve_future, fut) |
|
|
|
|
| def _resolve_future(fut: asyncio.Future) -> None: |
| |
| |
| if not fut.done(): |
| fut.set_result(True) |
|
|