Skip to content

Commit 3b09dab

Browse files
committed
refactor(resolver): move age filter fallback to resolver with cache server lookup
Move the two-step fallback logic from `_phase_resolve()` into `BootstrapRequirementResolver._resolve()`. When age filtering removes all candidates in multi-version mode, the resolver now queries the remote wheel cache server for the newest available version and returns only that single version. The resolver warns on zero results but does not error — `_phase_resolve()` raises the exception. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
1 parent 029327a commit 3b09dab

7 files changed

Lines changed: 367 additions & 97 deletions

src/fromager/bootstrap_requirement_resolver.py

Lines changed: 84 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from packaging.requirements import Requirement
1313
from packaging.version import Version
1414

15-
from . import resolver, sources, wheels
15+
from . import finders, resolver, sources, wheels
1616
from .dependency_graph import DependencyGraph
1717
from .requirements_file import RequirementType
1818

@@ -40,15 +40,24 @@ def __init__(
4040
self,
4141
ctx: context.WorkContext,
4242
prev_graph: DependencyGraph | None = None,
43+
multiple_versions: bool = False,
44+
cache_wheel_server_url: str = "",
4345
) -> None:
4446
"""Initialize requirement resolver.
4547
4648
Args:
4749
ctx: Work context with constraints and settings
4850
prev_graph: Optional previous dependency graph for caching
51+
multiple_versions: If ``True``, age filtering returns an empty
52+
list instead of falling back to all candidates, letting the
53+
caller decide how to handle the case.
54+
cache_wheel_server_url: URL of the remote wheel cache server.
55+
Used as a fallback when age filtering produces no candidates.
4956
"""
5057
self.ctx = ctx
5158
self.prev_graph = prev_graph
59+
self.multiple_versions = multiple_versions
60+
self.cache_wheel_server_url = cache_wheel_server_url
5261
# Session-level resolution cache to avoid re-resolving same requirements
5362
# Key: (requirement_string, pre_built) to distinguish source vs prebuilt
5463
# Value: tuple of (url, version) tuples sorted by version (highest first)
@@ -72,6 +81,8 @@ def resolve(
7281
1. Session cache (if previously resolved)
7382
2. Previous dependency graph
7483
3. PyPI resolution (source or prebuilt based on package build info)
84+
4. Remote wheel cache server (multi-version mode only, when age
85+
filtering produced no candidates)
7586
7687
Args:
7788
req: Package requirement
@@ -110,36 +121,7 @@ def resolve(
110121
logger.debug(f"resolved {req} from cache")
111122
return list(cached_result) if return_all_versions else [cached_result[0]]
112123

113-
# Resolve using strategies
114-
results = self._resolve(req, req_type, parent_req, pre_built)
115-
116-
# Cache the result
117-
self.cache_resolution(req, pre_built, results)
118-
return results if return_all_versions else [results[0]]
119-
120-
def _resolve(
121-
self,
122-
req: Requirement,
123-
req_type: RequirementType,
124-
parent_req: Requirement | None,
125-
pre_built: bool,
126-
) -> list[tuple[str, Version]]:
127-
"""Internal resolution logic without caching.
128-
129-
Tries resolution strategies in order:
130-
1. Previous dependency graph
131-
2. PyPI resolution (source or prebuilt)
132-
133-
Args:
134-
req: Package requirement
135-
req_type: Type of requirement
136-
parent_req: Parent requirement from dependency chain
137-
pre_built: Whether to resolve prebuilt (True) or source (False)
138-
139-
Returns:
140-
List of (url, version) tuples sorted by version (highest first)
141-
"""
142-
# Try graph
124+
# Try previous dependency graph
143125
cached_resolution = self._resolve_from_graph(
144126
req=req,
145127
req_type=req_type,
@@ -151,17 +133,13 @@ def _resolve(
151133
logger.debug(
152134
f"resolved from previous bootstrap: {len(cached_resolution)} version(s)"
153135
)
154-
return cached_resolution
155-
156-
# Fallback to PyPI using provider pattern
157-
if pre_built:
136+
results = cached_resolution
137+
elif pre_built:
158138
# Resolve prebuilt wheel
159-
# Get wheel server URLs (use PyPI as cache/fallback server)
160139
wheel_server_urls = wheels.get_wheel_server_urls(
161140
self.ctx, req, cache_wheel_server_url=resolver.PYPI_SERVER_URL
162141
)
163-
# Use shared retry loop logic from wheels module
164-
return wheels.resolve_all_prebuilt_wheels(
142+
results = wheels.resolve_all_prebuilt_wheels(
165143
ctx=self.ctx,
166144
req=req,
167145
wheel_server_urls=wheel_server_urls,
@@ -176,10 +154,76 @@ def _resolve(
176154
req_type=req_type,
177155
)
178156
max_age_cutoff = resolver._compute_max_age_cutoff(self.ctx)
179-
return resolver.find_all_matching_from_provider(
180-
provider, req, max_age_cutoff=max_age_cutoff
157+
results = resolver.find_all_matching_from_provider(
158+
provider,
159+
req,
160+
max_age_cutoff=max_age_cutoff,
161+
fallback_on_empty_age_filter=not self.multiple_versions,
181162
)
182163

164+
if not results and self.multiple_versions and self.cache_wheel_server_url:
165+
logger.info(
166+
"no results found with normal resolution, "
167+
"falling back to the cache server %s",
168+
self.cache_wheel_server_url,
169+
)
170+
results = self._resolve_from_cache_server(req)
171+
172+
if not results:
173+
logger.warning(
174+
"resolver returned no results with "
175+
"fallback wheel server URL %s and %s version mode",
176+
self.cache_wheel_server_url or "(none)",
177+
"multiple" if self.multiple_versions else "single",
178+
)
179+
180+
# Only cache non-empty results.
181+
if results:
182+
self.cache_resolution(req, pre_built, results)
183+
184+
if not results:
185+
return []
186+
if return_all_versions:
187+
return results
188+
return [results[0]]
189+
190+
def _resolve_from_cache_server(self, req: Requirement) -> list[tuple[str, Version]]:
191+
"""Fall back to the remote wheel cache server for a cached version.
192+
193+
When age filtering removes all candidates in multi-version mode,
194+
queries the remote cache server for the newest available wheel.
195+
Returns at most one version so that transitive dependencies are
196+
re-processed without rebuilding every old version.
197+
"""
198+
logger.info(
199+
"checking cache server %s for existing build",
200+
self.cache_wheel_server_url,
201+
)
202+
best: tuple[str, Version] | None = None
203+
for include_sdists, include_wheels in [(False, True), (True, False)]:
204+
try:
205+
provider = finders.PyPICacheProvider(
206+
cache_server_url=self.cache_wheel_server_url,
207+
constraints=self.ctx.constraints,
208+
include_sdists=include_sdists,
209+
include_wheels=include_wheels,
210+
)
211+
results = resolver.find_all_matching_from_provider(provider, req)
212+
if results:
213+
url, version = results[0]
214+
if best is None or version > best[1]:
215+
best = (url, version)
216+
except Exception as err:
217+
logger.warning(
218+
"error checking cache server %s: %s",
219+
self.cache_wheel_server_url,
220+
err,
221+
)
222+
if best is not None:
223+
logger.info("found version %s on cache server", best[1])
224+
return [best]
225+
return []
226+
183227
def get_cached_resolution(
184228
self,
185229
req: Requirement,

src/fromager/bootstrapper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ def __init__(
173173
self._resolver = bootstrap_requirement_resolver.BootstrapRequirementResolver(
174174
ctx=ctx,
175175
prev_graph=prev_graph,
176+
multiple_versions=multiple_versions,
177+
cache_wheel_server_url=self.cache_wheel_server_url,
176178
)
177179
# Push items onto the stack as we start to resolve their
178180
# dependencies so at the end we have a list of items that need to

src/fromager/resolver.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ def find_all_matching_from_provider(
243243
provider: BaseProvider,
244244
req: Requirement,
245245
max_age_cutoff: datetime.datetime | None = None,
246+
fallback_on_empty_age_filter: bool = True,
246247
) -> list[tuple[str, Version]]:
247248
"""Find all matching candidates from provider without full dependency resolution.
248249
@@ -255,6 +256,10 @@ def find_all_matching_from_provider(
255256
max_age_cutoff: If set, reject candidates published before this time.
256257
If all candidates are older than the cutoff, all are kept and
257258
a warning is emitted to avoid empty resolution.
259+
fallback_on_empty_age_filter: If ``True`` (default), keep all
260+
candidates when age filtering would produce an empty result.
261+
If ``False``, return an empty list instead, allowing the
262+
caller to implement its own fallback strategy.
258263
259264
Returns list of (url, version) tuples sorted by version (highest first).
260265
@@ -315,7 +320,7 @@ def find_all_matching_from_provider(
315320
)
316321
if filtered:
317322
candidates_list = filtered
318-
else:
323+
elif fallback_on_empty_age_filter:
319324
logger.warning(
320325
"%s: all %d candidate(s) of %s are older than %d days, "
321326
"keeping all to avoid empty resolution",
@@ -324,6 +329,15 @@ def find_all_matching_from_provider(
324329
req,
325330
max_age_days,
326331
)
332+
else:
333+
logger.info(
334+
"%s: all %d candidate(s) of %s are older than %d days",
335+
req.name,
336+
len(candidates_list),
337+
req,
338+
max_age_days,
339+
)
340+
candidates_list = []
327341

328342
# Convert candidates to list of (url, version) tuples
329343
# Candidates are sorted by version (highest first) by BaseProvider.find_matches()

0 commit comments

Comments
 (0)