diff --git a/package-lock.json b/package-lock.json index f101ddde..bbb33b4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8556,7 +8556,7 @@ }, "packages/console": { "name": "@harperfast/prerender-console", - "version": "0.9.0", + "version": "0.10.0", "license": "Apache-2.0", "dependencies": { "undici": "^7.18.2" diff --git a/packages/console/README.md b/packages/console/README.md index 88ce2495..cd636485 100644 --- a/packages/console/README.md +++ b/packages/console/README.md @@ -182,6 +182,17 @@ flagged when a settled deployment skips most of them, which means `reprobeAfter` `sweepInterval`), and registry rows that could not be decoded, which is a storage-layer escalation rather than anything a setting here reaches. +Plugin v0.58.0's `pageCheck` adds the one counter on that card that is not about the origin +changing: **page mismatches** — cached pages that disagree with the origin on a field they claim. +That is the class the signature comparison is structurally blind to (a value that changed and +changed _back_ between two passes, with a render landing inside the window), and it overlays the +outcome buckets rather than joining them: a mismatched row is also inside "Changed" or the +unchanged remainder, so it is never part of any sum on the card. The reading depends on the run +mode and the view says which applies — armed, each mismatch was hard-expired the moment it was +seen (a detection rate); in dry run nothing expires them, so the same disagreement is re-reported +every pass (a standing count of wrong pages being served). It stays at zero unless a rule sets +`pageCheck` and the render fleet posts its pages' offers (browser 1.20.0+). + **The discovery gate** is split across the two views that own its halves: how much crawl traffic the gate is holding out of the render rotation is on Traffic (it carries a bot, so the bot filter applies), while the purge that removes what got in before the gate went on sits on Overview beside diff --git a/packages/console/package.json b/packages/console/package.json index ab58c1f6..37ce7447 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@harperfast/prerender-console", - "version": "0.9.0", + "version": "0.10.0", "type": "module", "description": "Standalone Harper component serving the prerender management console UI, proxying to a prerender deployment's /prerender_admin API", "license": "Apache-2.0", diff --git a/packages/console/src/admin/views/probe.js b/packages/console/src/admin/views/probe.js index 52cc5eae..020ff4d9 100644 --- a/packages/console/src/admin/views/probe.js +++ b/packages/console/src/admin/views/probe.js @@ -57,18 +57,21 @@ const RANGES = [ /** * The pass counters, in the order a pass produces them. * - * THESE ARE SERIES SIDE BY SIDE, NOT A PARTITION, and two of them are deliberately not disjoint: - * `probed` is the total the four outcome counters divide up, and `throttled` is the slice of - * `failed` the ORIGIN caused rather than the rule. `fresh` is the one that is disjoint from - * everything — a skipped URL was never attempted, so it is not inside `probed` at all. The chart - * note under the bars says this, because a reader who assumes a partition here reads every share - * on the card wrong. + * THESE ARE SERIES SIDE BY SIDE, NOT A PARTITION, and three of them are deliberately not + * disjoint: `probed` is the total the four outcome counters divide up, `throttled` is the slice + * of `failed` the ORIGIN caused rather than the rule, and `page_mismatch` OVERLAYS the outcome + * buckets entirely — a row whose cached page disagreed with the origin is also inside `changed` + * or the unchanged count, because the plugin buckets by signature outcome alone. `fresh` is the + * one that is disjoint from everything — a skipped URL was never attempted, so it is not inside + * `probed` at all. The chart note under the bars says this, because a reader who assumes a + * partition here reads every share on the card wrong. */ const OUTCOMES = [ ['fresh', 'Skipped (fresh)', '#6b7488'], ['probed', 'Probes', '#3d8cff'], ['seeded', 'Seeded', '#8a93a6'], ['changed', 'Changed', '#f0a02a'], + ['page_mismatch', 'Page mismatch', '#22b8cf'], ['triggered', 'Triggered', '#10a87e'], ['deferred', 'Deferred', '#9d6bff'], ['failed', 'Failed', '#e0566f'], @@ -334,6 +337,13 @@ function drift(ctx) { const fresh = totalOf('fresh'); const throttled = totalOf('throttled'); const unreadable = totalOf('unreadable'); + const pageMismatch = totalOf('page_mismatch'); + // What a mismatch MEANS depends on the run mode, which is the status's fact and not the + // window's: armed, each one was hard-expired the moment it was seen (a detection rate); dry, + // nothing expires them, so the same disagreement is re-reported every pass (a standing gauge). + // The merged status is dry only when EVERY node is, which is exactly the reading wanted here — + // one live node means mismatches are being acted on somewhere. + const mismatchesStanding = pageMismatch > 0 && ctx.data.status?.dryRun !== false; // 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. @@ -362,6 +372,9 @@ function drift(ctx) { failing ? pill('probe failures dominate', 'bad') : null, throttled > 0 ? pill('origin pushing back', 'bad') : null, unreadable > 0 ? pill('unreadable rows', 'bad') : null, + // Only when nothing is expiring them: armed, a mismatch is the feature working and the + // tile suffices; standing, wrong pages are being served and re-found every pass. + mismatchesStanding ? pill('pages disagree with the origin', 'warn') : null, spacer(), legend(keys.map((key) => ({ label: OUTCOME_LABEL[key], color: OUTCOME_COLOR[key] }))), ], @@ -406,6 +419,23 @@ function drift(ctx) { 'address is a storage-layer fault: it belongs with the database team, not with a setting ' + 'here.', ]), + // THE CLASS THE SIGNATURE COMPARISON CANNOT SEE. Everything else on this card asks "did + // the origin change since the last look", which is structurally blind to a value that + // changed and changed BACK between two passes — and when a render landed inside that + // window, the cached page keeps the transient value (an out-of-stock claim for something + // the origin sells, a sale price that ended). pageCheck (plugin v0.58.0) compares the + // origin against what the PAGE claims, so these are wrong pages found, not changes seen. + pageMismatch > 0 && + note(mismatchesStanding ? 'warn' : '', [ + `${fmtCount(pageMismatch)} probe${pageMismatch === 1 ? '' : 's'} found the cached page disagreeing ` + + 'with the origin on a field it claims — a transient value the render captured and the ' + + 'signature comparison could never see, because the origin itself never looked changed. ', + mismatchesStanding + ? 'In dry run nothing expires them, so the same disagreement is re-reported every pass: read ' + + 'this as a standing count of wrong pages being served, not a rate.' + : 'Each one was hard-expired the moment it was seen — bots get origin content until the ' + + 're-render lands — so read this as a detection rate.', + ]), skipping && note('warn', [ `${pct(fresh, fresh + probed)} of the rows these passes considered were skipped because a ` + @@ -421,6 +451,11 @@ function drift(ctx) { el('div', { cls: 'stats' }, [ stat('Probes', fmtCount(probed), 'attempts across every finished pass'), stat('Changed', pct(changed, compared), `${fmtCount(changed)} of ${fmtCount(compared)} compared`), + // Overlays the outcome buckets — a mismatched row is also inside Changed or the + // unchanged remainder — so the sub-label names the relationship instead of a share. + stat('Page mismatch', fmtCount(pageMismatch), 'cached page ≠ origin — overlays the buckets', { + warn: mismatchesStanding, + }), stat( 'Triggered', fmtCount(triggered), @@ -450,9 +485,13 @@ function drift(ctx) { 'One emit per finished pass, sweep and canary alike, so the bars are passes and not probes — a tall ', 'bar is a pass that landed in that bucket, not a busier minute. These are series side by side and ', 'NOT a partition: “Probes” is the total the outcomes divide up, “Throttled” is the slice of ', - '“Failed” the origin caused, and “Skipped” sits outside “Probes” entirely because those rows were ', - 'never attempted. “Changed” is measured against the probes that HAD a baseline; seeds and failures ', - 'are excluded from that denominator because neither compared anything.', + '“Failed” the origin caused, “Page mismatch” overlays the outcome buckets (a mismatched row is ', + 'also inside “Changed” or the unchanged remainder), and “Skipped” sits outside “Probes” entirely ', + 'because those rows were never attempted. “Changed” is measured against the probes that HAD a ', + 'baseline; seeds and failures are excluded from that denominator because neither compared ', + 'anything. “Page mismatch” stays at zero unless a rule sets pageCheck AND the render fleet posts ', + 'its pages’ offers (browser 1.20.0+) — enabled against an older fleet it records nothing, and the ', + 'plugin log says so hourly.', ]), ], foot: [scanFooter(data)], @@ -520,6 +559,11 @@ function sweepCard(ctx, status) { 'Changed', `${num(last.changed)}${compared ? ` of ${num(compared)} compared (${pct(last.changed, compared)})` : ''}`, ], + // Overlays the signature buckets — a mismatch row is also inside Changed or the + // unchanged remainder — so it joins no subtraction here. Only worth a row when there + // were any; the run-mode pill at the top of the page says whether they were expired + // on the spot or are still serving. + last.pageMismatch ? ['Pages disagreeing with the origin', pill(num(last.pageMismatch), 'warn')] : null, ['Re-renders filed', last.triggered ? pill(num(last.triggered), 'ok') : pill('0', '')], last.deferred ? ['Deferred past the trigger cap', pill(num(last.deferred), 'warn')] : null, ['Failed probes', last.failed ? pill(num(last.failed), 'warn') : pill('0', 'ok')], diff --git a/packages/console/src/util/aggregate.js b/packages/console/src/util/aggregate.js index da1d75cd..3f83accb 100644 --- a/packages/console/src/util/aggregate.js +++ b/packages/console/src/util/aggregate.js @@ -1000,6 +1000,10 @@ const PROBE_COUNTERS = [ 'failed', 'fresh', 'throttled', + // OVERLAYS the outcome buckets rather than joining them: a row whose cached page disagreed + // with the origin (pageCheck, plugin v0.58.0) is ALSO inside `changed` or `unchanged`, so this + // sums on its own and belongs in no subtraction — same footing as `throttled` inside `failed`. + 'pageMismatch', 'errors', ]; diff --git a/packages/console/test/adminAssets.test.js b/packages/console/test/adminAssets.test.js index b405a5db..9c86b317 100644 --- a/packages/console/test/adminAssets.test.js +++ b/packages/console/test/adminAssets.test.js @@ -286,6 +286,9 @@ test('a metric the plugin emits is charted by the console, or waived with a reas * one the catalog says to ALERT on: it is the only signal that the probe is loading an origin that * cannot take it. * + * It has since earned its keep: plugin v0.58.0's pageCheck added `probe_page_mismatch`, and this + * test — not a reader, not a review — is what said the console was blind to it. + * * Ground truth here is the catalog's machine-readable `values` list rather than the emit sites, * because the emit sites are exactly what the regex cannot read. The two can drift — the test * above says so and it has happened — which is why this checks the family the OTHER test is blind diff --git a/packages/console/test/aggregate.test.js b/packages/console/test/aggregate.test.js index 8ec722cf..90c21a58 100644 --- a/packages/console/test/aggregate.test.js +++ b/packages/console/test/aggregate.test.js @@ -839,6 +839,17 @@ test('change probe: the resumable-sweep and pushback counters sum like every oth assert.equal(body.sweep.lastRun.throttled, 42); }); +// pageCheck's counter (plugin v0.58.0) OVERLAYS the outcome buckets — a mismatched row is also +// inside `changed` or `unchanged` — but each node still counts its own disjoint slice, so across +// nodes it sums exactly like the rest. What it must never do is join a subtraction. +test('change probe: page mismatches sum across nodes like every other pass counter', () => { + const { body } = mergerFor('change-probe')([ + ok('a', probeBody('a', { sweep: sweepWith({ pageMismatch: 3 }) })), + ok('b', probeBody('b', { sweep: sweepWith({ pageMismatch: 9 }) })), + ]); + assert.equal(body.sweep.lastRun.pageMismatch, 12); +}); + // THE WORST WINDOW ANY NODE ENDED ON, never a mean. The question is "was the probe being held // back", and averaging one throttled node against three healthy ones answers it with a number that // was true on no node — 2.75× describes nothing that happened. diff --git a/packages/console/test/probeView.test.js b/packages/console/test/probeView.test.js index 68aabd3b..949048ee 100644 --- a/packages/console/test/probeView.test.js +++ b/packages/console/test/probeView.test.js @@ -451,6 +451,57 @@ test('unreadable registry rows are escalated to the database layer, not shown as assert.match(text, /database team/); }); +// ---------------------------------------------------------------- page check (plugin v0.58.0) + +// A mismatch means a different thing per run mode, and the counter cannot say which: armed, each +// one was hard-expired the moment it was seen (a detection rate); dry, nothing expires them, so +// the same disagreement is re-reported every pass (a standing count of wrong pages being served). +// The run mode is the STATUS's fact, so the note must read it there rather than guess from the +// window — and only the standing case warns, because armed the counter is the feature working. +test('page mismatches in dry run read as a standing count of wrong pages, and warn', async () => { + const ctx = await ready({ + analytics: { ...ANALYTICS, series: [...ANALYTICS.series, passes('page_mismatch', 4, 5)] }, + }); + const text = draw(ctx).textContent; + assert.match(text, /20 probes found the cached page disagreeing/); + assert.match(text, /standing count of wrong pages/); + assert.match(text, /pages disagree with the origin/, 'earns a pill while nothing is expiring them'); +}); + +test('page mismatches while armed read as a detection rate, not an alarm', async () => { + const ctx = await ready({ + status: { ...STATUS, dryRun: false }, + analytics: { ...ANALYTICS, series: [...ANALYTICS.series, passes('page_mismatch', 4, 5)] }, + }); + const text = draw(ctx).textContent; + assert.match(text, /hard-expired the moment it was seen/); + assert.doesNotMatch(text, /standing count/); + assert.doesNotMatch(text, /pages disagree with the origin/); +}); + +// Mismatched rows are ALSO inside Changed or the unchanged remainder — the plugin buckets by +// signature outcome alone — and two tiles that look like siblings invite adding them. +test('the mismatch tile names the overlay instead of posing as an outcome bucket', async () => { + const ctx = await ready({ + analytics: { ...ANALYTICS, series: [...ANALYTICS.series, passes('page_mismatch', 4, 5)] }, + }); + assert.match(tile(ctx, 'Page mismatch').textContent, /overlays the buckets/); +}); + +test('zero mismatches raise nothing — pageCheck unset and a fleet predating it look identical', async () => { + const text = draw(await ready()).textContent; + assert.doesNotMatch(text, /found the cached page disagreeing/); + assert.doesNotMatch(text, /pages disagree with the origin/); +}); + +test('the sweep card reports pages that disagreed, and hides the row when there were none', async () => { + const withMismatches = await ready({ + status: { ...STATUS, sweep: { ...STATUS.sweep, lastRun: { ...STATUS.sweep.lastRun, pageMismatch: 12 } } }, + }); + assert.match(draw(withMismatches).textContent, /Pages disagreeing with the origin/); + assert.doesNotMatch(draw(await ready()).textContent, /Pages disagreeing with the origin/); +}); + // ---------------------------------------------------------------- how a pass ended // Three reasons a pass stops early, and they are not interchangeable: standing down for a reseed