Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
222 lines
7.0 KiB
Python
222 lines
7.0 KiB
Python
"""Swapping one flow's nodes into a live graph, leaving the rest connected."""
|
|
|
|
import threading
|
|
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes import Node
|
|
from fluksio.flow.pipeline import Pipeline
|
|
from tests.flow.test_pipeline import make_node, spec
|
|
|
|
|
|
def _shape(pipeline: Pipeline) -> tuple[dict[str, list[str]], dict[str, set[str]]]:
|
|
"""The wiring by id, so two pipelines built differently can be compared."""
|
|
return (
|
|
{msg: [n.id for n in nodes] for msg, nodes in pipeline.produces.items()},
|
|
{
|
|
node.id: {dep.id for dep in deps}
|
|
for node, deps in pipeline.dependencies.items()
|
|
},
|
|
)
|
|
|
|
|
|
def test_replacing_a_flow_leaves_the_other_flows_nodes_alone():
|
|
b_producer = make_node(
|
|
"meter", "b", lambda params: {"power": 1.0}, provides=[spec("power")]
|
|
)
|
|
b_consumer = make_node(
|
|
"load", "b", lambda power, params: None, requires=[spec("power")]
|
|
)
|
|
a_old = make_node("calc", "a", lambda params: None)
|
|
pipeline = Pipeline(nodes=[a_old, b_producer, b_consumer])
|
|
|
|
pipeline.replace_flow("a", [make_node("calc", "a", lambda params: None)])
|
|
|
|
assert pipeline.get_node_by_id("b.meter") is b_producer
|
|
assert pipeline.get_node_by_id("b.load") is b_consumer
|
|
assert pipeline.dependencies[b_consumer] == frozenset({b_producer})
|
|
|
|
|
|
def test_a_consumer_in_another_flow_picks_up_the_new_producer():
|
|
seen: list[float] = []
|
|
consumer = make_node(
|
|
"load",
|
|
"b",
|
|
lambda temp, params: seen.append(temp),
|
|
requires=[spec("a.temp")],
|
|
)
|
|
pipeline = Pipeline(
|
|
nodes=[
|
|
make_node(
|
|
"old", "a", lambda params: {"temp": 1.0}, provides=[spec("temp")]
|
|
),
|
|
consumer,
|
|
]
|
|
)
|
|
|
|
replacement = make_node(
|
|
"new", "a", lambda params: {"temp": 2.0}, provides=[spec("temp")]
|
|
)
|
|
pipeline.replace_flow("a", [replacement])
|
|
|
|
assert pipeline.dependencies[consumer] == frozenset({replacement})
|
|
replacement.inject()
|
|
assert seen == [2.0]
|
|
|
|
|
|
def test_a_consumer_loses_its_edge_when_the_producer_flow_stops_providing():
|
|
"""The crux: the consumer becomes a root, not an orphan nothing can reach."""
|
|
consumer = make_node(
|
|
"load", "b", lambda temp, params: None, requires=[spec("a.temp")]
|
|
)
|
|
pipeline = Pipeline(
|
|
nodes=[
|
|
make_node(
|
|
"meter", "a", lambda params: {"temp": 1.0}, provides=[spec("temp")]
|
|
),
|
|
consumer,
|
|
]
|
|
)
|
|
|
|
pipeline.replace_flow("a", [make_node("quiet", "a", lambda params: None)])
|
|
|
|
assert "a.temp" not in pipeline.produces
|
|
assert pipeline.dependencies[consumer] == frozenset()
|
|
assert consumer in pipeline._topological_sort()
|
|
|
|
|
|
def test_removing_a_flow_takes_its_nodes_out():
|
|
ran: list[str] = []
|
|
a_node = make_node(
|
|
"meter",
|
|
"a",
|
|
lambda params: ran.append("a") or {"temp": 1.0},
|
|
provides=[spec("temp")],
|
|
)
|
|
b_node = make_node(
|
|
"beat",
|
|
"b",
|
|
lambda params: ran.append("b") or {"tick": 1.0},
|
|
provides=[spec("tick")],
|
|
)
|
|
pipeline = Pipeline(nodes=[a_node, b_node])
|
|
|
|
pipeline.remove_flow("a")
|
|
|
|
assert pipeline.flow_nodes("a") == set()
|
|
assert pipeline.get_node_by_id("a.meter") is None
|
|
assert a_node not in pipeline.dependencies
|
|
b_node.inject()
|
|
assert ran == ["b"]
|
|
|
|
# A caller still holding the removed node — a wave that picked its targets
|
|
# before the removal — runs nothing rather than raising or reviving it.
|
|
pipeline.run({}, nodes={a_node})
|
|
assert ran == ["b"]
|
|
|
|
|
|
def test_a_replace_leaves_the_graph_a_rebuild_would_have_built():
|
|
"""A replace and a fresh build over the same nodes must agree, edge for edge."""
|
|
|
|
def flow_a(tag: str) -> list[Node]:
|
|
# Reads a message flow b owns, writes one flow b reads, and shares a
|
|
# third with a producer in flow b — so the comparison covers edges in
|
|
# both directions across the boundary, and the order of a fan-in whose
|
|
# producers sit on either side of it.
|
|
return [
|
|
make_node(
|
|
"meter",
|
|
"a",
|
|
lambda power, params: {"temp": 1.0},
|
|
requires=[spec("b.power")],
|
|
provides=[spec("temp"), MessageSpec(name=tag, dtype=DType.FLOAT)],
|
|
),
|
|
make_node(
|
|
"inner",
|
|
"a",
|
|
lambda temp, params: None,
|
|
requires=[spec("temp")],
|
|
),
|
|
make_node(
|
|
"mirror",
|
|
"a",
|
|
lambda params: {"power": 1.0},
|
|
provides=[MessageSpec(name="b.power", dtype=DType.FLOAT)],
|
|
),
|
|
]
|
|
|
|
def flow_b() -> list[Node]:
|
|
return [
|
|
make_node(
|
|
"supply", "b", lambda params: {"power": 1.0}, provides=[spec("power")]
|
|
),
|
|
make_node(
|
|
"load", "b", lambda temp, params: None, requires=[spec("a.temp")]
|
|
),
|
|
]
|
|
|
|
# Both flows write it, so the order the two producers come out in is the
|
|
# node list's, and a replace that appends rather than splices gets it wrong.
|
|
b_nodes = flow_b()
|
|
replaced = Pipeline(nodes=flow_a("old") + b_nodes)
|
|
replaced.replace_flow("a", flow_a("new"))
|
|
|
|
fresh = Pipeline(nodes=flow_a("new") + flow_b())
|
|
|
|
assert _shape(replaced) == _shape(fresh)
|
|
assert [n.id for n in replaced._topological_sort()] == [
|
|
n.id for n in fresh._topological_sort()
|
|
]
|
|
|
|
|
|
def test_a_cascade_running_during_a_replace_finishes_on_the_graph_it_started_with():
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
ran: list[str] = []
|
|
|
|
def slow(params):
|
|
started.set()
|
|
release.wait(5)
|
|
return {"temp": 1.0}
|
|
|
|
source = make_node("source", "a", slow, provides=[spec("temp")])
|
|
consumer = make_node(
|
|
"load", "a", lambda temp, params: ran.append("load"), requires=[spec("temp")]
|
|
)
|
|
pipeline = Pipeline(nodes=[source, consumer])
|
|
|
|
failure: list[BaseException] = []
|
|
|
|
def wave() -> None:
|
|
try:
|
|
pipeline.run({})
|
|
except BaseException as exc: # noqa: BLE001 - reported, not swallowed
|
|
failure.append(exc)
|
|
|
|
thread = threading.Thread(target=wave)
|
|
thread.start()
|
|
# The replace lands while the wave is inside the executor holding the old
|
|
# maps, which is the only moment the swap can be seen half-applied.
|
|
assert started.wait(5)
|
|
pipeline.replace_flow("a", [make_node("source", "a", lambda params: None)])
|
|
release.set()
|
|
thread.join(10)
|
|
|
|
assert not thread.is_alive()
|
|
assert failure == []
|
|
assert ran == ["load"]
|
|
|
|
|
|
def test_replacing_a_flow_clears_its_pause_and_leaves_another_flows():
|
|
pipeline = Pipeline(
|
|
nodes=[
|
|
make_node("one", "a", lambda params: None),
|
|
make_node("two", "b", lambda params: None),
|
|
]
|
|
)
|
|
pipeline.pause("a")
|
|
pipeline.pause("b")
|
|
|
|
pipeline.replace_flow("a", [make_node("one", "a", lambda params: None)])
|
|
|
|
assert pipeline.paused_flows() == ["b"]
|