fix(pypi): resolve self-referencing extras to a real fixed point - #4039
Open
muravev-vasilii wants to merge 1 commit into
Open
fix(pypi): resolve self-referencing extras to a real fixed point#4039muravev-vasilii wants to merge 1 commit into
muravev-vasilii wants to merge 1 commit into
Conversation
The loop in _resolve_extras that resolves `pkg[extra]` entries from a
package's own Requires-Dist decided it had converged by comparing
num_extras_before -- the size of the extras set at the start of the
round -- against len(new_extras), the number of extras discovered
during that round. Those are unrelated quantities, and the mismatch
breaks in two separate ways.
It stops early whenever the two happen to be equal while the set is
still growing, so extras reachable only through a further round are
never resolved and every dependency gated on them is silently dropped.
The smallest case is a two-hop chain: given `foo[b]; extra == 'all'`
and `foo[c]; extra == 'b'`, requesting `foo[all]` resolves to
{all, b} and loses everything behind `extra == 'c'`. This is not
limited to the first round -- a chain that branches before it deepens
hits the same equality later.
Conversely, for a package with no self-referencing extras -- the
overwhelmingly common case -- new_extras is always empty while the set
holds at least one entry, so the condition never holds and the loop
runs all 10000 rounds, allocating a dict each time, while evaluating
the generated BUILD file of every wheel in the build. On a ~52k-package
repository this dominated loading-phase Starlark CPU: _resolve_extras
alone accounted for 430-442 CPU-s, and total Starlark user-function CPU
fell from 747-759 CPU-s to 275-298 CPU-s once fixed, worth roughly 11%
of cold loading+analysis wall time on a 16-core machine.
Compare the size of the merged set instead, which is what the
before/after naming already implied. The loop is monotonic, so the
converged set is unchanged wherever it previously terminated correctly.
The condition was introduced in bazel-contrib#3527. The three existing tests that
exercise self-extras chains pass either way, so this also adds
regression tests for the three shapes that trigger the early exit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
muravev-vasilii
force-pushed
the
fix/pypi-resolve-extras-fixed-point
branch
from
August 12, 2026 10:23
0a89189 to
18cd4c6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_resolve_extrasdecides that its fixed-point loop has converged by comparingnum_extras_before— the size of the extras set at the start of the round — againstlen(new_extras), the number of extras discovered during that round:Those are unrelated quantities, and the mismatch breaks in two separate ways.
1. Extras are silently dropped
The loop exits early whenever the two counts happen to coincide while the set is still growing, so extras reachable only through a further round are never resolved —- and every dependency gated on them silently disappears from the generated target.
The smallest reproducer is a two-hop self-extras chain:
Requires-Distfoo[b]; extra == 'all',foo[c]; extra == 'b'foo[all]{all, b}{all, b, c}Anything behind
extra == 'c'is lost, with no error. This is not limited to the first round: a chain that branches before it deepens (all → {p, q},p → r,r → t) hits the same equality on round two and dropst.2. The loop never terminates early for ordinary packages
For a package with no self-referencing extras — the overwhelmingly common case —
self_reqsis empty, sonew_extrasis always{}whileextrasholds at least one entry. The condition can never hold, and the loop runs all 10000 rounds, allocating a dict each time, while evaluating the generatedBUILDfile of every wheel in the build.On a ~52k-package repository this dominated loading-phase Starlark CPU:
_resolve_extrasself-timeThe fix
Compare the size of the merged set, which is what the
before/afternaming already implied. The loop is monotonic, so the converged set is unchanged wherever it previously terminated correctly — this only stops it terminating too early, or not at all.Tests
Three regression tests are added to
tests/pypi/pep508/deps_tests.bzl, covering the three shapes that trigger the early exit: a two-hop chain, multiple requested extras, and a chain where the counts only coincide after the first round.All three fail on
mainwith exactly the dropped dependency, and pass with the fix:The three existing tests that exercise self-extras chains (
test_self_is_ignored,test_self_dependencies_can_come_in_any_order,test_self_include_deps_from_previously_visited) pass either way, which is why this went unnoticed.bazel test //tests/pypi/...is green (249/249) with the fix applied.Notes
The condition was introduced in #3527, which replaced the previous double loop with this fixed-point loop.