Skip to content

ci: prune expired R2 cache archives, never one a latest pointer names - #14089

Merged
teamleaderleo merged 5 commits into
mainfrom
ci/r2-cache-prune
Sep 24, 2026
Merged

teamleaderleo merged 5 commits into
mainfrom
ci/r2-cache-prune

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

scripts/ci/r2-cache.sh saves archives to the CI cache bucket and nothing ever deletes them. On 2026-09-24 the bucket held 143.4 GiB after about five days of the R2 backend, and 141.4 GiB of that was 138 xcode-compilation-* archives, so it grows by roughly 35 GiB a day. Per-commit DerivedData snapshots (#14081) would add about 2 GiB per build-changing main commit.

r2-cache-prune.yml runs daily on main and deletes v1/*/objects/ archives older than their family's retention. It never deletes an archive that any latest/ pointer names, whatever its age, because prefix restores read those. It re-reads every pointer immediately before deleting, because r2-cache.sh re-points latest/ at an existing key without re-uploading it. Retention is chosen by key prefix:

family retention why
admission-derived-data- 1 day one snapshot per main commit; consumers try the exact base, then the newest by prefix
xcode-compilation- 1 day restored newest by prefix; an older main misses every changed module (#14015: 3 of 2,665 app jobs hit)
everything else 30 days keyed by content (Package.resolved, toolchain), so an old key stays exact for a PR on an older base

It is a dry run by default. Deletes need a dispatch with delete checked, or the repository variable CI_R2_CACHE_PRUNE_DELETE=1. An incomplete listing or an unreadable pointer deletes nothing, pointers and keys outside v1/*/objects/ are never candidates, and one run deletes at most 2,000 archives, oldest first. Listing and signing reuse r2_cache_census.py.

Why one day: on 2026-09-24, 92 of the 100 most recently updated open pull requests had a base under a day old, and each extra day of retention holds about 35 GiB.

Dry run against the real bucket

Run from a throwaway branch with the same credentials, with no --delete path: 35943338712, then with the census modelling other windows.

family archives GiB eligible now
xcode-compilation- 138 141.4 0
other 4 1.9 0

Nothing is eligible yet, because no archive is older than five days. For the same bucket, the census reports that an age rule over all archives would reclaim 22.5 GiB at 3 days, 64.6 GiB at 2 days and 106.4 GiB at 1 day.

Validation

  • tests/test_r2_cache_prune.py, 8 tests, fake bucket: per-family retention; pointer targets kept in both archive formats; a pointer moved mid-run protects its new target; an incomplete listing and a bad pointer delete nothing; the per-run bound; the workflow runs only on main, on Linux, with permissions: {} at the top level and deletes only on opt-in.
  • The full ci-guards.yml command sweep passes locally, except two failures that also fail on a clean origin/main export (a missing vendor/bonsplit submodule, and a bun test path that expects its working-directory). actionlint is clean.

To enable deletes after reviewing a scheduled dry run: gh variable set CI_R2_CACHE_PRUNE_DELETE --repo manaflow-ai/cmux --body 1.

— Marmot g1 🎒
Run: run_cmux_ci_incremental_deriveddata_for_compile_admission_20260924_0d1859dd

🤖 Generated with Claude Code


Summary by cubic

Prunes the CI R2 cache bucket, which grows about 35 GiB a day and was never cleaned. Adds r2_cache_prune.py and a daily workflow on main that deletes v1/*/objects/ archives older than their family's retention.

Migration

  • Retention is 1 day for admission-derived-data- and xcode-compilation- archives, 30 days for everything else.
  • Archives named by any latest/ pointer are always kept; pointers are re-read right before deleting because a save can publish a new prefix's first pointer between listing and delete.
  • Pruning is a dry run by default; enable deletes by dispatching with delete checked or setting repository variable CI_R2_CACHE_PRUNE_DELETE=1.
  • The prune job runs on main only, in the main-only ci-cache-writer environment, and uses the Ubuntu runner outside manaflow-ai.
  • An incomplete listing or unreadable pointer deletes nothing, and one run deletes at most 2,000 archives, oldest first.
  • Adds 8 unit tests covering retention, pointer protection mid-run, and the workflow's opt-in and scope.

Written for commit 7180091. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Chores
    • Added a daily cleanup process for expired CI cache archives. It runs in preview mode by default; actual deletion requires an explicit opt-in.
    • Archives still referenced by the latest cache pointers are protected, and cleanup is capped per run to limit impact.
    • Added automated checks for cache cleanup behavior and workflow safeguards.

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 7eb1aa37-1929-4698-974f-4c04d02b4da8

📥 Commits

Reviewing files that changed from the base of the PR and between 980f8a2 and 90fc528.

📒 Files selected for processing (7)
  • .github/workflows/ci-guards.yml
  • .github/workflows/r2-cache-prune.yml
  • scripts/ci/detect_ci_change_areas.py
  • scripts/ci/r2_cache_prune.py
  • tests/test-execution.toml
  • tests/test_ci_change_areas.py
  • tests/test_r2_cache_prune.py
 ____________________
< I dream in semver. >
 --------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

teamleaderleo and others added 2 commits September 23, 2026 23:56
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Merged current main (c172961). The conflict in tests/test-execution.toml was a positional collision, and both entries are kept.

Main now keeps the R2 write credentials in the ci-cache-writer environment, and test_ci_pull_request_caches_are_read_only.py fails any job that names them without entering that environment. 2d758e7 adds environment: ${{ github.ref == 'refs/heads/main' && 'ci-cache-writer' || '' }} to the prune job. Without it, the job would also read empty credentials. That guard, test_r2_cache_prune.py, the registry validator and actionlint pass.

— Lemur g1 🖇️

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Rebased onto main. The only conflict was an append in tests/test-execution.toml, where both entries are kept. tests/test_r2_cache_prune.py, the registry validator and tests/test_ci_change_areas.py pass. Related: #14174 fixes the seed pointer, which has been stuck on f2abe643. That pointer names the archive this prune must keep, so the two don't conflict.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Correction to my comment above: at 06:56Z another push rebased this branch to 82c1c22, which replaced c172961 and 2d758e7. I have not pushed over it.

The current head still lacks the writer environment, so guards / workflow-guard-tests / preflight should fail on tests/test_ci_pull_request_caches_are_read_only.py:

FAIL: r2-cache-prune.yml prune: a job holding the R2 write credentials must declare environment: ${{ github.ref == 'refs/heads/main' && 'ci-cache-writer' || '' }}

The fix is one line on the prune job, after its if::

    environment: ${{ github.ref == 'refs/heads/main' && 'ci-cache-writer' || '' }}

Without it, main's run of the job would also get empty R2 credentials, because they live in that environment now.

— Lemur g1 🖇️

teamleaderleo and others added 3 commits September 24, 2026 03:46
The guard from #14147 requires every job holding the R2 write
credentials to declare it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Required by the fork runner routing guard (#14066). Also correct the
pointer re-read comment: with run-ID generations (#14174) a re-save no
longer moves a pointer backwards; a new prefix's first pointer is the
remaining race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both families try the pull request's exact base first, then the newest
by prefix. On 2026-09-24, 92 of the 100 most recently updated open pull
requests had a base under a day old, and each day of retention costs
about 35 GiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo
teamleaderleo merged commit 6e6faf0 into main Sep 24, 2026
51 of 52 checks passed
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