Skip to content

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

Closed

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

Activity

  1. harper-joseph commented on Aug 12, 2026

    @harper-joseph
    ContributorAuthor

    Note for whoever implements this — while editing the query-normalization block in both canonicalizeUrl copies, 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=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.

  2. harper-joseph commented on Aug 13, 2026

    @harper-joseph
    ContributorAuthor

    Hard prerequisite discovered while implementing #94: the fold must ship in BOTH packages, deployed together.

    The browser's mirrored canonicalizeUrl only ever compares two URLs it normalized itself, so a comparison-only option (like cacheKey.decodeReserved in #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 a canonical-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.decodeReserved now exists as the precedent for how a contingent key rule is expressed (default = current behavior, structural characters refused via itemEnum), so plusIsSpace should follow the same shape and live in the same group.

  3. harper-joseph commented on Aug 13, 2026

    @harper-joseph
    ContributorAuthor

    Code lands in #94. cacheKey.plusIsSpace is implemented in both canonicalizeUrl copies (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 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:

    1. Decide the direction — implemented as %20 → +, the cheaper re-key (20,204 of 37,983 catalog locs vs 36,808).
    2. Schedule the migration: enabling re-keys every affected URL, orphaning its cached page. Deploy plugin + renderer together, then warm deliberately with POST /sitemaps/<url> {revalidate: true}.
    3. Decide the open question on the loser keys — a merged pair leaves the old Target rendering unless it is deleted.
    4. Re-run the form-decoding discriminator per allowlisted parameter first (it is in the option's description); today that is CN and only CN.
  4. harper-joseph commented on Aug 13, 2026

    @harper-joseph
    ContributorAuthor

    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:

    1. The origin's own canonicals mix both spellings inside one URL — + for a facet separator, %20 inside 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.
    2. The loser keys are not self-healing, and the waste is larger than "a cleanup sweep worth costing out" implies. A sitemap refresh upserts under the new folded key and leaves the %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 %20 regex, and is probably worth building as a reusable operation.

    Set to true in HarperFast/kohls-pr#69 + HarperFast/render-service#72, to deploy in one window with the warm.

  5. harper-joseph commented on Aug 13, 2026

    @harper-joseph
    ContributorAuthor

    Two corrections, and the migration is done.

    The warm instrument — revalidate: true is wrong

    I 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 stamps nextRenderTime: 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. The CREATE branch 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:

    31 sitemaps, 18745 created, 2670 re-attributed, 798898 unchanged, 21386 unlinked, 0 failed
    

    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 %20 targets; 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:

    let cacheKey = result.id;
    await databases.page_cache.PrerenderedPage.put(cacheKey, {...});

    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 %20 key that no request can produce. Consequences:

    • The waste is total — ~161k renders/day into keys nothing reads.
    • Orphans warm nothing. I had implied the corpus would partly self-heal; it does not, which is what made the refresh above necessary rather than optional.
    • They will not self-retire either: with the fold on, both sides of the canonical comparison fold alike, so the verdict is self and 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 %20 and + spellings now produce one cache key and serve one page.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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