Repository navigation
feat(plugin): record what each cached page claims and re-render only when the page is wrong; v0.88.0 - #210
Conversation
… compilation, and [*].{…} tuple projection
The pure half of the page record (changeProbeSpec.js), with no runtime wiring yet:
- `valueAtPath` gains a trailing tuple projection, `[*].{a,b.c}`, so per-variant state
stays attached to its key; malformed tuple syntax drops the rule with a warning.
- `pageCheck` compiles `fields` ({ slot, fact, compare, ...options }) and `ignoreChanges`;
a bad entry drops alone. The priceFrom/availableFrom pair compiles exactly as before and
becomes optional once fields or ignoreChanges are given. None of it is in the fingerprint.
- A closed set of typed comparators (text, path, number, priceSet, names, skus) that fail
toward no claim, plus `fieldCaughtUp` (positive evidence only: the page must agree with the
new value and not the old, and every changed SKU is proven on its own), `changedSlots`, and
the canonical, size-bounded page-record serializer.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…when the page is wrong; v0.88.0 - Render path: `recordPageClaim` stores the renderer's `pageFacts` (browser >= 1.37.0) as `ProbeState.pageFacts` in the same patch as the claim, for rules that map fields — always the whole record (null when facts are absent, empty, oversized, or the render did not store every default device). A trigger clears it with the claim. New @Sealed column: needs a worker restart. - Probe: a mapped-field disagreement triggers like a claim-pair disagreement; a change the cached page already shows is absorbed (`caughtUp`); a change confined to `ignoreChanges` slots is absorbed and is not a change for the canary (`ignored`). Verification additionally requires no armed mapped field to disagree. - Mapping-defect guard (`changeProbe.mappingGuard`, 20% over >= 200 witnessed comparisons, recent rate): disarms a field that disagrees with most witnessed pages; never suppresses an individual disagreement. - Pass stats decompose changes per slot (`slotChanges`) and mismatches per field (`fieldMismatch`), plus `fieldGuard`. No new metric series. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request upgrades the prerender plugin to version 0.88.0 and introduces a page record feature (pageCheck.fields) along with a mapping-defect guard (mappingGuard) to compare extracted page facts against cached page states, preventing unnecessary re-renders and disarming faulty mappings. It also adds support for trailing tuple projections ([*].{a,b.c}) to extract nested array values. Feedback on the changes points out a compatibility issue in changeProbeSpec.js where URL.parse is used; since this method is only supported in Node.js v22.1.0+, it should be replaced with new URL() inside a try...catch block to prevent runtime errors on older Node.js LTS versions.
| const url = URL.parse(value.trim(), base ?? undefined); | ||
| if (!url) return null; |
There was a problem hiding this comment.
Avoid using URL.parse as it is only supported in Node.js v22.1.0+ and is not backported to Node.js v20. Using it will cause runtime errors on older Node.js LTS versions. Use new URL() wrapped in a try...catch block instead.
let url;
try {
url = new URL(value.trim(), base ?? undefined);
} catch {
return null;
}References
- Avoid using URL.parse if the target Node.js version is below v22.1.0, as it is not backported to Node 20. Use new URL() with appropriate error handling or pre-validation instead.
There was a problem hiding this comment.
Not changing: URL.parse is the repo idiom (it returns null instead of throwing on a malformed value — see recordPageClaim, ruleForUrl, buildProbeRequest), it exists in every Node line this plugin runs on (added in 22.1.0 and backported to 20.18.0; deployments run Node 24), and the same finding was dismissed on #127 for that reason.
…ed, so a page listing only some variants cannot disagree forever (review) Set equality between the endpoint's price list and the page's offer prices disagrees on every pass for a product whose page lists fewer variants than the endpoint (measured: one origin's structured data stops at 50 offers) whenever a price exists only on an unlisted variant. The re-render cannot fix it, so the product re-rendered on every pass forever, and the mapping guard could not see it because such products are a small minority of witnessed comparisons. Now: a single endpoint price must be among the page's prices (the claim pair's rule); for a list, every price the page prints must still be in the endpoint's set. What this gives up (a new price on a listed variant while its old price survives elsewhere) is what `skus` is for. Revert check: set equality fails the new truncated-subset case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Self-review (author side) — one defect fixed in 210f99a, the rest checked.
|
# Conflicts: # package-lock.json # packages/plugin/METRICS.md # packages/plugin/package.json
TL;DR
pageFactsand is written asProbeState.pageFactsin the same patch as the existing claim.pageCheck.fields) and chooses a comparator for each. The probe then:caughtUp);pageCheck.ignoreChangesslots, the fields a page cannot show (counted asignored). The canary does not count those as changes either.changedby slot (slotChanges) and page mismatches by field (fieldMismatch). No new metric series, so no console release is needed.ignoreChanges. Rules without them behave exactly as before, byte for byte. Adding a mapping re-baselines nothing, because mappings are not part of the rule fingerprint.Problem
The probe compares each observation with the last origin observation. That answers "did the origin change since I last looked?", which is not the same question as "is the cached page wrong?". It fails in two ways:
pageCheck(priceFrom/availableFrom) already compares price and availability against what the page claims. This PR extends that comparison to every field the page visibly shows.Design
1. The record (render path)
recordPageClaim(url, structuredOffers, cachedAt, { pageFacts, complete })inutil/changeProbe.js, called fromprocessJobResult:When it is written. Only for a rule that maps fields. It reuses the claim's read and patch, so it adds no extra read and no extra write. It sits behind the same master switch as the claim.
Which variant it comes from. The same way the claim chooses: the first stored variant that ran the extraction.
Canonical JSON. Fixed key order, and only the contract's keys and types, so equal facts store equal bytes.
It always describes one render. A rule with fields writes the whole record on every render, nulls included. It stores
nullwhen:pageFactsis absent (the renderer predates 1.37.0; a warning is logged at most hourly);pageFactsisnull(the extraction ran and found nothing);complete: false, a partial failure or a one-device render).Leaving an older render's record in place beside a newer
pageClaimAtwould compare a page that is no longer served.Why partial renders store null. The record is one per URL. If desktop re-rendered and mobile failed, the mobile page is older than the record would claim. A record that "caught up" with a change would then skip a mobile page that did not.
Why 16 KB. The record is written on every render and read on every probe, in a node-local table with one row per probed URL. A typical product record is an estimated 1–3 KB (the renderer measures well under 2 KB; 50 SKU offers add about 2 KB). A record at the renderer's own cap of 200 offers fits (tested). The renderer's worst case is about 125 KB, which would make one row cost what fifty normal rows cost.
Cleared on trigger. When a trigger hard-expires the page,
pageFactsis cleared together with the claim (writeSignatureclearClaim).Schema. New column
ProbeState.pageFacts: String. ProbeState is@sealed, so the column loads with the component's workers on deploy, not on a config reload.2. The mapping (
pageCheck.fields, compiled inchangeProbeSpec.js){ slot, fact, compare, ...options }.slotindexesextract. A bad entry is dropped with a warning; the rest of the rule is kept. An entry is refused at compile time if the comparator does not apply to the fact's kind (apriceSetagainst a title, for example).priceFrom/availableFromcompile exactly as before. They become optional oncefieldsorignoreChangesis given. The vocabulary (availableValues/unavailableValues) serves both the pair andskus.Every comparator fails toward no claim: null, absent, empty or unparseable on either side means "not compared". It is never counted as a disagreement, and never as evidence that a page caught up.
text<br>/<li><a …>, and the page's<meta name=description>holds that raw string exactly (stripping would turn 100% agreement into disagreement)pathnumbertolerance"4.0", the page4priceSetnamesnameKey) vs the page list's tailskus3. Tuple projection in extract paths
A trailing
[*].{a,b.c}projects each element to[a, b.c]: dotted inner paths, no brackets, no nesting. It sorts like any[*]projection. For example,payload.products[0].variants[*].{sku,availability,price.current}keeps each SKU's state attached to that SKU. Two separate[*]projections are each sorted independently and lose the pairing. Malformed brace syntax drops the rule with a warning, because it would otherwise extract null forever. The path is part of the fingerprint.4. Decisions in
processOne(#206'scomparable/extended flow unchanged)unchanged, so the canary's verdict does not count it;ignoredoverlayschangedfor the canary (the rest of the corpus has not caught up);caughtUpoverlaysWhen a slot counts as caught up. Only on positive evidence:
namesobject, a query parameter underpath, or a whitespace edit all leave the page agreeing with both values;skus, every SKU whose tuple changed is proven individually. A changed SKU the page does not list is never caught up.Verification (
PageVerification) keeps its original gate and additionally requires that no armed mapped field disagrees. A rule with fields but no claim pair verifies only when an armed mapped field agreed. A row that caught up is verified on the next pass, not this one.5. The mapping-defect guard (
changeProbe.mappingGuard)A wrong mapping disagrees on nearly every page. A correct one disagrees on a witnessed page only after a genuine round trip, which was measured at well under 1%. A witnessed page is one where the record was stamped after the baseline's
probedAtand the origin has been unchanged since.threshold(default 0.2) over at leastminWitnessedcomparisons (default 200). At a 1% true rate you would expect about 2 disagreements in 200, and the threshold needs 40.fieldMismatch. A warning names the rule, the slot and its extract path. It stays disarmed until that field's mapping changes or the process restarts.minWitnessed. Without that, a mapping that was right for a week and then broke (for example, the site changed its title template) would first have to outweigh the week of agreements.6. Stats
These are new fields on the pass record, returned by
GET /prerender_admin/change-probeand printed in the pass log line:caughtUp,ignored,slotChanges({ rule: { slot | "signal": n } }),fieldMismatch({ rule: { "<slot>:<fact>": n } }), andfieldGuard({ rule: { field: { witnessed, disagreed, armed } } }).pageMismatchnow also counts armed mapped mismatches.probed = seeded + rebaselined + unchanged + changed + failedstill holds. The console's cluster merge sums only the counters it already knows, so the new fields are visible per node. Showing them in the console is a follow-up.Example rule
Adversarial review: where this could re-render a corpus or swallow a change
minWitnessedwitnessed disagreements per node; before that, triggers are bounded bymaxTriggersPerSweepas alwayscomplete: falsestores null, so no caught-uppageFactsstores null (warned hourly): no claim, no suppressionTests
npm run lintandnpm run format:checkexit 0.New tests by file:
changeProbeSpec.test.js:[*], reorder vs. a moved price,extractPathProblem, malformed-rule drop, fingerprint;fieldCaughtUp: an off-page SKU, a removed SKU, invisible changes, and a visible change plus an off-page change together;changedSlots;changeProbe.test.js(runProbePass):slotChangeson a rule with no pageCheck.recordPageClaim: same-write storage, absent vs nullpageFacts, oversize refusal,complete: false, fields-only and pair-only rules, and status output.renderQueueVariants.test.js: the record is stored with the pages' ownlastCached; a partial render retires the record.Revert checks
Each mutation was applied to the source, the three test files were run, and the source was restored:
changeProbe.jsrestored to #206changeProbeSpec.jsrestored to #206RenderQueue.jsrestored to #206skuscaught-up without the per-SKU proofignoreChangesdisabled / ignored bucketed aschangedpageClaimAt > probedAtcomplete: truealwaysclearClaimleavespageFactsnamesprefix instead of suffix /pathcompares the full URL /textstrips HTMLpriceSetdrops nulls /skusunion instead of intersection /numberignores stringsRollout
Render fleet on
@harperfast/prerender-browser≥ 1.37.0 (#208). Older plugins ignore the new field.Deploy plugin 0.88.0 with a worker restart. The
ProbeState.pageFactscolumn is new on a@sealedtable and loads with the workers, not on a config reload. This step is inert until a rule maps fields.Add
fields/ignoreChangesto the rule. No re-baseline. Records fill as pages render: mapped fields compare as no claim until each URL's next render stores one.Watch:
fieldMismatchper field; a field whose count tracks the matched count is mis-mapped;caughtUp / changed, the renders saved;ignored;fieldGuard, and any… is DISARMEDwarning.Do this in dry run first if you can: the guard counts and disarms in dry run too.
Open items and limits
maxTriggersPerSweepper node for a pass or two before the guard has evidence. Adding mappings to an existing rule (the rollout above) is witnessed almost immediately.skus/priceSetmapping until restart. That never hides an origin change; it only stops correcting stale pages. Untested in production.priceSetis a subset test, not equality (changed in review, 210f99a). Equality disagreed forever with a page whose offer list is truncated (a price only on an unlisted variant), re-rendering that product every pass where the guard could not see it. The subset test gives up one case — a new price on a listed variant while its old price survives on another — which is whatskuscovers.slotChanges/fieldMismatch/caughtUp/fieldGuard; they are per node in the status endpoint. The cluster merge (PROBE_COUNTERS) could sumcaughtUp/ignoredin a follow-up console release.Version: plugin 0.88.0 (0.85.0 is #205, 0.86.0 is #206, 0.87.0 is #207).
🤖 Generated with Claude Code