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
125 lines
4.0 KiB
Python
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)}"
|