A rate limit now thins the work, not only the messages

The limit was applied in `apply_outputs`, which the executor reaches after the
item is off the queue — so a subscriber told to publish every 15s still cost a
queue entry, a `cascade_started`, a run record and a walk of everything
reachable from it per inbound message. Seven relay nodes behind one inverter
ran 192 times a minute to publish six.

Two halves, matching the two shapes it takes:

`trigger()` now keeps a value whose every port is inside its window and
journals nothing at all. The window split came out of `_throttled` as a
read-only `_window_split`, so the question is asked the same way in both
places and the exact split is still made once, at claim time.

A cascade carries the names it actually published, and the wave runs only the
nodes something in that set feeds. A node whose triggering inputs were all
held back is completed without running, which frees its own consumers to be
judged the same way — the case where a node re-published 619 messages a minute
off inputs that changed six times. Redeliveries and emissions carry no such
set and still walk everything, since one has a half-finished wave to finish
and the other is the value already being in state.

Skipping a node can make one ready that the scheduling pass has already walked
past, so `submit_ready` runs to a fixpoint. That also closes the same latent
hole on the replay path, where a done-marker skip could strand a join with no
future outstanding to come back for it.

Measured with the new `scripts/bench_engine.py`, 500 messages through the
house's shape: a limited source went from 500 cascades / 3500 node runs /
5009 events to 1 / 7 / 19, publishing the same 8 values; an unlimited source
into limited relays took the node reading them from 500 runs to 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
This commit is contained in:
2026-08-26 09:55:56 +02:00
co-authored by Claude Opus 5
parent 5726c80948
commit a9136c7811
4 changed files with 350 additions and 58 deletions
+100
View File
@@ -148,6 +148,106 @@ def test_a_limited_input_wakes_its_node_when_the_window_ends():
assert seen == [20.0, 21.0]
def test_an_inject_inside_the_window_costs_no_cascade():
"""The limit used to thin the messages and not the work.
It was applied after the item came off the queue, so a subscriber told to
publish every 15s still cost a queue entry, a run record and a walk of
everything downstream for every message the broker sent.
"""
source = make_node(
"source",
lambda params: None,
provides=[spec("temp", interval=WINDOW)],
)
pipeline, queue, service = _running([source])
source.inject({"temp": 20.0})
for item in queue.claim(10, 10):
service._run_item(item)
assert pipeline.state["demo.temp"] == 20.0
# Inside the window: held where it is, with nothing journaled for it.
source.inject({"temp": 21.0})
assert queue.claim(10, 10) == []
# And the timer still lets it out at the end of the window.
time.sleep(WINDOW * 2)
assert _run_due(queue, service) == 1
assert pipeline.state["demo.temp"] == 21.0
def test_a_node_whose_inputs_did_not_change_is_not_run():
"""A cascade used to walk everything reachable, changed or not."""
seen: list[float] = []
source = make_node("source", lambda params: None, provides=[spec("raw")])
relay = make_node(
"relay",
lambda raw, params: {"level": raw},
requires=[spec("raw")],
provides=[spec("level", interval=WINDOW)],
)
watcher = make_node(
"watcher",
lambda level, params: seen.append(level),
requires=[spec("level")],
)
_pipeline, queue, service = _running([source, relay, watcher])
def deliver(value: float) -> None:
source.inject({"raw": value})
for item in queue.claim(10, 10):
service._run_item(item)
deliver(1.0)
assert seen == [1.0]
# The relay runs — its own input did change — but publishes nothing, so
# the watcher is left on the value it already has.
deliver(2.0)
assert seen == [1.0]
# The held value reaches it when the window ends.
time.sleep(WINDOW * 2)
_run_due(queue, service)
assert seen == [1.0, 2.0]
def test_a_join_behind_a_skipped_branch_still_runs():
"""Skipping a node frees its consumers, which may already have been passed."""
seen: list[tuple[float, float]] = []
source = make_node(
"source",
lambda params: None,
provides=[spec("fast"), spec("slow", interval=WINDOW)],
)
middle = make_node(
"middle",
lambda slow, params: {"derived": slow},
requires=[spec("slow")],
provides=[spec("derived")],
)
join = make_node(
"join",
lambda fast, derived, params: seen.append((fast, derived)),
requires=[spec("fast"), spec("derived")],
)
_pipeline, queue, service = _running([source, middle, join])
def deliver(value: float) -> None:
source.inject({"fast": value, "slow": value})
for item in queue.claim(10, 10):
service._run_item(item)
deliver(1.0)
assert seen == [(1.0, 1.0)]
# `slow` is held this time, so `middle` is skipped — and `join` still has
# to run, because `fast` did change.
deliver(2.0)
assert seen == [(1.0, 1.0), (2.0, 1.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")])