Skip to content

Full-review fixes: sitemap walk safety, probe gaps, queue priority, serve path, admin (plugin 0.97.0, console 0.22.0) - #226

Merged
harper-joseph merged 72 commits into
mainfrom
fix/review-2026-09-29
Sep 30, 2026
Merged

harper-joseph merged 72 commits into
mainfrom
fix/review-2026-09-29

Conversation

@harper-joseph

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

Copy link
Copy Markdown
Contributor

Plugin 0.97.0, console 0.22.0. Fixes from a full review of the plugin at b312c1d, built in five areas, merged here, then reviewed again as one branch in three more rounds; those findings are fixed too. Every fix has a regression test that fails without it (plugin 1,814 tests, console 484, lint and format clean).

Deploying this needs a worker restart (schema changes, a renamed lease buffer) — see Deploy notes.

What it fixes

Sitemap walk

  • A walk with a failed child no longer turns shifted URLs into departures. A URL that moved into a child that failed was hard-expired and re-rendered, then re-rendered again as a rejoin on the next walk. Now only the held URLs on the routes the failed child last held are held back and re-linked; every other departure proceeds.
  • A truncated sitemap is a failed child, not a short one: a document whose root element never closes is refused (a truncated gzip decodes without error).
  • Shrink guard (sitemap.shrinkGuard): a urlset whose entry count falls below (1 − maxRatio) × its stored count by at least minUrls is refused and counts as a failed child. A refused shrink is accepted only after acceptAfter refusals and either a new origin version (changed Last-Modified or content digest) arriving at least newVersionAfter after the first refusal, or acceptAge. A child the index stops listing is judged after the walk by the URLs it still holds (listed nowhere else), and an index is judged the same way, so renamed children are not a partial index. Every check has the minUrls floor.
  • The origin-bypass token goes only to the root sitemap's host or domains, only over https, and redirects are followed manually with the decision re-made per hop (it used to follow redirects to any host with the token attached).
  • One departure budget per walk (sitemap.departure.maxPerWalk, 5000): across every child, from normal prunes and accepted shrinks alike, spent only on departures that get an action, and shared round-robin across the children so one child's mass event can't starve the rest. Departures past it are re-linked to the child that unlinked them (validator cleared) and depart on later walks — deferred, not dropped (departure_deferred). A well-formed partial build that loses less than maxRatio can no longer depart everything it dropped in one walk.
  • Every hold-back is visible: the refresh result and progress row carry holdBack — each child departures are held back for, why (refused shrink, failed child, budget), since when, and when a refusal can be accepted. A failed child's stored route keys are trusted only under the route table they were computed with (a fingerprint on the row), so a route edit — including a route added above another or an ingress-mode switch — widens its hold-back to its siblings instead of producing false departures.
  • A child the index stops listing is pruned (behind the guards) instead of keeping its URLs "listed" forever; a refused omission holds back its routes like a failed child.
  • A discovered URL's first sitemap listing gets the new-URL render window; a non-gone suppressed row it lists gets its recheck brought forward.
  • A URL listed by two children no longer departs when the other child answers 304 (a narrowed, retried re-read of just the children that could list it).
  • The discovery purge spares suppressed rows unless they are http-gone (they were re-minted and re-rendered in a loop).
  • Every patch that can race a cross-node delete carries its primary key (a patch to a missing record stores only the patched fields); the orphan sweeps remove key-less stubs after a re-read, deleting only the raw row.
  • Urlset rows store the first 500 entries (entryCount keeps the true count); the console pages over what is stored.
  • Bulk REST writers (DELETE /sitemaps/<url>, collection revalidate, POST /sitemaps with background: false) run detached from the request transaction.

Change probe

  • A pass skips only rows already probed since it (or the pass it resumes) began. It used to skip any URL whose last change was under reprobeAfter old — so a change caught by a daytime or manual sweep hid that URL from the next night's pass. reprobeAfter is retired (accepted, unused).
  • The anchored pass can't be lost: it interrupts a dry-run or reseed pass (across workers, re-asserted until honoured), chains after any other pass, and is caught up at boot if no armed pass (or, in dry-run config, dry pass) served the latest anchor. A pass that throws is resumed from its cursor; a row-level ProbeState fault skips only that row. probe_anchor counts each outcome.
  • The reseed after a canary trip acts on changes — except pages that all predate the invalidation epoch (so clearing a false trip restores them) and pages already healed after the trip (their claim postdates the epoch and agrees with the new observation). Coverage applies in every sweep while an invalidation is active; a covered change is counted as probe_covered, not changed, and a pre-epoch page whose claim agrees gets a verification so it serves. "Healed" requires every changed slot to be one the page check compares — a change to an unchecked slot (e.g. a strikethrough price) is acted on.
  • Render check (changeProbe.renderCheck, default on): a new render whose page claim disagrees with the probe's stored observation gets one detached confirming re-probe, paced from the headroom the sweep leaves under ratePerSecond (the total origin rate never exceeds it); the page is hard-expired and re-filed only if the origin still disagrees, and a re-probe never clears a newer claim.
  • Probe counters are emitted per batch (a thrown or restarted pass used to lose every call it made from the offload math); new probe_errors, probe_caught_up, probe_ignored. The first 1,000 failed actions of a pass are retried once after the walk, unless the row moved since.
  • "Verified" requires a comparison that happened and agreed. The heartbeat is timer-driven. Rows skipped as fresh don't consume the pacing budget. Optional time-of-day canary schedule (changeProbe.canary.schedule, default off).
  • probe_detection_lag: an upper bound on detection lag at every detected change.

