Skip to content

perf(web): stop hammering the daemon with per-page-view listing fan-out - #938

Merged
ericvicenti merged 2 commits into
mainfrom
perf/web-listdirectory-fanout
Aug 10, 2026
Merged

perf(web): stop hammering the daemon with per-page-view listing fan-out#938
ericvicenti merged 2 commits into
mainfrom
perf/web-listdirectory-fanout

Conversation

@juligasa

@juligasa juligasa commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

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 ListDirectory and another ~17% serving ListCitations, with 90%+ of total CPU inside sqlite3_step. The backend query shapes were fixed separately (#933-era work, commit 9cec42c4f); 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:

  • ListDirectory for the site home + ListDirectory for the current doc (Wave 1)
  • one ListDirectory(pageSize=2^25) per query block in the doc body — recursive over the whole account for AllDescendants blocks, then slice(0, limit)-ed client-side (Wave 2)
  • up to 30 InteractionSummary calls — one per embedded ref, just to render count badges (Wave 3). Each InteractionSummary is 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.txt is Allow: /, and the expensive /:directory / /:all-documents views are ordinary crawlable links — so crawler traffic replays the full fan-out on every hit, forever. That is the sustained 88% CPU.

Changes

1. InteractionSummary no longer calls ListDirectory at 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 via GetDocumentInfoactivitySummary.childrenCount) — the column comment in documents.go says verbatim it exists so cards can show the count "without a per-document interaction-summary request". Using it removes one ListDirectory from 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, InteractionSummary and ListCitations responses 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 in f0246a025): callers sending pageSize: 2^25 without following next_page_token would silently truncate long lists once the clamp is live. A new listAllPages helper fetches bounded pages (500) following next_page_token, with a 10k total safety cap, and replaces pageSize: 2^25 at the profile-hot call sites: listCitations in api-citations, api-interaction-summary and models/comments-resolvers, and listDirectory in models/directory. Covered by a dedicated multi-page/cap unit test; the other BIG_INT call sites hit RPCs that stay unclamped and are left for follow-ups.

Testing

  • tsc --noEmit passes in packages/shared and apps/web.
  • Full packages/shared vitest suite passes (1004 tests), including the new list-all-pages multi-page/cap tests.
  • api-interaction-summary / interaction-summary vitest suites pass (mocks updated from listDirectory to getDocumentInfo).
  • Verified GetDocumentInfo populates activitySummary.childrenCount (backend documentInfoFromRow, documents.go:1767 — the proto comment claiming it is listing-only is stale).

Deliberately out of scope (follow-ups)

  • Passing listing-row activitySummary into DocumentListItem/CitationCell to kill the per-row summary fetches on /:directory and /:all-documents (the prop plumbing already exists).
  • Capping or lazifying Wave 3's 30 summary prefetches.
  • robots.txt Disallow for /:directory / /:all-documents view URLs.
  • Pushing query-block limit into the backend call instead of slicing after fetching the whole subtree.

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.
@juligasa
juligasa requested a review from ericvicenti August 6, 2026 22:09
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 ericvicenti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ericvicenti
ericvicenti merged commit 2ae7e2d into main Aug 10, 2026
8 checks passed
ericvicenti added a commit that referenced this pull request Aug 10, 2026
ericvicenti added a commit that referenced this pull request Aug 10, 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.

2 participants