Step a paused flow, and deliver what a rate limit held back
Three things a pause and an interval were quietly losing:
- A rebuild builds a fresh pipeline, so nothing is paused any more and no
resume ever comes for what the old one parked. Release it on rebuild.
- POST /flows/{name}/step takes the oldest parked item and runs that one wave
while the flow stays paused, so a held-back cascade can be walked through.
Nothing parked answers plainly rather than failing.
- A per-port interval was leading-edge only: a producer going quiet inside the
window left the consumer on the value before it. The held value is kept and
a flush item scheduled on the queue's existing timer, so the window ends with
a delivery. One timer in flight per node, and none without a queue to run it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
"""Per-port intervals: deliver at most every x seconds."""
|
||||
|
||||
import time
|
||||
|
||||
from app.flow.executor import ExecutionService
|
||||
from app.flow.messages import DType, MessageSpec
|
||||
from app.flow.nodes import Node
|
||||
from app.flow.pipeline import Pipeline
|
||||
from app.flow.queue import MemoryWorkQueue
|
||||
|
||||
# Short enough to wait out in a test, long enough not to race the engine.
|
||||
WINDOW = 0.05
|
||||
|
||||
|
||||
def spec(name: str, interval: float = 0) -> MessageSpec:
|
||||
@@ -82,6 +89,65 @@ def test_an_unthrottled_input_still_wakes_a_node_beside_a_throttled_one():
|
||||
assert [quick for quick, _ in seen] == [1.0, 2.0]
|
||||
|
||||
|
||||
def _running(nodes: list[Node]) -> tuple[Pipeline, MemoryWorkQueue, ExecutionService]:
|
||||
"""A pipeline with the timer the engine uses for held-back values."""
|
||||
queue = MemoryWorkQueue()
|
||||
pipeline = Pipeline(nodes=nodes, work_queue=queue)
|
||||
service = ExecutionService(queue)
|
||||
service.bind(pipeline)
|
||||
return pipeline, queue, service
|
||||
|
||||
|
||||
def _run_due(queue: MemoryWorkQueue, service: ExecutionService) -> int:
|
||||
"""What the engine's timer thread does once the window has passed."""
|
||||
moved = queue.move_due(time.time())
|
||||
for item in queue.claim(10, 10):
|
||||
service._run_item(item)
|
||||
return moved
|
||||
|
||||
|
||||
def test_a_limited_output_publishes_its_last_value_when_the_window_ends():
|
||||
"""A producer going quiet must not strand the reading it held back."""
|
||||
readings = iter([1.0, 2.0])
|
||||
source = make_node(
|
||||
"source",
|
||||
lambda params: {"temp": next(readings)},
|
||||
provides=[spec("temp", interval=WINDOW)],
|
||||
)
|
||||
pipeline, queue, service = _running([source])
|
||||
|
||||
pipeline.run({})
|
||||
pipeline.run({})
|
||||
# Inside the window, so the second reading is held rather than published.
|
||||
assert pipeline.state["demo.temp"] == 1.0
|
||||
|
||||
time.sleep(WINDOW * 2)
|
||||
assert _run_due(queue, service) == 1
|
||||
|
||||
assert pipeline.state["demo.temp"] == 2.0
|
||||
|
||||
|
||||
def test_a_limited_input_wakes_its_node_when_the_window_ends():
|
||||
seen: list[float] = []
|
||||
source = make_node("source", lambda params: None, provides=[spec("temp")])
|
||||
consumer = make_node(
|
||||
"consumer",
|
||||
lambda temp, params: seen.append(temp),
|
||||
requires=[spec("temp", interval=WINDOW)],
|
||||
)
|
||||
_pipeline, queue, service = _running([source, consumer])
|
||||
|
||||
source.inject({"temp": 20.0}, durable=False)
|
||||
source.inject({"temp": 21.0}, durable=False)
|
||||
assert seen == [20.0]
|
||||
|
||||
time.sleep(WINDOW * 2)
|
||||
assert _run_due(queue, service) == 1
|
||||
|
||||
# The value that arrived inside the window is delivered at the end of it.
|
||||
assert seen == [20.0, 21.0]
|
||||
|
||||
|
||||
def test_a_manual_run_is_never_throttled_on_its_inputs():
|
||||
seen: list[float] = []
|
||||
source = make_node("source", lambda params: {"temp": 20.0}, provides=[spec("temp")])
|
||||
|
||||
@@ -127,6 +127,36 @@ def test_work_for_a_paused_flow_is_held_and_released_on_resume():
|
||||
assert seen == [1.0]
|
||||
|
||||
|
||||
def test_a_step_runs_one_held_item_and_leaves_the_flow_paused():
|
||||
pipeline, source, _state, seen = _pipeline_with_a_consumer()
|
||||
service = ExecutionService(pipeline._queue)
|
||||
service.bind(pipeline)
|
||||
|
||||
pipeline.pause("f")
|
||||
source.inject({"reading": 1.0})
|
||||
source.inject({"reading": 2.0})
|
||||
for item in pipeline._queue.claim(10, 10):
|
||||
service._run_item(item)
|
||||
assert seen == []
|
||||
|
||||
assert service.step("f") == "f.source"
|
||||
|
||||
assert seen == [1.0]
|
||||
# Still paused, and the second value is still waiting for the next step.
|
||||
assert pipeline.is_paused("f")
|
||||
assert [i.outputs for i in pipeline._queue.unpark("f")] == [{"f.reading": 2.0}]
|
||||
|
||||
|
||||
def test_stepping_a_flow_with_nothing_held_says_so_rather_than_failing():
|
||||
pipeline, _source, _state, _seen = _pipeline_with_a_consumer()
|
||||
service = ExecutionService(pipeline._queue)
|
||||
service.bind(pipeline)
|
||||
|
||||
pipeline.pause("f")
|
||||
|
||||
assert service.step("f") is None
|
||||
|
||||
|
||||
def test_work_for_a_stopped_flow_is_dropped():
|
||||
pipeline, source, state, seen = _pipeline_with_a_consumer()
|
||||
stopped = Pipeline(
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""Stopping a flow takes it off the engine; pausing holds its nodes."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.routes.flows import router
|
||||
from app.flow.messages import DType, MessageSpec
|
||||
from app.flow.nodes import Node
|
||||
from app.flow.pipeline import Pipeline
|
||||
from app.flow.schemas import FlowDef
|
||||
from app.flow.store import FlowStore
|
||||
|
||||
|
||||
@@ -101,3 +108,60 @@ def test_stopped_survives_a_restart(tmp_path: Path):
|
||||
|
||||
# A second store over the same directory is what a restart looks like.
|
||||
assert FlowStore(tmp_path / "flows").read_enabled("heating") is False
|
||||
|
||||
|
||||
class _StubController:
|
||||
"""Only what the step route touches: the store, and the step itself."""
|
||||
|
||||
def __init__(self, store: FlowStore, stepped: str | None) -> None:
|
||||
self.store = store
|
||||
self.stepped = stepped
|
||||
self.calls: list[str] = []
|
||||
|
||||
def step_flow(self, flow: str) -> str | None:
|
||||
self.calls.append(flow)
|
||||
return self.stepped
|
||||
|
||||
|
||||
def _stub(tmp_path: Path, stepped: str | None) -> _StubController:
|
||||
"""A controller over a store holding one flow, called 'heating'."""
|
||||
store = FlowStore(tmp_path / "flows")
|
||||
store.write_flow(FlowDef(name="heating"))
|
||||
return _StubController(store, stepped)
|
||||
|
||||
|
||||
def _client(controller: Any) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
app.state.flow_controller = controller
|
||||
app.dependency_overrides[get_current_user] = lambda: None
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_the_step_endpoint_reports_what_it_ran(tmp_path: Path):
|
||||
controller = _stub(tmp_path, "heating.sensor")
|
||||
|
||||
response = _client(controller).post("/api/v1/flows/heating/step")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Stepped 'heating.sensor'"}
|
||||
assert controller.calls == ["heating"]
|
||||
|
||||
|
||||
def test_stepping_a_flow_with_nothing_parked_answers_plainly(tmp_path: Path):
|
||||
"""A button pressed once too often is not an error."""
|
||||
controller = _stub(tmp_path, None)
|
||||
|
||||
response = _client(controller).post("/api/v1/flows/heating/step")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Nothing held back in flow 'heating'"}
|
||||
|
||||
|
||||
def test_stepping_an_unknown_flow_is_a_404(tmp_path: Path):
|
||||
controller = _stub(tmp_path, None)
|
||||
|
||||
response = _client(controller).post("/api/v1/flows/nosuchflow/step")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert controller.calls == []
|
||||
|
||||
Reference in New Issue
Block a user