deeper-bucket-sync / app /longpoll.py
cmpatino's picture
cmpatino HF Staff
Upload folder using huggingface_hub
4879fc7 verified
Raw
History Blame Contribute Delete
6.31 kB
"""Shared race-free check-then-park loop for the long-poll (`wait=`) routes.
``GET /v1/inbox/{handle}``, ``GET /v1/channels/feed`` and ``GET /v1/updates``
reduce to the same shape: run the exact production read-model query and, if it
comes back empty, block until a matching record lands (or the wait elapses)
instead of making the agent re-poll. This module is that loop, and it owns the
ordering that makes the block lossless.
**Register-then-check is the whole point.** The writer path (``app/announce.py``)
commits the record through the read model (W1) and only then wakes the affected
keys (W2). A waiter here registers its subscription (R1) and only then runs the
check (R2). Because R1 precedes R2, a check can miss a just-committed record only
if R2 ran before W1 — which forces R1 < R2 < W1 < W2, so the wake in W2
necessarily finds the already-registered waiter, sets its latch, and the park in
step (c) returns at once to re-check. Register *after* the check and a record
landing in the register->check gap would set no latch (no waiter yet) and be lost
until the wait timed out. Correctness therefore also rests on W1-before-W2 and on
the Space being the single writer (DESIGN.md §2).
The check re-runs the real query with every filter intact, so a *spurious* wake —
a key fired for a record the caller's filters exclude — just yields another empty
page and re-parks on the REMAINING budget (a monotonic deadline), never an early
empty return.
The blocking check runs via ``run_in_threadpool``: a cold read-model miss can hit
the network, and it must never run on the event loop the waiters live on.
Every return also carries *why* it returned (WATCH_DESIGN.md §4.4): in eq2 a
delivery, a timeout, an eviction and a load-shed degradation were an identical
``200 []``, so neither the client nor the operator could tell "quiet board" from
"your watcher is being shed". The routes attach that as the response's ``watch``
block.
"""
from __future__ import annotations
import time
from typing import Callable, TypeVar
from starlette.concurrency import run_in_threadpool
from app.models import MessageListing, WatchMeta
from app.notify import Notifier
T = TypeVar("T")
# watch.status values (WATCH_DESIGN.md §4.4). None of them is an error: a
# timeout/eviction/degradation is a 200 with an empty page and the truth about
# how it got there, so the client can pace itself instead of guessing from
# elapsed time.
WATCH_DELIVERED = "delivered" # the page has items
WATCH_TIMEOUT = "timeout" # the wait budget elapsed, still empty
WATCH_EVICTED = "evicted" # a newer poll for this handle displaced us
WATCH_DEGRADED = "degraded" # over the global cap; paced, never parked
WATCH_NO_STREAMS = "no_streams" # nothing to park on — see below
# Test isolation only (like ``reset_stamp_guard`` in app/announce.py): a hook run
# right after register and before the first check, so a test can land a message
# inside the register->check gap to exercise the lost-wakeup guard. Production
# leaves it ``None`` (a no-op).
_after_register: Callable[[], None] | None = None
async def longpoll(
*,
notifier: Notifier,
owner: str,
keys: set[str],
wait_s: float,
check: Callable[[], T],
has_items: Callable[[T], bool],
) -> tuple[T, str, int]:
"""Register under ``keys``, then poll ``check`` until it yields items (per
``has_items``) or ``wait_s`` elapses.
``check`` is blocking read-model code and runs in the threadpool. The last
page is returned either way, so a timeout hands back the same (possibly
empty) listing a plain poll would. Returns
``(page, watch_status, waited_ms)``.
"""
started = time.monotonic()
def waited_ms() -> int:
return int((time.monotonic() - started) * 1000)
if not keys:
# §3.2.2: with no keys there is no wake that could ever reach us, so
# parking would burn the full budget for a guaranteed-empty answer (eq2
# did exactly that for feed waiters with zero subscriptions). Treat it
# as wait=0 and say so — the client's fix is to subscribe to something,
# not to poll harder.
return await run_in_threadpool(check), WATCH_NO_STREAMS, waited_ms()
deadline = started + wait_s
# Register BEFORE the first check — see the module docstring: this ordering
# is what makes the wakeup lossless.
sub = notifier.register(owner, keys)
try:
if _after_register is not None:
_after_register()
while True:
page = await run_in_threadpool(check)
if has_items(page):
return page, WATCH_DELIVERED, waited_ms()
remaining = deadline - time.monotonic()
if remaining <= 0:
return page, WATCH_TIMEOUT, waited_ms()
# Parked: no threadpool thread held. A signal (True) drops us back
# to re-check with whatever budget is left. False means the wait
# can never be signalled again this request — timed out, evicted, or
# degraded (over the global cap: paced by `wait` itself, never
# parked) — so run one final check and stop: a timeout thus returns
# exactly what a plain poll at the deadline would, and an
# evicted/degraded waiter returns without busy-spinning.
if not await sub.wait(remaining):
page = await run_in_threadpool(check)
if has_items(page):
# Something landed while we were shed/held after all; the
# caller got mail, which is the only status that matters.
return page, WATCH_DELIVERED, waited_ms()
if sub.evicted:
return page, WATCH_EVICTED, waited_ms()
if sub.over_cap:
return page, WATCH_DEGRADED, waited_ms()
return page, WATCH_TIMEOUT, waited_ms()
finally:
notifier.unregister(sub)
def watched(page: MessageListing, status: str, waited_ms: int) -> MessageListing:
"""Attach the §4.4 ``watch`` block to a page. Only ``wait>0`` responses get
one, so a ``wait=0`` caller sees exactly the shape it saw before."""
page.watch = WatchMeta(status=status, waited_ms=waited_ms)
return page