Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harperfast/prerender",
"version": "0.68.0",
"version": "0.70.0",
"type": "module",
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
"license": "Apache-2.0",
Expand Down
40 changes: 27 additions & 13 deletions packages/plugin/src/util/changeProbe.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { fnv1a32 } from './hash.js';
import { epochMsOf, currentMinuteMs, getNextTimeOfDay, DAY, MINUTE, SECOND } from './time.js';
import { getResidencyByUrl } from './residency.js';
import { resolveEffectiveInterval } from './routeClass.js';
import { writeSchedules } from './renderSchedule.js';
import { writeSchedule } from './renderSchedule.js';
import { recordInvalidation, isScopeResolvable, resolveInvalidation } from './invalidation.js';
import { dispatcherFor, configuredStagingIp } from './upstream.js';
import { cacheKeysOf } from '../resources/Target.js';
Expand Down Expand Up @@ -84,7 +84,7 @@ const probeStateTable = () => databases.probe_state.ProbeState;
const YIELD_EVERY = 200;

// What every probe read of the registry projects — what matching and the trigger need
// (writeSchedules wants fromSitemap + the cadence). The stored signature is NOT here: it lives
// (writeSchedule wants fromSitemap + the cadence). The stored signature is NOT here: it lives
// in the node-local ProbeState table (see schema.graphql), read per probed URL.
const TARGET_SELECT = ['url', 'sitemapUrl', 'renderInterval', 'demandInterval', 'state'];

Expand Down Expand Up @@ -282,9 +282,26 @@ const probeOnce = async (rule, url) => {
};

/**
* Re-render one changed URL now: hard-expire the cached pages and file every device row at the
* current minute. Owner-scoped by the sweep, so the funnel's floor lowering covers these keys on
* the node whose claim scan reads them.
* Re-render one changed URL now: hard-expire the cached pages and file the URL's schedule row at
* the current minute. Owner-scoped by the sweep, so the funnel's floor lowering covers these keys
* on the node whose claim scan reads them.
*
* ONE SCHEDULE ROW, KEYED BY THE URL — not one per device. The PAGES are still expired per device,
* because page content genuinely is per device; the SCHEDULE is not. This file was missed by the
* v0.66.0 move to URL-keyed jobs (that change touched RenderQueue, Target and renderSchedule), and
* the per-device rows it kept writing cost more than a stale spelling:
*
* - `claim` gives a device-keyed row `deviceTypes: [thatDevice]` and a URL row the full default
* set, so two device rows became TWO ONE-DEVICE JOBS instead of one two-device job — and each
* job fetches the origin document for itself, which defeats the document reuse the browser
* gained in the same release. Probe-triggered renders were doubling origin document load.
* - The two jobs are claimed and rendered at different times, so desktop and mobile land
* different `lastCached` values: exactly the split-pair state URL-keyed jobs removed.
* - Two schedule writes per trigger instead of one, on a path that runs in-line with the sweep.
*
* Every other writer already files the URL row (`Target.put`, `Target.revalidate`, and `renderNow`
* for a default device); a per-device row remains legitimate only for a deliberate one-device
* render. Rows written before this fix convert themselves the first time they render.
*
* The expiry is backdated PAST the stale-while-revalidate window, not set to now. A trip means
* the page's probed fields (price/availability) provably changed, so one more serve is a served
Expand All @@ -307,14 +324,11 @@ export const triggerRevalidate = async (row) => {
// hours, and a stale minute files rows below other nodes' claim-floor guard bands (the
// Target.revalidate lesson).
const nextRenderTime = currentMinuteMs();
await writeSchedules(
keys.map((cacheKey) => ({
cacheKey,
nextRenderTime,
fromSitemap: !!row.sitemapUrl,
effectiveInterval: resolveEffectiveInterval(row.url, row),
}))
);
await writeSchedule(row.url, {
nextRenderTime,
fromSitemap: !!row.sitemapUrl,
effectiveInterval: resolveEffectiveInterval(row.url, row),
});
};

// ProbeState is node-local (`replicate: false`) and only ever touched by the owner's probe —
Expand Down
28 changes: 28 additions & 0 deletions packages/plugin/test/changeProbe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,34 @@ test('a trip hard-expires the page PAST the swr window — a known-wrong page is
}
});

test('a trip files ONE schedule row, keyed by the URL — not one per device', async () => {
// The v0.66.0 regression this pins. A device-keyed row gets `deviceTypes: [thatDevice]` from
// `claim`, so two of them are two ONE-DEVICE jobs: each fetches the origin document for
// itself (defeating document reuse) and they render at different times (splitting the pair's
// lastCached). One URL row is one job that renders every default device together.
const scheduled = [];
globalThis.databases.render_schedule.RenderSchedule = class extends FakeTable {
static async put(id, fields) {
scheduled.push({ id, ...fields });
}
};
globalThis.databases.page_cache.PrerenderedPage = class extends FakeTable {
static async get() {
return null; // no cached page: isolate the schedule write
}
};

const url = 'https://example.com/product/prd-a/';
await changeProbe.triggerRevalidate(row(url));

assert.equal(scheduled.length, 1, `expected exactly one schedule row, got ${JSON.stringify(scheduled)}`);
assert.equal(scheduled[0].id, url, 'the row must be keyed by the URL, with no device suffix');
assert.ok(!String(scheduled[0].id).includes('|'), 'a "|" in the key means a per-device row');
// `put` REPLACES the record, so both of these must be explicit or the funnel throws.
assert.equal(typeof scheduled[0].fromSitemap, 'boolean');
assert.ok(Number.isFinite(scheduled[0].effectiveInterval));
});

/** A rule whose extract maps index 2 -> price and index 3 -> availability, with pageCheck on. */
const PAGECHECK_RULES = [
{
Expand Down