Repository navigation
feat(plugin): don't discover a URL whose product already has a target under another slug (per-route entityPrefix); v0.90.0 - #211
Conversation
… under another slug (per-route entityPrefix); v0.90.0 A route may declare `entityPrefix`, a path-anchored regex whose match (plus the URL origin) identifies one entity. Traffic discovery then reads the targets sharing that prefix before minting: a sibling in rotation means the entity is already rendered, so the URL is not minted (it is still served). No siblings, or suppressed-only siblings, mint as before, so a genuine re-slug is delayed by one render cycle of the old target, never blocked. - The match must end on "/" at runtime (prd-123 is a string prefix of prd-1234); a pattern whose source does not is warned about at compile. - Validated like the other optional route fields: a bad value drops the field with a warning, never the route; passthrough routes refuse it. - Discovery only: sitemap ingestion and every other creation path never consult it. - The read is one bounded, one-sided PK range over Target (not residency-pinned; replicateFrom:false passed anyway), url+state only, inside the detached discovery step, cursor closed before anything else. Every failure mints. - ingress.entityGate: enabled (true), dryRun (true), siblingLimit (5). - prerender_ops/entity_gate counts every evaluation by outcome; armed refusals are also discovery_gated/entity. Console guard waiver for entity_gate (test-only; no console release needed). Refs #166 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Self-review (author side) — no defects found; no changes. Checked: the sibling read runs only inside the already-detached |
There was a problem hiding this comment.
Code Review
This pull request introduces an "entity discovery gate" feature (v0.90.0) to prevent redundant target minting for different URL variations of the same underlying entity. It adds a new entityPrefix configuration option for routes, implements the gate logic in packages/plugin/src/util/entityGate.js, integrates it into the page scheduling process, and adds corresponding metrics and tests. Regarding the feedback, a compatibility issue was identified in packages/plugin/src/util/entityGate.js where URL.parse is used. Since URL.parse is only supported in Node.js v22.1.0+ and not backported to Node 20 (LTS), it is recommended to use new URL() wrapped in a try...catch block instead to ensure compatibility with older Node.js versions.
| const parsed = URL.parse(url); | ||
| if (!parsed) return null; |
There was a problem hiding this comment.
Avoid using URL.parse because it is only supported in Node.js v22.1.0+ and is not backported to Node 20 (LTS). To ensure compatibility with older Node.js versions, use new URL() wrapped in a try...catch block instead.
| const parsed = URL.parse(url); | |
| if (!parsed) return null; | |
| let parsed; | |
| try { | |
| parsed = new URL(url); | |
| } catch { | |
| return null; | |
| } |
References
- Avoid using URL.parse if the target Node.js version is below v22.1.0, as it is not backported to Node 20. Use new URL() with appropriate error handling or pre-validation instead.
There was a problem hiding this comment.
Not changing: URL.parse is the repo idiom (null instead of a throw on a malformed value, which is exactly the fail-open behaviour the gate wants), it exists on every supported Node line (22.1.0+, backported to 20.18.0; deployments run Node 24), and the same finding was dismissed on #127.
…rop ingress.entityGate.siblingLimit (review)
Measured on a production catalog (150 suppressed old-slug targets): the first in-rotation sibling
under the entity prefix sat at key-order position 1 for 135, 2 for 14 and 3 for 1, so a 3-row read
finds it for 150/150 (1 row: 90%, 2 rows: 99%), and no entity had more than 4 siblings. A tunable
limit only offered a way to get that wrong, so the read is fixed at 3 (was siblingLimit 5 + 1 = 6)
and the option is removed. Past the limit the gate still MINTS, never refuses.
readSiblings now returns { blocker, seen } instead of collecting rows: the decision needs only the
first blocker's key and whether any sibling existed. Revert check: a 6-row read fails the read-shape
test and the "three dead slugs ahead of the live one" case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Review change (91041b4): the sibling read is a fixed 3 rows and |
# Conflicts: # package-lock.json # packages/plugin/package.json # packages/plugin/src/configSchema.js
Refs #166. This is the cheapest part of that issue: it recognises an entity (product) at discovery time. The entity table and turning a slug change into a retarget are not part of this PR, so #166 stays open.
Problem (measured on a production deployment)
Crawlers keep requesting old or invented slugs of products whose correct URL is already a target:
/product/prd-1005777/old-spelling.jspwhen/product/prd-1005777/new-spelling.jspexists, or placeholders like/product/prd-1006030/product.jsp. Discovery mints a target for each one. The target renders, the render finds the canonical elsewhere, and it is suppressed (canonical-mismatch). It re-renders weekly on the suppression recheck. AftermaxStrikesit is deleted, and the next crawler hit mints it again.suppressed/canonical-mismatch= 8.2% of all render outcomes, about 1,500 URL jobs/hour.The suppressed rows are left alone. Today they are the only thing that stops these URLs being minted again, and they age out through
maxStrikesas before.What this adds
Per-route
entityPrefix: a regular expression, anchored at the start of the URL path, whose match identifies one entity. The lookup prefix is<url origin> + <matched text>. EveryTargetwhose key starts with it is a sibling.The gate runs in
handlePageSchedulingonly, for a URL with no target row:dryRun: truestatenotsuppressed)would-gatesuppressed-only)no-siblings)no-prefix)error)bot_request.jsis its only caller.departureAction/demandFloor. An invalid regex, a non-string or an empty string drops the field with a warning, never the route. Passthrough routes refuse the field. Setting it together withdiscoverTargets: falseis warned as a no-op.Deviation from the brief:
sitemapUrldoes not gate on its ownThe brief said to gate when a sibling is sitemap-listed or in rotation. With that rule the gate can block a product's only correct URL forever. Suppose a sitemap lists a non-canonical URL S whose page canonicalizes to U. S's target is listed and suppressed, and every refresh keeps that row in place. U, the correct URL, would then never be discovered and would be served from the origin permanently. Before this PR, U would have been minted and rendered.
So a sibling gates only when it is in rotation. A listed sibling that is in rotation still gates, because it is in rotation. This rule covers the measured case, where the listed siblings were the correct slugs and so were in rotation.
sitemapUrlis no longer read, and the projection is['url', 'state'].Prefix collision: the match must end on
/…/prd-123is a string prefix of…/prd-1234/any.jsp. A prefix that stops mid-segment would let product 1234's target gate product 123 forever. How this is handled:/is ignored for that URL. The outcome isno-prefixand the URL is minted as before. This check is what makes it safe./is warned about but kept. Some patterns like that still work. For example,^/product/prd-\d+/?matches up to/whenever the URL continues past the id, and it is refused on the bare-id URL.prefixroute's own path, such as^/product/on/product/prd-, covers the whole route and is refused.^.The read: off the serve path, bounded, can't hang
setImmediate→handlePageScheduling), after the existing-rowTarget.get. It runs only for a would-be mint on a route that opted in, never on the response path.Targetis not residency-pinned. OnlyRenderSchedulecallssetResidencyById(resources/RenderSchedule.js:7), andutil/pageOrphanSweep.jsalready relies on node-localTargetreads. So the read is node-local.{ replicateFrom: false }is passed anyway as the second argument (not a query field, which Harper would silently ignore). IfTargetis ever pinned, this read still won't turn into an untimed cross-node fetch.url >= prefix, sorted,limit: 3— fixed, not configuration: measured on 150 suppressed old-slug targets, the first sibling in rotation was at key-order position 1 for 135, 2 for 14 and 3 for 1 (1 row: 90%, 2: 99%, 3: 100%), and no entity had more than 4 siblings. Past it the gate mints. No rows are kept — only the first blocker's key and whether any sibling existed. The upper bound is enforced while reading: the first key outside the prefix ends the loop. This is the same shape aslistPagesInnerandwalkPrefix, because a two-sided PK range falls back to a filtered intersection instead of a seek. The read stops at the first sibling in rotation.breakor by running out of rows before anything is recorded or written, and a test checks this.Observability
prerender_ops/entity_gate: detail is the outcome (gated/would-gate/suppressed-only/no-siblings/no-prefix/error), context is the bot. Exactly one emit per evaluation.would-gateis the dry-run number.discovery_gatedwith gateentity. The console's Discovery gate panel already includes them in its "Gated misses" total with no console release. It has no dedicated "by entity" tile, so the total can exceed route + bot.discovery_gated? The console totals everydiscovery_gatedrow as "served from the origin, never scheduled". Counting dry-run would-gates or allowed outcomes there would make that total wrong. So the census gets its own series, and only armed refusals go intodiscovery_gated.console/test/adminAssets.test.js): it scans pluginmetrics.jsemit sites for literal series names and fails unless the console reads each one or waives it. I addedprerender_ops.entity_gateto itsNOT_CHARTEDlist with the reason. This is a test-only change, so no console release is needed. A census tile is follow-up work.METRICS.md: I added a bullet rather than editing theprerender_opstable row. That row is the widest cell, so any edit makes prettier re-pad the whole table, and that table is already triplicated onmain(a pre-existing merge artifact that I left alone).Rollout
dryRun: true(the default) and setentityPrefixon the product route.prerender_ops/entity_gatewould-gateagainstrenderoutcomesuppressed/canonical-mismatch. If a route is mostlyno-prefix, its pattern doesn't match its URLs.ingress.entityGate.dryRun: false.canonical-mismatchrows age out throughmaxStrikes. Once the gate is armed, they are not minted again.Adversarial re-read
sitemapUrlalone never blocks.canonical.strict: false, variant) URLs for one entity. The first one minted renders and the other is served from the origin. Neither is more correct than the other from the origin's point of view.limit-bounded.[^/]+segments). The sticky anchor also stops the engine from trying later start positions.entityPrefixon a route where several URLs per entity are distinct pages.rawCache, so it should merge cleanly next to feat(plugin): probe only sitemap-listed targets (opt-in), and re-render a product that rejoins its sitemap; v0.89.0 #209'sarrivalAction, which is inserted beforerawCache.Tests
test/entityGate.test.js, 33 tests:/,discoverTargets: false.^, prd-123 vs prd-1234, optional trailing/, a match covering the whole route.entityPrefixmeans no read and nothing recorded; master switch off;no-prefix; read throws →errorand minted; unreadable row skipped.greater_than_equal, sort,select ['url','state'],limit = 3(SIBLING_READ_LIMIT),{ replicateFrom: false }as the second argument; cursor closed before any metric is emitted (both on break and on running out of rows); stops at the first sibling in rotation; the limit binds in the suppressed case.handlePageSchedulingwith a stubbedTarget: armed, dry run, no prefix, existing row, suppressed-only.evaluateEntityGateis referenced only inbot_request.js, at one call site after the existing-row check.Results:
npm run lintandnpm run format:checkpass (exit 0).Revert check
Each mutation was applied to the source with the tests unchanged:
if (!mint) return;from discovery/ruledryRun{ replicateFrom: false }All reverted afterwards.
Not in this PR
entity_gatecensus (waived in the guard).explainadmin view, so an operator can check a pattern against one URL. Theno-prefixcounts give the aggregate signal for now.🤖 Generated with Claude Code