Skip to content

fix(docs): stop a transient search fault from sticking as a silent blank - #750

Open
rejifald wants to merge 1 commit into
mainfrom
claude/docs-search-broken-7e9d45
Open

fix(docs): stop a transient search fault from sticking as a silent blank#750
rejifald wants to merge 1 commit into
mainfrom
claude/docs-search-broken-7e9d45

Conversation

@rejifald

@rejifald rejifald commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Reported as "search is broken on the docs website — it simply shows nothing."

What production actually does

I could not reproduce a blank search. ~20 varied queries (short, unicode, punctuation, code identifiers, nonsense) all returned HTTP 200 with sensible results. Cold start after 15 min idle was 5.2 s; warm 0.27 s. So a cold-start timeout is not the explanation — 5 s trips no platform limit, and proposal §6's "~22 s model load" is a local-spike figure, not what the deployed function pays.

What makes this hard to see from the outside: fumadocs' search dialog renders nothing at all when a request fails — no error state, no "no results". Every failure mode below is therefore indistinguishable from an empty index, and from each other.

Three defects that turn a momentary fault into a lasting blank

1. A memoized rejected promise. getEmbedder() cached the pipeline() promise, and its first load fetches ~87 MB from the HF CDN. Memoizing the rejection meant one blip there left every later query on that warm instance awaiting the same settled promise, with nothing to dislodge it but a recycle — a silent, indefinite search outage for everyone routed to it. loadIndex() had the same shape. Both now clear the slot on rejection so the next call retries.

2. Failures reported as success. The route answered 200 [] on any error, which made a broken index indistinguishable from a query with no matches in both directions that matter: uptime checks read the outage as healthy, and fumadocs' fetch client memoizes per-URL for the page's lifetime, so the empty array stuck to that query even after the backend recovered. It now answers 503, which is not cached — the visitor's next keystroke retries.

3. No maxDuration. /api/search-docs set none while /api/mcp already takes 60 for the identical model load. Now matches. Headroom, not the cause.

Verification

  • New test/search-route-failure.spec.ts fails on the pre-fix sources with exactly the right assertions (expected 200 to be 503, promise rejected "Error: CDN unreachable" instead of resolving) and passes after.
  • Full docs suite: 73 passed, 1 skipped. Lint clean. Pre-commit format/lint/typecheck green.
  • Exercised end-to-end against a local dev server: "stop hammering a flaky upstream"Circuit breaker.

The @/ alias in vitest.config.ts is needed for the spec to import an app-router module: without it a vi.mock() of a relative path and the route's @/-prefixed import resolve to two different module ids and the mock silently doesn't apply.

Not in this PR

