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