Files
app/backend/tests/flow/test_round_trips.py
T
stroblmeandClaude Opus 5 1069247085 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
2026-08-29 20:08:50 +02:00

125 lines
4.0 KiB
Python

"""What one message costs the state backend, in operations.
The engine's time goes on round trips, not on Python: a profile against a
real Redis put its own code at 8.5% of self-time and Redis I/O at 61%. So the
thing worth a regression test is the *count* — a change that quietly turns one
pipelined write back into four would not fail any behavioural test and would
cost a fifth of the throughput.
Counted against the memory backend, because the number is a property of the
call sites rather than of the transport.
"""
from collections import Counter
from typing import Any
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import Pipeline
from fluksio.flow.state import MemoryState
COUNTED = (
"get",
"set",
"get_multi",
"get_present",
"update",
"record",
"append_history",
"increment_multi",
"delete",
)
class CountingState(MemoryState):
"""A state backend that remembers how often it was asked for something."""
# MemoryState uses __slots__; this one needs an attribute of its own.
__slots__ = ("calls",)
def __init__(self) -> None:
super().__init__()
self.calls: Counter[str] = Counter()
def __getattribute__(self, name: str) -> Any:
if name in COUNTED:
object.__getattribute__(self, "calls")[name] += 1
return object.__getattribute__(self, name)
def spec(name: str) -> MessageSpec:
return MessageSpec(name=name, dtype=DType.FLOAT)
def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node:
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
node.assign_flow(flow, node_id)
return node
def build() -> tuple[Pipeline, Node, CountingState]:
source = make_node(
"source", "chain", lambda params: {"a": 1.0}, provides=[spec("a")]
)
relay = make_node(
"relay",
"chain",
lambda a, params: {"b": a + 1},
requires=[spec("a")],
provides=[spec("b")],
)
sink = make_node("sink", "chain", lambda b, params: None, requires=[spec("b")])
state = CountingState()
pipeline = Pipeline([source, relay, sink], state=state)
return pipeline, source, state
def test_recording_a_value_is_one_write() -> None:
"""Value, timestamp, history and version counter go in one round trip.
They were four calls — `update`, `append_history`, `increment_multi` and a
`delete` per rate-limited port — and a value crossing an edge pays them
twice.
"""
pipeline, source, state = build()
state.calls.clear()
pipeline.apply_outputs(source, {"chain.a": 1.0})
assert state.calls["record"] == 1
for method in ("update", "append_history", "increment_multi"):
assert state.calls[method] == 0, f"{method} should be folded into record()"
def test_a_node_reads_its_inputs_once() -> None:
"""Readiness and execution share one read of the same keys.
The readiness check used to read the triggering inputs and throw the
values away, and the node then read the same keys again to run on them.
"""
pipeline, source, state = build()
state.calls.clear()
changed = pipeline.apply_outputs(source, {"chain.a": 1.0})
pipeline.run_downstream(source, changed=changed)
# Two nodes ran downstream (relay, sink); one bulk read each.
assert state.calls["get_present"] == 2
def test_a_hop_costs_a_bounded_number_of_state_operations() -> None:
"""The whole of one value crossing one edge, counted.
A ceiling rather than an exact number, so an unrelated change does not
fail it — but low enough that reintroducing a per-value read or a
split-up write does.
"""
pipeline, source, state = build()
state.calls.clear()
changed = pipeline.apply_outputs(source, {"chain.a": 1.0})
pipeline.run_downstream(source, changed=changed)
total = sum(state.calls[name] for name in COUNTED)
assert total <= 8, f"state operations per hop grew: {dict(state.calls)}"