Skip to content

feat(plugin+console): rule fingerprint, availability words, [*] extract paths, anchored mode (prerender-v0.65.0 + console v0.13.0) - #159

Merged
harper-joseph merged 3 commits into
mainfrom
fix/probe-string-availability
Sep 16, 2026
Merged

harper-joseph merged 3 commits into
mainfrom
fix/probe-string-availability

Conversation

@harper-joseph

@harper-joseph harper-joseph commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Two packages: @harperfast/prerender 0.65.0 and @harperfast/prerender-console 0.13.0 (the probe view has to learn the new outcome or its arithmetic is wrong).

Why

Three things the change probe could not do, all hit on a deployment this week while moving a rule to a different endpoint at the site operator's own request:

  1. Editing a rule read as a mass change. A stored signature is only comparable to an observation made the same way. Change a rule's endpoint, extract list, or a routing header and the next probe of every matched URL produced a differently-shaped signature: 100% "changed" in one pass, every URL a spurious re-render (bounded by maxTriggersPerSweep, so most deferred and retried forever), and a certain canary trip invalidating the rule's whole scope. The only safe procedure was a manual dry-run cycle with reprobeAfter: 0, roughly a day without detection.
  2. The page check only read a boolean availability. Many endpoints say "In Stock" / "Out of Stock", or give availability per variant. Those mapped to "no claim", so the page-agreement check silently degraded to price only.
  3. No way to schedule one pass against a known daily change. An origin whose content moves on a fixed schedule — prices that change only at the site's midnight — wants ONE pass starting right after that moment, not a walk spread over 24h that notices a midnight change anywhere from minutes to a day later.

