From 9a3cfa40fd0e19728bb71f4e362db45785c2e9f8 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 11 Jun 2026 17:17:34 +0200 Subject: [PATCH] perf(resolving-npm-resolver): probe deprecation without hydrating version manifests With minimumReleaseAge active (the default), every range pick runs filter_pkg_metadata_by_publish_date, and any dist-tag pointing at a version outside the maturity cutoff repopulates by scanning all candidate versions, reading each candidate's deprecated flag. Reading that flag through PackageVersions::get hydrated the full manifest fragment (including the flattened catch-all map), which a time profile of a warm-cache fresh resolve of the babylon fixture showed as the single largest CPU consumer (~10 thread-seconds, all on the resolve task's critical path). PackageVersions::is_deprecated answers the same question from the raw fragment: a substring pre-check for the key text, then a single-field deserialize using the same normalization as PackageVersion::deprecated. The tag-repopulation loop now also parses candidate versions once per filter call instead of once per tag, matching the parsedSemverCache in pnpm's filterPkgMetadataByPublishDate, and the deprecated-pick fallback in pick_version_by_version_range uses the probe instead of hydrating every version. Warm-cache fresh resolve of the babylon fixture: resolve_workspace 7.5s -> 2.6s. Resolved version sets are byte-identical before/after across the vue and babylon fixtures. --- .../crates/registry/src/package_version.rs | 2 +- .../crates/registry/src/package_versions.rs | 41 +++++++++++- .../registry/src/package_versions/tests.rs | 57 ++++++++++++++++ .../src/pick_package_from_meta.rs | 60 ++++++++++------- .../src/pick_package_from_meta/tests.rs | 65 +++++++++++++++++++ 5 files changed, 200 insertions(+), 25 deletions(-) diff --git a/pacquet/crates/registry/src/package_version.rs b/pacquet/crates/registry/src/package_version.rs index bb46cc3fe8..cdce3918e2 100644 --- a/pacquet/crates/registry/src/package_version.rs +++ b/pacquet/crates/registry/src/package_version.rs @@ -151,7 +151,7 @@ where /// A bool `true` becomes `Some("")`, a bool `false` becomes `None`; /// a string stays as `Some(s)`. Missing field defaults to `None` via /// the `#[serde(default)]` on the field itself. -fn deserialize_deprecated_field<'de, Deser>( +pub(crate) fn deserialize_deprecated_field<'de, Deser>( deserializer: Deser, ) -> Result, Deser::Error> where diff --git a/pacquet/crates/registry/src/package_versions.rs b/pacquet/crates/registry/src/package_versions.rs index 04c93799c4..3265435c54 100644 --- a/pacquet/crates/registry/src/package_versions.rs +++ b/pacquet/crates/registry/src/package_versions.rs @@ -24,7 +24,16 @@ use std::{ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::value::RawValue; -use crate::package_version::PackageVersion; +use crate::package_version::{PackageVersion, deserialize_deprecated_field}; + +/// Single-field view of a version manifest for +/// [`PackageVersions::is_deprecated`] — same normalization as +/// [`PackageVersion::deprecated`], every other field skipped. +#[derive(Deserialize)] +struct DeprecatedProbe { + #[serde(default, deserialize_with = "deserialize_deprecated_field")] + deprecated: Option, +} #[derive(Debug, Default, Clone)] pub struct PackageVersions { @@ -141,6 +150,36 @@ impl PackageVersions { self.slots.contains_key(version) } + /// Whether `version` is marked deprecated, equivalent to + /// `get(version).is_some_and(|manifest| manifest.deprecated.is_some())` + /// but without hydrating the full manifest: an unhydrated fragment + /// is probed with a single-field deserialize (skipped entirely when + /// the fragment text doesn't contain the `"deprecated"` key). + /// An absent version or an undecodable fragment reads as not + /// deprecated, matching the "undecodable fragment behaves as if + /// the version were absent" contract of [`PackageVersions::get`]. + /// + /// The pick paths consult deprecation for *many* candidate + /// versions per packument (dist-tag repopulation, the + /// deprecated-pick fallback); hydrating each candidate parses its + /// whole manifest — including the flattened catch-all map — and + /// dominated warm-resolve CPU. + #[must_use] + pub fn is_deprecated(&self, version: &str) -> bool { + let Some(slot) = self.slots.get(version) else { return false }; + if let Some(parsed) = slot.parsed.get() { + return parsed.as_ref().is_some_and(|manifest| manifest.deprecated.is_some()); + } + let Some(json) = slot.source.json() else { return false }; + // Absence of the key text anywhere in the fragment proves the + // field is absent; presence (possibly in an unrelated nested + // position) falls through to the real parse. + if !json.contains(r#""deprecated""#) { + return false; + } + serde_json::from_str::(&json).is_ok_and(|probe| probe.deprecated.is_some()) + } + /// Version strings, in `HashMap` order. Never hydrates. pub fn keys(&self) -> impl Iterator { self.slots.keys() diff --git a/pacquet/crates/registry/src/package_versions/tests.rs b/pacquet/crates/registry/src/package_versions/tests.rs index d1108cd602..d717f293d6 100644 --- a/pacquet/crates/registry/src/package_versions/tests.rs +++ b/pacquet/crates/registry/src/package_versions/tests.rs @@ -126,3 +126,60 @@ fn latest_returns_none_for_dangling_or_undecodable_tag() { parse_package(r#"{"name": "foo", "dist-tags": {"latest": "9.9.9"}, "versions": {}}"#); assert!(dangling.latest().is_none()); } + +#[test] +fn is_deprecated_probes_without_hydrating() { + let package = parse_package( + r#"{ + "name": "foo", + "dist-tags": {}, + "versions": { + "1.0.0": {"name": "foo", "version": "1.0.0", "dist": {"integrity": "sha512-a", "tarball": "https://r/foo-1.0.0.tgz"}}, + "1.1.0": {"name": "foo", "version": "1.1.0", "deprecated": "use 2.x", "dist": {"integrity": "sha512-b", "tarball": "https://r/foo-1.1.0.tgz"}}, + "1.2.0": {"name": "foo", "version": "1.2.0", "deprecated": false, "dist": {"integrity": "sha512-c", "tarball": "https://r/foo-1.2.0.tgz"}}, + "1.3.0": {"name": "foo", "version": "1.3.0", "deprecated": true, "dist": {"integrity": "sha512-d", "tarball": "https://r/foo-1.3.0.tgz"}}, + "1.4.0": {"name": "foo", "version": "1.4.0", "deprecated": "", "dist": {"integrity": "sha512-e", "tarball": "https://r/foo-1.4.0.tgz"}}, + "1.9.0": {"corrupt": "fragment", "deprecated": 1} + } + }"#, + ); + + assert!(!package.versions.is_deprecated("1.0.0")); + assert!(package.versions.is_deprecated("1.1.0")); + assert!(!package.versions.is_deprecated("1.2.0")); + assert!(package.versions.is_deprecated("1.3.0")); + assert!(package.versions.is_deprecated("1.4.0")); + // Absent version and undecodable-probe fragments read as not + // deprecated, matching the absent-version contract of `get`. + assert!(!package.versions.is_deprecated("9.9.9")); + assert!(!package.versions.is_deprecated("1.9.0")); + + // The probe must agree with the hydrated field on every slot it + // can hydrate, and must not have hydrated anything itself: `get` + // still parses fresh (no cached Arc identity from the probe). + for version in ["1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0"] { + let manifest = package.versions.get(version).expect("hydrate"); + assert_eq!( + package.versions.is_deprecated(version), + manifest.deprecated.is_some(), + "probe vs hydrated disagree for {version}", + ); + } +} + +/// A fragment that mentions `"deprecated"` only in an unrelated nested +/// position (a dependency literally named `deprecated`) must fall +/// through to the real parse and report not-deprecated. +#[test] +fn is_deprecated_ignores_unrelated_key_text() { + let package = parse_package( + r#"{ + "name": "foo", + "dist-tags": {}, + "versions": { + "1.0.0": {"name": "foo", "version": "1.0.0", "dependencies": {"deprecated": "^0.0.2"}, "dist": {"integrity": "sha512-a", "tarball": "https://r/foo-1.0.0.tgz"}} + } + }"#, + ); + assert!(!package.versions.is_deprecated("1.0.0")); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs b/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs index 80dc428bdc..4fafbf2011 100644 --- a/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs +++ b/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs @@ -27,6 +27,7 @@ //! module pulls in no I/O. use std::{ + cell::OnceCell, collections::BTreeMap, sync::{Arc, LazyLock}, }; @@ -345,15 +346,12 @@ pub fn pick_version_by_version_range( // non-deprecated subset. Matches upstream's loop at // pickPackageFromMeta.ts#L194-L201. if let Some(ref picked) = max_pick { - let picked_meta = opts.meta.versions.get(picked); - let picked_is_deprecated = picked_meta.is_some_and(|version| version.deprecated.is_some()); + let picked_is_deprecated = opts.meta.versions.is_deprecated(picked); if picked_is_deprecated && all_versions.len() > 1 { - let non_deprecated: Vec<&str> = opts - .meta - .versions + let non_deprecated: Vec<&str> = all_versions .iter() - .filter(|(_, manifest)| manifest.deprecated.is_none()) - .map(|(name, _)| name.as_str()) + .copied() + .filter(|version| !opts.meta.versions.is_deprecated(version)) .collect(); if let Some(non_deprecated_max) = max_satisfying(&non_deprecated, opts.version_range) { return Some(non_deprecated_max); @@ -431,6 +429,14 @@ pub fn filter_pkg_metadata_by_publish_date( }); let mut dist_tags_within_date = std::collections::HashMap::new(); + // Candidate versions parsed once per filter call and shared by + // every repopulated tag, with the deprecation flag resolved + // lazily per candidate — mirrors upstream's `parsedSemverCache`. + // Deprecation goes through [`PackageVersions::is_deprecated`] + // instead of hydrating each candidate's manifest; the hydration + // per comparison dominated warm-resolve CPU on packuments with + // out-of-cutoff dist-tags. + let mut parsed_candidates: Option)>> = None; for (tag, version) in &meta.dist_tags { if versions_within_date.contains_key(version) { dist_tags_within_date.insert(tag.clone(), version.clone()); @@ -438,35 +444,43 @@ pub fn filter_pkg_metadata_by_publish_date( } let Ok(original) = Version::parse(version) else { continue }; let original_is_prerelease = !original.pre_release.is_empty(); - let mut best_version: Option<(Version, &String)> = None; - for candidate_raw in versions_within_date.keys() { - let Ok(candidate) = Version::parse(candidate_raw) else { continue }; + let candidates = parsed_candidates.get_or_insert_with(|| { + versions_within_date + .keys() + .filter_map(|raw| { + Version::parse(raw).ok().map(|parsed| (parsed, raw, OnceCell::new())) + }) + .collect() + }); + let deprecated = |slot: &(Version, &String, OnceCell)| -> bool { + *slot.2.get_or_init(|| versions_within_date.is_deprecated(slot.1)) + }; + let mut best_index: Option = None; + for (index, slot) in candidates.iter().enumerate() { + let (candidate, _, _) = slot; if tag != "latest" && candidate.major != original.major { continue; } if candidate.pre_release.is_empty() == original_is_prerelease { continue; } - match best_version { - None => best_version = Some((candidate, candidate_raw)), - Some((ref best, best_raw)) => { - let best_deprecated = versions_within_date - .get(best_raw) - .is_some_and(|manifest| manifest.deprecated.is_some()); - let candidate_deprecated = versions_within_date - .get(candidate_raw) - .is_some_and(|manifest| manifest.deprecated.is_some()); - let candidate_wins = (candidate > *best + match best_index { + None => best_index = Some(index), + Some(best) => { + let best_slot = &candidates[best]; + let best_deprecated = deprecated(best_slot); + let candidate_deprecated = deprecated(slot); + let candidate_wins = (*candidate > best_slot.0 && best_deprecated == candidate_deprecated) || (best_deprecated && !candidate_deprecated); if candidate_wins { - best_version = Some((candidate, candidate_raw)); + best_index = Some(index); } } } } - if let Some((_, best_raw)) = best_version { - dist_tags_within_date.insert(tag.clone(), best_raw.clone()); + if let Some(best) = best_index { + dist_tags_within_date.insert(tag.clone(), candidates[best].1.clone()); } } diff --git a/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta/tests.rs b/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta/tests.rs index 7da94d6d4b..bfeb09826b 100644 --- a/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta/tests.rs +++ b/pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta/tests.rs @@ -602,3 +602,68 @@ fn pick_from_meta_returns_none_for_undecodable_exact_version() { .expect("ok"); assert!(picked.is_none()); } + +/// The dist-tag repopulation tie-break prefers a non-deprecated +/// candidate over a higher deprecated one (and only falls back to +/// deprecated candidates when nothing else matches the tag family). +#[test] +fn filter_tag_rewrite_prefers_non_deprecated_candidate() { + let mut pkg = make_package( + "acme", + &[ + ("2.1.0", None), + ("2.2.0", Some("use 3.x")), + ("2.5.0", None), // dropped by the cutoff + ], + &[("old", "2.5.0")], + ); + pkg.time = Some(make_time_map(&[ + ("2.1.0", "2024-01-01T00:00:00.000Z"), + ("2.2.0", "2024-06-01T00:00:00.000Z"), + ("2.5.0", "2025-06-01T00:00:00.000Z"), + ])); + let cutoff = parse_iso("2025-01-01T00:00:00.000Z"); + let filtered = filter_pkg_metadata_by_publish_date(&pkg, cutoff, None); + assert_eq!( + filtered.dist_tag("old"), + Some("2.1.0"), + "non-deprecated 2.1.0 must beat the higher deprecated 2.2.0", + ); +} + +/// The repopulation tie-break must read deprecation from the raw +/// fragments without full-manifest hydration. The fragments here are +/// valid JSON but not decodable as complete version manifests, so a +/// tie-break that hydrates to check `deprecated` sees every candidate +/// as undecodable (= not deprecated) and picks the higher deprecated +/// version instead. Guards the no-hydration deprecation probe the +/// publish-date filter relies on — also matching pnpm, which reads +/// `versions[candidate].deprecated` off plain parsed JSON without +/// validating the rest of the entry. +#[test] +fn filter_tag_rewrite_reads_deprecation_from_raw_fragments() { + let mut pkg: Package = serde_json::from_str( + r#"{ + "name": "acme", + "dist-tags": {"old": "2.5.0"}, + "versions": { + "2.1.0": {"name": "acme"}, + "2.2.0": {"name": "acme", "deprecated": "use 3.x"}, + "2.5.0": {"name": "acme"} + } + }"#, + ) + .expect("parse package"); + pkg.time = Some(make_time_map(&[ + ("2.1.0", "2024-01-01T00:00:00.000Z"), + ("2.2.0", "2024-06-01T00:00:00.000Z"), + ("2.5.0", "2025-06-01T00:00:00.000Z"), + ])); + let cutoff = parse_iso("2025-01-01T00:00:00.000Z"); + let filtered = filter_pkg_metadata_by_publish_date(&pkg, cutoff, None); + assert_eq!( + filtered.dist_tag("old"), + Some("2.1.0"), + "deprecation must be read from the raw fragment, not via hydration", + ); +}