Render queue

  • The slow-lane retry waits on the effective cadence, not the route ceiling (a failing page with a 48h cadence waited 96h); a changed row retries at lease scale.
  • "Render this now" asks get a head start (RenderSchedule.urgentAt, queue.ready.urgentHeadStart 0.5): they used to score 0 and sit behind every overdue row. A detected change still outranks an equally late ask; bulk re-files (collection revalidate, sitemap revalidate: true) are not marked. Both head starts scale with sitemapBoost.
  • fileDueNow off the row's owner forwards to the owner (POST /prerender_peer/due-now, peer token; queue.dueNowForward) instead of a whole-row write that replaced the owner's row and its change mark. Falls back to the local write on refusal or connect failure; a plain ask is left to a slow owner.
  • A result from a lease granted before a change mark doesn't store the old page (grant times are kept to the millisecond); a provably late result doesn't overwrite a newer page or release the newer lease (generation-checked release, superseded outcome). An ask filed mid-render survives the render.
  • A key re-granted at the same due minute counts as a wedge miss, except for outcomes that leave the row due on purpose. Reconcile heartbeats its run; the verification walk stops re-repairing rows with no usable due time (keeper_unschedulable).
  • New render change_lag_ms (change mark → render landed) and render_size (decoded size bands).

Serve path

  • Proxied responses relay Location (resolved to absolute) and Content-Language — a proxied 3xx used to reach the crawler with no target.
  • A page row we found but won't serve (stale, probe-expired, blob fault) is proxied without conditional validators, and no local 304 is built for it. Cached serves carry a weak ETag W/"<lastCached ms>-<device>" and Last-Modified from the render time instead of the origin document's validators.
  • Peer rescue checks the owner copy's serve status before serving it.
  • HEAD misses go upstream as HEAD, are never captured, and can't reopen a gone target or drop a stored 404. Bodies that won't be sent are drained (≤ 1 MiB, 5 s deadline) or released.
  • Proxied origin bodies go through originBodyStream instead of Readable.toWeb: cancelling a toWeb stream in the window after creation throws ERR_INVALID_STATE as an uncaught exception (reproduced 20/20 in that window). Measured equal or cheaper per body; backpressure, truncation errors and the capture tee are tested.
  • Invalidation state is held per worker from a subscription (with a 60s backstop re-read and per-request fallback until primed) instead of two point reads per cache-servable request; an apply is visible to the writer's own worker before recordInvalidation returns, and a clear lands with its commit (an aborted clear can't unguard a worker).
  • \n-joined stored headers are appended as separate values (repeated vary / x-robots-tag / cache-control were dropped on cache serves). page.serveLinkHeader makes the stored Link header servable — default off, as before, until stored rows are sampled.
  • Negative cache: the Target guard is read before a 404 body is teed.
  • The peer token header is never forwarded to the origin.

Admin, config, metrics

  • POST /prerender_admin/revalidate no longer throws (nextRenderTime was undefined since 0.94.0, and the throw rolled back the render it filed); no-undef is enabled for the plugin with Harper's declared globals.
  • Override rows record the file value they replaced (ConfigOverride.fileHash): layers report masking (the file changed since) and redundant (it now matches). An override that would newly drop route or probe-rule entries is refused (409).
  • The config-override doorbell and every onConfigApplied listener run detached from the committing request's context.
  • The analytics scan folds rows as it streams (identical output, checked against a golden fixture); its window cache is bounded.
  • Maxima: four timer intervals ≤ 2^31−1 ms (a larger value fired every 1 ms), render.negative.statuses 4xx integers except 429, admin read limits.
  • host.pluginVersion is read at module load.

Console (0.22.0)

