Skip to content

Commit 2c63e80

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 f965c00 commit 2c63e80

10 files changed

Lines changed: 605 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
@@ -80,7 +80,8 @@ use turbopack_core::{
8080
reference_type::{CommonJsReferenceSubType, ReferenceType},
8181
resolve::{FindContextFileResult, find_context_file},
8282
version::{
83-
NotFoundVersion, OptionVersionedContent, Update, Version, VersionState, VersionedContent,
83+
NotFoundVersion, OptionVersionedContent, PartialUpdate, TotalUpdate, Update, Version,
84+
VersionState, VersionedContent,
8485
},
8586
};
8687
#[cfg(feature = "process_pool")]
@@ -91,6 +92,10 @@ use turbopack_node::worker_threads_backend;
9192
use turbopack_nodejs::NodeJsChunkingContext;
9293

9394
use crate::{
95+
aggregate_hmr::{
96+
AggregateHmrVersion, DiffResult, diff_chunks_against, is_hmr_eligible_chunk,
97+
merge_ecmascript_merged_update, merged_partial_update,
98+
},
9499
app::{AppProject, OptionAppProject},
95100
empty::EmptyEndpoint,
96101
entrypoints::Entrypoints,
@@ -2498,14 +2503,142 @@ impl Project {
24982503
}
24992504
}
25002505