What

  • Rule fingerprint (changeProbeSpec.js ruleFingerprint, ProbeState.ruleFingerprint): the rule's observation (source, URL template, method, headers sorted, body, extract, status signals) is hashed and stored with every baseline. A probe whose rule fingerprint differs from the stored one re-baselines the row: observation stored, nothing compared, nothing triggered, excluded from the canary's verdict (new rebaselined stat and probe_rebaselined metric). Label, pathPattern, invalidateScope and pageCheck are not part of it. Rows from before the column existed compare as usual and are stamped once on their next quiet probe, so the first edit after this deploy is already free.
  • Availability words in pageCheck: the endpoint value may be a boolean, an availability word, or a [*] list of per-variant words. Words are matched after dropping case and separators (InStock, IN_STOCK, In Stock, https://schema.org/InStock are one token); the built-in vocabulary is Google's plus Available/Unavailable; pageCheck.availableValues / unavailableValues extend or override it per rule. A per-variant list reads in stock when any variant is, the same reduction pageClaimFromOffers applies to the page. An unrecognised entry in that list makes the claim indeterminate rather than letting an unreadable value read as "out of stock".
  • [*] in extract paths (valueAtPath): project the rest of the path over an array, unreachable branches as null, and sort the projection so a reordered variant list is not a change (the one case traded away is two variants swapping values with each other — the same multiset, which is what the signature asks about). Lets a rule watch variants[*].availability without signing the variant objects whose other fields (inventory counters, store data) move on their own.
  • mode: anchored with anchorTime, anchorTimezone, anchorWindow: one full pass a day starting at a local time, paced across anchorWindow (0 = the ratePerSecond ceiling). No boot sweep in this mode; the canary keeps its own cadence. Editing the anchor re-arms via the existing syncProbeTimers marker comparison; an unusable timezone arms nothing and warns rather than firing at once; an anchor that has already passed today arms for tomorrow rather than firing immediately (the spring-forward hole).
  • Console (views/probe.js): rebaselined joins the outcome list and is subtracted out of compared, which otherwise counts re-baselined rows as comparisons that found nothing.

Version

@harperfast/prerender 0.64.1 → 0.65.0 (prerender-v0.65.0); @harperfast/prerender-console 0.12.0 → 0.13.0 (prerender-console-v0.13.0). #154 previously held both of those numbers and has been closed, so they are uncontested; #156 takes plugin 0.66.0 after this.

Tests

packages/plugin 1008 pass, packages/console 258 pass, lint and format clean (this repo has no PR CI). New: fingerprint stability and sensitivity, re-baseline path, legacy stamp, canary verdict exclusion, string and per-variant availability claims, rule vocabulary compile and clash handling, [*] projection and ordering, anchored scheduler arm/re-arm/no-boot-sweep/window pacing, and the console's compared arithmetic.

Operator notes

  • Schema adds a nullable ruleFingerprint column to ProbeState (node-local, replicate: false). Existing rows need nothing.
  • Expect one pass of probe_rebaselined after any rule edit; a steady count means a rule that keeps changing.
  • A deployment using [*] for per-variant availability should point pageCheck.availableFrom at that slot.
  • Deploy the console with the plugin, or the probe view under-reports compared for as long as they are apart.

Before flipping mode: anchored, decide the trigger budget and the canary threshold together

Anchored mode is correct for an origin that reprices on a daily schedule, and it interacts with two existing defaults in a way that is worth settling in advance rather than discovering on a promo night:

  • The per-day trigger budget is per pass. maxTriggersPerSweep is 5,000 per node per pass. A deployment running continuous at a 12h cycle gets two passes a day; one anchored pass a day halves that budget. Ordinary drift is a small fraction of it, so this is invisible until it isn't.
  • A mass overnight reprice is 5–13× over one pass's cap. Measured promotional events move 8–19% of a catalog at once; on a corpus of ~1.4M URLs across four nodes that is tens of thousands of changes per node against a 5,000 cap. The excess is deferred, not lost — but at one pass a day a single promo night takes days to work through, serving stale prices meanwhile.
  • The same event trips the canary. Its threshold is 0.1, well under that reprice rate, so the pass ends in a route-wide bulk invalidation instead. The two mechanisms fail a genuine mass reprice in opposite directions: the canary keeps freshness at a large origin cost, per-URL triggers keep origin cost bounded and cannot keep up.
  • The fingerprint above is the reason the threshold can now be raised. Much of why it sits at 0.1 was to catch a rule or endpoint shape change reading as a mass change; that case is now handled directly (re-baseline, no trigger, excluded from the verdict). Raising the threshold above the observed reprice rate lets an anchored pass do bounded, paced work.
  • Spread the pass. Anchored mode starts every node at the same instant, and an endpoint whose cache is rebuilt at the same daily boundary answers that burst entirely from misses. anchorWindow is the mitigation and it is only functional as of this PR — it was previously accepted and never passed to the pacer. Stagger per node or set a window; and pick an anchor outside 02:00–03:00 local anyway.

🤖 Generated with Claude Code

…ored mode; v0.65.0

A change-probe rule edit no longer reads as a mass change: the rule's observation
(endpoint, method, headers, body, extract, status signals) is fingerprinted and stored
with every baseline (ProbeState.ruleFingerprint); a mismatch re-baselines the row
(stored, not compared, not triggered, outside the canary verdict) instead of flipping
100% of matched URLs at once. Pre-fingerprint rows compare as usual and are stamped
once on their next quiet probe.

pageCheck now reads availability words ("In Stock", IN_STOCK, schema.org forms) and
[*] per-variant lists, with per-rule availableValues/unavailableValues on top of the
built-in vocabulary. valueAtPath gains [*] projection so a rule can watch
variants[*].availability without signing whole variant objects.

New scheduler mode "anchored": one pass a day at anchorTime in anchorTimezone, paced
to anchorWindow (0 = ceiling), no boot sweep, canary unchanged; unusable anchors arm
nothing and warn. Adds the rebaselined stat / probe_rebaselined metric.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an "anchored" mode for the change probe scheduler to run daily sweeps at a scheduled time, adds rule fingerprinting to prevent rule edits from triggering mass re-renders, and enhances availability checks with array projections and custom vocabularies. The review feedback suggests improving exception serialization robustness by falling back to String(e) when logging unusable timezone errors, and clearing stale page claims when a rule is re-baselined due to a fingerprint mismatch.

Comment thread packages/plugin/src/util/changeProbe.js Outdated
} catch (e) {
at = NaN;
logger.warn(
`[prerender] change-probe: anchorTimezone "${config.changeProbe.anchorTimezone}" is not usable (${e?.message ?? e})`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When serializing caught exceptions, we should not assume the error is a standard Error object. Safely access the error's properties using optional chaining and fall back to String(error) (e.g., e?.message ?? String(e)) to ensure robust serialization of non-Error throws.

Suggested change
`[prerender] change-probe: anchorTimezone "${config.changeProbe.anchorTimezone}" is not usable (${e?.message ?? e})`
`[prerender] change-probe: anchorTimezone "${config.changeProbe.anchorTimezone}" is not usable (${e?.message ?? String(e)})`
References
  1. When handling caught exceptions, safely access the error message using optional chaining and fall back to String(e) (e.g., e?.message ?? String(e)) to ensure that the error handling or logging block itself does not throw a TypeError if the caught exception is null, undefined, or a non-object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in 25b2648 — e?.message ?? String(e).

Comment on lines +601 to +605
if (ruleChanged) {
stats.rebaselined++;
await write(row.url, observed, { rowExists: true, fingerprint: rule.fingerprint });
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When a rule is re-baselined due to a fingerprint mismatch, the old page claim (which was based on the old rule's extract list/vocabulary) is no longer valid or comparable to the new rule's observations. We should pass clearClaim: true to write to clear any stale page claims. Ensure that both pageSignature and pageClaimAt are cleared together as a pair to avoid leaving inconsistent half-states.

Suggested change
if (ruleChanged) {
stats.rebaselined++;
await write(row.url, observed, { rowExists: true, fingerprint: rule.fingerprint });
return;
}
if (ruleChanged) {
stats.rebaselined++;
await write(row.url, observed, { rowExists: true, fingerprint: rule.fingerprint, clearClaim: true });
return;
}
References
  1. When managing page claims or signatures, pageSignature and pageClaimAt must always be updated or cleared together as a pair to avoid leaving inconsistent half-states.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one, and it is worth saying why in case the same suggestion comes back.

pageSignature / pageClaimAt are not derived from the rule at all. They are written by the RENDER path (recordPageClaim, from the browser's structuredOffers), never by the probe — the field comment says "written by the render, never here". So a rule edit does not invalidate them: the claim is a statement about what the cached page says, and the page has not changed just because we now read a different endpoint to check it.

The re-baseline branch also returns before any comparison happens, so there is nothing stale in play — pageDisagrees is only computed further down, guarded on rule.pageCheck && stored?.pageSignature.

Clearing the pair here would actively lose information: the page claim is the only side of the round-trip check that survives a rule edit, and dropping it would blind pageCheck for a full render interval on every matched URL until each page re-renders and re-seeds its claim.

The sibling finding (serializing a non-Error throw in the anchorTimezone warning) is taken in 25b2648.

harper-joseph and others added 2 commits September 16, 2026 13:31
…, projection order, unreadable variants; console v0.13.0

Five defects from the pre-release review, each with a pinned regression test.

- `anchorWindow` was INERT. `sliceSize` was only passed in continuous mode, so the pacer had no
  denominator, `cycleRatePerSecond` returned Infinity, and an anchored pass burst at the ceiling
  however the window was set — with `probe_cycle_behind` silent too, since it also needs a slice.
  Spreading that burst is the entire reason anchored mode exists.

- A DST spring-forward anchor armed a run IN THE PAST. An anchor of 02:30 on the day 02:00 jumps to
  03:00 names a wall-clock time that never occurs, and the resolver returns an already-passed
  instant. The pass fired an hour early and then re-armed to the same past instant on every
  completion: the whole corpus walked back to back at the ceiling rate until the hour was over. A
  stale anchor is now pushed on by a day, which lands on the next real occurrence because the
  offset is applied to the resolved instant rather than to the wall clock.

- `[*]` projections are SORTED. Left positional, an endpoint that returns its variants in an
  unstable order reads as 100% changed on every pass — which on the canary cohort is not a wave of
  per-URL re-renders but a trip and a bulk invalidation of the rule's whole scope. The trade is
  that two variants swapping prices with each other is invisible; the offers on the page are the
  same set either way.

- A null element in a projected availability list now counts as UNREADABLE, not as absent. `[*]`
  writes null where the path could not be walked, so a sold-out variant beside one whose
  availability has not populated used to answer a confident "out of stock" the endpoint never
  gave — a claim that is compared against the page's own and expires it on disagreement.

- METRICS.md failed `format:check`.

Console v0.13.0 alongside it: `compared` subtracted seeds and failures but not re-baselined rows,
so the pass after any rule edit — which can be almost entirely re-baselined — rendered as
"0% changed" over a population nothing had compared, indistinguishable from a static origin. The
outcome is now charted and explained in the panel's note.

1008 plugin tests, 258 console, lint and format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g (review)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@harper-joseph harper-joseph changed the title feat(plugin): rule fingerprint, availability words, [*] extract paths, anchored mode (prerender-v0.65.0) feat(plugin+console): rule fingerprint, availability words, [*] extract paths, anchored mode (prerender-v0.65.0 + console v0.13.0) Sep 16, 2026
@harper-joseph
harper-joseph merged commit d37b53d into main Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant