| """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_DELIVERED = "delivered" |
| WATCH_TIMEOUT = "timeout" |
| WATCH_EVICTED = "evicted" |
| WATCH_DEGRADED = "degraded" |
| WATCH_NO_STREAMS = "no_streams" |
|
|
| |
| |
| |
| |
| _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: |
| |
| |
| |
| |
| |
| return await run_in_threadpool(check), WATCH_NO_STREAMS, waited_ms() |
|
|
| deadline = started + wait_s |
| |
| |
| 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() |
| |
| |
| |
| |
| |
| |
| |
| if not await sub.wait(remaining): |
| page = await run_in_threadpool(check) |
| if has_items(page): |
| |
| |
| 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 |
|
|