Writing

Every Limit You Set Gets Multiplied by Fan-Out

Every Limit You Set Gets Multiplied by Fan-Out - abstract illustration

The tool loop that runs an AI job in my scheduler has a careful set of bounds: a maximum iteration count, a maximum total token spend, a per-call ceiling on tool result bytes, and a cumulative one. I picked those numbers deliberately and I'd have told you the job's cost was bounded. Every one of them is scoped to a single run, and a single event can start fifty runs.

Where the multiplication happens

An event-triggered job matches items - new mail, new feed entries - into buckets, and one of the dispatch modes runs the loop once per item. Fifty pending items means fifty independent runs, each with the full per-run allowance, from one trigger.

So the honest cost of one firing is up to fifty times the iteration cap in model requests, and fifty fully independent sets of tool side effects, each capable of everything the job's key is granted. Nothing summed them. Nothing looked at the batch as a unit at all.

The bounds weren't wrong. They bounded exactly what they said they bounded. The gap is that nobody, me included, reads "max iterations: 25" and thinks about the multiplier sitting in front of it.

What makes a fan-out gap dangerous rather than merely surprising is that it's quiet in normal operation. Most firings have one or two items. You watch it for weeks, the costs look like what the caps predict, and the shape only shows up when something unusual happens: a mail rule matching far more than expected, or a feed bucket catching up after a long outage. The first time you see the real cost is the time you least want to.

The correction I had to make to my own bug report

I filed this, wrote a fix, and got the scoping wrong on the first pass. Worth recording because the error is instructive.

I'd written that the fan-out happens inside a single dispatch function, so I scoped the budget to one call of it. It doesn't. The per-item mode splits a rule's items into one-item groups before that function is reached, so the multiplication happens across its callers, not inside any one of them. A budget scoped to a single call would have bounded nothing at all: each call would see one item and cheerfully spend its whole allowance.

The correct scope is the entire dispatch of one job firing, spanning every rule bucket and every group that one event touches. A single counter, created once per firing, shared by reference across every call underneath it.

The test that pins this is the one I'm most pleased with, because it's built so that the wrong scoping visibly disagrees rather than merely passing less thoroughly: two buckets of fifteen items each against a cap of twenty-five. Per-call scoping gives thirty runs and zero deferrals. Aggregate scoping gives twenty-five runs and five deferrals. There's no way to hold the wrong scope and pass it.

If a test can't distinguish the fix from the bug, it isn't a test for that bug. That sounds obvious written down and it is very easy to violate when you're testing a counter, because a counter that counts something looks like it works.

Exhaustion has to defer, not fail

This is the whole risk in the fix, and it took longer to get right than the counter did.

When the budget runs out, what happens to the items that didn't get to run? The tempting answer is to route them through the normal failure path. That's wrong in a way that compounds: the attempt counter increments before the job runs, so failing un-run items walks healthy rows toward the dead-letter queue for work that was never attempted. You'd build a cap that quietly destroys the backlog it was meant to protect.

The right answer is deferral, which in this codebase means leaving the row untouched. It doesn't get marked attempted, it doesn't get marked failed, and the next pass picks it up. The watched-folder dispatch planner already worked this way and said so in a comment, which is how I knew there was an established mechanism to match rather than a new one to invent.

The tests read the store directly rather than inferring: deferred items have an attempt count of zero, are not dead-lettered, and are picked up by the next pass. Reading it off the store matters because inferring "it wasn't attempted" from "it didn't run" would pass on the broken version too.

And the deferrals get logged. A cap that silently drops work looks identical to a cap that was never hit.

The number came from somewhere that already existed

The aggregate ceiling is twenty-five runs per event dispatch. I didn't derive that; I took it from the sibling constant that already bounded watched-folder fan-out.

That constant had deliberately not covered event dispatch, which is precisely the gap I was filling. Reusing the value was worth it for a reason that has nothing to do with the number being optimal: two limits on the same kind of thing that disagree by a factor of two invite someone to "fix" one of them later without knowing why they differed.

The part that's pre-existing and named as such

This shape isn't something the tool loop introduced. The non-tool-loop AI prompt and Claude prompt actions have the same per-item event dispatch, and fan-out has never had an aggregate ceiling on that path either. The tool loop just makes the per-firing cost dramatically larger, which is what pushed it over the line into a fix.

I filed the broader gap separately rather than folding it into this fix silently. The distinction between "this feature introduced a problem" and "this feature made an existing problem expensive" changes who should look at it and how urgently, and collapsing the two loses information I'll want later.

There's a related one I found the same week: a Claude prompt job's timeout gets silently capped at 600 seconds whenever the prompt spawns background agents. Same family of surprise. A limit exists, it's documented, and something in the layer underneath quietly imposes a different one. Every "the cap is X" claim is really "the cap is X, unless something below you has opinions."

What I'd go check

If you run AI work on a trigger, the question isn't what one run costs. It's what one trigger costs, at the largest fan-out that trigger can plausibly produce.

Take your per-run caps and multiply them by the biggest batch your matching rules could ever select. That number is your real exposure. If you don't have a limit scoped to the whole firing, you don't have a limit. And when you add one, make sure hitting it defers the leftovers instead of failing them, or you've built a cap that eats your backlog on its worst day.

Building something like this?

This is the kind of work I do for clients. Tell me what you're building and I'll give you a straight read on the approach.

Book a 30-minute call