perf(plugin): yield the sweep on a time budget, and measure loop lag per worker - #125
perf(plugin): yield the sweep on a time budget, and measure loop lag per worker#125harper-joseph wants to merge 1 commit into
Conversation
…per worker; v0.53.0 The sweep yielded every 200 rows. That constant was chosen when `bench/queue-index` measured a row at ~2.4us — 200 rows was ~0.5ms of held event loop, invisible beside a ~1.6ms cache hit. On the production corpus a row costs ~55us, so those same 200 rows hold the loop for ~11ms, and the sweep runs on a worker that also serves bot traffic. Every crawler request landing inside a slice waits for it. A row count cannot express "do not stall a request". It now yields on elapsed time (`queue.ready.yieldBudget`, default 2ms — just above the 1.6ms a cache hit takes to serve), so a delayed request waits about as long as it would have taken to answer. The clock is sampled every 32 rows rather than every row: at ~55us/row a per-row read is free, but the reason this is being rewritten at all is that a per-row cost moved 20x, and at 2.4us/row a per-row clock read would be ~2%. This is the same class of bug as the sweepCap waste in v0.52.0: a constant sized against a per-row cost that turned out to be 20x higher. AND THE ATTRIBUTION, because the evidence for the above stops short of proof. Bot-facing `duration` (path: 'p') on the live fleet shows median and p95 identical across every worker (1.6ms / ~2.7ms) while five workers carry a p99 of 13-53ms against 3.6-7.4ms elsewhere. Median flat, p95 flat, p99 blown out 10-30x is the signature of intermittent loop blocking. But the sweep self-gates to workerIndex 0 and the tail covers five workers, so it is CONSISTENT with the sweep and cannot be pinned on it — the queue-status sync, the reconciler, the sitemap refresh and GC are all indistinguishable in that data. `prerender_ops` `event_loop_lag` fixes that. It reports libuv's own histogram per worker (p99 and max, dimensioned by worker index), on every worker rather than pinned to one — which is the entire point, since a cluster-wide number averages away exactly the signal being looked for. Whatever only worker 0 does then shows up only on worker 0. `readLag` is extracted and tested because its failure is silent and fleet-wide: an empty window returns Infinity from percentile(), and recordAnalytics aggregates by mean, so one Infinity makes the merged row's mean Infinity for that period across every worker — the series reads as catastrophic while nothing is wrong. 785 tests pass. The new sweep test pins the invariant that matters for a scheduling change: the yield budget changes WHEN the walk pauses and never what it publishes, verified by running the same corpus at 1ms and at 1e9ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a per-worker event-loop lag monitor using Node's monitorEventLoopDelay to help diagnose event-loop blocking, and refactors the ready-set sweep to yield based on an elapsed time budget rather than a fixed row count. The review feedback highlights critical issues in the event-loop lag monitor implementation, including a bug that prevents live-reloading if disabled at startup, potential integer overflow from bitwise coercion on large intervals, and the need to flush remaining histogram data when the monitor is disabled.
| export function startEventLoopLagMonitor() { | ||
| if (started) return; | ||
| const interval = () => Math.max(0, config.management.eventLoopLagInterval | 0); | ||
| if (interval() <= 0) return; | ||
| started = true; | ||
|
|
||
| // `resolution` is how often libuv samples. 20ms is coarse enough to cost nothing and fine enough | ||
| // to catch a slice of the size this exists to look for; a stall shorter than one sample is, by | ||
| // construction, shorter than the thing being investigated. | ||
| const histogram = monitorEventLoopDelay({ resolution: 20 }); | ||
| histogram.enable(); | ||
|
|
||
| const report = () => { | ||
| // Read BEFORE reset — see `readLag`. Both halves matter and neither is obvious from the call. | ||
| const lag = readLag(histogram); | ||
| histogram.reset(); | ||
| if (lag.p99 !== undefined) metrics.eventLoopLag(lag.p99, 'p99', server.workerIndex); | ||
| if (lag.max !== undefined) metrics.eventLoopLag(lag.max, 'max', server.workerIndex); | ||
| }; | ||
|
|
||
| let armed = interval(); | ||
| let timer = setInterval(report, Math.min(MAX_TIMER_MS, armed)); | ||
| timer.unref?.(); | ||
|
|
||
| onConfigApplied(() => { | ||
| if (interval() === armed) return; | ||
| clearInterval(timer); | ||
| armed = interval(); | ||
| timer = armed > 0 ? setInterval(report, Math.min(MAX_TIMER_MS, armed)) : null; | ||
| timer?.unref?.(); | ||
| }); | ||
| } |
There was a problem hiding this comment.
There are three issues with the current implementation of startEventLoopLagMonitor:
- Startup Disabled Bug: If
config.management.eventLoopLagIntervalis0(disabled) at startup, the function returns early on line 61. This preventsonConfigAppliedfrom being registered, meaning the monitor can never be enabled at runtime without a full process restart, violating the live-reload contract. - Bitwise Coercion Issue: Using
| 0onconfig.management.eventLoopLagIntervalcan wrap large values (greater than 2^31 - 1) to negative numbers, whichMath.max(0, ...)then clamps to0, disabling the timer entirely instead of using the configured interval. We should avoid bitwise coercion and useMath.floorinstead. - Missing Flush on Disable: When disabling the periodic timer via live configuration changes, we must ensure we stop the timer and flush any remaining buffered data or counters one last time to prevent partial interval data from being silently lost.
We can resolve these issues by registering onConfigApplied unconditionally, using Math.floor instead of bitwise coercion, and flushing the remaining histogram data when disabling the monitor.
export function startEventLoopLagMonitor() {
if (started) return;
started = true;
const interval = () => Math.max(0, Math.floor(config.management.eventLoopLagInterval || 0));
let histogram = null;
let timer = null;
let armed = 0;
const report = () => {
if (!histogram) return;
const lag = readLag(histogram);
histogram.reset();
if (lag.p99 !== undefined) metrics.eventLoopLag(lag.p99, 'p99', server.workerIndex);
if (lag.max !== undefined) metrics.eventLoopLag(lag.max, 'max', server.workerIndex);
};
const sync = () => {
const nextInterval = interval();
if (nextInterval === armed) return;
if (timer) {
clearInterval(timer);
timer = null;
if (nextInterval === 0) {
report();
}
}
armed = nextInterval;
if (armed > 0) {
if (!histogram) {
histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
}
timer = setInterval(report, Math.min(2147483647, armed));
timer.unref?.();
} else if (histogram) {
histogram.disable();
histogram = null;
}
};
sync();
onConfigApplied(sync);
}References
- In Node.js, clamp or validate configuration options representing timeouts or delays passed to
setIntervalorsetTimeoutto not exceed2147483647to avoid unexpected hot loops, and avoid using bitwise coercion (e.g.,| 0) for large numbers. - When disabling a periodic timer or stats collector via live configuration changes, ensure to stop the timer and flush any remaining buffered data or counters one last time to prevent partial interval data from being silently lost.
Two changes: a fix for a constant that went stale, and the instrumentation to prove whether the fix mattered.
The sweep holds the event loop for ~11ms at a time
It yielded every 200 rows. That was chosen when
bench/queue-indexmeasured a row at ~2.4 µs — 200 rows is ~0.5ms of held loop, invisible beside a ~1.6ms cache hit. On the production corpus a row costs ~55 µs, so those same 200 rows hold the loop for ~11ms. The sweep runs on a worker that also serves bot traffic, so every crawler request landing inside a slice waits for it.The
for awaitdoesn't help: awaiting a promise that resolves on a microtask drains the microtask queue without returning to the poll phase. Only thesetImmediatelets I/O callbacks run.A row count cannot express "do not stall a request." It now yields on elapsed time —
queue.ready.yieldBudget, default 2ms, set just above the ~1.6ms a cache hit takes to serve, so a request delayed by the sweep waits about as long as answering it would have taken. The clock is sampled every 32 rows rather than every row: at ~55 µs/row a per-row read is free, but the entire reason this is being rewritten is that a per-row cost moved 20×, and at 2.4 µs/row a per-row clock read would be ~2%.This is the same class of bug as the
sweepCapwaste in v0.52.0 — a constant sized against a per-row cost that turned out 20× higher. Two in two releases, which is why the budget is expressed in time: it re-derives itself when the corpus changes instead of needing a human to notice.The evidence stops short of proof, so this also measures it
Bot-facing
duration(path: 'p') on the live fleet, per worker — these rows are per-thread:Median flat, p95 flat, p99 blown out 10–30× is the signature of intermittent loop blocking — it only touches the fraction of requests landing inside a stall.
But the sweep self-gates to
workerIndex === 0, and the tail covers five workers. So this is consistent with the sweep and cannot be pinned on it: the queue-status sync, the reconciler, the sitemap refresh and GC are all indistinguishable in that data. The p99s also reach 43–53ms, above the ~11ms the arithmetic predicts — plausible, since 55 µs/row is an average and a batch that misses the block cache hits disk through a synchronous native binding, but plausible is not measured.So
prerender_opsevent_loop_lagreports libuv's own histogram per worker (p99 and max, dimensioned by worker index), from every worker rather than pinned to one. That last part is the whole point — a cluster-wide number averages away exactly the signal being looked for. Whatever only worker 0 does then shows up only on worker 0, andqueue.ready.yieldBudgetbecomes a knob you can turn against a number instead of a hypothesis.monitorEventLoopDelayrather than the usualsetTimeout-drift trick: it samples in libuv at 20ms and costs nothing measurable, where a JS timer both competes for the loop it is measuring and misses any stall shorter than its own interval.Testing
785 pass / 0 fail.
The sweep test pins the invariant that matters for a scheduling change: the yield budget changes when the walk pauses and never what it publishes. Same corpus at a 1ms budget (yields on nearly every check) and at 1e9ms (never yields) must produce identical
scanned,due,publishedand the same granted jobs in the same order.readLagis extracted and tested rather than left inline, because its failure is silent and fleet-wide: an empty window returnsInfinityfrompercentile(), andrecordAnalyticsaggregates by mean — so a singleInfinitymakes the merged row's meanInfinityfor that period across every worker. The series would read as catastrophic while nothing was wrong, and that gets found from a dashboard weeks later. Tests cover empty, half-empty, NaN, and the case that must not be dropped: a genuine zero-lag window, since dropping it would make an idle worker indistinguishable from a broken monitor.Rollout note
event_loop_lagemits 2 rows per worker per window (default 60s), so 32 rows/node/minute at 16 workers.management.eventLoopLagInterval: 0disables it. The yield change needs no config to take effect andqueue.ready.yieldBudgetis live-reloadable.Expect this to slightly increase total sweep wall time — more yields means more trips through the loop — while reducing the worst-case latency any single request behind it pays. That is the intended trade, and
ready_sweep_msagainstevent_loop_lagis how to see both halves.Lint: the three
no-unused-varserrors are pre-existing onmaininpackages/console/test/trafficView.test.js.🤖 Generated with Claude Code