perf(web): stop hammering the daemon with per-page-view listing fan-out - #938
Merged
Conversation
Production profiling showed the daemon spending 65-68% of CPU serving ListDirectory and another 17% serving ListCitations, sustained for hours. The SSR gateway fans out dozens of gRPC calls per page view (directory of home + doc, one recursive whole-subtree listing per query block, and up to 30 interaction summaries for embed cards, each of which is 4 more calls), while every response is Cache-Control: no-store and the server builds a fresh QueryClient per request, so nothing is ever reused between two hits of the same page. Two changes: - InteractionSummary no longer calls ListDirectory just to count direct children. The backend already computes children_count for every document info row (GetDocumentInfo), so use that instead. The old count was also wrong: it silently truncated at the backend's default page size and did not exclude deleted documents. - The SSR universal client now caches the hot read-only API keys (Query, QueryBlock, InteractionSummary, ListCitations) in a module-scoped map with a 30s TTL and a bounded entry count. Entries store in-flight Promises, so concurrent renders of the same page share one backend call, and failures are evicted rather than cached. The SSR client is never authenticated, so entries are safe to share across requests. This turns crawler storms over the same pages into at most one backend call per unique query per 30s window.
Companion to the daemon-side page-size clamp: the daemon now caps ListDirectory/ListCitations page sizes at 2000, so callers sending pageSize 2^25 without following next_page_token would silently truncate long lists. New listAllPages helper fetches bounded pages (500) following next_page_token with a 10k total safety cap, replacing pageSize 2^25 at the profile-hot call sites: listCitations in api-citations, api-interaction-summary and comments-resolvers, and listDirectory in models/directory.
ericvicenti
approved these changes
Aug 10, 2026
ericvicenti
left a comment
Collaborator
There was a problem hiding this comment.
this seems reasonable for now
but still concerning that we are listing all pages in cases where we should have more sophisticated e2e pagination
probably the biggest fix is removing listDir from the interaction summary
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.
Problem
The production daemon has been pinned at 5-6 cores for hours at a time. Three CPU profiles taken over one day all show the same shape: ~65% of all daemon CPU serving
ListDirectoryand another ~17% servingListCitations, with 90%+ of total CPU insidesqlite3_step. The backend query shapes were fixed separately (#933-era work, commit9cec42c4f); this PR fixes the other half: the web gateway generates that traffic in the first place.Why the gateway hammers the daemon
Tracing the SSR loaders (
apps/web/app/loaders.ts,prefetchResourceData) shows a single document page view fires, with zero caching at any layer:ListDirectoryfor the site home +ListDirectoryfor the current doc (Wave 1)ListDirectory(pageSize=2^25)per query block in the doc body — recursive over the whole account forAllDescendantsblocks, thenslice(0, limit)-ed client-side (Wave 2)InteractionSummarycalls — one per embedded ref, just to render count badges (Wave 3). EachInteractionSummaryis itself 4 gRPC calls:ListCitations(pageSize=2^25)+GetDocument+ListDirectory(used only to count direct children) +ListDocumentChanges. These use the raw gRPC client, so the doc's own directory was fetched twice per render.That is ~35 ListDirectory + ~31 ListCitations per page view. Meanwhile every response is sent
Cache-Control: private, no-store/no-cache, the server builds a fresh QueryClient per request,robots.txtisAllow: /, and the expensive/:directory//:all-documentsviews are ordinary crawlable links — so crawler traffic replays the full fan-out on every hit, forever. That is the sustained 88% CPU.Changes
1.
InteractionSummaryno longer callsListDirectoryat all (packages/shared/src/api-interaction-summary.ts)The backend already computes the alive direct-children count for every document info row (
children_count, exposed viaGetDocumentInfo→activitySummary.childrenCount) — the column comment indocuments.gosays verbatim it exists so cards can show the count "without a per-document interaction-summary request". Using it removes oneListDirectoryfrom every interaction summary (up to 31 per page view) and is also more correct: the old count silently truncated at the backend's default page size and did not exclude deleted documents.2. 30s TTL cache for hot read-only keys in the SSR client (
apps/web/app/server-universal-client.ts)The SSR universal client now caches
Query,QueryBlock,InteractionSummaryandListCitationsresponses in a module-scoped map: 30s TTL, 500-entry bounded size (same pattern as the SSR HTML cache in@shm/editor/ssr-render). Entries store in-flight Promises, so concurrent renders of the same page share one backend call instead of racing duplicates, and rejected calls are evicted so errors are never cached. This client is never authenticated — it always sees the public view — so entries are safe to share across requests and users.Net effect: a typical page render drops from ~35 ListDirectory + ~31 ListCitations to ~4 + ~1 on a cold cache, and to zero daemon calls for any page re-rendered within the TTL window — which is exactly the crawler-replay pattern that dominates gateway traffic. Data staleness is bounded at 30s, for listings and count badges on a public gateway.
3. Real pagination for the hot list calls (
packages/shared/src/list-all-pages.ts, second commit)Companion to the new daemon-side page-size clamp on
ListDirectory/ListCitations(capped at 2000, landed on main inf0246a025): callers sendingpageSize: 2^25without followingnext_page_tokenwould silently truncate long lists once the clamp is live. A newlistAllPageshelper fetches bounded pages (500) followingnext_page_token, with a 10k total safety cap, and replacespageSize: 2^25at the profile-hot call sites:listCitationsinapi-citations,api-interaction-summaryandmodels/comments-resolvers, andlistDirectoryinmodels/directory. Covered by a dedicated multi-page/cap unit test; the otherBIG_INTcall sites hit RPCs that stay unclamped and are left for follow-ups.Testing
tsc --noEmitpasses inpackages/sharedandapps/web.packages/sharedvitest suite passes (1004 tests), including the newlist-all-pagesmulti-page/cap tests.api-interaction-summary/interaction-summaryvitest suites pass (mocks updated fromlistDirectorytogetDocumentInfo).GetDocumentInfopopulatesactivitySummary.childrenCount(backenddocumentInfoFromRow,documents.go:1767— the proto comment claiming it is listing-only is stale).Deliberately out of scope (follow-ups)
activitySummaryintoDocumentListItem/CitationCellto kill the per-row summary fetches on/:directoryand/:all-documents(the prop plumbing already exists).robots.txtDisallow for/:directory//:all-documentsview URLs.limitinto the backend call instead of slicing after fetching the whole subtree.