fix(gc): respect in-progress .sync sessions when removing repos#4241
fix(gc): respect in-progress .sync sessions when removing repos#4241seniorquico wants to merge 1 commit into
Conversation
CleanupRepo could delete a repository directory out from under a live on-demand sync: its "no in-flight writes" guard only checked .uploads and ignored the .sync/<uuid> staging directory. Extend the guard to also skip repo removal while a .sync session exists, mirroring the .uploads check. Also add a GC reaper that deletes stale .sync sessions past the GC delay, so a session left behind by an interrupted sync does not block repo removal (and leak disk) forever. Signed-off-by: Kyle Dodson <kyledodson@gmail.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4241 +/- ##
==========================================
- Coverage 91.73% 91.67% -0.07%
==========================================
Files 207 207
Lines 30989 31049 +60
==========================================
+ Hits 28428 28464 +36
- Misses 1645 1663 +18
- Partials 916 922 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a GC race where ImageStore.CleanupRepo could remove an entire repository directory while an on-demand sync was actively staging blobs under <repo>/.sync/<uuid>/. It extends the “no in-flight writes” guard to include .sync sessions and adds a GC reaper to delete stale .sync sessions after the GC delay.
Changes:
- Extend
CleanupRepo’s repo-removal guard to also consider active.sync/<uuid>staging sessions. - Add
removeStaleSyncSessionsin GC to reap stale.syncsessions and emit GC deletion metrics with a newsyncSessionlabel. - Add unit tests covering both the new guard behavior and the stale-session reaper behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/test/mocks/image_store_mock.go | Extends the ImageStore mock with sync-session list/stat/delete hooks for GC tests. |
| pkg/storage/types/types.go | Expands the types.ImageStore interface to include sync-session operations. |
| pkg/storage/imagestore/imagestore.go | Implements sync-session list/stat/delete and uses sync sessions to block repo removal in CleanupRepo. |
| pkg/storage/imagestore/imagestore_test.go | Adds a test ensuring .sync sessions prevent repo removal and that an empty .sync does not. |
| pkg/storage/gc/gc.go | Adds stale .sync session reaping and increments GC metrics for syncSession deletions. |
| pkg/storage/gc/gc_internal_test.go | Adds tests for reaping stale sync sessions and interaction with repo cleanup behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (is *ImageStore) ListSyncSessions(repo string) ([]string, error) { | ||
| sessionPaths, err := is.storeDriver.List(path.Join(is.rootDir, repo, syncConstants.SyncBlobUploadDir)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| sessions := make([]string, 0, len(sessionPaths)) | ||
| for _, sessionPath := range sessionPaths { | ||
| sessions = append(sessions, path.Base(sessionPath)) | ||
| } | ||
| return sessions, nil | ||
| } |
| blobUploads, _ := is.ListBlobUploads(repo) | ||
| syncSessions, _ := is.ListSyncSessions(repo) | ||
|
|
||
| // if removeRepo flag is true and we cleanup all blobs and there are no blobs currently being uploaded. | ||
| if removeRepo && count == len(blobs) && count > 0 && len(blobUploads) == 0 { | ||
| // if removeRepo flag is true and there are no uploads or sync sessions currently in-progress. | ||
| if removeRepo && count == len(blobs) && count > 0 && |
|
Thanks for coming forward with a PR to fix this. Blocking: sync sessions are always local; the store driver may not be. They are not part of serving storage — only a temporary staging area before the image is validated and pushed into actual storage. Sync staging always goes through ocidir:// + a local image store (pkg/extensions/sync/oci_layout.go, getImageStore). For remote storage (S3/GCS/Azure), config already requires extensions.sync.downloadDir, so sessions live under that local directory — not under the remote store-driver prefix. This PR’s ListSyncSessions / StatSyncSession / DeleteSyncSession go through the serving ImageStore’s storeDriver, like .uploads. That only matches the default local layout (no downloadDir), where .sync sits under //. For remote + downloadDir (and local + downloadDir):
Unlike .uploads (real store-driver objects, including on remote), .sync sessions are not remote objects. I don’t think we should land this as-is. Sync-session lifecycle needs to stay on the local staging root, not on the remote-capable serving store driver. Happy to discuss an approach that keeps the local in-repo guard without putting these APIs on ImageStore. |
|
Thanks, @andaaron! Yes, I'm happy to keep working on this with some guidance. What is the best way to workshop an alternative design that guards only the local storage? |
|
Here's what I propose: No new ImageStore APIs, drop ListSyncSessions / StatSyncSession / DeleteSyncSession from types.ImageStore. Those belong to local staging, not serving storage.
Sync already removes session dirs on the normal path (defer CleanupImage in syncImage, and os.RemoveAll in CommitAll). If we want cleanup for orphans, a better fit is a separate scheduled task generator in the sync extension that:
|
What type of PR is this?
bug
Which issue does this PR fix:
#4240
What does this PR do / Why do we need it:
ImageStore.CleanupRepodeletes an entire repository directory when garbage collection finds every committed blob unreferenced. Its "no in-flight writes" guard only checked the.uploads/directory (ListBlobUploads) and ignored the on-demand sync staging directory.sync/<uuid>/. As a result, GC could remove a repository out from under a live on-demand pull that is staging blobs into it (the default sync config with nodownloadDir), corrupting the in-flight sync.This PR closes that race with two mirrored changes:
pkg/storage/imagestore/imagestore.go):CleanupReponow also skips repo removal while<repo>/.sync/contains any session directory, exactly as it already does for a non-empty.uploads/.pkg/storage/gc/gc.go): a newremoveStaleSyncSessionsdeletes.sync/<uuid>staging sessions older than the GC delay, mirroring the existingremoveBlobUploadsreaper. Without it, a session left behind by a crashed/interrupted sync would block repo removal forever (and leak disk). It runs at the end ofcleanRepo, afterremoveUnreferencedBlobs(and thus after theCleanupRepoguard), so a freshly created session's blobs are never reaped out from under it within the same pass.Known limitation (disclosed): a
.sync/<uuid>directory's mtime only advances when its direct children change, so a single download running longer than the GC delay could still be reaped mid-flight. Severity is low — the GC delay defaults to 1 hour (DefaultGCDelay), well above realistic staging times — and this is strictly better than the prior behavior, which deleted live sessions unconditionally via repo removal. A related follow-up is intentionally out of scope:CommitAll(pkg/extensions/sync/destination.go) silently returnsnilwhen its session directory has vanished; it should fail loudly instead.Testing done on this change:
New unit tests were added covering both halves of the fix, exercised against the real on-disk staging layout (
<repo>/.sync/<uuid>/):TestCleanupRepoToleratesSyncSessions(pkg/storage/imagestore) — an in-progress.syncsession blocks repo removal; removal proceeds when none exists; an empty.sync/dir does not block removal.TestRemoveStaleSyncSessions(pkg/storage/gc) — a stale session (old mtime) is reaped while a fresh one is kept.TestRemoveStaleSyncSessionsNoSessions— no.syncdirectory ⇒ zero deletions, no error.TestGCReaperSyncSessionsWithRepoRemoval— a repo with an unreferenced blob plus a stale session: the guard blocks repo removal while the session is present, the blob is GC'd, and the reaper removes the stale session (empty repos are intentionally left in place).Will this break upgrades or downgrades?
No. This changes only runtime garbage-collection behavior. There is no on-disk format change, no config schema change, and no API change — the .sync/ staging layout already exists. A binary on either side of this change reads and writes the same storage. Safe to upgrade or downgrade.
Does this PR introduce any user-facing change?:
No API or configuration change. Behaviorally, garbage collection no longer deletes a repository while an on-demand sync is staging into it, and it now reaps stale
.syncstaging sessions. GC deletion metrics gain one new label value,syncSession, alongside the existingmanifest/blob/upload.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.