Skip to content

ci: surface cache budget consumers and cap the E2E await deadline - #599

Merged
ytallo merged 2 commits into
mainfrom
fix/ci-cache-prune-and-scenario-deadline
Jul 26, 2026
Merged

ci: surface cache budget consumers and cap the E2E await deadline#599
ytallo merged 2 commits into
mainfrom
fix/ci-cache-prune-and-scenario-deadline

Conversation

@ytallo

@ytallo ytallo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Correction (after CodeRabbit review): this PR originally added a step that deleted "duplicate" caches. That was wrong and has been removed — see On pruning below. What remains is diagnosis plus one deadline change.

The harness-integration job takes 10m31s. Measured breakdown of that run:

Step Time Share
Build pinned engine 308s 49%
Run integration scenarios 257s 41%
Restore Rust cache 28s 4%
everything else 35s 6%

1. Cache budget visibility (cache-warm.yml)

The 308s is a cache miss on every PR run — there is no integration-engine-* entry in the repository at all. The caching architecture is correct (PRs run with save-cache: false; cache-warm.yml saves with true), and the warm job does succeed. The entry simply doesn't survive: the repository sits at 10.42 GiB against a 10 GiB hard budget, so GitHub evicts by last access and the engine cache loses.

The budget report gave a total with no way to see what was consuming the space, which made the eviction look arbitrary. It now lists the 15 largest entries with their key and ref.

On current data that immediately shows 4.01 GiB — 38% of the budget — is Windows release-build caches from _rust-binary.yml, written on tag pushes by release.yml and read only by the next release of the same worker. Every PR pays for that by having the engine cache evicted instead.

Pruning those is a judgement call about release build times, so this PR does not make it — it only stops it being invisible. Reclaiming that 4 GiB, or raising the cache limit (configurable since Nov 2025, billed above 10 GiB), is what actually removes the 308s.

2. E2E await deadline (scenario_ms)

scenario_ms was 60s. It covers only the await phase — stack boot is budgeted separately by readiness_ms, left unchanged — and the slowest passing scenario observed in CI takes 3.9s. In the run measured above, two wedged scenarios burned 122s of the 257s scenario step purely waiting on deadlines.

Lowered to 25s: roughly 6x headroom over the slowest observed pass, and a wedged scenario now costs 25s instead of 60s. The default-deadline test additionally asserts that headroom, so a future reduction can't quietly creep toward real runtime.

On pruning

The first version of this PR deleted entries sharing the same key, on the assumption that only the newest is ever restored. CodeRabbit pointed out that entries are scoped by (key, ref, version), and it was right. Checked against live data:

  • the 4 "duplicates" the step would have deleted have the same version, different refsrefs/pull/{592,594,597,598}/merge;
  • all four PRs are open;
  • caches are ref-scoped: PR 598 cannot restore PR 592's cache. These were never duplicates — they were per-PR caches working as designed. The step would have caused cache misses on four open PRs;
  • and there are zero true duplicates on (key, ref, version) — GitHub enforces uniqueness on that tuple, so the corrected grouping would be dead code.

So the step was removed rather than regrouped. Caches from closed PRs are already handled by the existing prune-closed-prs job. The honest conclusion: there is no safe automatic pruning to add here — the real waste is the 4 GiB of Windows release caches, which is a human decision.

Expected effect

This PR saves 35s per wedged scenario on red runs, and unblocks the budget check once the space question is settled. The large win (308s) depends on the decision in §1.

Validation

  • cargo test --manifest-path harness/Cargo.toml -p harness-integration — 84 passed, 0 failed.
  • cache-warm.yml parses; the edited run: block passes bash -n.
  • The report table was simulated against the live listing (47 entries) with pipefail enabled, exit 0 — row capping lives inside the awk pass precisely because head would close sort's stdout early and SIGPIPE it under pipefail.

No write or DELETE path against the GitHub API remains in this PR.

CI-only, so no Linear ticket; labelled no-ticket per pr-linear-check.yml.

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jul 24, 2026 8:38pm
workers-tech-spec Ready Ready Preview, Comment Jul 24, 2026 8:38pm

