Skip to content

Bring the sitemap departure check onto main (v0.68.0) - #174

Merged
harper-joseph merged 2 commits into
mainfrom
fix/retire-gone-discovered
Sep 17, 2026
Merged

harper-joseph merged 2 commits into
mainfrom
fix/retire-gone-discovered

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

Mechanical follow-up to #164, no new code.

#164 was opened against fix/retire-gone-discovered (#163's branch) as a stacked PR. #163 merged to main first, and #164 was merged before GitHub had retargeted it — so its two commits landed on fix/retire-gone-discovered instead of main. This carries them across.

Diff is exactly:

3ed0d4a Merge pull request #164 from HarperFast/feat/sitemap-departure-check
0cd2b07 feat(plugin): re-check URLs that leave a sitemap, per route; v0.68.0

Content, rationale and tests are all reviewed on #164 — gemini's review there raised no comments. Ships inert: no route sets departureAction and sitemap.departure.dryRun defaults true.

🤖 Generated with Claude Code

harper-joseph and others added 2 commits September 16, 2026 19:20
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>
Re-check URLs that leave a sitemap, per route (v0.68.0)
@harper-joseph
harper-joseph merged commit 79db926 into main Sep 17, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a sitemap departure check feature (v0.68.0) to handle URLs that leave a sitemap, allowing routes to opt into actions like hard-expiring cached pages or scheduling immediate re-renders. The changes include new configuration options, post-walk processing logic, metrics updates, and comprehensive unit tests. Feedback on the implementation suggests wrapping the individual URL processing logic within the batch execution in a try-catch block to prevent a single database failure from aborting the entire departure check.

Comment on lines +702 to +761
apply: async (url) => {
const target = await Target.get({
id: url,
// `sitemapUrl` is the shear guard, `state` keeps suppressed targets out, and the two
// cadence fields are what `resolveEffectiveInterval` needs to file a rung-correct row.
select: ['url', 'sitemapUrl', 'state', 'renderInterval', 'demandInterval'],
});

const { action, reason } = decideDeparture({ url, target });
if (action === DepartureAction.NONE) {
run.countDeparture(reason);
return;
}

// Check and increment in ONE synchronous block. `applyInBatches` runs a batch's items in
// parallel, so a check that awaited anything before incrementing would let a whole batch
// through a cap of one.
if (acted >= maxActions) {
run.countDeparture('capped');
return;
}
acted++;

if (dryRun) {
run.countDeparture(`would-${action}`);
return;
}

// HARD-expired, past the stale-while-revalidate window rather than to `now`. A plain
// `Date.now()` expiry leaves the page 'swr' (`util/pageFreshness.js`), i.e. still SERVING
// for another `page.swrTtl` — and 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.
// This matches `changeProbe.triggerRevalidate`, which backdates for the same reason;
// `Target.revalidate` keeps the plain expiry deliberately, because an operator asking for
// a re-render is not asserting the content is wrong.
const hardExpiredAt = Date.now() - config.page.swrTtl;
await Promise.all(
cacheKeysOf(url).map(async (cacheKey) => {
const page = await PrerenderedPage.get({ id: cacheKey, select: ['cacheKey', 'expiresAt'] });
if (page) await PrerenderedPage.patch(cacheKey, { expiresAt: hardExpiredAt });
})
);

if (action === DepartureAction.RENDER) {
// THE CURRENT MINUTE, PER URL — never captured once for the whole pass. Rows are
// residency-routed, and a minute more than `queue.claimFloor.guard` old lands below the
// owner's floor and is never claimed again. This is the Target.revalidate lesson.
await writeSchedule(url, {
nextRenderTime: currentMinuteMs(),
// Derived, never a literal `false`, even though `decideDeparture` has already proved
// this target has no attribution and so this can only evaluate false. The literal is
// what `test/queueFunnel.test.js` forbids outright, and for a good reason: the
// derivation stays correct if this guard ever moves, where a literal would silently
// un-flag a sitemap-listed URL the day someone loosened the check above it.
fromSitemap: !!target.sitemapUrl,
effectiveInterval: resolveEffectiveInterval(url, target),
});
}
run.countDeparture(action);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The apply function processes each departed URL individually. However, if any single URL fails (e.g., due to a transient database read/write error or a missing record), the entire applyInBatches promise will reject, aborting the departure check for all remaining URLs. Wrapping the inner logic of the apply function in a try...catch block ensures that a single failure is isolated, logged, and counted, allowing the rest of the departed URLs to be processed successfully. Additionally, this implementation correctly parallelizes the independent sub-operations (cache keys/device variants within a single URL) using Promise.all while serializing the top-level URL batches.

		apply: async (url) => {
			try {
				const target = await Target.get({
					id: url,
					// `sitemapUrl` is the shear guard, `state` keeps suppressed targets out, and the two
					// cadence fields are what `resolveEffectiveInterval` needs to file a rung-correct row.
					select: ['url', 'sitemapUrl', 'state', 'renderInterval', 'demandInterval'],
				});

				const { action, reason } = decideDeparture({ url, target });
				if (action === DepartureAction.NONE) {
					run.countDeparture(reason);
					return;
				}

				// Check and increment in ONE synchronous block. `applyInBatches` runs a batch's items in
				// parallel, so a check that awaited anything before incrementing would let a whole batch
				// through a cap of one.
				if (acted >= maxActions) {
					run.countDeparture('capped');
					return;
				}
				acted++;

				if (dryRun) {
					run.countDeparture(`would-${action}`);
					return;
				}

				// HARD-expired, past the stale-while-revalidate window rather than to `now`. A plain
				// `Date.now()` expiry leaves the page 'swr' (`util/pageFreshness.js`), i.e. still SERVING
				// for another `page.swrTtl` — and 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.
				// This matches `changeProbe.triggerRevalidate`, which backdates for the same reason;
				// `Target.revalidate` keeps the plain expiry deliberately, because an operator asking for
				// a re-render is not asserting the content is wrong.
				const hardExpiredAt = Date.now() - config.page.swrTtl;
				await Promise.all(
					cacheKeysOf(url).map(async (cacheKey) => {
						const page = await PrerenderedPage.get({ id: cacheKey, select: ['cacheKey', 'expiresAt'] });
						if (page) await PrerenderedPage.patch(cacheKey, { expiresAt: hardExpiredAt });
					})
				);

				if (action === DepartureAction.RENDER) {
					// THE CURRENT MINUTE, PER URL — never captured once for the whole pass. Rows are
					// residency-routed, and a minute more than `queue.claimFloor.guard` old lands below the
					// owner's floor and is never claimed again. This is the Target.revalidate lesson.
					await writeSchedule(url, {
						nextRenderTime: currentMinuteMs(),
						// Derived, never a literal `false`, even though `decideDeparture` has already proved
						// this target has no attribution and so this can only evaluate false. The literal is
						// what `test/queueFunnel.test.js` forbids outright, and for a good reason: the
						// derivation stays correct if this guard ever moves, where a literal would silently
						// un-flag a sitemap-listed URL the day someone loosened the check above it.
						fromSitemap: !!target.sitemapUrl,
						effectiveInterval: resolveEffectiveInterval(url, target),
					});
				}
				run.countDeparture(action);
			} catch (e) {
				logger.error(`[prerender] Failed to process departure for ${url}: ${describeError(e)}`);
				run.countDeparture('failed');
			}
		},
References
  1. When performing batch database writes or revalidations, serialize the top-level batches (e.g., per-URL) to prevent long transactions from spanning monitor ticks and being aborted, but parallelize independent sub-operations (e.g., device variants within a single URL) using Promise.all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant