perf: close the warm-resolve, symlink-churn, and download-concurrency gaps (#12329)

## Motivation

The [vlt.sh benchmarks](https://benchmarks.vlt.sh/) (2026-06-11 run, pacquet 0.11.3) show pacquet several times slower than the fastest package managers in the warm-metadata fresh-resolve cells (`cache`: 3.9–8.1x), the cold-cache frozen-install cells (`lockfile`: up to 10x on vue), and `clean`. Profiling the babylon and vue fixtures locally (macOS time profiles of the warm fresh resolve and the install tail) surfaced three independent causes, fixed here.

## Changes

### 1. Deprecation probing without manifest hydration (pacquet)

With `minimumReleaseAge` active (the default), every range pick runs `filter_pkg_metadata_by_publish_date`, and any dist-tag pointing outside the maturity cutoff (`next`, `beta`, `canary`, a too-fresh `latest`) repopulates by scanning all candidates and reading each candidate's `deprecated` flag. Each read hydrated the full version manifest — a complete `serde_json` parse including the flattened catch-all map. On babylon's warm fresh resolve this was the single largest CPU consumer (~10 thread-seconds, all on the resolve task's critical path).

`PackageVersions::is_deprecated` now answers from the raw fragment (substring pre-check, then a single-field deserialize with the same normalization as `PackageVersion::deprecated`), the tag-repopulation loop parses candidate versions once per filter call (mirroring the `parsedSemverCache` in pnpm's `filterPkgMetadataByPublishDate`), and the deprecated-pick fallback uses the probe instead of hydrating every version.

**babylon warm fresh resolve: `resolve_workspace` 7.5s → 2.6s.**

### 2. Relative-symlink up-to-date check (pacquet)

`force_symlink_dir` joined an existing link's relative contents onto the link parent and compared the result *verbatim* against the wanted target. Virtual-store links contain `..` segments (`../../<pkg>/node_modules/<name>`), so the joined path never compared equal and every up-to-date symlink was unlinked and recreated. Node's `path.relative` — which upstream `symlink-dir`'s `isExistingSymlinkUpToDate` builds on — resolves its arguments, so pnpm treats those links as current. Both sides now pass through `lexical_normalize`. The babylon install tail was dominated by exactly this unlink+symlink churn.

**babylon warm install: 6.8s → 4.7s; warm frozen install: 4.2s → 2.3s.**

### 3. Default network concurrency floor 16 → 64 (pnpm + pacquet)

The default was `min(64, max(workers * 3, 16))`. Downloads are I/O-bound, not CPU-bound: on a 4-vCPU CI runner the formula yields 16 concurrent requests, so a low-latency registry drains 600–1300-tarball installs 16 at a time while staying unsaturated — a large share of the cold-cell (`lockfile`/`clean`) gap on the benchmark runners. The default is now `min(96, max(workers * 3, 64))`; the `networkConcurrency` setting still overrides it. Applied to `@pnpm/installing.package-requester`, the lockfile-resolution verifier fan-out that mirrors its floor, and the same two spots in pacquet. Changeset included (minor). **This is a user-visible defaults change on both stacks — flagging it explicitly for review.**

## Local results (M-series macOS, vlt fixtures, isolated store/cache)

| cell | before | after |
|---|---|---|
| vue `cache` | 1159 ms | **479 ms** |
| vue `cache+lockfile` | 621 ms | **392 ms** |
| vue no-op install | 48 ms | **41 ms** |
| babylon `cache` | ~8.8 s | **4.75 s** |
| babylon `cache+lockfile` | ~4.2 s | **2.27 s** |

vue's warm cells are now ahead of every competitor measured locally; babylon's `cache` cell closed from ~2.5x behind the leader to ~1.35x (the remainder is the per-file store-integrity verify and per-file linking that the pnpm store contract requires).

## Validation

- `cargo nextest`: registry, resolving-npm-resolver, resolving-deps-resolver, lockfile-verification, network, fs, tarball, package-manager, cli — 1300+ tests, all green; new unit tests cover the deprecation probe (string/bool/empty/corrupt shapes, nested-key false positives) and cross-parent relative-symlink reuse (fails without the fix).
- Lockfile stability: `--lockfile-only` output is byte-identical before/after on vue; on babylon the resolved **package-version sets are identical across 6 runs (3 per binary)**. The babylon lockfile does flap between runs in the peer-suffix shape of `webpack-dev-server@5.2.2` (`(bufferutil@4.1.0)(utf-8-validate@5.0.10)` appearing/disappearing) — this is **pre-existing nondeterminism** reproducible with the unmodified binary against itself, in the optional-peer area; worth a separate issue.
- Pre-push checks (fmt, taplo, `cargo doc -D warnings`, dylint) pass; eslint (root config) and `tsgo --build` pass for the two touched TS packages.
This commit is contained in:
Zoltan Kochan
2026-06-11 19:39:45 +02:00
committed by GitHub
parent bc9ed78f48
commit 84bb4b1a04
15 changed files with 298 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/installing.package-requester": minor
"@pnpm/installing.deps-installer": minor
"pnpm": minor
---
Raised the default network concurrency from `min(64, max(cpuCores * 3, 16))` to `min(96, max(cpuCores * 3, 64))`. Package downloads are I/O-bound, not CPU-bound, so deriving the floor from the core count left machines with few cores (for example 4-vCPU CI runners) downloading only 16 tarballs at a time and unable to saturate a low-latency registry. The `networkConcurrency` setting still overrides the default.

View File

@@ -70,6 +70,17 @@ jobs:
with:
persist-credentials: false
# The clippy --all-targets pass plus the nextest build hold two
# full sets of debug artifacts in target/, which no longer fits
# in the hosted runner's free disk — the linker dies with ENOSPC
# / SIGBUS mid-build. Drop the preinstalled toolchains this job
# never uses (~25 GB) before compiling anything.
- name: Free up runner disk space
if: runner.os == 'Linux'
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
df -h /
- name: Install Rust Toolchain
uses: ./.github/actions/rustup
with:

View File

@@ -30,6 +30,15 @@ jobs:
runs-on: ${{ inputs.platform }}
steps:
# A near-full "affected packages" sweep plus the pnpr Rust build
# can exhaust the hosted runner's free disk mid-test (the runner
# worker itself dies with "No space left on device"). Drop the
# preinstalled toolchains this job never uses (~25 GB) first.
- name: Free up runner disk space
if: ${{ runner.os == 'Linux' }}
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
df -h /
- name: Configure Git
run: |
git config --global core.autocrlf false

View File

@@ -27,10 +27,10 @@ export type { ResolutionPolicyViolation }
// count is in the header and the remainder is summarized at the end.
const MAX_VIOLATIONS_TO_PRINT = 20
// 16 mirrors the floor of pnpm's package-requester network-concurrency
// (Math.min(64, Math.max(workers*3, 16))); keep them aligned so the
// 64 mirrors the floor of pnpm's package-requester network-concurrency
// (Math.min(96, Math.max(workers*3, 64))); keep them aligned so the
// verification pass doesn't push past what the rest of the install respects.
const DEFAULT_CONCURRENCY = 16
const DEFAULT_CONCURRENCY = 64
export const RESOLUTION_SHAPE_MISMATCH_VIOLATION_CODE = 'RESOLUTION_SHAPE_MISMATCH'

View File

@@ -94,7 +94,11 @@ export function createPackageRequester (
} {
opts = opts || {}
const networkConcurrency = opts.networkConcurrency ?? Math.min(64, Math.max(calcMaxWorkers() * 3, 16))
// Downloads are I/O-bound, not CPU-bound: a low-latency registry only
// saturates with enough requests in flight, so the floor matters more
// than the per-core scaling — a CPU-derived floor left 4-core CI
// runners draining multi-hundred-tarball installs 16 at a time.
const networkConcurrency = opts.networkConcurrency ?? Math.min(96, Math.max(calcMaxWorkers() * 3, 64))
const requestsQueue = new PQueue({
concurrency: networkConcurrency,
})

View File

@@ -269,16 +269,20 @@ fn force_symlink_inner(
/// existing link's contents to an absolute path (using `link`'s
/// parent dir when the contents are relative), then compare lexically
/// against `wanted`. Single-level — does not follow chained symlinks.
///
/// Both sides pass through [`fn@crate::lexical_normalize`] before comparing.
/// Node's `path.relative` resolves its arguments, so the `..`
/// segments in the relative link contents [`symlink_dir`] writes
/// collapse on the upstream side; without the same collapse here,
/// every up-to-date relative symlink reads as stale and pays an
/// unlink + recreate.
fn existing_symlink_up_to_date(wanted: &Path, link: &Path, existing_link_string: &Path) -> bool {
let existing_absolute = if existing_link_string.is_absolute() {
existing_link_string.to_path_buf()
} else {
link.parent().unwrap_or_else(|| Path::new("")).join(existing_link_string)
};
// `path.relative(a, b) === ''` returns true iff lexically equal,
// so a direct PathBuf equality check is the right Rust analogue
// once both sides are absolute.
existing_absolute == wanted
crate::lexical_normalize(&existing_absolute) == crate::lexical_normalize(wanted)
}
/// Remove a regular file or directory that's occupying a symlink

View File

@@ -209,3 +209,36 @@ fn read_symlink_dir_reads_back_what_force_symlink_dir_wrote() {
.expect("canonicalize read-back path");
assert_eq!(resolved_read, fs::canonicalize(&target).expect("canonicalize target"));
}
/// Same reuse contract when the link lives in a *different* parent
/// directory than the target, so the on-disk link contents contain
/// `..` segments (the virtual-store layout shape:
/// `.pnpm/a@1/node_modules/b` → `.pnpm/b@2/node_modules/b`). The
/// up-to-date check must collapse those segments like Node's
/// `path.relative` does; comparing the joined path verbatim reads
/// every such link as stale.
#[test]
fn force_symlink_dir_reuses_relative_link_across_parents() {
let root = tempdir().expect("create temp dir");
let target = root.path().join("store").join("b@2").join("node_modules").join("b");
let link = root.path().join("store").join("a@1").join("node_modules").join("b");
fs::create_dir_all(&target).expect("create target dir");
let first = force_symlink_dir(&target, &link).expect("first call");
assert_eq!(first, ForceSymlinkOutcome { reused: false, warning: None });
#[cfg(unix)]
{
let contents = fs::read_link(&link).expect("read link");
assert!(
contents.to_string_lossy().contains(".."),
"link contents should be relative with parent segments: {contents:?}",
);
}
let second = force_symlink_dir(&target, &link).expect("second call");
assert_eq!(
second,
ForceSymlinkOutcome { reused: true, warning: None },
"an up-to-date relative link must be reused, not rewritten",
);
}

View File

@@ -35,9 +35,9 @@ use crate::{
};
/// Default concurrency cap for the per-candidate fan-out. Mirrors
/// upstream's `DEFAULT_CONCURRENCY = 16` (the floor of pnpm's
/// upstream's `DEFAULT_CONCURRENCY = 64` (the floor of pnpm's
/// `package-requester` network-concurrency formula).
const DEFAULT_CONCURRENCY: usize = 16;
const DEFAULT_CONCURRENCY: usize = 64;
/// Options bundle for [`verify_lockfile_resolutions`]. Mirrors
/// upstream's
@@ -45,7 +45,7 @@ const DEFAULT_CONCURRENCY: usize = 16;
#[derive(Debug, Default, Clone)]
pub struct VerifyLockfileResolutionsOptions<'a> {
/// Cap on concurrent verifier futures. `None` falls back to
/// the internal `DEFAULT_CONCURRENCY` (`16`, matching upstream).
/// the internal `DEFAULT_CONCURRENCY` (`64`, matching upstream).
pub concurrency: Option<usize>,
/// Absolute path of the lockfile being verified. Required for
/// the on-disk verification cache (the stat shortcut + per-path

View File

@@ -591,12 +591,16 @@ fn build_scheme_proxy(
/// [`worker/src/index.ts`](https://github.com/pnpm/pnpm/blob/1819226b51/worker/src/index.ts#L63-L72):
///
/// ```ts
/// networkConcurrency = Math.min(64, Math.max(calcMaxWorkers() * 3, 16))
/// networkConcurrency = Math.min(96, Math.max(calcMaxWorkers() * 3, 64))
/// // calcMaxWorkers() = Math.max(1, availableParallelism() - 1)
/// ```
///
/// Concretely: 16 on a 4-core machine, 21 on 8-core, 27 on 10-core,
/// 45 on 16-core, capped at 64.
/// Concretely: 64 up to a 22-core machine, scaling with cores beyond
/// that, capped at 96. The floor matters more than the scaling:
/// downloads are I/O-bound, not CPU-bound, and a low-latency registry
/// only saturates when enough requests are in flight — a CPU-derived
/// floor left 4-core CI runners draining 600-tarball installs 16 at a
/// time, several times slower than the same network could serve.
///
/// Uses [`std::thread::available_parallelism`] rather than
/// `num_cpus::get()` so cgroup / CPU-quota limits in containers and
@@ -608,7 +612,7 @@ fn build_scheme_proxy(
pub fn default_network_concurrency() -> usize {
let available_parallelism = std::thread::available_parallelism().map_or(1, NonZeroUsize::get);
let max_workers = available_parallelism.saturating_sub(1).max(1);
max_workers.saturating_mul(3).clamp(16, 64)
max_workers.saturating_mul(3).clamp(64, 96)
}
/// This is only necessary for tests.

View File

@@ -678,3 +678,14 @@ fn for_installs_falls_back_on_unencodable_user_agent() {
)
.expect("unencodable user-agent falls back to default");
}
/// Pins the floor and cap of the default request-concurrency formula.
/// The floor exists because downloads are I/O-bound: deriving it from
/// the core count left low-core CI runners draining multi-hundred-
/// tarball installs 16 requests at a time without saturating a
/// low-latency registry.
#[test]
fn default_network_concurrency_stays_within_floor_and_cap() {
let concurrency = super::default_network_concurrency();
assert!((64..=96).contains(&concurrency), "got {concurrency}");
}

View File

@@ -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<Option<String>, Deser::Error>
where

View File

@@ -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<String>,
}
#[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::<DeprecatedProbe>(&json).is_ok_and(|probe| probe.deprecated.is_some())
}
/// Version strings, in `HashMap` order. Never hydrates.
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.slots.keys()

View File

@@ -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"));
}

View File

@@ -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<Vec<(Version, &String, OnceCell<bool>)>> = 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>)| -> bool {
*slot.2.get_or_init(|| versions_within_date.is_deprecated(slot.1))
};
let mut best_index: Option<usize> = 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());
}
}

View File

@@ -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",
);
}