Skip to content

console v0.8.0: read the change probe and the discovery gate - #129

Merged
harper-joseph merged 2 commits into
mainfrom
chore/console-catchup-0.55
Aug 24, 2026
Merged

harper-joseph merged 2 commits into
mainfrom
chore/console-catchup-0.55

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

Console catch-up to plugin v0.55.0. The console last caught up at plugin v0.51.0; since then
the plugin shipped three capabilities the console could not see — and its own cross-package guard
tests said so on a fresh checkout:

PrerenderAdmin dispatches "change-probe" but the console cannot reach it
metrics.changeProbe names its series dynamically (`probe_${series}`) …

change-probe and discovery-purge were dispatched upstream and reachable from nowhere, and
discovery_gated was emitted and read by no panel. This wires all of it up.

Console 0.7.0 → 0.8.0. No plugin change, no schema change, no new endpoint — a drop-in over
0.7.0 on any cluster running plugin 0.53.0+ (older plugins simply have no probe routes; every new
panel degrades to its "not configured" state).

Reachability, and how the two routes aggregate

Both join PROXIED_GET/PROXIED_POST, and both get a merger rather than the single-node
shared answer.

Neither one sums the way analytics does — a probe pass and a purge pass each cover the keys one
node owns
— so the merge produces every node's own slice side by side plus the running tally, and
names the nodes that have not run. The alternative is precisely the failure this classification
exists to prevent: one node's "deleted 240,118" answered under a cluster label, which reads as done
when three quarters of the keyspace has not been touched.

Both POSTs are NODE_LOCAL_POST. The probe refuses under cluster scope for a second reason on top
of residency: its rate cap is a promise made to whoever runs the origin, per node, and fanning
the pass out would spend it N times over.

Change probe — a new view

The one freshness surface in this console that does not measure pages against a cadence, so it gets
its own page rather than a card on someone else's.

Two numbers are only meaningful together, and now sit together:

  • the change rate, measured against the probes that HAD a baseline. A pass that is mostly
    seeding has compared nothing; dividing by probed would report that as a stable catalogue. In
    the test fixture the honest figure is 12% and the naive one is 6% — same catalogue, different
    verdict.
  • the failure share. A probe whose endpoint changed shape reports zero changes, triggers
    nothing, and is indistinguishable from content that is not moving. Past 50% it becomes a banner
    saying those pages are back on interval-only freshness.

The canary's verdict is reported per node — a trip is a threshold crossed against one node's own
cohort, and folding four of them into a boolean would name no node and match no log line. The
pooled fraction rides alongside as the wider sample it is. A trip that recorded no invalidation says
which of the three reasons it was (dry run, holdoff, unresolvable scope), and an unresolvable scope
reads as the fault it is.

Manual passes run from here (sweep / canary), with an explicit run-mode picker: dryRun is the
difference between measuring and acting, and a POST that omits it inherits the configured value, so
a button labelled only "Run sweep" means different things on two deployments. changeProbe is
edited on this view; Config's index points at it.

Discovery gate — split across the views that own its halves

Traffic gets the signal: discovery_gated split by which gate refused and ranked by bot (it
carries a bot, so the existing client-side filter applies), plus a discovery column on the route
table — a gated route's misses never become targets, which is worth seeing next to its miss rate. A
deployment that has configured no gate gets the capability described rather than an empty chart.

Overview gets the purge, beside the key-rule orphan sweep it is a sibling of: census first, then
delete, stop, live progress. The gate-first interlock is stated on the card rather than left to
the plugin's 400 — with the route still discovering, crawlers re-mint exactly what a purge removes.
A node that has never purged says so instead of rendering as a clean census of zeroes.

Also: ingress.routes[].demandFloor (v0.55.0) rides the same cadence join and prints beside the
interval it bounds.

Tests

182 pass, 0 fail (45 new). Repo lint and format:check clean; the plugin's 823 still pass.

New: test/probeView.test.js (16) and test/overviewView.test.js (11) execute their views against
realistic payloads, plus 13 merger tests in aggregate.test.js and 5 discovery-gate tests in
trafficView.test.js.

The five assertions that matter were each proved by reverting the behaviour and watching them fail:

Reverted Test that caught it
compared → probed denominator the change rate is measured against what was COMPARED
sumValues → emit counts a pass counter is summed by VALUE
dropped keepBot on the gate pick the bot filter narrows the gate panel
minOf → maxOf on pass time the summary is stamped with the OLDEST pass
dropped the census precondition the purge button is inert until a census has run

Notes for the reviewer

  • packages/plugin/README.md documents change-probe but not discovery-purge,
    discoverTargets, discoveryBots or demandFloor
    — v0.54.0/v0.55.0 landed with the prose only
    in configSchema.js. Left alone here to keep this PR console-only and off the plugin release
    train; worth a follow-up.
  • src/util/aggregate.js contains three deliberate NUL bytes (comboKey's field separator), which
    makes file call it "data" and makes BSD grep skip it silently unless you pass -a. Not
    changed here, but it is a trap for anyone grepping the console.

🤖 Generated with Claude Code

The console last caught up at plugin v0.51.0. Since then the plugin shipped
three capabilities the console could not see, and its own cross-package guard
tests said so: `change-probe` and `discovery-purge` were dispatched upstream
and reachable from nowhere, and `discovery_gated` was emitted and charted by
no panel.

REACHABILITY. Both routes join PROXIED_GET/PROXIED_POST, and both get a
MERGER rather than the shared single-node answer. Neither one sums the way
analytics does — a probe pass and a purge pass each cover the keys ONE node
owns — so the merge produces every node's own slice side by side plus the
running tally, and names the nodes that have not run. The alternative is the
failure this classification exists to prevent: one node's "deleted 240,118"
answered under a cluster label, which reads as done when three quarters of
the keyspace has not been touched. Both POSTs are NODE_LOCAL_POST; the probe
refuses for a second reason on top of residency, since its rate cap is a
promise made to whoever runs the origin, per node.

CHANGE PROBE (new view). The one freshness surface that does not measure
pages against a cadence, so it gets its own page rather than a card on one.
Two numbers are only meaningful together and sit together: the change rate,
measured against the probes that HAD a baseline — a pass that is mostly
seeding has compared nothing, and dividing by `probed` would report that as
a stable catalogue — and the failure share, because a probe whose endpoint
changed shape reports zero changes, triggers nothing, and is indistinguishable
from content that is not moving. The canary's verdict is reported per node: a
trip is a threshold crossed against one node's own cohort, and folding four of
them into a boolean would name no node and match no log line. A trip that
recorded no invalidation says which of the three reasons it was. `changeProbe`
is edited here, and Config's index points at it.

DISCOVERY GATE, split across the views that own its halves. Traffic gets the
signal — `discovery_gated` by gate and by bot (it carries a bot, so the filter
applies), plus a `discovery` column on the route table, since a gated route's
misses never become targets. Overview gets the purge, beside the key-rule
orphan sweep it is a sibling of: census first, then delete, with the gate-first
interlock stated on the card rather than left to the plugin's 400 — with the
route still discovering, crawlers re-mint exactly what a purge removes. A node
that has never purged says so instead of rendering as a clean census of zeroes.

Also: `ingress.routes[].demandFloor` rides the cadence join and prints beside
the interval it bounds.

45 new tests. The five that matter were each proved by reverting the behaviour
and watching them fail: the compared-not-probed denominator, value-summed pass
counters, the bot filter on the gate panel, oldest-not-newest pass stamping,
and the census precondition on the purge button.

Co-Authored-By: Claude Opus 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 introduces the 'Change probe' view and 'Discovery gate' functionality to the prerender console, enabling operators to monitor and manage content freshness based on actual origin changes rather than fixed cadences. It includes new UI views, route aggregation logic, and comprehensive test suites. The review feedback suggests importing and utilizing the ratioOf helper in the new probe.js view to safely calculate the probe failure rate and avoid potential division-by-null issues.

Comment on lines +28 to +42
import {
emptyNote,
fmtCount,
isMerged,
legend,
pick,
rangePicker,
scanFooter,
scopeLabel,
segmented,
stackBy,
stackedBars,
sumValues,
windowEmpty,
} from '../charts.js';

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

Import ratioOf from ../charts.js to safely calculate ratios and avoid the Number(null) === 0 trap.

Suggested change
import {
emptyNote,
fmtCount,
isMerged,
legend,
pick,
rangePicker,
scanFooter,
scopeLabel,
segmented,
stackBy,
stackedBars,
sumValues,
windowEmpty,
} from '../charts.js';
import {
emptyNote,
fmtCount,
isMerged,
legend,
pick,
rangePicker,
ratioOf,
scanFooter,
scopeLabel,
segmented,
stackBy,
stackedBars,
sumValues,
windowEmpty,
} from '../charts.js';
References
  1. When calculating ratios or dividing metrics by a yardstick/cadence in JavaScript, avoid the Number(null) === 0 trap (where null / number evaluates to 0 instead of null or NaN). Use a helper function to explicitly validate that both the value and the yardstick are finite numbers and that the yardstick is greater than 0 before performing the division, returning null otherwise.

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.

Done in 9deb283 — ratioOf is imported and used at the threshold. See the reply on the other thread for why it was adopted as a convention rather than as a null-safety fix (sumValues cannot return null), and for the real defect that reading this site turned up.

// Compared = probes that had a baseline to compare against. Seeds and failures had none, so
// including them in the denominator understates the drift rate by exactly the seeding backlog.
const compared = Math.max(0, probed - seeded - failed);
const failing = probed > 0 && failed / probed > FAILURE_ALARM;

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

Avoid the Number(null) === 0 trap when dividing metrics by using the ratioOf helper function. This ensures that if either failed or probed is null or undefined, the result is handled safely rather than incorrectly evaluating to 0.

Suggested change
const failing = probed > 0 && failed / probed > FAILURE_ALARM;
const failing = ratioOf(failed, probed) > FAILURE_ALARM;
References
  1. When calculating ratios or dividing metrics by a yardstick/cadence in JavaScript, avoid the Number(null) === 0 trap (where null / number evaluates to 0 instead of null or NaN). Use a helper function to explicitly validate that both the value and the yardstick are finite numbers and that the yardstick is greater than 0 before performing the division, returning null otherwise.

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.

Adopted in 9deb283 — but not for the stated reason, and the difference is worth recording.

failed and probed here come from sumValues, which reduces from 0 over finite products
(Number.isFinite(s.mean) && s.count > 0). Both are therefore always numbers, never null, so the
Number(null) === 0 trap cannot arise at this site — and probed > 0 already covered the
zero-denominator case that ratioOf guards.

It still goes through the helper, because the guard belongs at the call site by convention rather
than by an argument a reader has to reconstruct from sumValues's contract. The ratio is now named
and compared explicitly, so nothing rests on null > 0.5 either:

const failureShare = ratioOf(failed, probed);
const failing = failureShare !== null && failureShare > FAILURE_ALARM;

Re-reading that site did turn up a real defect next door, and it was mine: mergeChangeProbe takes
the FIRST error it finds across nodes, so an error and a full set of counters arrive together
whenever three nodes swept and one threw. The sweep card treated the error as the whole answer and
dropped the three good slices. Both are shown now, with the error saying the counters cover only the
passes that finished — and an errored pass that counted nothing still shows the error alone rather
than a row of dashes. Two tests added for it, plus one pinning that an empty window (ratio null)
never reads as "probe failures dominate".

…sweep failure that kept its numbers

Review (gemini-code-assist) on #129: use `ratioOf` for the probe failure share.

Adopted, though not for the stated reason — `sumValues` reduces from 0 over
finite products, so neither side can be null and the `Number(null) === 0` trap
cannot arise at this site. It goes through the helper because that is where the
guard belongs by convention rather than by an argument a reader has to
reconstruct from `sumValues`'s contract, and the ratio is now named and compared
explicitly so nothing rests on `null > 0.5` either.

Reading that site turned up a real one next door, and it is mine: the cluster
merge takes the FIRST error it finds across nodes, so an error and a full set of
counters arrive together whenever three nodes swept and one threw. The sweep card
treated the error as the whole answer and dropped the three good slices. Both are
now shown, and the error says the counters cover only the passes that finished —
while an errored pass that counted nothing still shows the error alone rather
than a row of dashes.

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