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:
@@ -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")])
|
||||
|
||||
Reference in New Issue
Block a user