Skip to content

Commit f5be7ce

Browse files
committed
Turbopack: aggregate server HMR into one subscription
Replace per-chunk Server HMR fan-out with a single firehose subscription that diffs every HMR-eligible chunk under the target root in one tick. This significantly cuts the number of tokio task churn on projects with many server chunks and centralizes the diff/clear logic. It leads to a multi-second saving in both cold and warm builds in a large app. A following PR will bring this to client chunks. Rust: - New `aggregate_hmr` module: `AggregateHmrVersion` keyed by chunk path, `merged_partial_update` builder, and `is_hmr_eligible_chunk` (excludes `.map` files, which would force every diff to `Total`). - `Project::all_hmr_version_state` / `all_hmr_update` aggregate over the whole `hmr_root_path`. The seed transition emits an empty `Partial` so the JS consumer doesn't treat it as a restart and wipe handlers the triggering request just populated. Any chunk requiring `Total`/`Missing` escalates the batch. - `VersionedContentMap::hmr_chunks_in_path` lists eligible chunks with their `VersionedContent`. NAPI: - `projectAllHmrEvents(target)` returns a single subscription. JS: - `setupServerHmr` subscribes once via `allHmrEvents` instead of fanning out over `hmrChunkNamesSubscribe`. - `clear()` evicts every chunk under `server/chunks/` from `require.cache` directly rather than tracking subscriptions. This is a bit fragile as it relies on the path prefix and scanning require.cache.
1 parent 2da7291 commit f5be7ce

10 files changed

