Files
app/backend/tests/flow/test_round_trips.py
T
stroblme 75b3a93094
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m37s
Playwright Tests / test-playwright (2, 2) (push) Failing after 27s
pre-commit / pre-commit (push) Failing after 2m37s
Test Backend / test-backend (3.12) (push) Successful in 5m7s
Test Backend / test-backend (3.14) (push) Successful in 5m2s
Compose Smoke Test / test-compose (push) Failing after 28s
Playwright Tests / merge-reports (push) Failing after 2m29s
Fix work recovery and reduce engine and chart overhead
2026-10-29 14:24:17 +01:00

146 lines
4.7 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)}"
def test_reading_one_value_does_not_list_or_read_other_messages(monkeypatch) -> None:
state = MemoryState()
state.update({f"f.m{i}": i for i in range(1000)})
state.update({"f.m0": None, "__ts__:f.m0": 123.0})
pipeline = Pipeline([], state=state)
reads = []
get_multi = state.get_multi
def read(keys):
reads.append(keys)
return get_multi(keys)
def no_listing():
raise AssertionError("A single-message read must not enumerate state")
monkeypatch.setattr(MemoryState, "get_multi", lambda self, keys: read(keys))
monkeypatch.setattr(MemoryState, "message_names", lambda self: no_listing())
assert pipeline.value("f.m0") == {"value": None, "ts": 123.0}
assert reads == [["f.m0", "__ts__:f.m0"]]