Every series this release adds is charted — the metric guard waives none of them:

  • Change probe → Change safety: action errors (whether every detected change was acted on), caught up, ignored, covered; anchored-pass outcomes, with a skipped anchor flagged; detection lag from the pass start and from the previous pass (median, ≈p95, per rule); render-check outcomes.
  • Queue → Change to cache: change_lag_ms per route — median, ≈p95, mean, trend. Page size: render_size bands and the share over 1 MB by route and device. Filing: due_now_forward outcomes (fallbacks flagged) and keeper_unschedulable.
  • Sitemaps → Departures: the departure family grouped by what it means for a page (acted, shear, held back, deferred, skipped, lost), shrink refusals and acceptances, and the live hold-back list from each root's run row.
  • Also: the probe view's per-batch wording; sitemap detail pages over the stored entries.

New config options

Option Default
sitemap.shrinkGuard.maxRatio / .minUrls / .acceptAfter / .acceptAge / .newVersionAfter 0.5 / 1000 / 2 / 48h / 20h
sitemap.departure.maxPerWalk 5000 (lossless; -1 none)
changeProbe.renderCheck true
changeProbe.canary.schedule [] (fixed interval)
changeProbe.reprobeAfter retired — accepted, unused
queue.ready.urgentHeadStart 0.5
queue.dueNowForward.enabled / .timeoutMs true / 1000 (active only when peerRescue.token is set)
page.serveLinkHeader false
invalidation.syncInterval 60000

New metrics

probe_errors, probe_caught_up, probe_ignored, probe_covered, probe_anchor, probe_detection_lag, probe_render_mismatch; render change_lag_ms and outcome superseded; render_size; queue_health keeper_unschedulable; prerender_ops due_now_forward (incl. timed-out), sitemap_shrink_refused, sitemap_shrink_accepted; sitemap departure outcomes relinked, deferred, listed-unchanged, unknown-child; invalidation_error view-read-error / view-subscribe-error; serve_error bad-header. probe_* rows are now per batch: sum values, don't count rows. Documented in METRICS.md.

Deploy notes

  • Worker restart required. New attributes: RenderSchedule.urgentAt, ProbeState.renderRefiledAt, ConfigOverride.fileHash, and on Sitemap routes, routesFingerprint, shrinkRefusals, shrinkRefusedVersion, shrinkRefusedAt, shrinkAcceptedAt. The lease buffer is renamed (render_queue_v3): expect the usual one-time duplicate-render burst on restart.
  • Don't write config overrides during the rolling restart (ConfigOverride is sealed; a row carrying fileHash replicating to a not-yet-restarted node is untested).
  • Check stored child sitemaps' hosts and schemes before deploying: a child on a host outside domains, or on http://, is now fetched without the bypass token (and fails if the edge requires it).
  • Existing override rows show masking: null until re-saved; rows now identical to the file show redundant: true — safe to clear.
  • An anchored node restarted before any armed pass served the latest anchor runs a catch-up pass at boot (once more on the first restart after upgrading, if a dry run was the last pass recorded).
  • Departures are now metered at 5000 per walk by default, including on deployments that set sitemap.departure.maxActions: -1: a large legitimate removal (a clearance event) departs over several walks instead of one. Set sitemap.departure.maxPerWalk: -1 to restore the old behaviour.
  • A refused product child holds back departures on its route until the shrink is accepted (next rebuild that is still short, or acceptAge); the change probe still catches availability changes meanwhile.

Not in this release