Lines changed: 596 additions & 77 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
use std::sync::Arc;
2+
3+
use anyhow::Result;
4+
use rustc_hash::FxHashMap;
5+
use turbo_rcstr::RcStr;
6+
use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TraitRef, TryJoinIterExt, Vc};
7+
use turbo_tasks_fs::FileSystemPath;
8+
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
9+
use turbopack_core::version::{
10+
NotFoundVersion, PartialUpdate, Update, Version, VersionState, VersionedContent,
11+
};
12+
13+
use crate::versioned_content_map::VersionedContentMap;
14+
15+
pub struct HmrChunkWithContent {
16+
pub path: RcStr,
17+
pub content: ResolvedVc<Box<dyn VersionedContent>>,
18+
}
19+
20+
pub fn is_hmr_eligible_chunk(name: &str) -> bool {
21+
!name.ends_with(".map")
22+
}
23+
24+
/// Per-chunk versions keyed by path
25+
#[turbo_tasks::value(serialization = "skip", shared)]
26+
pub struct AggregateHmrVersion {
27+
#[turbo_tasks(trace_ignore)]
28+
pub versions: FxIndexMap<RcStr, TraitRef<Box<dyn Version>>>,
29+
}
30+
31+
#[turbo_tasks::value_impl]
32+
impl Version for AggregateHmrVersion {
33+
#[turbo_tasks::function]
34+
async fn id(&self) -> Result<Vc<RcStr>> {
35+
let mut entries = self
36+
.versions
37+
.iter()
38+
.map(|(path, version)| {
39+
let path = path.clone();
40+
let version = TraitRef::cell(version.clone());
41+
async move {
42+
let id = version.id().owned().await?;
43+
Ok::<_, anyhow::Error>((path, id))
44+
}
45+
})
46+
.try_join()
47+
.await?;
48+
entries.sort_by(|a, b| a.0.cmp(&b.0));
49+
50+
let mut hasher = Xxh3Hash64Hasher::new();
51+
hasher.write_value(entries.len());
52+
for (path, id) in entries {
53+
hasher.write_value(path.as_str());
54+
hasher.write_value(id.as_str());
55+
}
56+
Ok(Vc::cell(encode_base64(hasher.finish()).into()))
57+
}
58+
}
59+
60+
impl AggregateHmrVersion {
61+
pub async fn from_map(
62+
map: Vc<VersionedContentMap>,
63+
root: &FileSystemPath,
64+
) -> Result<Vc<Box<dyn Version>>> {
65+
let chunks = map.hmr_chunks_in_path(root).await?;
66+
if chunks.is_empty() {
67+
return Ok(Vc::upcast(NotFoundVersion::new()));
68+
}
69+
Ok(Vc::upcast(Self::from_chunks(&chunks).await?))
70+
}
71+
72+
pub async fn from_chunks(chunks: &[HmrChunkWithContent]) -> Result<Vc<Self>> {
73+
let versions = chunks
74+
.iter()
75+
.map(|HmrChunkWithContent { path, content }| {
76+
let path = path.clone();
77+
let content = *content;
78+
async move {
79+
let version = content.version().into_trait_ref().await?;
80+
Ok::<_, anyhow::Error>((path, version))
81+
}
82+
})
83+
.try_join()
84+
.await?
85+
.into_iter()
86+
.collect();
87+
Ok(Self { versions }.cell())
88+
}
89+
}
90+
91+
pub fn merge_ecmascript_merged_update(
92+
combined_entries: &mut FxHashMap<String, serde_json::Value>,
93+
combined_chunks: &mut FxHashMap<String, serde_json::Value>,
94+
instruction: &serde_json::Value,
95+
) {
96+
let Some(obj) = instruction.as_object() else {
97+
return;
98+
};
99+
if let Some(entries) = obj.get("entries").and_then(|v| v.as_object()) {
100+
for (k, v) in entries {
101+
combined_entries.insert(k.clone(), v.clone());
102+
}
103+
}
104+
if let Some(chunks) = obj.get("chunks").and_then(|v| v.as_object()) {
105+
for (k, v) in chunks {
106+
combined_chunks.insert(k.clone(), v.clone());
107+
}
108+
}
109+
}
110+
111+
/// Builds an `Update::Partial` whose instruction is a combined
112+
/// `EcmascriptMergedUpdate` covering `entries` and `chunks`. Empty maps are
113+
/// omitted so an empty `entries`/`chunks` field never appears in the payload.
114+
///
115+
/// Passing empty maps produces an instruction with only `type:
116+
/// "EcmascriptMergedUpdate"`, used to advance `VersionState` to `to` without
117+
/// the JS consumer applying anything: it sees a `partial` event with nothing
118+
/// to apply and short-circuits.
119+
pub fn merged_partial_update(
120+
to: TraitRef<Box<dyn Version>>,
121+
entries: FxHashMap<String, serde_json::Value>,
122+
chunks: FxHashMap<String, serde_json::Value>,
123+
) -> Update {
124+
let mut instruction = serde_json::Map::new();
125+
instruction.insert(
126+
"type".to_string(),
127+
serde_json::Value::String("EcmascriptMergedUpdate".to_string()),
128+
);
129+
if !entries.is_empty() {
130+
instruction.insert(
131+
"entries".to_string(),
132+
serde_json::Value::Object(entries.into_iter().collect()),
133+
);
134+
}
135+
if !chunks.is_empty() {
136+
instruction.insert(
137+
"chunks".to_string(),
138+
serde_json::Value::Object(chunks.into_iter().collect()),
139+
);
140+
}
141+
Update::Partial(PartialUpdate {
142+
to,
143+
instruction: Arc::new(serde_json::Value::Object(instruction)),
144+
})
145+
}
146+
147+
/// Per-chunk [`Update`]s computed against an `AggregateHmrVersion` snapshot.
148+
/// `has_new_chunks` is true when the current snapshot contains chunks absent
149+
/// from `from` (e.g. a new endpoint was written); callers decide whether that
150+
/// affects the batch shape.
151+
pub struct DiffResult {
152+
pub chunk_updates: Vec<(RcStr, ReadRef<Update>)>,
153+
pub has_new_chunks: bool,
154+
}
155+
156+
/// Diffs each chunk against `from`'s [`AggregateHmrVersion`] snapshot, if any.
157+
/// When `from` doesn't downcast to an aggregate version (e.g. the seed
158+
/// transition), the returned `chunk_updates` is empty.
159+
pub async fn diff_chunks_against(
160+
chunks: &[HmrChunkWithContent],
161+
from: Vc<VersionState>,
162+
) -> Result<DiffResult> {
163+
if chunks.is_empty() {
164+
return Ok(DiffResult {
165+
chunk_updates: Vec::new(),
166+
has_new_chunks: false,
167+
});
168+
}
169+
let from_resolved = from.get().to_resolved().await?;
170+
let Some(from_aggregate) = ResolvedVc::try_downcast_type::<AggregateHmrVersion>(from_resolved)
171+
else {
172+
return Ok(DiffResult {
173+
chunk_updates: Vec::new(),
174+
has_new_chunks: false,
175+
});
176+
};
177+
let from_aggregate = from_aggregate.await?;
178+
179+
let mut has_new_chunks = false;
180+
let chunk_updates = chunks
181+
.iter()
182+
.filter_map(|HmrChunkWithContent { path, content }| {
183+
let Some(prev) = from_aggregate.versions.get(path).cloned() else {
184+
has_new_chunks = true;
185+
return None;
186+
};
187+
Some((path.clone(), *content, TraitRef::cell(prev)))
188+
})
189+
.map(|(path, content, prev)| async move {
190+
let update = content.update(prev).await?;
191+
Ok::<_, anyhow::Error>((path, update))
192+
})
193+
.try_join()
194+
.await?;
195+
Ok(DiffResult {
196+
chunk_updates,
197+
has_new_chunks,
198+
})
199+
}

crates/next-api/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#![feature(arbitrary_self_types_pointers)]
33
#![feature(impl_trait_in_assoc_type)]
44

5+
mod aggregate_hmr;
56
pub mod analyze;
67
mod app;
78
mod asset_hashes_manifest;

crates/next-api/src/project.rs

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ use turbopack_core::{
8383
reference_type::{CommonJsReferenceSubType, ReferenceType},
8484
resolve::{FindContextFileResult, find_context_file},
8585
version::{
86-
NotFoundVersion, OptionVersionedContent, Update, Version, VersionState, VersionedContent,
86+
NotFoundVersion, OptionVersionedContent, PartialUpdate, TotalUpdate, Update, Version,
87+
VersionState, VersionedContent,
8788
},
8889
};
8990
#[cfg(feature = "process_pool")]
@@ -94,6 +95,10 @@ use turbopack_node::worker_threads_backend;
9495
use turbopack_nodejs::NodeJsChunkingContext;
9596

9697
use crate::{
98+
aggregate_hmr::{
99+
AggregateHmrVersion, DiffResult, diff_chunks_against, is_hmr_eligible_chunk,
100+
merge_ecmascript_merged_update, merged_partial_update,
101+
},
97102
app::{AppProject, OptionAppProject},
98103
empty::EmptyEndpoint,
99104
entrypoints::Entrypoints,
@@ -2547,14 +2552,142 @@ impl Project {
25472552
}
25482553
}
25492554

2555+
/// Aggregate counterpart to [`Self::hmr_version_state`]: one [`VersionState`]
2556+
/// covering every HMR-eligible chunk under `target`'s root. See
2557+
/// [`Self::all_hmr_update`].
2558+
#[turbo_tasks::function]
2559+
pub async fn all_hmr_version_state(
2560+
self: ResolvedVc<Self>,
2561+
target: HmrTarget,
2562+
session: TransientInstance<()>,
2563+
) -> Result<Vc<VersionState>> {
2564+
if target == HmrTarget::Client {
2565+
bail!("all_hmr_version_state is not yet implemented for the client target");
2566+
}
2567+
2568+
// The session argument keeps this from caching across sessions.
2569+
let _ = session;
2570+
2571+
#[tracing::instrument(
2572+
level = "info",
2573+
name = "get aggregate HMR version",
2574+
skip_all,
2575+
fields(target = %target),
2576+
)]
2577+
#[turbo_tasks::function(operation, root)]
2578+
async fn aggregate_hmr_version_operation(
2579+
this: ResolvedVc<Project>,
2580+
target: HmrTarget,
2581+
) -> Result<Vc<Box<dyn Version>>> {
2582+
let Some(map) = this.await?.versioned_content_map else {
2583+
bail!("must be in dev mode to hmr")
2584+
};
2585+
let root = this.hmr_root_path(target).owned().await?;
2586+
AggregateHmrVersion::from_map(*map, &root).await
2587+
}
2588+
let version_op = aggregate_hmr_version_operation(self, target);
2589+
2590+
// INVALIDATION: untracked initial read; the subscription drives invalidation.
2591+
let state = VersionState::new(
2592+
version_op
2593+
.read_trait_strongly_consistent()
2594+
.untracked()
2595+
.await?,
2596+
)
2597+
.await?;
2598+
Ok(state)
2599+
}
2600+
2601+
/// Aggregate counterpart to [`Self::hmr_update`]: a single `Update` whose
2602+
/// `EcmascriptMergedUpdate` is the union of per-chunk diffs under
2603+
/// `target`'s root.
2604+
///
2605+
/// All-or-nothing restart: any chunk needing `Total`/`Missing` escalates
2606+
/// the whole batch to `Total` (the runtime can't partially restart). New
2607+
/// chunks absent from `from` are skipped; the runtime require()s them on
2608+
/// demand.
2609+
#[turbo_tasks::function]
2610+
pub async fn all_hmr_update(
2611+
self: Vc<Self>,
2612+
target: HmrTarget,
2613+
from: Vc<VersionState>,
2614+
) -> Result<Vc<Update>> {
2615+
if target == HmrTarget::Client {
2616+
bail!("all_hmr_update is not yet implemented for the client target");
2617+
}
2618+
2619+
let Some(map) = self.await?.versioned_content_map else {
2620+
bail!("must be in dev mode to hmr")
2621+
};
2622+
let root = self.hmr_root_path(target).owned().await?;
2623+
let chunks_versioned_content = map.hmr_chunks_in_path(&root).await?;
2624+
2625+
// No chunks to diff yet (e.g. before any endpoints have been written).
2626+
if chunks_versioned_content.is_empty() {
2627+
return Ok(Update::None.cell());
2628+
}
2629+
2630+
// Build `to` up front so we can return it on every escape hatch below.
2631+
let to_aggregate = AggregateHmrVersion::from_chunks(&chunks_versioned_content).await?;
2632+
let to_ref = Vc::upcast::<Box<dyn Version>>(to_aggregate)
2633+
.into_trait_ref()
2634+
.await?;
2635+
2636+
let DiffResult {
2637+
chunk_updates,
2638+
has_new_chunks,
2639+
} = diff_chunks_against(&chunks_versioned_content, from).await?;
2640+
2641+
// Seed transition: `from` isn't an `AggregateHmrVersion` yet, so
2642+
// `diff_chunks_against` returned no updates. Emit an empty Partial to
2643+
// advance state to `to` without triggering a restart.
2644+
if chunk_updates.is_empty() && !has_new_chunks {
2645+
return Ok(
2646+
merged_partial_update(to_ref, FxHashMap::default(), FxHashMap::default()).cell(),
2647+
);
2648+
}
2649+
2650+
let mut combined_entries: FxHashMap<String, serde_json::Value> = FxHashMap::default();
2651+
let mut combined_chunks: FxHashMap<String, serde_json::Value> = FxHashMap::default();
2652+
for (_path, update) in chunk_updates {
2653+
match &*update {
2654+
Update::None => {}
2655+
Update::Missing | Update::Total(_) => {
2656+
return Ok(Update::Total(TotalUpdate { to: to_ref }).cell());
2657+
}
2658+
Update::Partial(PartialUpdate { instruction, .. }) => {
2659+
merge_ecmascript_merged_update(
2660+
&mut combined_entries,
2661+
&mut combined_chunks,
2662+
instruction,
2663+
);
2664+
}
2665+
}
2666+
}
2667+
2668+
if combined_entries.is_empty() && combined_chunks.is_empty() && !has_new_chunks {
2669+
return Ok(Update::None.cell());
2670+
}
2671+
2672+
Ok(merged_partial_update(to_ref, combined_entries, combined_chunks).cell())
2673+
}
2674+
25502675
/// Gets a list of all HMR chunk names that can be subscribed to for the
25512676
/// specified target. Used by the dev server to set up server-side HMR
25522677
/// subscriptions for all Node.js App Router entries (pages and route
2553-
/// handlers).
2678+
/// handlers). See [`is_hmr_eligible_chunk`] for the eligibility rule.
25542679
#[turbo_tasks::function]
25552680
pub async fn hmr_chunk_names(self: Vc<Self>, target: HmrTarget) -> Result<Vc<Vec<RcStr>>> {
25562681
if let Some(map) = self.await?.versioned_content_map {
2557-
Ok(map.keys_in_path(self.hmr_root_path(target).owned().await?))
2682+
let names = map
2683+
.keys_in_path(self.hmr_root_path(target).owned().await?)
2684+
.await?;
2685+
let filtered: Vec<RcStr> = names
2686+
.iter()
2687+
.filter(|name| is_hmr_eligible_chunk(name))
2688+
.cloned()
2689+
.collect();
2690+
Ok(Vc::cell(filtered))
25582691
} else {
25592692
bail!("must be in dev mode to hmr")
25602693
}

0 commit comments

Comments
 (0)