Repository navigation
feat(plugin): a per-node health line for log-based alerting; sitemap refresh rows keep their last success; v0.108.0 - #257
Conversation
…temap refresh row that keeps its last success; v0.108.0
One `[prerender] health {json}` line per node every `health.interval` (default 5m): the bot
requests answered since the last line (source, status class, own vs relayed 5xx, slow responses,
snapshots older than their route interval, truncated deliveries), node totals and per prerender
route, plus the queue keeper, change probe and scheduled sitemap walk state. Written whether or
not anything is wrong, so a missing line is itself a signal.
Counted in one shared buffer (getSab) with Atomics.add on the serve path, independent of
analytics.enabled and never read from hdb_analytics. Every worker arms a timer; the first to
compare-and-swap an interval boundary drains the counters (Atomics.exchange) and writes the line.
`health.level` (default notify) for hosts that ship only warn and above.
SitemapRefresh rows now restate `lastRefreshed` (the root's last completed walk) on every
progress write, so a running or failed walk no longer erases it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…replace the response with a throw (self-review of #257) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new 'health line' feature that periodically logs a JSON line containing metrics about bot requests and background tasks (queue keeper, change probe, sitemap walks) for log-based alerting. The metrics are tracked in shared memory across workers. The review feedback suggests executing the state providers in parallel using Promise.all instead of sequentially to prevent accumulated timeouts if multiple providers hang.
| for (const [name, read] of Object.entries(stateProviders)) { | ||
| try { | ||
| const section = withTimeout( | ||
| Promise.resolve().then(() => read(nowMs)), | ||
| STATE_TIMEOUT_MS | ||
| ); | ||
| line[name] = (await section) ?? null; | ||
| } catch (e) { | ||
| line[name] = null; | ||
| errors.push(name); | ||
| getLogger().debug?.(`[prerender] health: the ${name} section failed: ${e?.message ?? String(e)}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
The state providers are currently executed sequentially within a for...of loop. If one or more providers hang or take a long time to respond, the sequential execution will accumulate their delays (up to N * STATE_TIMEOUT_MS, which could be 15 seconds or more). Running them in parallel using Promise.all ensures that the total execution time is capped at STATE_TIMEOUT_MS (5 seconds), improving the responsiveness and reliability of the health line generation.
const entries = Object.entries(stateProviders);
const results = await Promise.all(
entries.map(async ([name, read]) => {
try {
const section = await withTimeout(
Promise.resolve().then(() => read(nowMs)),
STATE_TIMEOUT_MS
);
return { name, value: section ?? null };
} catch (e) {
getLogger().debug?.('[prerender] health: the ' + name + ' section failed: ' + (e?.message ?? String(e)));
return { name, value: null, error: true };
}
})
);
for (const { name, value, error } of results) {
line[name] = value;
if (error) {
errors.push(name);
}
}There was a problem hiding this comment.
Agreed. Fixed in 354de6b: the sections are now read with Promise.all, so sections that hang share a single 5 s deadline. Results are still written in provider order, with failures named in err. The test now hangs two sections and cuts both off with one 5 s tick, which a sequential read cannot pass.
…he plugin's own 5xx (review of #257) fetchOriginResource throws when the origin never answers (refused, reset, timed out before a status), and the bot handler answers 500. Counted as own5xx, an origin outage read as this plugin failing. The throw is now marked (a WeakSet in util/upstream.js) and counted as originUnreachable, node and route. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…live route change, see failed child sitemaps, file render-now's 504 as ours (review of #257) - A live route change drained nothing: each worker moved to the new layout's buffer and released the old one, losing every count since the last line, and a change back found the old buffer's stale counts. Each worker now folds the old buffer into the new one as it switches (route blocks by name, else `other`). - sitemap: `failedChildren` (a walk whose children fail still completes, so `failing` never showed an index whose children all 503), and the carried `lastRefreshed` is re-read before each progress write so a walk that another walk overtook cannot restate an older completion. - render-now's timeout 504 is made here, so it counts as own5xx, not as a relayed origin error. - probeHealth's fallback for rows that predate the completed-pass fields ignores a dry run on an armed probe, as `anchorServedSince` does. - The serve-path count makes no closure or string per request. - METRICS.md 4f: the `other` bucket, `lastPassAgeH` measured from the pass's start, `failedChildren`, the age baseline, and that a request drained mid-count can straddle two lines. - Tests: the route-change fold and the overtaken walk (each fails with its fix reverted), render-now's 504, the dry-run guard, failedChildren; the shared clock is reset per test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…an interval of health counts (review of #257) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Review round (independent agent review, plus my own pass), fixed in 1617b05, e202eb9 and 9ea5c29:
Each behavioural fix has a test that fails with the fix reverted. The plugin suite (2,111 tests) passes, and I re-ran the local Docker check on the new head. The PR description is rewritten to match. |
…g ones share one deadline (review of #257) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
What
[prerender] health {json}line everyhealth.interval(default 5 minutes). The line carries:own5xx(the plugin's),originUnreachable(the origin never answered), or the origin's own relayed 5xx (the rest);ok, heartbeat age, ready-set depth.SitemapRefresh.lastRefreshedis now restated on every progress write, re-read before each one, and the later value kept. Before,putreplaced the row, so a running or failed walk erased the only record of when the root last completed.Why
Alerting from logs on what crawlers experience. Analytics stay in each node's
hdb_analyticsuntil something reads them; this line rides whatever already ships the node's log. It is written whether or not anything is wrong, so a node with no line is itself the signal that the plugin, or every worker on the node, has stopped.How
handleBotRequest, once the status is known. It makes about tenAtomics.addinto one shared buffer held throughgetSab. It doesn't depend onanalytics.enabled, never readshdb_analytics, and makes no closures or strings per request.finallyand is guarded, so it can't replace the response.util/upstream.js, so the handler's 500 is filed asoriginUnreachable.madeHere, so it is filed as ours.Atomics.exchange(…, 0)and writes the line. A busy or wedged worker can't hold it back.other. Nothing counted before the change is lost, and a change back finds nothing stale.keeperHealth(renderSchedule.js),probeHealth(changeProbe.js) andsitemapHealth(Sitemap.js). None touches a residency-pinned table. A section that throws or takes over 5 s is written as null and named inerr.healthgroup withenabled,intervalandlevel(defaultnotify). On hosts that forward onlywarnand above from components to their log shipper, setwarn.Visible elsewhere
lastRefreshedon running and failed rows too.How it was checked
test/health.test.js):span, the disabled path, one write per boundary, the level;worker_threadscount 80,000 requests into the same shared memory and race for one boundary. Exactly one writes, and the written line plus the remainder equal the total.botServePath.test.js), with analytics off: an origin miss, a relayed 502, an origin that hangs up (originUnreachable), an old cached snapshot and a handler 500, each filed correctly.sitemapWalkGuards.test.js):failedChildrencounts; removed roots, children and theallmarker are skipped.probeHealth(changeProbe.test.js): the completed-pass age, a failed last pass, disarmed fields, and a dry run not counted on an armed probe.originUnreachable30,own5xx0, every route right.notifythe line reached only the log file; atwarnit also reached container output.line_format→json→unwrapwas checked on a local Loki 3.5.3 against lines in this format. That covered sums, ratios, bracketed route paths, state fields and the missing-line query. Malformed lines raised no query error.npm run lint,npm run format:checkand the plugin suite (2,111 tests) pass. This repo has no PR CI.Known limits (documented in METRICS.md 4f)
Not verified
Release
prerender-v0.108.0: 0.106.1 is #256 and 0.107.0 is #254. After merge, I'll cut the release; consumers bump the tarball and, where needed, sethealth.level: warn.🤖 Generated with Claude Code