Skip to content

Commit 36541b4

Browse files
Merge pull request #45 from HarperFast/fix/align-device-jitter
Aligns a URL's device variants onto one render slot ahead of the sitemap pre-warm, plus rejects an empty cacheKey.delimiter (review finding).
2 parents 7106df8 + ec92a8c commit 36541b4

8 files changed

Lines changed: 150 additions & 14 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: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ rest: true # required for the @export-ed table REST endpoints
8383

8484
page:
8585
ttl: 86400000 # 24h — default cached-page TTL
86-
minTtl: 21600000 # 6h — floor for sitemap-derived TTLs
86+
minTtl: 21600000 # 6h — floor for sitemap-derived TTLs. Also floors the RENDER INTERVAL a
87+
# sitemap's `changefreq` produces, and therefore the width of the
88+
# initial-render jitter: `changefreq: hourly` becomes a 6h cadence
89+
# spread over 6h, i.e. 4x the sustained render load of `daily`.
90+
# Raise this to slow a fleet down; it trades page freshness for load.
8791
swrTtl: 10800000 # 3h — stale-while-revalidate window
8892

8993
render:
@@ -241,6 +245,33 @@ A forwarded-mode config that compiles to **zero** prerender routes is reported a
241245
(`/prerender_admin` surfaces it): nothing is prerendered in that state, and it is what a single
242246
typo produces, since invalid entries are dropped one at a time.
243247

248+
### How bulk sitemap population is staggered
249+
250+
Populating a large sitemap must not queue every URL at once. A new target's first render is
251+
therefore `now + (hash(url) % renderInterval)`, floored to the minute — a uniform spread over the
252+
interval, so the fleet sees a flat stream rather than a herd. Because `processJobResult`
253+
reschedules from **render completion** (`currentMinuteMs() + interval`) rather than a fixed
254+
time-of-day, that spread is preserved cycle over cycle and self-paces to fleet throughput.
255+
256+
Three properties are worth knowing before a bulk upload:
257+
258+
- **The stagger window is the target's own `renderInterval`, not a fixed 24h.** For a sitemap
259+
target that comes from `changefreq`, floored at `page.minTtl` — so `always`/`hourly` spread over
260+
`minTtl` (6h by default) and re-render that often, i.e. 4× the sustained load of `daily`. A
261+
sitemap's `changefreq` is the single biggest determinant of steady-state render load, and it
262+
comes from the sitemap XML, not from this config. Check it before uploading.
263+
- **A URL's device variants share one slot.** The offset is seeded off the URL half of the cache
264+
key, so `…|desktop` and `…|mobile` come due together, sort adjacently in claim order, and get
265+
rendered back-to-back by one worker off a warm origin. It also keeps the cached copies of one
266+
page the same age — seeded off the full key they drifted up to a whole interval apart, so a
267+
content change could appear on one device and not the other for hours.
268+
- **`revalidate: true` bypasses the stagger entirely**, setting every entry due in the same
269+
minute. That is its purpose (forcing a backfill), but it is not how to warm a large sitemap for
270+
the first time — omit it and let the jitter place the URLs.
271+
272+
There is no separate "warm-up" pacing knob, and none is needed: the initial spread is exactly the
273+
steady-state cadence, so a fleet that can sustain the ongoing load can absorb the warm.
274+
244275
### Staging passthrough
245276

246277
To verify an origin against a staging edge (e.g. a CDN's staging network) _through_ the
@@ -336,7 +367,11 @@ this plugin's resources all set `loadAsInstance = false`.
336367
- **Overview** — per-node queue status with staleness, table counts, the due-now backlog, and
337368
a next-24h histogram of `nextRenderTime`. That histogram is the quickest way to tell a
338369
healthy jittered spread from a render herd: a flat distribution means the initial-render
339-
jitter is working, a single tall bar means everything comes due at once.
370+
jitter is working, a single tall bar means everything comes due at once. Note the histogram
371+
is capped at `management.scanCap` rows and reports `truncated` — at a large registry read the
372+
shape, not the counts. The due-now backlog is the capacity signal: jitter flattens the
373+
arrival curve but cannot lower it, so a backlog that climbs and never returns to zero means
374+
sustained demand (`Σ targets ÷ renderInterval`) exceeds fleet throughput.
340375
- **URL explainer** — paste a URL and see the ingress route that matched, the query allowlist
341376
it selected, the canonical URL, the resulting cache key, and the live
342377
`RenderTarget`/`RenderSchedule`/`PrerenderedPage`/`NonIndexable` rows under it. It also

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.13.0",
3+
"version": "0.14.0",
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,25 @@ const mergeInto = (target, source, path = 'prerender') => {
410410
}
411411
};
412412

413+
/**
414+
* Reject overrides that pass `mergeInto`'s type check but cannot be honored at all, restoring
415+
* the default. This is distinct from `collectConfigWarnings`, which reports settings that are
416+
* merely risky — these are values that would silently corrupt behavior, so they are dropped.
417+
*
418+
* `cacheKey.delimiter` is structural: an empty string is a string, so the type check accepts
419+
* it, but then `toCacheKey` concatenates the attributes with no separator (making the keys of
420+
* two URLs where one is a prefix of the other collide) and `parse` splits on `''`, i.e. into
421+
* individual CHARACTERS — a cache key parses to `{ url: 'h', deviceType: 't' }`. It would also
422+
* collapse the whole render schedule onto a single minute, since `indexOf('')` is 0 and every
423+
* jitter seed becomes the empty string. Nothing downstream can work, so the default wins.
424+
*/
425+
const rejectUnusableOverrides = (fresh) => {
426+
if (fresh.cacheKey.delimiter === '') {
427+
getLogger().warn?.('[prerender] Ignoring prerender.cacheKey.delimiter: must not be empty — keeping the default');
428+
fresh.cacheKey.delimiter = defaultConfig().cacheKey.delimiter;
429+
}
430+
};
431+
413432
/**
414433
* Apply host-provided options onto the live `config`, with validation. Safe to
415434
* call repeatedly (e.g. on every options `change`). Resets to defaults first so
@@ -418,6 +437,7 @@ const mergeInto = (target, source, path = 'prerender') => {
418437
export const applyOptions = (options) => {
419438
const fresh = defaultConfig();
420439
if (isPlainObject(options)) mergeInto(fresh, options);
440+
rejectUnusableOverrides(fresh);
421441

422442
// Replace the contents of the live object in place to preserve the reference.
423443
for (const key of Object.keys(config)) delete config[key];

packages/plugin/src/resources/RenderTarget.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ export class RenderTarget extends databases.render_service.RenderTarget {
3434
// silently. `util/reconcile.js` is what actually repairs it.
3535
const result = await super.put({ ...CacheKey.parse(cacheKey), ...data }, target);
3636

37-
// Absent a valid explicit time, jitter the first render across the interval (keyed
38-
// off the cacheKey) so bulk-created targets don't all come due at once. RenderTarget
39-
// is API-exposed, so validate the numbers (reject negatives / NaN / non-numbers)
40-
// rather than trust the payload.
37+
// Absent a valid explicit time, jitter the first render across the interval (keyed off
38+
// the URL half of the cacheKey, so a URL's device variants share one slot) so
39+
// bulk-created targets don't all come due at once. RenderTarget is API-exposed, so
40+
// validate the numbers (reject negatives / NaN / non-numbers) rather than trust the
41+
// payload.
4142
const interval =
4243
Number.isFinite(data.renderInterval) && data.renderInterval > 0
4344
? data.renderInterval

packages/plugin/src/util/time.js

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,43 @@ export const getNextTimeOfDay = (timeStr, timezone) => {
4646
};
4747

4848
/**
49-
* Deterministic first-render time: `now` plus a per-key offset in `[0, interval)`,
49+
* The jitter offset is seeded off the URL half of a cache key, NOT the whole key, so every
50+
* device-type variant of one URL lands on the SAME minute. Seeded off the full key, `desktop`
51+
* and `mobile` hash to unrelated offsets and drift up to a whole interval apart, which means
52+
* the two copies of a page can differ in age by up to 24h — a content change shows on one
53+
* device and not the other, and every render pays a cold origin/CDN fetch. Aligned, the pair
54+
* sorts adjacently in `RenderQueue.claim`'s nextRenderTime order and is rendered back-to-back
55+
* by one worker off a warm origin. Residency already groups them this way (`schedulerNode`
56+
* comes from the URL alone), so the seed now agrees with the routing.
57+
*
58+
* Alignment persists cycle over cycle because `processJobResult` reschedules from
59+
* `currentMinuteMs() + interval` — both variants completing within the same minute get an
60+
* identical next time, so the pair stays locked instead of drifting.
61+
*/
62+
const jitterSeed = (key) => {
63+
const str = String(key ?? '');
64+
const at = str.indexOf(config.cacheKey.delimiter);
65+
// No delimiter → not a cache key. Seed off the whole string rather than the empty prefix
66+
// `CacheKey.extractUrl` would return, which would collapse every such key onto a single
67+
// minute — precisely the herd this jitter exists to prevent.
68+
return at === -1 ? str : str.slice(0, at);
69+
};
70+
71+
/**
72+
* Deterministic first-render time: `now` plus a per-URL offset in `[0, interval)`,
5073
* floored to the minute. Spreads the initial render of freshly-scheduled targets
5174
* across the render interval instead of firing them all at once (the thundering
52-
* herd on bulk sitemap population / crawl spikes). The offset is keyed off the
53-
* cacheKey so it's stable and reproducible. Recurring re-renders are scheduled
54-
* relative to render completion (see RenderQueue.processJobResult), so this initial
55-
* spread is preserved cycle over cycle rather than realigning to a fixed instant.
75+
* herd on bulk sitemap population / crawl spikes). The offset is keyed off the URL
76+
* half of the cacheKey (see `jitterSeed`) so it's stable, reproducible, and shared
77+
* by a URL's device variants. Recurring re-renders are scheduled relative to render
78+
* completion (see RenderQueue.processJobResult), so this initial spread is preserved
79+
* cycle over cycle rather than realigning to a fixed instant.
5680
*/
5781
export const getInitialRenderTime = (key, interval) => {
5882
// Guard against a zero/negative/NaN interval (which would make the modulo NaN and
5983
// schedule an invalid time) so the helper is safe to call with unvalidated inputs.
6084
const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : config.render.defaultInterval;
61-
return currentMinuteMs(Date.now() + (fnv1a32(key) % safeInterval));
85+
return currentMinuteMs(Date.now() + (fnv1a32(jitterSeed(key)) % safeInterval));
6286
};
6387

6488
export const getNextSitemapRefreshTime = () => getNextTimeOfDay(config.sitemap.refreshTime, config.sitemap.timezone);

packages/plugin/test/config.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,19 @@ test('applyOptions keeps the literal token when valueEnv is unset or missing', (
145145
applyOptions({ securityToken: { value: 'literal', valueEnv: '__MISSING_ENV__' } });
146146
assert.equal(config.securityToken.value, 'literal');
147147
});
148+
149+
/*
150+
* An empty cacheKey.delimiter passes mergeInto's typeof check (it IS a string) but cannot be
151+
* honored: `toCacheKey` would concatenate with no separator, `parse` would split on '' into
152+
* individual characters, and every jitter seed would become '' — collapsing the whole render
153+
* schedule onto one minute. It is rejected outright rather than merely warned about.
154+
*/
155+
test('applyOptions rejects an empty cacheKey.delimiter and keeps the default', () => {
156+
applyOptions({ cacheKey: { delimiter: '' } });
157+
assert.equal(config.cacheKey.delimiter, '|');
158+
});
159+
160+
test('applyOptions still honors a non-empty cacheKey.delimiter override', () => {
161+
applyOptions({ cacheKey: { delimiter: '::' } });
162+
assert.equal(config.cacheKey.delimiter, '::');
163+
});

packages/plugin/test/time.test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,43 @@ test('getInitialRenderTime is stable per key and spreads across keys', () => {
4343
const values = new Set(Array.from({ length: 100 }, (_, i) => getInitialRenderTime(`key-${i}`, interval)));
4444
assert.ok(values.size > 1, 'distinct keys spread across times');
4545
});
46+
47+
/*
48+
* The jitter is seeded off the URL half of the cache key, so a URL's device variants come due
49+
* together: the pair sorts adjacently in claim order and renders back-to-back off a warm
50+
* origin, and the two cached copies of a page never differ in age by up to a whole interval.
51+
* Seeded off the full cacheKey (as it was), `|desktop` and `|mobile` hashed to unrelated
52+
* offsets.
53+
*/
54+
test('getInitialRenderTime aligns a URL device variants on one slot', () => {
55+
const interval = 24 * 60 * MINUTE;
56+
const url = 'https://x.test/catalog/shoes';
57+
const desktop = getInitialRenderTime(`${url}|desktop`, interval);
58+
const mobile = getInitialRenderTime(`${url}|mobile`, interval);
59+
const tablet = getInitialRenderTime(`${url}|tablet`, interval);
60+
61+
// Allow a 1-minute window: the calls can straddle a wall-clock minute boundary.
62+
assert.ok(Math.abs(desktop - mobile) <= MINUTE, 'desktop and mobile share a slot');
63+
assert.ok(Math.abs(desktop - tablet) <= MINUTE, 'desktop and tablet share a slot');
64+
});
65+
66+
test('getInitialRenderTime still spreads distinct URLs that share a device type', () => {
67+
const interval = 24 * 60 * MINUTE;
68+
const values = new Set(
69+
Array.from({ length: 200 }, (_, i) => getInitialRenderTime(`https://x.test/p/prd-${i}|desktop`, interval))
70+
);
71+
// Aligning device variants must not collapse the URL spread. 200 URLs over 1440 minute
72+
// buckets: a handful of hash collisions is expected, a near-total collapse is the bug.
73+
assert.ok(values.size > 100, `distinct URLs stay spread (got ${values.size} slots)`);
74+
});
75+
76+
/*
77+
* A key with no delimiter is not a cache key. It must fall back to hashing the whole string:
78+
* `CacheKey.extractUrl` returns '' for such a key, and seeding off that would put EVERY
79+
* delimiter-less key on the same minute — the exact herd this jitter prevents.
80+
*/
81+
test('getInitialRenderTime does not collapse keys that lack the delimiter', () => {
82+
const interval = 24 * 60 * MINUTE;
83+
const values = new Set(Array.from({ length: 100 }, (_, i) => getInitialRenderTime(`no-delimiter-${i}`, interval)));
84+
assert.ok(values.size > 50, `delimiter-less keys stay spread (got ${values.size} slots)`);
85+
});

0 commit comments

Comments
 (0)