Repository navigation
Retire a gone target on the first verdict when no sitemap lists it (v0.67.0) - #163
Merged
Merged
Conversation
…p lists it; v0.67.0
A 404/410 verdict suppressed the target and rechecked it at `gone.recheckInterval`,
deleting only after `gone.maxStrikes` consecutive verdicts. Measured on a ~850k-URL
deployment, `render/outcome/suppressed/http-error` runs at ~29% of all render outcomes
(88,992 vs 210,719 rendered/stored on one node in 22.8h) — a third of the render budget
spent re-proving that dead URLs are dead.
The strike ceiling now depends on sitemap attribution, because the two cases are not
symmetric and the asymmetry runs opposite to the intuition:
- NO ATTRIBUTION: nothing re-creates the target. Discovery mints only on a 200 from the
origin (`maybeSchedule` checks the status, `isPrerenderCandidate` checks it again), so
a URL the origin answers 404/410 for cannot be re-minted by a crawler hit. Retiring is
terminal, so it happens on the first verdict (`gone.maxStrikesUnlisted`, default 1)
and the row, its schedule row and every recheck render are saved.
- SITEMAP-LISTED: the refresh re-creates it on the next pass (an absent target is a
CREATE; a suppressed one is SKIPped), so the suppressed row is the ONLY thing holding
the walk off. Deleting would trade one recheck per `recheckInterval` for a full render
per `sitemap.refreshInterval` — at a 6h refresh against a 14d recheck, ~28x MORE render
work. These keep counting `gone.maxStrikes`, unchanged.
Scoped to gone verdicts deliberately: noindex and canonical-mismatch verdicts come from
pages the origin serves 200 for, so discovery re-mints them on the next bot request (a
`<meta>` noindex is not even visible to the header check) and retiring those on sight
would loop the same way.
Set `gone.maxStrikesUnlisted` equal to `gone.maxStrikes` to restore the old behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new configuration option, maxStrikesUnlisted, to optimize the suppression and retirement of prerender targets. Unlisted targets that return a 404 or 410 ('gone') status are now retired on the first verdict by default, preventing wasteful rechecks for URLs that cannot be re-created by discovery. Sitemap-listed targets continue to use the standard maxStrikes threshold to avoid redundant rendering work. These changes are implemented via a new suppression.js utility, integrated into the Target resource, and verified with comprehensive unit tests. There are no review comments, and I have no additional feedback to provide.
harper-joseph
added a commit
that referenced
this pull request
Sep 17, 2026
Both this and #163 claimed v0.67.0. Main's version cannot go backwards, so merge order has to be ascending by version; renumbering this one is the single-bump way to resolve it and puts this PR last, which suits it — it is the lowest-risk of the set and touches only the admin sweep paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
harper-joseph
added a commit
that referenced
this pull request
Sep 17, 2026
Both this and #163 claimed v0.67.0. Main's version cannot go backwards, so merge order has to be ascending by version; renumbering this one is the single-bump way to resolve it and puts this PR last, which suits it — it is the lowest-risk of the set and touches only the admin sweep paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 of 3 tasks
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.
What
A 404/410 verdict suppresses the target and rechecks it at
gone.recheckInterval(14d), deleting only aftergone.maxStrikes(2) consecutive verdicts. This makes the strike ceiling depend on sitemap attribution: a gone verdict on a URL no sitemap lists now retires the target on the first verdict (render.suppression.gone.maxStrikesUnlisted, default1). Sitemap-listed targets are unchanged.Why
Measured on a ~850k-URL deployment (one node, 22.8h of
render/outcome/*):rendered/storedsuppressed/http-errorsuppressed/canonical-mismatch~29% of all render outcomes are http-error suppressions, steady at 3–5.8k per 30-min bucket all day — roughly 356k/day fleet-wide. Each dead URL costs two renders (strike 1 suppresses, strike 2 fourteen days later deletes). That is a third of the render budget spent re-proving that dead URLs are dead.
Why the ceiling depends on attribution
The two cases are not symmetric, and the asymmetry runs opposite to the intuition:
200from the origin:maybeSchedulechecks the status, andisPrerenderCandidatechecks it again. A URL the origin answers 404/410 for cannot be re-minted by a crawler hit, so retiring it is terminal. The suppressed row it would otherwise hold blocks nothing, and the row + schedule row + recheck render are pure waste.actionForExistingreturnsCREATEfor an absent target andSKIPfor a suppressed one, so the suppressed row is the only thing holding the walk off. Deleting trades one recheck perrecheckIntervalfor a full render persitemap.refreshInterval— at a 6h refresh against a 14d recheck, ~28× more render work, not less.Scoped to gone verdicts deliberately: noindex and canonical-mismatch verdicts come from pages the origin serves
200for, so discovery re-mints them on the next bot request (a<meta>noindex is not even visible toisCandidateFromHeaders, which reads headers) and retiring those on sight would loop the same way.Shape
packages/plugin/src/util/suppression.js—gradeSuppression()/isGoneVerdict(), pure and config-reading likeutil/failureBackoff.js, so the decision is unit-testable (Target.jssubclasses a table at import time and cannot be loaded without a live Harper).Target.suppressreads the grading instead of inlining it.sitemapUrlwas already in its projection, so there is no extra read.render.suppression.gone.maxStrikesUnlisted(default1,min: 1). Set it equal togone.maxStrikesto restore the pre-0.67.0 behaviour.A URL that departs a sitemap is patched to
sitemapUrl: nullfirst, so it falls into the retire-immediately class — which is what lets this compose with the sitemap-departure work rather than fight it.Tests
npm testinpackages/plugin: 1058 pass, 0 fail. Lint andformat:checkclean.test/suppression.test.js(6 tests) over the grading.test/suppressionStatus.test.js:seedSourcegainssitemapUrl; the four existing gone tests now seed a listed target (the suppression path they were always exercising), plus three new tests for the unlisted path — unlisted 404 and 410 retire on the first verdict, and an unlisted non-gone verdict still suppresses rather than retiring.Risk
The retirement is on a path that already deleted the cached pages at strike 1, so nothing that was being served stops being served any sooner. What changes is that the target row and its schedule row go too, and the 14-day recheck render never happens. If a 404 was wrong (an origin blip), a sitemap-listed URL is untouched by this change, and an unlisted one is restored by the next bot request that gets a 200 — which is how it was discovered in the first place.
🤖 Generated with Claude Code