Repository navigation
Description
Problem
Discovery mints a second cache key for pages we already have, purely by re-spelling a space inside a
faceted query value. Measured from a 30-minute read_audit_log window on render_service.Target
(615 distinct discovery-created URLs, 93% catalog): 4.7% of discovered catalog URLs write a space
as + — ?CN=Color:Blue+Silhouette:Bath+Rugs where the declared spelling is Bath%20Rugs. That
is ~1,300/day, and the origin serves both spellings the same page.
#90 stops them becoming immortal targets, by reading the page's canonical against the cache key: the
re-spelling is a canonical-variant verdict and gets suppressed. But that is retirement, not
identity — it costs a render per URL, and since only 5 of 25 sampled canonical twins are in a
sitemap, usually neither spelling ends up prerendered and bots take the origin proxy for that page.
Collapsing the two spellings into one key is the better end state: the duplicate never exists, no
render is spent, and the crawler's +-spelled URL serves the cached page instead of falling
through to the origin.
The standard a normalization has to meet
We serve on behalf of the origin. We fetch and render the representative N(U) and then serve its
bytes for every U that maps to it, so the requirement is not "these two URLs look equivalent" but:
for every URL
Uwe accept,origin(N(U))is the same resource asorigin(U).
Every rule in canonicalizeUrl is therefore a claim about the origin's URL semantics, and the claims
worth making are the ones settled at the parser boundary — where the decoder erases a distinction
before any application logic runs. Those hold for every URL, forever, and are established by one
discriminating probe per allowlisted query parameter, never by surveying pages. A claim that is
per-resource instead (does this path serve the same thing with a trailing slash? — #93) cannot be
established that way at all and should not be assumed.
This fold is a parser-boundary claim: the origin form-decodes the query. Under form-decoding +
and %20 are the same character before the application sees anything, so no page can distinguish
them — which is what makes the fold universally safe rather than safe-on-the-sample.
Why + ≡ %20 is safe to assert here
The origin form-decodes its query. The discriminator, re-run 2026-08-13 on CN — the only
parameter any route allowlists, and therefore the only one that reaches a cache key:
| request | products | canonical it declares |
|---|---|---|
?CN=Brand:BLACK%2BDECKER (the one %2B loc in the whole sitemap) |
53 | self — keeps %2B |
?CN=Brand:BLACK+DECKER |
0 | ?CN=Brand:BLACK%20DECKER |
?CN=Brand:BLACK%20DECKER |
0 | ?CN=Brand:BLACK%20DECKER |
A raw + comes back canonicalized as %20 and returns that (empty) page, while %2B returns the
brand's 53 products — the origin read the raw + as a space. Under form-decoding + and %20
are the same character in every position, and %2B is the literal plus.
Confirmed from the other direction: all four spellings of a two-space multi-facet value return one
page (120 products) under one canonical — Color:Blue%20Silhouette:Bath%20Rugs,
Color:Blue+Silhouette:Bath+Rugs, and both mixings. So the two spellings are interchangeable in
separator and value position alike.
The origin's own canonicals mix both spellings inside a single URL — it emits + for a facet
separator and %20 inside a value, e.g. ?CN=Color:Blue+Silhouette:Bath%20Rugs. Unfolded, our key
and the origin's canonical for the same page therefore disagree by construction on every
multi-facet catalog URL. Folded, they agree. This was not part of the original argument for the fold
and is arguably the stronger one.
Resolution
cacheKey.plusIsSpace shipped in plugin v0.45.0 / browser v1.17.0 (#94): query-scoped, %2B
never folded, folded before the sort so two spellings of a multi-param query agree, mirrored in the
browser's cacheKey config, defaulted off so it ships inert.
Direction: %20 → +, the cheaper re-key. Counted over all six catalog sitemaps (38,013 locs,
2026-08-13):
| normalization | catalog locs whose key changes |
|---|---|
%20 → + |
20,220 (53%) |
+ → %20 |
36,835 (97%) |
PDP routes drop their query entirely, so the whole product corpus is untouched either way.
This deployment is being set to true — paired PRs, to be deployed in one window:
- component: HarperFast/kohls-pr#69
- render fleet: HarperFast/render-service#72
Both sides must move together. The browser's mirrored canonicalizeUrl only ever compares two
URLs it normalized itself, so a comparison-only option (like cacheKey.decodeReserved) does not need
mirroring there. A fold is the opposite — it changes which URLs are the same key. If the plugin
folds %20→+ and the renderer does not, a job URL keyed under the folded spelling
(…Bath+Rugs) is compared against the page's canonical (…Bath%20Rugs), reads as a
canonical-variant, and gets suppressed — enabling the fold alone would retire the entire folded
catalog corpus.
Migration — this is the real cost
Re-keying orphans the cached page under the old key: 20,220 of 38,013 catalog locs × 2 device
variants ≈ 40,400 cached pages go cold and re-render — about 1.6h of fleet spare capacity at the
measured 71,289 renders/hr ceiling (the config sits at ~63%). PDPs, 94.7% of the corpus, do not move.
- Warm deliberately afterwards (
POST /sitemaps/<url> {revalidate: true}) rather than waiting for
the jittered natural cadence. - The loser keys need a sweep, and this is the part that is not self-healing. A sitemap refresh
upserts the target under the new folded key and leaves the%20-keyed target in place, still on
its 6h schedule — ~20,220 × 2 orphan targets, ~161k renders/day of pure waste (~15% of fleet
output). Their renders are not incorrect (the renderer posts back the raw page URL, which the
plugin re-canonicalizes onto the live folded key, so each folded page simply renders twice per
interval), but they are wasted. - The precise sweep predicate is "the stored url is not a fixed point of the current
canonicalizeUrl" — the general orphan test after any cache-key rule change, not a%20
regex. Worth building as a reusable operation rather than a one-off cleanup.
Interaction with #90
They compose, and #90 landed first — it is comparison-only and re-keys nothing. Once the fold is on,
a +-for-space URL is the canonical key, so it never reaches a canonical verdict at all and
canonical-variant volume for this class should fall to ~zero. That makes the reason slug the
monitor for whether the fold is doing its job.
Not in scope
- No blanket decode. 15% of catalog locs carry a structural escape —
%26and%2F. Decoding
those reparses the URL (Product:Coats%20%26%20Jackets→ an&that splits the query), and since
the canonical half is also the origin-fetch URL, it would mis-key and mis-fetch. - No
%2Bfolding. Separator vs value position are different meanings; the BLACK+DECKER pair
above is the counterexample.
Note for the neighbouring sort rule
While editing the query-normalization block in both canonicalizeUrl copies: param sorting is
invariable for distinct keys (every major CDN ships it as a built-in cache-key normalization,
every mainstream server parses the query into a map, Google folds reorderings), and is a claim only
for repeated instances of one key — ?f=b&f=a sorts to ?f=a&f=b, which an origin honoring
repetition order would answer differently. Not a shape this deployment emits, so it needs no config,
just a comment so it is not re-derived. Repetition inside a value
(CN=…ChildAgeRange:Little+Kids+ChildAgeRange:Big+Kids) is untouched by a segment-level sort, which
is correct: that ordering is the site's grammar, not URL structure.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
harper-joseph commentedon Aug 12, 2026
Note for whoever implements this — while editing the query-normalization block in both
canonicalizeUrlcopies, record the one residual on the neighbouring sort rule: param sorting is invariable for distinct keys (every major CDN ships it as a built-in cache-key normalization, every mainstream server parses the query into a map, Google folds reorderings), and is a claim only for repeated instances of one key —?f=b&f=asorts to?f=a&f=b, which an origin honoring repetition order would answer differently. Not a shape this deployment emits, so it needs no config, just a comment so it is not re-derived. Repetition inside a value (CN=…ChildAgeRange:Little+Kids+ChildAgeRange:Big+Kids) is untouched by a segment-level sort, which is correct: that ordering is the site's grammar, not URL structure.harper-joseph commentedon Aug 13, 2026
Hard prerequisite discovered while implementing #94: the fold must ship in BOTH packages, deployed together.
The browser's mirrored
canonicalizeUrlonly ever compares two URLs it normalized itself, so a comparison-only option (likecacheKey.decodeReservedin #94) does not need mirroring there. A fold is the opposite — it changes which URLs are the same key. If the plugin folds%20→+and the browser does not, then a job URL keyed under the folded spelling (…Bath+Rugs) is compared against the page's canonical (…Bath%20Rugs), reads as acanonical-variant, and gets suppressed — i.e. enabling the fold would retire the entire folded catalog corpus. Note it in the config description too, since a browser-side deployment that forgets the option is the failure mode.Also from #94:
cacheKey.decodeReservednow exists as the precedent for how a contingent key rule is expressed (default = current behavior, structural characters refused viaitemEnum), soplusIsSpaceshould follow the same shape and live in the same group.harper-joseph commentedon Aug 13, 2026
Code lands in #94.
cacheKey.plusIsSpaceis implemented in bothcanonicalizeUrlcopies (query-scoped,%2Bnever folded, folded before the sort so two spellings of a multi-param query agree), mirrored in the browser'scacheKeyconfig with a test pinning the failure mode from the comment above, and defaulted off so it ships inert.What remains here is operational, and is why this issue stays open:
%20→+, the cheaper re-key (20,204 of 37,983 catalog locs vs 36,808).POST /sitemaps/<url> {revalidate: true}.CNand onlyCN.harper-joseph commentedon Aug 13, 2026
Body rewritten against a 2026-08-13 re-measurement. The discriminator reproduces exactly (
%2B→ 53 products keeping%2B; raw+→ 0 products canonicalizing to%20;%20→ the same page), and the recount over the six catalog sitemaps is 20,220 of 38,013 locs (53%) changing key under%20→+, vs 36,835 (97%) the other way.Two things the original argument missed:
+for a facet separator,%20inside a value (?CN=Color:Blue+Silhouette:Bath%20Rugs). Unfolded, our key and the origin's canonical for the same page disagree by construction on every multi-facet catalog URL. That is a stronger argument for the fold than the crawler re-spellings it was proposed for.%20-keyed target in place on its 6h schedule: ~20,220×2 orphan targets ≈ 161k renders/day, ~15% of fleet output. Not incorrect (the renderer posts back the raw page URL, which re-canonicalizes onto the live key, so each page just renders twice per interval) — but it does not decay on its own.The sweep predicate that generalizes: a Target is orphaned iff its stored url is not a fixed point of the current
canonicalizeUrl. That is the right test after any cache-key rule change, not a%20regex, and is probably worth building as a reusable operation.Set to
truein HarperFast/kohls-pr#69 + HarperFast/render-service#72, to deploy in one window with the warm.harper-joseph commentedon Aug 13, 2026
Two corrections, and the migration is done.
The warm instrument —
revalidate: trueis wrongI recommended
POST /sitemaps/<url> {revalidate: true}above. That branch is unconditional (if (revalidate) action = TargetAction.RENDER), so it renders every loc in the sitemap rather than the re-keyed subset, and stampsnextRenderTime: currentMinuteMs()— all due in the same minute. Across the six catalog sitemaps that is 76,026 renders at once, ~1.9× the needed work with the jitter bypassed. TheCREATEbranch directly above it exists precisely to prevent that ("bulk sitemap population must not stampede the queue").A plain refresh is the right instrument. Run 2026-08-13 20:15 UTC against the root sitemap:
That matches the model exactly: 18,745 created ≈ the 20,220 re-keyed locs minus the ~10% that already had a page under the folded key (measured 2 of 20 sampled); 21,386 unlinked = the departed
%20targets; 798,898 unchanged = the product corpus, untouched because PDP routes allowlist no query params. No claim-cap warnings followed — no herd.Orphaned keys are total waste, not duplicate work
I wrote that an orphan's renders "are not incorrect (the renderer posts back the raw page URL, which the plugin re-canonicalizes onto the live folded key, so each folded page simply renders twice per interval)". That is wrong. The claim path writes under the schedule row's own stored key:
and those rows came from
cacheKeysOf(url)=CacheKey.toCacheKey({ url, deviceType })on the target's stored url, verbatim — no re-canonicalization anywhere. So an orphan renders into the old%20key that no request can produce. Consequences:selfand suppression never fires.The sweep predicate is unchanged — a Target is orphaned iff its stored url is not a fixed point of the current
canonicalizeUrl— but the timing advice flips: run it whenever, earlier is strictly better, since orphans contribute nothing to the warm.Both corrections are in HarperFast/kohls-pr#70. Deployed and verified live on all four nodes: the
%20and+spellings now produce one cache key and serve one page.