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
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import orjson
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
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
|
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")
|
@ws_router.websocket("/ws")
|
||||||
async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
||||||
"""Stream values, node status and execution events as they happen.
|
"""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
|
The token goes in the query string because browsers cannot set headers on
|
||||||
a websocket handshake.
|
a websocket handshake.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 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:
|
with Session(engine) as session:
|
||||||
user = user_from_token(session, token)
|
return user_from_token(session, token)
|
||||||
|
|
||||||
|
user = await run_in_threadpool(_authenticate)
|
||||||
if user is None:
|
if user is None:
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
return
|
return
|
||||||
@@ -944,8 +968,13 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
|||||||
controller: FlowController | None = getattr(
|
controller: FlowController | None = getattr(
|
||||||
websocket.app.state, "flow_controller", None
|
websocket.app.state, "flow_controller", None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def send_snapshot() -> None:
|
||||||
if controller is not None:
|
if controller is not None:
|
||||||
await websocket.send_json(snapshot_payload(controller, only))
|
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:
|
async with event_bus.subscribe() as queue:
|
||||||
receiver = asyncio.create_task(websocket.receive_text())
|
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
|
{sender, receiver}, return_when=asyncio.FIRST_COMPLETED
|
||||||
)
|
)
|
||||||
if receiver in done:
|
if receiver in done:
|
||||||
# The client went away.
|
# A frame from the client. Only a disconnect ends the
|
||||||
sender.cancel()
|
# 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
|
||||||
|
|
||||||
|
# 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
|
break
|
||||||
event = sender.result()
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for event in batch:
|
||||||
if only is not None and event.get("type") == "dashboard_changed":
|
if only is not None and event.get("type") == "dashboard_changed":
|
||||||
# The scope was resolved once, at the handshake. A panel
|
# The scope was resolved once, at the handshake. A
|
||||||
# pointed at another dashboard would otherwise fetch the
|
# panel pointed at another dashboard would otherwise
|
||||||
# new document and then draw tiles nothing ever updates.
|
# fetch the new document and then draw tiles nothing
|
||||||
# ``or set()`` because a panel that was deleted resolves to
|
# ever updates. ``or set()`` because a panel that was
|
||||||
# None, the same as a person's token — and that would widen
|
# deleted resolves to None, the same as a person's
|
||||||
# this socket to everything on the bus.
|
# token — and that would widen this socket to
|
||||||
|
# everything on the bus.
|
||||||
only = panel_scope(token, websocket.app) or set()
|
only = panel_scope(token, websocket.app) or set()
|
||||||
if controller is not None:
|
if out:
|
||||||
await websocket.send_json(snapshot_payload(controller, only))
|
await _send(websocket, out)
|
||||||
|
out = []
|
||||||
|
await send_snapshot()
|
||||||
if only is not None and not event_for_panel(event, only):
|
if only is not None and not event_for_panel(event, only):
|
||||||
continue
|
continue
|
||||||
await websocket.send_json(event)
|
out.append(event)
|
||||||
|
if out:
|
||||||
|
await _send(websocket, out)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -26,6 +27,13 @@ class EventBus:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
|
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
|
||||||
|
# Events published since the last flush was scheduled. A cascade
|
||||||
|
# publishes 13-16 of them and each used to cost its own
|
||||||
|
# `call_soon_threadsafe`; they now cost one between two turns of the
|
||||||
|
# loop, which is what a wave is.
|
||||||
|
self._pending: list[dict[str, Any]] = []
|
||||||
|
self._pending_lock = threading.Lock()
|
||||||
|
self._flush_scheduled = False
|
||||||
# Kept whether or not anyone is listening, so opening the log panel
|
# Kept whether or not anyone is listening, so opening the log panel
|
||||||
# shows what just happened rather than an empty box.
|
# shows what just happened rather than an empty box.
|
||||||
self.recent_logs: deque[dict[str, Any]] = deque(maxlen=LOG_HISTORY)
|
self.recent_logs: deque[dict[str, Any]] = deque(maxlen=LOG_HISTORY)
|
||||||
@@ -54,14 +62,31 @@ class EventBus:
|
|||||||
loop = self._loop
|
loop = self._loop
|
||||||
if loop is None or not self._subscribers:
|
if loop is None or not self._subscribers:
|
||||||
return
|
return
|
||||||
|
with self._pending_lock:
|
||||||
|
self._pending.append(event)
|
||||||
|
if self._flush_scheduled:
|
||||||
|
# A flush is already on its way and has not run yet, so it
|
||||||
|
# will find this event too.
|
||||||
|
return
|
||||||
|
self._flush_scheduled = True
|
||||||
try:
|
try:
|
||||||
loop.call_soon_threadsafe(self._dispatch, event)
|
loop.call_soon_threadsafe(self._dispatch)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Loop already closed — shutting down.
|
# Loop already closed — shutting down.
|
||||||
pass
|
with self._pending_lock:
|
||||||
|
self._pending.clear()
|
||||||
|
self._flush_scheduled = False
|
||||||
|
|
||||||
def _dispatch(self, event: dict[str, Any]) -> None:
|
def _dispatch(self) -> None:
|
||||||
|
"""Hand everything published since the last turn to every subscriber."""
|
||||||
|
with self._pending_lock:
|
||||||
|
batch = self._pending
|
||||||
|
self._pending = []
|
||||||
|
self._flush_scheduled = False
|
||||||
|
if not batch:
|
||||||
|
return
|
||||||
for queue in self._subscribers:
|
for queue in self._subscribers:
|
||||||
|
for event in batch:
|
||||||
if queue.full():
|
if queue.full():
|
||||||
# Drop the oldest so a slow client never blocks the engine.
|
# Drop the oldest so a slow client never blocks the engine.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -162,9 +162,7 @@ class DelayNode(Node):
|
|||||||
if self._pipeline is not None and self._pipeline.defer(
|
if self._pipeline is not None and self._pipeline.defer(
|
||||||
self, self._to_messages(output) or {}, self.delay
|
self, self._to_messages(output) or {}, self.delay
|
||||||
):
|
):
|
||||||
logger.debug(
|
logger.debug("[%s] Sending %s in %ss", self.name, output, self.delay)
|
||||||
"[%s] Sending %s in %ss", self.name, output, self.delay
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
time.sleep(self.delay)
|
time.sleep(self.delay)
|
||||||
|
|
||||||
|
|||||||
@@ -1725,9 +1725,7 @@ class Pipeline:
|
|||||||
per timestamp, one at a time under the global state lock.
|
per timestamp, one at a time under the global state lock.
|
||||||
"""
|
"""
|
||||||
keys = [
|
keys = [
|
||||||
k
|
k for k in self._state.message_names() if not flow or flow_of(k) == flow
|
||||||
for k in self._state.keys()
|
|
||||||
if not k.startswith("__") and (not flow or flow_of(k) == flow)
|
|
||||||
]
|
]
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterable, Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -98,6 +98,15 @@ class StateBackend(ABC):
|
|||||||
"""Clear all keys in the state."""
|
"""Clear all keys in the state."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def message_names(self) -> list[str]:
|
||||||
|
"""The message names in state, without the bookkeeping keys.
|
||||||
|
|
||||||
|
Its own method because the Redis backend can answer it from a set it
|
||||||
|
maintains, rather than by scanning a namespace that holds five
|
||||||
|
bookkeeping keys for every message.
|
||||||
|
"""
|
||||||
|
return [k for k in self.keys() if not k.startswith("__")]
|
||||||
|
|
||||||
def close(self) -> None: # noqa: B027
|
def close(self) -> None: # noqa: B027
|
||||||
"""Release whatever the backend holds outside this process.
|
"""Release whatever the backend holds outside this process.
|
||||||
|
|
||||||
@@ -510,18 +519,44 @@ class RedisState(StateBackend):
|
|||||||
data = cast(bytes | None, self._client.get(self._key(key)))
|
data = cast(bytes | None, self._client.get(self._key(key)))
|
||||||
return self._deserialize(data) if data is not None else default
|
return self._deserialize(data) if data is not None else default
|
||||||
|
|
||||||
|
def _names_key(self) -> str:
|
||||||
|
return self._key("__names__")
|
||||||
|
|
||||||
|
def message_names(self) -> list[str]:
|
||||||
|
"""The message names, from the set kept beside them.
|
||||||
|
|
||||||
|
`keys()` is a SCAN of the whole namespace, which holds a version, a
|
||||||
|
timestamp, a history list and a last-seen marker for every message —
|
||||||
|
so reading the message names cost several times as many round trips
|
||||||
|
as there are messages. Every websocket connect asks for this.
|
||||||
|
"""
|
||||||
|
members = cast(set[bytes], self._client.smembers(self._names_key()))
|
||||||
|
return [m.decode("utf-8") for m in members]
|
||||||
|
|
||||||
|
def _note(self, pipe: Any, keys: Iterable[str]) -> None:
|
||||||
|
"""Record the message names among `keys`, in the caller's pipeline."""
|
||||||
|
names = [k for k in keys if not k.startswith("__")]
|
||||||
|
if names:
|
||||||
|
pipe.sadd(self._names_key(), *names)
|
||||||
|
|
||||||
def set(self, key: str, value: Any) -> None:
|
def set(self, key: str, value: Any) -> None:
|
||||||
data = self._serialize(value)
|
data = self._serialize(value)
|
||||||
|
pipe = self._client.pipeline()
|
||||||
if self._ttl:
|
if self._ttl:
|
||||||
self._client.setex(self._key(key), self._ttl, data)
|
pipe.setex(self._key(key), self._ttl, data)
|
||||||
else:
|
else:
|
||||||
self._client.set(self._key(key), data)
|
pipe.set(self._key(key), data)
|
||||||
|
self._note(pipe, [key])
|
||||||
|
pipe.execute()
|
||||||
|
|
||||||
def exists(self, key: str) -> bool:
|
def exists(self, key: str) -> bool:
|
||||||
return bool(self._client.exists(self._key(key)))
|
return bool(self._client.exists(self._key(key)))
|
||||||
|
|
||||||
def delete(self, key: str) -> None:
|
def delete(self, key: str) -> None:
|
||||||
self._client.delete(self._key(key), self._history_key(key))
|
pipe = self._client.pipeline()
|
||||||
|
pipe.delete(self._key(key), self._history_key(key))
|
||||||
|
pipe.srem(self._names_key(), key)
|
||||||
|
pipe.execute()
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all keys in the namespace."""
|
"""Clear all keys in the namespace."""
|
||||||
@@ -568,6 +603,7 @@ class RedisState(StateBackend):
|
|||||||
pipe.setex(self._key(key), self._ttl, data)
|
pipe.setex(self._key(key), self._ttl, data)
|
||||||
else:
|
else:
|
||||||
pipe.set(self._key(key), data)
|
pipe.set(self._key(key), data)
|
||||||
|
self._note(pipe, mapping)
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -780,6 +816,7 @@ class RedisState(StateBackend):
|
|||||||
pipe.expire(history_key, self._ttl)
|
pipe.expire(history_key, self._ttl)
|
||||||
for key in counters:
|
for key in counters:
|
||||||
pipe.incr(self._key(key))
|
pipe.incr(self._key(key))
|
||||||
|
self._note(pipe, values)
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
|
|
||||||
def history(self, key: str) -> list[tuple[float, float]]:
|
def history(self, key: str) -> list[tuple[float, float]]:
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""What the bus costs the event loop, and that it still delivers everything."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from fluksio.flow.events import QUEUE_SIZE, EventBus
|
||||||
|
|
||||||
|
|
||||||
|
def _event(i: int) -> dict[str, object]:
|
||||||
|
return {"type": "node_executed", "node": f"n{i}", "outputs": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cascade_costs_one_loop_callback() -> None:
|
||||||
|
"""The whole point of coalescing.
|
||||||
|
|
||||||
|
A three-node cascade publishes 13-16 events and each used to cross to the
|
||||||
|
event loop on its own. They are one callback now — and still every event.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def run() -> tuple[int, int]:
|
||||||
|
bus = EventBus()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
bus.bind(loop)
|
||||||
|
calls = 0
|
||||||
|
real = loop.call_soon_threadsafe
|
||||||
|
|
||||||
|
def counting(*args: object, **kwargs: object) -> object:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return real(*args, **kwargs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe = counting # type: ignore[method-assign]
|
||||||
|
try:
|
||||||
|
async with bus.subscribe() as queue:
|
||||||
|
# From a worker thread, which is where nodes run.
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=lambda: [bus.publish(_event(i)) for i in range(16)]
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return calls, queue.qsize()
|
||||||
|
finally:
|
||||||
|
loop.call_soon_threadsafe = real # type: ignore[method-assign]
|
||||||
|
|
||||||
|
calls, delivered = asyncio.run(run())
|
||||||
|
assert calls == 1
|
||||||
|
assert delivered == 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_slow_subscriber_still_loses_its_oldest() -> None:
|
||||||
|
"""Coalescing must not turn the drop-oldest bound into unbounded growth."""
|
||||||
|
|
||||||
|
async def run() -> tuple[int, object]:
|
||||||
|
bus = EventBus()
|
||||||
|
bus.bind(asyncio.get_running_loop())
|
||||||
|
async with bus.subscribe() as queue:
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=lambda: [bus.publish(_event(i)) for i in range(QUEUE_SIZE + 50)]
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return queue.qsize(), (await queue.get())["node"]
|
||||||
|
|
||||||
|
size, oldest = asyncio.run(run())
|
||||||
|
assert size == QUEUE_SIZE
|
||||||
|
# The first fifty were dropped, not the last.
|
||||||
|
assert oldest == "n50"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_is_buffered_with_nobody_listening() -> None:
|
||||||
|
"""An engine with no browser open holds no events."""
|
||||||
|
|
||||||
|
async def run() -> int:
|
||||||
|
bus = EventBus()
|
||||||
|
bus.bind(asyncio.get_running_loop())
|
||||||
|
for i in range(10):
|
||||||
|
bus.publish(_event(i))
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return len(bus._pending)
|
||||||
|
|
||||||
|
assert asyncio.run(run()) == 0
|
||||||
@@ -4,11 +4,11 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from fluksio.flow import executor
|
from fluksio.flow import executor
|
||||||
|
from fluksio.flow import queue as queue_module
|
||||||
from fluksio.flow.executor import ExecutionService
|
from fluksio.flow.executor import ExecutionService
|
||||||
from fluksio.flow.messages import DType, MessageSpec
|
from fluksio.flow.messages import DType, MessageSpec
|
||||||
from fluksio.flow.nodes import Node
|
from fluksio.flow.nodes import Node
|
||||||
from fluksio.flow.pipeline import Pipeline
|
from fluksio.flow.pipeline import Pipeline
|
||||||
from fluksio.flow import queue as queue_module
|
|
||||||
from fluksio.flow.queue import MemoryWorkQueue, WorkItem
|
from fluksio.flow.queue import MemoryWorkQueue, WorkItem
|
||||||
from fluksio.flow.state import MemoryState
|
from fluksio.flow.state import MemoryState
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,7 @@ def build() -> tuple[Pipeline, Node, CountingState]:
|
|||||||
requires=[spec("a")],
|
requires=[spec("a")],
|
||||||
provides=[spec("b")],
|
provides=[spec("b")],
|
||||||
)
|
)
|
||||||
sink = make_node(
|
sink = make_node("sink", "chain", lambda b, params: None, requires=[spec("b")])
|
||||||
"sink", "chain", lambda b, params: None, requires=[spec("b")]
|
|
||||||
)
|
|
||||||
state = CountingState()
|
state = CountingState()
|
||||||
pipeline = Pipeline([source, relay, sink], state=state)
|
pipeline = Pipeline([source, relay, sink], state=state)
|
||||||
return pipeline, source, state
|
return pipeline, source, state
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ export type LiveStatus = {
|
|||||||
/** Why it is queued — what it is waiting for, and on which machine. */
|
/** Why it is queued — what it is waiting for, and on which machine. */
|
||||||
detail?: string | null
|
detail?: string | null
|
||||||
}
|
}
|
||||||
/** How a node's connection is doing, which is not how its last run went. */
|
|
||||||
export type NodeHealth = {
|
|
||||||
health: "ok" | "down" | "unknown"
|
|
||||||
detail?: string | null
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* A node's last failure, kept after it has run again.
|
* A node's last failure, kept after it has run again.
|
||||||
*
|
*
|
||||||
@@ -72,7 +67,6 @@ const ENGINE_EVENT_LIMIT = 100
|
|||||||
const values = new Map<string, LiveValue>()
|
const values = new Map<string, LiveValue>()
|
||||||
const statuses = new Map<string, LiveStatus>()
|
const statuses = new Map<string, LiveStatus>()
|
||||||
const failures = new Map<string, NodeFailure>()
|
const failures = new Map<string, NodeFailure>()
|
||||||
const health = new Map<string, NodeHealth>()
|
|
||||||
let engineEvents: EngineEvent[] = []
|
let engineEvents: EngineEvent[] = []
|
||||||
// How many times this page has seen a node emit. The number itself means
|
// How many times this page has seen a node emit. The number itself means
|
||||||
// nothing; a change is what restarts the pulse.
|
// nothing; a change is what restarts the pulse.
|
||||||
@@ -194,13 +188,6 @@ export const liveStore = {
|
|||||||
path: { name: flow, node_id: node },
|
path: { name: flow, node_id: node },
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
},
|
},
|
||||||
setHealth(nodeId: string, entry: NodeHealth) {
|
|
||||||
health.set(nodeId, entry)
|
|
||||||
notify(`health:${nodeId}`)
|
|
||||||
},
|
|
||||||
getHealth(nodeId: string) {
|
|
||||||
return health.get(nodeId)
|
|
||||||
},
|
|
||||||
recordEngineEvent(event: EngineEvent) {
|
recordEngineEvent(event: EngineEvent) {
|
||||||
// A new array each time, so the hook's snapshot comparison sees the change.
|
// A new array each time, so the hook's snapshot comparison sees the change.
|
||||||
engineEvents = [...engineEvents, event].slice(-ENGINE_EVENT_LIMIT)
|
engineEvents = [...engineEvents, event].slice(-ENGINE_EVENT_LIMIT)
|
||||||
@@ -271,8 +258,6 @@ export const liveStore = {
|
|||||||
notify(`emit:${key}`)
|
notify(`emit:${key}`)
|
||||||
emits.clear()
|
emits.clear()
|
||||||
priorEmits.clear()
|
priorEmits.clear()
|
||||||
for (const key of health.keys()) notify(`health:${key}`)
|
|
||||||
health.clear()
|
|
||||||
engineEvents = []
|
engineEvents = []
|
||||||
notify("engine")
|
notify("engine")
|
||||||
logLines = []
|
logLines = []
|
||||||
@@ -304,14 +289,6 @@ export function useNodeFailure(nodeId: string): NodeFailure | undefined {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How the node's connection is doing, once it has said anything about it. */
|
|
||||||
export function useNodeHealth(nodeId: string): NodeHealth | undefined {
|
|
||||||
return useSyncExternalStore(
|
|
||||||
(listener) => subscribeKey(`health:${nodeId}`, listener),
|
|
||||||
() => health.get(nodeId),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The last hundred things the engine said about itself, oldest first. */
|
/** The last hundred things the engine said about itself, oldest first. */
|
||||||
export function useEngineEvents(): EngineEvent[] {
|
export function useEngineEvents(): EngineEvent[] {
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect } from "react"
|
|||||||
|
|
||||||
import { OpenAPI } from "@/client"
|
import { OpenAPI } from "@/client"
|
||||||
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
||||||
|
import { healthKeys } from "@/components/Health/queries"
|
||||||
import { runKeys } from "@/components/Runs/queries"
|
import { runKeys } from "@/components/Runs/queries"
|
||||||
import { connectionStore } from "@/lib/connectionStore"
|
import { connectionStore } from "@/lib/connectionStore"
|
||||||
import { apiToken } from "@/lib/portal"
|
import { apiToken } from "@/lib/portal"
|
||||||
@@ -146,10 +147,16 @@ const authHandlers = new Set<() => void>()
|
|||||||
|
|
||||||
function schedule() {
|
function schedule() {
|
||||||
if (timer) return
|
if (timer) return
|
||||||
timer = setTimeout(() => {
|
// Jittered, because every client of an engine that restarted is counting
|
||||||
|
// the same backoff from the same moment: without it they all come back
|
||||||
|
// together, and keep coming back together.
|
||||||
|
timer = setTimeout(
|
||||||
|
() => {
|
||||||
timer = null
|
timer = null
|
||||||
connect()
|
connect()
|
||||||
}, retry)
|
},
|
||||||
|
retry * (0.5 + Math.random()),
|
||||||
|
)
|
||||||
retry = Math.min(retry * 2, RECONNECT_MAX)
|
retry = Math.min(retry * 2, RECONNECT_MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,12 +171,23 @@ function connect() {
|
|||||||
liveStore.setConnected(true)
|
liveStore.setConnected(true)
|
||||||
connectionStore.setSocketOpen(true)
|
connectionStore.setSocketOpen(true)
|
||||||
// Whatever happened while the socket was down was missed, so nothing
|
// Whatever happened while the socket was down was missed, so nothing
|
||||||
// held in cache can be trusted to still be current.
|
// held in cache can be trusted to still be current. Scoped to what this
|
||||||
client?.invalidateQueries()
|
// socket actually feeds: an unqualified invalidation refetches every
|
||||||
|
// query the page holds, and the usual reason the socket dropped is the
|
||||||
|
// engine restarting — so every open tab and every wall panel did that at
|
||||||
|
// once, at the moment it was least able to answer.
|
||||||
|
for (const queryKey of [
|
||||||
|
flowKeys.all,
|
||||||
|
dashboardKeys.all,
|
||||||
|
panelKeys.all,
|
||||||
|
runKeys.all,
|
||||||
|
healthKeys.all,
|
||||||
|
]) {
|
||||||
|
client?.invalidateQueries({ queryKey })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
const handle = (message: FlowEvent) => {
|
||||||
const message: FlowEvent = JSON.parse(event.data)
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "snapshot":
|
case "snapshot":
|
||||||
liveStore.setValues(message.values)
|
liveStore.setValues(message.values)
|
||||||
@@ -216,9 +234,12 @@ function connect() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "node_health":
|
case "node_health":
|
||||||
liveStore.setHealth(message.node, {
|
// The canvas draws node health from the flow detail's `issues`, which
|
||||||
health: message.health,
|
// the server derives — so the screen only moved on mount, navigation
|
||||||
detail: message.detail,
|
// or a rebuild, never when health actually flipped. The store had a
|
||||||
|
// health map of its own and nothing ever read it.
|
||||||
|
client?.invalidateQueries({
|
||||||
|
queryKey: message.flow ? flowKeys.detail(message.flow) : flowKeys.all,
|
||||||
})
|
})
|
||||||
if (message.health === "down") {
|
if (message.health === "down") {
|
||||||
liveStore.recordEngineEvent({
|
liveStore.recordEngineEvent({
|
||||||
@@ -292,6 +313,25 @@ function connect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
// A frame that is not JSON, or one this bundle cannot read, costs the
|
||||||
|
// frame rather than the connection: an exception thrown here escapes into
|
||||||
|
// `window.onerror` and leaves whatever it had already applied behind.
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
if (payload?.type === "batch") {
|
||||||
|
// A cascade publishes a dozen events at once and the engine coalesces
|
||||||
|
// them into one frame. An installation older than this bundle sends
|
||||||
|
// them one at a time, which is the branch below.
|
||||||
|
for (const message of payload.events ?? []) handle(message)
|
||||||
|
} else {
|
||||||
|
handle(payload)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Dropped an unreadable socket frame", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
// A socket we already dropped: its close says nothing about the connection
|
// A socket we already dropped: its close says nothing about the connection
|
||||||
// we want now.
|
// we want now.
|
||||||
|
|||||||
Reference in New Issue
Block a user