Skip to content

Commit ea67ce5

Browse files
trialclaude
andcommitted
fix(plugin): review of 0.101.0 -- a dry run counts each entity once and spends its own lane of the budget, retryAfter is per entity, the budget is its own module tested across worker threads; v0.101.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3cba016 commit ea67ce5

10 files changed

Lines changed: 247 additions & 74 deletions

File tree

‎packages/plugin/METRICS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ Notes that bite:
189189
disagree about the canonical. `canonical_adopt` (`entities.adopt`): one emit per adoption decision,
190190
made only when an observation names a canonical that is another URL than the one observed: `adopted`,
191191
`reactivated`, `would-adopt` (**the dry-run number**), `exists` (every duplicate spelling, nightly),
192-
`suppressed`, `recent`, `capped` (past `maxPerHour` on this node), `refused`, `error`.
192+
`suppressed`, `recent` (the entity was adopted, or in a dry run would have been, within `retryAfter`),
193+
`capped` (past `maxPerHour` on this node), `refused`, `error`.
193194
- **The change probe's `probe_*` series changed shape in v0.97.0, and the table row above predates
194195
it.** (1) The pass counters are emitted **per probed batch as increments**, not once when a pass
195196
ends: a nine-hour pass is no longer one row that a dropped analytics window loses whole, and a pass

