Post step: bounded incremental mirror maintenance; maintenance failure no longer vetoes the commit - #55
Conversation
…e no longer vetoes the commit Replace the post-step `git gc --auto` with a geometric repack that only folds small packs and loose objects, marking packs >= 256 MiB with .keep so they are never rewritten. A maintenance timeout or failure is reported but no longer sets shouldCommit=false: the mirror was already synced, and refusing to persist it made every following job repeat the same repack. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… version-independent Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…anch protection withholds the mirror on untrusted triggers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ajwerner
left a comment
There was a problem hiding this comment.
I found three issues with the maintenance changes. The inline comments explain the impact, the checks I ran, and suggested fixes.
| '-d', | ||
| '-l', | ||
| '-n', | ||
| '--geometric=2', |
There was a problem hiding this comment.
[P2] Limit the total bytes rewritten in one run
The 256 MiB cutoff limits each pack, but Git chooses which packs to combine by object count. Several packs below the cutoff can therefore be combined into a much larger write.
I reproduced this on Git 2.50.1 with a 1 MiB cutoff. Six packs of about 0.75 MiB, containing 4, 8, 16, 32, 64 and 128 objects, survived successful maintenance after every addition. Adding another 128-object pack made the next run rewrite all seven into one roughly 5.3 MiB pack. At the default cutoff, the same proportions would mean about 1.3 GiB of output, which can bring back repeated 120-second maintenance timeouts.
Please choose packs using a limit on their combined size and add a test with different object sizes. The per-pack cutoff alone does not provide the claimed limit on work.
There was a problem hiding this comment.
Agreed — the per-pack cutoff only bounded the largest input, not the roll-up. Fixed in 53d9017: markKeepPacks now returns {kept, deferred}. Packs ≥ threshold get a permanent .keep as before; of the rest, the largest get a per-run .keep (marker content, so it's distinguishable from a real keep) until the packs left for the geometric roll-up sum to < threshold. Those .keeps are removed in a finally, and a stale one left by a killed run is recognised by its content and cleared next time. Since git chooses by object count, this is the only way I found to bound the bytes without reimplementing the selection.
Test never rewrites more than the keep threshold per run, whatever the object counts: 2 × ~1 MiB packs with few objects + 8 tiny packs with many objects, 1 MiB threshold → bytes written < 1 MiB, the two medium packs untouched; with the deferral disabled the same test measures 2.2 MiB written. Deferred packs are reconsidered on the next run once the small ones around them have been folded.
| if (stat.size < keepBytes) { | ||
| continue | ||
| } | ||
| await fs.promises.writeFile(path.join(packDir, `${base}.keep`), '') |
There was a problem hiding this comment.
[P2] Add a way to reclaim deleted history
These .keep files are permanent, and replacing gc --auto removes the action's way to discard objects that no branch or tag needs anymore. Geometric repacking keeps those objects, while .keep prevents later garbage collection from deleting their packs.
I tested a kept pack after deleting its only branch. Repeated maintenance kept the whole pack. Even git gc --prune=now kept it; removing the .keep file let GC reclaim it.
As branches are deleted or rewritten, a long-lived mirror can keep growing even if the live repository stays small. Please add a way to reclaim this space or periodically rebuild the mirror. If the backend already guarantees such rebuilds, document that dependency and verify that it keeps disk usage under control.
There was a problem hiding this comment.
There's no external rebuild I could rely on (mirrors only get replaced by a purge), so 53d9017 adds an explicit reclaim: once per MAINTENANCE_RECLAIM_INTERVAL_MS (14 d) the run lifts every .keep and does repack -a -d -l -n --write-midx + prune --expire=2.weeks.ago under its own 600 s deadline instead of the incremental step. The stamp (<mirror>/blacksmith-maintenance-reclaim) is written before the attempt, so a mirror too large for the deadline doesn't retry every job — it just stays on bounded incremental runs for another interval (that's the failure mode this PR is fixing, so I'd rather leak unreachable objects for 14 days than reintroduce it). A mirror with no stamp starts its interval rather than reclaiming at once, so a release doesn't make the whole fleet rewrite mirrors on the same day.
Tests: first sight only stamps; when due, a branch deleted after its pack was kept has its objects gone, one pack left, fsck clean, other refs intact; a reclaim that times out leaves the mirror intact and the next run is incremental again.
Open to a different interval or a size cap on the reclaim if you'd rather not have any unbounded step at all — the alternative I considered was repack -a -d only when total mirror size < N GiB, but that leaves the largest mirrors (the ones that accumulate the most) never reclaiming.
| if (packRefs.exitCode !== 0) { | ||
| core.warning( | ||
| `[git-mirror] pack-refs failed with exit code ${packRefs.exitCode}` | ||
| ) | ||
| await removeMaintenanceLeftovers(mirrorPath) |
There was a problem hiding this comment.
[P2] Report pack-refs failures as maintenance failures
After logging this failure, the function returns {success: true, timedOut: false} below. I simulated both a timeout (exit 124) and another failure (exit 128), and both were reported as success.
This means main.ts never emits the maintenance-failure metric for this step, even when it spends the full 120 seconds timing out.
Please return success: false and preserve whether the command timed out, with tests for both cases. The synced mirror can still be saved: the new cleanup logic already allows that when maintenance fails.
There was a problem hiding this comment.
Fixed in 53d9017: pack-refs (and the reclaim's prune) now go through the same fail() path as repack — exit 124 → {success:false, timedOut:true}, any other nonzero → {success:false, timedOut:false}, leftovers removed in both cases. cleanup() still commits the synced mirror either way. Covered by real-git tests (pack-refs exit 3; hung pack-refs → timeout + packed-refs.lock removed) and the mocked cleanup tests (both still commit with shouldCommit=true).
… reclaim kept packs periodically markKeepPacks now returns a KeepSelection: packs at or above the keep threshold stay permanently kept, and of the rest the largest are given a per-run .keep until the remaining candidates add up to less than the threshold. Geometric repack selects by object count, so without this several medium packs could combine into a rewrite far larger than any one of them. Deferred keeps are removed after the run; stale ones from a killed run are recognised by their marker content and cleared. pack-refs failures are now reported: timeout -> success:false/timedOut:true, other exits -> success:false/timedOut:false. cleanup() still commits the synced mirror either way. Once per MAINTENANCE_RECLAIM_INTERVAL_MS (14 days) the run lifts every .keep and does repack -a -d --write-midx + prune --expire=2.weeks.ago under MAINTENANCE_RECLAIM_TIMEOUT_SECS (600 s), so objects that became unreachable inside kept packs are eventually dropped. The stamp is written before the attempt so a reclaim that times out is not retried by every following job; a mirror without a stamp starts its interval rather than reclaiming at once. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 53d9017. Configure here.
repack -a -d + prune drop unreachable commits the existing commit-graph still lists; fsck and incremental graph writes then fail on those entries. Remove the graph (single file or chain) after a successful reclaim and write it again from the reachable commits, within the reclaim's remaining budget. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ajwerner
left a comment
There was a problem hiding this comment.
Approving. A couple of follow-ups worth keeping in mind:
-
The new commit-graph rebuild covers successful cleanup. One failure case remains: the full repack can delete old commits, then
prunecan fail or time out before the graph is removed. Since we still save the mirror, later jobs runninggit fsckcan fail on those stale entries. Removing both graph formats before the full repack, and skipping reclamation if that removal fails, would cover this too. The existing cleanup code can rebuild the graph afterward; leaving it absent if rebuilding fails is safe. A test whereprunefails after repacking would catch this case. -
The byte limit still covers existing packs only. Git can store a small fetch as loose objects even when it contains large binaries, so a few files totaling more than 256 MiB can bypass the limit. I reproduced a roughly 2 MiB repack with a 1 MiB threshold this way. The likely symptom is another slow post step or 120-second maintenance timeout; I haven't reproduced incorrect checkout contents. Accounting for loose objects in the budget, or keeping incoming objects packed, would close that gap.
…ects packed The reclaim repack deletes unreachable commits; a graph that still lists them fails fsck in every later job if prune then fails, so the graph is removed before the repack and reclaim is skipped when that removal fails. Sync fetches use fetch.unpackLimit=1 so objects always land in a pack whose size the per-run byte budget can see. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
@ajwerner both follow-ups from your approval are in 6572b50:
|

Summary
The post step ran
git gc --autoon the mirror with a 120 stimeout, and a GC failure/timeout setshouldCommit=false. On a large mirror this is a livelock: each synced job adds a pack; oncegc.autoPackLimit(50) is crossed,gc --autodoes a full repack of every pack including the multi-GB base pack. When that takes >120 s (I/O-bound, so it does on slower disks/arches for a ~2 GB pack), the job discards the synced mirror, the snapshot never advances, and the next job trips the exact same repack — every job pays ~120 s in the post step and nothing ever improves until the disk is purged.Two changes:
Bounded maintenance instead of
gc --auto(runMirrorMaintenance, replacesrunMirrorGC):markKeepPackswrites a permanentpack-*.keepfor every pack ≥ 256 MiB, and — because the geometric repack picks packs to roll up by object count, not size — additionally gives the largest of the remaining packs a per-run.keep(marker contentblacksmith-checkout: deferred to a later run) until the packs left for the roll-up add up to < 256 MiB. So a run rewrites at most 256 MiB of existing packs plus the new loose objects, regardless of how many medium packs have accumulated. Deferred.keeps are removed in afinally; a stale one from a killed run is recognised by its content and cleared on the next run. A rolled-up pack that grows past the threshold becomes another kept pack. Kept packs also don't count towardgc.autoPackLimit, so an older action version sharing the mirror won't trip a full repack on them either. A multi-pack-index keeps lookups fast across kept packs..keepfiles rather thanrepack --keep-pack=: with git 2.34 geometric repack still folds a--keep-packpack into the roll-up and-dthen deletes it, losing objects (reproduced on a valid fixture: cleanfsckbefore, dangling refs after).Reclaim. Kept packs never lose objects, so history that became unreachable (deleted/rewritten branches) stays on disk. Once per
MAINTENANCE_RECLAIM_INTERVAL_MS(14 d) the run instead lifts every.keepand doesunder
MAINTENANCE_RECLAIM_TIMEOUT_SECS(600 s). The commit-graph (single file and split chain) is removed before the repack — it would otherwise still list the pruned commits andfsck/incremental graph writes fail on those in every later job ifprunethen fails or times out — and a run that cannot remove it falls back to an incremental repack instead of reclaiming; a fresh--reachablegraph is written only after a successfulprune. The stamp file (<mirror>/blacksmith-maintenance-reclaim, epoch ms) is written before the attempt, so a mirror too large for the deadline does not retry it every job — it goes back to bounded incremental runs for another interval. A mirror without a stamp starts its interval rather than reclaiming at once, so a rollout doesn't make every job in the fleet rewrite its mirror simultaneously.Maintenance is optional and never vetoes the commit.
cleanup()keepsshouldCommit=false/vmHydratedGitMirror=falsefor mirror sync failure or timeout, but a maintenance failure or timeout — ofrepack,pruneorpack-refs, each reported assuccess:falsewithtimedOutset only for exit 124 — is only logged (still reported via the existinggit_mirror_gc_failuremetric) and the synced mirror is committed. On failure/timeout, staletmp_pack_*,.tmp-*,*.lockandpacked-refs.lockleft by the killed process are removed so the next job's sync doesn't hit a lock nobody holds. Maintenance now runs only whenshouldCommitis true (same gate the commit-graph catch-up already had).The sync fetch runs with
fetch.unpackLimit=1so received objects always land in a pack (a large blob left loose would not be counted bymarkKeepPacks's byte budget).CleanupResult.gcResult→maintenanceResult.runMirrorMaintenance(mirrorPath, {timeoutSecs, keepBytes, reclaimIntervalMs, reclaimTimeoutSecs, now}).Tests
__test__/mirror-maintenance-git.test.ts(real git): base pack + small packs + loose objects → base pack kept (same inode/size), small packs and loose objects rolled up, midx written,fsck --strictclean, all refs resolve; second run is a no-op; a kept pack is excluded from the roll-up even when larger packs arrive later; threshold marking is idempotent. Mixed sizes: 2 packs of ~1 MiB with few objects + 8 tiny packs with many objects under a 1 MiB threshold → bytes written < threshold, the two medium packs untouched and their.keeps gone afterwards (the assertion fails with 2.2 MiB written if the deferral is disabled);markKeepPacksdeferral order and cleanup. Reclaim: first sight only writes the stamp; when due, a branch deleted after the base pack was kept has its objects dropped, mirror collapses to one pack,fsckclean, other refs intact; a reclaim that times out leaves the mirror intact, updates the stamp, and the next run is incremental again;prunefailing after the reclaim repack leaves no commit-graph behind andfsckstays clean.__test__/mirror-sync-negotiation-git.test.ts: the sync fetch adds exactly one pack and no loose objects. Agitshim (installed via atimeoutshim, since jest's sandboxedprocess.envisn't what children inherit) simulates a hung repack →timedOut: true, leftovers removed, mirror untouched; failing repack →success:false, timedOut:false;pack-refsexit 3 →success:false, timedOut:false; hungpack-refs→timedOut:true,packed-refs.lockremoved.__test__/mirror-cleanup.test.ts(mocked exec + gRPC): repack timeout/failure and pack-refs timeout/failure each still commit withshouldCommit=true, vmHydratedGitMirror=true; sync failure/timeout skips maintenance and commitsfalse/false;shouldCommit=falseinput skips maintenance; nogcinvocation.Rollout notes
Existing mirrors carry whatever pack layout
gc --autoleft; the first post step on such a mirror marks its big pack(s) kept, defers the medium ones and rolls up the rest, so even the first run is bounded by the threshold. The first reclaim happens 14 days after the first run of this version on each mirror (600 s budget; on timeout the mirror keeps working from its kept packs and the next attempt is another 14 days out). Verified locally on git 2.34.1;--geometric/--write-midxexist since 2.31/2.32.CI
gc.auto=0,receive.autogc=false,maintenance.auto=false) on the bare mirror: git 2.55'sreceive-packotherwise consolidates the pushed packs itself, so the fixture's "6 small packs" was 2 packs on the CI runner. Verified on git 2.34.1 and 2.55.0.test-git-mirror-container/test-git-mirror-after-container: with sticky disk branch protection on,pull_requestruns cannot hydrate a mirror (the agent declines the fresh entity and the action falls back to a direct clone). When the disk isn't mounted on a non-pushevent, the jobs now verify the fallback checkout and emit a notice instead of failing; onpush(trusted, may hydrate) the mirror assertions remain hard.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Link to Devin session: https://app.devin.ai/sessions/a3b42ef15226438faf3f9cdde9662918
Open in Devin Desktop: https://app.devin.ai/desktop/session/a3b42ef15226438faf3f9cdde9662918?variant=devin
Requested by: @piob-io