Skip to content

Fold + and %20 in the cache key (config-gated) — collapse duplicate facet spellings instead of retiring them #92

Description

@harper-joseph

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 U we accept, origin(N(U)) is the same resource as origin(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 — %26 and %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 %2B folding. 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.

Refs #84, #90, #93, #94

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions