Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions docs/proposals/unified-cache-manager.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
Proposal: Unified Cache Manager
================================

Problem
-------

Fromager's caching logic is scattered across multiple modules with no central
coordination. Prebuilt wheels are checked separately from previously built
wheels, remote wheel servers are not consulted during local builds, and there
is no mechanism to share a cache across multiple backends. This leads to:

- Redundant builds when wheels already exist in a remote cache.
- No visibility into cache hit rates, artifact integrity, or staleness.
- No short-circuit path — even a full cache hit still downloads source and
sets up a build environment before discovering the wheel exists.

Proposed Solution
-----------------

Introduce a unified ``CacheManager`` class that centralizes all cache
operations behind a prioritized multi-backend lookup strategy.

Architecture
~~~~~~~~~~~~

.. code-block:: text

CacheManager
├── lookup_backends (searched in order):
│ ├── LocalDirectoryBackend(wheels-repo/build/) [recursive for parallel builds]
│ ├── LocalDirectoryBackend(wheels-repo/downloads/)
│ └── RemotePEP503Backend(https://cache-server/simple/) [optional]
└── store_backend:
└── LocalDirectoryBackend(wheels-repo/downloads/)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Prebuilt wheels stay on the dedicated ``SourceType.PREBUILT`` path and are
not consulted by the general cache short-circuit.

Key components:

- ``WheelCacheKey`` — Content-addresses artifacts by canonicalized package
name, version, and numeric build tag.
- ``CacheBackend`` protocol — Abstract interface implemented by
``LocalDirectoryBackend`` (filesystem) and ``RemotePEP503Backend``
(PEP 503 simple repository). Thread-safe with internal locking.
- ``CacheManager`` — Orchestrates prioritized lookup across backends with
graceful fallback on fetch failures, and routes all stores to a single
designated local backend.

Lookup and store
~~~~~~~~~~~~~~~~

On lookup, the manager iterates ``lookup_backends`` in order and returns the
first hit. Remote hits are downloaded to the store backend's directory and
registered in the local index for subsequent fast lookups.

On store, newly built wheels are always placed in the single ``store_backend``
(the downloads directory). This keeps the design simple and avoids
collection-routing complexity in core Fromager. Variant-specific routing
can be added as builder-level logic in a follow-up.

Short-circuit optimization
~~~~~~~~~~~~~~~~~~~~~~~~~~

When a cache hit is found during the ``PREPARE_SOURCE`` phase and a
``CacheManager`` is active, ``PrepareSource.run()`` skips build environment
creation and build dependency resolution entirely — proceeding directly to
``ProcessInstallDeps``. Install dependencies are extracted from the cached
wheel's metadata. This eliminates the most expensive steps for packages that
do not need rebuilding.

Remote cache with integrity
~~~~~~~~~~~~~~~~~~~~~~~~~~~

``RemotePEP503Backend`` lazily fetches per-project package indices on first
access and maintains a session-scoped in-memory index. Lookups honor
interpreter wheel tags, skip yanked files (PEP 592), and filter
``data-requires-python`` the same way the resolver does. Downloads are
verified with streaming SHA256 checksums, use atomic temporary files, and
reject plaintext HTTP URLs that lack integrity hashes (unless
``--cache-allow-insecure`` is passed for development workflows). Filenames
are sanitized to prevent path traversal attacks. HTTP 400/404 (and empty
successful pages) are treated as definitive misses for the run; transport
and 5xx failures are retried on later lookups.

Observability
~~~~~~~~~~~~~

A new ``fromager cache`` CLI command group provides:

- ``cache list`` — Show all cached artifacts with versions and build tags.
- ``cache stats`` — Display on-disk inventory counts and sizes per backend.
(Hit/miss rates are process-local during bootstrap and are not persisted.)
- ``cache verify`` — Validate integrity of local cache contents.
- ``cache invalidate`` — Remove specific artifacts by name/version/tag.
- ``cache gc`` — Garbage-collect old build tags, keeping only the N most
recent per package+version.

Scope
-----

The new cache subsystem is opt-in via ``--use-cache-manager`` on the
``bootstrap`` command. When disabled, existing behavior is preserved
unchanged.

Files added or modified:

- ``src/fromager/cache.py`` — New module with all cache classes and factory.
- ``src/fromager/commands/cache_cmd.py`` — CLI commands.
- ``src/fromager/commands/bootstrap.py`` — Wiring ``--use-cache-manager`` and
``--cache-allow-insecure`` options.
- ``src/fromager/bootstrapper/_cache.py`` — Short-circuit integration via
``_find_cached_wheel_via_manager``.
- ``src/fromager/context.py`` — ``cache`` property on ``WorkContext``.
- ``tests/test_cache.py`` — Unit tests for the cache subsystem.

Benefits
--------

- Eliminates redundant builds when wheels exist in a remote or local cache.
- Reduces bootstrap time by short-circuiting cached packages (skips source
download, build env setup, and build dep resolution).
- Provides cache observability through dedicated CLI commands.
- Enforces artifact integrity with SHA256 verification and atomic writes.
- Thread-safe design compatible with background I/O pre-fetching.

Security considerations
-----------------------

- Remote downloads are verified against SHA256 hashes declared in PEP 503
index pages. Mismatched files are deleted and raise an error.
- Plaintext HTTP URLs without SHA256 hashes are rejected by default.
The ``--cache-allow-insecure`` flag explicitly opts in for internal or
development registries.
- Filenames from remote indices are sanitized to prevent directory traversal.
- Local cache writes use atomic ``tempfile`` + ``rename`` to prevent readers
from observing partial files.
- ``scan()`` skips symlinked wheels to prevent ``invalidate``/``gc`` from
deleting files outside the cache root.
- Fetch failures (network errors, hash mismatches) are caught and treated as
cache misses, falling through to the next backend or a fresh build.

Verification
------------

- All existing unit and e2e tests pass unchanged (legacy path preserved).
- New tests cover cache components, short-circuit logic, concurrency safety,
CLI commands, and error handling.
- Linting (``ruff``), type checking (``mypy``), and formatting all pass.

Future work
-----------

- Integration with ``build_tag_hook`` (issue #1059) for platform-suffixed
cache keys.
- Variant/collection-based store routing as builder-level logic (separate PR).
- Automatic detection of accelerated packages via ELF inspection or wheel
tag analysis.
- Promotion of ``--use-cache-manager`` to default behavior once proven in
production.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ canonicalize = "fromager.commands.canonicalize:canonicalize"
download-sequence = "fromager.commands.download_sequence:download_sequence"
wheel-server = "fromager.commands.server:wheel_server"
lint-requirements = "fromager.commands.lint_requirements:lint_requirements"
cache = "fromager.commands.cache_cmd:cache_cli"

[tool.coverage.run]
branch = true
Expand Down
9 changes: 9 additions & 0 deletions src/fromager/bootstrapper/_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ def _build_wheel(
server.update_wheel_mirror(ctx)
# When we update the mirror, the built file moves to the downloads directory.
wheel_filename = ctx.wheels_downloads / built_filename.name
# Keep the CacheManager index in sync for same-run lookups (e.g.
# --multiple-versions). Without this, initialize()-time scans miss
# wheels produced later in the bootstrap.
if ctx.cache is not None:
pbi = ctx.package_build_info(wi.req)
build_tag = pbi.build_tag(wi.resolved_version)
ctx.cache.store_wheel(
wi.req, wi.resolved_version, build_tag, wheel_filename
)
logger.info(f"built wheel for version {wi.resolved_version}: {wheel_filename}")
return wheel_filename, sdist_filename

Expand Down
66 changes: 58 additions & 8 deletions src/fromager/bootstrapper/_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ def _download_wheel_from_cache(
req=req, wheel_url=wheel_url, output_directory=ctx.wheels_downloads
)
if cache_wheel_server_url != ctx.wheel_server_url:
server.update_wheel_mirror(ctx)
# Index only this wheel — never sweep wheels_build from bg threads.
server.index_wheel(ctx, cached_wheel)
logger.info("found built wheel on cache server")
unpack_dir = _extract_build_reqs_from_wheel(
ctx.work_dir, req, resolved_version, cached_wheel
Expand All @@ -171,23 +172,71 @@ def _download_wheel_from_cache(
return None, None


def _find_cached_wheel_via_manager(
ctx: context.WorkContext,
req: Requirement,
resolved_version: Version,
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
"""Cache lookup using the CacheManager (thread-safe, no Bootstrapper state).

Delegates to ``ctx.cache.lookup_wheel()`` for a unified prioritized
lookup across backends. On hit, extracts build-requirement metadata
into a work_dir unpack directory (same contract as the legacy path).

Returns:
Tuple of (cached_wheel_filename, unpack_dir).
Both None if no cache hit. ``unpack_dir`` is under ``ctx.work_dir``.
"""
assert ctx.cache is not None
pbi = ctx.package_build_info(req)
build_tag = pbi.build_tag(resolved_version)

result = ctx.cache.lookup_wheel(req, resolved_version, build_tag)
if not result.hit:
return None, None

assert result.path is not None
if result.was_downloaded:
# Index only the promoted wheel. Full update_wheel_mirror() would
# move every file out of the shared wheels_build directory and can
# steal an in-progress build from another package.
server.index_wheel(ctx, result.path)

try:
unpack_dir = _extract_build_reqs_from_wheel(
ctx.work_dir, req, resolved_version, result.path
)
except Exception as err:
logger.debug(
"could not extract build requirements from cached wheel %s: %s",
result.path.name,
err,
)
unpack_dir = None
if unpack_dir is None:
unpack_dir = _create_unpack_dir(ctx.work_dir, req, resolved_version)
return result.path, unpack_dir


def find_cached_wheel(
ctx: context.WorkContext,
cache_wheel_server_url: str | None,
req: Requirement,
resolved_version: Version,
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
"""Look for cached wheel in 3 locations (thread-safe, no Bootstrapper state).
"""Look for cached wheel (thread-safe, no Bootstrapper state).

Checks for cached wheels in order:
1. wheels_build directory (previously built)
2. wheels_downloads directory (previously downloaded)
3. Cache server (remote cache)
When a CacheManager is configured on the context, delegates to it
for a unified prioritized lookup across backends. Otherwise falls
back to the legacy per-directory search.

Returns:
Tuple of (cached_wheel_filename, unpacked_cached_wheel).
Both None if no cache hit.
"""
if ctx.cache is not None:
return _find_cached_wheel_via_manager(ctx, req, resolved_version)

cached_wheel, unpacked = _look_for_existing_wheel(
ctx, req, resolved_version, ctx.wheels_build
)
Expand Down Expand Up @@ -218,9 +267,10 @@ def bg_prepare_prebuilt(
) -> PreparedSourceData:
"""Background-safe prebuilt download: no Bootstrapper state accessed."""
# Thread-safe: paths include {req.name}-{resolved_version} (unique per package),
# mkdir uses exist_ok=True (atomic), and update_wheel_mirror() is already locked.
# mkdir uses exist_ok=True (atomic), and index_wheel() is locked.
logger.info(f"using pre-built wheel for {req_type} requirement")
wheel_filename = wheels.download_wheel(req, wheel_url, ctx.wheels_prebuilt)
unpack_dir = _create_unpack_dir(ctx.work_dir, req, resolved_version)
server.update_wheel_mirror(ctx)
# Index only this prebuilt wheel so bg threads never sweep wheels_build.
server.index_wheel(ctx, wheel_filename)
return PreparedSourceData(wheel_filename=wheel_filename, unpack_dir=unpack_dir)
33 changes: 31 additions & 2 deletions src/fromager/bootstrapper/_prepare_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from packaging.requirements import Requirement
from packaging.version import Version

from .. import build_environment, dependencies, sources
from .. import build_environment, dependencies, server, sources
from ..log import req_ctxvar_context
from ..requirements_file import RequirementType, SourceType
from . import _cache
Expand Down Expand Up @@ -40,7 +40,7 @@ def _bg_prepare_source(
)
if unpacked is not None:
return PreparedSourceData(
sdist_root_dir=unpacked / unpacked.stem,
sdist_root_dir=unpacked / unpacked.name,
cached_wheel_filename=cached_wheel,
)
source_filename = sources.download_source(
Expand Down Expand Up @@ -146,6 +146,35 @@ def run(self, bt: Bootstrapper) -> list[Phase]:
sdist_root_dir = prepared.sdist_root_dir
wi.cached_wheel_filename = prepared.cached_wheel_filename

# Short-circuit: when CacheManager provides a hit, skip build env
# creation and build-dep resolution. Install deps come from the wheel.
if prepared.cached_wheel_filename is not None and bt.ctx.cache is not None:
pbi = bt.ctx.package_build_info(wi.req)
build_tag = pbi.build_tag(wi.resolved_version)
bt.ctx.cache.store_wheel(
wi.req,
wi.resolved_version,
build_tag,
prepared.cached_wheel_filename,
)
# Wheel is already in downloads; index it without sweeping wheels_build.
server.index_wheel(bt.ctx, prepared.cached_wheel_filename)
# Prefer the unpack dir produced by find_cached_wheel (work_dir/<pkg>-<ver>).
unpack_dir = prepared.sdist_root_dir.parent
if unpack_dir.parent != bt.ctx.work_dir:
unpack_dir = _cache._create_unpack_dir(
bt.ctx.work_dir, wi.req, wi.resolved_version
)
wi.build_result = SourceBuildResult(
wheel_filename=prepared.cached_wheel_filename,
sdist_filename=None,
unpack_dir=unpack_dir,
sdist_root_dir=None,
build_env=None,
source_type=SourceType.CACHED,
)
return [ProcessInstallDeps(wi)]

assert sdist_root_dir is not None

if sdist_root_dir.parent.parent != bt.ctx.work_dir:
Expand Down
Loading
Loading