feat(plugin): one render job per URL — URL-keyed schedule, one result schedules every device (v0.66.0) - #156
feat(plugin): one render job per URL — URL-keyed schedule, one result schedules every device (v0.66.0)#156harper-joseph wants to merge 3 commits into
Conversation
…ne result schedules every device; v0.66.0
RenderSchedule now holds ONE ROW PER URL (was one per `<url>|<device>`
cacheKey). A claim hands the renderer one job naming every device in
`deviceTypes.default`; a browser >= 1.23.0 renders them in turn and posts one
result `{ id, url, deviceTypes, variants: [...] }`, and the plugin makes one
scheduling decision for the URL. Pages stay per device.
Why: two rows per URL drifted apart through every per-device path (render-now,
revalidate, reconcile's fresh jitter, each retry lane), so "a split pair" was a
normal production state — with the per-URL `strikes` counter fed twice per
cycle and reset by whichever device happened to succeed. One result per URL is
one strike per failed cycle, one page claim for the probe, an exact shared
verification basis, half the schedule rows and half the reschedule writes.
- `CacheKey.urlOf/deviceOf/isCacheKey` are the only readers of a schedule key;
residency hashes the URL either way. The primary-key attribute keeps its name
(`cacheKey`): Harper refuses to rename a populated table's primary key.
- NO MIGRATION SWEEP. A pre-0.66.0 per-device row converts the first time it
renders: its job renders exactly its device, its result writes the URL row and
deletes the device row. Siblings fold within a cycle at no extra renders, and
every reader tolerates both key shapes meanwhile.
- `processDecodedJobResult` reasons about a LIST of variants (a legacy flat
result becomes a one-variant list): a redirect on any device decides the URL,
a genuine non-indexable verdict on any device suppresses it, rendered pages
are stored per device, a failed or not-attempted device puts the URL in the
retry lanes with the good pages kept, all-rendered reschedules once.
- `renderNow` for a device outside the default set still writes a per-device
row: a one-off render beside the rotation, retired without touching the URL
row. For a default device it now pulls the URL row forward.
- `claim` returns `deviceTypes` plus `deviceType` (the first) for renderers
that predate the list; `job_result` accepts both envelopes and refuses a
variants body whose contentLengths do not add up.
- Admin: `POST /prerender_admin/schedule` takes `{ url }` or `{ cacheKey }`,
the schedule read falls back to the per-device row, `revalidate` writes the
URL row. Console unchanged.
Deploy the render fleet (browser 1.23.0) first: an older renderer given a URL
job renders only its first device — degraded, not broken.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request transitions the prerender scheduling queue from a per-device model to a per-URL model (v0.66.0), consolidating multiple device variants into a single job per URL to keep device snapshots aligned. It includes on-the-fly conversion of legacy per-device schedule rows during their first render, updates to metrics, administration endpoints, and extensive test coverage. Feedback suggests validating and rejecting unusable configuration overrides at the entry point where configuration is parsed or applied, rather than adding defensive guards or fallbacks (such as config.deviceTypes.default[0]) in downstream utility functions.
| const deviceKey = CacheKey.isCacheKey(urlOrCacheKey) | ||
| ? urlOrCacheKey | ||
| : CacheKey.toCacheKey({ url, deviceType: config.deviceTypes.default[0] }); |
There was a problem hiding this comment.
Avoid adding defensive guards or fallbacks for config.deviceTypes.default in downstream utility functions. Instead, validate and reject unusable or structural configuration overrides at the entry point where the configuration is parsed or applied, restoring them to safe defaults.
References
- Validate and reject unusable or structural configuration overrides at the entry point (where configuration is parsed or applied) by restoring them to safe defaults, rather than adding defensive guards in downstream utility functions.
There was a problem hiding this comment.
Agreed, and done in f9b0fa7: deviceTypes.default is now nonEmpty in the schema, so an emptied list is refused where config is applied (default kept — same mechanism as cacheKey.delimiter), and the downstream fallback in RenderQueue.js is gone. That also closes a related hole: claim and describeJob's fold rule read the same list, and a fallback in only one of them let them disagree. readScheduleRowLocal here reads config.deviceTypes.default[0] for the fallback per-device key, which is now guaranteed to exist. Tests: config.test.js (rejection at apply) and renderQueueVariants.test.js.
…s + auto-review) - "Failed" is the complement of rendered, not a list of failure shapes: an outcome this code does not know, or a `redirected` whose destination re-keyed to the SAME cache key, now takes the retry lanes instead of falling through to the all-rendered branch and rescheduling a URL as a success that stored nothing. A result naming no device at all is refused with a 400. - `retireSource`: after `Target.delete` the row is only known gone when the job FOLDS (URL row or default-device row); a non-default one-device row (`|tablet`) whose URL turned out to redirect is now retired too, instead of sitting at its current-minute due time and being re-granted on every claim pass forever. - A refiled result with a failed sibling no longer takes the retry lanes: the source row is gone, so holding its lease pinned the claim floor for nothing and the failure was charged to the destination's target. - Suppression that deletes at maxStrikes marks the folding row gone, so it is not deleted twice (Harper writes a tombstone for a delete of an absent key). - `deviceTypes.default` is `nonEmpty` in the schema: an emptied list is refused at apply time and the default kept, instead of a downstream fallback that `claim` and the fold rule could disagree on (auto-review). - `CacheKey.isCacheKey/urlOf/deviceOf` key on a SUPPORTED device tail rather than "contains the delimiter", so a configurable delimiter that can occur inside a URL cannot misread a URL row. - README: bulk revalidate before the corpus has converted costs one extra render per leftover device row; render-now on a URL row renders every default device before it posts. configSchema: maxPerMinute arithmetic at one row per URL. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Pushed f9b0fa7 addressing the auto-review note and a fresh-context adversarial pass over the diff (the outside-model review CLIs on this machine are currently non-functional, so that pass is the mitigation). Three real defects were found and fixed, each with a pinned test:
Also: no double delete on suppress→maxStrikes for a folding row; the key-shape test keys on a supported device tail so a configurable delimiter that can appear inside a URL cannot misread a URL row; README carve-outs for bulk revalidate during the conversion cycle and render-now latency; configSchema arithmetic at one row per URL. 1029/1029 plugin tests, lint and format clean (the earlier figure in this comment was posted before one new test's final assertion was corrected in 99b2682 — the destination adopted after a 301 is legitimately claimable). |
… due — assert nothing of the SOURCE is re-granted Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What
A render job is one URL.
RenderSchedulenow holds one row per URL (was one per<url>|<device>cacheKey). A claim hands the renderer one job naming every device indeviceTypes.default; the renderer (browser >= 1.23.0, #155) renders them in turn and posts one result —{ id, url, deviceTypes, variants: [...] }— and the plugin makes one scheduling decision for the URL. Pages are still stored per device (PrerenderedPagestays keyed by<url>|<device>).Depends on #155 being deployed to the render fleet first (see rollout order).
Why
Two independent rows per URL drift apart through every per-device path — render-now and revalidate write one device key on purpose, reconcile repairs a missing row with fresh jitter, each retry lane delays only its own device, and completion-relative rescheduling re-anchors each device to its own finish time. The code calls a split pair "a normal production state", and it costs:
strikescounter, which was fed per device: both devices failing burned the fast-retry lane in one cycle, and one device succeeding reset the counter under the other. One result per URL is one strike per failed cycle.PageVerification.basisAt, the per-row verdicts in the reenqueue accelerator, the probe's per-URL page claim written by whichever device rendered last. They still work; they now cover a rare transitional state instead of the steady state.How
CacheKey.urlOf / deviceOf / isCacheKeyare the only readers of a schedule key;extractUrlon a URL-shaped key would return''. Residency hashes the URL either way, so ownership never moves. The primary-key attribute keeps its namecacheKey: Harper refuses to rename the primary key of a populated table (verified indatabases.ts), so the schema comment says to read it as "the schedule key".processDecodedJobResult, rewritten around a list of variants): a legacy flat result is normalized to a one-variant list, so old and new browsers share one path. Precedence across variants is the precedence one device's result used to impose on the whole URL: a bail-at-nav redirect on any device decides the URL; a genuine non-indexable verdict on any device suppresses it (nothing stored); otherwise every rendered variant's page is stored (onelastCachedfor the pair, so the verification basis is exact), and a failed variant — renderer error, auth/transient status, or a device the browser was asked for and did not post back (not-attempted) — puts the URL in the retry lanes with the good pages kept; all rendered reschedules once. Onerender outcomeemit per result (contract unchanged); onetime_mssample per variant.renderNowfor a device outsidedeviceTypes.defaultwrites a<url>|<device>row — a one-off render of that device, stored and retired without touching the URL row. For a default device, render-now now pulls the URL row forward (every device together) rather than adding a one-device render beside it.claimreturns{ id: <row key>, url, deviceTypes, deviceType: deviceTypes[0], … }(deviceTypefor renderers that predate the list);job_resultaccepts the variants envelope (bodies concatenated, walked bycontentLength, refused with a 400 when the lengths do not add up) or the legacy flat shape.POST /prerender_admin/scheduletakes{ url }or{ cacheKey }; the schedule read tries the URL row then the per-device row and reportsscheduleKey+perDevice;revalidatewrites the URL row. The console needs no change (it reads the same fields and still sendscacheKey).not-attempteddetail;time_msper variant), config descriptions.Rollout order
Render fleet first (browser 1.23.0, #155), then this. A 1.23.0 renderer given a legacy per-device job posts the legacy shape, so it runs correctly against 0.64.x/0.65.x. The reverse is degraded, not broken: a pre-1.23.0 renderer given a URL job renders
deviceType(the first device) and posts flat; this plugin attributes that to the first default device and the other devices go unrendered until the fleet is upgraded (pinned by a test).On the customer cluster the conversion completes within one PDP cycle (96h) with no extra renders;
reconcile(shipped disabled) treats a URL scheduled under a leftover device row as scheduled, so enabling it mid-conversion cannot double-render.Tests
npm run lint,npm run format:check(this repo has no PR CI).test/renderQueueVariants.test.js(20 tests): claim job shape for URL and per-device rows; body framing and its 400; the precedence matrix (all rendered / one failed / both failed = one strike / auth outranks transient / verdict wins / redirect wins / temp redirect / client-side refile); partial results (not-attempted); the per-device → URL row conversion incl. the fast lane (row kept) and slow lane (converted); a non-default one-device row leaving the URL row untouched; a targetless URL row dropped; an older renderer's flat result attributed to the first device.reconcile,renderQueueRedirect,suppressionStatus,targetRevalidate— those files deliberately keep seeding the pre-0.66.0 shape and posting legacy results, so they now also pin the conversion.Version
@harperfast/prerender0.64.1 → 0.66.0 (0.65.0 is reserved by #154). Release tagprerender-v0.66.0. Follow-ups after release: the customer component bump (deploy the render-service bump to 1.23.0 first).🤖 Generated with Claude Code