Repository navigation
feat(browser): pressure-based admission — brake the render limit on container CPU pressure, size claims to free capacity; v1.39.0 - #228
harper-joseph wants to merge 1 commit into
Conversation
…ontainer CPU pressure, size claims to free capacity; v1.39.0
Opt-in `admission: { mode: 'pressure' }`. Each worker steps its concurrent-render limit between
`min` and `max` from cgroup v2 PSI CPU pressure, measured over each step's own interval from the
`some total=` stall counter: up one while pressure is low and the limit is holding a job back, down
one while it is high, down a quarter when badly overloaded, on a staggered interval. `max` defaults
to `concurrency`, so by default the limit only brakes. A raised limit starts a waiting render at
once. Each queue claim is sized to what can start soon (free slots, or free prefetch-pool room) up to
`jobClaimLimit`. The default `fixed` mode is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a pressure-based admission control system that dynamically scales the concurrent render limit based on cgroup v2 CPU pressure (PSI) instead of relying on a fixed slot count. The changes span settings resolution, worker scheduling, connection pooling, and CPU stall monitoring utilities, backed by comprehensive unit tests. The feedback suggests a performance optimization to conditionally start the admission timer only when CPU pressure monitoring is actually available, preventing unnecessary timer overhead on unsupported environments like macOS or cgroup v1 hosts.
| if (this.lastStall === null) { | ||
| logger.warn( | ||
| { limit: this.admissionLimit }, | ||
| 'pressure admission: no CPU pressure reading (needs cgroup v2 PSI) — the render limit holds' | ||
| ); | ||
| } | ||
| this.startAdmission(this.admission); |
There was a problem hiding this comment.
When CPU pressure monitoring (PSI) is unavailable (e.g., on macOS or cgroup v1 hosts), this.lastStall remains null. In this case, the admission limit will never change. To avoid the unnecessary overhead of starting the admission timer and repeatedly attempting to read the non-existent pressure file every few seconds, we can conditionally start the admission timer only when this.lastStall is not null.
| if (this.lastStall === null) { | |
| logger.warn( | |
| { limit: this.admissionLimit }, | |
| 'pressure admission: no CPU pressure reading (needs cgroup v2 PSI) — the render limit holds' | |
| ); | |
| } | |
| this.startAdmission(this.admission); | |
| if (this.lastStall === null) { | |
| logger.warn( | |
| { limit: this.admissionLimit }, | |
| 'pressure admission: no CPU pressure reading (needs cgroup v2 PSI) — the render limit holds' | |
| ); | |
| } else { | |
| this.startAdmission(this.admission); | |
| } |
There was a problem hiding this comment.
Code Review
This pull request introduces a dynamic, pressure-based admission control system for the prerender browser worker, allowing it to scale concurrent renders based on cgroup v2 CPU pressure rather than relying solely on a fixed concurrency limit. It includes the core admission logic, updates to the worker and queue consumer to support dynamic claim sizes, and extensive unit tests. The review feedback highlights a valid concern regarding a potential resource leak in startAdmission if the worker is destroyed while the initial startup timer is pending, suggesting a defensive check to prevent scheduling a new interval after shutdown.
| this.admissionTimer = globalThis.setTimeout( | ||
| () => { | ||
| // Re-baseline rather than step: the window since construction is an arbitrary slice of an | ||
| // interval, and each step should read one whole interval. | ||
| this.samplePressure(); | ||
| this.admissionTimer = setInterval(step, admission.intervalMs); | ||
| this.admissionTimer.unref(); | ||
| }, | ||
| Math.floor(Math.random() * admission.intervalMs) | ||
| ); |
There was a problem hiding this comment.
When starting the admission timer, if destroy() or shutdown() is called while the initial setTimeout is pending, there is a risk of starting an un-cleared setInterval if the callback executes. Although destroy() attempts to clear the timer, adding a defensive check for this.shuttingDown (or this.admissionTimer === null) inside the setTimeout callback ensures that we do not schedule a new setInterval during or after shutdown, preventing potential resource leaks.
| this.admissionTimer = globalThis.setTimeout( | |
| () => { | |
| // Re-baseline rather than step: the window since construction is an arbitrary slice of an | |
| // interval, and each step should read one whole interval. | |
| this.samplePressure(); | |
| this.admissionTimer = setInterval(step, admission.intervalMs); | |
| this.admissionTimer.unref(); | |
| }, | |
| Math.floor(Math.random() * admission.intervalMs) | |
| ); | |
| this.admissionTimer = globalThis.setTimeout( | |
| () => { | |
| if (this.shuttingDown) return; | |
| // Re-baseline rather than step: the window since construction is an arbitrary slice of an | |
| // interval, and each step should read one whole interval. | |
| this.samplePressure(); | |
| this.admissionTimer = setInterval(step, admission.intervalMs); | |
| this.admissionTimer.unref(); | |
| }, | |
| Math.floor(Math.random() * admission.intervalMs) | |
| ); |
Adds an opt-in
admission: { mode: 'pressure' }to the render worker. It does two things:minandmaxaccording to the container's CPU pressure (cgroup v2 PSI).fixed, the default, behaves exactly as before.Why. Measured on a 20-pod production render fleet (15-core CFS limit per pod, 3 workers × 5 slots):
concurrencyjobs lets a busy worker hold jobs an idle one could start.For the human reviewer
avg10as the signal, and a defaultmaxof2 × concurrency. All three changed:claimSize): free slots, or free prefetch-pool room. A fixed size of 1 capped each worker's start rate at one job per claim round trip, and a 1-job claim scans only 4 ready-set entries on the queue side.some total=stall counter (pressureBetween).avg10is a 10 s moving average, so a controller stepping every 5 s kept reacting to stale readings.maxdefaults toconcurrency(resolveAdmission), so by default the limit only brakes. Idle CPU with work waiting is also what a slow origin looks like. A limit that climbs on low pressure therefore sends more concurrent origin requests, and opens more Chrome pages, exactly then. Going aboveconcurrencyis an explicit operator choice, and the README says so.jobHeldBack). The prefetch loop waits for a slot before it takes a job, so full slots count as demand there only while the pool holds a job. Pooled jobs with a slot free are prefetching ahead, not waiting on the limit.prefetch.maxDepth(8) claimed jobs ahead per worker. It deepens and never shrinks, and this change doesn't touch it.admissionthrough before this can be enabled. That is a follow-up PR there.Changes
admission.ts(new): theAdmissionSettingstype and the purenextAdmissionLimit. It returns +1 under low pressure while a job is held back, −1 under high pressure, andfloor(×0.75)above 2× high, clamped to[min, max]. With no reading, the limit holds.util/cpu.ts:parseCpuStallUs/readCpuStallUsread the PSI stall counter, returning null without cgroup v2 PSI.pressureBetweenturns two samples into a percent.Worker.ts:awaitSlotwaits while in-flight renders are at the limit, and wakes on a render finishing or on the limit rising.startAdmissionstarts at a random offset so the workers sharing a container step at different moments, and re-baselines at the first tick.samplePressureandstepAdmissiondo the per-tick reading and step.claimSize.saturation.concurrency, plussaturation.admission{min, max, pressure}.maxActivePagesfollowsmax. Nothing enforces it; it only feeds thefreeSlotsstat.RenderQueueConsumer.ts: takes an optionalclaimLimitgetter that is read before each claim. The default issettings.jobClaimLimit, as before.settings.ts:admissionoption.jobClaimLimitdefaults tomaxand acts as the ceiling on each claim.resolveAdmission:min/maxmust be integers,0 ≤ low < high < 100, andintervalMsmust be within[1000, MAX_TIMER_MS]. Amaxbelow the defaultminpulls that default down to it.jobClaimLimitdoc, which said the default wasconcurrency * 2.config.ts: exportsMAX_TIMER_MSfor that bound.external/http.ts: the undici pool gets one connection per render that may post at once (maxunder pressure).README.md: adds anadmissionsection with defaults, the origin and memory caveat for raisingmax, and the measured thresholds. It also fixes thejobClaimLimitdefault in the options table.package.json: 1.38.0 → 1.39.0.Verification
test/admission.test.ts, 15 tests:nextAdmissionLimitstep table;pressureBetween;RenderWorkerwith a stub browser, a gated renderer and a local queue endpoint:concurrencyrenders;🤖 Generated with Claude Code