‎packages/plugin/README.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ entities:
331331
observer; it is the endpoint's own answer for the product id. A stored render's declared
332332
`pageFacts.canonical` is the other.
333333
- When two observations disagree, the newer wins. A render's instant is when it read the origin
334-
(store time less its longest render), so a render claimed before a re-slug can't undo the probe
334+
(store time less the sum of its renders), so a render claimed before a re-slug can't undo the probe
335335
that saw it.
336336
- An unchanged observation writes nothing, and neither does the same canonical spelled otherwise
337337
(`%27` for an apostrophe). That is one document, as the probe's own `path` comparator already
@@ -343,10 +343,13 @@ entities:
343343
- A target suppressed as a canonical verdict (`canonical-mismatch`, `canonical-variant`) is reactivated
344344
the same way. One suppressed for any other reason (a 404, a noindex) is left alone.
345345
- Bounded by `maxPerHour` per node, shared by every worker thread and every observer; by `retryAfter`
346-
per entity (a canonical that did not take is filed once per window, not nightly, so it costs one render
347-
a week for as long as the origin names it); and by both dry runs. A probe pass run as a dry run,
348-
including an operator's measure-only sweep, files nothing.
349-
- In a dry run, what arming would file is `would-adopt` plus `capped`.
346+
per entity, whichever canonical (a canonical that did not take is filed once per window, not nightly,
347+
so it costs one render a week for as long as the origin names it, and two spellings naming each other
348+
cannot reactivate each other in turn); and by both dry runs. A probe pass run as a dry run, including
349+
an operator's measure-only sweep, files nothing.
350+
- In a dry run, what arming would file is `would-adopt` plus `capped`. A dry run remembers each entity
351+
it would have adopted (`wouldAdoptAt`), so a repeat inside `retryAfter` reads `recent` as it would
352+
armed, and it spends its own lane of the hourly budget, never an armed node's real slots.
350353
- **Why.** Measured on one deployment, products re-slug ~100 times a day and the product sitemap
351354
changes once a day. An out-of-stock product is not in the sitemap at all, so its new canonical
352355
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 & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3126,9 +3126,11 @@ export const configSchema = group('Prerender plugin configuration.', {
31263126
dryRun: option(
31273127
true,
31283128
'Count `would-adopt` and file nothing. The default, because the number to know first is how many ' +
3129-
'canonicals arming would file: `would-adopt` plus `capped` (the budget counts would-adopts too). ' +
3130-
'A change-probe pass run as a dry run \u2014 the probe\u2019s own `dryRun`, or an operator\u2019s ' +
3131-
'measure-only sweep \u2014 files nothing either.'
3129+
'canonicals arming would file: `would-adopt` plus `capped`. A dry run remembers each entity it ' +
3130+
'would have adopted, so a repeat inside `retryAfter` reads `recent` as it would armed, and counts ' +
3131+
'against its own lane of the hourly budget, never an armed node\u2019s real slots. A change-probe ' +
3132+
'pass run as a dry run \u2014 the probe\u2019s own `dryRun`, or an operator\u2019s measure-only ' +
3133+
'sweep \u2014 files nothing either.'
31323134
),
31333135
maxPerHour: option(
31343136
60,
@@ -3140,9 +3142,10 @@ export const configSchema = group('Prerender plugin configuration.', {
31403142
),
31413143
retryAfter: option(
31423144
7 * DAY,
3143-
'An entity whose adopted canonical did not take (it 404s, or its page names another canonical ' +
3144-
'after all) is not filed again for this long, so a bad canonical costs one render per window, ' +
3145-
'not one per observation.',
3145+
'An entity adopted once is not adopted again for this long, whichever canonical is named then: a ' +
3146+
'canonical that did not take (it 404s, or its page names another canonical after all) costs one ' +
3147+
'render per window, not one per observation, and two spellings that name each other cannot ' +
3148+
'reactivate each other in turn.',
31463149
{ unit: 'ms', min: HOUR }
31473150
),
31483151
}

‎packages/plugin/src/metrics.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,8 @@ export const METRICS = Object.freeze({
716716
'filed due now), reactivated (a canonical-verdict suppression lifted, due now), would-adopt (either, in ' +
717717
'a dry run — THE DRY-RUN NUMBER), exists (the canonical has a target in rotation — every duplicate ' +
718718
'spelling, nightly), suppressed (its target is suppressed for a reason the origin\u2019s word does not overturn), ' +
719-
'recent (adopted within retryAfter), capped (past maxPerHour on this node), refused (unkeyable, off the domain ' +
719+
'recent (the entity was adopted, or in a dry run would have been, within retryAfter), capped (past maxPerHour ' +
720+
'on this node), refused (unkeyable, off the domain ' +
720721
'allowlist, or not on a prerender route), error. ' +
721722
'raw_cache = one emit per raw-document store attempt, split by outcome: `stored`, `stored-unshared`, ' +
722723
'or the reason it was refused (not-200, staging, has-cookie, content-type, no-store, no-body, ' +

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1778,6 +1778,8 @@ export class PrerenderAdmin extends Resource {
17781778
canonicalAt: entityRow?.canonicalAt ? new Date(entityRow.canonicalAt).getTime() : null,
17791779
adoptedCanonical: entityRow?.adoptedCanonical ?? null,
17801780
adoptedAt: entityRow?.adoptedAt ? new Date(entityRow.adoptedAt).getTime() : null,
1781+
wouldAdoptCanonical: entityRow?.wouldAdoptCanonical ?? null,
1782+
wouldAdoptAt: entityRow?.wouldAdoptAt ? new Date(entityRow.wouldAdoptAt).getTime() : null,
17811783
}
17821784
: null,
17831785
// Already described (locally or by the owner) — see above.

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,14 @@ type Entity @table(database: "render_service") {
172172
# render claimed before a re-slug, landing after the probe saw it) does not.
173173
canonicalAt: Date
174174
firstSeenAt: Date
175-
# The last canonical a target was ADOPTED for, and when: a canonical that did not take is not re-filed
176-
# until `entities.adopt.retryAfter` has passed.
175+
# The last canonical a target was ADOPTED for, and when: the entity is not adopted again until
176+
# `entities.adopt.retryAfter` has passed, whichever canonical is named then.
177177
adoptedCanonical: String
178178
adoptedAt: Date
179+
# The same, for an adoption a DRY RUN would have made: so the dry-run number counts entities, not
180+
# observations, and arming starts from no memory at all (a dry run never blocks a real adoption).
181+
wouldAdoptCanonical: String
182+
wouldAdoptAt: Date
179183
}
180184

181185
# The render queue: ONE ROW PER URL, due at `nextRenderTime`. A claim of a row is one job that

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

Lines changed: 45 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ import { CacheKey } from './cacheKey.js';
4949
import { entityPrefixOf } from './entityGate.js';
5050
import { classifyPath, matchRoute, PRERENDER, queryAllowlistFor } from './routeClass.js';
5151
import { canonicalizeUrl } from './url.js';
52-
import { currentMinuteMs, dateColumnMs, HOUR } from './time.js';
52+
import { currentMinuteMs, dateColumnMs } from './time.js';
5353
import { getSab } from './coordination.js';
54+
import { createHourlyBudget, hourlyBudgetLength } from './hourlyBudget.js';
5455
import { Target } from '../resources/Target.js';
5556

5657
const table = () => databases.render_service.Entity;
@@ -63,6 +64,8 @@ const ENTITY_SELECT = Object.freeze([
6364
'canonicalAt',
6465
'adoptedCanonical',
6566
'adoptedAt',
67+
'wouldAdoptCanonical',
68+
'wouldAdoptAt',
6669
]);
6770

6871
/** Every outcome of observing a canonical (`prerender_ops` / `entity_canonical`). */
@@ -83,7 +86,7 @@ export const CanonicalOutcome = Object.freeze({
8386
ERROR: 'error',
8487
});
8588

86-
/** Every outcome of the probe's adoption check (`prerender_ops` / `canonical_adopt`). */
89+
/** Every outcome of an adoption check (`prerender_ops` / `canonical_adopt`). */
8790
export const AdoptOutcome = Object.freeze({
8891
/** A target created for the canonical, due now. */
8992
ADOPTED: 'adopted',
@@ -95,7 +98,7 @@ export const AdoptOutcome = Object.freeze({
9598
EXISTS: 'exists',
9699
/** Its target is suppressed for a reason the origin's word does not overturn (a 404, a noindex). */
97100
SUPPRESSED: 'suppressed',
98-
/** Adopted within `retryAfter` already: a canonical that did not take is not re-filed every night. */
101+
/** The entity was adopted (or, in a dry run, would have been) within `retryAfter`, whichever canonical. */
99102
RECENT: 'recent',
100103
/** Past `entities.adopt.maxPerHour` on this node this hour. */
101104
CAPPED: 'capped',
@@ -276,52 +279,22 @@ const canonicalFromValue = (value, probedUrl) => {
276279
}
277280
};
278281

279-
// THE ADOPTION BUDGET: adoptions this hour on this node, shared by every worker thread — [hourSinceEpoch, count]
280-
// in a shared buffer (util/coordination.js `getSab`). Every source can adopt (the probe on its worker, renders
281-
// and serve-time checks and proxied documents on any worker), so a per-thread counter would multiply the cap by
282-
// the thread count. Hours since the epoch fit an int32 for a quarter of a million years.
283-
//
284-
// The window rolls forward only, and whoever wins the roll zeroes the count — the same shape as
285-
// util/invalidationReenqueue.js `reserveSlot`, for the same reasons: a straggler carrying an older hour must not
286-
// zero a count the new hour has spent, and a loser of a race costs or grants at most one adoption. This bounds
287-
// render load from a burst (a site-wide re-spelling), it is not a safety property.
288-
const BUDGET_SAB_KEY = 'entity_adopt_budget_v1';
289-
const B_HOUR = 0;
290-
const B_COUNT = 1;
282+
// THE ADOPTION BUDGET: adoptions this hour on this node, shared by every worker thread (util/hourlyBudget.js, over
283+
// a shared buffer). Every source can adopt (the probe on its worker, renders and serve-time checks and proxied
284+
// documents on any worker), so a per-thread counter would multiply the cap by the thread count. Two lanes: real
285+
// adoptions, and dry-run ones, so a dry run measures what the cap would do without spending the slots an armed
286+
// node's real adoptions need (an operator's measure-only sweep on an armed node, for one).
287+
const BUDGET_SAB_KEY = 'entity_adopt_budget_v2';
288+
const REAL = 0;
289+
const DRY = 1;
291290
let budgetCell = null;
292-
const budgetI32 = () => (budgetCell ??= new Int32Array(getSab(BUDGET_SAB_KEY, 8)));
291+
const budgetI32 = () =>
292+
(budgetCell ??= new Int32Array(getSab(BUDGET_SAB_KEY, hourlyBudgetLength(2) * Int32Array.BYTES_PER_ELEMENT)));
293293

294-
/** The node's adoption budget: `reserve(limit)` takes a slot this hour (false past `limit`); `release()` returns one. */
295-
export const adoptionBudget = Object.freeze({
296-
reserve(limit, nowMs = Date.now()) {
297-
const cell = budgetI32();
298-
const hour = Math.floor(nowMs / HOUR);
299-
const observed = Atomics.load(cell, B_HOUR);
300-
if (hour > observed && Atomics.compareExchange(cell, B_HOUR, observed, hour) === observed) {
301-
Atomics.store(cell, B_COUNT, 0);
302-
}
303-
for (let attempt = 0; attempt < 8; attempt++) {
304-
const used = Atomics.load(cell, B_COUNT);
305-
if (used >= limit) return false;
306-
if (Atomics.compareExchange(cell, B_COUNT, used, used + 1) === used) return true;
307-
}
308-
// Eight lost races: other workers are spending the budget right now. Refusing is the safe direction.
309-
return false;
310-
},
311-
release() {
312-
const cell = budgetI32();
313-
for (let attempt = 0; attempt < 8; attempt++) {
314-
const used = Atomics.load(cell, B_COUNT);
315-
if (used <= 0 || Atomics.compareExchange(cell, B_COUNT, used, used - 1) === used) return;
316-
}
317-
},
318-
});
294+
/** The node's adoption budget (util/hourlyBudget.js): lane 0 real adoptions, lane 1 dry-run ones. */
295+
export const adoptionBudget = createHourlyBudget(budgetI32);
319296
/** Tests: an empty budget. */
320-
export const resetAdoptionCountsForTest = () => {
321-
const cell = budgetI32();
322-
Atomics.store(cell, B_HOUR, 0);
323-
Atomics.store(cell, B_COUNT, 0);
324-
};
297+
export const resetAdoptionCountsForTest = () => adoptionBudget.reset();
325298

326299
/**
327300
* RESOLVE A CANONICAL: record an observation of the origin naming `value` as the canonical of the entity `url`
@@ -337,13 +310,16 @@ export const resetAdoptionCountsForTest = () => {
337310
* Bounded three ways:
338311
* - `entities.adopt.maxPerHour` per node, across every worker and every source (`adoptionBudget`). A put that
339312
* fails gives its slot back.
340-
* - `retryAfter` per entity, so a canonical that did not take (it 404s and is retired, or its page names
341-
* another canonical and is suppressed) is filed at most once per window. Such an entity costs one render a
342-
* window for as long as the origin keeps naming it.
313+
* - `retryAfter` per ENTITY, whichever canonical: an entity adopted within it is `recent`. So a canonical that
314+
* did not take (it 404s and is retired, or its page names another canonical and is suppressed) costs one
315+
* render a window for as long as the origin keeps naming it, and two spellings that name each other (an
316+
* origin re-slugging back, or serving two inconsistent copies) cannot reactivate each other in turn.
343317
* - a dry run — `entities.adopt.dryRun`, or the caller's own (`dryRun`: the change probe passes its pass's
344-
* effective dry run, which an operator's measure-only sweep sets) — that counts and files nothing. In a dry
345-
* run the budget counts would-adopts too, so what arming files is `would-adopt + capped`, capped at
346-
* `maxPerHour`. The OBSERVATION is written in a dry run too: it is an observation, not an action.
318+
* effective dry run, which an operator's measure-only sweep sets) — that counts and files nothing. A dry run
319+
* REMEMBERS what it would have filed (`wouldAdoptAt`), so a repeat observation within `retryAfter` reads
320+
* `recent` exactly as it would armed, and spends its own lane of the budget, so `would-adopt + capped` is
321+
* what arming files and a dry run never spends an armed node's slots. The OBSERVATION is written in a dry
322+
* run too: it is an observation, not an action.
347323
*
348324
* All I/O injectable for tests (`createCanonicalResolver`).
349325
*/
@@ -381,13 +357,23 @@ export const createCanonicalResolver = ({
381357
return decided(AdoptOutcome.ERROR);
382358
}
383359
const nowMs = now();
384-
const last = dateColumnMs(observed.row.adoptedAt);
385-
if (observed.row.adoptedCanonical === observed.canonical && nowMs - last < adopt.retryAfter) {
386-
return decided(AdoptOutcome.RECENT);
387-
}
360+
const dry = Boolean(adopt.dryRun || dryRun);
361+
// Per entity, whichever canonical: the last real adoption, or — in a dry run — the last one it would have made.
362+
const last = dateColumnMs(dry ? observed.row.wouldAdoptAt : observed.row.adoptedAt);
363+
if (nowMs - last < adopt.retryAfter) return decided(AdoptOutcome.RECENT);
388364
if (!adoptable(observed.canonical)) return decided(AdoptOutcome.REFUSED);
389-
if (!budget.reserve(adopt.maxPerHour, nowMs)) return decided(AdoptOutcome.CAPPED);
390-
if (adopt.dryRun || dryRun) return decided(AdoptOutcome.WOULD_ADOPT);
365+
if (!budget.reserve(adopt.maxPerHour, nowMs, dry ? DRY : REAL)) return decided(AdoptOutcome.CAPPED);
366+
if (dry) {
367+
// Remembered, so the dry-run number counts entities, not observations. Best-effort, like the real memory.
368+
try {
369+
await markAdopted(observed.key, { wouldAdoptCanonical: observed.canonical, wouldAdoptAt: new Date(nowMs) });
370+
} catch (e) {
371+
logger.warn?.(
372+
`[prerender] entity: recording a would-adopt of ${observed.canonical} failed: ${e?.message ?? e}`
373+
);
374+
}
375+
return decided(AdoptOutcome.WOULD_ADOPT);
376+
}
391377
// `put` REPLACES the row (and is the reactivation that clears a suppression and its strikes), so a
392378
// suppressed row's own declarations ride along.
393379
const interval = Number(target?.renderInterval);
@@ -400,7 +386,7 @@ export const createCanonicalResolver = ({
400386
...(Number.isFinite(interval) && interval > 0 ? { renderInterval: interval } : {}),
401387
});
402388
} catch (e) {
403-
budget.release();
389+
budget.release(REAL);
404390
logger.warn?.(`[prerender] entity: adopting ${observed.canonical} failed: ${e?.message ?? String(e)}`);
405391
return decided(AdoptOutcome.ERROR);
406392
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* AN HOURLY BUDGET SHARED BY EVERY WORKER THREAD ON A NODE, over an Int32Array the caller hands in (a shared
3+
* buffer in production, util/coordination.js `getSab`). No Harper dependency, so it can be tested across real
4+
* worker threads.
5+
*
6+
* Layout: [hourSinceEpoch, spent in lane 0, spent in lane 1, ...]. Hours since the epoch fit an int32 for a
7+
* quarter of a million years. Each LANE is its own count against the same limit: the entity registry spends
8+
* real adoptions in one and dry-run adoptions in the other, so a dry run measures what the cap would do without
9+
* spending the slots a real adoption needs.
10+
*
11+
* The window rolls forward only, and whoever wins the roll zeroes every lane — the shape of
12+
* util/invalidationReenqueue.js `reserveSlot`, for the same reasons: a straggler carrying an older hour must not
13+
* zero a count the new hour has spent, and a loser of a race costs or grants at most one slot. This bounds load
14+
* from a burst; it is not a safety property.
15+
*/
16+
17+
const HOUR_MS = 3_600_000;
18+
const B_HOUR = 0;
19+
20+
/** The Int32Array length a budget with `lanes` lanes needs. */
21+
export const hourlyBudgetLength = (lanes) => 1 + lanes;
22+
23+
/**
24+
* A budget over `cell()` (called on every use, so the caller can allocate lazily). `reserve(limit, nowMs, lane)`
25+
* takes a slot in `lane` this hour, false past `limit`; `release(lane)` returns one.
26+
*/
27+
export const createHourlyBudget = (cell) => {
28+
const roll = (i32, nowMs) => {
29+
const hour = Math.floor(nowMs / HOUR_MS);
30+
const observed = Atomics.load(i32, B_HOUR);
31+
if (hour > observed && Atomics.compareExchange(i32, B_HOUR, observed, hour) === observed) {
32+
for (let lane = 1; lane < i32.length; lane++) Atomics.store(i32, lane, 0);
33+
}
34+
};
35+
return Object.freeze({
36+
reserve(limit, nowMs = Date.now(), lane = 0) {
37+
const i32 = cell();
38+
roll(i32, nowMs);
39+
const at = 1 + lane;
40+
for (let attempt = 0; attempt < 8; attempt++) {
41+
const used = Atomics.load(i32, at);
42+
if (used >= limit) return false;
43+
if (Atomics.compareExchange(i32, at, used, used + 1) === used) return true;
44+
}
45+
// Eight lost races: other workers are spending the budget right now. Refusing is the safe direction.
46+
return false;
47+
},
48+
release(lane = 0) {
49+
const i32 = cell();
50+
const at = 1 + lane;
51+
for (let attempt = 0; attempt < 8; attempt++) {
52+
const used = Atomics.load(i32, at);
53+
if (used <= 0 || Atomics.compareExchange(i32, at, used, used - 1) === used) return;
54+
}
55+
},
56+
/** Tests: an empty budget. */
57+
reset() {
58+
const i32 = cell();
59+
for (let i = 0; i < i32.length; i++) Atomics.store(i32, i, 0);
60+
},
61+
});
62+
};

0 commit comments

Comments
 (0)