The gap
The change probe compares the origin's answer to its own previous answer. That bounds drift — a value changing after a render — but it is structurally blind to a value that changes and changes back between two probes. If a render lands inside that window, the cached page keeps the transient state and the probe never notices, because the signature it reads still equals the one it stored.
Measured on a production commerce deployment (n=143 sampled cached product pages, 112 of them inside the serve window):
- price mismatches among served pages: 0/112 (0.0%) — the probe is doing its job on drift.
- availability mismatches among served pages: 3/112 (2.7%), every one of them the same shape: the cached page says
OutOfStock, the origin says available, and the price matches exactly.
One case traced end to end: the probe stored [39.99,35.99,35.99,true] (available) at 11:04:37Z; the page rendered at 11:10:33Z — six minutes later — capturing OutOfStock; the origin now returns exactly the stored signature again. The probe's next pass will compare equal and trigger nothing, so the page serves a wrong availability until its render interval expires (48h in that deployment).
Four alternative explanations were tested and eliminated:
| hypothesis |
test |
result |
| endpoint is flaky |
5 consecutive calls |
byte-identical signatures |
| answer varies per store |
6 store identifiers incl. a nonsense one |
identical for every probed field |
| render captured a different variant |
compared SKUs |
cached page carries the same SKU as the origin's default |
| systematic render defect |
control pages |
controls render the correct availability |
At ~180k product-page serves/hour on that deployment, 2.7% is ~5k serves/hour carrying a stale availability. A shorter sweepInterval shrinks the window a round trip must fit inside but cannot close the class, and the exposure scales with render interval — so it gets worse exactly when you stretch intervals on the strength of the probe.
Proposal: compare the origin against what the PAGE says, not against the last probe
Make the stored signature mean "what the cached page currently claims" rather than "what the origin said last time". Then the existing comparison — origin-now vs stored — becomes origin-now vs page, which is the question that actually matters for structured-data correctness, and any disagreement triggers regardless of history.
Mechanism, reusing what already exists:
- On render completion (
RenderQueue.processJobResult), when a probe rule matches the URL and the new option is enabled, extract the rendered page's own structured-data values with the document-mode extractor already in changeProbeSpec (extractJsonLdOffers), normalize, and write that as the URL's ProbeState signature.
- The probe pass is then unchanged. It fetches the origin, compares to the stored signature, and triggers on difference — which now means "the page disagrees with reality".
This needs no schema change and no new storage: ProbeState already holds exactly one signature per URL, and the render result is processed on the owner node (claims are owner-scoped), which is the same node the owner-scoped sweep uses.
The one genuinely new piece: field mapping
The request-mode signature is an ordered extract array (e.g. [regularPrice, salePrice, lowestApplicablePrice, isAvailableForShip]); the page's structured data yields (price, availability) per offer. They are not directly comparable, and the correspondence is site-specific — so the rule must declare it:
pageCheck:
enabled: true
priceFrom: 2 # index into `extract` holding the price the page renders
availableFrom: 3 # index into `extract` holding the availability verdict
Comparison: mismatch when the origin's price is absent from the page's offer prices, or when the origin's availability disagrees with whether any offer on the page is in stock (a page whose every SKU is out of stock while the origin says available is a mismatch — that is one of the three measured cases).
Cost
One structured-data extraction per render on pages matching a pageCheck rule — a bounded regex scan plus a JSON parse, order milliseconds against a render that already stores a ~1MB blob. Nothing added to the serve path, and nothing added to the probe path.
Design questions worth settling before implementation
- Device types. A URL renders per device and each render could capture different content, but
ProbeState is one row per URL. Last-writer-wins, or only trust one device, or store per device?
- Failure semantics. If the page yields no structured data (extraction returns null), it must leave the stored signature untouched rather than write a null — same rule the probe already applies to failed probes, so a markup change cannot mass-trigger.
- Does this replace or supplement drift detection? Making the signature mean "what the page says" is strictly better for correctness, but it loses the ability to report a pure change rate from probe-to-probe deltas. If that metric matters, it needs its own counter.
- Interaction with seeding. A first observation currently seeds without triggering. Under this design a freshly rendered page always has a signature, so seeding should become rarer — worth confirming it does not mask a real disagreement on the first pass after a render.
Happy to implement once the shape is agreed — particularly the mapping surface in (the pageCheck block) and the device-type answer in (1).
The gap
The change probe compares the origin's answer to its own previous answer. That bounds drift — a value changing after a render — but it is structurally blind to a value that changes and changes back between two probes. If a render lands inside that window, the cached page keeps the transient state and the probe never notices, because the signature it reads still equals the one it stored.
Measured on a production commerce deployment (n=143 sampled cached product pages, 112 of them inside the serve window):
OutOfStock, the origin says available, and the price matches exactly.One case traced end to end: the probe stored
[39.99,35.99,35.99,true](available) at11:04:37Z; the page rendered at11:10:33Z— six minutes later — capturingOutOfStock; the origin now returns exactly the stored signature again. The probe's next pass will compare equal and trigger nothing, so the page serves a wrong availability until its render interval expires (48h in that deployment).Four alternative explanations were tested and eliminated:
At ~180k product-page serves/hour on that deployment, 2.7% is ~5k serves/hour carrying a stale availability. A shorter
sweepIntervalshrinks the window a round trip must fit inside but cannot close the class, and the exposure scales with render interval — so it gets worse exactly when you stretch intervals on the strength of the probe.Proposal: compare the origin against what the PAGE says, not against the last probe
Make the stored signature mean "what the cached page currently claims" rather than "what the origin said last time". Then the existing comparison — origin-now vs stored — becomes origin-now vs page, which is the question that actually matters for structured-data correctness, and any disagreement triggers regardless of history.
Mechanism, reusing what already exists:
RenderQueue.processJobResult), when a probe rule matches the URL and the new option is enabled, extract the rendered page's own structured-data values with the document-mode extractor already inchangeProbeSpec(extractJsonLdOffers), normalize, and write that as the URL'sProbeStatesignature.This needs no schema change and no new storage:
ProbeStatealready holds exactly one signature per URL, and the render result is processed on the owner node (claims are owner-scoped), which is the same node the owner-scoped sweep uses.The one genuinely new piece: field mapping
The request-mode signature is an ordered
extractarray (e.g.[regularPrice, salePrice, lowestApplicablePrice, isAvailableForShip]); the page's structured data yields(price, availability)per offer. They are not directly comparable, and the correspondence is site-specific — so the rule must declare it:Comparison: mismatch when the origin's price is absent from the page's offer prices, or when the origin's availability disagrees with whether any offer on the page is in stock (a page whose every SKU is out of stock while the origin says available is a mismatch — that is one of the three measured cases).
Cost
One structured-data extraction per render on pages matching a
pageCheckrule — a bounded regex scan plus a JSON parse, order milliseconds against a render that already stores a ~1MB blob. Nothing added to the serve path, and nothing added to the probe path.Design questions worth settling before implementation
ProbeStateis one row per URL. Last-writer-wins, or only trust one device, or store per device?Happy to implement once the shape is agreed — particularly the mapping surface in (the
pageCheckblock) and the device-type answer in (1).