Refresh only cache objects near expiry - #1
Merged
Conversation
added 2 commits
August 20, 2026 18:01
The commit post-step keeps an active tenant's referenced layers alive by copying them in place, which resets the bucket's LastModified expiry clock. It did that for EVERY referenced object on EVERY build. Each copy is billed as a Tier1 PUT, and those refreshes measured ~3.8M Tier1 requests over 20 days — 83% of the entire S3 bill for runner caching, far more than storage itself. Touching an object with six days left on a seven-day rule buys nothing. One LIST per prefix (Tier2, ~12x cheaper per call, paginated) now gives every object's age, and only those near expiry are copied. The garbage-collection property is UNCHANGED. An object is refreshed only when this build references it AND it is near expiry; unreferenced orphans are still never touched and still age out. Skipping a YOUNG referenced object cannot expire it — by definition it has days of life left, and any build inside that window refreshes it then. Fails OPEN in both unknown cases: a failed listing and a key missing from the listing both refresh as before. Refreshing unnecessarily costs one request; skipping wrongly costs the layer. Threshold defaults to 1 day (BP_CACHE_REFRESH_AGE_DAYS). Worst-case object age is threshold + longest inter-build gap, which must stay under the 7-day lifecycle. Observed gaps reach ~2.4 days across a weekend, so 1 day caps age near 3.4d with ~3.6 days of tolerance. 3 days would remove only marginally more copies while cutting that tolerance to ~1.6. Parsing and the fail-open rule move to src/refresh-policy.js so they can be tested. This repo had no test infrastructure at all; adds node --test (no new dependencies) and 11 cases covering UTC parsing, the nested blobs/sha256/ key shape, malformed lines, threshold inclusivity, both fail-open paths, and an assertion that the shipped threshold stays under the lifecycle with headroom. Ships as v2 rather than moving v1, so existing pins are untouched.
The refresh-policy tests existed but no job executed them, so a green check only meant the bundle built and dist/ was fresh.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The cost
The commit post-step keeps an active tenant's referenced layers alive by copying them in place — a metadata REPLACE, no data transfer — which resets the bucket's
LastModifiedexpiry clock. It did that for every referenced object on every build.Each copy is billed as a Tier1 PUT. Those refreshes measured ~3.8M Tier1 requests over 20 days — 83% of the entire S3 bill for runner caching, far more than storage itself:
Touching an object with six days left on a seven-day rule buys nothing.
The change
One LIST per prefix (Tier2, ~12x cheaper per call, paginated) gives every object's age; only those near expiry are copied.
The GC property is unchanged — this is the part to check
Orphan collection was the point of the original design, and it still works. An object is refreshed only when this build references it and it is near expiry. Unreferenced orphans are still never touched and still age out.
Skipping a young referenced object cannot expire it: by definition it has days of life left, and any build inside that window refreshes it then.
Fails open
Both unknown cases refresh exactly as before:
agesisnull→ refresh everythingRefreshing unnecessarily costs one request. Skipping wrongly costs the layer.
Threshold: why 1 and not 3
Worst-case object age is
threshold + longest inter-build gap, and that must stay under the 7-day lifecycle.An active repo builds many times an hour, so at 1 day the overwhelming majority of refreshes are already skipped. Raising it to 3 removes little more while cutting the tolerance by more than half. Overridable via
BP_CACHE_REFRESH_AGE_DAYS; the reasoning is in the code so nobody raises it without redoing the arithmetic.Tests — the repo had none
No test script, no test directory,
@vercel/nccthe only devDependency. Parsing and the fail-open rule move tosrc/refresh-policy.jsso they are testable; only the AWS call and error handling stay inpost.js.Adds
node --test(no new dependencies) and 11 cases:aws s3 lsparsing, and that timestamps are read as UTC — a naive parse would shift ages by the TZ offset and could flip a boundary decisionblobs/sha256/shapeShips as v2
@v1pins are untouched; nobody moves silently.Test plan
npm test— 11/11node --check src/post.jsnpm run build, and verified the logic is present in the minifieddist/post.jsby string literal (the exit code alone lies —nccwas missing and the build silently no-opped on the first attempt)v2