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
+9 -2
View File
@@ -387,10 +387,17 @@ class ExecutionService:
}
)
replay = item.deliveries > 1
try:
pipeline.apply_outputs(node, item.outputs or None)
published = pipeline.apply_outputs(node, item.outputs or None)
pipeline.run_downstream(
node, entry_id=item.entry_id, replay=item.deliveries > 1
node,
entry_id=item.entry_id,
replay=replay,
# A redelivery has to finish a walk that may be half done, and
# an item with no payload is the value already being in state.
# Neither can say what changed, so neither filters on it.
changed=None if replay or not item.outputs else published,
)
finally:
# Paired, or a cascade that raised — state backend gone, say — is a
+152 -47
View File
@@ -569,13 +569,14 @@ class Pipeline:
"""When a rate-limit window of this node is due to be let through."""
return f"__flush__:{node_name}"
def _throttled(self, node: Node, result: dict[str, Any]) -> dict[str, Any]:
"""Hold back the outputs whose port is not due to publish yet.
def _window_split(
self, node: Node, result: dict[str, Any], now: float
) -> tuple[dict[str, Any], dict[str, Any], float, float | None]:
"""What may publish now, what its window holds back, when it ends, and
whether a flush of this node is already booked.
The value is not lost: it is kept and published when the window ends,
so a producer that goes quiet still delivers its last reading rather
than leaving the consumer on the one before it. Nothing declaring an
interval means nothing to look up.
Reads state and writes nothing, so the same question can be asked when
a value arrives and again when the cascade carrying it is claimed.
"""
limited = {
name: spec.interval
@@ -583,9 +584,8 @@ class Pipeline:
if spec.interval > 0 and name in result
}
if not limited:
return result
return result, {}, 0.0, None
now = time.time()
flush_key = self._flush_key(node.id)
stamps = self._state.get_multi(
[self._timestamp_key(name) for name in limited] + [flush_key]
@@ -604,19 +604,32 @@ class Pipeline:
else:
held[name] = value
due_at = min(due_at or window_ends, window_ends)
return passed, held, due_at, stamps.get(flush_key)
def _throttled(self, node: Node, result: dict[str, Any]) -> dict[str, Any]:
"""Hold back the outputs whose port is not due to publish yet.
The value is not lost: it is kept and published when the window ends,
so a producer that goes quiet still delivers its last reading rather
than leaving the consumer on the one before it. Nothing declaring an
interval means nothing to look up.
"""
now = time.time()
passed, held, due_at, pending = self._window_split(node, result, now)
if self._queue is not None:
# Without a queue there is no timer to let the value out later, so
# holding it would only mean losing it more slowly.
for name in limited:
if name in passed:
for name in passed:
spec = node.provides.get(name)
if spec is not None and spec.interval > 0:
# A fresh publish makes anything held for that port stale.
self._state.delete(self._held_key(name))
if held:
self._state.update(
{self._held_key(name): value for name, value in held.items()}
)
self._schedule_flush(node, due_at, now, stamps.get(flush_key))
self._schedule_flush(node, due_at, now, pending)
return passed
def _schedule_flush(
@@ -652,10 +665,14 @@ class Pipeline:
}
if held:
# Publishing runs the limit again, which is what clears the hold —
# or puts the timer back, if this fired a hair early.
self.apply_outputs(node, held)
self.run_downstream(node)
# or puts the timer back, if this fired a hair early. In that case
# nothing came out and there is nothing downstream to wake.
published = self.apply_outputs(node, held)
if published:
self.run_downstream(node, changed=published)
if any(spec.interval > 0 for spec in node.requires.values()):
# A wake-up that was held back, not a value that just changed —
# so this one deliberately walks everything reachable.
self._execute_parallel(
{node, *self._get_downstream(node)}, self._state, check_ready=True
)
@@ -1134,8 +1151,15 @@ class Pipeline:
check_ready: bool = False,
entry_id: str = "",
replay: bool = False,
changed: set[str] | None = None,
) -> StateBackend:
"""Execute nodes concurrently, scheduling each as its inputs arrive."""
"""Execute nodes concurrently, scheduling each as its inputs arrive.
``changed`` restricts the wave to nodes something published to: one
whose triggering inputs are all untouched is completed without being
run, so what is downstream of *it* is judged on the same footing. None
runs everything the subset holds, which is what a manual run means.
"""
# One view of the graph for the whole wave: a flow replaced halfway
# through must not have this wave asking the new dependencies about a
# node the old edges knew.
@@ -1154,42 +1178,64 @@ class Pipeline:
n: sum(1 for dep in deps[n] if dep in target_nodes) for n in target_nodes
}
# Copied: the caller's set is not this wave's to grow.
fresh = set(changed) if changed is not None else None
submitted: set[Node] = set()
skipped: set[Node] = set()
node_futures: dict[Node, Future[dict[str, Any] | None]] = {}
def is_ready(n: Node) -> bool:
if in_degree[n] != 0:
return False
if check_ready:
return self._is_node_ready(n, state)
return True
def untouched(n: Node) -> bool:
"""Did this wave publish nothing this node waits on?"""
assert fresh is not None
return not any(
spec.trigger and name in fresh for name, spec in n.requires.items()
)
def complete(n: Node) -> None:
"""Count a node as done without running it, freeing its consumers."""
skipped.add(n)
for consumer in edges[n]:
if consumer in target_nodes:
in_degree[consumer] -= 1
def submit_ready(executor: ThreadPoolExecutor) -> None:
for n in target_nodes:
if n in submitted or n in skipped:
continue
# Gate before the readiness check, so a held-back node does not
# spend the synchronous claim it would need once it may run.
if self._gate_blocks(n):
continue
if is_ready(n):
submitted.add(n)
# To a fixpoint: completing a node without running it frees its
# consumers, and one this pass has already walked past would
# otherwise be stranded — there is no future outstanding to bring
# the drain loop back round for it.
progressed = True
while progressed:
progressed = False
for n in target_nodes:
if n in submitted or n in skipped:
continue
# Gate before the readiness check, so a held-back node does
# not spend the synchronous claim it needs once it may run.
if self._gate_blocks(n) or in_degree[n] != 0:
continue
if fresh is not None and untouched(n):
# Ahead of the readiness check, so an unchanged node
# spends neither a delivery stamp nor a synchronous
# claim on a run it is not going to make.
complete(n)
progressed = True
continue
if check_ready and not self._is_node_ready(n, state):
if n.synchronous:
# Not ready now; a later trigger may make it ready.
skipped.add(n)
continue
if replay and entry_id and self._already_done(entry_id, n):
# Its side effect happened on an earlier delivery; its
# outputs are still in state, so downstream carries on.
skipped.add(n)
submitted.discard(n)
for consumer in edges[n]:
if consumer in target_nodes:
in_degree[consumer] -= 1
complete(n)
progressed = True
continue
submitted.add(n)
node_futures[n] = executor.submit(
self._execute_node, n, state, entry_id
)
elif n.synchronous and in_degree[n] == 0:
# Not ready now; a later trigger may make it ready.
skipped.add(n)
def drain(executor: ThreadPoolExecutor) -> None:
submit_ready(executor)
@@ -1203,6 +1249,10 @@ class Pipeline:
# A node returning nothing (rate limiting, an error) stops
# propagation along its branch.
if result is not None:
# Before the decrement: a consumer freed by this node
# is judged on what it just published.
if fresh is not None:
fresh.update(result)
for consumer in edges[n]:
if consumer in target_nodes:
in_degree[consumer] -= 1
@@ -1229,11 +1279,14 @@ class Pipeline:
return self._execute_parallel(nodes, self._state, check_ready=False)
def apply_outputs(self, node: Node, outputs: dict[str, Any] | None) -> None:
def apply_outputs(self, node: Node, outputs: dict[str, Any] | None) -> set[str]:
"""Record what a node emitted: state, history, versions and events.
Shared by the direct path and by the execution service replaying a
journaled item, so a value looks the same on the canvas either way.
Returns the message names that actually reached state — post rate
limiting — which is what the cascade behind it has to walk from.
"""
if outputs:
# This is where a chatty subscriber gets thinned out, so a port set
@@ -1241,7 +1294,7 @@ class Pipeline:
outputs = self._throttled(node, outputs)
if not outputs:
return
return set()
state = self._state
ts = time.time()
@@ -1274,9 +1327,14 @@ class Pipeline:
"ts": ts,
}
)
return set(outputs)
def run_downstream(
self, node: Node, entry_id: str = "", replay: bool = False
self,
node: Node,
entry_id: str = "",
replay: bool = False,
changed: set[str] | None = None,
) -> StateBackend:
"""Run everything downstream of a node that has just published.
@@ -1284,7 +1342,17 @@ class Pipeline:
node with outside side effects can record that it ran. On a ``replay``
— the same item handed back after a crash — that record is checked
first: at-least-once delivery must not mean two of the same request.
``changed`` names the messages this wave actually published. A node
none of whose triggering inputs are in it would re-run on values it has
already read, which is how one node came to publish 619 messages a
minute off inputs that changed six times. ``None`` means walk
everything reachable, which is what a manual run wants.
"""
if changed is not None and not changed:
# Everything the cascade carried was held back by a rate limit, so
# there is nothing new for anything downstream to read.
return self._state
downstream = set(self._get_downstream(node))
if not downstream:
return self._state
@@ -1294,6 +1362,7 @@ class Pipeline:
check_ready=True,
entry_id=entry_id,
replay=replay,
changed=changed,
)
def trigger(
@@ -1309,9 +1378,13 @@ class Pipeline:
A stopped flow drops the event: its subscriptions and schedules are torn
down anyway, and anything still arriving from another thread would be
work the flow was explicitly told not to do. A *paused* flow still
publishes, so the incoming value is visible on the canvas, and holds
the nodes downstream of it.
work the flow was explicitly told not to do. A *paused* flow parks the
item, so nothing of it publishes until the flow is resumed or stepped.
A value whose every port is inside its rate-limit window is kept here
and never journaled at all: the limit is about how often the value
goes out, and enqueueing a cascade to discover that costs a run record
and a walk of everything downstream per message.
"""
state = self._state
@@ -1321,11 +1394,37 @@ class Pipeline:
if durable is None:
durable = self._queue is not None
if durable and self._queue is not None:
self._enqueue_cascade(node, outputs)
if not self._held_at_source(node, outputs):
self._enqueue_cascade(node, outputs)
return state
return self._run_here(node, outputs)
def _held_at_source(self, node: Node, outputs: dict[str, Any] | None) -> bool:
"""Keep a value back before it costs a cascade, or say it has to run.
Only when *every* port is inside its window: anything that would
publish leaves the exact split to ``apply_outputs`` at claim time,
which is the one place it has always been made.
"""
if not outputs:
# A cascade carrying nothing exists to walk, not to publish.
return False
try:
now = time.time()
passed, held, due_at, pending = self._window_split(node, outputs, now)
if passed or not held:
return False
self._state.update(
{self._held_key(name): value for name, value in held.items()}
)
self._schedule_flush(node, due_at, now, pending)
return True
except Exception:
# Journalling it is what this was avoiding, not what it depends on.
logger.exception("Could not hold '%s' back at the source", node.id)
return False
def _run_here(
self, node: Node, outputs: dict[str, Any] | None, cause: str = "manual"
) -> StateBackend:
@@ -1348,8 +1447,14 @@ class Pipeline:
}
)
try:
self.apply_outputs(node, outputs)
state = self.run_downstream(node, entry_id=run_id)
published = self.apply_outputs(node, outputs)
state = self.run_downstream(
node,
entry_id=run_id,
# No payload means the value is already in state and this is a
# wake-up, which has nothing to name as changed.
changed=published if outputs else None,
)
finally:
# Paired, or a cascade that raised leaves the run open until the
# abandoned sweep ten minutes later.
+89 -9
View File
@@ -14,6 +14,11 @@ Three scenarios, each answering a different question:
Reports cascades enqueued, node executions and bus events per injection —
the numbers, not the wall clock, are the point.
``relay``
The same, with the *source* unlimited: the cascade really is owed, and
what the relays hold back is what the node reading them should not be
woken for.
``throughput``
How many messages a second, and how late is the last node? A relay chain,
driven until the queue drains, reporting end-to-end latency percentiles.
@@ -170,7 +175,27 @@ class Engine:
def stop(self) -> None:
self.service.stop()
def drain(self, timeout: float = 30.0) -> bool:
def deliver(self, source: Node, outputs: dict[str, Any]) -> None:
"""Inject one value and run every cascade it owes, in this thread.
The counting scenarios drive the queue by hand rather than starting
the consumer: injecting flat out means five hundred messages land
before the first cascade writes the timestamp its window is measured
from, so every one of them looks due and the thing being counted never
happens. A house sends three readings a second into an engine that
finishes a cascade in four milliseconds, which is this.
"""
source.inject(outputs)
while True:
self.queue.move_due(0.0)
items = self.queue.claim(16, 1)
if not items:
return
for item in items:
self.service._run_item(item)
self.queue.ack(item)
def drain(self, timeout: float = 60.0) -> bool:
"""Wait for the queue to go quiet. False means it never did.
Delayed items are not waited for: a rate-limit flush is due a whole
@@ -224,13 +249,11 @@ def scenario_throttle(args: argparse.Namespace) -> dict[str, Any]:
nodes.append(relay(f"relay{i}", "raw", f"out{i}", args.window, counters))
engine = Engine(nodes, args.redis, counters)
engine.start()
try:
started = time.perf_counter()
for i in range(args.messages):
source.inject({"raw": float(i)})
injected = time.perf_counter() - started
drained = engine.drain()
engine.deliver(source, {"raw": float(i)})
elapsed = time.perf_counter() - started
finally:
engine.stop()
@@ -239,8 +262,7 @@ def scenario_throttle(args: argparse.Namespace) -> dict[str, Any]:
"messages": args.messages,
"relays": args.relays,
"window_s": args.window,
"drained": drained,
"inject_s": round(injected, 3),
"elapsed_s": round(elapsed, 3),
# The three numbers the bug is about.
"cascades_started": counters.by_event.get("cascade_started", 0),
"node_executions": counters.executions,
@@ -256,6 +278,62 @@ def scenario_throttle(args: argparse.Namespace) -> dict[str, Any]:
}
def scenario_relay(args: argparse.Namespace) -> dict[str, Any]:
"""An *unlimited* source into limited relays, and one node reading them all.
The other half of the house's shape, and the one holding it at the source
cannot help with: the readings really do arrive, so a cascade is owed —
but the relays hold their outputs back, and what reads them has nothing
new to read. That node published 619 messages a minute off inputs that
changed six times.
"""
counters = Counters()
source = node("source", lambda params: None, provides=[spec("raw")])
nodes = [source]
for i in range(args.relays):
nodes.append(relay(f"relay{i}", "raw", f"out{i}", args.window, counters))
ports = [f"out{i}" for i in range(args.relays)]
def watch(**kwargs: Any) -> dict[str, Any]:
counters.executions += 1
return {"total": sum(kwargs[p] for p in ports)}
nodes.append(
node(
"watch",
watch,
requires=[spec(p) for p in ports],
provides=[spec("total")],
)
)
engine = Engine(nodes, args.redis, counters)
try:
started = time.perf_counter()
for i in range(args.messages):
engine.deliver(source, {"raw": float(i)})
elapsed = time.perf_counter() - started
finally:
engine.stop()
return {
"scenario": "relay",
"messages": args.messages,
"relays": args.relays,
"window_s": args.window,
"elapsed_s": round(elapsed, 3),
"cascades_started": counters.by_event.get("cascade_started", 0),
"node_executions": counters.executions,
"bus_events": counters.events,
"published": counters.by_event.get("message_value", 0),
"per_message": {
"executions": round(counters.executions / args.messages, 3),
"events": round(counters.events / args.messages, 3),
},
}
def scenario_throughput(args: argparse.Namespace) -> dict[str, Any]:
"""A relay chain, driven flat out: messages a second, and how late the tail is."""
counters = Counters()
@@ -289,7 +367,7 @@ def scenario_throughput(args: argparse.Namespace) -> dict[str, Any]:
with lock:
starts[value] = time.perf_counter()
source.inject({"v0": value})
drained = engine.drain()
drained = engine.drain(args.drain_timeout)
elapsed = time.perf_counter() - started
finally:
engine.stop()
@@ -330,7 +408,7 @@ def scenario_fanout(args: argparse.Namespace) -> dict[str, Any]:
started = time.perf_counter()
for i in range(args.messages):
source.inject({"raw": float(i)})
drained = engine.drain()
drained = engine.drain(args.drain_timeout)
elapsed = time.perf_counter() - started
finally:
engine.stop()
@@ -349,6 +427,7 @@ def scenario_fanout(args: argparse.Namespace) -> dict[str, Any]:
SCENARIOS: dict[str, Callable[[argparse.Namespace], dict[str, Any]]] = {
"throttle": scenario_throttle,
"relay": scenario_relay,
"throughput": scenario_throughput,
"fanout": scenario_fanout,
}
@@ -391,6 +470,7 @@ def main() -> int:
metavar="HOST",
help="Run against a real Redis (namespaces bench/benchq) rather than memory.",
)
parser.add_argument("--drain-timeout", type=float, default=60.0)
parser.add_argument("--profile", action="store_true", help="cProfile, top 25.")
parser.add_argument("--json", action="store_true", help="Machine-readable output.")
args = parser.parse_args()
+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")])