From c11dc4e3b20af6cd64350df3f34fb985c61b1998 Mon Sep 17 00:00:00 2001 From: "Tomasz (Tom) Kramkowski" Date: Fri, 22 May 2026 17:03:16 +0100 Subject: [PATCH] Avoid conflict set combinatorial explosion Fix the combinatorial explosion caused by large conflict sets. `without_extras` would redo a lot of the same work repeatedly on a large graph of nodes, but it is now memoized. Some inspiration from work by Zanie Blue (762b366). --- crates/uv-pep508/src/marker/algebra.rs | 31 ++++++-- crates/uv/tests/it/lock_conflict.rs | 102 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/crates/uv-pep508/src/marker/algebra.rs b/crates/uv-pep508/src/marker/algebra.rs index e9a138ff13104..214330b15da5e 100644 --- a/crates/uv-pep508/src/marker/algebra.rs +++ b/crates/uv-pep508/src/marker/algebra.rs @@ -558,27 +558,46 @@ impl InternerGuard<'_> { /// `((os_name == ... and extra == foo) or (sys_platform == ... and extra != foo))`, /// this would return a marker /// `os_name == ... or sys_platform == ...`. - pub(crate) fn without_extras(&mut self, mut i: NodeId) -> NodeId { + pub(crate) fn without_extras(&mut self, i: NodeId) -> NodeId { + let mut cache = FxHashMap::default(); + self.without_extras_cached(i, &mut cache) + } + + fn without_extras_cached( + &mut self, + mut i: NodeId, + cache: &mut FxHashMap, + ) -> NodeId { if matches!(i, NodeId::TRUE | NodeId::FALSE) { return i; } + if let Some(&cached) = cache.get(&i) { + return cached; + } + + let original = i; let parent = i; let node = self.shared.node(i); - if matches!(node.var, Variable::Extra(_)) { + let result = if matches!(node.var, Variable::Extra(_)) { i = NodeId::FALSE; for child in node.children.nodes() { i = self.or(i, child.negate(parent)); } if i.is_true() { - return NodeId::TRUE; + NodeId::TRUE + } else { + self.without_extras_cached(i, cache) } - self.without_extras(i) } else { // Restrict all nodes recursively. - let children = node.children.map(i, |node| self.without_extras(node)); + let children = node + .children + .map(i, |node| self.without_extras_cached(node, cache)); self.create_node(node.var.clone(), children) - } + }; + cache.insert(original, result); + result } /// Returns a new tree where the only nodes remaining are `extra` nodes. diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index d2f02118459b4..e3e5744b50ac7 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use anyhow::Result; use assert_fs::prelude::*; use insta::assert_snapshot; @@ -16568,3 +16570,103 @@ fn project_level_conflict_with_group() -> Result<()> { Ok(()) } + +/// See: +#[test] +fn many_conflicts_with_requested_dependency_extra() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let root_pyproject_toml = context.temp_dir.child("pyproject.toml"); + // 29 conflicts results in a reasonably short run-time after fixing, and a very long runtime + // without the fix. This is to attempt to ensure that this test won't fail sporadically on a + // slow machine, or succeed (while broken) on a super fast machine. + root_pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = "==3.12.*" + dependencies = [] + + [project.optional-dependencies] + x00 = ["branch-package"] + + [tool.uv.sources] + branch-package = { path = "branch-package" } + + [tool.uv] + conflicts = [[ + { extra = "x00" }, + { extra = "x01" }, + { extra = "x02" }, + { extra = "x03" }, + { extra = "x04" }, + { extra = "x05" }, + { extra = "x06" }, + { extra = "x07" }, + { extra = "x08" }, + { extra = "x09" }, + { extra = "x10" }, + { extra = "x11" }, + { extra = "x12" }, + { extra = "x13" }, + { extra = "x14" }, + { extra = "x15" }, + { extra = "x16" }, + { extra = "x17" }, + { extra = "x18" }, + { extra = "x19" }, + { extra = "x20" }, + { extra = "x21" }, + { extra = "x22" }, + { extra = "x23" }, + { extra = "x24" }, + { extra = "x25" }, + { extra = "x26" }, + { extra = "x27" }, + { extra = "x28" }, + ]] + "#, + )?; + + let branch_pyproject_toml = context + .temp_dir + .child("branch-package") + .child("pyproject.toml"); + branch_pyproject_toml.write_str( + r#" + [project] + name = "branch-package" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["leaf-package[easy]"] + + [tool.uv.sources] + leaf-package = { path = "../leaf-package" } + "#, + )?; + + let leaf_pyproject_toml = context + .temp_dir + .child("leaf-package") + .child("pyproject.toml"); + leaf_pyproject_toml.write_str( + r#" + [project] + name = "leaf-package" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + easy = [] + "#, + )?; + + assert_cmd::Command::from_std(context.lock()) + .timeout(Duration::from_mins(1)) + .assert() + .success(); + + Ok(()) +}