A truncated origin body leaves the crawler's connection open (Harper pipes without an error listener; predates this branch). Own-code failures that answer 500 instead of falling back to the origin; a cap on UA-derived analytics bot labels; expiring non-default device pages on a probe trip; malformed URLs in prefix mode (500 → 400); the probe's swrTtl-relative hard expiry (don't raise page.swrTtl until it is fixed).

🤖 Generated with Claude Code

…v0.97.0

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 updates the version of the @harperfast/prerender package from 0.96.0 to 0.97.0 in both package.json and package-lock.json. There are no review comments, and I have no additional feedback to provide.

harper-joseph and others added 28 commits September 29, 2026 17:31
… filed instead of throwing

v0.94.0 moved the write to `fileDueNow` and deleted the `nextRenderTime` local the response still
returned, so every revalidate threw a ReferenceError after filing and Harper rolled the filing back.
`fileDueNow` now returns the due time it wrote, which the handler reports.

`no-undef` is now on for packages/plugin, with Harper's own declared globals (its dist/index.d.ts)
and the Node runtime globals the plugin uses. That undefined local was the only hit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…che statuses in the schema

- `ingress.report.interval`, `management.backlogSnapshotInterval`, `render.reconcile.interval`
  and `queue.statusSyncInterval` now max out at 2147483647 ms. Past it Node fires a timer every
  millisecond, so "30 days = effectively off" was a hot loop.
- `management.analytics.maxRange` (max 7 days), `management.analytics.scanCap` (max 3,000,000),
  `management.scanCap` (max 1,000,000) and `management.pageSize` (max 500) are console-editable and
  had no ceiling.
- `render.negative.statuses` entries must be integers 400-499 other than 429. A 5xx or 429 would
  store and replay an origin failure, and a string such as "404" stored nothing. This is enforced
  through a new schema field, `items` (a per-entry rule for a list of numbers), which rejects the
  whole list at apply time and at the override door, like `itemEnum`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… racing a cross-node delete leaves a readable row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e loads, not on first request

After a deploy that does not restart Harper, the new package.json is on disk under workers still
running the old code, and a lazy read on the first overview request reported the staged version as
the running one. Read at module load, it names the code the worker loaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rs run outside the caller's request

The override subscription's listener may run in the async context of the request whose write rang
it, and a timer armed there inherits that request's transaction. So the re-read, the apply and every
timer the apply re-arms (probe, reconcile, backlog, the backstop poll) could run on a transaction
Harper closes after the response ("Database closed during transaction get"). The debounce timer is
now armed through `runDetached`, and `applyOptions` runs each listener through a new synchronous
`runInLoadContext` (util/detach.js). That covers every listener whatever called the apply, and a
listener's throw is still caught by the apply's own guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… due time apart, instead of re-repairing them every pass

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s only rows that make a request

The freshness skip keyed on `probedAt`, which moves only when a baseline is written, so it skipped
exactly the URLs that had recently changed: a change caught by an off-schedule pass was skipped by the
next anchored pass and its reprice waited a further day. A pass now skips only rows written since it
(or the pass it resumes) began; `changeProbe.reprobeAfter` is retired (accepted, read by nothing).
Rows skipped as fresh no longer consume the rate window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the staleness window is not taken over by a second one

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…red something and agreed

`claimsDisagree` answered false both on agreement and when no dimension was comparable (an
unrecognised page availability beside a null endpoint price), and the verification proof for the
price/availability pair was only that a stored claim existed. `compareClaims` now answers
agreed / disagreed / nothing comparable, and the proof requires agreed — as mapped fields already
required an armed field that agreed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and a changed page's at lease scale

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… window cache is bounded

- `bucketize` and `systemSeries` are split into accumulate (`add`) and `finish`
  (`createSeriesFold` / `createSystemFold`), and the scan folds each row inside the walk. Before,
  it held every kept row until the walk ended: about 250k objects at a 300k scan cap, on a worker
  that also serves bot traffic. Memory now scales with combos x buckets. The output is identical:
  a golden file produced by the pre-change code on a seeded fixture pins it, and a walk that
  poisons each row after yielding it proves nothing is buffered.
- The window cache was keyed by the exact requested range and never evicted, so a script walking
  the range parameter grew it for the life of the worker. `clampRange` now rounds to a whole minute,
  and the cache keeps at most eight windows (the console offers six), evicting the oldest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n is retried once after the walk

`probe_*` pass counters were one emit per finished pass: lost whole to a dropped analytics window,
lost entirely when the pass threw (the emit sat after the walk). They are now emitted per probed
batch as increments, and the error path reports what it reached. New series: `probe_errors`,
`probe_caught_up`, `probe_ignored`. A change whose action threw used to wait for the next pass,
because the cursor had moved past it; the first 1,000 failures of a pass are retried once when the
walk ends (pass record: retried, recovered, unacted). The console guard waives the new series.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… not a table-row edit

Growing the prerender_ops table cell re-pads every row of the table under prettier; a note beside the
entity-gate one says the same without touching lines other changes edit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ot on walk progress

The heartbeat rode the walk's yields (every 200 rows examined), so a pass inside one long await — a
Retry-After wait capped at 5 minutes (the staleness window), a slow action slot — read as dead, and a
console "Run sweep" started a second full-rate pass beside it. A pass-long unref'd interval now beats
through the walk, the drain and the retries, and is stopped before the claim is released.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… one never releases or overwrites the next lease's, and one granted before a change mark is not stored

A lease slot now carries its latest grant's instant and due minute (buffer v3). A result whose render
provably began before the key's latest grant is dropped whole and its release is generation-checked;
a result whose grant predates the row's changedAt stores nothing and leaves the row due and marked;
and a released key re-granted at the same due minute counts a miss, so a result whose commit failed
after the release engages the wedge guard instead of looping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…use lists the compiler would drop

A config override row holds a whole option, and a list is one option. So one route edited from the
console stored a copy of every route, and later config.yaml edits to any route deployed and were
merged away silently.

- Each ConfigOverride row now records `fileHash` (new schema attribute), a stable hash of the file
  layer's value at its path when it is written. `GET /prerender_admin/config` layers carry
  `masking` (the override is in effect and the file has changed since the row was written; null for
  a row written before this change, which recorded nothing) and `redundant` (the override equals the
  file now). A masking row is logged at every apply and is a `warn` finding in `warnings`. A
  redundant one is an `info` finding. Re-saving a row changes its fingerprint, so every worker picks
  it up.
- `POST /prerender_admin/config-override`: a set of `ingress.routes` or `changeProbe.rules` whose
  compiler would drop entries is listed in `rejected` (with `dropped` and the compiler's reasons)
  and refused with 409 on apply. Before, the apply answered `applied: true`. The check inspects
  the value being set, so a bad entry already in the file never blocks an unrelated edit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… masking/redundant + dropped-entry refusal, streaming analytics fold, negative statuses validation, version at load, admin read maxima, detached config doorbell

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ep or a restart

The anchor timer awaited runProbeSweepOnce, which returns {skipped} whenever any sweep holds the node
(a reseed, a manual pass, a resume), and re-armed for tomorrow with no log or metric; a restart
spanning the anchor also lost the night (the timer only looks forward, and the boot check resumed
only a claim that was running). Now the anchor asks a measuring pass (dry run or reseed) to stand
down — through the claim row, so it reaches another worker's pass — and waits for an acting pass and
runs after it; at boot, a node that has started no acting pass since the most recent anchor
(DST-correct) catches that anchor's pass up, and a dead claim that predates the anchor is replaced by
a whole pass instead of resumed. New series probe_anchor, detail on_time / interrupted / chained /
caught_up / skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eue.ready.urgentHeadStart), and head starts are in boosted units

Every fileDueNow caller (render-now, revalidate, admin rejoin) and Target.put given an explicit
current-minute time (an adopted redirect destination, a sitemap revalidate) marks the row urgentAt.
Filed at the current minute such a row was zero cadences late: behind every overdue row, and past the
ready set's capacity not published at all. The keeper holds urgent rows as their own class and ranks
them queue.ready.urgentHeadStart (default 1) ahead. Both head starts are now multiplied by
sitemapBoost, so a fresh changed or urgent row outranks routine sitemap lateness under a cadence
instead of under half of one.

Touches Target.put (sitemap agent's file) for the explicit-time mark: one derived field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…idation does not cover

A trip replaced the rest of the night with a DRY-RUN reseed, but the invalidation epoch refuses only
pages rendered before it: a page re-rendered after the trip that then changed (or disagreed with its
claim) was baselined and never expired, and served wrong. The reseed now runs armed and acts on a
change unless every page of the URL predates the epoch and no verification exempts it — those it
baselines without acting (counted `covered`), so clearing a false trip still restores them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A truncated gzip body decodes leniently to a well-formed-looking prefix
(1,972 of 5,000 entries, 200, no throw), which the walk read as a shorter
sitemap: every URL past the cut was unlinked, departed and later rejoined.
The parse now refuses a document whose root is never closed, so it is a
failed child. Targeted rather than full XML validation, which refuses
real sitemaps' tolerated errors (an unescaped & in a loc).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erdict is gone

A canonical-mismatch or noindex row answers 200, so deleting it lets the
next crawl re-mint it: a delete, a mint, a render and a re-suppression per
URL. Suppression clears demandInterval, so skipVisited never spared them.
Gone rows are still purged (nothing re-mints a 404).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-created stubs

The key-rule orphan sweep used one unconstrained projected scan, which can
end silently at an unreadable row and report no orphans; it now uses
walkUrlRange. A patch landing on a just-deleted key creates a row with only
the patched fields and no primary-key attribute: walkUrlRange reports where
such a row sat, recoverKeylessKeys finds its key in the primary store, and
the Target and page sweeps delete it. The page sweep also deletes a
content-less stub (no statusCode, no lastCached) that its age test kept
forever.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dropped children, first listings

- A walk with a failed child acts on no departure and re-links what it
  unlinked (departure_relinked), so shear into a failed child is neither
  rendered as a departure nor, next walk, as a rejoin.
- sitemap.shrinkGuard (maxRatio 0.5, minUrls 1000) refuses a prune that
  would unlink most of a child; the child fails instead.
- A child the index stops listing is pruned after the walk, behind the
  guard, and its row dropped once the walk is clean.
- A URL the owning child drops but a 304'd child still lists is
  re-attached to it (departure_listed_unchanged).
- A discovered URL's first listing, or its non-gone suppression recheck,
  is filed due now inside newTargets.maxPerRun (listedSoon).
- Departures and rejoins file through fileDueNow with a change mark, so
  they keep a probe mark and get the changed head start.
- The bypass token goes only to the root's host or config.domains,
  re-decided on every manually followed redirect hop.
- Every Target/page patch names its primary key; urlset rows store a
  500-entry sample; the blocking REST writers run via runDetached.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e went unseen

No per-URL "last seen unchanged" is stored, so each change a sweep detects emits two upper bounds,
labelled by rule: since the start of the pass that found it (for an anchored pass, since the anchor
it serves) and since the start of the previous pass. A duration series — read its percentiles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… view, not two reads per request

Every cache-servable request made two Invalidation point reads (~10M/day at current
traffic; the module header had priced it at <=5,800). Each worker now holds the table in
memory and resolves synchronously; a listener-form subscription (omitCurrent) is the
doorbell that re-reads it on any committed change, and invalidation.syncInterval (1 min)
is the backstop against delivery stopping silently. Until the view has loaded under a
live subscription — at boot, after subscribe() fails or the subscription closes, after a
read fails — resolution falls back to the per-request reads and LKG exactly as before.
Re-reads run detached (a doorbell can fire inside the writer's request context). The
Invalidation database stays single-table because a subscription is priced per database.

New invalidation_error kinds: view-read-error, view-subscribe-error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he bypass token to

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e owner, so an earlier due time and a change mark survive

A node that does not own the row (about three calls in four) read nothing locally, and its whole-row put
replaced the owner's row: an overdue row pushed back to the current minute, changedAt and demandPeriod
wiped. It now asks the owner over POST /prerender_peer/due-now (peer token, a leaf that never forwards
onward) and writes nothing itself; if the owner cannot be asked or refuses, it files locally as before,
and a failing owner is not asked again for 30s. queue.dueNowForward.{enabled,timeoutMs}; counted as
prerender_ops due_now_forward.

Touches http_handlers/index.js (serve agent's file) to route the new peer path: one import, two lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
harper-joseph and others added 22 commits September 29, 2026 21:08
…rops a stored 404

Now that a HEAD goes upstream as a HEAD, its status is the origin's HEAD handler's — and many
answer 200 to any path. A HEAD may still confirm a stored 404 (as the background HEAD re-check
does), but reopening a gone-suppressed target from traffic, dropping a stored 404, and the
dry run's would-serve-live count all take a GET's status. The origin resource now carries its
`method` (added with the upstream changes that follow).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…off by default; drained 304 bodies

- A proxy that found a PrerenderedPage row it would not serve (stale — which includes probe
  hard-expired pages — blob faults, a render-now fallback over a page) now strips the
  crawler's validators upstream AND skips the local conditional, as only `invalidated` did.
  The crawler's If-Modified-Since is our render time; compared with an origin Last-Modified
  that does not track content it answered 304 locally, and the crawler kept the pre-change
  snapshot. A true miss and a raw serve keep ordinary conditional handling.
- A snapshot carries `ETag: W/"<lastCachedMs>"` beside Last-Modified, so If-None-Match
  names the exact render (no one-second HTTP-date granularity, no cross-node clock).
- `page.serveLinkHeader` defaults to FALSE (the pre-0.97.0 behaviour) until stored rows are
  sampled. Newline-joined stored headers (puppeteer joins repeats with `\n`) are appended as
  separate values — which also restores repeated x-robots-tag/vary/cache-control on cache
  serves — and a value no response may carry is counted (`serve_error` `bad-header`) with a
  rate-limited log instead of an error log per serve.
- A GET body released by a local 304 is drained (up to 1 MiB) so the pooled connection is
  reused; past that, and for HEAD, the undici stream is destroyed. Cancelling the
  Readable.toWeb wrapper is still avoided: re-verified, 10 of 20 cancels throw
  ERR_INVALID_STATE against an origin that sends its body in one write.
- A relative origin `Location` is resolved against the public request URL.
- The peer token header (`peerRescue.header`) is never forwarded to the origin.

Tests: botServePath, response, upstream.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… snapshot ETag, Link off by default + split joined headers, invalidation own-write visibility, drained local-304 bodies, HEAD can't reopen or drop, absolute Location, peer token never forwarded

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…robe before expiring it

renderCheck acted on the stored baseline's word, so it expired correct renders whenever the origin had
moved since the probe looked: an intra-day availability change, an off-schedule reprice before the canary
trips, a false trip's glitch baselines, and — in interval or continuous mode with no invalidation — against
a baseline of any age. A disagreeing render is now suspect: one confirming probe of the URL (detached,
paced node-wide at ratePerSecond via a shared-buffer slot, shed past a minute) decides. Still disagreeing:
expired and re-filed (confirmed); agreeing: the baseline is updated and nothing expires (cleared). No
request when the origin is known to have moved since the probe last observed the URL (untrusted, left to
the pass); one confirmation per stored observation (bounded). recordPageClaim still resolves { stale: true }
for a suspect render, so RenderQueue's change_lag guard keeps its meaning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ld holds back only its route

- shrinkGuard measures the DOCUMENT: refuse a urlset whose entry count
  fell below (1 - maxRatio) of the stored entryCount by at least minUrls,
  never would-be-unlinked / attributed (which counted forward shear and
  re-keying as shrink, tripped every walk and froze departures). An
  identical shorter document is accepted after shrinkGuard.acceptAfter
  (default 3) refusals. New sitemap_shrink_refused / _accepted series.
- An index that stops listing more than maxRatio of its children (by
  count or by the entries they held) is refused: nothing is pruned.
- A failed child re-links only the held URLs on a route its stored entry
  sample covers (siblings when it was never stored); every other
  departure of the walk proceeds.
- The 304 re-read fetches only for held URLs still unattributed after
  the walk on a route with a departure/arrival action, only children
  whose sample shares that route, with one retry.
- The sitemap bypass token needs https (or a local origin).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Stubs are found on this node's replica only, and the cascading delete
replicated page, schedule and probe-state deletes cluster-wide, including
where the row is whole. Now a stub is deleted only if it is still
url-less on re-read with no owner-local schedule row and no rendered
page, and only through the raw table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…al only for newly dropped entries

- The admin sitemap detail reports entriesStored beside entryCount, and
  the console pages over the stored sample and says so ("showing the
  first 500 of N") instead of offering empty pages past it.
- A route or probe-rule list set is refused only for entries it NEWLY
  drops relative to the running value, so a bad entry the file layer
  already drops no longer blocks every whole-list edit from the console.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ccept, failed children hold back only their route, narrowed 304 re-read, index-level dropped-child guard, https-only token, safe stub delete, console paging past stored entries, 409 only for newly dropped entries

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…o longer advises reprobeAfter

Plugin v0.97.0 emits probe_* per probed batch (not once per finished pass) and retired
changeProbe.reprobeAfter. The pass card is now "Probe passes — counts, not a live gauge", its help and
empty state describe both emit shapes, the skip note reads a large skip share as resumed passes
re-walking ground (a node restarting mid-pass), and the settings table and settings-card description
drop reprobeAfter; the plugin's status settings stop reporting it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rmed-pass record dry runs can't overwrite, render check confirms by one paced re-probe, dry-run anchor serving, per-row fault isolation + resumable thrown passes, re-asserted stand-down, coverage in every sweep, resume cursor, stale-retry skip, console text, direct epoch read, PK on probe patches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	packages/plugin/src/metrics.js
…p detail paging over stored entries); v0.22.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rst outcomes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a leftDue release carries the miss count

A mark filed due now is typically granted 0.2-1.5s later, often in the same second; floored to its
second the grant read as earlier than the mark and a correct first render of a changed page was
dropped as changed-during-render. The slot gains a grantedMs word (8 words, 32 B). A leftDue release
now carries the stored miss count instead of resetting it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the miss count

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

A clear inside a request transaction was removed from the worker's view before the delete
committed; if the transaction aborted, that worker served pages still invalidated — failing
open. A clear now lands with the post-commit reload (late is the safe direction); an apply is
still patched, since it can only raise an epoch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rded origin body stream

- The snapshot ETag is `W/"<lastCachedMs>-<deviceType>"`: one render job stamps the same
  lastCached on every device variant, so desktop and mobile shared a tag. The device comes from
  the resource, the request, or the stored row's cache key.
- A local 304's drain has its own 5 s deadline, then destroys the source, instead of holding a
  socket until undici's 300 s bodyTimeout on a stalled origin body.
- The proxied origin body is now `originBodyStream`, a small adapter whose enqueue is guarded,
  in place of `Readable.toWeb`. Characterized in test/originBodyStream.test.js (child process,
  tolerant): on Node 24.15 a toWeb cancel landing in the stream's creation microtask phase
  throws ERR_INVALID_STATE as an uncaughtException 20/20 (0/20 synchronous, next tick, or after
  a read — why a quick repro can miss it). Harper's own cancel of a body for a departed client,
  and the capture's over-cap cancel, now reach a cancel that cannot throw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…for the commit, drain deadline, originBodyStream replaces Readable.toWeb on proxied bodies

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ld; dropped children judged by what they still hold

- A: a child its index stops listing is refused when, after the walk, it
  still holds at least minUrls URLs listed nowhere else and more than
  maxRatio of its stored entryCount; refused, it is a failed child.
- B: a refused shrink is accepted only once refused acceptAfter times
  (default 2) AND it survived a new origin version (Last-Modified or
  content; a dropped child uses its index's) or shrinkGuard.acceptAge
  (default 48h) passed. An accepted shrink unlinks at most
  shrinkGuard.releasePerWalk (default 5000) a walk; the rest release on
  later walks (sitemap_shrink_held_back).
- C/D/F: the index guard runs after the walk on URLs listed nowhere,
  not child names, with the minUrls floor; each refused omitted child is
  a failed child, so the route hold-back covers shear into it.
- E: each urlset row stores the full set of routes its entries fall on,
  which scopes the hold-back instead of the 500-entry sample.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…red rows count as covered, verified rescue, healed needs every changed slot shown

- N1: the render check's confirming re-probes draw from the sweep's node-wide budget (the headroom a
  running sweep publishes, none at the ceiling or under origin backoff), so the node never probes past
  ratePerSecond; mayBeStale's invalidation term trusts only the baseline, not a covered walk-past.
- N2: a pre-epoch page rendered by the render whose claim agrees with the new origin is verified (while
  verification is armed) and so served, instead of staying refused until re-rendered.
- N3: 'healed' only when every changed slot is in the claim pair or on an armed mapped field that
  agreed; an unmapped slot that moved may postdate the heal render, so the pass acts.
- N4: coverage is decided in the pass: a covered change is counted covered (probe_covered), not
  changed, with no detection lag, every pass; healed is a caught-up change.
- N5: a confirming re-probe stands down (superseded) when a newer render's claim landed meanwhile, and
  clears only the claim it judged.
- N6: a thrown reseed or boot resume is resumed from its cursor, bounded like the anchored pass.
- RV1b noted beside anchorServedSince (one catch-up on the first boot after upgrade; accepted).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ill hold, shrink accepted only after a new origin version or acceptAge with releasePerWalk, refused omissions count as failed children, route sets per child, minUrls floor everywhere

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, verified rescue for agreeing pre-epoch pages, healed only when every changed slot is checked, covered changes counted apart, re-probes never wipe a newer claim, bounded resume for reseeds and boot resumes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@harper-joseph harper-joseph changed the title Full-review fixes: sitemap walk safety, probe gaps, queue priority, serve path, admin (plugin 0.97.0) Full-review fixes: sitemap walk safety, probe gaps, queue priority, serve path, admin (plugin 0.97.0, console 0.22.0) Sep 30, 2026
@harper-joseph
harper-joseph marked this pull request as ready for review September 30, 2026 09:46
harper-joseph and others added 4 commits September 30, 2026 10:35
…ce, visible hold-back, stale route keys

- sitemap.departure.maxPerWalk (default 5000, -1 none): one budget per walk across every child, from normal
  prunes and accepted shrinks alike. Departures past it are re-linked to the child that unlinked them, whose
  validator is cleared, and depart on later walks (departure_deferred) — lossless, unlike maxActions. Replaces
  the per-child sitemap.shrinkGuard.releasePerWalk / Sitemap.shrinkRelease (never released).
- sitemap.shrinkGuard.newVersionAfter (default 20h): a new origin version confirms a refused shrink only if it
  arrives that long after the first refusal; a touch or re-publish of the same bad build is not a rebuild.
- The refresh result and progress row carry holdBack: each child departures are held back for, why
  (refused-shrink / failed-child / budget), since when, and when a refusal can be accepted.
- A failed child whose stored route keys the current config no longer produces also holds back its
  siblings' URLs, so a route edit cannot turn its hold-back into false departures.
- Nested sub-index accounting (child-sitemap units) documented: it fails closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…shared round-robin, off when departures are off; route-table fingerprint for stale keys; drained dropped children not re-accepted

- The walk budget counts only departures the executor will act on (decideDeparture on the post-walk
  target): suppressed targets and routes with no departure action no longer push real departures back.
- Shared round-robin across the children that unlinked them, so one child's mass event cannot starve
  the rest walk after walk.
- No metering when departures are off or no route departs (arrival-only deployments).
- Per-URL error handling in the budget read and the re-link: one failure no longer aborts the rest.
- A child's stored route keys are trusted only under the route table they were computed with
  (Sitemap.routesFingerprint, routeClass.js routeTableFingerprint): a route added above another or an
  ingress-mode switch now widens a failed child's hold-back to its siblings.
- A dropped child accepted once (Sitemap.shrinkAcceptedAt) drains without being refused and re-accepted
  every walk; a refusal with no recorded first time records one.
- holdBack entries: failed-child carries heldBack and only children that held something back are
  named; budget carries deferred; per-walk entries carry at, refusals since.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nge to cache, page size, filing, departures

- Change probe view, Change safety: action errors (the G2 'every change was expired' check), caught up,
  ignored, covered, anchored-pass outcomes (skipped flagged), detection lag from pass start and from the
  previous pass (median, ≈p95, per rule), and the render check's outcomes.
- Queue view, Change to cache: render change_lag_ms per route — median, ≈p95, mean, trend.
- Queue view, Page size: render_size bands, share over 1 MB (one decimal) by route and device.
- Queue view, Filing: due_now_forward outcomes (fallbacks flagged) and keeper_unschedulable.
- Sitemaps view, Departures: the departure family grouped by what it means for a page (acted, shear,
  held back, deferred, skipped, lost), shrink refusals/acceptances, and the live hold-back list from each
  root's run row.
- The metric guard no longer waives any of these series.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tes, a 304 clears stale shrink state, one route key per held URL; console Since cells, lost-URL counts, inconclusive re-probes

Co-Authored-By: Claude Opus 5.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