Skip to content

Bug: python_uv workflow forces UV cache into per-build scratch dir, so every build starts cold #903

Description

@bnusunny

Description:

The python_uv workflow forces UV's cache into the per-build scratch directory, which is a fresh mkdir_temp() that SAM CLI deletes when the build finishes. UV therefore starts from a completely cold cache on every function, on every build, and can never reuse anything it downloaded before.

UvRunner._ensure_cache_dir() (packager.py#L98-L103):

def _ensure_cache_dir(self, config: UvConfig, scratch_dir: str) -> None:
    """Ensure UV cache directory is configured."""
    if not config.cache_dir:
        config.cache_dir = os.path.join(scratch_dir, "uv-cache")
        if not os.path.exists(config.cache_dir):
            self._osutils.makedirs(config.cache_dir)

That value is emitted as an explicit --cache-dir argument (utils.py#L131-L132), which also overrides any UV_CACHE_DIR the user has set. scratch_dir originates from osutils.mkdir_temp() inside SAM CLI's ApplicationBuilder._build_function(), so it is created and destroyed once per build definition.

This makes the effect twofold:

  • Between functions in one sam build — each function gets its own empty uv-cache, so shared dependencies (boto3, pydantic, …) are re-downloaded and re-unpacked once per function. Build time scales linearly with function count.
  • Between successive builds — the scratch directory is deleted on exit, so the next sam build starts cold again.

Both manifest dispatch paths are affected, since _handle_pyproject_build and _build_from_requirements both call UvRunner.install_requirements().

For contrast, python_pip never sets PIP_CACHE_DIR, so pip transparently uses the user-global ~/.cache/pip and stays warm across functions and builds. UV — whose main advantage is aggressive caching — is the only Python workflow deprived of it.

This is structurally the same class of problem as #900 (Rust workspace members each getting an isolated target/, defeating cargo's cache), with the additional penalty that the cache is discarded between builds and not just between functions.

Steps to reproduce:

  1. Create a SAM app with several Python functions that share dependencies, each using BuildMethod: uv, e.g. a requirements.txt of:
    boto3==1.35.0
    pydantic==2.9.0
    requests==2.32.3
    
  2. Run sam build.
  3. Run sam build again.

Every function in step 2, and every function again in step 3, performs a full cold-cache dependency download.

The behaviour can be reproduced against UV directly. Cold cache per invocation (what the workflow does today):

uv pip install -r req.txt --target ./t --cache-dir ./fresh-cache-per-build \
    --python-version 3.12 --python-platform x86_64-unknown-linux-gnu

versus a cache that persists between invocations (proposed):

uv pip install -r req.txt --target ./t --cache-dir ./shared-cache \
    --python-version 3.12 --python-platform x86_64-unknown-linux-gnu

Observed result:

Benchmarked with the three dependencies above, 5 iterations, medians reported. All paths on the same filesystem (xfs) to avoid tmpfs skew. UV 0.11.28.

requirements.txt path (_build_from_requirements)

Scenario Median
Isolated cache, cold every build — current behaviour 2622 ms
Shared cache, first build (cold) 2664 ms
Shared cache, subsequent builds 245 ms

pyproject.toml path (_handle_pyproject_build)

Scenario Median
Isolated cache, cold every build — current behaviour 2963 ms
Shared cache, first build (cold) 3023 ms
Shared cache, subsequent builds 481 ms

Multi-function build (one sam build, N functions sharing dependencies)

Functions Isolated (current) Shared Wasted
4 10436 ms 3236 ms 7.2 s
8 20658 ms 4051 ms 16.6 s

Current behaviour is strictly linear at roughly 2.6 s per function, with no variance across iterations because it is always cold (individual runs: 2610, 2610, 2622, 2627, 2663 ms). Each isolated build populates and then discards about 51 MB of cache.

Two further observations:

  • Removing the redirect costs nothing on a cold start. Shared-cache first build (2664 ms) matches the isolated build (2622 ms) within noise, so no one pays more than they do today.
  • No hardlink hazard. Files installed via --target from a shared cache report nlink=1, i.e. UV copies rather than hardlinks for --target installs, so a persistent cache cannot be corrupted by later mutation of build artifacts.

Expected result:

UV should be able to reuse its cache across functions and across builds, so that only genuinely new or changed dependencies are downloaded.

Either of the following would achieve that:

  1. Drop _ensure_cache_dir() and let UV use its own default global cache (~/.cache/uv). This matches what python_pip already does by not touching PIP_CACHE_DIR, and additionally restores the user's ability to point UV elsewhere with UV_CACHE_DIR. The trade-off is that builds then depend on user-global state — which is the trade-off pip already accepts.
  2. Default cache_dir to a durable location such as .aws-sam/cache/uv rather than the ephemeral scratch directory, keeping the cache project-scoped while still persistent.

A related gap worth fixing alongside this: UvConfig is only ever instantiated bare — UvConfig() at actions.py#L44, packager.py#L129 and packager.py#L211 — and PythonUvWorkflow never passes a config into PythonUvBuildAction. Its cache_dir and no_cache fields are therefore unreachable, so there is currently no way for a user to work around this from a template. (See also #839, which asks for UV configuration to be exposed more generally; that request is about which UV flags are surfaced, whereas this issue is about the cache location and its performance cost.)

Additional environment details (Ex: Windows, Mac, Amazon Linux etc)

  1. OS: Amazon Linux 2023
  2. If using SAM CLI, sam --version: 1.164.0 (aws-lambda-builders 1.65.0)
  3. AWS region: n/a — local build only

Additional: UV 0.11.28, Python 3.12

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions