Skip to content

Fix: Multi version bootstrap age filter fallback for edge cases - #1289

Open
rd4398 wants to merge 2 commits into
python-wheel-build:mainfrom
rd4398:fix/multi-version-age-filter-fallback
Open

Fix: Multi version bootstrap age filter fallback for edge cases #1289
rd4398 wants to merge 2 commits into
python-wheel-build:mainfrom
rd4398:fix/multi-version-age-filter-fallback

Conversation

@rd4398

@rd4398 rd4398 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Commit 1:

feat(resolver): add AgeFallback.NEWEST for multi-version age filter
Replace the boolean `fallback_on_empty_age_filter` parameter in
`find_all_matching_from_provider()` with an `AgeFallback` enum that
supports three strategies when max-release-age filtering removes all
candidates:

- `ALL`: keep every candidate (single-version default, unchanged)
- `NEWEST`: keep only the single newest candidate (new)
- `NONE`: return empty list

Multi-version mode now uses `AgeFallback.NEWEST` so that when a
dependency has no releases within the age window (e.g. bar from 2
years ago depended on by recent foo), the newest version is built
instead of failing. This prevents cascading failures where the
dependent package also fails to build.

Co-Authored-By: Claude <claude@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>

Commit 2:

fix(resolver): constrained packages bypass max-release-age filtering
Packages with explicit constraints (e.g. `boto3==1.35.88` in
constraints files) now skip age filtering entirely in
`find_all_matching_from_provider()`. A constraint is explicit user
intent and should not be silently overridden by the age heuristic.

Previously in multi-version mode with `--max-release-age`, a
constrained package whose pinned version was older than the age
window would have all candidates removed, causing silent resolution
failure. In single-version mode the fallback kept all candidates
anyway, but emitted a misleading "keeping all to avoid empty
resolution" warning.

Co-Authored-By: Claude <claude@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>

Closes #1288

@rd4398
rd4398 requested a review from a team as a code owner August 5, 2026 18:45
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@rd4398, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 57 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f7b64a5-9ba9-4395-a545-0b07f8920a6a

📥 Commits

Reviewing files that changed from the base of the PR and between 925a19e and 6434bda.

📒 Files selected for processing (7)
  • e2e/ci_bootstrap_suite.sh
  • e2e/test_bootstrap_age_constraint_bypass.sh
  • e2e/test_bootstrap_age_fallback_newest.sh
  • src/fromager/bootstrap_requirement_resolver.py
  • src/fromager/resolver.py
  • tests/test_bootstrap_requirement_resolver.py
  • tests/test_cooldown.py
📝 Walkthrough

Walkthrough

The resolver now uses the AgeFallback enum for age-filter behavior. Constrained packages bypass --max-release-age filtering. Multi-version bootstrap falls back to the newest candidate when all releases are too old. Single-version bootstrap retains all candidates. Unit and end-to-end tests cover the new strategies and CI execution.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the multi-version bootstrap age-filter fallback change.
Description check ✅ Passed The description explains the AgeFallback changes and the constrained-package behavior addressed by the pull request.
Linked Issues check ✅ Passed The implementation satisfies issue #1288 by selecting the newest unconstrained version and bypassing age filtering for constrained packages.
Out of Scope Changes check ✅ Passed The source changes and tests directly support the age-filter fallback and constrained-package requirements in issue #1288.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mergify mergify Bot added the ci label Aug 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/fromager/resolver.py (1)

304-313: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add requirement log context.

These logs describe one requirement but do not use req_ctxvar_context(). Wrap the age-filter operation in the established requirement context before logging. As per coding guidelines, “Use req_ctxvar_context() for per-requirement logging.”

