diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e2ea8f4..1da5fb9cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Perf** `vp run` starts faster in workspaces whose tasks share cache `input` and `output` patterns: each distinct pattern is now parsed once instead of once for every task that uses it. In a 138-package workspace, this saves about 2 seconds of CPU on every run ([#887](https://github.com/voidzero-dev/vite-task/pull/887)). - **Perf** When tasks miss the cache, `vp run` no longer waits for the disk after saving each task's result, so the run finishes sooner. If the computer loses power or the operating system crashes during `vp run`, the cache entries that the run saved can be lost, and those tasks run again next time instead of hitting the cache ([#871](https://github.com/voidzero-dev/vite-task/pull/871)). - **Fixed** Running `vp run` in a package that sits inside a workspace folder but isn't listed in its `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` now runs that package's own tasks, instead of treating the enclosing workspace as its root. When both configurations exist, `pnpm-workspace.yaml` takes precedence ([#859](https://github.com/voidzero-dev/vite-task/pull/859)). - **Fixed** A mistyped or misplaced setting in a task now gets an error that names it. For example, `cache: { outputs: [...] }` now fails with an `unknown field` error that names `outputs` and lists the accepted settings, instead of `data did not match any variant of untagged enum UserTaskDefinition` ([vite-plus#2635](https://github.com/voidzero-dev/vite-plus/issues/2635), [#815](https://github.com/voidzero-dev/vite-task/pull/815)). diff --git a/crates/vt_graph/src/config/mod.rs b/crates/vt_graph/src/config/mod.rs index 967ef82a1..7e22df87a 100644 --- a/crates/vt_graph/src/config/mod.rs +++ b/crates/vt_graph/src/config/mod.rs @@ -1,8 +1,11 @@ pub mod user; -use std::{collections::BTreeSet, sync::Arc}; +use std::{ + collections::BTreeSet, + sync::{Arc, LazyLock, Mutex, PoisonError}, +}; -use rustc_hash::FxHashSet; +use rustc_hash::{FxHashMap, FxHashSet}; use serde::Serialize; pub use user::{ AutoTracking, Command, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig, @@ -291,13 +294,9 @@ fn resolve_glob_to_workspace_relative( pattern }; - let glob = wax::Glob::new(pattern).map_err(|source| ResolveTaskConfigError::InvalidGlob { - pattern: Str::from(pattern), - source: Box::new(source), - })?; - let (invariant_prefix, variant) = glob.partition(); + let partitioned = partition_glob(pattern)?; - let joined = package_dir.join(&invariant_prefix).clean(); + let joined = package_dir.join(partitioned.invariant_prefix.as_str()).clean(); let stripped = joined.strip_prefix(workspace_root).map_err(|_| { ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } })?; @@ -309,10 +308,8 @@ fn resolve_glob_to_workspace_relative( let escaped_prefix = wax::escape(stripped.as_str()); - let result = match variant { - Some(variant_glob) if escaped_prefix.is_empty() => { - Str::from(variant_glob.to_string().as_str()) - } + let result = match &partitioned.variant { + Some(variant_glob) if escaped_prefix.is_empty() => variant_glob.clone(), Some(variant_glob) => vt_str::format!("{escaped_prefix}/{variant_glob}"), None if escaped_prefix.is_empty() => Str::from("**"), None => Str::from(escaped_prefix.as_ref()), @@ -321,6 +318,45 @@ fn resolve_glob_to_workspace_relative( Ok(result) } +/// A glob pattern split into its literal prefix and the glob that follows it. +struct PartitionedGlob { + invariant_prefix: Str, + variant: Option, +} + +/// Parse and partition `pattern`, at most once per distinct pattern in the process. +/// +/// `wax` compiles a regular expression both when it parses a glob and when it partitions one, +/// and only the partition's text is kept here. The result depends on the pattern alone, while +/// workspaces repeat the same patterns across packages and tasks, so loading a task graph would +/// otherwise compile the same expressions thousands of times. +fn partition_glob(pattern: &str) -> Result, ResolveTaskConfigError> { + static PARTITIONED: LazyLock>>> = + LazyLock::new(Mutex::default); + + if let Some(partitioned) = + PARTITIONED.lock().unwrap_or_else(PoisonError::into_inner).get(pattern) + { + return Ok(Arc::clone(partitioned)); + } + + let invalid = |source| ResolveTaskConfigError::InvalidGlob { + pattern: Str::from(pattern), + source: Box::new(source), + }; + let (invariant_prefix, variant) = wax::Glob::new(pattern).map_err(invalid)?.partition(); + let partitioned = Arc::new(PartitionedGlob { + // The prefix is made of the pattern's own literal components, so this is lossless. + invariant_prefix: Str::from(invariant_prefix.to_string_lossy().as_ref()), + variant: variant.map(|glob| Str::from(glob.to_string().as_str())), + }); + PARTITIONED + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(Str::from(pattern), Arc::clone(&partitioned)); + Ok(partitioned) +} + #[derive(Debug, Clone, Serialize)] pub struct EnvConfig { /// environment variable names to be fingerprinted and passed to the task, with defaults populated @@ -624,6 +660,27 @@ mod tests { ); } + #[test] + fn test_resolved_input_config_same_pattern_in_two_packages() { + // A pattern is parsed once per process; each package still resolves it against its own + // directory, and the workspace check still applies to each use. + let (pkg, ws) = test_paths(); + let other = pkg.parent().unwrap().join("other-pkg"); + let user_inputs = vec![UserInputEntry::Glob("src/**".into())]; + let own = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let from_other = + ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws).unwrap(); + assert!(own.positive_globs.contains("packages/my-pkg/src/**"), "{:?}", own.positive_globs); + assert!( + from_other.positive_globs.contains("packages/other-pkg/src/**"), + "{:?}", + from_other.positive_globs + ); + let user_inputs = vec![UserInputEntry::Glob("../../../outside/**".into())]; + assert!(ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).is_err()); + assert!(ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws).is_err()); + } + #[test] fn test_resolved_input_config_outside_workspace_error() { let (pkg, ws) = test_paths();