Conditional sitemap fetching — skip the reconcile on a 304 (v0.73.0) - #165
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces conditional sitemap fetching using the If-Modified-Since header to optimize performance and reduce database load during sitemap refreshes. It adds configuration options, GraphQL schema updates, and metrics tracking for unmodified sitemaps. Feedback on the changes highlights a potential bug in sitemapConditional.js where a null lastRefreshed value could evaluate to epoch 0 instead of NaN, potentially bypassing the unconditional repair net; a code suggestion is provided to add an explicit truthiness check.
| // 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; |
There was a problem hiding this comment.
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.
| if (!(Date.now() - epochMsOf(stored.lastRefreshed) < fullPassInterval)) return null; | |
| if (!stored.lastRefreshed) return null; | |
| if (!(Date.now() - epochMsOf(stored.lastRefreshed) < fullPassInterval)) return null; |
References
- 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.
There was a problem hiding this comment.
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);
});…304; v0.69.0 Every pass re-fetched every child sitemap in full and ran its prune scan, whether the document had changed or not. On one production corpus that is 31 documents and ~125 MB of XML per pass, four passes a day, to discover a change that happens once a night. `sitemap.conditional` sends `If-Modified-Since` from a stored `Last-Modified` and, on a 304, skips the body, the parse, the prune scan and every write. That last part is the point: the per-child prune scan holds a read cursor, and cursor-seconds are what scale linearly with refresh frequency, so an unchanged pass now costs one request per document and NO database work. Polling for a rebuild every few minutes becomes affordable rather than merely possible — which is what lets a deployment detect a nightly rebuild in minutes instead of waiting out a fixed grid slot. USE `Last-Modified`, NOT `ETag`, and this is measured, not assumed. Against a production edge, `If-Modified-Since` returned a clean 304 with no body; `If-None-Match` sent back with the exact ETag that same edge had just served returned 200 and the full 7.9 MB document. An ETag-based conditional fetch fails in the worst way available — it looks correct, returns 200 every time, and silently re-transfers the whole corpus forever. `sitemap_not_modified` exists so that failure is visible: a steady zero means the origin is not honouring the validator and the frequency should come back down. AN INDEX IS STILL DESCENDED on a 304. That 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 rebuild on a different schedule from the index that lists them (measured: children nightly, their index at a different hour entirely). The stored entries are re-read and each child makes its own conditional decision. `fullPassInterval` (24h) forces an unconditional fetch of any document whose entries have not been ingested in that 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 it a corpus could drift for as long as the origin left its sitemaps untouched. `lastRefreshed` now means "entries last INGESTED" precisely so it can be measured against; a 304 leaves it alone. Set the interval to 0 to restore the pre-0.69.0 behaviour. The decision lives in util/sitemapConditional.js so it is testable; `resources/Sitemap.js` subclasses a table at import time and cannot be loaded without a live Harper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 0.67.0-0.72.0 train merged without this one (held back deliberately), so 0.69.0 would now be a downgrade. Rebased onto main and renumbered; the METRICS.md row is merged rather than replaced, so sitemap_departure_*, probe_trigger_queue_depth and sitemap_not_modified all survive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2545ed9 to
f4f6784
Compare
…ng; v0.77.0 `POST /prerender_admin/explain` exists to answer "why does this URL behave this way", and it could not answer it for the one property operators ask about most. It reported Target.renderInterval and stopped — but that is the CEILING, not the cadence. The value a row is actually scheduled from is resolveEffectiveInterval(url, target), which folds four inputs (route interval, stored interval, default, ladder rung) through two clamps (the route's demandFloor, the route's interval as a ceiling), and the answer is routinely none of the numbers an operator can see. Measured on a production cluster this week: a PDP route configured `renderInterval: 96h` was rendering every 48h. 95.7% of its targets carried a ladder rung, the route's `demandFloor: 48h` equalled the ladder's slowest rung, so `max(rung, floor)` clamped every rung to exactly 48h and the configured 96h ceiling never bound once. The knob named "floor" was the real cadence and the knob named "ceiling" was inert. Establishing that took fifteen explain calls plus reading Target.demandInterval out of the table by hand for 162 URLs and working the algebra backwards — because `demandInterval` was not in explain's select at all. So: select it, and add a `cadence` block reporting the whole chain — every input, which one supplied the base, and what clamped the result. `clampedBy` is the field to read first: 'floor' means the ladder wanted this page faster and the route refused (seeing it across a route means the ladder has no dynamic range there), 'ceiling' means a stored rung is slower than the route allows (what a rung outliving a lowered interval looks like), null means no rung. `explainCadence` lives in util/routeClass.js beside the resolver, not in the admin view that renders it, and derives from the same functions rather than recomputing the algebra. A second implementation would be a second thing to keep correct, and its failure mode is the worst available to a diagnostic: a view that confidently explains a cadence the scheduler is not using. A test cross-checks the two across the input matrix rather than trusting they stay in step. MERGE AFTER #181 (v0.76.0). Open PRs #165, #177 and #178 reserve v0.73.0, v0.74.0 and v0.75.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed as part of the combined train (#165 → #177 → #178 → #181 → #182) alongside kohls-pr#108/#111. Trial-merged all five; they compose and 1148 tests pass together. First, the good news, because I went looking for the opposite. I expected the 304 short-circuit to break the post-walk sitemap-departure check. It does not: One narrow gap survives it. 1. Cross-child duplicate listings can produce a FALSE departure — MEDIUMBoth premises verified in the code:
The sequence:
On main, step 3 is a 200 and the candidate is correctly counted Bounded (one wasted render + one expiry each, at ~95/800k scale), not a cache-wide event. But it's silent, and it acts rather than skips. The same shape already exists on main for a child that fails its fetch — Cheapest mitigation is on the consumer side (keep #111's 2.
|
What
sitemap.conditionalsendsIf-Modified-Sincefrom a storedLast-Modifiedand, on a 304, skips the body, the parse, the prune scan and every write.Why this is worth more than the bytes
Every pass re-fetched every child in full and ran its prune scan whether the document had changed or not — on one production corpus, 31 documents and ~125 MB of XML per pass, four passes a day, to discover a change that happens once a night.
But the download is the smaller half. The per-child prune scan holds a read cursor, and cursor-seconds are the cost that scales linearly with refresh frequency (
sitemap.refreshInterval's own docs say this is the reason not to set it very low). A 304 skips that too, so an unchanged pass costs one request per document and no database work at all.That changes what is affordable. A deployment whose sitemaps rebuild once a night can now poll every few minutes and pay for the walk only on the pass that finds the rebuild — detecting it in minutes instead of waiting out a fixed grid slot, and without depending on the rebuild finishing before a fixed anchor.
Use
Last-Modified, notETag— measured, not assumedIf-Modified-Since: <last-modified>If-None-Match: <the exact ETag that edge just served>An origin that advertises a validator is not promising to honour it, and an ETag-based conditional fetch fails in the worst way available: it looks correct, returns 200 every time, and silently re-transfers the whole corpus forever.
sitemap_not_modifiedexists so that failure is visible — a steady zero where this is enabled means the origin is not honouring the validator and the frequency should come back down.Two details
An index is still descended on a 304. That only says the child list is unchanged, not the children — they are separate documents with their own validators, and on a real corpus they rebuild on a different schedule from the index that lists them (measured: children nightly at ~06:08 UTC, their index at 17:40 UTC the same day). The stored entries are re-read and each child makes its own conditional decision. Watching only the root would miss the rebuild entirely.
fullPassInterval(24h) is the repair net, and it is why this 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 unconditional pass a corpus could drift for as long as the origin left its sitemaps untouched, with nothing noticing.lastRefreshednow means "entries last ingested" precisely so it can be measured against; a 304 deliberately leaves it alone.0restores the pre-0.69.0 behaviour.Shape
schema.graphql:Sitemap.lastModified: String— additive. Stored as the raw header string and echoed back verbatim; parsing to aDateand reformatting risks handing the origin a value differing by a second and re-fetching everything.res.okguard —Response.okis 200–299, so a not-modified would otherwise be thrown as a failed fetch.url, isIndex, lastModified, lastRefreshed):entrieson a product child is megabytes and this read happens for every document on every pass. Entries are read back only on the one path that needs them — a 304 on an index, whose row is small by construction.util/sitemapConditional.jsso it is testable;resources/Sitemap.jssubclasses a table at import time.sitemap_not_modified, on the result, the progress row and the finish log.Degradation
No stored validator (first walk, or an origin that sends none) → unconditional. Unreadable
lastRefreshed→ unconditional; the comparison is written!(elapsed < interval)so a NaN takes the full-fetch branch rather than silently reading as "recently ingested".revalidate: true→ unconditional, since that is exactly a request to re-ingest.Tests
npm testinpackages/plugin: 1081 pass, 0 fail. Lint andformat:checkclean. 8 new tests cover the validator decision: recently-ingested-with-validator, pastfullPassInterval, missing/empty validator, never-seen document, unreadable date,revalidate, disabled, andfullPassInterval: 0.Follow-up (not in this PR)
Once this is deployed,
sitemap.refreshIntervalcan come down to polling range on the consumer side. That is a config change on the deployment, worth making only after one day ofsitemap_not_modifiedconfirms the origin honours the validator in production the way it did in the probe.🤖 Generated with Claude Code