Repository navigation
feat(plugin): every fetch from the origin resolves the entity's canonical, and the entity serve trusts the newest word; v0.102.0 - #243
Conversation
…ity-resolve # Conflicts: # package-lock.json # packages/plugin/METRICS.md # packages/plugin/README.md # packages/plugin/package.json # packages/plugin/src/metrics.js # packages/plugin/src/util/changeProbe.js # packages/plugin/test/changeProbe.test.js
…ity-resolve # Conflicts: # packages/plugin/src/util/changeProbe.js
…ical, and the entity serve trusts the newest word; v0.102.0 The registry now learns from renders (a canonical verdict's declared canonical included), serve-time checks and proxied origin documents as well as the nightly probe, and adopts a canonical no target holds from any of them. The entity serve answers spellings suppressed as canonical verdicts, refuses a page the registry has since heard re-slugged (moved), lets the registry break a tie, refuses query strings, and times each evaluation. Console 0.26.0 charts the registry and the new outcomes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the 'Entity Serve' and 'Entity Registry' features, allowing the prerender plugin to serve a cached canonical render for variant spellings of an entity and track canonical URLs observed from the origin. The changes span the plugin's request handling, metrics, admin API, change probe, and the console's traffic view. Feedback on the implementation highlights a race condition in the hourly adoption budget reset, a potential type coercion bug in safeDecodeURI for non-string values, and a false-negative ambiguity check in readEntityRows when the number of rows exactly matches the query limit.
| const observed = Atomics.load(cell, B_HOUR); | ||
| if (hour > observed && Atomics.compareExchange(cell, B_HOUR, observed, hour) === observed) { | ||
| Atomics.store(cell, B_COUNT, 0); | ||
| } |
There was a problem hiding this comment.
There is a brief race condition at the boundary of a new hour. If thread A successfully updates B_HOUR via compareExchange but has not yet executed Atomics.store(cell, B_COUNT, 0), another thread B can read the new hour, skip the reset block, and read the stale high B_COUNT from the previous hour, leading to spurious capped rejections. Resetting B_COUNT to 0 before updating B_HOUR ensures that any thread observing the new hour is guaranteed to see the reset count.
| const observed = Atomics.load(cell, B_HOUR); | |
| if (hour > observed && Atomics.compareExchange(cell, B_HOUR, observed, hour) === observed) { | |
| Atomics.store(cell, B_COUNT, 0); | |
| } | |
| if (hour > observed) { | |
| Atomics.store(cell, B_COUNT, 0); | |
| Atomics.compareExchange(cell, B_HOUR, observed, hour); | |
| } |
There was a problem hiding this comment.
Fixed in #241 (789375c), merged here. The budget moved to util/hourlyBudget.js when it gained a dry-run lane. Each lane is now one 64-bit cell holding hour << 32 | count, so rolling the hour and zeroing the count is a single compare-and-swap and no thread can see one without the other. Resetting the count before the hour (the suggestion) would only move the race: a second thread could zero a count the new hour had already spent. A thread carrying an older hour is now refused instead of rolling the cell back. Tested across four real worker threads released together from an Atomics.wait gate: one hour grants exactly its limit, and an hour rolled under contention grants the next hour its full limit. With the CAS replaced by a plain store, the tests fail.
| const safeDecodeURI = (value) => { | ||
| try { | ||
| return decodeURI(value); | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; |
There was a problem hiding this comment.
If value is not a string (e.g., null or undefined), decodeURI will implicitly coerce it to a string (returning "null" or "undefined"), which can cause sameDocument(null, "null") to incorrectly evaluate to true. Adding a type check ensures robust and strictly correct behavior.
| const safeDecodeURI = (value) => { | |
| try { | |
| return decodeURI(value); | |
| } catch { | |
| return null; | |
| } | |
| }; | |
| const safeDecodeURI = (value) => { | |
| if (typeof value !== 'string') return null; | |
| try { | |
| return decodeURI(value); | |
| } catch { | |
| return null; | |
| } | |
| }; |
References
- Guard exported functions against invalid inputs (e.g., non-string types) to ensure the robustness of the module's public API.
| export const readEntityRows = async ({ table, prefix, url, limit = ENTITY_READ_LIMIT }) => { | ||
| let own = null; | ||
| let read = 0; | ||
| let unreadable = false; | ||
| let ended = false; | ||
| const keys = []; | ||
| for await (const row of table.search( | ||
| { | ||
| conditions: [{ attribute: 'url', comparator: 'greater_than_equal', value: prefix }], | ||
| sort: { attribute: 'url' }, | ||
| select: [...ENTITY_ROW_SELECT], | ||
| limit, | ||
| }, | ||
| { replicateFrom: false } | ||
| )) { | ||
| read++; | ||
| const key = row?.url; | ||
| if (typeof key !== 'string') { | ||
| unreadable = true; | ||
| continue; | ||
| } | ||
| // Ascending order: the first key outside the prefix proves every later key is outside it too. | ||
| if (!key.startsWith(prefix)) { | ||
| ended = true; | ||
| break; | ||
| } | ||
| if (key === url) own = { state: row.state ?? null, suppressedReason: row.suppressedReason ?? null }; | ||
| else if (inRotation(row)) keys.push(key); | ||
| } | ||
| return { own, inRotation: keys, complete: !unreadable && (ended || read < limit) }; | ||
| }; |
There was a problem hiding this comment.
When querying a database with a limit, using a 'limit + 1' pattern is a standard way to determine if there are more results beyond the limit. Currently, if there are exactly limit rows under the prefix, complete is marked as false and the request falls through as AMBIGUOUS, even if those were the only rows. Querying with limit + 1 and checking if read <= limit avoids this false negative.
export const readEntityRows = async ({ table, prefix, url, limit = ENTITY_READ_LIMIT }) => {
let own = null;
let read = 0;
let unreadable = false;
let ended = false;
const keys = [];
for await (const row of table.search(
{
conditions: [{ attribute: 'url', comparator: 'greater_than_equal', value: prefix }],
sort: { attribute: 'url' },
select: [...ENTITY_ROW_SELECT],
limit: limit + 1,
},
{ replicateFrom: false }
)) {
read++;
const key = row?.url;
if (typeof key !== 'string') {
unreadable = true;
continue;
}
if (!key.startsWith(prefix)) {
ended = true;
break;
}
if (read > limit) {
break;
}
if (key === url) own = { state: row.state ?? null, suppressedReason: row.suppressedReason ?? null };
else if (inRotation(row)) keys.push(key);
}
return { own, inRotation: keys, complete: !unreadable && (ended || read <= limit) };
};There was a problem hiding this comment.
Fixed in #240 (d745752), where readEntityRows lives, and merged here. The read asks for limit + 1 rows and is complete when it ended on a key outside the prefix, or read at most limit. Tested: an entity with exactly 8 rows is served at the end of the table and with another product after it; one more row under the prefix is ambiguous. With the old read < limit test, it fails.
…ity-resolve # Conflicts: # packages/plugin/METRICS.md # packages/plugin/src/metrics.js
…nly with the page its verdict named, confirmations allow for the read-to-store gap, crawler misses create no registry rows, no query strings, the tap obeys the master switch and lets go of an unsent body, the stored path falls back to declaredCanonical; v0.102.0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ity-resolve # Conflicts: # packages/plugin/src/util/entityServe.js # packages/plugin/test/entityServe.test.js
…ity-resolve # Conflicts: # packages/plugin/test/entity.test.js
…ity-resolve # Conflicts: # packages/plugin/METRICS.md # packages/plugin/README.md # packages/plugin/src/schemas/schema.graphql
…it is on by default; v0.102.0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…er from the origin while it was being served, by detector and field, with an upper bound on how long it was out; v0.102.0 One emit per detection, from data the detector already holds: no per-serve work and no extra reads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # package-lock.json # packages/plugin/METRICS.md # packages/plugin/README.md # packages/plugin/package.json # packages/plugin/src/metrics.js
Merged after #240 and #241, which it builds on. Needs browser 1.40.0 (#239) for one of its observers.
On a site where every spelling of a product is one document, every fetch from the origin says where the product lives now. This PR makes every such fetch tell the entity registry, adopts a canonical no target holds from any of them, and makes the entity serve trust the newest observation. The result is more misses answered from a rendered page, and a re-slug handled within minutes instead of a day.
Why
Measured on one production deployment (2026-10-01 and 10-02, read-only):
canonical-mismatch. #240's entity serve refused every one of them ashas-target.Before this PR, only the nightly probe and stored renders wrote the registry. A daytime re-slug left the old page "confirmed" until its next check (up to 12h on the measured deployment). Meanwhile the entity serve handed that page, which names the old slug, to other spellings: gap 1 in the review of #240/#241. The render that first saw the re-slug was a
canonical-mismatchverdict, which carried no canonical back: gap 2.For the human reviewer
probe(the nightly pass),render(a stored page's canonical, and the canonical a canonical verdict declares),check(a serve-time check, from the endpoint's slot or the origin document) andorigin(a document proxied from the origin on anentityServeroute).originobserver only runs onentityServeroutes for exactly that reason.originobservation moves a row the registry already holds (from the probe or a render), and an entity with no row readsuntracked.foreign, and a URL with one is not tapped.entities.adopt.maxPerHourper node across every worker thread, and byretryAfterper entity. It is dry run by default.Target.suppressedCanonical, written from browser 1.40.0'sdeclaredCanonical. Otherwise it falls through asmoved. This does not depend on the registry.has-target.moved: when the registry names another document, first heard after the candidate was last confirmed (rendered, or checked and agreed), the candidate predates a re-slug and is not served. An unreadable instant vetoes.CONFIRMATION_READ_SLACK_MS(2 min) earlier, and an observation in that gap vetoes.ambiguous, as before.has-query: a spelling with a query string falls through. A route that keys a param says it can change the document.ingress.entityServe.enabled, the one switch an operator stops the entity serve's bot-path work with.What
declaredCanonical, resolved as the verdict resolved it, without its fragment. It is absent on every other result, so an older plugin never sees the key.util/entity.js:observeRenderedCanonical(url, canonical, atMs)now resolves: it records and adopts.canonical_adoptcarries the observer as its context.resources/RenderQueue.js:pageFacts.canonical, falling back to itsdeclaredCanonical. A sitemap target is stored even when its canonical moved, and its page-record extraction can fail.declaredCanonicaltoTarget.suppress, which records it assuppressedCanonical, and then resolves it, observed at the result time less the job's renders.util/serveCheck.js:runCheckresolves it, whatever the verdict, observed when the origin was asked. A throw costs the check nothing.canonicalFieldOfis now exported fromchangeProbe.js.util/originCanonical.js(new):tapOriginCanonicalandtapsOriginCanonical, wired intobot_request.js's miss proxy before the raw-cache capture. The tap applies only to a GET with a 200 HTML response, on anentityServeroute, with the registry on.util/entityServe.js:has-queryandmoved.suppressedReason, and a spelling suppressed as a canonical verdict proceeds.Entitypoint read, made only once there is a page to serve and only with the registry on.prerender_ops/entity_serve_msper evaluation.prerender_ops/served_wrong(new, 32c1ec1): every cached copy found to differ from the origin while it was being served, at the moment a detector found it.check,check-raw,sweep,render-check,negative-recheck,negative-fetch. The canary is left out: its cohort repeats every half hour.pageCheckfield label,claim, or404.README.md,METRICS.mdand the metrics catalog are updated.movedandhas-queryand shows the cost per evaluation (median, ≈p95).Verification
test/entityServe.test.js, end to end throughhandleBotRequest:canonical-mismatchorcanonical-variantis served. One in rotation, or suppressed asnoindex,http-goneor with no reason, ishas-target.moved: the registry's word after the render vetoes, and before it does not. A spelling of the page itself, in anydecodeURI-equal form, does not veto. An unreadable instant vetoes. No row, or no canonical, does not.has-query, andentity_serve_mson every outcome.origin) and adopts the canonical its document declares, while the crawler gets every byte the origin sent. There is no tap with the registry off, off an opted-in route, or for a HEAD.test/originCanonical.test.js(new, 7):test/serveCheck.test.js:test/renderQueueVariants.test.js:renderand files the new canonical due now and urgent.test/entity.test.js: a render, a check and an origin observation each adopt, and both series name the observer.Console: the newer fall-throughs, the cost stat, the registry panel's dry-run read and its "off" state.
Mutation checks. Each of these fails at least one test:
has-target;canonical_adopt.Review fixes, each with tests:
untracked, and a query canonical asforeign;suppressedCanonical, and the verdict's read instant.Each of those 10 mutations is caught too.
served_wrong, each detection point with a test that fails without it:helddisagreements report nothing.Plugin 2018/2018, console 529/529, browser 428/428; lint and format clean. With the registry on by default, the tap tests now switch it off explicitly; a tap that ignored the switch fails them.
A flaky test, not this PR's:
documentFactsOfYielding: the same result as documentFactsOf, with the event loop let in between rounds(test/documentFacts.test.js, on main since 0.98.0) failed about once in a dozen full runs, on this branch and on #241's. It asserts the scan yielded by countingsetInterval(0)ticks, and a 0 ms interval is clamped to 1 ms, so a gzip scan that finishes inside a millisecond records none. This PR's timing-sensitive files ran 15 times under parallel load with no failure.Review: a fresh-context Claude subagent reviewed 5cbabf7, #241's rework and #239's
declaredCanonicaladversarially. It found no bot-path throw, hang or slot leak, and no corruption of the response bytes. What it did find:suppressedCanonicaland the rule above (cb1289d).foreignfor a query canonical, no tap for a query URL,untracked.retryAfteris per entity.declaredCanonicalwas posted for noindex pages, any scheme and any length. Fixed in fix(browser): CSS url() self-references are fragment-only, and a canonical verdict reports the canonical it declared; v1.40.0 #239 (a4f96b7): canonical verdicts only, http(s), keyable.ingress.entityServe.enabled.Plus doc corrections:
movedcan persist under the adoption dry run; the schema and docstrings now list every observer; the console says "Canonical moves", since a move is not always a re-slug. No outside-model review is available, so cross-model coverage is degraded.Gemini's code-assist bot left three comments here, each on code from an earlier PR, so each is fixed there and merged in. All three threads are answered:
sameDocumentwith a non-string, also feat(plugin): an Entity registry holding each product's current canonical, and the change probe adopts a canonical no target holds; v0.101.0 #241;ambiguous: #240 d745752.Live renders (#239, measured on one production origin) with 1.38.0 and 1.40.0, three product pages on two devices: 0 unexpected diffs; the stars fill at another URL; a misspelled product URL reports its real canonical.
Deploy notes
The order matters:
renderobserver's canonical verdicts and for the star fills at other spellings. Snapshots rendered before it keep their absolute self-references until their next render.PageCheckgains a column (#240);Entityis a new table (#241);TargetgainssuppressedCanonical, written from the next verdict of each spelling. Older suppressions are answered only when the canonical's page is confirmed after them.entities.adopt.dryRun: false.ingress.entityGate.dryRun: false). Readwould-serve,movedandhas-targeton the Entity serve panel, andentity_serve_ms.ingress.entityServe.dryRun: false.Steps 3 and 4 come before step 6 because, with the gate armed, a re-slugged unlisted product's new canonical is filed only by adoption. And it is the registry that tells the entity serve the old page has moved.
Not in this PR
Entityinstead of every target, through a host-supplied entity source: #242, and Entity table: one row per product with its current canonical — the probe checks entities, a re-slug becomes a retarget #166 phase 2.Release: plugin 0.102.0 and console 0.26.0 (0.99.0 is #238's, 0.100.0 and console 0.25.0 #240's, 0.101.0 #241's). Config: no new options; the existing ones gain readers. Schema:
Target.suppressedCanonical.🤖 Generated with Claude Code