Skip to content

feat(plugin): resumable probe sweeps and origin backoff; v0.56.0 - #131

Merged
harper-joseph merged 3 commits into
mainfrom
feat/probe-safety
Aug 25, 2026
Merged

feat(plugin): resumable probe sweeps and origin backoff; v0.56.0#131
harper-joseph merged 3 commits into
mainfrom
feat/probe-safety

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

Why

Two safety properties the change probe was missing, and they are the same shape as the purge regression fixed in 0.55.1: a background job with no feedback from the system it loads. The purge had no backpressure from the storage engine and ended up 503-ing the render fleet. The probe has none from the origin, and no memory across restarts.

Resumable sweeps

The sweep's walk position is in-memory, so a restart mid-pass re-probes every URL the pass had already covered — hours of origin requests that can only confirm what is already stored. That is not hypothetical: a rollout plus an incident meant several restarts in one evening, and each one reset seeding progress.

The stored baseline already carries probedAt, so no schema change was needed. A pass now skips a URL whose baseline is younger than reprobeAfter (default 12h — half the default sweepInterval, kept below it so the skip can never eat a real pass's cadence). A restarted pass walks past covered ground in seconds and reaches new work immediately.

Deliberate exemptions, both load-bearing:

  • The canary never skips. Its cohort is probed every 30 minutes precisely to catch mass changes between sweeps; freshness-skipping there would silence the detector.
  • A canary-triggered reseed never skips. After a mass change every baseline is known-stale, and skipping the fresh-looking ones would leave exactly the pages the trip was about carrying pre-change signatures.

This required inverting the read/probe order (the pass used to probe first, then read). That trades a node-local point read for an origin request — the right way round, since the origin request is the scarce, externally-visible resource.

Origin backoff

ratePerSecond is sized with the origin's operator for a healthy origin. It says nothing about an origin having a bad afternoon, and a sweep that holds its configured rate through 429s and 503s is adding load to something already failing.

Pushback responses — 429, 502, 503, 504, and connect/read timeouts — now double the pacing window; a clean batch halves it back. Immediate response, gradual recovery, steady state unchanged at the configured rate. An explicit Retry-After outranks the computed wait (the origin named a number; we do not guess under it). An origin that refuses abortAfterDistress probes in a row (default 50) ends the pass rather than crawling a doomed one into the next window while holding the sweep lock — the next scheduled pass is the retry and it starts clean.

A 404 or a per-product 500 is explicitly not pushback. This corpus has a stable ~1.7% API-dark product floor; treating it as distress would throttle a perfectly healthy sweep down to nothing.

Two new prerender_ops series: probe_fresh (skipped as already-covered) and probe_throttled (alert on this — it is the only signal that the probe is loading an origin that cannot take it).

Verification

  • 833/833 unit tests, 6 new: freshness skip honored, unknown/unparseable probedAt probes rather than skips, backoff doubles-then-halves with exact window arithmetic, Retry-After wins, a fully-refusing origin aborts early, and a 404 floor never throttles.
  • Lint and format clean. No repo CI on PRs — verified locally per repo policy.

For the human reviewer

  • reprobeAfter must stay below sweepInterval. At the defaults (12h vs 24h) there is a wide margin, but a deployment that raised it near the interval would silently stretch real cadence. The schema text says so; it is not enforced, because the sensible bound depends on how long a pass actually takes on that corpus.
  • The backoff is per-pass, not persistent. A pass that backed off to 64× starts the next pass at 1× and rediscovers the pressure. That is intentional — a stale multiplier would throttle a recovered origin — but it does mean a persistently sick origin is re-probed at full rate once per sweepInterval before backing off again.
  • Distress classification is a judgment call. 502/504 are included because on this deployment they mean an overloaded origin behind a CDN, not a broken rule.

Review coverage

Authored by Claude (Fable). Self-review + domain pass only — no outside-model leg ran; flagging per policy.

🤖 Generated with Claude Code

harper-joseph and others added 2 commits August 24, 2026 21:23
Two safety properties the probe was missing, both the same shape: a
background job with no feedback from the system it loads.

RESUMABLE SWEEPS. The walk cursor is in memory, so a restart mid-pass
re-probed every URL the pass had already covered — hours of origin requests
that can only confirm what is already stored, and we restart often. The
stored baseline already carries probedAt, so a pass now skips a URL whose
baseline is younger than reprobeAfter (default 12h, half the default sweep
interval). A restarted pass reaches new work in seconds. The canary never
skips, and a canary-triggered reseed never skips.

ORIGIN BACKOFF. ratePerSecond is sized with the origin's operator for a
healthy origin; it says nothing about one having a bad afternoon, and a sweep
holding its configured rate through 429s and 503s adds load to something
already failing. Pushback responses (429/502/503/504, connect and read
timeouts) now double the pacing window; clean batches halve it back —
immediate response, gradual recovery, steady state unchanged. An explicit
Retry-After outranks the computed wait, and an origin refusing
abortAfterDistress probes in a row ends the pass for the next scheduled one
to retry cleanly. A 404 or a per-product 500 is NOT pushback and must not
throttle a healthy sweep.

Co-Authored-By: Claude Fable 5 <[email protected]>

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces features to make change-probe sweeps resumable and resilient to origin distress, including a reprobeAfter option to skip recently probed URLs, and backoff/abort mechanisms (backoffMax, abortAfterDistress) when encountering origin distress. Review feedback recommends handling potential BigInt values during date parsing to avoid crashes, wrapping synchronous invocations of runProbeSweepOnce in try...catch blocks to safely catch synchronous exceptions, and clamping the calculated wait delay to Node.js's maximum timeout limit to prevent overflow warnings.

Comment thread packages/plugin/src/util/changeProbe.js Outdated
if (!row) return null;
// A Date column can surface as a Date, an epoch number, or a string depending on how the row
// was written; anything unparseable reads as "age unknown", which probes rather than skips.
const probedAt = row.probedAt === undefined || row.probedAt === null ? NaN : new Date(row.probedAt).getTime();

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

If row.probedAt is returned as a BigInt from the database, passing it directly to new Date() will throw a TypeError and crash the entire sweep. Coerce potential BigInt values to Number first, and wrap the instantiation in a try...catch block to safely default to NaN on any unparseable values.

	let probedAt = NaN;
	if (row.probedAt !== undefined && row.probedAt !== null) {
		try {
			const val = typeof row.probedAt === 'bigint' ? Number(row.probedAt) : row.probedAt;
			probedAt = new Date(val).getTime();
		} catch {
			probedAt = NaN;
		}
	}
References
  1. When parsing or coercing date values in JavaScript, always perform a truthiness check first (e.g., ensuring the value is not null or undefined) before passing it to new Date(). This avoids bugs where new Date(null).getTime() evaluates to 0 (epoch 0) instead of NaN.

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.

Good catch, fixed. A Long column surfacing as BigInt is a live trap in this codebase (resolveRenderInterval and resolveEffectiveInterval both coerce for it), and new Date() refusing a BigInt outright would have failed the read, not just the parse — taking the whole sweep down. Now coerced one type earlier.

Skipped the surrounding try/catch: past BigInt, new Date() returns Invalid Date rather than throwing for anything a column can hold, and NaN.getTime() already routes to the safe direction (probe, never skip). Pinned by a test using a BigInt-derived timestamp.

const chained = sweepInterrupt;
sweepInterrupt = null;
if (chained) runProbeSweepOnce({ dryRun: true, label: chained }).catch((e) => logger.error(e));
if (chained) runProbeSweepOnce({ dryRun: true, label: chained, reseed: true }).catch((e) => logger.error(e));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When invoking a function that returns a Promise across an interface boundary or asynchronously without awaiting it, wrap the synchronous invocation itself in a try...catch block. If the function throws synchronously, the .catch() handler will be bypassed, propagating an unhandled exception.

		if (chained) {
			try {
				runProbeSweepOnce({ dryRun: true, label: chained, reseed: true }).catch((e) => logger.error(e));
			} catch (e) {
				logger.error(e);
			}
		}
References
  1. When invoking a function that returns a Promise (especially across an interface boundary), wrap the synchronous invocation itself in a 'try...catch' block to capture any synchronous exceptions. If the function throws synchronously, asynchronous error handling (such as 'await' or '.catch()') will be bypassed, which can propagate unhandled exceptions and cause unexpected failures.

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.

Dismissing this one: runProbeSweepOnce is declared async (changeProbe.js:579), and an async function can never throw synchronously — a throw anywhere in its body, including before its first await, becomes a rejected promise. So the .catch() is reachable for every failure mode that exists here, and the try/catch would be unreachable code.

The guidance is sound where a callee might be a plain function returning a promise (the sync-throw path is real there); it just does not apply to an async callee. Same finding was raised and dismissed on the same grounds in #126.

return { chained: true };
}
runProbeSweepOnce({ dryRun: true, label }).catch((e) => logger.error(e));
runProbeSweepOnce({ dryRun: true, label, reseed: true }).catch((e) => logger.error(e));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Wrap the synchronous invocation of runProbeSweepOnce in a try...catch block to ensure that any synchronous exceptions thrown during the call are safely caught and do not bypass the .catch() handler.

	try {
		runProbeSweepOnce({ dryRun: true, label, reseed: true }).catch((e) => logger.error(e));
		} catch (e) {
			logger.error(e);
		}
References
  1. When invoking a function that returns a Promise (especially across an interface boundary), wrap the synchronous invocation itself in a 'try...catch' block to capture any synchronous exceptions. If the function throws synchronously, asynchronous error handling (such as 'await' or '.catch()') will be bypassed, which can propagate unhandled exceptions and cause unexpected failures.

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.

Dismissing this one: runProbeSweepOnce is declared async (changeProbe.js:579), and an async function can never throw synchronously — a throw anywhere in its body, including before its first await, becomes a rejected promise. So the .catch() is reachable for every failure mode that exists here, and the try/catch would be unreachable code.

The guidance is sound where a callee might be a plain function returning a promise (the sync-throw path is real there); it just does not apply to an async callee. Same finding was raised and dismissed on the same grounds in #126.

Comment thread packages/plugin/src/util/changeProbe.js Outdated
Comment on lines +439 to +440
const wait = Math.max(window - elapsed, retryAfterMs);
if (wait > 0) await pause(wait);

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

Clamp the calculated wait delay to not exceed 2147483647 (the maximum 32-bit signed integer) before passing it to pause(). Exceeding this limit in Node.js causes a TimeoutOverflowWarning and executes the callback immediately (after 1ms), which can trigger unexpected hot loops.

Suggested change
const wait = Math.max(window - elapsed, retryAfterMs);
if (wait > 0) await pause(wait);
const wait = Math.min(Math.max(window - elapsed, retryAfterMs), 2147483647);
if (wait > 0) await pause(wait);
References
  1. In Node.js, configuration options representing timeouts or delays passed to 'setTimeout' must enforce a maximum limit of 2147483647 (the maximum 32-bit signed integer). Exceeding this limit causes Node.js to emit a 'TimeoutOverflowWarning' and execute the callback immediately (after 1ms), which can lead to unexpected immediate timeouts and system failures.

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.

Agreed and applied, with one change of approach: clamped at the call site rather than via a schema max. The repo does cap individual timer options that way (sweepInterval, startDelay, canary.interval), but this window is the PRODUCT of concurrency, 1/ratePerSecond and the live backoff multiplier — three separately-sane values that can multiply past the limit with no single option out of range, so no per-option cap can close it.

The failure mode is worth naming: past the cap setTimeout fires after 1ms instead of waiting, so the backoff would invert into a hot loop against an origin that had just asked for room. Pinned by a test.

…he timer cap

Both from PR review. A Long column can surface as BigInt and new Date()
refuses rather than coerces it, so an unguarded read would take down the
sweep. The pacing wait is the product of concurrency, 1/ratePerSecond and
the backoff multiplier, so three separately-sane options can multiply past
setTimeout's signed-32-bit delay — past which it fires after 1ms instead of
waiting, turning the backoff into a hot loop.

Co-Authored-By: Claude Fable 5 <[email protected]>
@harper-joseph

harper-joseph commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review — pushed as a follow-up commit, 835/835 tests green.

Applied:

  • BigInt probedAt — real hazard here (Long columns surface as BigInt elsewhere in this file, and new Date() refuses rather than coerces one, which would have failed the read and taken the sweep with it). Coerced, with a test using a BigInt-derived timestamp.
  • Timer clamp — applied at the call site rather than as a schema max, because this window is the product of concurrency × 1/ratePerSecond × the live backoff multiplier; no per-option cap can bound it. Worth naming the failure mode: past the cap setTimeout fires after 1ms instead of waiting, so the backoff would invert into a hot loop against an origin that just asked for room. Tested.

Dismissed, with rationale on each thread:

@harper-joseph
harper-joseph merged commit d8625fe into main Aug 25, 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