Skip to content

Commit 1e27cfb

Browse files
Merge pull request #39 from HarperFast/fix/reconcile-query
fix(plugin): the reconcile walk was rejected on its first page; v0.10.1
2 parents 7109c5c + a5210a0 commit 1e27cfb

6 files changed

Lines changed: 196 additions & 152 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/plugin/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ rest: true # required for the @export-ed table REST endpoints
8787
interval: 21600000 # 6h — how often each node sweeps its own slice of the keyspace
8888
startDelay: 300000 # 5m — grace after boot before the first sweep
8989
startJitter: 300000 # 5m — per-node spread, so a rolling restart doesn't sync the sweeps
90-
batchSize: 500 # targets per page (bounds how long each read transaction stays open)
91-
maxRestores: 5000 # ceiling on rows RESTORED per sweep; a truncated sweep logs that it was
90+
maxRestores:
91+
5000 # ceiling on rows RESTORED per sweep; the scan always completes, so a
92+
# truncated sweep still reports the full size of the gap
9293

9394
sitemap:
9495
refreshTime: '12:00' # local time-of-day for the daily sitemap refresh
@@ -291,9 +292,16 @@ The state is therefore terminal _and_ silent: the cached page expires, every lat
291292
falls through to the origin, and there is no error and no metric to notice it by. The only
292293
symptom is a page whose `lastCached` keeps receding.
293294

294-
`render.reconcile` is the repair. Each node walks the target registry in primary-key pages and,
295-
**for the keys it owns**, checks node-locally whether the schedule row exists and restores it if
296-
not. Owner-scoped is a safety requirement, not an optimization: a point read of a
295+
`render.reconcile` is the repair. Each node makes **one pass** over the target registry and, **for
296+
the keys it owns**, checks node-locally whether the schedule row exists, collecting the gaps and
297+
restoring them once the scan has finished.
298+
299+
The pass is deliberately cursor-free, so nothing depends on the order rows arrive in. Paging by
300+
primary key and resuming from the last key seen would make correctness rest on the storage engine
301+
returning rows in key order — and if that ever stopped holding, the cursor would skip rows
302+
silently, which is the worst failure mode available to a repair tool. Restoring only after the
303+
scan closes also makes the transaction rule structural rather than a convention: no write is ever
304+
issued while the scan's cursor is open. Owner-scoped is a safety requirement, not an optimization: a point read of a
297305
residency-pinned row this node does not own takes Harper's untimed replication fetch, so a
298306
single such read could hang the sweep forever. Every node sweeping its own slice covers the
299307
whole keyspace with no coordination and no cross-node reads.

packages/plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harperfast/prerender",
3-
"version": "0.10.0",
3+
"version": "0.10.1",
44
"type": "module",
55
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
66
"license": "Apache-2.0",

packages/plugin/src/config.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,10 @@ const defaultConfig = () => ({
208208
interval: 6 * HOUR, // how often each node sweeps its own slice of the keyspace
209209
startDelay: 5 * MINUTE, // grace after boot before the first sweep
210210
startJitter: 5 * MINUTE, // per-node spread, so a rolling restart doesn't sync the sweeps
211-
batchSize: 500, // targets per page; bounds how long each read transaction stays open
212-
// Ceiling on rows RESTORED per sweep (not rows examined). A membership change can
213-
// strand a large slice of the keyspace at once, and rewriting millions of rows in
214-
// a single pass would be its own outage. A truncated sweep says so in the log.
211+
// Ceiling on rows RESTORED per sweep. The scan always runs to completion, so a
212+
// truncated sweep still reports the true size of the gap — the cap bounds only how
213+
// much is repaired at once, since a membership change can strand a large slice of the
214+
// keyspace and rewriting millions of rows in one pass would be its own outage.
215215
maxRestores: 5000,
216216
},
217217
},

packages/plugin/src/util/reconcile.js

Lines changed: 79 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -36,122 +36,115 @@ import { fnv1a32 } from './hash.js';
3636
import { getResidencyByUrl } from './residency.js';
3737
import { 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
*/
5164
export 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

Comments
 (0)