Skip to content

feat: act on a detected change when it is found; resume an interrupted probe pass (plugin v0.94.0, console v0.19.0) - #222

Merged
harper-joseph merged 7 commits into
mainfrom
feat/probe-expire-on-detect
Sep 28, 2026
Merged

harper-joseph merged 7 commits into
mainfrom
feat/probe-expire-on-detect

Conversation

@harper-joseph

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

Copy link
Copy Markdown
Contributor

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:

  • the queue drained at trigger.ratePerSecond (3/s per node there), was capped at trigger.maxPending (50k), and everything past a cap was deferred to the next pass;
  • each node spent over 12 hours draining and deferred ~33k changes;
  • a restart dropped the ~18k still queued.

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:

  • the claim floor, and the claim scan's slowdown when rows pile onto one minute (both gone in 0.93.0);
  • the render fleet's capacity, which bounds how fast pages re-render, not how fast a known-wrong page should stop being served.

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).

    • actOnChange hard-expires the cached device pages and files the render.
    • Actions run beside the walk, at most 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.
    • The new baseline is written only after its action succeeds, so a failure or a restart leaves the change detectable.
    • Disabling the probe stops the pass within a batch; at most the in-flight actions finish.
  • Changed pages render first.

    • The schedule row carries a durable mark, the new RenderSchedule.changedAt column.
    • The keeper ranks marked rows queue.ready.changedHeadStart cadences ahead (default 1). The head start is additive because a just-filed row has lateness 0, which no multiplier can move.
    • Starvation stays bounded: a routine row wins once it is more than changedHeadStart cadences later than the changed rows being held (documented in util/renderPriority.js). So during a big wave, a 1h-cadence homepage re-renders about an hour late at worst.
    • A failed render's retry carries the mark; the render's own reschedule clears it.
    • queue-state gains now.dueChanged and now.oldestChangedAt.
  • fileDueNow (util/renderSchedule.js) is now used by every "render this now" writer: the probe action, revalidate, render-now and admin rejoin.

    • An already-due row keeps its due time and its mark, so a change found again, or re-filed for another reason, never moves back in the queue.
    • This works on the row's owner, where the read is local. Elsewhere the row is filed plainly, as before.
    • A revalidate now only ever lowers a page's expiry. Before, it could put a probe-hard-expired page back into stale-while-revalidate serving.
  • Restart resilience.

    • The sweep's heartbeat publishes a walk cursor: the last fully probed batch, held back to the lowest URL with an action still in flight.
    • On boot in anchored mode, a claim that is still running but whose heartbeat stopped (stale after 5 min) is resumed from that cursor.
    • The resume keeps the interrupted pass's dry-run and reseed settings and the start of the pass it continues. A config apply before the check fires re-arms it rather than dropping it.
  • 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.

    • The pass records triggered (changes acted on), errors, maxActionsInFlight, and actionWaitMs, the time the walk spent blocked on a full pipeline.
    • The running pass reports actionsInFlight, cursor, reseed and originStartedAt.
  • Removed:

    • config changeProbe.maxTriggersPerSweep, changeProbe.trigger.ratePerSecond and changeProbe.trigger.maxPending;
    • series probe_deferred and probe_trigger_queue_depth;
    • pass fields queued, deferred and triggerQueueDepth.

    trigger.concurrency now means actions in flight, and defaults to 8.

Console 0.19.0

  • Probe view:
    • The queue-depth and deferral alarms, rows and chart series are gone, along with the budget and drain-rate settings rows.
    • New: "Acted on" and "Action errors" (a watch on any error), and actions in flight with the time spent waiting for a slot. A watch fires when that wait passes 10% of the pass's time and at least a minute.
    • A resumed pass shows the pass it continues and its resume point.
  • Queue:
    • a "Changed, waiting" tile, judged by the worse of the oldest changed page's actual wait and the time to re-render the count (2h watch, 8h bad);
    • a per-node changed column;
    • changed classes marked in the lateness table.
  • Health: a "Changed pages waiting" check.
  • Guard test: the probe view must not read a series or field the plugin no longer declares.
  • On older plugins the new displays stay hidden.

