@@ -49,8 +49,9 @@ import { CacheKey } from './cacheKey.js';
4949import { entityPrefixOf } from './entityGate.js' ;
5050import { classifyPath , matchRoute , PRERENDER , queryAllowlistFor } from './routeClass.js' ;
5151import { canonicalizeUrl } from './url.js' ;
52- import { currentMinuteMs , dateColumnMs , HOUR } from './time.js' ;
52+ import { currentMinuteMs , dateColumnMs } from './time.js' ;
5353import { getSab } from './coordination.js' ;
54+ import { createHourlyBudget , hourlyBudgetLength } from './hourlyBudget.js' ;
5455import { Target } from '../resources/Target.js' ;
5556
5657const 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`). */
8790export 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 ;
291290let 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 }
0 commit comments