2506+
/// Aggregate counterpart to [`Self::hmr_version_state`]: one [`VersionState`]
2507+
/// covering every HMR-eligible chunk under `target`'s root. See
2508+
/// [`Self::all_hmr_update`].
2509+
#[turbo_tasks::function]
2510+
pub async fn all_hmr_version_state(
2511+
self: ResolvedVc<Self>,
2512+
target: HmrTarget,
2513+
session: TransientInstance<()>,
2514+
) -> Result<Vc<VersionState>> {
2515+
if target == HmrTarget::Client {
2516+
bail!("all_hmr_version_state is not yet implemented for the client target");
2517+
}
2518+
2519+
// The session argument keeps this from caching across sessions.
2520+
let _ = session;
2521+
2522+
#[tracing::instrument(
2523+
level = "info",
2524+
name = "get aggregate HMR version",
2525+
skip_all,
2526+
fields(target = %target),
2527+
)]
2528+
#[turbo_tasks::function(operation, root)]
2529+
async fn aggregate_hmr_version_operation(
2530+
this: ResolvedVc<Project>,
2531+
target: HmrTarget,
2532+
) -> Result<Vc<Box<dyn Version>>> {
2533+
let Some(map) = this.await?.versioned_content_map else {
2534+
bail!("must be in dev mode to hmr")
2535+
};
2536+
let root = this.hmr_root_path(target).owned().await?;
2537+
AggregateHmrVersion::from_map(*map, &root).await
2538+
}
2539+
let version_op = aggregate_hmr_version_operation(self, target);
2540+
2541+
// INVALIDATION: untracked initial read; the subscription drives invalidation.
2542+
let state = VersionState::new(
2543+
version_op
2544+
.read_trait_strongly_consistent()
2545+
.untracked()
2546+
.await?,
2547+
)
2548+
.await?;
2549+
Ok(state)
2550+
}
2551+
2552+
/// Aggregate counterpart to [`Self::hmr_update`]: a single `Update` whose
2553+
/// `EcmascriptMergedUpdate` is the union of per-chunk diffs under
2554+
/// `target`'s root.
2555+
///
2556+
/// All-or-nothing restart: any chunk needing `Total`/`Missing` escalates
2557+
/// the whole batch to `Total` (the runtime can't partially restart). New
2558+
/// chunks absent from `from` are skipped; the runtime require()s them on
2559+
/// demand.
2560+
#[turbo_tasks::function]
2561+
pub async fn all_hmr_update(
2562+
self: Vc<Self>,
2563+
target: HmrTarget,
2564+
from: Vc<VersionState>,
2565+
) -> Result<Vc<Update>> {
2566+
if target == HmrTarget::Client {
2567+
bail!("all_hmr_update is not yet implemented for the client target");
2568+
}
2569+
2570+
let Some(map) = self.await?.versioned_content_map else {
2571+
bail!("must be in dev mode to hmr")
2572+
};
2573+
let root = self.hmr_root_path(target).owned().await?;
2574+
let chunks_versioned_content = map.hmr_chunks_in_path(&root).await?;
2575+
2576+
// No chunks to diff yet (e.g. before any endpoints have been written).
2577+
if chunks_versioned_content.is_empty() {
2578+
return Ok(Update::None.cell());
2579+
}
2580+
2581+
// Build `to` up front so we can return it on every escape hatch below.
2582+
let to_aggregate = AggregateHmrVersion::from_chunks(&chunks_versioned_content).await?;
2583+
let to_ref = Vc::upcast::<Box<dyn Version>>(to_aggregate)
2584+
.into_trait_ref()
2585+
.await?;
2586+
2587+
let DiffResult {
2588+
chunk_updates,
2589+
has_new_chunks,
2590+
} = diff_chunks_against(&chunks_versioned_content, from).await?;
2591+
2592+
// Seed transition: `from` isn't an `AggregateHmrVersion` yet, so
2593+
// `diff_chunks_against` returned no updates. Emit an empty Partial to
2594+
// advance state to `to` without triggering a restart.
2595+
if chunk_updates.is_empty() && !has_new_chunks {
2596+
return Ok(
2597+
merged_partial_update(to_ref, FxHashMap::default(), FxHashMap::default()).cell(),
2598+
);
2599+
}
2600+
2601+
let mut combined_entries: FxHashMap<String, serde_json::Value> = FxHashMap::default();
2602+
let mut combined_chunks: FxHashMap<String, serde_json::Value> = FxHashMap::default();
2603+
for (_path, update) in chunk_updates {
2604+
match &*update {
2605+
Update::None => {}
2606+
Update::Missing | Update::Total(_) => {
2607+
return Ok(Update::Total(TotalUpdate { to: to_ref }).cell());
2608+
}
2609+
Update::Partial(PartialUpdate { instruction, .. }) => {
2610+
merge_ecmascript_merged_update(
2611+
&mut combined_entries,
2612+
&mut combined_chunks,
2613+
instruction,
2614+
);
2615+
}
2616+
}
2617+
}
2618+
2619+
if combined_entries.is_empty() && combined_chunks.is_empty() && !has_new_chunks {
2620+
return Ok(Update::None.cell());
2621+
}
2622+
2623+
Ok(merged_partial_update(to_ref, combined_entries, combined_chunks).cell())
2624+
}
2625+
25012626
/// Gets a list of all HMR chunk names that can be subscribed to for the
25022627
/// specified target. Used by the dev server to set up server-side HMR
25032628
/// subscriptions for all Node.js App Router entries (pages and route
2504-
/// handlers).
2629+
/// handlers). See [`is_hmr_eligible_chunk`] for the eligibility rule.
25052630
#[turbo_tasks::function]
25062631
pub async fn hmr_chunk_names(self: Vc<Self>, target: HmrTarget) -> Result<Vc<Vec<RcStr>>> {
25072632
if let Some(map) = self.await?.versioned_content_map {
2508-
Ok(map.keys_in_path(self.hmr_root_path(target).owned().await?))
2633+
let names = map
2634+
.keys_in_path(self.hmr_root_path(target).owned().await?)
2635+
.await?;
2636+
let filtered: Vec<RcStr> = names
2637+
.iter()
2638+
.filter(|name| is_hmr_eligible_chunk(name))
2639+
.cloned()
2640+
.collect();
2641+
Ok(Vc::cell(filtered))
25092642
} else {
25102643
bail!("must be in dev mode to hmr")
25112644
}

0 commit comments

Comments
 (0)