Request Review

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 49 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The cache-warming workflow now removes redundant cache duplicates and reports largest entries. E2E scenario deadlines default to 25 seconds, with tests validating the value and a minimum safety threshold.

Changes

Cache maintenance

Layer / File(s) Summary
Cache pruning and reporting
.github/workflows/cache-warm.yml
The workflow deletes older duplicate cache entries, reports freed space, and lists the largest caches in the budget summary.

Scenario deadline defaults

Layer / File(s) Summary
Deadline default and safety validation
harness/tests/e2e/src/types/scenario/compiled.rs, harness/tests/e2e/src/types/scenario.rs
DeadlinesV1::default() now uses a 25-second scenario deadline, and tests validate the expected value and minimum bound.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Poem

A bunny prunes caches, neat and bright,
Keeps newest keys and frees up the night.
Twenty-five seconds now guides each run,
With safety checks beneath the sun.
Hop, hop—clean workflows have begun!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main CI changes: cache budget visibility and the reduced E2E await deadline.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ci-cache-prune-and-scenario-deadline

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/cache-warm.yml:
- Around line 84-98: The duplicate-cache selection in “Delete superseded
duplicates of the same cache key” currently groups only by key; retain and emit
each row’s ref and version from actions_caches, sort by key, ref, and version
with newest last_accessed_at first, and update the awk grouping to keep only the
newest row per (key, ref, version) while collecting older IDs and sizes for
deletion.
- Around line 259-265: Update the cache-report pipeline in the workflow to
remove the head -15 stage and limit output to the first 15 records within the
existing awk formatter, preserving the current sorting and table formatting
while avoiding SIGPIPE failures under pipefail.
- Around line 93-99: Update the cache-listing flow that populates rows so gh api
--paginate output is written to a temporary file first and its exit status is
checked before any processing or deletion. Only run sort/awk and mapfile after a
successful listing, and clean up the temporary file on success and failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c1fefcb8-dc1e-4bd3-9415-9bed8f33159e

📥 Commits

Reviewing files that changed from the base of the PR and between 6452708 and 031aa38.

📒 Files selected for processing (3)
  • .github/workflows/cache-warm.yml
  • harness/tests/e2e/src/types/scenario.rs
  • harness/tests/e2e/src/types/scenario/compiled.rs

