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:
2026-08-16 16:44:17 +02:00
co-authored by Claude Fable 5
parent 0b2d8e587a
commit 93b4a9a0b1
8 changed files with 354 additions and 20 deletions
+66
View File
@@ -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")])