Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 11 additions & 11 deletions packages/plugin/METRICS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harperfast/prerender",
"version": "0.72.0",
"version": "0.73.0",
"type": "module",
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
"license": "Apache-2.0",
Expand Down
33 changes: 33 additions & 0 deletions packages/plugin/src/configSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -1732,6 +1732,39 @@ export const configSchema = group('Prerender plugin configuration.', {
{ min: 0 }
),
failedCap: option(100, 'Max failed-entry samples carried back in a refresh result.', { min: 0 }),
conditional: group(
'Conditional sitemap fetching: send `If-Modified-Since` and skip the whole reconcile for a ' +
'document the origin answers 304 to.\n\n' +
'WHAT IT BUYS. A pass re-fetches every child and scans the `sitemapUrl` index once per ' +
'child, and it is that prune scan — a held read cursor, whose seconds scale linearly with ' +
'refresh frequency — that sets the real cost of refreshing often. A 304 skips the body, the ' +
'parse, the scan and every write, so an unchanged pass costs one request per document and ' +
'no database work at all. That is what makes polling for a change affordable instead of ' +
'merely possible: a deployment whose sitemaps rebuild once a night can check every few ' +
'minutes and pay for the walk only on the pass that finds the rebuild.\n\n' +
'USE `Last-Modified`, NOT `ETag`, AND DO NOT ASSUME EITHER. Measured on one production ' +
'edge: `If-Modified-Since` returned a clean 304, while `If-None-Match` sent back the exact ' +
'ETag the same edge had just served and got 200 with the full multi-megabyte body. An ' +
'origin that advertises a validator is not promising to honour it, which is why the ' +
'`not_modified` counter is worth watching — a steady zero here means every pass is doing ' +
'full work and the frequency should come back down.\n\n' +
'AN INDEX IS STILL DESCENDED on a 304: that only says the CHILD LIST is unchanged, not the ' +
'children, and on a real corpus the children rebuild on a different schedule from the index ' +
'that lists them. Each child then makes its own conditional decision.',
{
enabled: option(true, 'Send `If-Modified-Since` when a stored validator is available.'),
fullPassInterval: option(
24 * HOUR,
'Force an UNCONDITIONAL fetch of a document whose entries have not been ingested in this ' +
'long. This is the repair net and it is why the feature is safe to leave on: a 304 skips ' +
'the reconcile, and the reconcile is also what re-CREATES targets lost to anything else — ' +
'a bad purge, a half-applied delete, a botched migration. Without a periodic full pass a ' +
'corpus could drift for as long as the origin left its sitemaps untouched and nothing ' +
'would notice. Set it to 0 to make every fetch unconditional (the pre-0.69.0 behaviour).',
{ unit: 'ms', min: 0 }
),
}
),
departure: group(
'What a refresh does about URLs that LEAVE a sitemap, beyond unlinking them. The action is ' +
'declared PER ROUTE (`ingress.routes[].departureAction`); this group bounds and observes it, ' +
Expand Down
55 changes: 50 additions & 5 deletions packages/plugin/src/resources/Sitemap.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { actionForExisting, canSkipLookup, createRefreshRun, TargetAction } from
import { configuredStagingIp, dispatcherFor } from '../util/upstream.js';
import { setImmediate } from 'node:timers/promises';
import { applyInBatches, collectFromScan } from '../util/scan.js';
import { conditionalValidatorFor } from '../util/sitemapConditional.js';
import { decideDeparture, DepartureAction } from '../util/sitemapDeparture.js';
import { cacheKeysOf } from './Target.js';
import { writeSchedule } from '../util/renderSchedule.js';
Expand Down Expand Up @@ -363,6 +364,7 @@ const progressFields = (snapshot) => ({
created: snapshot.created,
updated: snapshot.updated,
skipped: snapshot.skipped,
notModified: snapshot.notModified,
duplicates: snapshot.duplicates,
deferred: snapshot.deferred,
removed: snapshot.removed,
Expand Down Expand Up @@ -407,9 +409,9 @@ async function runTrackedRefresh(rootUrl, options) {
});

logger.info(
`[prerender] Sitemap refresh for ${rootUrl} finished: ${result.sitemapsProcessed} sitemaps, ` +
`${result.created} created, ${result.updated} re-attributed, ${result.skipped} unchanged, ` +
`${result.removed} unlinked, ${result.failed.length} failed`
`[prerender] Sitemap refresh for ${rootUrl} finished: ${result.sitemapsProcessed} sitemaps ` +
`(${result.notModified} not modified), ${result.created} created, ${result.updated} re-attributed, ` +
`${result.skipped} unchanged, ${result.removed} unlinked, ${result.failed.length} failed`
);

// The same numbers as METRICS — corpus churn and walk health, previously log-only.
Expand All @@ -419,6 +421,7 @@ async function runTrackedRefresh(rootUrl, options) {
metrics.sitemapRun(result.created, 'created');
metrics.sitemapRun(result.updated, 'updated');
metrics.sitemapRun(result.skipped, 'skipped');
metrics.sitemapRun(result.notModified, 'not_modified');
metrics.sitemapRun(result.removed, 'removed');
metrics.sitemapRun(result.failed.length, 'failed');
} catch (e) {
Expand All @@ -439,12 +442,40 @@ async function runTrackedRefresh(rootUrl, options) {
*
* The stored row is written last, so a document that throws partway leaves the previous row —
* and its `lastRefreshed` — untouched rather than recording a refresh that did not happen.
*
* A 304 writes NOTHING — the stored row is still current, validator included — and for a urlset
* skips the reconcile entirely, which is where a pass's real cost lives: the per-child prune scan
* holds a read cursor, and cursor-seconds are what scale with refresh frequency. That is what
* makes polling often affordable rather than merely possible.
*/
async function refreshOneSitemap(sitemapUrl, { parentUrl, revalidate, run, visited }) {
logger.info(`Processing sitemap`, sitemapUrl);

const latestSitemap = await fetchLatestSitemap(sitemapUrl);
// A narrow projection on purpose: `entries` on a product child is megabytes, and this read
// happens for every document on every pass. The entries are read back only on the one path
// that needs them — a 304 on an INDEX, whose row is small by construction.
const stored = await Sitemap.get({ id: sitemapUrl, select: ['url', 'isIndex', 'lastModified', 'lastRefreshed'] });
const ifModifiedSince = conditionalValidatorFor(stored, revalidate);

const latestSitemap = await fetchLatestSitemap(sitemapUrl, { ifModifiedSince });

if (latestSitemap.notModified) {
run.count('notModified');

// An INDEX still has to be descended. A 304 says the CHILD LIST is unchanged, not that the
// children are — they are separate documents with their own validators, and on a real corpus
// they move on a different schedule from the index that lists them (measured: children
// rebuilt nightly, the index that lists them at a different hour entirely). So re-read the
// stored entries and keep walking; each child then makes its own conditional decision.
if (stored?.isIndex === true) {
const storedRow = await Sitemap.get({ id: sitemapUrl, select: ['url', 'entries'] });
return (storedRow?.entries ?? []).map(({ loc }) => loc).filter(Boolean);
}
return [];
}

const row = { ...latestSitemap, parentUrl };
delete row.notModified;

if (latestSitemap.isIndex === true) {
await Sitemap.put(sitemapUrl, row);
Expand Down Expand Up @@ -780,7 +811,7 @@ async function processDepartures(run) {
}
}

async function fetchLatestSitemap(url) {
async function fetchLatestSitemap(url, { ifModifiedSince = null } = {}) {
// Route every Harper→origin sitemap fetch through the same edge as the render/origin-fetch
// path: whenever a staging IP is configured, pin the TCP connection to it (Host/SNI stay the
// real origin, exactly like upstream.js). The security token typically only authenticates
Expand All @@ -795,9 +826,19 @@ async function fetchLatestSitemap(url) {
headers: {
'User-Agent': config.sitemap.userAgent,
[config.origin.securityToken.header]: config.origin.securityToken.value,
// Echoed back VERBATIM from the stored row — see the schema comment. Absent on the first
// fetch of a document, when the origin sends no validator, and whenever the caller wants a
// full re-ingest.
...(ifModifiedSince ? { 'If-Modified-Since': ifModifiedSince } : {}),
},
dispatcher: dispatcherFor(stagingIp),
});

// BEFORE the `res.ok` guard, because 304 is not ok: `Response.ok` is 200-299, so a
// not-modified would otherwise be thrown as a failed fetch. Nothing else to read — a 304 has no
// body — and nothing to write: the stored row is still current, validator included.
if (res.status === 304) return { url, notModified: true };

const xml = await res.text();

// A blocked/errored fetch returns an HTML error page with a 4xx/5xx status. Guard the
Expand All @@ -819,10 +860,14 @@ async function fetchLatestSitemap(url) {

return {
url,
notModified: false,
lastRefreshed: new Date(),
isIndex: parsed.isIndex,
entries: parsed.entries,
entryCount: parsed.entries.length,
// Null where the origin sends none, which makes every later fetch of this document
// unconditional — the correct degradation, not an error.
lastModified: res.headers.get('last-modified') ?? null,
};
}

Expand Down
10 changes: 10 additions & 0 deletions packages/plugin/src/schemas/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,17 @@ type Sitemap @table(database: "sitemaps") {
isIndex: Boolean
entryCount: Int
entries: [SitemapEntry]
# When this document's ENTRIES were last ingested — NOT merely when it was last checked. A
# conditional fetch that comes back 304 leaves this alone, so it keeps meaning "the reconcile
# behind this row ran then", which is what `sitemap.conditional.fullPassInterval` measures
# against and what an operator reading the console wants it to mean.
lastRefreshed: Date
# The origin's `Last-Modified` for this document, replayed as `If-Modified-Since` on the next
# fetch. Stored as the RAW HEADER STRING, deliberately: it is an opaque validator to be echoed
# back verbatim, and parsing it to a Date and reformatting risks handing the origin a value
# that differs by a second and re-fetching the whole corpus. Null where the origin sends none,
# which simply means every fetch of that document is unconditional.
lastModified: String
# The index that listed this sitemap, or null for one added directly.
#
# Every document reached during a walk gets its own row, children included, so without this
Expand Down
47 changes: 47 additions & 0 deletions packages/plugin/src/util/sitemapConditional.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { config } from '../config.js';
import { epochMsOf } from './time.js';

/**
* Whether one sitemap document can be fetched CONDITIONALLY, and with what validator.
*
* Lives here rather than in `resources/Sitemap.js` for the usual reason: that module subclasses a
* table at import time and cannot be loaded without a live Harper, so a decision left inside it is
* untestable.
*
* ── WHY `Last-Modified` AND NOT `ETag` ───────────────────────────────────────────────────────
*
* Measured against a production edge: `If-Modified-Since` returned a clean 304 with no body, while
* `If-None-Match` — sent back with the exact ETag that same edge had just served — returned 200 and
* the full multi-megabyte document. An origin that advertises a validator is not promising to
* honour it, and an ETag-based conditional fetch fails in the worst possible way: it looks correct,
* returns 200 every time, and silently re-transfers the whole corpus on every pass. Hence the stored
* validator is the `Last-Modified` string, echoed back verbatim.
*
* ── WHAT A 304 SKIPS, AND WHY THAT IS THE POINT ──────────────────────────────────────────────
*
* Not just the download. A pass scans the `sitemapUrl` index once per child, and that prune scan
* holds a read cursor whose seconds scale linearly with refresh frequency — it is the cost that
* decides how often a corpus can afford to be refreshed. A 304 skips the body, the parse, the scan
* and every write, so an unchanged pass costs one request per document and no database work.
*/
export const conditionalValidatorFor = (stored, revalidate) => {
const { enabled, fullPassInterval } = config.sitemap.conditional;
if (!enabled || revalidate) return null;

// No validator: first walk of this document, or an origin that sends none. Conditional
// fetching degrades to unconditional rather than to broken.
if (!stored?.lastModified) return null;

// THE REPAIR NET. A 304 skips the reconcile, and the reconcile is also what re-CREATES targets
// lost to anything else — a bad purge, a half-applied delete, a botched migration. Without a
// periodic unconditional pass a corpus could drift for as long as the origin left its sitemaps
// untouched, with nothing noticing. `lastRefreshed` means "entries last INGESTED" precisely so
// it can be measured against here; a 304 deliberately does not update it.
//
// Written as `!(elapsed < interval)` rather than `elapsed >= interval` so that a NaN — an
// unreadable or absent date — takes the unconditional branch instead of silently reading as
// "ingested at the epoch" or, worse, passing the comparison.
if (!(Date.now() - epochMsOf(stored.lastRefreshed) < fullPassInterval)) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Always perform a truthiness check on date values before parsing or coercing them. In JavaScript, passing null to new Date() (which can happen inside epochMsOf if stored.lastRefreshed is null) evaluates to 0 (epoch 0) instead of NaN. This can corrupt calculations and cause a document with a missing or null lastRefreshed value to be incorrectly treated as recently ingested (especially if fullPassInterval is large or during testing), bypassing the unconditional repair net. Adding an explicit truthiness check ensures we safely fall back to unconditional fetching.

Suggested change
if (!(Date.now() - epochMsOf(stored.lastRefreshed) < fullPassInterval)) return null;
if (!stored.lastRefreshed) return null;
if (!(Date.now() - epochMsOf(stored.lastRefreshed) < fullPassInterval)) return null;
References
  1. When parsing or coercing date values in JavaScript, always perform a truthiness check first (e.g., ensuring the value is not null or undefined) before passing it to Date(). This avoids bugs where new Date(null).getTime() evaluates to 0 (epoch 0) instead of NaN.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining this one — the premise does not hold in this codebase. epochMsOf already rejects null explicitly rather than handing it to new Date():

export const epochMsOf = (value) => (value || value === 0 ? new Date(value).getTime() : Number.NaN);

so epochMsOf(null) is NaN, not 0. That helper exists for precisely the reason this comment gives — its own doc comment calls Number(null) being 0 "the most plausible-looking wrong answer available" and explains that accepting a missing value as the epoch has real teeth on the claim-floor paths.

Worth noting the direction too: even if it did coerce to 0, the result would be Date.now() - 0, which is far larger than fullPassInterval, so the comparison fails and the function returns null — an unconditional fetch. That is the repair net firing, not being bypassed.

The guard is deliberately written as !(elapsed < interval) rather than elapsed >= interval so that a NaN takes the same unconditional branch, and the comment above it says so. test/sitemapConditional.test.js pins both shapes:

test('an unreadable lastRefreshed falls through to unconditional', () => {
	setConditional();
	assert.equal(conditionalValidatorFor(stored({ lastRefreshed: 'not a date' }), false), null);
	assert.equal(conditionalValidatorFor(stored({ lastRefreshed: null }), false), null);
});


return stored.lastModified;
};
5 changes: 5 additions & 0 deletions packages/plugin/src/util/sitemapRun.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export const createRefreshRun = ({ removedSampleCap = 20, failedCap = 100, depar
duplicates: 0,
deferred: 0,
removed: 0,
// Documents the origin answered 304 to, so their entries were never re-parsed and their
// prune scan never ran. On a healthy corpus this is most of every pass between rebuilds;
// a steady ZERO where conditional fetching is enabled means the origin is not honouring
// If-Modified-Since and every pass is doing full work.
notModified: 0,
sitemapsProcessed: 0,
sitemapsDiscovered: 0,
};
Expand Down
61 changes: 61 additions & 0 deletions packages/plugin/test/sitemapConditional.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { applyOptions } from '../src/config.js';
import { conditionalValidatorFor } from '../src/util/sitemapConditional.js';

globalThis.logger ??= { debug() {}, info() {}, warn() {}, error() {} };

const HOUR = 60 * 60 * 1000;
const LM = 'Wed, 16 Sep 2026 06:08:37 GMT';

const setConditional = (overrides = {}) =>
applyOptions({ sitemap: { conditional: { enabled: true, fullPassInterval: 24 * HOUR, ...overrides } } });

const stored = (over = {}) => ({ lastModified: LM, lastRefreshed: new Date(Date.now() - HOUR), ...over });

test('a recently-ingested document with a validator is fetched conditionally', () => {
setConditional();
assert.equal(conditionalValidatorFor(stored(), false), LM);
});

// The repair net: a 304 skips the reconcile, and the reconcile is also what re-creates targets
// lost to anything else. Past the interval the document is re-ingested whether it changed or not.
test('a document not ingested within fullPassInterval is fetched unconditionally', () => {
setConditional();
assert.equal(conditionalValidatorFor(stored({ lastRefreshed: new Date(Date.now() - 25 * HOUR) }), false), null);
});

test('no stored validator means unconditional — degrade to full, never to broken', () => {
setConditional();
assert.equal(conditionalValidatorFor(stored({ lastModified: null }), false), null);
assert.equal(conditionalValidatorFor(stored({ lastModified: '' }), false), null);
});

test('a document never seen before is fetched unconditionally', () => {
setConditional();
assert.equal(conditionalValidatorFor(null, false), null);
assert.equal(conditionalValidatorFor(undefined, false), null);
});

// An unreadable or absent date must not read as "ingested at epoch 0" and certainly not as
// "recently ingested": NaN fails the comparison, which falls through to a full fetch.
test('an unreadable lastRefreshed falls through to unconditional', () => {
setConditional();
assert.equal(conditionalValidatorFor(stored({ lastRefreshed: 'not a date' }), false), null);
assert.equal(conditionalValidatorFor(stored({ lastRefreshed: null }), false), null);
});

test('revalidate always fetches unconditionally — the operator asked for a re-ingest', () => {
setConditional();
assert.equal(conditionalValidatorFor(stored(), true), null);
});

test('disabling the feature restores unconditional fetching everywhere', () => {
setConditional({ enabled: false });
assert.equal(conditionalValidatorFor(stored(), false), null);
});

test('fullPassInterval 0 makes every fetch unconditional', () => {
setConditional({ fullPassInterval: 0 });
assert.equal(conditionalValidatorFor(stored({ lastRefreshed: new Date() }), false), null);
});