Repository navigation
Re-check URLs that leave a sitemap, per route (v0.68.0) - #164
Merged
harper-joseph merged 1 commit intoSep 17, 2026
Merged
Conversation
A refresh unlinks a departed target (`sitemapUrl -> null`) and leaves it rendering on its own cadence. That is right for a URL that merely stopped being declared; on a retail catalog it is not. A product that sells out or is withdrawn leaves the product sitemap the same day (measured on one deployment: 57/58 sold-out products absent from that day's sitemap, 77/77 in-stock present), while its page keeps serving the snapshot taken when it was still available — until its next scheduled render, which on a slow cadence is most of a day of serving markup the origin no longer agrees with. New per-route `ingress.routes[].departureAction`: none — unlink and nothing else. The default, and the previous behaviour. expire — hard-expire the cached pages; the URL re-renders on its own cadence. render — hard-expire AND file the URL to render at the current minute. PER ROUTE because only some URL classes carry the meaning: a product URL leaving a product sitemap says something about that product, while a listing URL leaving a paginated sitemap usually means the catalog was re-bucketed, and acting on it would expire current pages. A RE-CHECK, NOT A RETIREMENT. Departure is a statement about the declaration, not the page: the origin typically still serves the URL with out-of-stock markup. The render's own verdict decides what follows — a page that is merely unavailable re-renders and stays, and one the origin has actually retired answers 404/410 and is retired by the suppression path on the first verdict, since the target is now unlinked (v0.67.0). HARD-expired, past `page.swrTtl` rather than to `now`: a plain expiry leaves the page 'swr' — still SERVING for another swrTtl — and the swr window exists to smooth over a late re-render of content presumed still right, which a departed product page is not. Same reasoning as `changeProbe.triggerRevalidate`. THE CHECK RUNS AFTER THE WHOLE WALK, which is the load-bearing detail rather than an optimisation. A paginated corpus shears across child boundaries: children are walked in order, so a URL moving to an EARLIER child is re-attached before the child it left is pruned and never looks departed — but one moving to a LATER child is pruned first and looks departed until the child that now claims it is reached. Acting at prune time would fire on every URL that shifted forward, which on a fixed-size paginated sitemap is every URL after an insertion. Candidates are re-read once the walk ends and anything that picked up an attribution is dropped, counted as `reattached`. Bounded and observable: `sitemap.departure.dryRun` defaults TRUE (the first thing a deployment needs is the count a real walk would act on), `maxActions` caps actions per walk so one truncated-but-valid child sitemap cannot expire a large slice of the cache, and `maxCandidates` caps the in-memory list. Every candidate is decided and counted by outcome, on the result and the progress row, with a `sitemap_departure_*` series each. `departureAction` is compiled and validated in `compileEntry` alongside renderInterval / discoverTargets / demandFloor, under the same drop-the-FIELD rule: a typo costs that route its departure check, never how the path is served. 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 sitemap departure feature (version 0.68.0) that handles URLs leaving a sitemap. It adds configuration options (departureAction per route, and a global departure configuration group), implements a post-walk departure check to safely handle boundary-shearing in paginated sitemaps, and allows hard-expiring cached pages or triggering immediate re-renders for departed URLs. Comprehensive unit tests and metrics tracking have also been added. There are no review comments, and the implementation appears robust and well-tested, so I have no feedback to provide.
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 refresh unlinks a departed target (
sitemapUrl -> null) and leaves it rendering on its own cadence. This adds a per-routeingress.routes[].departureActionso a route can say that departure means something:noneexpirerenderWhy
A product that sells out or is withdrawn leaves the product sitemap the same day — measured on one deployment, 57/58 sold-out products absent from that day's sitemap, 77/77 in-stock present. Its page keeps serving the snapshot taken while it was still available until the next scheduled render, which on a slow cadence is most of a day of serving markup the origin no longer agrees with.
Per route, because only some URL classes carry that meaning. A product URL leaving a product sitemap says something about that product; a listing URL leaving a paginated sitemap usually means the catalog was re-bucketed, and acting on it would expire pages that are perfectly current.
A re-check, not a retirement. Departure is a statement about the declaration, not the page — the origin typically still serves the URL, 200, with out-of-stock markup. The render's own verdict decides what follows: a page that is merely unavailable re-renders with correct markup and stays; one the origin has actually retired answers 404/410 and is retired by the suppression path on the first verdict, because the target is now unlinked (#163).
Two details that are load-bearing
The expiry is hard. Past
page.swrTtl, not tonow. A plainDate.now()expiry leaves the pageswr(util/pageFreshness.js) — i.e. still serving for anotherswrTtl. The swr window exists to smooth over a late re-render of content presumed still right, which is exactly what a departed product page is not. Same reasoning, and same arithmetic, aschangeProbe.triggerRevalidate;Target.revalidatekeeps its plain expiry deliberately.The check runs after the whole walk. This is not an optimisation. A paginated corpus shears across child boundaries: children are walked in order, so a URL moving to an earlier child is re-attached before the child it left is pruned and never looks departed — but one moving to a later child is pruned first and looks departed until the child that now claims it is reached. Acting at prune time would fire on every URL that shifted forward, which on a fixed-size paginated sitemap is every URL after an insertion. Candidates are collected during the walk, re-read once it ends, and anything that picked up an attribution is dropped and counted as
reattached.That counter is also the diagnostic: a large
departure_reattachedshare means the corpus is shearing and the rawremovedcount is not a departure count.Bounded and observable
sitemap.departure.dryRundefaults true. The first thing any deployment needs is the number a real walk would act on — a number nobody has until they look, and the one that decides whethermaxActionsis a ceiling or a no-op.maxActions(5000) caps actions per walk, so one child sitemap that fetches truncated but still parses as valid XML cannot expire a large slice of the cache. Overflow is counted ascappedand left for the next walk.maxCandidates(50000) caps the in-memory list separately — it bounds memory across a walk that can prune millions.sitemap.departure.enabledis a master switch for incident response without editing the route list.SitemapRefreshprogress row, and asitemap_departure_*series each.departureCapis 0 andaddRemovedallocates nothing — the feature costs an untouched deployment one boolean per walk.Shape
util/sitemapDeparture.js— the decision table, pure, so every branch is testable without a database.departureActionis compiled and validated incompileEntryalongsiderenderInterval/discoverTargets/demandFloor, under the same drop-the-FIELD rule: a typo costs that route its departure check, never how the path is served. (This is why the field needed compiler support at all —compileEntrynormalizes to an explicit allowlist, so an unknown field is silently dropped.)createRefreshRungains a bounded candidate list and an outcome tally. ItsaddRemovedloop had abreakatremovedSampleCapthat would have truncated the candidate list to the sample size; that is now two independent caps, with a test pinning it.Tests
npm testinpackages/plugin: 1073 pass, 0 fail. Lint andformat:checkclean. 15 new tests cover the route opt-in and the master switch, an invalid action compiling away tonone, the re-attached/suppressed/target-gone/route-opted-out skips, and the two independent caps.The existing
queueFunnelguard caught a hardcodedfromSitemap: falsein the new schedule write; it now derives from the live target, which is what that guard asks for and stays correct if the shear guard above it ever moves.Rollout
Ships inert: no route opts in, and
dryRunis on. The sequence is to setdepartureAction: 'render'on the product route, read one walk'sdeparture_would_renderanddeparture_reattached, sizemaxActionsagainst them, then turndryRunoff.🤖 Generated with Claude Code