Also applies to: 346-357

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fromager/resolver.py` around lines 304 - 313, Wrap the age-filter logging
paths in the established req_ctxvar_context() requirement context before
emitting messages. Update the branches around the constrained-package log and
the corresponding age-filter operation near the later referenced block,
preserving the existing filtering behavior while ensuring all per-requirement
logs include the request context.

Source: Coding guidelines

tests/test_cooldown.py (1)

807-828: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove log-message assertions from these behavior tests.

The result and wheel assertions already verify the required behavior. Log text is diagnostic output and can change without changing behavior.

  • tests/test_cooldown.py#L807-L828: remove caplog assertions after verifying the empty result.
  • tests/test_cooldown.py#L831-L853: remove caplog assertions after verifying version 2.0.0.
  • tests/test_cooldown.py#L1008-L1036: remove caplog assertions after verifying constrained version 1.2.2.
  • tests/test_cooldown.py#L1039-L1060: remove the negative log assertion after verifying the empty result.
  • e2e/test_bootstrap_age_constraint_bypass.sh#L59-L67: remove the log grep; retain the wheel assertion.
  • e2e/test_bootstrap_age_fallback_newest.sh#L51-L59: remove the log grep; retain the wheel-count and version assertions.

Based on learnings, “avoid asserting on exact log output strings since they are brittle implementation details.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cooldown.py` around lines 807 - 828, Remove brittle log-output
assertions while preserving all behavior assertions: in tests/test_cooldown.py
ranges 807-828, 831-853, 1008-1036, and 1039-1060, delete the caplog assertions
after the existing result/version checks; in
e2e/test_bootstrap_age_constraint_bypass.sh range 59-67 and
e2e/test_bootstrap_age_fallback_newest.sh range 51-59, remove only the log grep
commands and retain the wheel, count, and version assertions.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/fromager/resolver.py`:
- Around line 304-313: Wrap the age-filter logging paths in the established
req_ctxvar_context() requirement context before emitting messages. Update the
branches around the constrained-package log and the corresponding age-filter
operation near the later referenced block, preserving the existing filtering
behavior while ensuring all per-requirement logs include the request context.

In `@tests/test_cooldown.py`:
- Around line 807-828: Remove brittle log-output assertions while preserving all
behavior assertions: in tests/test_cooldown.py ranges 807-828, 831-853,
1008-1036, and 1039-1060, delete the caplog assertions after the existing
result/version checks; in e2e/test_bootstrap_age_constraint_bypass.sh range
59-67 and e2e/test_bootstrap_age_fallback_newest.sh range 51-59, remove only the
log grep commands and retain the wheel, count, and version assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d6f59d0e-48a2-4967-9d79-35f7577e6234

📥 Commits

Reviewing files that changed from the base of the PR and between e979af8 and 3dd9464.

📒 Files selected for processing (7)
  • e2e/ci_bootstrap_suite.sh
  • e2e/test_bootstrap_age_constraint_bypass.sh
  • e2e/test_bootstrap_age_fallback_newest.sh
  • src/fromager/bootstrap_requirement_resolver.py
  • src/fromager/resolver.py
  • tests/test_bootstrap_requirement_resolver.py
  • tests/test_cooldown.py

andre-motta
andre-motta previously approved these changes Aug 5, 2026

@andre-motta andre-motta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Lets test this with https://pypi.org/project/dead/ on walkerpass or similar before releasing!

@rd4398

rd4398 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

The fix for CI failures are part of #1287
I will rebase the PR once above mentioned PR lands

@rd4398
rd4398 requested a review from smoparth August 5, 2026 19:02
@LalatenduMohanty

Copy link
Copy Markdown
Member

@rd4398 #1287 has merged

Packages with explicit constraints (e.g. `boto3==1.35.88` in
constraints files) now skip age filtering entirely in
`find_all_matching_from_provider()`. A constraint is explicit user
intent and should not be silently overridden by the age heuristic.

Previously in multi-version mode with `--max-release-age`, a
constrained package whose pinned version was older than the age
window would have all candidates removed, causing silent resolution
failure. In single-version mode the fallback kept all candidates
anyway, but emitted a misleading "keeping all to avoid empty
resolution" warning.

Co-Authored-By: Claude <claude@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
@rd4398
rd4398 force-pushed the fix/multi-version-age-filter-fallback branch from 3dd9464 to e6b9dec Compare August 6, 2026 14:49
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

pavank63
pavank63 previously approved these changes Aug 6, 2026

@pavank63 pavank63 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Tested on local system (x86_64) with old (main: 0.92.1.dev13) and new (PR: 0.92.1.dev15) side-by-side using --multiple-versions.

tomli>=2.0,<=2.0.2 with --max-release-age=1

Scenario OLD (main) NEW (PR)
Unconstrained all 3 candidate(s) older than 1 days → empty, skipped falling back to newest version 2.0.2 → resolves
Constrained tomli==2.0.0 all 1 candidate(s) older than 1 days → pinned version skipped skipping age filter for constrained package → resolves 2.0.0

dead>=1.0 (latest 2.1.0 uploaded 2025-02-08, ~543 days old)

Scenario OLD (main) NEW (PR)
--max-release-age=30 all 11 candidate(s) older than 30 days → empty, skipped falling back to newest version 2.1.0 → resolves
--max-release-age=365 all 11 candidate(s) older than 365 days → empty, skipped falling back to newest version 2.1.0 → resolves
--max-release-age=600 have 3 candidate(s) within 600 days → resolves 3 have 3 candidate(s) within 600 days → resolves 3
--max-release-age=30 + dead==1.5.2 all 1 candidate(s) older than 30 days → pinned version skipped skipping age filter for constrained package → resolves 1.5.2

When versions fall within the age window, both behave identically. The PR only changes behavior when all candidates are outside the window.

Comment thread src/fromager/resolver.py Outdated
req,
max_age_days,
)
elif age_fallback == AgeFallback.NEWEST:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is reached when filtered is empty (i.e., all candidates failed the age check). filtered is built from candidates_list, so if candidates_list itself is empty — possible when the requirement specifier matches nothing — candidates_list[0] raises IndexError. There is no guard and no unit test for this path.

Suggested fix: guard before the index access:

  elif age_fallback == AgeFallback.NEWEST:
      if not candidates_list:
          candidates_list = []
      else:
          newest = candidates_list[0]
          logger.info(...)
          candidates_list = [newest]

Or just check candidates_list before assigning newest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NEWEST branch is only reached when filtered is empty (all candidates failed the age check), but candidates_list is the original unfiltered list. It's guaranteed non-empty because provider.find_matches() raises ResolverException if zero candidates match the requirement specifier (line 829-832). The exception is re-raised at line 286-288. So by the time we reach the NEWEST branch, candidates_list always has at least one element.

Comment thread src/fromager/resolver.py Outdated

if max_age_cutoff is not None:
# Constraints are explicit user intent — they override age filtering.
is_constrained = provider.constraints.get_constraint(req.name) is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_constrained is broader than "pinned"

get_constraint returns a match for any constraint type — range, exclusion, or exact pin — not just pins. So a loose constraint like tomli>=1.0 also sets is_constrained=True and skips age filtering entirely, even though the docstring frames this as bypassing only for exact pins ("explicit user intent").

Failure case: user sets tomli>=1.0 (just to exclude 0.x) plus --max-release-age=365, expecting the age filter to still apply — but it's silently skipped since any constraint match short-circuits it.

Options:

  1. Only bypass for exact pins (constraint.specifier == "=="), fall through to age filtering otherwise.
  2. Keep current behavior, but rename "pinned" → "constrained" in docstring/logs and document the tradeoff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good, fixed in latest commit

Comment thread tests/test_cooldown.py Outdated
# ---------------------------------------------------------------------------


def test_max_release_age_skips_filter_for_constrained_package(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constraint-bypass tests don't use production's fallback mode

Both new unit tests call find_all_matching_from_provider with age_fallback=resolver.AgeFallback.NONE, but production multi-version mode (bootstrap --multiple-versions + constraints file) uses AgeFallback.NEWEST. That combination — constrained package + NEWEST fallback — isn't unit tested; only covered implicitly by the (slow, non-isolating) e2e test.

Gap: if constraint bypass later starts interacting with fallback strategy (e.g. "skip bypass when fallback is NEWEST"), nothing here would catch the regression.

Suggested fix: add a unit test in test_cooldown.py with age_fallback=resolver.AgeFallback.NEWEST and a constrained provider, asserting the constrained version is returned rather than just the newest unconstrained one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in latest commit

Comment thread src/fromager/resolver.py Outdated
# Constraints are explicit user intent — they override age filtering.
is_constrained = provider.constraints.get_constraint(req.name) is not None

if max_age_cutoff is not None and is_constrained:

@Lanceypantsy Lanceypantsy Aug 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_constrained is computed unconditionally on every call, but it's only ever used inside if max_age_cutoff is not None branches. When max_age_cutoff is None (no age filter configured — the common case), the constraint lookup is wasted work.

This is minor on its own, but find_all_matching_from_provider is called once per requirement per resolution pass, and get_constraint does a dict lookup on a canonicalized name. Not expensive, but the intent is clearer if the check is co-located with its use:

  if max_age_cutoff is not None:
      is_constrained = provider.constraints.get_constraint(req.name) is not None
      if is_constrained:
          logger.info(...)
      else:
          # existing filter logic

Not blocking, but worth the two-line fix for clarity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in latest commit

Comment thread src/fromager/resolver.py
)


class AgeFallback(enum.StrEnum):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT:

StrEnum is unnecessary here — plain Enum would be fine

class AgeFallback(enum.StrEnum):
    NONE = "none"
    ALL = "all"
    NEWEST = "newest"

StrEnum makes members compare equal to their string values (AgeFallback.NEWEST == "newest"), which is useful when values get serialized to config files, CLI args, or JSON. None of that applies here — AgeFallback is only ever used in internal comparisons, never as a raw string. This leaves an implicit interface open: callers could pass the bare string "newest" and it would silently work, bypassing the type system.

A plain enum.Enum makes the type boundary explicit with no downside.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every internal enum in the codebase uses StrEnum (RequirementType, SourceType, BuildSDist, DownloadKind, BootstrapPhase). Switching to Enum would break the established convention. I would prefer keeping it this way

Comment thread src/fromager/resolver.py Outdated
class AgeFallback(enum.StrEnum):
"""Strategy when max-release-age filtering removes all candidates."""

NONE = "none"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT:

ALL is the default value for age_fallback in find_all_matching_from_provider and represents the original behavior. Convention in Python enums is to put the default/most-common member first. Reordering to ALL, NEWEST, NONE would match the parameter default and read naturally from most- to least-permissive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in latest commit

@rd4398
rd4398 dismissed stale reviews from pavank63 and andre-motta via 76a87f6 August 6, 2026 15:59
Replace the boolean `fallback_on_empty_age_filter` parameter in
`find_all_matching_from_provider()` with an `AgeFallback` enum that
supports three strategies when max-release-age filtering removes all
candidates:

- `ALL`: keep every candidate (single-version default, unchanged)
- `NEWEST`: keep only the single newest candidate (new)
- `NONE`: return empty list

Multi-version mode now uses `AgeFallback.NEWEST` so that when a
dependency has no releases within the age window (e.g. bar from 2
years ago depended on by recent foo), the newest version is built
instead of failing. This prevents cascading failures where the
dependent package also fails to build.

Co-Authored-By: Claude <claude@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>

fix(resolver): address PR review feedback

- Narrow constraint bypass to exact == pins only, so range constraints
  like >=1.0 still go through age filtering. Reuses the existing
  `_has_equality_pin()` helper.
- Move the constraint check inside the `max_age_cutoff is not None`
  block to avoid unnecessary work when age filtering is disabled.
- Reorder `AgeFallback` enum members: ALL (default), NEWEST, NONE
  — most- to least-permissive.
- Add unit test for NEWEST fallback + pinned constraint combination.
- Add unit test verifying range constraints are still age-filtered.

Co-Authored-By: Claude <claude@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
@rd4398
rd4398 force-pushed the fix/multi-version-age-filter-fallback branch from 76a87f6 to 6434bda Compare August 6, 2026 15:59
@LalatenduMohanty

Copy link
Copy Markdown
Member

I have approved the PR however my concern is that multi-version resolution now has three fallback layers stacked:

  1. Age filter with NEWEST fallback (new)
  2. Cache server fallback (pre-existing, only when cache URL configured)
  3. Log warning and return empty (pre-existing)

That's a lot of "try this, else try that" for one resolution path, and it makes the behavior hard to predict for someone reading the code cold.

Single-version mode has none of this . The two modes are diverging in resolution semantics, which could become a maintenance burden as more edge cases surface.

I will create a follow up issue which we can look in to resolving this post this PR merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiple version bootstrap should not silently skip packages when all candidates are older than max-release-age

5 participants