File size: 6,306 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 | """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
|