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:
2026-08-29 20:08:50 +02:00
co-authored by Claude Opus 5
parent da528340a9
commit 1069247085
10 changed files with 287 additions and 78 deletions
+73 -20
View File
@@ -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: