Skip to content

Commit 992155e

Browse files
Merge pull request #21 from HarperFast/spread-render-schedule
feat: spread render schedule + claim-per-capacity (plugin v0.4.0, browser v1.7.0)
2 parents 6bcd4e3 + 13c7f8b commit 992155e

15 files changed

Lines changed: 113 additions & 46 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/browser/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-browser",
3-
"version": "1.6.0",
3+
"version": "1.7.0",
44
"type": "module",
55
"description": "Headless-browser render library for Harper Prerender: claims render jobs from the @harperfast/prerender queue, renders pages in headless Chrome (Puppeteer), and posts the HTML back. Embedded by a render service and configured entirely via startWorker() options.",
66
"keywords": [

packages/browser/src/settings.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ export const applySettings = (options: BrowserOptions): Settings => {
229229
fresh.config = typeof options.config === 'string' ? loadConfig(options.config) : mergeConfig(options.config ?? {});
230230
fresh.concurrency = options.concurrency ?? fresh.concurrency;
231231
fresh.rps = options.rps ?? fresh.rps;
232-
fresh.jobClaimLimit = options.jobClaimLimit ?? fresh.concurrency * 2;
232+
// Claim at most what this worker can actually render at once. Over-claiming (the old
233+
// concurrency*2) made one worker grab jobs it couldn't start and hold them leased-idle,
234+
// starving other renderers of a burst and doubling the per-claim lease-write transaction.
235+
fresh.jobClaimLimit = options.jobClaimLimit ?? fresh.concurrency;
233236
fresh.browserExpirationThreshold = options.browserExpirationThreshold ?? fresh.browserExpirationThreshold;
234237
fresh.incognitoPages = options.incognitoPages ?? fresh.incognitoPages;
235238
fresh.contentEncoding = options.contentEncoding ?? fresh.contentEncoding;

packages/browser/test/settings.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ test('applies defaults and derives jobClaimLimit from concurrency', () => {
1717
applySettings({ harper: HARPER, concurrency: 4 });
1818
assert.equal(settings.harper.workerId, 'w1');
1919
assert.equal(settings.concurrency, 4);
20-
assert.equal(settings.jobClaimLimit, 8); // concurrency * 2
20+
assert.equal(settings.jobClaimLimit, 4); // defaults to concurrency
2121
assert.equal(settings.queuePort, 9926);
2222
assert.equal(settings.bypass.header, 'x-harper-renderer-bypass');
2323
assert.equal(settings.contentEncoding, 'gzip');

packages/plugin/README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,7 @@ rest: true # required for the @export-ed table REST endpoints
7979
swrTtl: 10800000 # 3h — stale-while-revalidate window
8080

8181
render:
82-
defaultInterval: 86400000 # 24h — how often a target is re-rendered
83-
time: '07:00' # local time-of-day for the daily render run
84-
timezone: America/New_York
82+
defaultInterval: 86400000 # 24h — how often a target is re-rendered (relative to completion)
8583

8684
sitemap:
8785
refreshTime: '12:00' # local time-of-day for the daily sitemap refresh

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.3.6",
3+
"version": "0.4.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: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,11 @@ const defaultConfig = () => ({
163163
},
164164

165165
render: {
166-
defaultInterval: DAY, // how often a target is re-rendered
167-
time: '07:00', // local time-of-day for the daily render run
168-
timezone: 'America/New_York',
166+
// How often a target is re-rendered. Cadence is relative to each render's
167+
// completion (not a fixed time-of-day), and a target's first render is jittered
168+
// across this interval — so the fleet renders as a smooth stream rather than a
169+
// daily herd. Sitemap-derived targets override this per-URL from `changefreq`.
170+
defaultInterval: DAY,
169171
},
170172

171173
sitemap: {
@@ -180,6 +182,11 @@ const defaultConfig = () => ({
180182
queue: {
181183
jobLeaseTime: 10 * MINUTE, // how long a claimed job is leased before re-claim
182184
statusSyncInterval: MINUTE, // how often queue status is recomputed/broadcast
185+
// Hard ceiling on jobs granted per claim, regardless of what a consumer asks for.
186+
// Each claimed job costs a lease write held under the claim mutex, so this bounds
187+
// the per-claim transaction (keeps one greedy/misconfigured worker from grabbing a
188+
// huge batch — long lock hold + starving other renderers of the burst).
189+
maxClaimLimit: 25,
183190
},
184191

185192
// Per-device-type User-Agent strings sent to the origin.

packages/plugin/src/http_handlers/bot_request.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,10 @@ async function handlePageScheduling(resource) {
176176
const cacheKey = CacheKey.toCacheKey({ url: cacheKeyUrl(resource.url), deviceType });
177177
const existingTarget = await RenderTarget.get({ id: cacheKey, select: 'cacheKey' });
178178
if (!existingTarget) {
179+
// No explicit time → RenderTarget.put jitters the first render across the
180+
// interval, so a crawl that discovers many URLs at once doesn't stampede.
179181
await RenderTarget.put(cacheKey, {
180182
renderInterval: config.render.defaultInterval,
181-
nextRenderTime: currentMinuteMs(),
182183
});
183184
}
184185
}

packages/plugin/src/resources/RenderQueue.js

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getMutex } from '../util/coordination.js';
22
import { config } from '../config.js';
3-
import { currentMinuteMs, getNextRenderTime } from '../util/time.js';
3+
import { currentMinuteMs } from '../util/time.js';
44
import { QueueState } from './QueueState.js';
55
import { CacheKey } from '../util/cacheKey.js';
66
import { cacheKeyUrl, normalizeUrl } from '../util/url.js';
@@ -112,7 +112,18 @@ export class RenderQueue extends Resource {
112112
const renderTarget = await RenderTarget.get({ id: cacheKey, select: ['renderInterval', 'sitemapUrl'] });
113113
const renderInterval = renderTarget?.renderInterval;
114114

115-
const nextRenderTime = getNextRenderTime();
115+
// Schedule the next render relative to when THIS one completed (now), not a
116+
// fixed wall-clock time — so renders stay spread across the interval instead of
117+
// realigning into a daily herd, and the cadence self-paces to fleet throughput.
118+
// The per-target renderInterval drives the recurring cadence; fall back to the
119+
// default when a target exists without a valid interval (a bare number check also
120+
// rejects NaN from an arbitrary API PUT).
121+
const interval =
122+
Number.isFinite(renderInterval) && renderInterval > 0 ? renderInterval : config.render.defaultInterval;
123+
// The cached page expires when the next render is due; the swrTtl window then keeps
124+
// it served while the re-render lands, so render latency up to swrTtl never causes
125+
// a cache miss.
126+
const nextRenderTime = currentMinuteMs() + interval;
116127

117128
if (result.content) {
118129
result.headers['x-harper-rendered'] = '1';
@@ -126,11 +137,14 @@ export class RenderQueue extends Resource {
126137
});
127138
}
128139

129-
if (typeof renderInterval === 'number' && renderInterval > 0) {
130-
// Refresh fromSitemap from the live target so it self-corrects if the URL
131-
// has since left its sitemap.
140+
if (renderTarget) {
141+
// A target owns this schedule → recurring. Reschedule relative to completion
142+
// using the resolved interval (so a target lacking an explicit renderInterval
143+
// falls back to the default instead of getting stuck re-claiming every lease
144+
// period). Refresh fromSitemap from the live target so it self-corrects if the
145+
// URL has since left its sitemap.
132146
await RenderSchedule.put(cacheKey, { nextRenderTime, fromSitemap: !!renderTarget.sitemapUrl });
133-
} else if (!renderTarget) {
147+
} else {
134148
// No target owns this schedule: it's a one-off (render-now) or an orphaned
135149
// row. Nothing sets a recurring cadence, so drop the schedule instead of
136150
// leaving it to be re-claimed when the lease expires.
@@ -169,6 +183,11 @@ export class RenderQueue extends Resource {
169183
return [];
170184
}
171185

186+
// Bound the batch server-side so no consumer can over-claim: a large grant means a
187+
// large lease-write burst held under this mutex (long lock hold) and lets one worker
188+
// hoard a burst other renderers should share.
189+
limit = Math.min(Math.max(1, limit | 0), config.queue.maxClaimLimit);
190+
172191
const currentMinute = currentMinuteMs();
173192
// Fully drain the search (read) transaction into memory BEFORE issuing any
174193
// RenderSchedule.put leases. Interleaving the puts inside the `for await` keeps the

packages/plugin/src/resources/RenderTarget.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { config } from '../config.js';
22
import { CacheKey } from '../util/cacheKey.js';
33
import { getResidencyByUrl } from '../util/residency.js';
4-
import { currentMinuteMs, getNextRenderTime } from '../util/time.js';
4+
import { currentMinuteMs, getInitialRenderTime } from '../util/time.js';
55
import { setImmediate } from 'node:timers/promises';
66

77
const {
@@ -28,8 +28,20 @@ export class RenderTarget extends databases.render_service.RenderTarget {
2828
// schedule — is benign and self-heals on the next sitemap refresh / revalidate.
2929
const result = await super.put({ ...CacheKey.parse(cacheKey), ...data }, target);
3030

31+
// Absent a valid explicit time, jitter the first render across the interval (keyed
32+
// off the cacheKey) so bulk-created targets don't all come due at once. RenderTarget
33+
// is API-exposed, so validate the numbers (reject negatives / NaN / non-numbers)
34+
// rather than trust the payload.
35+
const interval =
36+
Number.isFinite(data.renderInterval) && data.renderInterval > 0
37+
? data.renderInterval
38+
: config.render.defaultInterval;
39+
3140
await RenderSchedule.put(cacheKey, {
32-
nextRenderTime: typeof nextRenderTime === 'number' ? nextRenderTime : getNextRenderTime(),
41+
nextRenderTime:
42+
Number.isFinite(nextRenderTime) && nextRenderTime > 0
43+
? nextRenderTime
44+
: getInitialRenderTime(cacheKey, interval),
3345
fromSitemap: !!data.sitemapUrl,
3446
});
3547

0 commit comments

Comments
 (0)