Submit change-probe triggers to a paced queue instead of awaiting them (v0.71.0) - #173
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a bounded, separately-paced trigger queue to decouple change-probe triggers from the main walk in the prerender plugin, preventing trigger volume from inflating pass duration. The configuration schema, metrics documentation, and tests have been updated to support this new architecture. Feedback on the changes highlights a potential timeout overflow issue in the queue's pacing logic where the calculated wait delay passed to sleep could exceed the 32-bit signed integer limit, and suggests clamping the value to prevent unexpected immediate execution.
| const wait = startAt - at; | ||
| if (wait > 0) await sleep(wait); |
There was a problem hiding this comment.
The calculated wait delay passed to sleep (which wraps setTimeout) can exceed 2147483647 (the maximum 32-bit signed integer) if ratePerSecond is configured to an extremely small positive value. In Node.js, exceeding this limit causes setTimeout to emit a TimeoutOverflowWarning and execute the callback immediately (after 1ms), which would break the pacing rate-limiting. We should clamp wait to 2147483647 to prevent this.
| const wait = startAt - at; | |
| if (wait > 0) await sleep(wait); | |
| const wait = Math.min(startAt - at, 2147483647); | |
| if (wait > 0) await sleep(wait); |
References
- In Node.js, clamp or validate configuration options representing timeouts or delays passed to
setIntervalorsetTimeoutto not exceed2147483647(the maximum 32-bit signed integer). Exceeding this limit causes Node.js to execute the callback immediately (after 1ms), which can trigger unexpected hot loops.
|
Pushed 953fc2a lowering the 20 was picked before the injection arithmetic was done, and it is the one way this change can hurt — it is something the old in-line path could never do. At 20/s a 90,000-trigger budget drains in ~1.25h, which on a four-node cluster injects renders several times faster than the fleet can claim them, deepening the ready set and starving its lowest-priority class. The useful target is a drain that finishes inside the pass, since past that the queue backs up and changes defer for want of queue rather than of budget. 90,000 triggers across a 9h pass is ~2.8/s, so 5/s leaves headroom while still being ~4× the effective rate the in-line path managed. The option text now explains the sizing rather than leaving it to be inferred. On the reference deployment, spare render capacity is ~38k/hr fleet (87.6k ceiling − 49.4k measured), i.e. ~2.6/s per node — so even 5 is worth reviewing against a measured rate there before arming. |
953fc2a to
e79d0b7
Compare
|
Pushed e79d0b7 (and rebased onto #172's latest): the pacing sleep is now clamped. Confirmed and worth stating plainly — Reachable with an absurdly small
|
a3c4e47 to
4cfafe4
Compare
… device; v0.70.0
`triggerRevalidate` was missed by the v0.66.0 move to URL-keyed jobs — that change
touched RenderQueue.js, Target.js and renderSchedule.js, but not changeProbe.js, so the
probe kept filing `cacheKeysOf(url)` (url|desktop, url|mobile) instead of the URL row.
`claim` gives a device-keyed row `deviceTypes: [thatDevice]` and a URL row the full
default set, so every probe trigger became TWO ONE-DEVICE JOBS instead of one two-device
job. Three costs, in order:
- EACH JOB FETCHES THE ORIGIN DOCUMENT FOR ITSELF, which defeats the document reuse the
browser gained in the same release. Probe-triggered renders were doubling origin
document load — on a deployment that had asked for probe-driven origin load to come
DOWN.
- The two jobs are claimed and rendered at different times, so desktop and mobile land
different `lastCached`: exactly the split-pair state URL-keyed jobs removed.
- Two schedule writes per trigger instead of one, on a path that runs IN-LINE with the
sweep, so it also lengthens every pass.
The PAGES are still expired per device — page content genuinely is per device. Only the
SCHEDULE collapses to one row.
Every other writer already files the URL row (`Target.put`, `Target.revalidate`, and
`renderNow` for a default device); a per-device row stays legitimate only for a deliberate
one-device render. Rows written before this fix convert themselves the first time they
render, so no migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f awaiting them; v0.71.0 The sweep ran `await trigger(row)` inside the row handler. Triggering is six database operations, so with the handler's concurrency shared between probing and triggering, TRIGGER VOLUME SET PASS DURATION — and pass duration is detection latency, because the gap between two probes of one URL is one pass. That closes a loop: more change -> more triggers -> longer pass -> a longer window in which each URL can change -> more change detected per pass -> more triggers. Measured on one deployment, arming the probe took a pass from 9.2h to a projected ~21h with bot traffic FLAT across both windows (153,728/hr vs 150,743/hr, so contention was ruled out rather than assumed). Every available knob traded one bad outcome for another: raising maxTriggersPerSweep lengthened the pass, lowering it shed more change. Neither is a fix, because both act on the same coupling. Triggers are now submitted to a bounded queue that drains BESIDE the walk. Pass duration becomes max(probe time, drain time) instead of the sum, so the sweep runs at its probe-rate floor whatever the change rate, and the meaningful limit becomes triggers per SECOND — what the render fleet actually experiences — rather than per pass. WHAT IS PRESERVED EXACTLY. The baseline write still happens only after a successful trigger, and it moved INTO the queue for that reason: writing at submit time would lose the change outright on any failure. A failed trigger, or one abandoned by `stop()`, leaves the stored signature stale, so the next pass re-detects and retries — the existing retry story, unchanged. A full queue is reported as `deferred`, the same accounting exhausting the per-pass budget already produced, so nothing downstream learns a new state. The canary keeps the immediate shape (`createInlineTrigger`): its cohort is a few hundred URLs and its whole value is being fast, so there is nothing for a paced queue to spread. One contract, so `runProbePass` has no branch. Deliberately in memory and deliberately not durable: losing the queue costs one repeat detection, since every unsettled item still has its old signature stored. An aborted pass simply abandons it. New `changeProbe.trigger` group: `ratePerSecond` (20), `concurrency` (4), `maxPending` (5000). `maxTriggersPerSweep` still bounds the per-pass total. New `probe_trigger_queue_depth` metric, because a queue steadily at `maxPending` means changes are deferred for want of QUEUE rather than of budget, and `probe_deferred` alone cannot tell those apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20/s was picked before the injection arithmetic was done, and it is the one way this change can hurt: it is something the old in-line path could never do. At 20/s a 90,000-trigger budget drains in ~1.25h, which on a four-node cluster injects renders several times faster than the fleet can claim them — deepening the ready set and starving its lowest-priority class. The useful target is a drain that finishes INSIDE the pass, since past that the queue backs up and changes defer for want of queue rather than of budget. 90,000 triggers across a 9h pass is ~2.8/s, so 5/s leaves headroom without being able to outrun a fleet — and it is still ~4x the effective rate the in-line path managed. The option text now says how to size it rather than leaving it to be inferred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…delay Review follow-up. `sleep` wraps `setTimeout`, and past a signed 32-bit delay it fires after 1ms instead of waiting — so an unclamped wait turns the slowest possible drain into an UNPACED one, the exact inversion probePacer's own MAX_TIMER_MS clamp exists to prevent. Reached by an absurdly small ratePerSecond (1e-7 is a 10,000,000,000 ms slot). Reuses probePacer's MAX_TIMER_MS rather than a second literal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e79d0b7 to
ab31bde
Compare
The coupling
The sweep ran
await trigger(row)inside the row handler. Triggering is six database operations, so with the handler's concurrency shared between probing and triggering, trigger volume set pass duration — and pass duration is detection latency, because the gap between two probes of one URL is one pass.That closes a loop:
Measured on one deployment: arming the probe took a pass from 9.2h to a projected ~21h, with bot traffic flat across both windows (153,728/hr vs 150,743/hr on the same node), so contention was ruled out by measurement rather than assumed. Every available knob traded one bad outcome for another — raising
maxTriggersPerSweeplengthened the pass, lowering it shed more change. Neither is a fix, because both act on the same coupling.The change
Triggers are submitted to a bounded queue that drains beside the walk. Pass duration becomes
max(probe time, drain time)rather than the sum, so the sweep runs at its probe-rate floor whatever the change rate — and the meaningful limit becomes triggers per second, which is what the render fleet actually experiences, rather than per pass.What is preserved exactly
stop(), leaves the stored signature stale, so the next pass re-detects and retries — the existing retry story, unchanged.deferred— the same accounting exhausting the per-pass budget already produced, with the same semantics, so nothing downstream learns a new state.stop()is called still completes and is still baselined: its trigger actually succeeded, and an issued write cannot be un-issued. Only pending work is abandoned.Two deliberate choices
The canary keeps the immediate shape (
createInlineTrigger). Its cohort is a few hundred URLs and its whole value is being fast, so there is nothing for a paced queue to spread. Same contract, sorunProbePasshas one code path rather than a branch.In memory, not durable. Losing the queue costs one repeat detection, since every unsettled item still has its old signature stored. Durability would add a table to the probe's write path, which is the one thing
ProbeState's design goes out of its way to avoid.Config and observability
New
changeProbe.triggergroup:ratePerSecond(5),concurrency(4),maxPending(5000).maxTriggersPerSweepstill bounds the per-pass total — the two limits answer different questions and the option text says which.New
probe_trigger_queue_depthmetric. A queue sitting atmaxPendingmeans changes are being deferred for want of queue rather than of budget, andprobe_deferredalone cannot distinguish those.Tests
npm testinpackages/plugin: 1062 pass, 0 fail. Lint andformat:checkclean.12 new tests in
test/triggerQueue.test.jscover the load-bearing properties: submit returns while the trigger is still blocked; the baseline is written only after success and carriesclearClaim; a throwing trigger writes nothing and is counted; one failure doesn't stop the queue; a full queue refuses;stop()abandons pending work but completes what's in flight; pacing spaces trigger starts on a fake clock; and the inline shape settles synchronously with the same ordering.The existing probe tests now drive the real inline trigger rather than a stub, because the trigger-then-write ordering moved into
triggerQueue.jsand a stub re-implementing it would keep passing while the shipped path regressed.Versioning
v0.71.0, stacked on #172 (v0.70.0). Note #162 and #163 both currently claim v0.67.0, so the whole set may need renumbering at merge time.🤖 Generated with Claude Code