Deploying

  • Deploy the plugin to every node before console 0.19.0.
  • Config: delete the three removed keys from config.yaml and from any stored overrides. A stored override logs a warning until cleared.
  • Schema: RenderSchedule.changedAt is additive. I verified it with an in-place 0.93.1 → 0.94.0 upgrade on Harper 5.2.14.
  • On a wave day, origin load goes up by design. A changed page is expired when it is found, and is served from the origin until its render lands. The fleet re-renders changed pages first. Before this change, most such pages kept serving known-wrong content from the cache.
  • Today's leftovers: changes deferred or dropped before this deploy stay detectable (their baselines are stale). The next pass acts on all of them, from the moment it finds them.

Verification

  • Unit tests: plugin node --test 1403/1403, console 444/444. eslint and prettier --check are clean.
  • Mutation checks: I broke each property below and confirmed a test fails:
    • baseline written after the action;
    • backpressure;
    • head start, and its bound;
    • re-detection keeps its place and its first changedAt;
    • no second patch of an expired page;
    • retry carries the mark;
    • the keeper load reads the mark;
    • the resume cursor held back to in-flight actions;
    • the resume walks from the cursor, keeps the dry-run setting, and is armed at boot;
    • an early config apply cannot drop a pending resume;
    • revalidate never raises an expiry;
    • fileDueNow never demotes;
    • wait time is counted;
    • both reseed paths are dry runs.
  • Real Harper 5.2.14 in Docker, with a local fake price origin:
    • Upgrade: 0.93.1 → 0.94.0 in place added changedAt, and the keeper came up exact.
    • Pass with 150 of 300 prices changed:
      • 150 acted on, 0 errors, 6.3 s, the same duration as a pass with no changes;
      • each changed row was marked and its page's expiry backdated past the swr window;
      • dueChanged: 150;
      • the claim returned only changed product pages, ahead of 20 routine rows that were 6 hours overdue.
    • Restart mid-pass (400 rows, 20% changed, restarted after 196 were probed):
      • it resumed 304 s after the restart, from the published cursor, and probed the remaining 205 rows;
      • that's 405 probes in all for 400 rows, against about 560 with the pre-review "skip by baseline age" and 600 from the top;
      • it acted on the other 40 changes, so all 80 changed rows ended up marked;
      • it recorded the interrupted pass's start as resumedFrom, and actionWaitMs was 0.
    • 0 Harper [error] lines.
  • Review: one fresh-context adversarial review of the plugin diff.
    • Bugs: the resume re-probed every unchanged row (fixed with the cursor), and the reseed tests passed only by the schema's default (fixed).
    • Risks: an acting reseed made a false trip irreversible (fixed by keeping it a dry run); backpressure was invisible (fixed with actionWaitMs); the resume dropped the interrupted pass's mode (fixed).
    • Nits: a config apply could cancel a pending resume; writers dropped the mark; a revalidate could raise a hard expiry; BigInt, Infinity and zero-value edge cases; stale docs. All fixed.

Not covered

  • No multi-node cluster run. In particular, a render-now or revalidate issued on a node that does not own the row cannot see the mark, and its write replaces the owner's row. That is the same cross-node behaviour as before.
  • The canary is unchanged apart from staying a dry run. At its 0.7 threshold, a mass change still bulk-invalidates the scope and relies on the accelerator to heal.
  • A crash can cost the actions in flight (at most trigger.concurrency). They are re-probed on resume because the cursor is held back to them.

🤖 Generated with Claude Code

harper-joseph and others added 6 commits September 28, 2026 14:43
…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>

@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 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'])) ?? {};

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

Please ensure that getScheduleRow is imported from ../util/renderSchedule.js at the top of this file, as it is now being called here to retrieve the changedAt field.

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.

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.

Comment thread packages/plugin/src/util/changeProbe.js Outdated
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' });

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 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).

Suggested change
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
  1. 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.

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.

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>
@harper-joseph
harper-joseph merged commit 16e851e into main Sep 28, 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