Repository navigation
fix(plugin): scope the demand ladder's visit signal to bots and to targets it can act on - #145
Merged
Merged
Conversation
…rgets it can act on; v0.61.0 The visit filter behind `render.demand` recorded every prerender-class request from every bot, including URLs that own no Target and never will. Both are silent-failure paths: the Bloom ring's false-positive rate is `fill^k`, so a saturated ring does not error — it answers "visited" for everything and the ladder promotes the whole corpus to its floor, which looks exactly like a working ladder from every metric except `demand_fill`. Measured on a production deployment: terminal fill 0.986, i.e. ~91% false positives, against a `bitsPerSlice` sized for ~100k distinct URLs per slice. Two gates, both mirroring precedent that already exists for discovery: - `render.demand.bots`, the same shape and matching rules as `ingress.discoveryBots`. Cadence is render budget, so whoever counts decides where that budget goes; a scraper walking the corpus breadth-first promotes pages no search engine asked for. - A rotation gate. `recordVisit` moves behind `resolveResource` so it can read `cacheStatus`: any status that FOUND a page row counts (a page row exists only where a Target does, so demoting `stale`/`invalidated`/`blob-missing` would be a false negative), a `miss` counts only when it is a cacheable 200 on a route that mints targets, and `bypass`/`skip` never looked so they cannot claim one. Also corrects the reading guidance for `demand_fill` in both the metric catalog and METRICS.md: it is a sawtooth that resets each slice, so the documented "average it" reports the middle of the ramp — a mean of 0.75 (13% false) and a peak of 0.986 (91%) are the same slice, and only the peak is the answer.
There was a problem hiding this comment.
Code Review
This pull request introduces a new render.demand.bots configuration option to restrict which bots' visits count as demand for the render ladder, helping to prevent Bloom filter ring saturation. It refactors the demand recording logic into a more robust recordDemand function that filters out non-eligible requests (such as bypasses, skips, and certain misses) and checks bot eligibility. Additionally, it updates metrics documentation to advise reading the peak of demand_fill rather than its mean, and adds comprehensive unit tests to verify the new behavior. There are no review comments to address, so no further feedback is provided.
…, not 'not real demand'
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Bloom ring behind
render.demandrecorded every prerender-class request from every bot, with nofilter on either axis. Two consequences, and both fail silently.
It counts the wrong bots.
recordVisitwas called with a URL and nothing else — the bot name was neverpassed in, so no filtering was possible. A third-party scraper's visit promoted a page exactly as hard as a
search engine's. On the deployment this was measured against, ~36% of bot requests come from crawlers the
site is not being optimised for.
It counts URLs the ladder can never act on. The call sat before
resolveResourceand beforemaybeSchedule, so it ran ahead of the discovery gate. Every URL on a prerender route was recorded,including two large classes that own no Target and never will: misses on discovery-gated routes
(combinatorial facet URLs —
discoverTargets: falsestops the mint, not the recording) and URLs the origin404s. On the same deployment those were ~1.2M requests/day, ~25% of all bot traffic.
Why that is a correctness problem, not a cost one. The ring's false-positive rate is
fill^k, so itdegrades off a cliff rather than gradually. At the default
k=7,bitsPerSlice = 1<<20:At 91% the ring answers "visited" for essentially everything, so the ladder promotes the whole corpus to its
floor. Nothing else moves: the decision counters look healthy,
fast_fractionis unaffected, targets sit atplausible rungs. The cadence has simply stopped being demand-driven, and the only metric that says so is
demand_fill— which, as it happens, was documented in a way that hides it (below).Changes
render.demand.bots— same shape and matching rules asingress.discoveryBots:['*'](default)counts every bot,
[]counts none, a list counts exactly those, case-insensitively. Default is unchangedbehaviour, so this is opt-in. The two allowlists share a compile helper but not their cached state, and
there is a test pinning that they stay independent.
They are deliberately separate knobs rather than one: minting corpus is permanent, while counting demand
only reallocates cadence inside a budget that already exists, so a deployment can reasonably answer the two
questions differently.
A rotation gate.
recordVisitmoves behindresolveResourceinto a newrecordDemand, so it can readcacheStatus— a sharper test thanresource.miss:hit,swr,stale,invalidated,blob-missing,blob-timeout,peer-rescue. A page row exists only where a Target does, so treating those as unvisitedwould demote pages that are merely late — the false-negative direction this filter must not have.
misscounts only when this request is what puts the URL into the rotation: a cacheable 200 on aroute that mints targets. Not on a discovery-gated route, not on a non-200.
bypass(non-GET) andskip(a deliberate cache bypass) never looked for a page row, so theycannot claim one — and they are not
misseither, since a miss looked and found nothing. They takeneither branch. That is the conservative reading of an ambiguous request, not a claim that on-demand
traffic is illegitimate:
resolveResourcestampscacheStatusbefore the render-now branch, so anon-demand render of an unknown URL arrives as a plain
missand does record.Reading guidance for
demand_fill, in both the metric catalog and METRICS.md. It is a sawtooth thatresets to ~0 at each
sliceMsrollover and climbs until the next one, so the documented "average it,never sum" reports the middle of the ramp. Because the exponent is 7, that distinction is the whole
signal: a mean of 0.75 implies 13% false positives, and the 0.986 peak of the same slice implies 91%.
Take max/p95.
bitsPerSlice's own doc now carries the sizing curve and says to cut what goes in beforeraising it, since that row replicates on every flush.
For the human reviewer
await.recordVisitused torun before
resolveResource; it now runs after. IfresolveResourcethrows (an origin connect timeout,~0.2% of proxy fetches) no visit is recorded where one previously was. I think that is correct — we
cannot classify a URL whose origin fetch failed — but it is a real difference and it is not free.
entire slice, and served from origin every time within it, is not recorded. Measured on the reference
deployment that is ~712 requests/day against ~59k catalog cache serves, and the same URLs are recorded
from their cache hits in the same slice. Using
cacheStatusrather thanresource.missis what keepsthis down to the all-slice case instead of every stale serve.
demand_fillfalling, and adding a series wouldcouple this to the console's metric-coverage guard. Happy to add
demand_gated(reason, bot) mirroringdiscovery_gatedif reviewers would rather see it counted directly.distinct inserts, which takes fill from 0.986 to ~0.88 and the false-positive rate from 91% to ~41%.
Better, still not good. A deployment at this scale will also want
botsset and/orbitsPerSliceraised;that is why the sizing curve is now in the schema doc rather than left as folklore.
recordDemandis exported for tests only, followingrecordServeOutcomein the same file.What operators should expect on deploy
A saturated ring promotes everything, so today's rung distribution is not evidence of demand — on the
reference deployment 100% of sampled PDPs sit at their
demandFloorand 90% of catalog at theirs. Oncefill comes down the ladder starts discriminating for the first time, and pages nothing in
render.demand.botsvisits will demote toward their route base. That is the feature working, but it isa real cadence change on real pages and it will look like a regression on a freshness dashboard.
Total render demand should fall with it, since fewer targets hold a fast rung. Watch
demand_fill's peak,demand_promoted/demand_demoted, and the render rate.One deploy will probably not be enough. Dropping the junk should roughly halve distinct inserts, which
takes fill from 0.986 to ~0.88 and the false-positive rate from 91% to ~41% — better, still not a working
filter. Setting
botscuts further. SizebitsPerSlicefrom the measured new peak rather than from anestimate; that is why the sizing curve is now in the schema doc.
Verification
node --testinpackages/plugin: 934 pass, 0 fail. Newtest/demandSignal.test.js(16 assertions)drives
recordDemandthrough the real visit filter — record → flush → refresh →visitedWithin—rather than a stub, so a wiring change that records the cache key instead of the device-free URL fails
there. Four new
userAgent.test.jscases cover the allowlist and its independence from discovery.npm run lintandnpm run format:checkclean at the root.omitted its
missprecondition (so abypasswith a 200 was recorded), andskipwas not in thenever-looked set.
behaviour it targets is only observable at production distinct-URL rates. The intended live check is
demand_fill's peak before and after, per node.Review coverage
Written and self-reviewed by Claude (Opus 5). No outside-model review ran — no independent
second-family coverage on this diff.