The 87 MB model is re-downloaded on every cold instance. The build already downloads those weights (into node_modules' default cache), then embed.ts repoints env.cacheDir at an empty /tmp on Vercel and throws them away. next.config.mjs traces the 13 MB Orama index and onnxruntime's native libs into the function, but not the model.

Bundling it (outputFileTracingIncludes + env.localModelPath) would cut cold start to roughly the 0.3 s local-disk load measured here. But 87 MB model + 52 MB onnxruntime + 13 MB index lands near Vercel's 250 MB uncompressed function limit, so it wants a preview deploy to validate rather than a blind push to the production hot path. That also closes the standing §6 watch-item.

Separately: pnpm dev never builds the index, so local search is silently empty until pnpm --filter @stitchapi/docs build:search-index is run once. With the 503 change that now at least surfaces as a failed request instead of looking like "no matches".

🤖 Generated with Claude Code


Update — the model is now bundled (perf(docs) commit)

The follow-up above is done. .model is transformers.js' cacheDir for both roles: the index build populates it (miss → download → write), the deployed function reads it (hit → FileResponse), and next.config.mjs traces it into both search functions.

Read-only is safe for the hit, which is the crux: FileCache's constructor only stores a path, match() is an existence check, and hub.js explicitly declines to write FileResponses back. The only writer is put(), reached solely for a remote Response. Verified directly — chmod -R a-w .model, query served in 0.14 s, no write errors.

Function size

Preview deploys turned out to be disabled for this project (all 100 recent GitHub deployments are Production), so this was measured from a real next build by summing each route's .nft.json trace:

/api/search-docs /api/mcp
model weights 86.9 MB 86.9 MB
search index 13.3 MB 13.3 MB
onnxruntime linux/x64 33.9 MB 33.9 MB
onnxruntime linux/arm64 18.1 MB 18.1 MB
app + deps 9.5 MB 33.0 MB
total 161.7 MB 185.3 MB

Both clear the 250 MB uncompressed limit with 88 MB / 65 MB to spare. Two notes on how those numbers were reached, because the naive figures were 266 MB / 290 MB:

  • The first build double-counted the model. A stale copy sat in node_modules/…/transformers/.cache from before cacheDir moved, and the trace picked up both. It is not recreated — verified after deleting it and rebuilding, the build writes only to .model.
  • ~17.5 MB of macOS-only binaries (libvips…dylib, sharp-darwin-arm64, onnxruntime darwin/arm64) are traced on this machine and won't exist on a Linux build; excluded above.

The 18.1 MB of linux/arm64 onnxruntime is dead weight on x64 and is the obvious next trim if headroom ever gets tight — left alone here since the current comment deliberately ships the whole linux dir.

Runtime proof against the production build

next start on the real build, with .model the only copy of the weights anywhere on disk:

  • first query 0.22 s, warm 4.5 ms, HTTP 200 — far too fast to be an 87 MB download, so the bundled route is resolving .model through import.meta.url correctly (the one thing dev mode could not exercise)
  • "stop hammering a flaky upstream"Circuit breaker; "agent call my API without exposing the secret"apiKey, "Let an agent call your API without handing it the key", bearer, Capability, not credential
  • index build still reports embeddings deterministic ✓ (dim 384, maxDiff 0)

Against the 5.2 s cold start this replaces.

@rejifald
rejifald force-pushed the claude/docs-search-broken-7e9d45 branch 2 times, most recently from 22fc0ad to fc3b805 Compare August 16, 2026 14:48
The fumadocs search dialog renders nothing at all when a request fails —
no error, no "no results" — so every failure below reads to a visitor as
"search is broken, it shows nothing". Three of them turned a momentary
fault into a lasting one.

`getEmbedder()` memoized the `loadEmbedder()` promise, rejection included.
That gives back exactly what deferring the `@huggingface/transformers`
import bought: the deferral (see embed.ts's module header, and the Jul 31 –
Aug 10 2026 outage behind it) exists so a failed load stays scoped to the
one call that needed it — but parking the rejected promise in `extractor`
re-widens it to every later query on that warm instance, with nothing to
dislodge it but a recycle. `loadIndex()` had the same shape. Both now clear
the slot on rejection so the next call retries.

The route answered `200 []` on any error, which made a broken index
indistinguishable from a query with no matches in both directions that
matter: uptime checks read the outage as healthy, and fumadocs' fetch
client memoizes per-URL for the page's lifetime, so the empty array stuck
to that query even after the backend recovered. It now answers 503, which
is not cached — the visitor's next keystroke retries.

`/api/search-docs` also set no `maxDuration` while `/api/mcp` already takes
60 for the identical model load; it now matches. Headroom rather than a fix
for a specific timeout — the two paths should not disagree about how long
that load may take.

The regression spec needs to import an app-router module, hence the `@/`
alias in vitest.config.ts: without it a `vi.mock()` of a relative path
and the route's `@/`-prefixed import resolve to two different module ids
and the mock silently doesn't apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rejifald
rejifald force-pushed the claude/docs-search-broken-7e9d45 branch from fc3b805 to c59ab9e Compare August 16, 2026 15:08
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