Skip to content

perf(autocomplete): trie prefix index; fix cache poisoning and XSS escape - #6

Open
frankstupak wants to merge 1 commit into
SkinnnyJay:mainfrom
frankstupak:lumen-uplift/autocomplete
Open

perf(autocomplete): trie prefix index; fix cache poisoning and XSS escape#6
frankstupak wants to merge 1 commit into
SkinnnyJay:mainfrom
frankstupak:lumen-uplift/autocomplete

Conversation

@frankstupak

Copy link
Copy Markdown

Third delivery from Lumen Industries. The autocomplete subproject had a broken prefix path, a poisoned cache, a response-leaking request pipeline, and an XSS hole. All fixed, all tested, headline number is five digits.

What was wrong

  1. Prefix search never worked. performSearch passed ^query to Fuse.js, but the ^ prefix operator only exists under useExtendedSearch: true (Fuse.js docs) — which was never set. Every 1–2 char query fuzzy-matched a literal caret.
  2. Cross-filter cache poisoning. The cache key omitted category and tags. A cached response for ?q=x&category=books was served verbatim to ?q=x&category=movies. The existing "consistent cache keys" test never caught it because it asserted toBeDefined() — and null is defined.
  3. Server-side lodash.debounce on the request path. Per the lodash contract, debounced calls return the result of the last invocation — and undefined before any invocation runs. With debounceMs > 0, concurrent users could receive undefined or each other's search responses. Debounce belongs on the client keystroke, never on a shared server path.
  4. Filters ran after the limit. Fetch top-N, then filter by category → 0 results even when matches existed just past the cutoff.
  5. Stored XSS. highlightedTitle/highlightedDescription interpolated raw item data around <mark> tags. Any item titled <img src=x onerror=…> executed in the demo frontend.
  6. threshold: 0 silently ignored (|| default in two places), "exact" strategy was identical to fuzzy, and the popular-query "average" was (avg+x)/2 — an exponential drift where the first sample's weight decays to 1/2^(N-1).

What's better

  • New PrefixIndex — character trie indexing full title, title words, and tags. O(|prefix| + k) lookups, exact-token matches ranked first, Unicode-safe, deterministic ordering. Wired into the prefix strategy.
  • Cache key now covers every response-affecting param (category, tags order-insensitive, normalized query case, correct fuzzy-default semantics).
  • Single-flight request coalescing replaces debounce: identical concurrent requests share one engine search (stampede guard); distinct requests always get their own response.
  • Filter-then-limit, honest exact-phrase strategy, threshold: 0 honored, HTML-escaped highlights with overlapping-range merge, true running mean, rebuild timer unref()d.
  • Housekeeping: @fastify/swagger added (tsc was failing on a missing dep), unused lodash/lodash.debounce removed.

Numbers

npm run bench:prefix — 100,000 items, 200 query rounds, limit 10 (Node 22, Xeon):

Implementation ms/query Speedup
Fuse.js as shipped (^q, no extended search) 361.30
Fuse.js correctly configured (^q, useExtendedSearch) 112.60
PrefixIndex trie 0.0058 ~19,500× (vs correct Fuse), ~62,600× vs shipped

Trie build for 100k items: 614 ms one-time (200,030 tokens).

Tests

  • 65 passing (was 36): +15 trie unit tests, +14 regression tests — one per bug above, including a cache-poisoning repro with strict not.toBeNull() assertions, a concurrent-request response-ownership test, and an XSS escape test.
  • Full original suite untouched and green. tsc --noEmit clean (it wasn't before). ESLint: 0 errors, 0 warnings on all touched files.
  • Public API unchanged — debounceMs is still accepted, documented as client-side-only.

— Lumen Industries

…che poisoning, remove server-side debounce, escape XSS in highlights

- NEW PrefixIndex trie: O(|prefix|+k) prefix lookups. 0.0058 ms/query vs
  112.6 ms for a correctly-configured Fuse.js prefix scan at 100k items
  (~19,500x); vs 361.3 ms for the code path as shipped (~62,600x).
  bench/prefix-bench.ts, npm run bench:prefix.
- FIX prefix strategy: '^query' was passed to Fuse without
  useExtendedSearch, so the caret was fuzzy-matched as a literal char.
- FIX cache key omitted category/tags: cached results for one filter were
  served verbatim to different filters (cross-filter cache poisoning).
  Key now also normalizes query case and fuzzy-default semantics.
- FIX server-side lodash.debounce on the request path: per lodash docs,
  debounced calls return the LAST invocation's result (undefined before
  the first), so concurrent users could receive each other's responses
  or undefined. Replaced with single-flight request coalescing.
- FIX filters applied after limit: category/tag filters could return 0
  results despite matches existing past the pre-filter cutoff.
- FIX threshold 0 ignored (|| default) in sanitize + applyFilters.
- FIX stored XSS: highlighted title/description now HTML-escaped before
  <mark> insertion; overlapping match ranges merged.
- FIX exact strategy now does exact phrase matching (was identical to fuzzy).
- FIX popular-query avgExecutionTime: true running mean, not (avg+x)/2 drift.
- Background index-rebuild timer unref'd; @fastify/swagger dep added
  (tsc was failing); unused lodash/lodash.debounce deps removed.
- +29 tests (65 total): trie unit suite + regression tests for every fix.
@frankstupak frankstupak changed the title autocomplete: trie prefix index (~19,500x faster), cross-filter cache poisoning fix, server-side debounce removal, XSS escape perf(autocomplete): trie prefix index; fix cache poisoning and XSS escape Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant