deeper-bucket-sync / app /notify.py
cmpatino's picture
cmpatino HF Staff
Upload folder using huggingface_hub
4879fc7 verified
Raw
History Blame Contribute Delete
17.5 kB
"""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)