Skip to content

Queue every detected change instead of deferring past a budget (v0.75.0) - #178

Open
harper-joseph wants to merge 1 commit into
mainfrom
feat/probe-queue-everything
Open

harper-joseph wants to merge 1 commit into
mainfrom
feat/probe-queue-everything

Conversation

@harper-joseph

@harper-joseph harper-joseph commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Why

Deferring was the routine outcome of a busy pass, and it's a bad trade. The origin read that proved a URL changed has already been paid for; dropping the result throws that away and re-buys it on the next pass — which in anchored mode is a day later.

The render queue is itself a backlog. A trigger files a row; the fleet drains it at whatever rate it can. So the thing worth bounding is how fast the writes land, not whether they land at all.

Correction (2026-09-18). This PR originally justified itself with "~123k changed products per node against a 90k cap, so ~26% dropped every night". That came from a dry-run pass taken immediately after a rule swap, where every row still carried a null rule fingerprint and so compared as changed — the exact one-time artifact dryRun exists to absorb. The first completed armed pass measured the real rate: 1.98% changed (23,810 of 1,219,689 probed fleet-wide), rebaselined: 0, deferred: 0 on all four nodes. The cap was never reached and the drain rate never bound (0.21/s against a 3/s limit).

So this change is not fixing a live loss. It stands on the principle — deferring discards an origin read already paid for, and a mass-change event (a real reprice, or a widened rule) is exactly when the old default would have dropped the most — plus the heartbeat queue-depth reading, which is worth having on its own. Treat it as a safety property, not an urgent fix, and size maxPending against a mass event rather than against steady state.

What changed

maxTriggersPerSweep now defaults to 0 — no ceiling. It remains available as a cap on what a single pass may inject (useful while sizing a new rule, where a mistake would otherwise queue the whole corpus), but it is no longer the routine dropper.

The queue outlives the pass. It's module-scoped, and the pass no longer awaits drain(). This matters beyond tidiness: a pass that detects more change than the bounded drain can place would otherwise be held open by it, and in anchored mode a pass still running at the next anchor makes that anchor skip — turning a busy night into a missed one. An aborted pass no longer stops the queue either; what's in it was genuinely detected and its baseline is unwritten, so draining it is still correct.

BLOCKER — the kill switch this claims does not exist. An earlier draft of this section ended "Only disabling the probe clears it." That is false: resetTriggerQueue's only caller is resetChangeProbeState, marked "Tests only", and syncProbeTimers never touches the queue. So with the probe disabled the queue keeps hard-expiring pages and filing renders until it empties — up to maxPending / ratePerSecond, ~4h37m at the new defaults. Removing if (stats.aborted) triggers.stop() without adding a real operator-reachable clear leaves no way to stop a running drain. Must be fixed before merge.

trigger.maxPending 5,000 → 50,000, and documented as what it actually is now: the only remaining bound, and a memory bound rather than a policy one. An entry carries the observed signature (~1.4 KB), so 50,000 pending is ~70 MB per node. A refusal is an overload alarm, not a normal outcome.

Two things I want to be explicit about

The stats change meaning. triggered and errors are now cumulative counters read at pass end, not per-pass totals — a trigger submitted by this pass may settle during the next. queued is the honest per-pass number, and the new triggerQueuePending reports how much hadn't landed when the pass ended.

