Repository navigation
feat: act on a detected change when it is found; resume an interrupted probe pass (plugin v0.94.0, console v0.19.0) - #222
Conversation
…e the render ahead of rotation; resume an interrupted pass; v0.94.0 The change probe's trigger queue drained at `trigger.ratePerSecond`, capped at `trigger.maxPending` and `maxTriggersPerSweep`, and DEFERRED everything past either cap to the next pass. On a day when 60% of the product corpus changed overnight each node took over 12h to drain, deferred ~35k changes it had detected, and still held thousands when a restart dropped the in-memory queue — ~152k pages the probe knew were wrong kept serving until the next day's pass. - A detected change is acted on when it is found (util/changeActions.js): the page is hard-expired and its render filed. Actions run beside the walk, at most `trigger.concurrency` in flight; a full pipeline makes the pass wait (backpressure), never drops. No budget, no deferral. The baseline is written only after its action succeeds, so a failure or a restart leaves the change detectable. - The render is MARKED (`RenderSchedule.changedAt`, durable) and the keeper ranks it `queue.ready.changedHeadStart` cadences ahead (default 1): additive, bounded — a routine row wins once it is more than that many cadences later than the changed rows held. A failed render's retry carries the mark; the render's reschedule clears it. queue-state reports `now.dueChanged`. - Idempotent: an already hard-expired page is not patched again, a due row keeps its due time, and a change found again before its render landed keeps its place and first `changedAt`. - Restart resilience: anchored mode resumes a sweep a restart cut short (a `running` claim whose heartbeat stopped), skipping every row the interrupted pass already probed. - The canary's reseed acts too (it was a dry run): after a trip, changed pages re-render first. Removed: `changeProbe.maxTriggersPerSweep`, `changeProbe.trigger.ratePerSecond`, `changeProbe.trigger.maxPending`; `probe_deferred`, `probe_trigger_queue_depth`; pass fields `queued`, `deferred`, `triggerQueueDepth`. `trigger.concurrency` now means actions in flight (default 8). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed at boot Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ver demote a re-filed row (review) Adversarial review of the change-probe redesign: - RESUME re-probed most of the pass: `probedAt` moves only when a baseline is written, and an unchanged probe writes none, so "skip rows probed since the pass began" skipped only the changed rows. The heartbeat now publishes a walk CURSOR (the last fully-probed batch, held back to the lowest URL whose action is still in flight) and the resume starts the walk there. It carries the interrupted pass's own dry-run and reseed semantics and the start of the pass it continues, and a config apply before the check fires re-arms it instead of dropping it. - The canary's RESEED is a dry run again: the trip already invalidated the scope, and per-URL hard-expiry would make a false trip irreversible, re-expire pages that re-rendered correctly since, and put the scope ahead of the accelerator's demand-driven heals. Its tests now pin the dry run on an armed probe (they passed by the schema default before). - `fileDueNow` (util/renderSchedule.js): every "render this now" writer — the probe action, a revalidate, a render-now, an admin rejoin — keeps an already-due row's due time and its change mark (on the owner, where the read is local). A revalidate only ever LOWERS a page's expiry, so it cannot put a probe hard-expired page back into swr serving. - Backpressure is visible: `actionWaitMs` (time the walk was blocked on a full action pipeline) on the progress and the finished pass. - queue-state `now.oldestChangedAt`; the running pass reports `reseed` and `originStartedAt`. - Nits: a BigInt-safe expiry read, a non-finite head start adds nothing, a reclassified changed row can never read as unmarked, stale comments and docs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and action errors, resumed passes, changed pages waiting; v0.19.0 Plugin 0.94.0 acts on a detected change when it is found (page hard-expired, render filed ahead of rotation) and removed the trigger queue, its budget and every deferral. The console still drew all of it: a "deferred" alarm, a queue-near-full alarm, a queue-depth tile and chart series, a drain-rate ETA, and "Trigger queue" / "Triggers per sweep" settings rows — each reading fields that no longer exist. Change probe: - Removed every display, flag, chart series and settings row for `deferred`, `queued`, `triggerQueueDepth`, `maxTriggersPerSweep`, `trigger.maxPending`, `trigger.ratePerSecond`, `probe_deferred` and `probe_trigger_queue_depth`. Draining has no ETA now (at most trigger.concurrency actions settling); the node table says "walk finished · finishing N actions". - "Acted on" (triggered) and "Action errors" rows; a non-zero `errors` is an `action-errors` watch on any count, last pass or running pass — each is a page still serving, retried on its next probe. - Actions in flight (running pass) and the most in flight (finished pass), against trigger.concurrency; rows left out when no node reports them, n/a with the version for an older node in a mixed cluster. - `startedBy: 'resume'`: a "resumed" pill on the running pass, "resumed after a restart · continues the pass started …" on the last pass, and a `resumed` info flag explaining the large skipped-as-fresh share. - Settings: "Actions in flight — at most N" replaces the trigger-queue rows. Queue and Health: - `now.dueChanged` as "Changed, waiting" beside Due now, judged by time to re-render at the observed render rate (> 2h watch, > 8h bad, as the backlog); a per-node "changed" column in the keeper table; changed classes marked in the lateness table; a "Changed pages waiting" Health check. All hidden when no node reports the field; a mixed-version cluster withholds the sum and names the node. - The queue-state merge sums `dueChanged` only when every node reports it, and keys classes by the changed flag so a changed class no longer folds into its routine twin. Guards: a new test fails if the probe view reads a `probe_*` series the plugin no longer declares (the catalog → console test could not see that direction), and if any client module names a removed field or setting. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… action backpressure, oldest changed wait Plugin 6a4dfa0 changed the change-probe contract under console 0.19.0; this follows it. - Resume walks from a cursor now, so a resumed pass has no large `fresh` count: the info flag that explained one is gone. The running pass shows what it continues (`current.originStartedAt`, and "the reseed (a dry run)" when `current.reseed`), its resume point (`progress.cursor`), and no percentage or ETA: its counts cover only the tail of the key range, so matched ÷ slice under-read it and projected a false "will overrun the anchor". The finished pass shows `resumedFrom` and `resumeCursor`. - Backpressure: `actionWaitMs` sits beside actions in flight on both pass cards, and a new `action-bound` watch fires past 10% of the pass's elapsed time (and at least a minute) — the pass is paced by its actions, which from outside looks like origin throttling. The Pacing card no longer derives an origin latency for an action-bound pass; it names trigger.concurrency instead. - Changed pages waiting is judged by the ACTUAL wait of the oldest changed page (`now.oldestChangedAt`) as well as count ÷ render rate — the worse wins, 2h watch / 8h bad for both (`LIMITS.changedWaitMs`), on the Queue tile and the Health check alike. The merge takes the earliest across nodes, only when every node sends the field (null is an answer; unreported is left out). - The canary reseed is a dry run again; nothing in the console said otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e latter measures 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.94.0 and the console to version 0.19.0, replacing the in-memory trigger queue with an immediate action pipeline that hard-expires changed pages and files their renders ahead of routine rotation. It also introduces restart resilience, allowing interrupted anchored sweeps to resume from their last walk cursor on boot, and updates the console UI to display these new metrics. The feedback recommends ensuring that getScheduleRow is properly imported in RenderQueue.js and suggests using the !(elapsed < interval) pattern in changeProbe.js to safely handle potential NaN values during elapsed time comparisons.
| // row; `put` replaces the record, so a retry that omitted the mark would quietly demote a page | ||
| // that is still being served from the origin. A local point read: results land on the owner, | ||
| // and elsewhere it reads nothing and the mark is simply not carried. | ||
| const { changedAt } = (await getScheduleRow(sourceUrl, ['changedAt'])) ?? {}; |
There was a problem hiding this comment.
It is imported: getScheduleRow is in the ../util/renderSchedule.js import block at the top of RenderQueue.js (line 28), and the retry test (renderQueueVariants.test.js, "a CHANGED row keeps its mark through a failed render") exercises this call. No change.
| return { resumed: false, reason: 'claim still live' }; | ||
| } | ||
| const originStartedAt = epochMsOf(sweep.originStartedAt ?? sweep.startedAt); | ||
| if (Date.now() - originStartedAt > RESUME_WITHIN_MS) return decided({ resumed: false, reason: 'older than a day' }); |
There was a problem hiding this comment.
When writing elapsed time comparisons where the timestamp might be null, undefined, or unparseable (resulting in NaN), use the pattern !(elapsed < interval) instead of elapsed >= interval (or elapsed > interval). This ensures that NaN values fail the inequality comparison and correctly fall through to the fallback/unconditional branch (failing closed by not resuming the pass).
| if (Date.now() - originStartedAt > RESUME_WITHIN_MS) return decided({ resumed: false, reason: 'older than a day' }); | |
| const elapsed = Date.now() - originStartedAt; | |
| if (!(elapsed < RESUME_WITHIN_MS)) return decided({ resumed: false, reason: 'older than a day' }); |
References
- When writing elapsed time comparisons where the timestamp might be null, undefined, or unparseable (resulting in NaN), use the pattern
!(elapsed < interval)instead ofelapsed >= interval.
There was a problem hiding this comment.
Fixed in c7aeca7: the age test is now !(Date.now() - originStartedAt < RESUME_WITHIN_MS), and an unusable recorded originStartedAt falls back to the claim's own (validated) startedAt. NaN would also have thrown in the log line's toISOString() and silently cancelled the resume. Test: "an unusable recorded origin falls back to the claim's own start".
…igin falls back to the claim's start (review) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Why
On 2026-09-28 the change probe found 60% of product pages changed overnight on one deployment. That was ~696k across 4 nodes; the day before it was ~74k. Changes went through an in-memory trigger queue:
trigger.ratePerSecond(3/s per node there), was capped attrigger.maxPending(50k), and everything past a cap was deferred to the next pass;That left ~152k pages the probe knew were wrong still being served until the next day's pass. Anchored mode also never resumed a pass a restart cut short.
The limits protected things that no longer exist, or no longer need it:
Supersedes #178, which moved toward queuing everything but kept the queue.
What changed
Plugin 0.94.0
A detected change is acted on when it is found (
util/changeActions.js).actOnChangehard-expires the cached device pages and files the render.trigger.concurrency(8) at once. When every slot is busy the walk waits for one (backpressure): nothing is refused, deferred or dropped, and there is no per-pass budget.Changed pages render first.
RenderSchedule.changedAtcolumn.queue.ready.changedHeadStartcadences ahead (default 1). The head start is additive because a just-filed row has lateness 0, which no multiplier can move.changedHeadStartcadences later than the changed rows being held (documented inutil/renderPriority.js). So during a big wave, a 1h-cadence homepage re-renders about an hour late at worst.now.dueChangedandnow.oldestChangedAt.fileDueNow(util/renderSchedule.js) is now used by every "render this now" writer: the probe action, revalidate, render-now and admin rejoin.Restart resilience.
runningbut whose heartbeat stopped (stale after 5 min) is resumed from that cursor.The canary's reseed stays a dry run. The trip has already invalidated the whole scope. Per-URL expiry on top would make a false trip irreversible, and would re-expire pages that had already re-rendered correctly.
Visibility.
triggered(changes acted on),errors,maxActionsInFlight, andactionWaitMs, the time the walk spent blocked on a full pipeline.actionsInFlight,cursor,reseedandoriginStartedAt.Removed:
changeProbe.maxTriggersPerSweep,changeProbe.trigger.ratePerSecondandchangeProbe.trigger.maxPending;probe_deferredandprobe_trigger_queue_depth;queued,deferredandtriggerQueueDepth.trigger.concurrencynow means actions in flight, and defaults to 8.Console 0.19.0
Deploying
config.yamland from any stored overrides. A stored override logs a warning until cleared.RenderSchedule.changedAtis additive. I verified it with an in-place 0.93.1 → 0.94.0 upgrade on Harper 5.2.14.Verification
node --test1403/1403, console 444/444.eslintandprettier --checkare clean.changedAt;fileDueNownever demotes;changedAt, and the keeper came up exact.dueChanged: 150;resumedFrom, andactionWaitMswas 0.[error]lines.actionWaitMs); the resume dropped the interrupted pass's mode (fixed).Not covered
trigger.concurrency). They are re-probed on resume because the cursor is held back to them.🤖 Generated with Claude Code