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
+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()