Repository navigation
feat(plugin): discovery gate (per-route flag + bot allowlist) and discovered-target purge; v0.54.0 - #127
Conversation
The console tests build downgraded fixtures by rest-destructuring keys away
(const { medians, ...rest } = row) — the unused bindings are the point.
Repo lint has been red on main since v0.51.0 because of them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng the cascade sweepKeyRuleOrphans destructured Target from the databases global — the RAW table — so its deletes never ran the resource override's fan-out, contrary to its own comment: schedule rows survived (each re-rendering once before retiring itself) and PrerenderedPage rows and blobs leaked permanently. Never observed in production (the sweep has only been run as a dry-run census), but any real run would have leaked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…covered-target purge; v0.54.0 Traffic discovery on a combinatorial URL space mints every novel link a crawler walks into a permanent render obligation. Three pieces: - ingress.routes[].discoverTargets (default true): a gated route's unknown URLs are served (origin proxy) but never minted; sitemap targets unaffected. - ingress.discoveryBots (default ['*']): only listed bots may mint, ending the malformed-URL class (crawlers with broken link extractors) at the source. - POST /prerender_admin/discovery-purge: detached, paced, owner-scoped bulk delete of never-sitemap-listed targets under a URL prefix; dryRun defaults true; refuses an ungated prefix (gate first, then purge, or crawlers re-mint). Plus a discovery_gated metrics series (prerender_ops) for what the gates hold out of the rotation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces traffic discovery gating and a discovery purge mechanism to prevent and clean up unbounded corpus growth from crawler traffic on combinatorial URL spaces. It adds route-level discoverTargets and global discoveryBots configuration options, gates target creation on bot requests, tracks gated misses with a new discovery_gated metric, and implements a paced, owner-scoped discovery-purge admin action. A compatibility issue was correctly identified in discoveredPurge.js where URL.parse is used; this should be replaced with new URL() wrapped in a try...catch block to ensure compatibility with Node 20.
| * admin layer can surface a precise message and tests can cover each refusal. | ||
| */ | ||
| export const validatePurgePrefix = (urlPrefix, { force = false } = {}) => { | ||
| const parsed = typeof urlPrefix === 'string' ? URL.parse(urlPrefix) : null; |
There was a problem hiding this comment.
Avoid using URL.parse as it is not backported to Node 20 (only available in Node v22.1.0+). To ensure compatibility with Node 20, use new URL() wrapped in a try...catch block instead.
| const parsed = typeof urlPrefix === 'string' ? URL.parse(urlPrefix) : null; | |
| let parsed = null; | |
| if (typeof urlPrefix === 'string') { | |
| try { | |
| parsed = new URL(urlPrefix); | |
| } catch {} | |
| } |
References
- Avoid using
URL.parseif the target Node.js version is below v22.1.0, as it is not backported to Node 20. Usenew URL()with appropriate error handling or pre-validation instead.
There was a problem hiding this comment.
Keeping URL.parse deliberately — it is this repo's established idiom for exactly this shape (see routeClass.js#classifyUrl, which runs it on the hot serve path for every bot request, shipped many releases ago). A Node 20 runtime would already fail at ingress long before reaching this admin-only path, so the effective runtime floor here is ≥22 (the test suite's type-stripping already requires it); diverging to new URL()+try/catch in one file would trade consistency for a compatibility level the codebase doesn't actually have. Same conclusion as the identical finding on #126.
Why
Traffic discovery treats every URL a crawler requests as corpus. On a site with combinatorial URL spaces (faceted navigation, filter permutations) that inverts control of the render budget: crawlers walking facet links mint every novel combination into a permanent render obligation. Measured on a production deployment, a catalog's discovered-facet corpus grew from ~224k to an estimated ~3M+ targets in eleven days (a 400-row sample was 100% discovered / 0% sitemap-listed, averaging 5.2 facet terms), putting theoretical demand far above the fleet and the queue ~49h behind — structurally unable to converge. The same mechanism at smaller volume mints malformed URLs invented by crawlers with broken link extractors (#81), which retry forever and permanently pollute the origin-auth alarm.
Route ceilings can't fix this: the corpus itself is the demand. The fix is to stop the minting (gate), then remove what was minted (purge) — in that order, or crawlers re-mint what the purge removes.
What
1. Per-route discovery gate —
ingress.routes[].discoverTargets(defaulttrue).A gated route's unknown URLs are still served (origin proxy on a miss, as today) but never create a target. Sitemap-declared URLs on the route schedule exactly as before — the gate distinguishes declared corpus from crawler-walked corpus. Compiled in
routeClass.jswith therenderIntervalfield rules (an invalid value drops the field, never the route; warned and ignored on passthrough).2. Discovery bot allowlist —
ingress.discoveryBots(default['*']).Only listed bots may mint targets, matched case-insensitively against the names
getBotNameresolves (registry, derived, orother).['*']preserves today's behavior;[]makes target creation sitemap-only site-wide. This ends the malformed-URL junk class (#81) at the source: the broken-extractor crawlers that invent those URLs are exactly the ones a deployment leaves off the list. Creation-only — serving, the demand ladder's visit signal, invalidation reenqueue, and sitemap ingestion are untouched.renderNow(explicit, operator-configured) is deliberately not gated.Both gates sit in
maybeSchedule, so a gated miss also skips the detached per-missTarget.get— on a gated combinatorial route, misses are most of the traffic. A newdiscovery_gatedseries (prerender_ops; dimensions: which gate, which bot) counts what's being held out.3. Discovered-target purge —
POST /prerender_admin/discovery-purge.Detached, paced (
ratePerSecond, default 200), owner-scoped (run on every node, likesweep-orphans— the in-flight lease check is node-local) bulk delete of targets under an absolute URL prefix whosesitemapUrlis null.dryRundefaults true (a bare start is a census). Progress onGET /prerender_admin/discovery-purge;{ action: 'stop' }cancels. Two interlocks are refusals, not docs: a bare-origin prefix is always rejected, and a prefix whose matched route still allows discovery is rejected withoutforce: true— gate first, then purge. The walk is a chunked one-sided keyset scan (thewalkTargetsshape) with the two-phase no-writes-under-an-open-cursor discipline fromutil/scan.js; deletes go through the resource class so the cascade (schedules, pages, probe baseline) holds.4. Fix: orphan sweep now deletes through the resource class.
sweepKeyRuleOrphansdestructuredTargetfrom thedatabasesglobal — the raw table — so its deletes never cascaded, contrary to its own comment: schedule rows survived (each re-rendering once before self-retiring) andPrerenderedPagerows/blobs leaked permanently. Found while building the purge on the same pattern; never triggered in production (the sweep has only ever been run as a dry-run census).5. Chore: repo lint is green again —
no-unused-varsnow ignores rest-sibling omissions (the console tests' fixture-downgrade idiom, red onmainsince v0.51.0).Verification
['*']/list/[], case-insensitivity, live config), purge predicate / owner scope / lease deferral / dry-run / pacing / cancel,walkPrefixchunk+boundary behavior, both purge interlocks, and the detached start/finish lifecycle.For the human reviewer
discovery_gatedcounts gated misses, not denied mints (a miss on an already-known target counts too). Chosen so the gate check stays ahead of the per-missTarget.get; the metric doc says exactly this.force: trueto override).maxDeletes/dryRundefaults are unchanged.Review coverage
Authored by Claude (Fable). Self-review + domain pass only — no outside-model leg ran for this PR; flagging per policy so a reviewer weights the diff accordingly.
🤖 Generated with Claude Code