File size: 17,536 Bytes
4879fc7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | """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__)
# WATCH_DESIGN.md Β§3.2.1: an over-cap request is held for a jittered span drawn
# from this range before its one final check. Not a config knob β it is a
# pacing floor, and the only value that matters is that it is >> the ~2s a
# hot-looping client would use and << the wait ceiling.
_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 # shared with the owning Notifier
self._loop = loop # captured at register time (event loop)
self._over_cap = over_cap # past the global cap; paces, never parks
self._evicted = False # displaced by a newer waiter for this owner
self._degraded = over_cap # can never be signalled again
self._active = not over_cap # tracked in the registry's maps
self._latch = False # a wake landed; the next wait consumes it
self._future: asyncio.Future | None = None # set only while parked
@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:
# Park on a fresh future the registry (and thus wake) can find.
fut = self._loop.create_future()
self._future = fut
if hold is not None:
# Paced, not parked: no slot held, no wake possible, no re-check
# loop β the caller runs its one final check when we return.
if hold > 0:
await asyncio.sleep(hold)
return False
try:
await asyncio.wait_for(fut, timeout)
except asyncio.TimeoutError:
# wait_for cancelled `fut`; a wake that raced the timeout still set
# the latch, so the re-check below reports it rather than losing it.
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]] = {}
# Per owner, oldest-first, to evict the stalest connection first.
self._by_owner: dict[str, list[Subscription]] = {}
self._total = 0
self._max_per_owner = max_waiters_per_owner
self._max_total = max_waiters_total
# A large wake spreads its future resolutions over [0, wake_spread_s]
# so agents don't all re-poll in the same tick; see _flush. Wakes at or
# below wake_spread_threshold targets stay instant.
self._wake_spread_s = wake_spread_s
self._wake_spread_threshold = wake_spread_threshold
self._clock = clock
# owner -> (monotonic stamp, mode) of its most recent wait>0 poll. A
# hint for the digest's `watching` block, not an audit log: it is lost
# on restart, and a restart reads as "nobody is watching" β the
# truthful answer, since every parked connection died with it.
self._last_poll: dict[str, tuple[float, str]] = {}
# Cheap operational counters for /v1/healthz. eq2 shipped this feature
# with zero observability, so an operator could not tell a quiet board
# from a registry that had been degrading every request for hours.
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 # its parked wait() will return False
oldest._evicted = True # ...and say why, for watch.status
fut = self._detach_locked(oldest)
if fut is not None:
evicted.append((oldest._loop, fut))
self._evictions += 1
owned = self._by_owner.get(owner) # re-fetch; None once emptied
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
# Logged outside the lock; both lines are the operator's only warning
# that watchers are being served a worse contract than they asked for.
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)
# ββ liveness & observability ββ
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,
}
# ββ internals (all _locked helpers require self._lock held) ββ
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 # set BEFORE resolving so wait can't miss it
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:
# Cross the thread boundary outside the lock: the loop resolves each
# future on its own thread, where touching it is safe.
#
# A large wake (a broadcast, or a busy channel) would otherwise resolve
# every parked long-poll in the same instant, so all agents re-poll at
# once β a synchronized request spike into this Space that can trip the
# *.hf.space edge rate limit. When more than `wake_spread_threshold`
# waiters are woken, spread their releases uniformly over
# [0, spread_s] so the re-polls arrive staggered. Small/targeted wakes
# (@mentions) and eviction flushes (spread_s=0) stay instant.
if spread_s > 0.0 and len(pending) > self._wake_spread_threshold:
for loop, fut in pending:
offset = random.uniform(0.0, spread_s)
# call_later must run on the loop thread β hop there first.
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:
# Runs on the owning loop. Idempotent: a double wake, or a wake that raced
# the wait's own timeout/cancel, may find the future already resolved.
if not fut.done():
fut.set_result(True)
|