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
+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.