Repository navigation
feat(plugin): cache origin documents for URLs outside the render rotation; v0.76.0 - #181
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a raw-document caching feature to cache origin documents for URLs outside the render rotation, reducing repeat origin fetches for long-tail crawlable spaces. This includes adding configuration options (rawCache and render.raw), database schemas (RawPage in raw_cache), metrics tracking, and integration into the bot request handler. Feedback on the changes suggests adding a truthiness check on stored.lastCached before passing it to epochMsOf to prevent unexpected date coercion bugs, and appending a .catch() block to the floating promise chain in captureForRawCache to safely handle any unhandled promise rejections.
| // (`ProbeState.pageSignature`), and nothing rendered this — an absent proof is the true answer, | ||
| // not a missing optimisation. | ||
| const rawEpoch = stored ? await resolveInvalidation(routeScopeForEntry(info.route)) : null; | ||
| const servable = stored && !(rawEpoch && !(epochMsOf(stored.lastCached) > rawEpoch.at)); |
There was a problem hiding this comment.
Perform a truthiness check on stored.lastCached before passing it to epochMsOf. Since lastCached is a nullable Date field in the RawPage schema, a missing or null value could evaluate to 0 (epoch 0) or cause unexpected behavior, potentially bypassing invalidation checks when no raw epoch is active.
| const servable = stored && !(rawEpoch && !(epochMsOf(stored.lastCached) > rawEpoch.at)); | |
| const servable = stored && stored.lastCached && !(rawEpoch && !(epochMsOf(stored.lastCached) > rawEpoch.at)); |
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
new Date(). This avoids bugs wherenew Date(null).getTime()evaluates to0(epoch 0) instead ofNaN, which can corrupt metrics or calculations expecting a missing date to result inNaN.
There was a problem hiding this comment.
Declining this one — the premise does not hold here, and the suggested change would narrow the behaviour.
epochMsOf already returns NaN for a missing value; that is the entire reason it exists rather than a bare new Date(). From its own docstring:
The empty check is not decoration:
new Date(null).getTime()is 0, notNaN, so a missing column would otherwise read as 1970 — an infinitely old timestamp — rather than as absent.
Verified against the real implementation:
lastCached |
epochMsOf |
servable vs an active epoch? |
|---|---|---|
null / undefined / '' |
NaN |
no |
'garbage' |
NaN |
no |
0 |
0 |
no |
Every unreadable value already fails closed, which is the direction that matters — the comparison is !(a > b) precisely so NaN refuses rather than serves. It is the same shape pageFreshness.js uses on the rendered-page path.
The suggestion would also change behaviour in a way I do not want: adding stored.lastCached && makes a row with no lastCached unservable even when no invalidation is active. For a raw page lastCached is not the freshness field — expiresAt is, and readRawPage has already enforced it before this line. So that row is perfectly servable absent an epoch, and refusing it would be a silent, unnecessary miss.
The second note on this PR (the floating promise) was right and is fixed in 2d16d09 — the catch now logs with e?.message ?? String(e) rather than swallowing.
🤖 Generated with Claude Code
| captured.then((result) => { | ||
| if (!result.bytes) { | ||
| metrics.rawCache(result.outcome); | ||
| return; | ||
| } | ||
| return storeRawPage({ cacheKey, resource, bytes: result.bytes, policy }); | ||
| }); |
There was a problem hiding this comment.
Add a .catch() block to the floating promise chain to handle any potential synchronous or asynchronous rejections thrown during the capture or storage process (e.g., if metrics.rawCache or storeRawPage throws). This prevents unhandled promise rejections in Node.js, and ensures we safely handle non-standard exceptions using e?.message ?? String(e).
| captured.then((result) => { | |
| if (!result.bytes) { | |
| metrics.rawCache(result.outcome); | |
| return; | |
| } | |
| return storeRawPage({ cacheKey, resource, bytes: result.bytes, policy }); | |
| }); | |
| captured.then((result) => { | |
| if (!result.bytes) { | |
| metrics.rawCache(result.outcome); | |
| return; | |
| } | |
| return storeRawPage({ cacheKey, resource, bytes: result.bytes, policy }); | |
| }).catch((e) => { | |
| logger.error?.("[prerender] raw cache capture failed: " + (e?.message ?? String(e))); | |
| }); |
References
- Ensure that error handling in critical boot-path operations (such as database subscriptions during startup) safely handles non-standard exceptions using
e?.message ?? String(e). ATypeErrorescaping a catch block on the boot path can cause the entire component or application to fail to load, whereas a safely handled error allows the system to fall back to a degraded but functional state (e.g., falling back to a poll instead of a subscription).
…tion; v0.76.0 On a large catalog the crawlable URL space is far bigger than the corpus worth rendering. Facet and parameter combinations a bot invents by following links own no Target, are never scheduled, and therefore MISS ON EVERY REQUEST — measured at 89.8% of requests to one deployment's listing route. Every one of those is an origin fetch for a document the origin had already served, minutes earlier, to a different crawler. So the fetch is not the thing to optimise away; the REPEAT of it is. On a miss for a route that opts in, the body the crawler is already reading is teed, captured and stored, and the next crawler asking for that URL is answered from storage. Marginal cost is one write: no render capacity, no second origin request, no scheduling. Off by default and off for every route until it opts in with `rawCache: true`. WHAT IT MUST NEVER DO, and what the tests pin: - It only ever replaces an ORIGIN PROXY. A stored document is read when PrerenderedPage held nothing at all — a true miss. 'stale' and 'invalidated' found a page row, which means a Target, which means a render is coming, and for those the live origin is the better answer. - It is not a prerendered snapshot, and nothing downstream may blur the two: raw serves carry their own cache status, are excluded from page_age (no render to be old relative to), and record no demand — a RawPage owns no Target, and feeding the ladder's Bloom filter the combinatorial facet space is what saturates it. - A bulk invalidation still reaches them. The epoch is read only when a stored row exists, compared with the same !(a > b) shape so an unreadable timestamp fails closed. No verification lookup: a verification certifies a render's claims, and nothing rendered this. - A personalized document is refused. `sanitizeOriginResponseHeaders` drops set-cookie, so fetchOriginResource now surfaces `hadSetCookie` — the only surviving evidence that the origin tried to personalize the response. Its own table in its own database, not a PrerenderedPage row. That table has no expiration and this population is unbounded and long-tailed by construction; it is not residency-pinned, so every row would replicate to every node; and page_cache is the database whose transaction log has been measured pegging worker threads at 85-95% of CPU on the receive side of a copy. Same reasoning Invalidation and PageVerification already give for their own databases. Bytes are stored exactly as the origin sent them, with the origin's own content-encoding, because response.js re-encodes from the stored header at serve time. content-length is dropped on the way in: a re-encoded body has a different length, and a stored one would contradict the bytes actually written. `expiry: midnight` exists for origins whose content steps at a fixed hour rather than drifting. There an interval is the wrong shape — a document fetched at 23:00 with a 6h TTL serves post-change content for five hours. There is no herd to spread, because raw pages refill on demand at the rate bots actually arrive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0f87d87 to
dc4c1cb
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>
|
Intended kohls rollout, decided 2026-09-18: catalog routes only — Recording it here because the config PR can't be written until this merges and the release is cut, and the reasoning is worth keeping with the feature. A raw-cached PDP is an unprobed Merchant Center surface: it carries prices, nothing renders it, and the change probe never sees it, so its TTL would be the only bound on price staleness. Catalog is the opposite case — the origin's SSR document already carries the whole SEO surface (title, canonical, the full We considered the more elaborate option — blanket-invalidate non-sitemap PDPs and reactivate them on probe evidence, i.e. the
So invalidate-by-default plus evidence-that-cannot-arrive produces the same serving behaviour as not caching, at the cost of a new scope axis and a probe extension. If PDP raw caching is ever wanted, the lever is a short TTL ( 🤖 Generated with Claude Code |
Eight fixes, one of which broke this PR's own stated invariant. INVALIDATION WAS DEFEATED BY A CONDITIONAL REQUEST. When the epoch refused a stored raw page the code fell through to the origin with cacheStatus still 'miss', and BOTH 304 defences key on that exact value — `stripValidators` here and `suppressConditional` in response.js. So a crawler returning with the etag it got from the stored document forwarded it, an origin whose ETag is publish-date-shaped answered 304, and the crawler kept the pre-invalidation bytes while every signal said the invalidation worked. It did not self-heal: the store fires only on a 200, so every validator-carrying request repeated it until expiry. A refused raw page now reports 'invalidated'. `invalidatedBy` stays unset — it drives the heal accelerator, which pulls a RENDER forward, and a raw URL owns no target. A ZERO-LENGTH 200 WAS STORED AND REPLAYED. `Buffer.concat([], 0)` is an empty buffer and empty buffers are truthy, so `!result.bytes` let an origin 200 with no body be stored and served as zero bytes under `content-encoding: gzip`. Checked on `.length` now, counted as `empty`. CAPTURING REMOVES ORIGIN->CRAWLER BACKPRESSURE, and the memory bound was understated by half. Without a capture a slow client costs socket buffers — the reader stops, the window closes, the origin holds the data. The capture reads in a tight loop, so the origin drains at full speed however slowly the client reads and this worker's heap becomes the buffer. Measured with a stalled reader: a 768 KB document held 768 KB queued for the unread branch PLUS 768 KB captured — ~2x maxBytes per capture, not 1x. On a mostly-miss route that was a heap lever the client controlled. Bounded now by `maxConcurrentCaptures` (default 16); past it a response is served without being captured, which costs nothing because the next request stores it. `await reader.cancel()` NEVER SETTLED when the crawler's branch was not drained — a tee branch's cancel returns the SHARED cancel promise. So for a disconnect, a HEAD, or a local 304 on an oversize document the `oversize` metric was never emitted (the one counter METRICS.md tells operators to watch) and a frame and reader stayed pinned. The await bought nothing. `render.raw.expiry: <ms>` WAS UNREACHABLE. The schema defaulted it to the string 'midnight' and config.js type-checks every override against its default, so a numeric value was rejected with one log line and the default silently kept — the documented setting did nothing, and the test assigned the field directly so it passed on a state config could not produce. Split into `expiry: midnight|interval` plus `expiryMs`, and the test now goes through applyOptions. Also: an unreadable raw blob was completely silent while costing the full blobReadBudgetMs per request until expiry — now `serve_error` raw-blob-timeout / raw-blob-unreadable, matching the page path. An explicit `missHeader: origin` no longer gets a stored document (it leaves cacheStatus 'miss', but it is an authorized "what does the origin have right now"). `raw` is declared in CACHE_STATUSES and SERVE_SOURCES and added to the console's CACHE_SERVED set — omitting it there would not read as missing, it would read as a smaller hit rate, exactly as `verified` did for one release. `x-harper-raw` is dropped: no reader anywhere, and being in the stored header set it shipped to every crawler ungated by the debug header. Guarded `value.byteLength` (NaN would disable the cap) and the detached promise's `.catch`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the review note on the detached promise. The catch already prevented an unhandled rejection; it now says what happened, using `e?.message ?? String(e)` because a non-Error throw is exactly what it exists to survive.
# Conflicts: # package-lock.json # packages/plugin/METRICS.md # packages/plugin/package.json
What
On a large catalog the crawlable URL space is far bigger than the corpus worth rendering. Facet and parameter combinations a bot invents by following links own no
Target, are never scheduled, and therefore miss on every request — measured at 89.8% of requests to one deployment's listing route. Every one of those is an origin fetch for a document the origin had already served, minutes earlier, to a different crawler.So the fetch is not the thing to optimise away; the repeat of it is. On a miss for a route that opts in, the body the crawler is already reading is teed, captured, and stored — and the next crawler asking for that URL is answered from storage. Marginal cost is one write: no render capacity, no second origin request, no scheduling.
Off by default (
render.raw.enabled: false) and off for every route until it opts in withrawCache: true. Unset config is byte-identical to today.What it must never do
These are the invariants, and each one is pinned by a test:
PrerenderedPageheld nothing at all — a true miss.staleandinvalidatedfound a page row, which means aTarget, which means a render is coming; for those the live origin is the better answer. Widening this tostalewould trade a live document for one up to a day old on exactly the URLs that have a render scheduled.page_age(there is no render to be old relative to), and record no demand — aRawPageowns noTarget, and feeding the ladder's Bloom filter the combinatorial facet space is exactly what saturates it, at which point it answers "visited" for everything.!(a > b)shape so an unreadable timestamp fails closed. No verification lookup: a verification certifies a render's claims against the origin, and nothing rendered this.sanitizeOriginResponseHeadersdropsset-cookie, sofetchOriginResourcenow surfaceshadSetCookie— the only surviving evidence that the origin tried to personalize the response.Its own table, not a
PrerenderedPagerowThree measured reasons, the same ones
InvalidationandPageVerificationalready give for their own databases:PrerenderedPagehas no expiration, and this population is unbounded and long-tailed by construction — a facet nobody visits again would keep its bytes forever.@table(expiration:)makes the tail collect itself.page_cacheis the database whose transaction log has been measured pegging worker threads at 85–95% of CPU on the receive side of a copy. A new blob stream of this shape does not belong in it.Cost on a raw miss is one extra point read. On a raw hit it is three: the
RawPage.get, an epoch read (LKG-cached), and the bounded blob read. All only on requests that already missed the rendered cache and were headed to the origin anyway.The encoding rule
Bytes are stored exactly as the origin sent them, with the origin's own
content-encoding(gzip), becauseresponse.jsre-encodes from the stored header at serve time.content-lengthis dropped on the way in: a re-encoded body has a different length, and a stored one would contradict the bytes actually written — the class of mismatch that makes a crawler read a truncated document under a 200.Consequence worth stating:
maxBytestherefore caps compressed bytes. It bounds memory and storage; it is not a test against a search engine's decompressed size limit. That is deliberately out of scope here.expiry: midnightNot a convenience spelling of 24h. It exists for origins whose content steps at a fixed hour rather than drifting: there an interval is the wrong shape — a document fetched at 23:00 with a 6h TTL serves post-change content for five hours, while one fetched at 01:00 expires long before anything about it has changed. There is no herd to spread, because raw pages refill on demand at the rate bots actually arrive.
On
tee()A
tee()buffers for whichever branch is slower. The capture reads in a tight loop, so it is always the faster branch and can never hold the crawler's response back — but that is the smaller half of the story, and an adversarial review measured the rest.Retention is ~2x
maxBytesper capture, not 1x: one copy inchunks, plus one the tee holds for the branch the crawler has not read. Measured with a stalled reader, a 768 KB document held 1.5 MB.And capturing removes origin→crawler backpressure. Without it a slow client costs socket buffers — the reader stops, the TCP window closes, the origin holds the data. The capture drains the origin at full speed however slowly the client reads, so this worker's heap becomes the buffer. On a route that is ~90% misses that was a heap lever the client controlled.
So the number of simultaneous captures is capped (
maxConcurrentCaptures, default 16); past it a response is served without being captured. Worst case is ~2 x maxBytes x maxConcurrentCapturesper worker. Degrading to "this one is not stored" costs nothing — the next request stores it.Cancelling one branch does not cancel the source, so abandoning an oversize capture leaves the response untouched — but the cancel is not awaited, because a tee branch's
cancel()returns the shared cancel promise and never settles when the crawler's branch goes undrained (disconnect, HEAD, local 304). The store itself is detached, never awaited on the response path.Fixed after adversarial review
cacheStatusat'miss', and both 304 defences key on that value — so a crawler replaying the storedetaggot a 304 from the origin and kept the pre-invalidation bytes, while every signal reported success. Now reportsinvalidated.Buffer.concat([], 0)is truthy.expiry: <ms>was unreachable: the config layer type-checks against a string default, so a numeric value was rejected and the default silently kept. Split intoexpiry: midnight|interval+expiryMs.serve_errorraw-blob-timeout/raw-blob-unreadable.missHeader: originno longer gets a stored document.rawis declared inCACHE_STATUSES/SERVE_SOURCESand added to the console'sCACHE_SERVED— omitting it there reads as a smaller hit rate, not as missing.x-harper-rawdropped (no reader, and it shipped to every crawler ungated).Tests
test/rawCache.test.js(25) plus two route-compiler cases. 1132 pass, lint and format clean.The ones worth reading: an oversize document is still delivered in full while its capture is abandoned; a truncated origin body yields no capture rather than a partial document; a garbage
expiryfalls back to the boundary and never toNaN(which would store everything and serve none of it); andcontent-lengthis dropped whilecontent-encodingsurvives.Not in this PR
Decompressed-size post-processing (the search-engine size limit). That needs a decode and belongs in the browser package, where a parser dependency is cheap.
Relates to #180 — this implements the raw-document half of it; the probe-gating half is already achievable with a route
renderIntervalplus the existing probe trigger.🤖 Generated with Claude Code