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