Coalesce the event bus, and fix the socket that ended on a client frame
A three-node cascade publishes 13-16 events and each one crossed to the
event loop on its own. They are one `call_soon_threadsafe` now — whatever
was published between two turns of the loop goes over together — and every
subscriber still receives every event, oldest still dropped first when one
falls behind.
The socket end of the same path:
- **any frame from the client ended its stream.** `receive_text` was
awaited once, outside the loop, so a keepalive — or anything else a
client decided to say — satisfied it and was read as the client going
away. It is recreated per iteration; only a disconnect ends the stream.
- events go out in one frame per wave (`{"type": "batch", "events": [...]}`,
capped at 64), serialised once with orjson rather than per client with
the stdlib's `json.dumps` through `send_json`. The client unpacks a batch
and still understands single frames, so an older engine behind a newer
bundle keeps working.
- authenticating and building the snapshot happen on a thread. Both were on
the event loop: one is a database round trip, the other reads the whole
of state, per connect and again per `dashboard_changed` per panel.
`Pipeline.values()` — what that snapshot is — no longer SCANs the whole
Redis namespace. It scanned five bookkeeping keys for every message to find
the messages; `RedisState` keeps a set of the names beside them and answers
from it. Maintained wherever a message is written, so a seeded value or a
deleted flow keeps it exact.
On the client, while in the same file:
- a `node_health` event invalidates the flow's detail. The canvas draws
health from the server-derived `issues`, so a node going down or
recovering only showed on mount, navigation or a rebuild. The store had
a health map of its own that nothing ever read; it and `useNodeHealth`
are gone rather than wired up, since the server's view is the one the
canvas already uses.
- a reconnect invalidates the five key families this socket feeds instead
of the entire cache, and the backoff is jittered. The usual reason a
socket dropped is the engine restarting, so every tab and every wall
panel refetched everything, together, at the moment it was least able to
answer.
- a frame that will not parse costs the frame, not the connection. It was
the one unguarded `JSON.parse` in the app; an exception there escaped to
`window.onerror` and left whatever it had already applied behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
This commit is contained in:
@@ -4,6 +4,7 @@ import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
@@ -925,6 +926,22 @@ def event_for_panel(event: dict[str, Any], only: set[str]) -> bool:
|
||||
return event.get("type") == "message_value" and str(event.get("name") or "") in only
|
||||
|
||||
|
||||
# How many events one frame may carry. A ceiling rather than a target: the
|
||||
# batch is whatever the bus happens to hold, and a client that has been away
|
||||
# should not be handed the whole queue in one message.
|
||||
MAX_FRAME_EVENTS = 64
|
||||
|
||||
|
||||
async def _send(websocket: WebSocket, events: list[dict[str, Any]]) -> None:
|
||||
"""One event, or a batch of them under `events`.
|
||||
|
||||
Serialised once with orjson rather than per client with the stdlib, which
|
||||
is what `send_json` does.
|
||||
"""
|
||||
payload = events[0] if len(events) == 1 else {"type": "batch", "events": events}
|
||||
await websocket.send_text(orjson.dumps(payload).decode())
|
||||
|
||||
|
||||
@ws_router.websocket("/ws")
|
||||
async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
||||
"""Stream values, node status and execution events as they happen.
|
||||
@@ -932,8 +949,15 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
||||
The token goes in the query string because browsers cannot set headers on
|
||||
a websocket handshake.
|
||||
"""
|
||||
with Session(engine) as session:
|
||||
user = user_from_token(session, token)
|
||||
|
||||
# On a thread: authenticating is a database round trip, and the snapshot
|
||||
# below reads the whole of state. Neither belongs on the event loop, which
|
||||
# every other socket and every request is sharing.
|
||||
def _authenticate() -> Any:
|
||||
with Session(engine) as session:
|
||||
return user_from_token(session, token)
|
||||
|
||||
user = await run_in_threadpool(_authenticate)
|
||||
if user is None:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
@@ -944,8 +968,13 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
||||
controller: FlowController | None = getattr(
|
||||
websocket.app.state, "flow_controller", None
|
||||
)
|
||||
if controller is not None:
|
||||
await websocket.send_json(snapshot_payload(controller, only))
|
||||
|
||||
async def send_snapshot() -> None:
|
||||
if controller is not None:
|
||||
payload = await run_in_threadpool(snapshot_payload, controller, only)
|
||||
await websocket.send_text(orjson.dumps(payload).decode())
|
||||
|
||||
await send_snapshot()
|
||||
|
||||
async with event_bus.subscribe() as queue:
|
||||
receiver = asyncio.create_task(websocket.receive_text())
|
||||
@@ -956,23 +985,47 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
||||
{sender, receiver}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if receiver in done:
|
||||
# The client went away.
|
||||
sender.cancel()
|
||||
break
|
||||
event = sender.result()
|
||||
if only is not None and event.get("type") == "dashboard_changed":
|
||||
# The scope was resolved once, at the handshake. A panel
|
||||
# pointed at another dashboard would otherwise fetch the
|
||||
# new document and then draw tiles nothing ever updates.
|
||||
# ``or set()`` because a panel that was deleted resolves to
|
||||
# None, the same as a person's token — and that would widen
|
||||
# this socket to everything on the bus.
|
||||
only = panel_scope(token, websocket.app) or set()
|
||||
if controller is not None:
|
||||
await websocket.send_json(snapshot_payload(controller, only))
|
||||
if only is not None and not event_for_panel(event, only):
|
||||
# A frame from the client. Only a disconnect ends the
|
||||
# stream — a keepalive, or anything else it decides to
|
||||
# say, used to be read as the client going away and cost
|
||||
# it every live update from then on.
|
||||
receiver.exception()
|
||||
receiver = asyncio.create_task(websocket.receive_text())
|
||||
if sender not in done:
|
||||
continue
|
||||
elif sender not in done:
|
||||
continue
|
||||
await websocket.send_json(event)
|
||||
|
||||
# Everything the bus has right now, not just the one event
|
||||
# that woke this: a cascade puts a dozen in at once, and one
|
||||
# frame carrying them costs one wakeup rather than a dozen.
|
||||
batch = [sender.result()]
|
||||
while len(batch) < MAX_FRAME_EVENTS:
|
||||
try:
|
||||
batch.append(queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for event in batch:
|
||||
if only is not None and event.get("type") == "dashboard_changed":
|
||||
# The scope was resolved once, at the handshake. A
|
||||
# panel pointed at another dashboard would otherwise
|
||||
# fetch the new document and then draw tiles nothing
|
||||
# ever updates. ``or set()`` because a panel that was
|
||||
# deleted resolves to None, the same as a person's
|
||||
# token — and that would widen this socket to
|
||||
# everything on the bus.
|
||||
only = panel_scope(token, websocket.app) or set()
|
||||
if out:
|
||||
await _send(websocket, out)
|
||||
out = []
|
||||
await send_snapshot()
|
||||
if only is not None and not event_for_panel(event, only):
|
||||
continue
|
||||
out.append(event)
|
||||
if out:
|
||||
await _send(websocket, out)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user