Repository navigation
feat(plugin): pageCheck — compare the origin against what the cached page claims; v0.58.0 - #139
Conversation
…page claims; v0.58.0
The probe compares the origin to its OWN previous answer, so a value that
changes and changes back between two passes is invisible — and a render
that landed inside that window leaves a page carrying the transient value
until its interval expires. Measured on a production commerce deployment
at ~2.7% of served product pages, every instance a page reading
OutOfStock for something the origin reports as available.
A rule may now declare pageCheck: { enabled, priceFrom, availableFrom }.
The render path records what each page claims (ProbeState.pageSignature,
node-local, written only by renders), and the pass compares the origin
against it in addition to the drift baseline. Kept SEPARATE from the
existing drift signature rather than replacing it: a page can only ever
express (price, availability), so folding the two would silently narrow
drift detection and stop catching regular/sale price moves.
Default off; inert unless a rule opts in. No extra origin traffic — the
comparison reuses values the probe already fetched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a "PAGE CHECK" (pageCheck) feature to resolve "round-trip blindness" in the prerender plugin, allowing the system to detect when a cached page's content (such as price or availability) disagrees with the origin API even if the origin's overall signature remains unchanged. This is achieved by recording page claims during renders, comparing them against API claims during probe passes, and tracking mismatches with a new probe_page_mismatch metric. The feedback suggests two improvements: first, wrapping the array destructuring in claimsDisagree inside the try...catch block to prevent potential TypeError crashes on malformed database records; second, parsing the URL once outside the .find() callback in recordPageClaim using URL.parse to avoid redundant allocations on the hot render path.
| export const claimsDisagree = (pageClaim, apiClaim) => { | ||
| if (!pageClaim || !apiClaim) return false; | ||
| let page, api; | ||
| try { | ||
| page = JSON.parse(pageClaim); | ||
| api = JSON.parse(apiClaim); | ||
| } catch { | ||
| return false; | ||
| } | ||
| const [pagePrices, pageInStock] = page; | ||
| const [apiPrices, apiInStock] = api; | ||
| if (apiInStock !== pageInStock) return true; | ||
| if (apiPrices.length && !apiPrices.every((p) => pagePrices.includes(p))) return true; | ||
| return false; | ||
| }; |
There was a problem hiding this comment.
The claimsDisagree function parses pageClaim and apiClaim inside a try...catch block, but performs array destructuring outside of it. If the parsed JSON is not an array (e.g., if the database contains a corrupted string, a primitive, or an object), the destructuring will throw a TypeError (e.g., TypeError: page is not iterable), which will propagate and crash the entire probe sweep. Wrapping the entire destructuring and validation logic inside the try...catch block ensures robust error handling.
| export const claimsDisagree = (pageClaim, apiClaim) => { | |
| if (!pageClaim || !apiClaim) return false; | |
| let page, api; | |
| try { | |
| page = JSON.parse(pageClaim); | |
| api = JSON.parse(apiClaim); | |
| } catch { | |
| return false; | |
| } | |
| const [pagePrices, pageInStock] = page; | |
| const [apiPrices, apiInStock] = api; | |
| if (apiInStock !== pageInStock) return true; | |
| if (apiPrices.length && !apiPrices.every((p) => pagePrices.includes(p))) return true; | |
| return false; | |
| }; | |
| export const claimsDisagree = (pageClaim, apiClaim) => { | |
| if (!pageClaim || !apiClaim) return false; | |
| try { | |
| const page = JSON.parse(pageClaim); | |
| const api = JSON.parse(apiClaim); | |
| if (!Array.isArray(page) || !Array.isArray(api)) return false; | |
| const [pagePrices, pageInStock] = page; | |
| const [apiPrices, apiInStock] = api; | |
| if (!Array.isArray(pagePrices) || !Array.isArray(apiPrices)) return false; | |
| if (apiInStock !== pageInStock) return true; | |
| if (apiPrices.length && !apiPrices.every((p) => pagePrices.includes(p))) return true; | |
| return false; | |
| } catch { | |
| return false; | |
| } | |
| }; |
| */ | ||
| export const recordPageClaim = async (url, html) => { | ||
| try { | ||
| const rule = probeRules().find((r) => r.pageCheck && r.pathPattern.test(new URL(url).pathname)); |
There was a problem hiding this comment.
Constructing a new URL(url) inside the .find() callback is inefficient because it parses the URL repeatedly for every rule being evaluated. Since url is constant for the duration of recordPageClaim, parsing it once outside the .find() call avoids redundant object allocations and C++ boundary crossings on the hot render path. Additionally, prefer using URL.parse to maintain consistency with the repository's established idioms.
| const rule = probeRules().find((r) => r.pageCheck && r.pathPattern.test(new URL(url).pathname)); | |
| const pathname = URL.parse(url)?.pathname; | |
| const rule = probeRules().find((r) => r.pageCheck && r.pathPattern.test(pathname)); |
References
- Prefer using URL.parse for parsing URLs to maintain consistency with the repository's established idioms, especially when the effective runtime floor is Node 22+ and compatibility with older Node versions is not required.
…he HTML Review feedback: the plugin was parsing the whole stored document to recover structured data the renderer already had in a live DOM — a regex scan plus a JSON parse of a ~1MB commerce PDP on the hottest write path here, to reconstruct something the browser could hand over for free. recordPageClaim now prefers result.structuredOffers (browser >= 1.20.0, see the companion PR) and falls back to parsing the HTML only for results from renderers that do not send it. pageClaimFromOffers is split out of pageClaimOf so both paths share one normalization, which keeps the comparison contract in the plugin where it belongs. No deploy ordering constraint: either package may go first, and a mixed fleet works throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Good call on the extraction — taken, and it made the design better. Pushed a second commit: Renderer side is #140 (browser v1.20.0): one No deploy ordering constraint — the fallback means either package can go first and a mixed fleet works throughout. 859/859 plugin tests, 150/150 browser tests. |
Review call: parsing the stored document to recover the page's offers is too expensive to keep as a fallback — it is a regex scan plus a JSON parse of a ~1MB commerce PDP on the hottest write path in this process, to reconstruct what the renderer already had structured in a live DOM. So pageCheck now takes the claim only from result.structuredOffers, and is simply INERT against a renderer older than 1.20.0. Inert is logged hourly rather than passing silently: a config that looks enabled and protects nothing is the worst of the three outcomes. pageClaimOf is gone with its last caller; pageClaimFromOffers is the single normalization. Fixes a real integration bug the fallback was masking: the call site read result.offers while the renderer posts result.structuredOffers, so the renderer path would never have been taken and every page claim would have come from the expensive fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Fallback removed entirely per review — agreed it's too expensive to keep even as a backstop.
Removing it surfaced a real bug it had been masking: the call site read 858–859/859 tests (one flaky test unrelated to this change — passes on rerun and in isolation). |
Two findings from review: - claimsDisagree destructured outside its try/catch, so a stored claim that is valid JSON but not an array (an older release's shape, or a damaged row) would throw a TypeError from inside the sweep's per-URL path and end the whole pass. Shape-check inside the try; anything unrecognisable reads as 'no comparable claim'. - recordPageClaim built a new URL() inside the find() predicate, so the same URL was parsed once per rule on every render. Parse once, and use URL.parse (repo idiom, returns null rather than throwing). Adds a regression test covering seven corrupted-claim shapes.
|
Both findings were real — fixed and pushed.
860/860 tests. |
…g was a no-op Caught by review question about where ProbeState rows come from. Verified against the engine: an update/patch on a missing primary key returns 'updated 0 of 1 records' with the key skipped — it does NOT create. The seeding write is BY DEFINITION to a URL with no row yet, so switching writeSignature from put to patch (done earlier in this branch to stop it clobbering pageSignature) would have made every first observation a silent no-op: the probe would re-seed forever and never detect a single change. That would have broken the deployed feature, not just the new one. Back to put, now carrying pageSignature explicitly so a signature write cannot erase the claim the render path owns. The trigger's claim-clear is folded into the same write instead of a second round trip, so the clearPageClaim port is gone. recordPageClaim has the same hazard in reverse — patch cannot create, put would clobber the probe's own fields — so it reads once and chooses. Adds a test that a drift write carries the page claim through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Your question about where ProbeState rows come from caught a serious bug I introduced in this branch — thank you, this would have shipped.
Earlier in this branch I switched Fixed: back to Added a test pinning that a drift write carries the claim through. 861/861. To answer the question directly: yes — ProbeState rows are PDP-only, from both paths. The sweep writes them for rule-matched URLs, and |
… guessed verdicts Self-review pass. An unrecognized availability vocabulary (PreOrder, a private API status string) or an unparseable price format previously reduced to a CONFIDENT wrong claim, which disagrees with the other side on every pass and hard-expires every matched page after every render, forever — a churn loop that would also have fired fleet-wide on any site-side vocabulary change. Now: availability reduces to true only for Google's in-stock set, false only for an unmixed definitive negative, and null (no claim, incomparable) otherwise, on both the page and endpoint sides; price compares only when the page prints at least one parseable price. Also: a NULL structuredOffers (renderer >= 1.20.0 ran and the page declared no offers) no longer takes the 'renderer too old' warn — only an ABSENT field does; pageCheck.enabled that is truthy but not boolean true warns instead of silently disabling; the pageCheck config paragraph regains its lost newline before invalidateScope; METRICS.md typo; recordPageClaim gains direct unit coverage pinning the put-seeds/patch-updates verb choice. Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
…disagreements A drift trip hard-expires the page, so the stored claim describes nothing served any more — and on a price drift it disagrees with the freshly written baseline by construction, so preserving it re-tripped the same already-expired, already-filed page on every subsequent pass until its re-render landed: wasted trigger budget once per sweep on exactly the most common trip class. The claim now clears on any acted trip; the seed and dry-run writes still carry it through (each pinned by its own test). Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
…gnature outcome; rule symmetry
Adversarial review findings. (1) writeSignature's whole-row put copied a
pageSignature read a full probe-request earlier — a render landing in that
window had its fresh claim replaced by the stale copy (and could take one
spurious expiry for it). An existing row now takes a patch naming only the
probe's own columns, so the render path's claim survives structurally;
clearing on an acted trip is the one explicit exception, and only a missing
row takes a put. (2) A page-mismatch row skipped seeded/unchanged/changed
entirely, breaking the documented probed = seeded+unchanged+changed+failed
invariant and silently shrinking the canary's changed+unchanged denominator
right when claims are most likely stale; buckets now count by signature
outcome alone and pageMismatch overlays them. (3) recordPageClaim selected
the first rule WITH pageCheck while the sweep selects the first match of ALL
rules — an earlier pageCheck-less rule shadowing the URL meant claims written
per render and never compared, silently; both sides now select identically.
(4) recordPageClaim honors the changeProbe.enabled master switch ('Off = no
probes, no timers, nothing stored'). Also: the trip-expiry test no longer
flakes on a millisecond tick, and writeSignature's verb choice is pinned by
a direct test.
Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
|
Second self-review round, plus a fresh-context adversarial pass over the final diff (outside-model CLIs are still dead on this machine — details in the body's coverage section). Three commits since the last review:
872/872 tests. The body is rewritten to describe the current state. |
Implements option A from #138, with the design questions there answered by measurement rather than assumption.
The gap
The probe compares the origin to its own previous answer. That bounds drift, but it is structurally blind to a value that changes and changes back between two passes — the signatures compare equal and nothing triggers. If a render landed inside that window, the cached page keeps the transient value until its render interval expires.
Measured on a production commerce deployment (n=143 cached product pages, 112 inside the serve window):
OutOfStock, the origin says available, and the price matches exactly.One traced end to end: the probe stored
[39.99,35.99,35.99,true]at11:04:37Z; the page rendered at11:10:33Z— six minutes later — capturingOutOfStock; the origin now returns exactly the stored signature again. The next pass compares equal and triggers nothing.Four competing explanations were tested and eliminated before writing any code: the endpoint is stable (5/5 identical), store-independent (6 store identifiers, including a nonsense one), the page agrees with it at the same instant (34/34 on price and availability), and the cached page carries the same SKU as the origin's default — so it is not variant selection either.
What this adds
A rule may declare a mapping from its own
extractarray to what the page renders:ProbeState.pageSignature) — one node-local write per render on matching URLs, no extra origin traffic. The claim comes only from the renderer, which reads it off its live DOM and posts it with the result (structuredOffers, browser >= 1.20.0 — companion PR feat(browser): post the page's structured Product offers with the result; v1.20.0 #140).There is deliberately NO fallback to parsing the stored HTML. Recovering the offers here means a regex scan plus a JSON parse of a ~1MB commerce PDP on the hottest write path in this process, to reconstruct what the browser already had structured in front of it. So
pageCheckis inert against a renderer older than 1.20.0 — and says so, hourly, rather than passing silently, because a config that looks enabled and protects nothing is the worst of the three outcomes. The wire contract makes that diagnosis honest: an absentstructuredOffersmeans the renderer predates the feature (warn); null means a >=1.20.0 renderer ran the extraction and the page declared no Product offers (silent, nothing to record). Neither package blocks the other on deploy;pageChecksimply does nothing until both are in place.unchangedearly-return, because "unchanged" is precisely the case this exists to catch.A dimension compares only when BOTH sides actually claim it. The one failure mode this feature must not have is a systematic false disagreement: it would hard-expire the page, the re-render would produce the same claim, and the next pass would expire it again — a permanent churn loop across every matched page, triggered fleet-wide by nothing more than a site-side markup change. So every reduction fails toward "no claim, incomparable" rather than toward a guess:
trueonly for a recognized in-stock vocabulary (Google's own reading:InStock,InStoreOnly,OnlineOnly,LimitedAvailability),falseonly when every stated availability is an unmixed definitive negative (OutOfStock,SoldOut,Discontinued), and null for anything else —PreOrder,BackOrder, a private vocabulary. URL forms normalize defensively even though the renderer already strips them.true/'true'/false/'false'); a count or status string is null, never a guess.AggregateOfferreduces to "no price claim") and the endpoint projected one.Kept separate from the drift signature, not folded into it. A page can only ever express
(price, availability), so making the stored signature mean "what the page says" would silently narrow drift detection and stop catchingregularPrice/salePricemoves — the class that produced 19 measured price mismatches. That is why this costs a column.ProbeState now has two writers, and the verbs are load-bearing.
patchdoes not create a missing record (verified against the engine: "updated 0 of 1 records", key inskipped_hashes) andputreplaces the whole row. So an existing row only ever takes a patch naming that writer's own columns — the probe patchessignature/probedAt(plus an explicitpageSignature: nullon an acted trip), the render path patchespageSignature— and the other writer's column survives structurally rather than by being copied through a read that may be a full probe-request stale. A missing row takes a put (patch cannot create; that verb mistake was caught in review and would have made every seed a silent no-op). Both verb choices are pinned by direct unit tests. The one remaining race — the render path creating a row between the probe's read and its seeding put — costs at most one baseline, re-seeded on the next pass, and is documented at the write.Design answers from #138
desktopvsmobilecached pages for 40 URLs on full(sku, price, availability)sets: 39/40 byte-identical. The one exception was two renders 41 hours apart, and the stale side had already expired and was unservable. Structured data is device-independent, so one row per URL is correct and last-writer-wins is safe.Deliberate limitation
A status-signal literal (e.g. sold-out expressed as a 4xx via
statusSignals) carries no price/availability to project, sopageCheckskips those responses rather than guessing. That leaves the sold-out-page-says-in-stock case to the existing signature comparison, which does catch it as a change. Worth revisiting with animplies:clause onstatusSignalsif it proves material.Verification
unchanged, inertness with no stored claim, the status-signal skip, triggering without a baseline, seeding a claim-only row via patch, EVERY acted trip clearing the claim (a drift trip too — a preserved claim disagrees with the new baseline by construction and would re-trip an already-expired page each pass), and dry-run preserving it.probed = seeded + unchanged + changed + failedholds; the canary'schanged + unchangeddenominator keeps page-mismatch rows) withprobe_page_mismatchas an overlay — pinned by tests.sourcerejection, the tri-state availability vocabulary (including mixed and unrecognized), both-sides-claimed gating on each dimension, the price-absent-from-page asymmetry, multi-variant pages not reading as disagreements, and corrupted stored claims never ending a sweep.recordPageClaimhas direct coverage with a fake table pinning the verb choice, the absent-vs-null wire contract including the hourly warn throttle, thechangeProbe.enabledmaster switch gating storage ("Off = … nothing stored"), rule selection matching the sweep's (first match of ALL rules — a "first match with pageCheck" search would let a pageCheck-less rule shadow the URL on the sweep side and silently never compare), and never throwing into the render path.writeSignature's verb/column choice has its own direct test.canonicalPrice(null)returned"0.00"(Number(null) === 0), and an intermediatewriteSignatureput→patch change that would have made every seed a silent no-op — patch cannot create.pageCheck.enabledthat is truthy but not booleantrue(a YAML"true"string) warns instead of silently disabling.For the human reviewer
extractare terse; named paths would be friendlier but would duplicate extraction config. Dropped whole on any out-of-bounds index, because a half-applied mapping silently compares the wrong column.priceFromindex is the one remaining mass-churn footgun this code cannot detect (e.g. mappingregularPricewhen the page prints the sale price would disagree on every discounted product). The mitigation is operational: validate the mapping empirically against a sample of live pages before enabling, and watchprobe_page_mismatchafter.recordPageClaimis awaited on the render path. It is one node-local point read plus one write on a small table, wrapped so it can never fail the render.result.offerswhile the renderer postsresult.structuredOffers. With the fallback present, the renderer path would never have been taken and every claim would have come from the expensive path — silently. Evidence that the fallback was actively harmful rather than merely costly.pageCheckbeing inert on an old renderer is a deliberate failure mode. The hourly warning is the mitigation; if that is judged too quiet for something this load-bearing, a config warning at startup would be the alternative.probe_page_mismatchreads as a standing gauge of disagreeing pages per pass in dry-run, and as a detection rate when armed.Review coverage
Authored by Claude (Fable). Two self-review rounds plus a fresh-context Claude adversarial pass over both PRs' diffs with full repo access (partial mitigation for the missing outside model — it reproduced its blockers executably). Outside-model legs were attempted and are unavailable on this machine (Codex CLI: burned refresh token, needs
codex logout && codex login; Gemini CLI: no API key configured), so model coverage is Claude-only and degraded, flagged per policy.The adversarial pass raised 8 findings; 6 were real and are fixed in the two
fix(plugin)/fix(browser)review commits (stats accounting, write-verb TOCTOU, rule-selection symmetry, master switch, the browser cap trio) plus the test flake. Declined with rationale: a per-URL repeat-trigger damper — the overflow-refusal and both-sides-claimed gating remove every identified systematic-churn class, the measured device-divergence rate is 0/39 servable pages, andmaxTriggersPerSweepbounds anything residual; worth revisiting only ifprobe_page_mismatchshows the same URLs repeating.🤖 Generated with Claude Code