Skip to content

Fix env cache churn from defaulted exclude-newer and race in concurrent env creation - #138

Merged
OwenPriceSkelly merged 2 commits into
mainfrom
owen/fix-env-cache-hash-and-create-race
Sep 2, 2026
Merged

Fix env cache churn from defaulted exclude-newer and race in concurrent env creation#138
OwenPriceSkelly merged 2 commits into
mainfrom
owen/fix-env-cache-hash-and-create-race

Conversation

@OwenPriceSkelly

Copy link
Copy Markdown
Member

Problem

Found 2026-09-01 while launching several concurrent tasks of one function: 5 of 6 concurrent .local() tasks failed with a mix of uv venv: "A directory already exists", "failed to open file ...uv.toml: No such file or directory", and ModuleNotFoundError from a half-installed environment. Two underlying bugs:

1. A defaulted exclude-newer churns the env hash every second. When a script's PEP 723 header has no [tool.uv] exclude-newer, Pep723Metadata defaults it to datetime.now(timezone.utc) at parse time, and compute_env_hash includes the whole [tool.uv] table. So the env hash changes every second: every run (and every Function whose shell command is templated in a different second) gets a fresh ENV_DIR and a full uv rebuild, defeating the per-site environment cache entirely. Meanwhile N tasks of one function submitted together do share a hash — and since their env never pre-exists, they all take the create path at once.

2. Environment creation isn't safe under concurrency. The shell template guards creation with a bare [ -d "$ENV_DIR" ] check and builds directly at the final path, so concurrent same-hash tasks race: two uv venv calls collide, the shared ${ENV_DIR}.uv.toml staging file gets mv'd out from under the others, and a task can start running against an env another task is still installing into.

Fix

  • compute_env_hash now drops exclude-newer from the hashed [tool.uv] table when the user didn't set it explicitly (checked via pydantic model_fields_set). An exclude-newer pinned in the script header still affects the hash exactly as before, and the defaulted value is still written to the env's uv.toml, so fresh builds remain pinned to their build time.
  • The shell template builds the env in a unique temp dir ($ENV_DIR.tmp.$(hostname).$$) — including its staged uv.toml and groundhog-meta.json — then publishes it with an atomic rename. A task that loses the publish race discards its build and uses the winner's. uv venv gets --relocatable so entry-point script shebangs stay valid after the rename. Net effect: an existing ENV_DIR always means a complete environment.

A task killed mid-build can still leave a stale *.tmp.* dir behind, but stale temp dirs are inert (never reused, unlike the half-built ENV_DIRs the old code could leave and then happily reuse).

Verification

  • Existing suite passes (357 tests), plus new tests: defaulted exclude-newer hashes identically across parses at different times, user-pinned exclude-newer still changes the hash, and the template builds in the temp dir / publishes by rename / never touches $ENV_DIR/ before publish.
  • End-to-end: rendered a real shell command from a script with no exclude-newer; the env hash is now stable across seconds, and 6 concurrent executions of the env-creation section against a cold cache all succeeded (previously 5/6 failed), leaving exactly one complete env dir — with uv.toml and groundhog-meta.json inside, and its python able to import the installed dependency.

One adjacent issue observed but left alone: on hosts with no uv on PATH, concurrent tasks also race in the pip install uv bootstrap block. That's pre-existing and much rarer in practice.

🤖 Generated with Claude Code

Two related fixes for environment caching:

1. compute_env_hash no longer includes a defaulted (non-user-set)
   exclude-newer. The default is the parse-time clock, so hashing it
   changed the env hash every second, giving every run (and every
   Function templated in a different second) a fresh ENV_DIR and a full
   rebuild. Only an exclude-newer actually pinned in the script header
   affects the hash now.

2. The shell template builds the environment in a unique temp dir
   (hostname + pid) and renames it into place, instead of creating it
   at the final path unguarded. Concurrent tasks sharing an env hash
   previously raced on creation and failed with 'directory already
   exists' / missing uv.toml / ModuleNotFoundError from half-installed
   envs; now the loser of the publish race discards its build and uses
   the winner's. uv venv gets --relocatable so entry-point shebangs
   survive the rename. An existing ENV_DIR now always means a complete
   environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- exclude-newer: default to None on UvMetadata instead of guarding the
  env hash with model_fields_set (which was defeated by extra="allow"
  underscore-typo keys and by CLI file rewrites baking the wall-clock
  default into user scripts). The effective default is now injected at
  command-templating time: _serialize_uv_toml() setdefaults the
  build-time timestamp into the uv.toml handed to uv, so fresh builds
  still resolve against a fixed cutoff while the volatile value never
  enters env identity or file rewrites. hog init calls
  _default_exclude_newer() directly so new scripts keep an explicit pin.
- shell template: extend the EXIT trap to also remove $ENV_TMP and
  $ENV_TMP.uv.toml so failed builds under set -euo pipefail don't leak
  partial venvs.
- shell template: probe `uv venv --help` for --relocatable and pass it
  via $UV_VENV_RELOCATABLE so endpoints with uv < 0.2.31 degrade
  gracefully instead of hard-failing.
- shell template: suffix ENV_TMP with .$RANDOM to avoid collisions
  across PID namespaces (containerized tasks sharing hostname and
  scratch); drop the now-pointless pre-build rm -rf.
- tests: replace the FakeDatetime hash test with parse-twice/None
  assertions; add coverage for the underscore-typo extra key, rewrite
  round-trips not injecting exclude-newer, and the injected/pinned
  exclude-newer in the uv.toml heredoc; add a trap-covers-ENV_TMP test;
  fix the create-branch slice in the publish-by-rename test to actually
  span venv-creation..publish (with unique-marker guards) instead of a
  vacuous window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@OwenPriceSkelly
OwenPriceSkelly merged commit 6ff2d0d into main Sep 2, 2026
2 checks passed
@OwenPriceSkelly
OwenPriceSkelly deleted the owen/fix-env-cache-hash-and-create-race branch September 2, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant