Skip to content

Commit fdf816f

Browse files
authored
fix(storage): defer blob deletes for cache-unknown blobs until dedupe rebuild completes (#4195)
With dedupe enabled on cloud storage, duplicate blobs are stored as zero-size placeholders whose content lives in a single original blob, resolvable only through the dedupe cache. When the cache is lost or incomplete (e.g. an ephemeral local cache database with cloud storage), deleteBlob's cache lookup misses, the move-content-to-next-duplicate logic is skipped entirely, and the storage delete destroys the only content-bearing copy. Every duplicate of that digest in other repositories is then permanently broken (416 RequestedRangeNotSatisfiable on read), which is the data loss reported in issue #2625. The startup dedupe/restore walk (RunDedupeBlobs) already rebuilds the cache from storage, but GC/retention can run before or while it does, which is exactly the corruption window. Fix: track completion of the dedupe/restore walk on the ImageStore (reusing the DedupeTaskGenerator completion callback, renamed to OnRunComplete and now fired for both dedupe and restore runs) and defer deletes of content-bearing blobs that the cache cannot account for until the walk has completed. Such deletes return the new ErrDedupeRebuildInProgress; GC/retention retries on a later interval, and blobs known to the cache (or zero-size placeholders) are unaffected. Local storage is exempt: it dedupes via hardlinks, so deleting one path never destroys shared content. The gate is armed only when RunDedupeBlobs is invoked (the server always does at startup), so stores that never schedule the walk keep existing behavior. Generator submission order in StartBackgroundTasks is deliberately unchanged: the walk being scheduled a few statements after the GC generator leaves only a sub-millisecond arming window, whereas reordering the submissions shifts the GC generator's first pass by a scheduler slot and changes long-standing GC timing behavior. Fixes #2625 Signed-off-by: Johnathan Aretos <johnathan@squareup.com>
1 parent 8ceb7f3 commit fdf816f

5 files changed

Lines changed: 519 additions & 38 deletions

File tree

errors/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ var (
130130
ErrTimeout = errors.New("operation timeout")
131131
ErrNotImplemented = errors.New("not implemented")
132132
ErrDedupeRebuild = errors.New("couldn't rebuild dedupe index")
133+
ErrDedupeRebuildInProgress = errors.New("dedupe cache rebuild in progress, blob delete deferred")
133134
ErrMissingAuthHeader = errors.New("required authorization header is missing")
134135
ErrUserAPIKeyNotFound = errors.New("user info for given API key hash not found")
135136
ErrUserSessionNotFound = errors.New("user session for given ID not found")

pkg/storage/common/common.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -918,9 +918,9 @@ type restoreRunState struct {
918918
// completed successfully. It is incremented when a task is generated and decremented
919919
// only by onDone, which a task calls on success (see dedupeTask.DoWork). A failed task
920920
// is therefore never decremented, so the count intentionally never reaches zero again
921-
// for this run, preventing checkCompletion from firing OnRestoreComplete after a failure.
921+
// for this run, preventing checkCompletion from firing OnRunComplete after a failure.
922922
pendingTaskCount atomic.Int64
923-
// completeOnce ensures OnRestoreComplete is called at most once for this run.
923+
// completeOnce ensures OnRunComplete is called at most once for this run.
924924
completeOnce sync.Once
925925
// done is set to true once all tasks for this run have been generated. It is read
926926
// both by IsDone() on the scheduler goroutine and by checkCompletion() from onDone
@@ -943,10 +943,12 @@ type DedupeTaskGenerator struct {
943943
lastDigests []godigest.Digest
944944
repos []string // list of repos on which we run dedupe
945945
Log zlog.Logger
946-
// OnRestoreComplete is called exactly once after ALL restore tasks have executed
947-
// successfully. Used to write the restore-complete marker so the next startup with
948-
// dedupe=false can skip the expensive per-digest scan.
949-
OnRestoreComplete func()
946+
// OnRunComplete is called exactly once after all tasks of a run have
947+
// succeeded, whether the run dedupes blobs (Dedupe=true) or restores them
948+
// (Dedupe=false). Restore runs use it to write the restore-complete marker;
949+
// both kinds use it to signal the image store that blob deletes may proceed
950+
// (see ImageStore.deleteBlob).
951+
OnRunComplete func()
950952
// run holds the completion-tracking state for the current run. onDone closures
951953
// capture the *restoreRunState pointer directly and operate on it independently
952954
// of any later Reset(). It is an atomic.Pointer because checkCompletion() compares
@@ -975,7 +977,7 @@ func (gen *DedupeTaskGenerator) getRun() *restoreRunState {
975977

976978
// All restore tasks for this run have been generated AND all of them have completed successfully.
977979
func (gen *DedupeTaskGenerator) checkCompletion(run *restoreRunState) {
978-
if gen.OnRestoreComplete != nil &&
980+
if gen.OnRunComplete != nil &&
979981
run.done.Load() &&
980982
run.pendingTaskCount.Load() == 0 {
981983
// Dispatch asynchronously so the executor goroutine that triggered completion
@@ -985,11 +987,11 @@ func (gen *DedupeTaskGenerator) checkCompletion(run *restoreRunState) {
985987
defer func() {
986988
if r := recover(); r != nil {
987989
gen.Log.Error().Interface("panic", r).Str("component", "dedupe").
988-
Msg("panic in OnRestoreComplete")
990+
Msg("panic in OnRunComplete")
989991
}
990992
}()
991993

992-
gen.OnRestoreComplete()
994+
gen.OnRunComplete()
993995
}()
994996
})
995997
}
@@ -1044,10 +1046,10 @@ func (gen *DedupeTaskGenerator) Next() (scheduler.Task, error) {
10441046
// mark digest as processed before running its task
10451047
gen.lastDigests = append(gen.lastDigests, gen.digest)
10461048

1047-
// For restore passes, track each task so the marker is only written after all succeed.
1049+
// Track each task so completion fires only after all of them succeed.
10481050
var onDone func()
10491051

1050-
if !gen.Dedupe && gen.OnRestoreComplete != nil {
1052+
if gen.OnRunComplete != nil {
10511053
run.pendingTaskCount.Add(1)
10521054

10531055
onDone = func() {
@@ -1074,10 +1076,10 @@ func (gen *DedupeTaskGenerator) Reset() {
10741076

10751077
// Only start a fresh run if the current one has no in-flight tasks (or completion
10761078
// isn't tracked at all). If tasks are still executing, keep the same run state so
1077-
// their onDone callbacks can still drive checkCompletion to fire OnRestoreComplete
1079+
// their onDone callbacks can still drive checkCompletion to fire OnRunComplete
10781080
// once they finish; replacing gen.run here would otherwise make that run's
10791081
// completion unobservable forever.
1080-
if gen.OnRestoreComplete == nil || (run.done.Load() && run.pendingTaskCount.Load() == 0) {
1082+
if gen.OnRunComplete == nil || (run.done.Load() && run.pendingTaskCount.Load() == 0) {
10811083
gen.run.Store(&restoreRunState{})
10821084
}
10831085

@@ -1095,7 +1097,7 @@ type dedupeTask struct {
10951097
duplicateBlobs []string
10961098
dedupe bool
10971099
log zlog.Logger
1098-
// onDone is called when this restore task succeeds. nil for dedupe (non-restore) tasks.
1100+
// onDone is called when this task succeeds; nil when completion isn't tracked.
10991101
onDone func()
11001102
}
11011103

pkg/storage/common/common_test.go

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ func TestDedupeGeneratorErrors(t *testing.T) {
624624
func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
625625
testLog := log.NewTestLogger()
626626

627-
Convey("OnRestoreComplete fires once after all restore tasks finish successfully", t, func(c C) {
627+
Convey("OnRunComplete fires once after all restore tasks finish successfully", t, func(c C) {
628628
digest := godigest.FromString("blob1")
629629
duplicateBlobs := []string{"/repo/blob1-a", "/repo/blob1-b"}
630630

@@ -662,7 +662,7 @@ func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
662662
ImgStore: imgStore,
663663
Dedupe: false,
664664
Log: testLog,
665-
OnRestoreComplete: func() {
665+
OnRunComplete: func() {
666666
callCountMutex.Lock()
667667
callCount++
668668
callCountMutex.Unlock()
@@ -687,11 +687,11 @@ func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
687687
So(task, ShouldBeNil)
688688
So(generator.IsDone(), ShouldBeTrue)
689689

690-
// OnRestoreComplete is dispatched asynchronously
690+
// OnRunComplete is dispatched asynchronously
691691
select {
692692
case <-done:
693693
case <-time.After(5 * time.Second):
694-
t.Fatal("timed out waiting for OnRestoreComplete")
694+
t.Fatal("timed out waiting for OnRunComplete")
695695
}
696696

697697
callCountMutex.Lock()
@@ -703,6 +703,76 @@ func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
703703
So(generator.IsDone(), ShouldBeFalse)
704704
})
705705

706+
Convey("OnRunComplete fires once after all dedupe tasks finish successfully", t, func(c C) {
707+
digest := godigest.FromString("blob-dedupe")
708+
duplicateBlobs := []string{"/repo/blob-dedupe-a", "/repo/blob-dedupe-b"}
709+
710+
getNextCalls := 0
711+
712+
imgStore := &mocks.MockedImageStore{
713+
GetRepositoriesFn: func() ([]string, error) {
714+
return []string{"repo1"}, nil
715+
},
716+
GetNextDigestWithBlobPathsFn: func(repos []string, lastDigests []godigest.Digest) (
717+
godigest.Digest, []string, error,
718+
) {
719+
getNextCalls++
720+
if getNextCalls == 1 {
721+
return digest, duplicateBlobs, nil
722+
}
723+
724+
return "", nil, nil
725+
},
726+
RunDedupeForDigestFn: func(ctx context.Context, digest godigest.Digest, dedupe bool,
727+
duplicateBlobs []string,
728+
) error {
729+
return nil
730+
},
731+
}
732+
733+
var (
734+
callCountMutex sync.Mutex
735+
callCount int
736+
)
737+
738+
done := make(chan struct{})
739+
740+
generator := &common.DedupeTaskGenerator{
741+
ImgStore: imgStore,
742+
Dedupe: true,
743+
Log: testLog,
744+
OnRunComplete: func() {
745+
callCountMutex.Lock()
746+
callCount++
747+
callCountMutex.Unlock()
748+
close(done)
749+
},
750+
}
751+
752+
task, err := generator.Next()
753+
So(err, ShouldBeNil)
754+
So(task, ShouldNotBeNil)
755+
So(generator.IsDone(), ShouldBeFalse)
756+
757+
err = task.DoWork(context.Background())
758+
So(err, ShouldBeNil)
759+
760+
task, err = generator.Next()
761+
So(err, ShouldBeNil)
762+
So(task, ShouldBeNil)
763+
So(generator.IsDone(), ShouldBeTrue)
764+
765+
select {
766+
case <-done:
767+
case <-time.After(5 * time.Second):
768+
t.Fatal("timed out waiting for OnRunComplete")
769+
}
770+
771+
callCountMutex.Lock()
772+
So(callCount, ShouldEqual, 1)
773+
callCountMutex.Unlock()
774+
})
775+
706776
Convey("Reset keeps the same run while a restore task is in-flight", t, func(c C) {
707777
digest := godigest.FromString("blob2")
708778
duplicateBlobs := []string{"/repo/blob2-a"}
@@ -741,7 +811,7 @@ func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
741811
ImgStore: imgStore,
742812
Dedupe: false,
743813
Log: testLog,
744-
OnRestoreComplete: func() {
814+
OnRunComplete: func() {
745815
callCountMutex.Lock()
746816
callCount++
747817
callCountMutex.Unlock()
@@ -760,26 +830,26 @@ func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
760830
So(err, ShouldBeNil)
761831
So(generator.IsDone(), ShouldBeTrue)
762832

763-
// Reset() must keep the same run state, since OnRestoreComplete hasn't fired yet
833+
// Reset() must keep the same run state, since OnRunComplete hasn't fired yet
764834
generator.Reset()
765835
So(generator.IsDone(), ShouldBeTrue)
766836

767-
// finishing the in-flight task now drives checkCompletion to fire OnRestoreComplete
837+
// finishing the in-flight task now drives checkCompletion to fire OnRunComplete
768838
err = task.DoWork(context.Background())
769839
So(err, ShouldBeNil)
770840

771841
select {
772842
case <-done:
773843
case <-time.After(5 * time.Second):
774-
t.Fatal("timed out waiting for OnRestoreComplete")
844+
t.Fatal("timed out waiting for OnRunComplete")
775845
}
776846

777847
callCountMutex.Lock()
778848
So(callCount, ShouldEqual, 1)
779849
callCountMutex.Unlock()
780850
})
781851

782-
Convey("checkCompletion recovers if OnRestoreComplete panics", t, func(c C) {
852+
Convey("checkCompletion recovers if OnRunComplete panics", t, func(c C) {
783853
digest := godigest.FromString("blob3")
784854
duplicateBlobs := []string{"/repo/blob3-a"}
785855

@@ -816,7 +886,7 @@ func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
816886
ImgStore: imgStore,
817887
Dedupe: false,
818888
Log: panicLog,
819-
OnRestoreComplete: func() {
889+
OnRunComplete: func() {
820890
defer close(done)
821891
panic("boom")
822892
},
@@ -836,18 +906,18 @@ func TestDedupeTaskGeneratorRestoreComplete(t *testing.T) {
836906
select {
837907
case <-done:
838908
case <-time.After(5 * time.Second):
839-
t.Fatal("timed out waiting for OnRestoreComplete")
909+
t.Fatal("timed out waiting for OnRunComplete")
840910
}
841911

842912
for range 100 {
843-
if strings.Contains(logBuf.String(), "panic in OnRestoreComplete") {
913+
if strings.Contains(logBuf.String(), "panic in OnRunComplete") {
844914
break
845915
}
846916

847917
time.Sleep(10 * time.Millisecond)
848918
}
849919

850-
So(logBuf.String(), ShouldContainSubstring, "panic in OnRestoreComplete")
920+
So(logBuf.String(), ShouldContainSubstring, "panic in OnRunComplete")
851921
})
852922

853923
Convey("getRun is safe when the first call races across goroutines", t, func(c C) {

pkg/storage/imagestore/imagestore.go

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"slices"
1414
"strings"
1515
"sync"
16+
"sync/atomic"
1617
"time"
1718
"unicode/utf8"
1819

@@ -54,6 +55,9 @@ type ImageStore struct {
5455
linter common.Lint
5556
commit bool
5657
compat []compat.MediaCompatibility
58+
// dedupeRebuildDone is set once RunDedupeBlobs has walked all blobs, i.e. the
59+
// cache accounts for every pre-existing blob; see deleteBlob.
60+
dedupeRebuildDone atomic.Bool
5761
}
5862

5963
func (is *ImageStore) Name() string {
@@ -95,6 +99,9 @@ func NewImageStore(rootDir string, cacheDir string, dedupe, commit bool, log zlo
9599
events: recorder,
96100
}
97101

102+
// Deletes are only gated while a dedupe/restore walk is pending; see RunDedupeBlobs.
103+
imgStore.dedupeRebuildDone.Store(true)
104+
98105
return imgStore
99106
}
100107

@@ -1983,7 +1990,7 @@ func (is *ImageStore) CleanupRepo(repo string, blobs []godigest.Digest, removeRe
19831990
func (is *ImageStore) deleteBlob(repo string, digest godigest.Digest) error {
19841991
blobPath := is.BlobPath(repo, digest)
19851992

1986-
_, err := is.storeDriver.Stat(blobPath)
1993+
binfo, err := is.storeDriver.Stat(blobPath)
19871994
if err != nil {
19881995
var pathNotFoundErr driver.PathNotFoundError
19891996
if errors.As(err, &pathNotFoundErr) {
@@ -2009,6 +2016,16 @@ func (is *ImageStore) deleteBlob(repo string, digest godigest.Digest) error {
20092016
return err
20102017
}
20112018

2019+
// Cache miss: with dedupe on remote storage this blob may be the only
2020+
// content copy backing zero-size duplicates elsewhere.
2021+
// Defer until the startup walk has rebuilt the cache; GC retries later.
2022+
if dstRecord == "" && binfo.Size() > 0 && !is.dedupeRebuildDone.Load() {
2023+
is.log.Warn().Str("digest", digest.String()).Str("blobPath", blobPath).Str("component", "dedupe").
2024+
Msg("no cache record for content blob while dedupe rebuild is still running, deferring delete")
2025+
2026+
return zerr.ErrDedupeRebuildInProgress
2027+
}
2028+
20122029
// remove cache entry and move blob contents to the next candidate if there is any
20132030
if ok := is.cache.HasBlob(digest, blobPath); ok {
20142031
if err := is.cache.DeleteBlob(digest, blobPath); err != nil {
@@ -2056,6 +2073,15 @@ func (is *ImageStore) deleteBlob(repo string, digest godigest.Digest) error {
20562073
}
20572074
}
20582075

2076+
// No cache (dedupe off): leftover placeholders are refilled by the restore
2077+
// walk; until it completes this blob may be their only content copy.
2078+
if fmt.Sprintf("%v", is.cache) == fmt.Sprintf("%v", nil) && binfo.Size() > 0 && !is.dedupeRebuildDone.Load() {
2079+
is.log.Warn().Str("digest", digest.String()).Str("blobPath", blobPath).Str("component", "dedupe").
2080+
Msg("content blob delete requested before dedupe restore walk finished, deferring delete")
2081+
2082+
return zerr.ErrDedupeRebuildInProgress
2083+
}
2084+
20592085
if err := is.storeDriver.Delete(blobPath); err != nil {
20602086
var pathNotFoundErr driver.PathNotFoundError
20612087
if errors.As(err, &pathNotFoundErr) {
@@ -2442,6 +2468,12 @@ func (is *ImageStore) RunDedupeForDigest(ctx context.Context, digest godigest.Di
24422468
func (is *ImageStore) RunDedupeBlobs(interval time.Duration, sch *scheduler.Scheduler) {
24432469
markerPath := path.Join(is.rootDir, storageConstants.DedupeRestoreCompleteMarker)
24442470

2471+
// Gate deletes of cache-unknown blobs until the walk completes (see deleteBlob).
2472+
// Local storage dedupes via hardlinks, so deletes there never destroy shared content.
2473+
if is.storeDriver.Name() != storageConstants.LocalStorageDriverName {
2474+
is.dedupeRebuildDone.Store(false)
2475+
}
2476+
24452477
if is.dedupe {
24462478
// Dedupe is active: remove the restore-complete marker so that a future dedupe→false
24472479
// transition knows it must run restore again.
@@ -2470,6 +2502,9 @@ func (is *ImageStore) RunDedupeBlobs(interval time.Duration, sch *scheduler.Sche
24702502
is.log.Info().Str("component", "dedupe").
24712503
Msg("restore-complete marker present, skipping dedupe restore scan")
24722504

2505+
// storage holds no deduped blobs, so deletes are safe without a walk
2506+
is.dedupeRebuildDone.Store(true)
2507+
24732508
return
24742509
}
24752510

@@ -2490,16 +2525,21 @@ func (is *ImageStore) RunDedupeBlobs(interval time.Duration, sch *scheduler.Sche
24902525
Log: is.log,
24912526
}
24922527

2493-
if !is.dedupe {
2494-
generator.OnRestoreComplete = func() {
2495-
if _, err := is.storeDriver.WriteFile(markerPath,
2496-
[]byte(storageConstants.DedupeRestoreMarkerComplete)); err != nil {
2497-
is.log.Error().Err(err).Str("component", "dedupe").
2498-
Msg("failed to write restore-complete marker")
2499-
} else {
2500-
is.log.Info().Str("component", "dedupe").
2501-
Msg("restore-complete marker written; future startups will skip the restore scan")
2502-
}
2528+
generator.OnRunComplete = func() {
2529+
// walk finished: deferred blob deletes may proceed (see deleteBlob)
2530+
is.dedupeRebuildDone.Store(true)
2531+
2532+
if is.dedupe {
2533+
return
2534+
}
2535+
2536+
if _, err := is.storeDriver.WriteFile(markerPath,
2537+
[]byte(storageConstants.DedupeRestoreMarkerComplete)); err != nil {
2538+
is.log.Error().Err(err).Str("component", "dedupe").
2539+
Msg("failed to write restore-complete marker")
2540+
} else {
2541+
is.log.Info().Str("component", "dedupe").
2542+
Msg("restore-complete marker written; future startups will skip the restore scan")
25032543
}
25042544
}
25052545

0 commit comments

Comments
 (0)