Filing everything due-now flattens priority. Claims order by nextRenderTime, so a large ready set at the same due time competes evenly — the homepage against every changed PDP. The backlog drains, but lower-priority work starves while it does. Render priority lanes (#80) are the real fix; this is a knowing tradeoff, not an oversight.

Also fixes an observability gap

Queue depth now rides the sweep heartbeat, so the queue is visible while a pass runs. Previously the only reading came from a finished pass — useless for a drain whose entire purpose is to outlive the pass, and it cost real time diagnosing a live fleet earlier today.

Tests

npm test in packages/plugin: 1106 pass, 0 fail. Lint and format:check clean.

New test pins the default: with maxTriggers: 0, three changed URLs give deferred: 0, queued: 3, triggered: 3, and three baselines written after their triggers. The existing "past the trigger budget a change DEFERS" test keeps its explicit finite cap, so the ceiling still works when set.

Versioning — THIS PR CURRENTLY BUMPS BACKWARDS

It sets packages/plugin to 0.75.0, but that reservation expired: #165 and #177 merged, the 0.73.0–0.76.0 numbers collapsed into a single prerender-v0.77.0 release, and main is now at 0.79.0 (0.78.0 raw-cache TTL, 0.79.0 readiness contracts). Merging as-is is the downgrade-of-main that #59 caused.

Renumber to the next free version at merge time — read gh release list then, rather than reusing a number written here. See #183.

🤖 Generated with Claude Code

… budget; v0.75.0

Deferring was the routine outcome of a busy pass, and it is a bad trade: the origin read
that proved a URL changed has already been paid for, and dropping the result throws that
away and re-buys it on the next pass — which in anchored mode is a DAY later. The render
queue is itself a backlog; a trigger files a row and the fleet drains it at whatever rate
it can. So bound how fast the writes LAND, not whether they land at all.

Three changes:

  - `maxTriggersPerSweep` defaults to 0 = NO CEILING. It stays available as a cap on what
    one pass may inject (sizing a new rule, say), but it is no longer the routine dropper.

  - THE QUEUE OUTLIVES THE PASS. It is module-scoped and the pass no longer awaits
    `drain()`. A pass that detected more change than the bounded drain can place would
    otherwise be held open by it — and in anchored mode a pass still running at the next
    anchor makes that anchor SKIP, turning a busy night into a missed one. An aborted pass
    no longer stops the queue either: what is in it was genuinely detected and its baseline
    is unwritten, so draining it is still right. Only disabling the probe clears it.

  - `trigger.maxPending` 5,000 -> 50,000 and is now documented as what it actually is: the
    ONLY remaining bound, and a MEMORY bound rather than a policy one (an entry carries the
    observed signature, ~1.4 KB, so 50,000 is ~70 MB/node). A refusal is an overload alarm,
    not a normal outcome.

CONSEQUENCE ON THE STATS, stated because it changes their meaning: `triggered`/`errors` are
cumulative counters read at pass end, not per-pass totals — a trigger this pass submitted
may settle during the next. `queued` is the honest per-pass number and the new
`triggerQueuePending` says how much had not landed when the pass ended.

Queue depth now also rides the sweep heartbeat, so the queue is visible WHILE a pass runs.
Previously the only reading came from a finished pass, which is useless for a drain whose
whole purpose is to outlive the pass — and that gap cost real time diagnosing a live fleet.

KNOWN TRADEOFF, not fixed here: filing everything due-now flattens priority, since claims
order by nextRenderTime and a large ready set at the same due time competes evenly. The
backlog drains, but lower-priority work starves while it does. Render priority lanes (#80)
are the real fix.

Co-Authored-By: Claude Opus 5 (1M context) <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 updates @harperfast/prerender to version 0.75.0 and restructures the change probe sweep's trigger queue so that it outlives individual passes, defaulting maxTriggersPerSweep to 0 (unlimited) to avoid discarding paid-for origin reads. Feedback on these changes suggests ensuring that the queue is stopped and flushed when the change probe is disabled via live configuration to prevent data loss. Additionally, the reviewer recommends preserving per-pass delta metrics for triggered and errors instead of mixing cumulative queue stats with delta metrics, which complicates dashboard querying and aggregation.

Comment on lines +1158 to +1160
// An aborted pass does NOT stop the queue either: what is already in it was genuinely
// detected and its baseline is unwritten, so draining it is still the right thing. Only
// disabling the probe clears it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When disabling the change probe via live configuration changes (e.g., when config.changeProbe.enabled becomes false in syncProbeTimers), ensure that we stop the timer and flush any remaining buffered data or counters in the queue one last time before clearing it. This prevents partial interval data or pending triggers from being silently lost.

References
  1. When disabling a periodic timer or stats collector via live configuration changes, ensure to stop the timer and flush any remaining buffered data or counters one last time to prevent partial interval data from being silently lost.

Comment on lines 1165 to 1166
stats.triggered = triggers.stats.triggered;
stats.errors = triggers.stats.errors;

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

Overwriting stats.triggered and stats.errors with the cumulative queue stats (triggers.stats.triggered and triggers.stats.errors) changes their meaning to cumulative counters. However, other metrics in the same probe_* series (like probe_probed, probe_changed, probe_deferred) remain per-pass deltas. Mixing cumulative and delta metrics in the same series makes dashboard querying and TSDB aggregation highly inconsistent and difficult. Consider preserving the per-pass delta meaning for triggered and errors by capturing the queue's start stats at the beginning of the pass and subtracting them at the end.

harper-joseph added a commit that referenced this pull request Sep 18, 2026
…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>
@harper-joseph

Copy link
Copy Markdown
Contributor Author

Reviewing this as part of the combined release train (#165#177#178#181#182, plus kohls-pr#112 which lowers canary.threshold to 0.4 and therefore makes canary trips more frequent). Two findings I verified directly, one I verified against the live cluster, and several from a deeper pass that I have not independently confirmed — labelled as such.

Verified: an aborted pass no longer stops the queue, and nothing else does either

The diff replaces

if (stats.aborted) triggers.stop();
await triggers.drain();

with a comment stating "An aborted pass does NOT stop the queue either… Only disabling the probe clears it."

That path does not exist. resetTriggerQueue (changeProbe.js:350) has exactly one caller — resetChangeProbeState at :1773, which is documented "Tests only". syncProbeTimers clears timers and the loop-lag monitor and never touches the queue. So disabling the probe does not clear it.

Operationally that matters most during the incident this PR is for: changeProbe.enabled: false is the kill switch, and with the new maxPending the queue keeps hard-expiring pages and filing due-now rows for up to maxPending / ratePerSecond after the operator turned the probe off — 4h37m at 50,000/3. Before this PR, isCanceled aborted the pass and stats.aborted dropped the queue, so the kill switch worked.

Smallest fix: call resetTriggerQueue() from syncProbeTimers when enabled goes false, which makes the comment true.

Verified against the live cluster: this ships inert on kohls

kohls holds a config-override row changeProbe.maxTriggersPerSweep = 90000 (confirmed just now via GET /prerender_admin/config, alongside mode: anchored, canary.threshold: 0.7, cycleTarget: 24h). The override layer wins over both config.yaml and the package default, so merging this changes nothing on the one deployment it was written for — and whenever that row is later deleted, behaviour jumps 90,000 → unlimited with no deploy and no PR.

kohls-pr#112 prescribes an explicit deploy-then-delete order for exactly this hazard on canary.threshold. This PR needs the equivalent step for maxTriggersPerSweep in its description.

Also worth knowing: kohls sets only trigger.ratePerSecond: 3 and has no trigger.maxPending override, so it picks up the new default.

Not independently verified — worth the author's eyes

These come from a deeper review pass. I have not traced them myself; treat as leads, not conclusions.

  1. A canary trip may now write hours-old baselines over the post-trip reseed. A trip → requestSweepReseedsweepInterrupt → pass aborts → chained dry-run reseed writes fresh baselines. Meanwhile the surviving queue calls writeSignature(url, item.observed, { clearClaim: true }) for the same key range with signatures captured up to maxPending/ratePerSecond earlier — regressing signature, stamping probedAt fresh (so reprobeAfter: 6h then skips those URLs next pass), and nulling pageSignature/pageClaimAt, which is the pageCheck mismatch detector. If so, this is verbatim the failure actOnTrip's own comment at :1320-1325 says the reseed exists to prevent — and that comment is untouched by the PR.

  2. Deferral relocates rather than disappears. triggerQueue.js SUBMIT_FULL increments the same stats.deferred as the old budget path, so probe_deferred can no longer distinguish "budget spent" from "queue full". On an 81%-of-cohort event at 305k/node, the arithmetic given was ~27% still deferring at maxPending 50,000. That contradicts the new schema text ("an OVERLOAD ALARM … not a routine outcome") and METRICS.md ("nothing is deferred in normal operation").

  3. triggered/errors/trigger_queue_depth become process-lifetime counters read once per pass, while the console sums them across passes (probe.js:412) and labels last.triggered as this pass's. maxDepth as a lifetime high-water mark means once the queue touches maxPending once, the depth alarm reads maxed forever — disabling the very signal the new config text tells operators to watch.

  4. A live ratePerSecond edit drops the whole queue (sweepTriggers rebuild → stop() → clears pending), which is the remediation the new schema text recommends when depth sits high.

  5. Two other subsystems document maxTriggersPerSweep as their bound for a whole-corpus false-change event — changeProbeSpec.js:250 and configSchema.js:951-953 (statusSignals). With the default at 0 that bound is gone; both comments are now stale.

Ordering

I'd ask that #178 and kohls-pr#112 not ship in the same deploy: #178's riskiest path is triggered by a canary trip, and #112 makes trips more likely. Happy to be wrong on the unverified items — flagging rather than blocking.

🤖 Generated with Claude Code

harper-joseph added a commit that referenced this pull request Sep 18, 2026
…at each URL produces; v0.79.0

The browser now posts what its readiness contract said. Without something on this
side reading it, contracts would reproduce the exact trap they exist to avoid: a
gate that fails on every render and costs a timeout, with nothing anywhere saying
so. Two consumers.

1. `render_readiness`, a new metric. `verdict` is the share of renders that
   finished COMPLETE — the only signal that separates a render missing a widget
   from a good one, since both are 200, non-empty and indexable (measured under
   CPU contention: 8 of 15 renders finished unsatisfied and all 15 reported
   outcome=ok). `unmet` names the CLAUSE, so a contract that has rotted against a
   template change reads as one clause failing across a page type rather than as
   an unexplained slowdown. `satisfied_ms` is what `timeoutMs` should be tuned
   from: a p95 approaching the timeout means the contract is being abandoned under
   load and the optimisation is quietly gone.

2. `RenderExpectation`, per-URL, node-local, written on the result path beside the
   probe claim. A contract bounds what it NAMES, and recommendation rails cannot
   usefully be named — they have no server-rendered placeholder, so "every rail
   that exists is filled" is true of a page that ended up with one rail instead of
   three. Measured: a render that satisfied its contract stored 610 product links
   where that URL normally stores 674. The page's own history is the only oracle
   that covers it.

   It converges rather than alarming forever, which is the whole difficulty: a
   rail removed site-wide must not fail that URL on every future render. A
   shortfall is a vote, not a verdict — three in a row and the expectation is
   re-learned. A SUSPECTED shortfall deliberately does not re-learn, because
   learning from a render we believe is short would ratchet the expectation down
   to whatever the page just failed to produce, which is how a real regression
   would erase its own evidence.

The comparison runs where the data already is, on the result. Handing the
expectation to the renderer at claim time would cost a cross-database point read
per job or a denormalization onto RenderSchedule — residency-pinned and rewritten
on every render, the two costs its own schema comments exist to avoid. The browser
still supports being given expectations for the in-render path; nothing needs it
for detection.

Both are best-effort on the render path, like recordPageClaim: a regression signal
must never cost a render.

Version note: main is at 0.78.0 and open PR #178 bumps to 0.75.0, which would
downgrade it. This takes 0.79.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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