diff --git a/api/Cargo.toml b/api/Cargo.toml
index 6c488cd6c..617dc40e6 100644
--- a/api/Cargo.toml
+++ b/api/Cargo.toml
@@ -116,7 +116,7 @@ infer = "0.15.0"
x509-parser = { version = "0.15.1", features = ["verify"] }
sitemap-rs = "0.2.1"
askalono = "0.5.0"
-moka = { version = "0.12", features = ["future"] }
+moka = { version = "0.12", features = ["future", "sync"] }
tree-sitter-highlight = "0.22.6"
tree-sitter-javascript = "0.21.4"
diff --git a/api/src/api/mod.rs b/api/src/api/mod.rs
index 90ea62bd1..901ce0b58 100644
--- a/api/src/api/mod.rs
+++ b/api/src/api/mod.rs
@@ -15,6 +15,7 @@ pub use self::errors::*;
pub use self::hooks::InboundTrustedAuthservId;
pub use self::hooks::PostmarkWebhookPassword;
pub use self::package::PublishQueue;
+pub use self::package::RegistryMetadataCache;
use self::publishing_task::publishing_task_router;
use self::self_user::self_user_router;
pub use self::types::*;
diff --git a/api/src/api/package.rs b/api/src/api/package.rs
index edffe9154..53c53af08 100644
--- a/api/src/api/package.rs
+++ b/api/src/api/package.rs
@@ -1,5 +1,6 @@
// Copyright 2024 the JSR authors. All rights reserved. MIT license.
use anyhow::Context;
+use bytes::Bytes;
use chrono::Utc;
use comrak::adapters::SyntaxHighlighterAdapter;
use deno_ast::MediaType;
@@ -11,7 +12,7 @@ use deno_graph::Module;
use deno_graph::Resolution;
use deno_graph::WorkspaceMember;
use deno_graph::analysis::ModuleInfo;
-use deno_graph::ast::CapturingModuleAnalyzer;
+use deno_graph::ast::ParserModuleAnalyzer;
use deno_graph::source::JsrUrlProvider;
use deno_graph::source::LoadError;
use deno_graph::source::LoadOptions;
@@ -35,6 +36,7 @@ use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
use std::borrow::Cow;
+use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
use std::sync::Arc;
@@ -251,8 +253,21 @@ pub fn package_router() -> Router
{
),
)
.get(
+ // The graph is resolved live — a `jsr:` range in the package's source
+ // picks up whatever version matches it today — so even a pinned version
+ // is not immutable, and a day is the ceiling rather than the obvious
+ // answer. "latest" moves on publish on top of that, so it stays short;
+ // it used to be pinned for a day along with everything else.
+ //
+ // `_shared`: the handler reads nothing but the buckets, keyed by scope,
+ // package and version — no db, no iam, no permission branch — so the
+ // response is identity-independent and the lb may serve it from its
+ // shared cache to authenticated callers. Without this every signed-in
+ // view of a dependency graph bypassed the cache and paid the full cold
+ // build, which is by far the most expensive handler in the API.
"/:package/versions/:version/dependencies/graph",
- util::cache(
+ util::cache_versioned_shared(
+ CacheDuration::FIVE_MINUTES,
CacheDuration::ONE_DAY,
util::json(get_dependencies_graph_handler),
),
@@ -2297,6 +2312,174 @@ pub async fn list_dependencies_handler(
Ok(deps)
}
+/// Upper bound on a single fallback-registry request made while building a
+/// dependency graph.
+///
+/// Deliberately much tighter than [`crate::tarball::FALLBACK_REQUEST_TIMEOUT`],
+/// which this used to share. A publish can afford to wait half a minute on a
+/// degraded fallback; a page render cannot, and the probe fires once per object
+/// the modules bucket does not hold — so on a fallback-hosted package the
+/// registry's patience is paid for every file in the package, one after another.
+const DEP_TREE_FALLBACK_REQUEST_TIMEOUT: std::time::Duration =
+ std::time::Duration::from_secs(5);
+
+/// The body handed to deno_graph for a module whose structure came from
+/// metadata. It only ever reaches `Module::source`, and the one thing this
+/// endpoint reads off a source is its length — which [`MetadataSizes`] has.
+///
+/// Wasm is the exception: deno_graph runs wasm bytes through `wasm_module_to_dts`
+/// and an empty body would error the module out of the graph, so it gets the
+/// minimum valid wasm module instead. That leaves the module in the graph and
+/// costs nothing, because `build_module_info` drops `Module::Wasm` anyway.
+fn stub_module_body(specifier: &ModuleSpecifier) -> Arc<[u8]> {
+ if MediaType::from_specifier(specifier) == MediaType::Wasm {
+ Arc::new([
+ 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0x0A, 0x01, 0x00,
+ ])
+ } else {
+ Arc::new([])
+ }
+}
+
+/// Cache for the metadata documents a dependency graph is built from.
+///
+/// A build is now almost entirely `meta.json` and `_meta.json` reads,
+/// and it walks them a level at a time: a package's dependencies aren't known
+/// until its metadata arrives, so the endpoint's latency is round trips to the
+/// modules bucket, in series, two per level of the dependency tree. The bucket
+/// is in a different cloud from the API, so each of those is an internet round
+/// trip.
+///
+/// Across builds they are overwhelmingly the same documents — the handful of
+/// packages nearly every dependency tree bottoms out in. Over a sweep of 419
+/// packages, 56% of these reads were repeats, and `@std/path/meta.json` alone
+/// was read by one build in seven. Holding them here takes the repeats off the
+/// critical path entirely.
+#[derive(Clone)]
+pub struct RegistryMetadataCache {
+ /// `_meta.json`. Published once and never rewritten (it goes up with
+ /// `CACHE_CONTROL_IMMUTABLE`), so the only reason to expire it is to give the
+ /// memory back.
+ version: moka::sync::Cache, Bytes>,
+ /// `meta.json`, the package's version list. Rewritten by every publish, and
+ /// it decides which version a `jsr:` range resolves to — so it is held only
+ /// briefly, and a newly published version starts being picked up within
+ /// [`PACKAGE_METADATA_TTL`].
+ package: moka::sync::Cache, Bytes>,
+}
+
+/// Ceiling on the metadata held in memory. Documents average ~12KB, so this is
+/// room for a few thousand of them — far more than the popular tail that
+/// actually repeats.
+const REGISTRY_METADATA_CACHE_BYTES: u64 = 64 * 1024 * 1024;
+
+/// How long a package's version list may be served from memory. This is the
+/// delay before a `jsr:` range in some other package starts resolving to a
+/// newly published version.
+const PACKAGE_METADATA_TTL: std::time::Duration =
+ std::time::Duration::from_secs(60);
+
+/// How long a version's metadata is held. The document itself never changes, so
+/// this is not about staleness but about deletion: a staff version delete
+/// removes it from the bucket and purges the CDN, and this cache is per instance
+/// so no purge can reach it. Keeping the window short bounds how long a deleted
+/// version can still appear in a graph. Anything read more than once in this
+/// window — which is every package a dependency tree bottoms out in — still
+/// stays warm.
+const VERSION_METADATA_TTL: std::time::Duration =
+ std::time::Duration::from_secs(5 * 60);
+
+impl RegistryMetadataCache {
+ pub fn new() -> Self {
+ fn build(ttl: std::time::Duration) -> moka::sync::Cache, Bytes> {
+ moka::sync::Cache::builder()
+ .max_capacity(REGISTRY_METADATA_CACHE_BYTES)
+ .weigher(|_key: &Arc, value: &Bytes| {
+ value.len().try_into().unwrap_or(u32::MAX)
+ })
+ .time_to_live(ttl)
+ .build()
+ }
+ Self {
+ version: build(VERSION_METADATA_TTL),
+ package: build(PACKAGE_METADATA_TTL),
+ }
+ }
+
+ /// Read a metadata document, from memory if it is there.
+ ///
+ /// Only a document the bucket actually holds is cached. A miss falls through
+ /// to the caller unrecorded, so the fallback-registry probe still runs on
+ /// every request for a package this registry doesn't have, and a package that
+ /// gets published later isn't pinned as missing.
+ async fn get_or_download(
+ &self,
+ kind: MetadataKind,
+ path: Arc,
+ bucket: &crate::s3::BucketWithQueue,
+ ) -> Result