Stop and start a flow without rebuilding anything

A stopped flow's nodes are built like any other flow's — being stopped
means having no subscriptions, schedules or webhooks, not being absent —
so a toggle only ever needed the lifecycle call and the gate that goes
with it. It was doing a whole-pipeline rebuild instead, which on a
populated installation is every node in every flow reconnecting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
This commit is contained in:
2026-08-23 18:35:28 +02:00
co-authored by Claude Opus 5
parent c95d0ca8f5
commit 56396732a4
3 changed files with 80 additions and 3 deletions
+4 -1
View File
@@ -13,7 +13,10 @@ Deferring because out of scope is fine, but don't mention deferring than.
### Dashboard UI rework (dedicated session) ### Dashboard UI rework (dedicated session)
Rework the dashboard UI to make it more flexible in terms of the look and feel. Rework the dashboard UI to make it more flexible in terms of the look and feel.
I consider following design options a) overall appearance: material look or liquid glass (einUI https://github.com/einui/einui) b) color palette I consider following design options a) overall appearance: material look or liquid glass (einUI https://github.com/einui/einui) b) color palette (https://plotly.com/python/discrete-color/).
Based on a) and b) it should be possible to derive almost endles combinations of looks.
The color palette should affect charts/gauges and other widgets
### To be sorted ### To be sorted
+41 -2
View File
@@ -426,9 +426,48 @@ class FlowController:
entry.last_error_ts = None entry.last_error_ts = None
async def set_enabled(self, flow: str, enabled: bool) -> None: async def set_enabled(self, flow: str, enabled: bool) -> None:
"""Stop or start one flow. Rebuilding is what applies it.""" """Stop or start one flow, without building anything.
Its nodes are built whether or not it runs — being stopped means
having no subscriptions, schedules or webhooks, not being absent — so
all this has to do is the lifecycle call and the gate that goes with
it. Nothing is compiled and no other flow is touched.
"""
await run_in_threadpool(self.store.write_enabled, flow, enabled) await run_in_threadpool(self.store.write_enabled, flow, enabled)
await self.reload() if self.pipeline is None:
# Nothing built yet, so there are no nodes to start or stop.
await self.reload()
return
await self._acquire(FLOW_REBUILD_WAIT)
try:
if enabled:
# The gate first: a subscription that fires the instant it
# starts must not be turned away by a flag on its way out.
self.disabled = self.disabled - {flow}
self.pipeline.set_disabled(self.disabled)
# Lifts any quarantine, which is what a rebuild used to do by
# throwing the whole supervisor away.
await self.supervisor.cancel_flow(flow)
await self._activate(flow)
else:
self.disabled = self.disabled | {flow}
self.pipeline.set_disabled(self.disabled)
await self._teardown(flow)
finally:
self._lock.release()
self._publish(
{
"type": "pipeline_rebuilt",
"issues": [issue.model_dump() for issue in self.issues],
"nodes": [status.model_dump() for status in self.node_statuses()],
# Every flow's, not this one's: the canvas replaces its paused
# set from this, so a narrowed list would clear the markers of
# flows this never touched.
"paused": self.paused_flows(),
}
)
async def _acquire(self, wait: float | None) -> None: async def _acquire(self, wait: float | None) -> None:
"""Take the rebuild lock, or say the engine is busy. """Take the rebuild lock, or say the engine is busy.
@@ -297,3 +297,38 @@ def test_a_deleted_flow_leaves_its_consumers_reporting_a_missing_input(
assert engine.pipeline.get_node_by_id("a.meter") is None assert engine.pipeline.get_node_by_id("a.meter") is None
asyncio.run(asyncio.wait_for(scenario(), timeout=10)) asyncio.run(asyncio.wait_for(scenario(), timeout=10))
def test_stopping_one_flow_does_not_touch_the_others(
tmp_path: Path, lifecycle: list[str]
):
"""A toggle is a lifecycle call, not a rebuild.
A stopped flow's nodes are still built — being stopped means having no
subscriptions, not being absent — so nothing is compiled and no other
flow's node is asked to reconnect. That is where the seconds went.
"""
store = FlowStore(tmp_path / "flows")
for name in ("a", "b"):
store.write_flow(FlowDef(name=name, nodes=[NodeDef(id="io", type="lifecycle")]))
engine = FlowController(store)
async def scenario() -> None:
await engine.reload()
before = {name: engine.loaded[name].node for name in ("a.io", "b.io")}
lifecycle.clear()
await engine.set_enabled("a", False)
assert lifecycle == ["stop a.io"]
# Still built, still the same objects: only the lifecycle changed.
assert {name: engine.loaded[name].node for name in ("a.io", "b.io")} == before
assert engine.disabled == {"a"}
lifecycle.clear()
await engine.set_enabled("a", True)
assert lifecycle == ["start a.io"]
assert {name: engine.loaded[name].node for name in ("a.io", "b.io")} == before
asyncio.run(asyncio.wait_for(scenario(), timeout=10))