Skip to content

Commit 39c4878

Browse files
trialclaude
andcommitted
fix(plugin): review of 0.101.0 -- a measure-only sweep adopts nothing, one document in any spelling, the cap spans a pass's resumes; v0.101.0
- The sweep hands the observer its PASS's dry run (`limits.dryRun`): an operator's measure-only sweep, or a dry-run reseed, filed up to maxPerPass urgent targets while the config said armed. - `sameDocument`: a canonical spelled otherwise (`%27` for an apostrophe, anything decodeURI decodes) is the same document, as the probe's own path comparator treats it. Unchanged observation, no move; the probed row is the canonical in any spelling; and before adopting, a bounded node-local sibling read (8 rows, replicateFrom false) finds a target in rotation holding it spelled otherwise. Without it an endpoint spelling a slug raw while the Target key percent-encodes it adopted a duplicate every pass and flapped the row. - Only an absolute URL or a /-rooted path is a canonical: a relative path resolved against the probed URL's directory and invented a URL under the entity's own prefix. - Every Entity write is a patch naming the key, so racing first observations merge and cannot erase the adoption memory. - The cap counts by the pass's origin, so a resume shares it; a failed put gives its slot back; a failed adoption memory no longer reports the filed adoption as an error. - Reactivation carries `unlistedAt` (the arrival check sees a rejoin) and a BigInt `renderInterval`. - A render's observation instant is the store time less the SUM of its renders: a job renders its devices in turn. - Docs: canonicalAt is when the current canonical was first observed; a dry run's filing count is would-adopt + capped; a bad canonical costs one render per retryAfter. - Tests for each, and for the route-class refusal and an unreadable stored instant; each fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f8cf507 commit 39c4878

8 files changed

Lines changed: 315 additions & 79 deletions

File tree

‎packages/plugin/README.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -334,14 +334,20 @@ changeProbe:
334334
- When two observations disagree, the newer wins. A render's instant is when it read the origin
335335
(store time less its longest render), so a render claimed before a re-slug can't undo the probe
336336
that saw it.
337-
- An unchanged observation writes nothing.
338-
- A canonical under another entity's prefix is ignored.
337+
- An unchanged observation writes nothing, and neither does the same canonical spelled otherwise
338+
(`%27` for an apostrophe). That is one document, as the probe's own `path` comparator already
339+
treats it.
340+
- A canonical under another entity's prefix is ignored, and so is a relative path (only an absolute
341+
URL or a `/`-rooted path counts).
339342
- **Adoption.** When the probe reports a canonical that is another URL of the same entity, and no target
340343
holds it in rotation, its target is filed due now and urgent, as redirect adoption does.
341344
- A target suppressed as a canonical verdict (`canonical-mismatch`, `canonical-variant`) is reactivated
342345
the same way. One suppressed for any other reason (a 404, a noindex) is left alone.
343-
- Bounded by `maxPerPass` per pass and node, by `retryAfter` per entity (a canonical that did not take is
344-
filed once per window, not nightly), and by both dry runs.
346+
- Bounded by `maxPerPass` per pass and node (a resumed pass shares its cap), by `retryAfter` per entity
347+
(a canonical that did not take is filed once per window, not nightly, so it costs one render a week for
348+
as long as the endpoint names it), and by both dry runs. A pass run as a dry run, including an
349+
operator's measure-only sweep, files nothing.
350+
- In a dry run, what arming would file is `would-adopt` plus `capped`.
345351
- **Why.** Measured on one deployment, products re-slug ~100 times a day and the product sitemap
346352
changes once a day. An out-of-stock product is not in the sitemap at all, so its new canonical
347353
arrived only by traffic discovery, with its first render jittered across the route's 96h interval.

