Skip to content

fix(python-uv): let UV use its own persistent cache - #904

Open
bnusunny wants to merge 1 commit into
aws:developfrom
bnusunny:fix/uv-shared-cache
Open

fix(python-uv): let UV use its own persistent cache#904
bnusunny wants to merge 1 commit into
aws:developfrom
bnusunny:fix/uv-shared-cache

Conversation

@bnusunny

Copy link
Copy Markdown
Contributor

Issue #, if available: #903

Description of changes

UvRunner._ensure_cache_dir() pointed UV's --cache-dir at a uv-cache directory inside the build's scratch directory. Scratch comes from osutils.mkdir_temp() in SAM CLI's ApplicationBuilder._build_function() and is deleted when the build finishes, so UV started from a cold cache on every function of every build. Within one sam build each function re-downloaded shared dependencies (build time scaled linearly with function count), and the next build started cold again. The explicit flag also overrode any UV_CACHE_DIR the user had set, so there was no workaround.

This removes _ensure_cache_dir(). With no --cache-dir, UV uses its own default cache, which persists across functions and builds — matching python_pip, which never sets PIP_CACHE_DIR and inherits pip's user-level cache. A caller-supplied config.cache_dir is still forwarded, and UV_CACHE_DIR is honoured again.

scratch_dir is dropped from install_requirements(), where it had no remaining use. It stays on the manifest handlers: _build_from_lock_file still writes exported requirements there, and _build_from_requirements keeps it to match the signature shared by handlers dispatched from build_dependencies().

Description of how you validated changes

Existing suites: 831 unit, 13 functional, 20 integration — all pass. ruff and black clean.

New unit tests assert no --cache-dir is passed and no cache directory is created by default, that an explicit cache_dir is still honoured, and that a build never derives cache_dir from scratch. I verified these fail against the old implementation on the assertion (not merely on the signature change) by re-introducing the old cache-defaulting logic under the new signature.

Measured with boto3 + pydantic + requests, medians of 5 iterations, all paths on one filesystem, UV 0.11.28:

before after (warm)
requirements.txt 2622 ms 245 ms
pyproject.toml 2963 ms 481 ms
4 functions 10436 ms 3236 ms
8 functions 20658 ms 4051 ms

Cold first build is unchanged (2664 ms vs 2622 ms, within noise), so nothing gets slower.

Two risks I checked before proposing a shared cache:

  • Concurrency (sam build --parallel): six concurrent installs against one cold cache completed with no errors and identical output trees. UV locks its cache.
  • Hardlinks: files installed via --target report nlink=1, so UV copies rather than hardlinks for --target installs and a persistent cache can't be corrupted by later mutation of build artifacts.

Checklist

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

UvRunner._ensure_cache_dir() pointed UV's --cache-dir at a "uv-cache"
directory inside the build's scratch directory. That directory comes from
osutils.mkdir_temp() in ApplicationBuilder._build_function() and is deleted
when the build finishes, so UV started from a completely cold cache on every
function of every build and could never reuse anything it had downloaded.

Two consequences: within a single `sam build` each function re-downloaded
shared dependencies, so build time scaled linearly with function count; and
because scratch is discarded on exit, the next `sam build` started cold
again. The explicit --cache-dir argument also overrode any UV_CACHE_DIR the
user had set, leaving no way to work around it.

Stop deriving a cache directory. With no --cache-dir, UV uses its own default
location, which persists across functions and across builds. This matches the
python_pip workflow, which likewise never sets PIP_CACHE_DIR and inherits
pip's user-level cache. A caller-supplied config.cache_dir is still forwarded
unchanged, and UV_CACHE_DIR is now honoured again.

Measured with boto3 + pydantic + requests, medians of 5 iterations, all paths
on one filesystem (UV 0.11.28):

  requirements.txt   isolated 2622 ms -> shared, warm  245 ms
  pyproject.toml     isolated 2963 ms -> shared, warm  481 ms

  4 functions       10436 ms -> 3236 ms
  8 functions       20658 ms -> 4051 ms

A cold first build is unchanged (2664 ms shared vs 2622 ms isolated, within
noise), so no build gets slower. Concurrent builds sharing one cache are safe:
UV locks its cache, and six parallel installs against a single cold cache
completed with no errors and identical output trees. Files installed via
--target 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.

scratch_dir is dropped from UvRunner.install_requirements(), where it had no
remaining use. It is retained on the manifest handlers: _build_from_lock_file
still writes exported requirements there, and _build_from_requirements keeps
it to match the signature shared by handlers dispatched from
build_dependencies().

Adds unit coverage asserting no --cache-dir is passed and no cache directory
is created by default, that an explicit cache_dir is still honoured, and that
a build never derives cache_dir from the scratch directory.

Fixes aws#903
@bnusunny
bnusunny requested a review from a team as a code owner July 29, 2026 23:07

@vicheey vicheey 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.

Approved!
One small comment, not blocking: consider documenting the full cache precedence chain (config.cache_dir > UV_CACHE_DIR > UV default) in the DESIGN.md caching section.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants