Skip to content

[pull] main from jsr-io:main - #132

Merged
pull[bot] merged 1 commit into
code:mainfrom
jsr-io:main
Aug 29, 2026
Merged

[pull] main from jsr-io:main#132
pull[bot] merged 1 commit into
code:mainfrom
jsr-io:main

Conversation

@pull

@pullpullBot commented Aug 29, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

…mbols listing (#1542)
Tracing showed the R2 download step taking 45-92% of request time on
`/docs` and `/source`, spread across a wide, effectively random set of
packages, with no download faster than ~130ms. That reads like a
storage-latency problem, and the natural conclusion is to put a cache or
CDN in front of the buckets.
It isn't one, and that fix can't be built as stated. The API reaches the
buckets over the S3 API at `<account>.r2.cloudflarestorage.com`, which
is not served through Cloudflare's cache and cannot be fronted by one.
(A CDN cache in front of the *public* bucket paths already exists —
`proxyToR2` serves `jsr.io/@scope/pkg/…` out of `caches.default`. It
just isn't in the path the API uses.)
The traces are accurate about *where* time goes. What they can't show is
that most of those downloads shouldn't be happening at all: the same
immutable objects are fetched over and over. Latency per fetch is not
really the problem; the number of fetches is. This PR removes the
repeats, and fixes two other things found along the way.
## 1. The same immutable object is downloaded on every request
`<version>_meta.json` is downloaded once per source-file view, to turn
imports into links. It's immutable per version and shared by every file
in that version — but nothing cached it. In a one-hour production
sample, **76% of the fetches were re-reads of an object already pulled
that same hour**, and the busiest package fetched the identical object
202 times. Three other call sites read the same object and also
re-download it every time.
The README on the package overview is the same shape: immutable per
version, re-fetched on every render even when the doc context cache
hits.
This adds an in-process cache for immutable modules-bucket objects. The
S3 path already encodes a published version, so it's a safe key with
nothing to invalidate. The package-level `meta.json` is deliberately
excluded — it's rewritten on every publish.
Two details worth reviewing:
- **Absences are not cached.** A 404 leaves the loader as an error so it
isn't stored, which keeps an object still being written during publish
from being remembered as missing.
- **Misses are single-flighted**, so a cold key costs one download
rather than one per waiting request.
### On memory
The cache is bounded in **bytes**, not entries, and needs no increase to
the container's memory limit.
`GenerateCtxCache` used `max_capacity(64)`, which counts entries. That
had two problems: 64 tiny packages evicted each other exactly as readily
as 64 enormous ones (a sample of 2000 docs requests touched 534 distinct
packages), and the bytes actually held were bounded by nothing at all —
the comment estimated "2-5mb average, 320mb max" but nothing enforced
it.
It's now weighed by the stored size of the doc nodes each context was
built from. So both caches are byte-bounded, and the two together have a
smaller worst case than that one cache had on its own.
`GenerateCtxCache` misses are single-flighted too. That matters more
than for a typical cache: building a context allocates the 10-50 MB that
the render permit exists to bound, so concurrent requests for the same
cold package multiplied real memory, not just CPU.
## 2. The symbol listing limit doesn't bound the all-symbols page
Found while looking at what else was consuming origin capacity, and it
turned out to be the larger cost.
`SYMBOL_LISTING_LIMIT` (2048) caps how many symbol rows a module listing
may contain. Its comment says why it exists: to keep the docs response
under Cloud Run's 32 MiB response limit.
But it's applied inside `ModuleDocCtx::new`, so it bounds **one
module**. `AllSymbolsCtx::new` builds one listing *per entrypoint* and
concatenates them, so a package with many entrypoints multiplies
straight past the cap. `@ubx/sdk-aws` publishes 1921 exports, putting
its ceiling around 3.9 million rows.
When the response exceeds the limit, Cloud Run kills the request
mid-flight. Every attempt builds the entire listing, spends ~1.6s of
CPU, and returns nothing usable. And a 500 carries no cache headers, so
retries always reach the origin — a search crawler walking that
package's symbol pages produced **871 identical failing requests in one
hour**, roughly half of one instance's capacity spent on work that could
never succeed.
This refuses up front with a **413** instead. The count mirrors what
rendering would actually emit, so it errs towards serving rather than
refusing.
The durable fix belongs in `deno_doc` — the limit should be a total
budget rather than a per-module one. When that lands, this guard should
stop firing on its own.
**Making the refusal cacheable took three changes**, since the outcome
is a fixed property of a published version:
1. The API attaches a long-lived `Cache-Control` to the 413 on pinned
versions, alongside the existing `EntrypointOrSymbolNotFound` case.
2. `cache_versioned_impl` / `cache_impl` previously attached cache
headers only to 404s.
3. `lb/proxy.ts` previously cached only 200s and 404s, so the header
would have been inert.
## 3. Both docs search routes were missing `_shared`
`docs/search` and `docs/search_structured` are derived purely from the
published version, with no permission/member/sudo branch — exactly like
`docs` above them, which is already `_shared`. Marking them lets the lb
serve them from its shared cache to authenticated callers instead of
bypassing the cache whenever auth is present.
## Frontend
`LocalSymbolSearch` already disables the input when the index is
missing, but `onInput` asserted the index was present. A refused listing
now leaves it unset by design, so guard it rather than throw on every
keystroke.
## Not in this PR
- **The source file bytes and their syntax highlighting.** Both
immutable per `(scope, package, version, path)` and both currently
uncached, but the working set is much wider than the metadata objects,
so it needs a memory budget this PR doesn't try to claim.
- **Reading the module graph from Postgres instead of R2.**
`source_specifier_links` needs exactly one module's info plus the file
list, and Postgres already has the file list. Storing the per-module
info at publish would delete the fetch entirely rather than caching it —
but that's a migration plus a backfill.
## Testing
- 3 new unit tests covering the per-entrypoint accumulation, including
the case this fixes: many entrypoints each well under the per-module
limit still exceeding the total.
- 2 new lb tests: a 413 with a cacheable directive is cached, one
without still isn't.
- `cargo clippy`, `cargo fmt`, and the lb suite (43 tests) are clean.
- The database-backed API tests were not run locally and need CI.
@pullpullBot locked and limited conversation to collaborators Aug 29, 2026
@pull
pullBot merged commit 888163a into code:mainAug 29, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@crowlKats