Let a pipeline swap one flow's nodes instead of being rebuilt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user