fix(docs): stop a transient search fault from sticking as a silent blank - #750
Open
rejifald wants to merge 1 commit into
Open
fix(docs): stop a transient search fault from sticking as a silent blank#750rejifald wants to merge 1 commit into
rejifald wants to merge 1 commit into
Conversation
rejifald
force-pushed
the
claude/docs-search-broken-7e9d45
branch
2 times, most recently
from
August 16, 2026 14:48
22fc0ad to
fc3b805
Compare
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
force-pushed
the
claude/docs-search-broken-7e9d45
branch
from
August 16, 2026 15:08
fc3b805 to
c59ab9e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thepipeline()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-docsset none while/api/mcpalready takes 60 for the identical model load. Now matches. Headroom, not the cause.Verification
test/search-route-failure.spec.tsfails 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.The
@/alias invitest.config.tsis needed for the spec to import an app-router module: without it avi.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.tsrepointsenv.cacheDirat an empty/tmpon Vercel and throws them away.next.config.mjstraces 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 devnever builds the index, so local search is silently empty untilpnpm --filter @stitchapi/docs build:search-indexis 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.
.modelis transformers.js'cacheDirfor both roles: the index build populates it (miss → download → write), the deployed function reads it (hit →FileResponse), andnext.config.mjstraces 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, andhub.jsexplicitly declines to writeFileResponses back. The only writer isput(), reached solely for a remoteResponse. 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 buildby summing each route's.nft.jsontrace:/api/search-docs/api/mcpBoth 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:
node_modules/…/transformers/.cachefrom beforecacheDirmoved, and the trace picked up both. It is not recreated — verified after deleting it and rebuilding, the build writes only to.model.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/arm64onnxruntime 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 starton the real build, with.modelthe only copy of the weights anywhere on disk:.modelthroughimport.meta.urlcorrectly (the one thing dev mode could not exercise)embeddings deterministic ✓ (dim 384, maxDiff 0)Against the 5.2 s cold start this replaces.