‎packages/plugin/src/configSchema.js‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,7 +1534,8 @@ export const configSchema = group('Prerender plugin configuration.', {
15341534
'rule that maps `canonical` in `pageCheck.fields` (an endpoint field holding the canonical path or URL, ' +
15351535
'e.g. `{ slot: 6, fact: canonical, compare: path }`). Every probe of a URL on a route with an ' +
15361536
'`entityPrefix` records the canonical the endpoint names for its entity. When that is ANOTHER URL of ' +
1537-
'the same entity and no target holds it in rotation, its target is filed due now and urgent, as ' +
1537+
'the same entity and no target holds it in rotation (in any spelling: `%27` and an apostrophe are one ' +
1538+
'document), its target is filed due now and urgent, as ' +
15381539
'redirect adoption does — a target suppressed as a canonical verdict (`canonical-mismatch`, ' +
15391540
'`canonical-variant`) is reactivated the same way; one suppressed for any other reason is left alone.\n\n' +
15401541
'WHY: a product whose slug changes while it is out of stock is not in the sitemap, so its new ' +
@@ -1552,13 +1553,16 @@ export const configSchema = group('Prerender plugin configuration.', {
15521553
dryRun: option(
15531554
true,
15541555
'Count `would-adopt` and file nothing. The default, because the number to know first is how many ' +
1555-
'canonicals a pass would file. The probe\u2019s own `dryRun` files nothing either.'
1556+
'canonicals a pass would file: `would-adopt` plus `capped` (the cap counts would-adopts too). A ' +
1557+
'pass run as a dry run — the probe\u2019s own `dryRun`, or an operator\u2019s measure-only sweep — ' +
1558+
'files nothing either.'
15561559
),
15571560
maxPerPass: option(
15581561
500,
1559-
'Most targets one pass files on this node. Measured on one deployment, products re-slug ~100 ' +
1560-
'times a day cluster-wide; a site-wide re-spelling would file every product at once, so past ' +
1561-
'this the pass counts `capped` and leaves the rest to the sitemap and the next pass.',
1562+
'Most targets one pass files on this node, counted across the pass\u2019s resumes. Measured on one ' +
1563+
'deployment, products re-slug ~100 times a day cluster-wide; a site-wide re-spelling would file ' +
1564+
'every product at once, so past this the pass counts `capped` and leaves the rest to the sitemap ' +
1565+
'and the next pass.',
15621566
{ min: 0 }
15631567
),
15641568
retryAfter: option(

‎packages/plugin/src/resources/RenderQueue.js‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -458,14 +458,15 @@ async function syncQueueState(force = false, pending = null) {
458458
return { status, ...desired };
459459
}
460460

461-
// The longest render among a result's stored variants (ms), 0 when none says: how long before the store
462-
// the origin was read, at the latest.
463-
const longestRenderMs = (variants) => {
464-
let longest = 0;
461+
// How long before the store a result's origin read began, at the earliest that can be told: the devices of a
462+
// job render IN TURN on one slot, so the first device's read began the sum of the stored renders before the
463+
// store. 0 when none says.
464+
const renderSpanMs = (variants) => {
465+
let span = 0;
465466
for (const variant of variants) {
466-
if (typeof variant.renderTime === 'number' && variant.renderTime > longest) longest = variant.renderTime;
467+
if (typeof variant.renderTime === 'number' && variant.renderTime > 0) span += variant.renderTime;
467468
}
468-
return longest;
469+
return span;
469470
};
470471

471472
export class RenderQueue extends Resource {
@@ -1071,10 +1072,10 @@ export class RenderQueue extends Resource {
10711072
complete: everyDevice,
10721073
}),
10731074
// The entity registry (util/entity.js): the canonical this page declared, observed when the origin
1074-
// was READ — the store time less the longest render — so a render claimed before a re-slug cannot
1075-
// outrank the probe that saw it. A point read, and a write only when the canonical moves; never
1076-
// rejects.
1077-
observeRenderedCanonical(scheduleUrl, describing.pageFacts, cachedAt - longestRenderMs(stored)).catch(() => {}),
1075+
// was READ — the store time less the stored renders, which run one after another — so a render
1076+
// claimed before a re-slug cannot outrank the probe that saw it. A point read, and a write only
1077+
// when the canonical moves; never rejects.
1078+
observeRenderedCanonical(scheduleUrl, describing.pageFacts, cachedAt - renderSpanMs(stored)).catch(() => {}),
10781079
...observed.map((variant) =>
10791080
recordReadinessExpectation({ url: scheduleUrl, deviceType: variant.deviceType }, variant.readiness)
10801081
),

‎packages/plugin/src/schemas/schema.graphql‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
#
88
# render_schedule RenderSchedule — the hot render queue (isolated)
99
# render_service Target, QueueStatus, — URL registry (incl. indexability
10-
# QueueControl verdicts) + queue status + pause intent
10+
# QueueControl, Entity verdicts) + queue status + pause intent
11+
# + each entity's current canonical
1112
# config ConfigOverride — operator-set config overrides (isolated
1213
# because it is SUBSCRIBED; see the type)
1314
# page_cache PrerenderedPage — rendered-HTML cache (blob writes)
@@ -166,8 +167,9 @@ type Entity @table(database: "render_service") {
166167
canonical: String
167168
# Which observer named it: 'probe' or 'render'.
168169
canonicalFrom: String
169-
# When that observer READ the origin. A later observation that disagrees moves the canonical; an earlier
170-
# one (a render claimed before a re-slug, landing after the probe saw it) does not.
170+
# When that observer READ the origin — when the CURRENT canonical was first observed; an unchanged
171+
# observation writes nothing. A later observation that disagrees moves the canonical; an earlier one (a
172+
# render claimed before a re-slug, landing after the probe saw it) does not.
171173
canonicalAt: Date
172174
firstSeenAt: Date
173175
# The last canonical the change probe ADOPTED a target for, and when: a canonical that did not take is

‎packages/plugin/src/util/changeProbe.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2538,8 +2538,9 @@ export const runProbeSweepOnce = async ({
25382538
readCheck: config.changeProbe.serveCheck?.enabled ? readPageCheck : null,
25392539
skipCheckedSince: serveChecksArmed() ? (resume?.originStartedAt ?? startedAt) : null,
25402540
recordCheck: config.changeProbe.serveCheck?.enabled ? writePageCheck : null,
2541-
// One observer per pass: it counts this pass's adoptions against `adoptCanonical.maxPerPass`.
2542-
onCanonical: entitiesOn() ? createCanonicalObserver() : null,
2541+
// One observer per pass, counting adoptions by the pass's origin (a resume shares its cap), and
2542+
// filing nothing when THIS pass is a dry run — an operator's measure-only sweep included.
2543+
onCanonical: entitiesOn() ? createCanonicalObserver({ probeDryRun: limits.dryRun, passId: passOrigin }) : null,
25432544
guard: theMappingGuard(),
25442545
...limits,
25452546
// Rows this pass (or the pass it resumes) already probed — see `processOne`. The same for a

0 commit comments

Comments
 (0)