Comment thread .github/workflows/cache-warm.yml Outdated
Comment on lines +84 to +98
# A key can hold several entries, one per ref that saved it. Only the
# newest is ever restored, so the rest are dead weight that still counts
# against the repository budget — and evicting real entries under LRU.
- name: Delete superseded duplicates of the same cache key
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
mapfile -t rows < <(
gh api --method GET "repos/$REPOSITORY/actions/caches" \
-F per_page=100 --paginate \
--jq '.actions_caches[] | [.key, .last_accessed_at, .id, .size_in_bytes] | @tsv' \
| sort -t"$(printf '\t')" -k1,1 -k2,2r \
| awk -F"$(printf '\t')" '$1 == prev { print $3 "\t" $4 } { prev = $1 }'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate workflow:"
git ls-files | grep -F '.github/workflows/cache-warm.yml' || true

echo
echo "Relevant workflow excerpt:"
if [ -f .github/workflows/cache-warm.yml ]; then
  sed -n '1,140p' .github/workflows/cache-warm.yml | cat -n
fi

echo
echo "Search for cache API cache fields in repo docs/scripts:"
rg -n "actions_caches|size_in_bytes|last_accessed_at|cacheVersion|version|ref" .github README.md 2>/dev/null || true

Repository: iii-hq/workers

Length of output: 40519


🌐 Web query:

GitHub Actions REST API list cache entries actions_caches response fields id key ref version last_accessed_at size_in_bytes

💡 Result:

The GitHub Actions REST API endpoint to list cache entries for a repository is GET /repos/{owner}/{repo}/actions/caches [1][2]. When a request to this endpoint is successful (HTTP 200), the response body contains a total_count integer and an actions_caches array containing objects with the following fields [1][2]: id: The integer ID of the cache [1][2]. ref: The Git reference (branch or pull request) associated with the cache [1][2]. key: The cache key or prefix [1][2]. version: The version string for the cache [1][2]. last_accessed_at: A date-time string indicating when the cache was last accessed [1][2]. created_at: A date-time string indicating when the cache was created [1][2]. size_in_bytes: The size of the cache in bytes, represented as an integer [1][2].

Citations:


Group duplicate-candidate rows by ref and version too.

actions_caches entries are scoped by key, version, and ref. This step drops .ref and .version, so it can delete valid caches from another branch/PR or cache version. Keep the newest row per (key, ref, version) before collecting IDs to delete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cache-warm.yml around lines 84 - 98, The duplicate-cache
selection in “Delete superseded duplicates of the same cache key” currently
groups only by key; retain and emit each row’s ref and version from
actions_caches, sort by key, ref, and version with newest last_accessed_at
first, and update the awk grouping to keep only the newest row per (key, ref,
version) while collecting older IDs and sizes for deletion.

Comment thread .github/workflows/cache-warm.yml Outdated
Comment on lines +93 to +99
mapfile -t rows < <(
gh api --method GET "repos/$REPOSITORY/actions/caches" \
-F per_page=100 --paginate \
--jq '.actions_caches[] | [.key, .last_accessed_at, .id, .size_in_bytes] | @tsv' \
| sort -t"$(printf '\t')" -k1,1 -k2,2r \
| awk -F"$(printf '\t')" '$1 == prev { print $3 "\t" $4 } { prev = $1 }'
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the workflow around the reported lines and surrounding shell options.
if [ -f .github/workflows/cache-warm.yml ]; then
  echo "## file exists"
  wc -l .github/workflows/cache-warm.yml
  echo "## lines 1-140"
  sed -n '1,140p' ./github/workflows/cache-warm.yml | cat -n
else
  echo "file missing"
  git ls-files .github/workflows || true
fi

echo "## occurrences of set -euo pipefail / mapfile / gh api caches"
rg -n "set -euo pipefail|mapfile|gh api --method GET \"repos/\$REPOSITORY/actions/caches\"|--paginate|cache" .github/workflows/cache-warm.yml .github/workflows 2>/dev/null || true

Repository: iii-hq/workers

Length of output: 296


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## workflow snippet"
sed -n '1,160p' .github/workflows/cache-warm.yml | cat -n

echo "## occurrences"
rg -n "set -euo pipefail|mapfile|gh api --method GET|actions/caches|--paginate|rm -rf|xargs|delete" .github/workflows/cache-warm.yml

echo "## bash behavior for process substitution with set -e and non-zero commands"
bash -c 'set -euo pipefail; x=1; true; y=($(false)); echo "$?"' || echo "status=$?"

echo "## bash behavior for process substitution exit status in same shell under set -u (no read)”
bash -c 'set -euo pipefail; while IFS= read -r _ < <(false); do true; done; echo final' || echo "status=$?"

echo "## bash behavior with read capturing from command substitution before assignment"
bash -c 'set -euo pipefail; mapfile -t rows < <(false); echo ${`#rows`[@]}' || echo "status=$?"

Repository: iii-hq/workers

Length of output: 8384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## bash behavior tests\n'
python3 - <<'PY'
import tempfile, subprocess, os

cases = [
    ("mapfile-subset", 'set -euo pipefail; mapfile -t rows < <(false); printf "len=%d rows=[%s]\\n" "${`#rows`[@]}" "$*"'),
    ("read-loop-subset", 'set -euo pipefail; count=1; while IFS= read -r _ < <(false); do :; done; printf "count=%d\\n" "$count"'),
]
for name, script in cases:
    f = tempfile.NamedTemporaryFile(mode='w', delete_on_close=False, suffix='.sh')
    f.write("#!/bin/sh\n" + script + "\n")
    f.close()
    os.chmod(f.name, 0o755)
    r = subprocess.run(["/bin/sh", f.name], capture_output=True, text=True)
    print(f"## {name}")
    print("stdout=", r.stdout.strip())
    print("stderr=", repr(r.stderr.strip()))
    print("returncode=", r.returncode)
PY

printf '\n## workflow lines 230-270\n'
sed -n '230,270p' .github/workflows/cache-warm.yml | cat -n

printf '\n## all mapfile process substitutions in workflow\n'
rg -n "mapfile -t .*< <\\(" .github/workflows/cache-warm.yml

Repository: iii-hq/workers

Length of output: 333


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## bash behavior tests\n'
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

for case in \
  'mapfile-subset set -euo pipefail; mapfile -t rows < <(false); printf "len=%d\n" "${`#rows`[@]}"' \
  'read-loop-subset set -euo pipefail; count=1; while IFS= read -r _ < <(false); do :; done; printf "count=%d\n" "$count"' \
  'command-subset set -euo pipefail; x=($(false)); printf "len=%d\n" "${`#x`[@]}"' \
  'mapfile-subset-silent set -euo pipefail; mapfile -t rows < <({ false >&2; printf "bad\n"; }); printf "len=%d\n" "${`#rows`[@]}"' \
; do
  name=${case%% *}
  cmd=${case#* }
  printf '%s\n' "$cmd" > "$tmpdir/test.sh"
  chmod +x "$tmpdir/test.sh"
  printf '%s\n' "## $name"
  bash "$tmpdir/test.sh" || printf 'RETURNED_NONZERO\n'
  wait
done

printf '\n## workflow lines 230-270\n'
sed -n '230,270p' .github/workflows/cache-warm.yml | cat -n

printf '\n## all mapfile process substitutions in workflow\n'
rg -n "mapfile -t .*< <\\(" .github/workflows/cache-warm.yml

Repository: iii-hq/workers

Length of output: 2884


Propagate cache-listing failures before deleting anything.

mapfile -t rows < <(...) still returns success even when the process substitution exits non-zero, so a failed or interrupted gh api --paginate can leave an empty or partial rows array and proceed to delete nothing. Capture the listing to a temp file first, then mapfile only after the listing command exits successfully.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cache-warm.yml around lines 93 - 99, Update the
cache-listing flow that populates rows so gh api --paginate output is written to
a temporary file first and its exit status is checked before any processing or
deletion. Only run sort/awk and mapfile after a successful listing, and clean up
the temporary file on success and failure.

Comment thread .github/workflows/cache-warm.yml Outdated
ytallo added 2 commits July 24, 2026 17:36
The repository sits at 10.42 GiB against a 10 GiB hard budget, so the
`Report cache budget` job fails and GitHub evicts by last-access. The
report gave a total but no way to see what was consuming the space, which
made the eviction look arbitrary.

List the 15 largest entries with their key and ref. On current data that
immediately shows 4.01 GiB — 38% of the budget — is Windows release-build
caches from `_rust-binary.yml`, written on tag pushes and read only by the
next release of the same worker, while the pinned-engine cache that every
PR run depends on gets evicted and costs 308s to rebuild each time.

Whether to keep those is a judgement call about release build times, so
this only makes the trade-off visible. The `ref` column is what tells a
reader whether an entry is shared (refs/heads/main) or scoped to one PR.

Row capping happens inside the awk pass: piping through `head` would close
sort's stdout early and SIGPIPE it under `pipefail`.
`scenario_ms` covers only the await phase — stack boot is budgeted
separately by `readiness_ms` — and the slowest passing scenario observed in
CI takes ~4s. At 60s, a run with two wedged scenarios spent 122s of its
257s scenario step waiting on deadlines rather than doing work.

25s keeps roughly 6x headroom over the slowest observed pass while capping
what a wedged scenario costs. The default-deadline test now also asserts
the headroom, so a future reduction cannot quietly approach the real
runtime.
@ytallo ytallo changed the title ci: prune cache duplicates and cap the E2E await deadline ci: surface cache budget consumers and cap the E2E await deadline Jul 24, 2026
@ytallo
ytallo force-pushed the fix/ci-cache-prune-and-scenario-deadline branch from 031aa38 to 0a65d0f Compare July 24, 2026 20:37
@ytallo
ytallo merged commit 6192db9 into main Jul 26, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-ticket PR deliberately has no Linear ticket (bump/typo/CI-only)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant