@@ -36,122 +36,115 @@ import { fnv1a32 } from './hash.js';
3636import { getResidencyByUrl } from './residency.js' ;
3737import { getInitialRenderTime } from './time.js' ;
3838
39+ // Rows scanned between event-loop yields, so a sweep over a large registry stays background
40+ // work rather than monopolizing the thread.
41+ const YIELD_EVERY = 200 ;
42+
3943/**
40- * Walk targets in primary-key order and restore any missing schedule row for the keys this
41- * node owns. All I/O is injected so the traversal, the ownership filter and the caps are
42- * testable without a live database.
44+ * ONE pass over the targets: find the keys this node owns whose schedule row is missing, then
45+ * restore them after the scan has finished. All I/O is injected, so the traversal, the
46+ * ownership filter and the cap are testable without a live database.
47+ *
48+ * Deliberately CURSOR-FREE, and therefore indifferent to the order rows arrive in. An earlier
49+ * version paged by primary key and resumed from the last key seen, which quietly made
50+ * correctness depend on the storage engine returning rows in key order: if that ever stopped
51+ * holding, the cursor would skip rows silently — the worst possible failure mode for a repair
52+ * tool, in the one place nobody would look. A single pass needs no such guarantee.
53+ *
54+ * The two phases also make the transaction rule structural rather than a convention to
55+ * remember: no write is issued while the scan's cursor is open, because every write happens
56+ * after it closes. (Interleaving them would hold the read transaction across the writes and
57+ * pin the log against reclamation — the same reason `claim` drains before leasing.)
4358 *
44- * `searchTargets({ cursor, limit })` must return an ARRAY (a drained batch), not a live
45- * iterator: writes must never be issued while a search cursor is still open, since that keeps
46- * the read transaction open across them and pins the log against reclamation (the same
47- * reason `claim` drains before leasing). Paging by primary key is what keeps each read
48- * transaction short — a single walk across a million targets would sit open long enough for
49- * Harper to complain about it and commit it out from under us.
59+ * The cap bounds WRITES, not scanning: the scan always runs to completion, so `missing` is the
60+ * true size of the gap even when only `maxRestores` of it was repaired this pass. That also
61+ * means the loop never breaks early, so the iterator is always fully consumed and its read
62+ * transaction always released.
5063 */
5164export const reconcileSchedules = async ( {
52- searchTargets ,
65+ streamTargets ,
5366 getSchedule,
5467 putSchedule,
5568 ownerOf,
5669 hostname,
57- batchSize,
5870 maxRestores,
59- onBatch = ( ) => { } ,
71+ onYield = ( ) => { } ,
6072} = { } ) => {
61- const stats = { examined : 0 , owned : 0 , restored : 0 , truncated : false , lastKey : null } ;
62- let cursor = null ;
63-
64- for ( ; ; ) {
65- const batch = await searchTargets ( { cursor, limit : batchSize } ) ;
66- if ( ! batch . length ) break ;
67-
68- for ( const target of batch ) {
69- const cacheKey = target . cacheKey ;
70- // Advance the cursor for every row, including ones we skip, so a capped or
71- // interrupted run always makes forward progress instead of re-reading its prefix.
72- cursor = cacheKey ;
73- stats . examined ++ ;
74- stats . lastKey = cacheKey ;
75-
76- // Residency is keyed off the URL-half exactly as RenderSchedule's own
77- // `setResidencyById` computes it, so this agrees with where the row actually lives.
78- if ( ownerOf ( CacheKey . extractUrl ( cacheKey ) ) !== hostname ) continue ;
79- stats . owned ++ ;
80-
81- if ( await getSchedule ( cacheKey ) ) continue ;
82-
83- // A cap on WRITES, not on rows examined: the pathological case is a membership
84- // change stranding a large slice of the keyspace at once, and restoring millions of
85- // rows in a single pass would be its own outage. Report the truncation so a short
86- // count is never mistaken for "all clear".
87- if ( stats . restored >= maxRestores ) {
88- stats . truncated = true ;
89- return stats ;
90- }
91-
92- await putSchedule ( cacheKey , {
93- // The jittered initial time, NOT "now": a repair pass can restore a great many
94- // rows at once, and scheduling them all immediately would replace a silent
95- // outage with a render herd. This is the same value the original
96- // `RenderTarget.put` would have written, so a repaired target rejoins the
97- // rotation exactly where it belonged.
98- //
99- // `Long` columns can arrive as BigInt, which `Number.isFinite` rejects outright, so
100- // coerce before handing it over. No range check is needed here: the callee guards
101- // `Number.isFinite(interval) && interval > 0`, so a NON-POSITIVE value falls back
102- // to the default too — `Number(null)` is 0, which that guard rejects.
103- nextRenderTime : getInitialRenderTime ( cacheKey , Number ( target . renderInterval ) ) ,
104- fromSitemap : ! ! target . sitemapUrl ,
105- } ) ;
106- stats . restored ++ ;
107- }
73+ const stats = { examined : 0 , owned : 0 , missing : 0 , restored : 0 , truncated : false } ;
74+ const toRestore = [ ] ;
75+
76+ // Phase 1 — read only.
77+ for await ( const target of streamTargets ( ) ) {
78+ stats . examined ++ ;
79+ if ( stats . examined % YIELD_EVERY === 0 ) await onYield ( ) ;
80+
81+ const cacheKey = target . cacheKey ;
10882
109- await onBatch ( stats ) ;
83+ // Residency is keyed off the URL-half exactly as RenderSchedule's own
84+ // `setResidencyById` computes it, so this agrees with where the row actually lives.
85+ if ( ownerOf ( CacheKey . extractUrl ( cacheKey ) ) !== hostname ) continue ;
86+ stats . owned ++ ;
11087
111- // A short page means the index walk is done.
112- if ( batch . length < batchSize ) break ;
88+ if ( await getSchedule ( cacheKey ) ) continue ;
89+ stats . missing ++ ;
90+
91+ // Past the cap we keep counting but stop collecting, so the gap is measured in full
92+ // while the repair stays bounded. A membership change can strand a large slice of the
93+ // keyspace at once, and rewriting millions of rows in one pass would be its own outage.
94+ if ( toRestore . length < maxRestores ) toRestore . push ( target ) ;
95+ }
96+
97+ // Phase 2 — writes, with the scan's cursor now closed.
98+ for ( const target of toRestore ) {
99+ await putSchedule ( target . cacheKey , {
100+ // The jittered initial time, NOT "now": a repair pass can restore a great many rows at
101+ // once, and scheduling them all immediately would replace a silent outage with a
102+ // render herd. This is the same value the original `RenderTarget.put` would have
103+ // written, so a repaired target rejoins the rotation exactly where it belonged.
104+ //
105+ // `Long` columns can arrive as BigInt, which `Number.isFinite` rejects outright, so
106+ // coerce before handing it over. No range check is needed here: the callee guards
107+ // `Number.isFinite(interval) && interval > 0`, so a NON-POSITIVE value falls back to
108+ // the default too — `Number(null)` is 0, which that guard rejects.
109+ nextRenderTime : getInitialRenderTime ( target . cacheKey , Number ( target . renderInterval ) ) ,
110+ fromSitemap : ! ! target . sitemapUrl ,
111+ } ) ;
112+ stats . restored ++ ;
113113 }
114114
115+ stats . truncated = stats . missing > stats . restored ;
116+
115117 return stats ;
116118} ;
117119
118120/** `reconcileSchedules` bound to the live tables. */
119- export const reconcileScheduleGaps = async ( {
120- batchSize = config . render . reconcile . batchSize ,
121- maxRestores = config . render . reconcile . maxRestores ,
122- } = { } ) => {
121+ export const reconcileScheduleGaps = async ( { maxRestores = config . render . reconcile . maxRestores } = { } ) => {
123122 const {
124123 render_service : { RenderTarget } ,
125124 render_schedule : { RenderSchedule } ,
126125 } = databases ;
127126
128127 return reconcileSchedules ( {
129- searchTargets : ( { cursor, limit } ) =>
130- Array . fromAsync (
131- RenderTarget . search ( {
132- // Paging by primary key is an ordinary index walk: Harper injects this exact
133- // condition shape (`greater_than` on the primary key) for any unconstrained
134- // scan, so a cursor is just that scan resumed. The first page passes no
135- // condition and lets Harper inject it.
136- ...( cursor === null
137- ? { }
138- : { conditions : [ { attribute : 'cacheKey' , comparator : 'greater_than' , value : cursor } ] } ) ,
139- sort : { attribute : 'cacheKey' } ,
140- select : [ 'cacheKey' , 'renderInterval' , 'sitemapUrl' ] ,
141- limit,
142- } )
143- ) ,
128+ // One unconstrained scan, streamed. No conditions, no `sort`, no `limit`:
129+ // - no `sort`, because asking Harper to sort by the primary key is what broke v0.10.0
130+ // (the primary key is not flagged `indexed`, so a sort on it is rejected unless a
131+ // condition accompanies it — and Harper injects its own scan condition only AFTER
132+ // that check, so the first page threw before scanning anything);
133+ // - no conditions, because with none Harper injects that full-scan condition itself,
134+ // which is exactly what this wants;
135+ // - no `limit`, because the caller streams and never resumes, so it needs no paging.
136+ //
137+ // Nothing here depends on the order rows arrive in — see `reconcileSchedules`.
138+ streamTargets : ( ) => RenderTarget . search ( { select : [ 'cacheKey' , 'renderInterval' , 'sitemapUrl' ] } ) ,
144139 // Node-local by construction — see the module comment. Existence is all that matters.
145140 getSchedule : ( cacheKey ) => RenderSchedule . get ( { id : cacheKey , select : [ 'cacheKey' ] } , { replicateFrom : false } ) ,
146141 // Writes route by residency, so this reaches the owning node even though the read above
147142 // deliberately does not.
148143 putSchedule : ( cacheKey , row ) => RenderSchedule . put ( cacheKey , row ) ,
149144 ownerOf : getResidencyByUrl ,
150145 hostname : server . hostname ,
151- batchSize,
152146 maxRestores,
153- // Yield between pages so a sweep over a large registry stays background work.
154- onBatch : ( ) => setImmediate ( ) ,
147+ onYield : ( ) => setImmediate ( ) ,
155148 } ) ;
156149} ;
157150
@@ -182,9 +175,9 @@ export const runReconcileOnce = async (options) => {
182175 // warning rather than an info line. A clean pass says so quietly.
183176 if ( stats . restored || stats . truncated ) {
184177 logger . warn (
185- `[prerender] schedule reconcile: restored ${ stats . restored } missing schedule row(s) across ${ stats . owned } owned target(s) (${ stats . examined } examined)` +
178+ `[prerender] schedule reconcile: restored ${ stats . restored } of ${ stats . missing } missing schedule row(s) across ${ stats . owned } owned target(s) (${ stats . examined } examined)` +
186179 ( stats . truncated
187- ? ` — stopped at the ${ config . render . reconcile . maxRestores } -restore cap, more may remain `
180+ ? ` — ${ stats . missing - stats . restored } left for the next sweep by the ${ config . render . reconcile . maxRestores } -restore cap`
188181 : '' )
189182 ) ;
190183 } else {
0 commit comments