detached 3565447

fix(snapshot): invalidate stale gc marks

commit
3565447ca4f28b4c0462658240ffc862e00bdb5a
author
brice · · 2026-07-21 21:13 UTC
parent
abf5279
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +127 -2
M internal/adapters/snapshot/maintenance.go +18 -1
@@ -70,6 +70,9 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
7070 if err := ctx.Err(); err != nil {
7171 return err
7272 }
73+ // A renamed session can be replaced by a later publication. Invalidate any
74+ // mark made for the old namespace before that replacement becomes possible.
75+ r.invalidateStorageEpoch(key)
7376 if err := r.rename(canonical, quarantine); err != nil {
7477 return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err))
7578 }
@@ -191,6 +194,7 @@ type manifestMaintenance struct {
191194
192195 type sessionMaintenance struct {
193196 token string
197+ epoch uint64
194198 marked map[uint64]manifestMaintenance
195199 uncertain bool
196200 conservative bool
@@ -380,6 +384,12 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in
380384 return nil
381385 }
382386 }
387+ if state.epoch != r.storageEpoch(key) {
388+ // A publication may have left an unpublished manifest after this mark.
389+ // Discard all mark and sweep cursors; the next pass starts from it.
390+ r.clearSessionMaintenance(key)
391+ return nil
392+ }
383393 if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 {
384394 return err
385395 }
@@ -401,10 +411,11 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e
401411 return nil, err
402412 }
403413 state := r.maintenanceSessions[key]
404- if state == nil || state.token != token {
414+ if state == nil || state.token != token || state.epoch != r.storageEpoch(key) {
405415 r.clearSessionMaintenance(key)
406416 state = &sessionMaintenance{
407417 token: token,
418+ epoch: r.storageEpoch(key),
408419 conservative: conservative,
409420 marked: make(map[uint64]manifestMaintenance),
410421 }
@@ -542,6 +553,12 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st
542553 }
543554
544555 func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
556+ // Every bounded sweep batch is tied to the mark's storage epoch. Do not use
557+ // stale references after any publication or replacement.
558+ if state.epoch != r.storageEpoch(key) {
559+ r.clearSessionMaintenance(key)
560+ return nil
561+ }
545562 referenced := retainedReferences(state.marked, state.conservative)
546563 root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
547564 if !state.sweepRootDone {
@@ -70,6 +70,9 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
70 if err := ctx.Err(); err != nil {70 if err := ctx.Err(); err != nil {
71 return err71 return err
72 }72 }
73+ // A renamed session can be replaced by a later publication. Invalidate any
74+ // mark made for the old namespace before that replacement becomes possible.
75+ r.invalidateStorageEpoch(key)
73 if err := r.rename(canonical, quarantine); err != nil {76 if err := r.rename(canonical, quarantine); err != nil {
74 return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err))77 return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err))
75 }78 }
@@ -191,6 +194,7 @@ type manifestMaintenance struct {
191 194
192 type sessionMaintenance struct {195 type sessionMaintenance struct {
193 token string196 token string
197+ epoch uint64
194 marked map[uint64]manifestMaintenance198 marked map[uint64]manifestMaintenance
195 uncertain bool199 uncertain bool
196 conservative bool200 conservative bool
@@ -380,6 +384,12 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in
380 return nil384 return nil
381 }385 }
382 }386 }
387+ if state.epoch != r.storageEpoch(key) {
388+ // A publication may have left an unpublished manifest after this mark.
389+ // Discard all mark and sweep cursors; the next pass starts from it.
390+ r.clearSessionMaintenance(key)
391+ return nil
392+ }
383 if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 {393 if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 {
384 return err394 return err
385 }395 }
@@ -401,10 +411,11 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e
401 return nil, err411 return nil, err
402 }412 }
403 state := r.maintenanceSessions[key]413 state := r.maintenanceSessions[key]
404- if state == nil || state.token != token {414+ if state == nil || state.token != token || state.epoch != r.storageEpoch(key) {
405 r.clearSessionMaintenance(key)415 r.clearSessionMaintenance(key)
406 state = &sessionMaintenance{416 state = &sessionMaintenance{
407 token: token,417 token: token,
418+ epoch: r.storageEpoch(key),
408 conservative: conservative,419 conservative: conservative,
409 marked: make(map[uint64]manifestMaintenance),420 marked: make(map[uint64]manifestMaintenance),
410 }421 }
@@ -542,6 +553,12 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st
542 }553 }
543 554
544 func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {555 func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
556+ // Every bounded sweep batch is tied to the mark's storage epoch. Do not use
557+ // stale references after any publication or replacement.
558+ if state.epoch != r.storageEpoch(key) {
559+ r.clearSessionMaintenance(key)
560+ return nil
561+ }
545 referenced := retainedReferences(state.marked, state.conservative)562 referenced := retainedReferences(state.marked, state.conservative)
546 root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)563 root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
547 if !state.sweepRootDone {564 if !state.sweepRootDone {
M internal/adapters/snapshot/maintenance_test.go +90 -0
@@ -530,3 +530,93 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
530530 })
531531 }
532532 }
533+
534+func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
535+ repo := NewRepository(privateDir(t))
536+ key := sessionKey("named")
537+ first := publicationWithTailShard(t, "named", 1, "ff")
538+ stalePaths := make([]string, 0, maintenanceBatch+1)
539+ for i := 0; i < maintenanceBatch+1; i++ {
540+ digest := digestInShard(t, "00", i)
541+ path := repo.objectPath(key, digest)
542+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
543+ t.Fatal(err)
544+ }
545+ if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
546+ t.Fatal(err)
547+ }
548+ stalePaths = append(stalePaths, path)
549+ }
550+ if err := repo.Publish(context.Background(), first); err != nil {
551+ t.Fatal(err)
552+ }
553+
554+ // This completes marking but exhausts the sweep budget in the stale shard,
555+ // leaving the next sweep batch pending.
556+ if err := repo.Maintain(context.Background()); err != nil {
557+ t.Fatal(err)
558+ }
559+ staleRemaining := false
560+ for _, path := range stalePaths {
561+ if _, err := os.Lstat(path); err == nil {
562+ staleRemaining = true
563+ break
564+ }
565+ }
566+ if !staleRemaining {
567+ t.Fatal("sweep did not leave a pending stale object")
568+ }
569+
570+ second := publicationWithTailShard(t, "named", 2, "ff")
571+ injected := errors.New("before HEAD")
572+ repo.hooks.beforeHeadWrite = func(string) error { return injected }
573+ if err := repo.Publish(context.Background(), second); !errors.Is(err, injected) {
574+ t.Fatalf("Publish error = %v, want injected failure", err)
575+ }
576+ repo.hooks.beforeHeadWrite = nil
577+
578+ for i := 0; i < 20; i++ {
579+ if err := repo.Maintain(context.Background()); err != nil {
580+ t.Fatal(err)
581+ }
582+ for _, object := range second.Objects {
583+ if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
584+ t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)
585+ }
586+ }
587+ staleRemaining = false
588+ for _, path := range stalePaths {
589+ if _, err := os.Lstat(path); err == nil {
590+ staleRemaining = true
591+ break
592+ }
593+ }
594+ }
595+ if staleRemaining {
596+ t.Fatal("stale object was not eventually swept")
597+ }
598+}
599+
600+func publicationWithTailShard(t *testing.T, name string, generation uint64, shard string) ports.SnapshotPublication {
601+ t.Helper()
602+ for i := 0; i < 4096; i++ {
603+ publication := repositoryPublication(t, name, generation, []byte(fmt.Sprintf("state-%d-%d", generation, i)))
604+ if fmt.Sprintf("%02x", publication.Objects[0].Digest[0]) == shard {
605+ return publication
606+ }
607+ }
608+ t.Fatalf("did not find publication tail in shard %q", shard)
609+ return ports.SnapshotPublication{}
610+}
611+
612+func digestInShard(t *testing.T, shard string, n int) ports.SnapshotDigest {
613+ t.Helper()
614+ for i := 0; i < 4096; i++ {
615+ digest := sha256.Sum256([]byte(fmt.Sprintf("stale-%d-%d", n, i)))
616+ if fmt.Sprintf("%02x", digest[0]) == shard {
617+ return digest
618+ }
619+ }
620+ t.Fatalf("did not find stale digest in shard %q", shard)
621+ return ports.SnapshotDigest{}
622+}
@@ -530,3 +530,93 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
530 })530 })
531 }531 }
532 }532 }
533+
534+func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
535+ repo := NewRepository(privateDir(t))
536+ key := sessionKey("named")
537+ first := publicationWithTailShard(t, "named", 1, "ff")
538+ stalePaths := make([]string, 0, maintenanceBatch+1)
539+ for i := 0; i < maintenanceBatch+1; i++ {
540+ digest := digestInShard(t, "00", i)
541+ path := repo.objectPath(key, digest)
542+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
543+ t.Fatal(err)
544+ }
545+ if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
546+ t.Fatal(err)
547+ }
548+ stalePaths = append(stalePaths, path)
549+ }
550+ if err := repo.Publish(context.Background(), first); err != nil {
551+ t.Fatal(err)
552+ }
553+
554+ // This completes marking but exhausts the sweep budget in the stale shard,
555+ // leaving the next sweep batch pending.
556+ if err := repo.Maintain(context.Background()); err != nil {
557+ t.Fatal(err)
558+ }
559+ staleRemaining := false
560+ for _, path := range stalePaths {
561+ if _, err := os.Lstat(path); err == nil {
562+ staleRemaining = true
563+ break
564+ }
565+ }
566+ if !staleRemaining {
567+ t.Fatal("sweep did not leave a pending stale object")
568+ }
569+
570+ second := publicationWithTailShard(t, "named", 2, "ff")
571+ injected := errors.New("before HEAD")
572+ repo.hooks.beforeHeadWrite = func(string) error { return injected }
573+ if err := repo.Publish(context.Background(), second); !errors.Is(err, injected) {
574+ t.Fatalf("Publish error = %v, want injected failure", err)
575+ }
576+ repo.hooks.beforeHeadWrite = nil
577+
578+ for i := 0; i < 20; i++ {
579+ if err := repo.Maintain(context.Background()); err != nil {
580+ t.Fatal(err)
581+ }
582+ for _, object := range second.Objects {
583+ if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
584+ t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)
585+ }
586+ }
587+ staleRemaining = false
588+ for _, path := range stalePaths {
589+ if _, err := os.Lstat(path); err == nil {
590+ staleRemaining = true
591+ break
592+ }
593+ }
594+ }
595+ if staleRemaining {
596+ t.Fatal("stale object was not eventually swept")
597+ }
598+}
599+
600+func publicationWithTailShard(t *testing.T, name string, generation uint64, shard string) ports.SnapshotPublication {
601+ t.Helper()
602+ for i := 0; i < 4096; i++ {
603+ publication := repositoryPublication(t, name, generation, []byte(fmt.Sprintf("state-%d-%d", generation, i)))
604+ if fmt.Sprintf("%02x", publication.Objects[0].Digest[0]) == shard {
605+ return publication
606+ }
607+ }
608+ t.Fatalf("did not find publication tail in shard %q", shard)
609+ return ports.SnapshotPublication{}
610+}
611+
612+func digestInShard(t *testing.T, shard string, n int) ports.SnapshotDigest {
613+ t.Helper()
614+ for i := 0; i < 4096; i++ {
615+ digest := sha256.Sum256([]byte(fmt.Sprintf("stale-%d-%d", n, i)))
616+ if fmt.Sprintf("%02x", digest[0]) == shard {
617+ return digest
618+ }
619+ }
620+ t.Fatalf("did not find stale digest in shard %q", shard)
621+ return ports.SnapshotDigest{}
622+}
M internal/adapters/snapshot/publish.go +3 -0
@@ -28,6 +28,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
2828 if err := ctx.Err(); err != nil {
2929 return err
3030 }
31+ // A failed publication can still leave immutable blobs or a manifest behind.
32+ // Invalidate an in-progress GC mark before creating any publication storage.
33+ r.invalidateStorageEpoch(key)
3134 if err := r.ensureSession(key); err != nil {
3235 return err
3336 }
@@ -28,6 +28,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
28 if err := ctx.Err(); err != nil {28 if err := ctx.Err(); err != nil {
29 return err29 return err
30 }30 }
31+ // A failed publication can still leave immutable blobs or a manifest behind.
32+ // Invalidate an in-progress GC mark before creating any publication storage.
33+ r.invalidateStorageEpoch(key)
31 if err := r.ensureSession(key); err != nil {34 if err := r.ensureSession(key); err != nil {
32 return err35 return err
33 }36 }
M internal/adapters/snapshot/repository.go +16 -1
@@ -26,6 +26,11 @@ type Repository struct {
2626 locks sync.Map // map[string]*sync.Mutex
2727 hooks repositoryHooks
2828
29+ // storageEpochs is guarded by each session's lock. It invalidates a
30+ // maintenance mark whenever publication or deletion changes that session's
31+ // on-disk namespace.
32+ storageEpochs map[string]uint64
33+
2934 // maintenanceMu owns continuation state for bounded maintenance. Keeping
3035 // directory handles on the repository makes successive calls advance rather
3136 // than repeatedly reading the first batch of a large directory.
@@ -61,7 +66,17 @@ var _ ports.LegacySnapshotSource = (*Repository)(nil)
6166
6267 // NewRepository creates a repository rooted at dir. It does not create files
6368 // until the first publication, so merely constructing it is side-effect free.
64-func NewRepository(dir string) *Repository { return &Repository{dir: dir} }
69+func NewRepository(dir string) *Repository {
70+ return &Repository{dir: dir, storageEpochs: make(map[string]uint64)}
71+}
72+
73+// invalidateStorageEpoch must be called with sessionLock(key) held.
74+func (r *Repository) invalidateStorageEpoch(key string) {
75+ r.storageEpochs[key]++
76+}
77+
78+// storageEpoch must be called with sessionLock(key) held.
79+func (r *Repository) storageEpoch(key string) uint64 { return r.storageEpochs[key] }
6580
6681 func (r *Repository) sessionLock(key string) *sync.Mutex {
6782 lock, _ := r.locks.LoadOrStore(key, &sync.Mutex{})
@@ -26,6 +26,11 @@ type Repository struct {
26 locks sync.Map // map[string]*sync.Mutex26 locks sync.Map // map[string]*sync.Mutex
27 hooks repositoryHooks27 hooks repositoryHooks
28 28
29+ // storageEpochs is guarded by each session's lock. It invalidates a
30+ // maintenance mark whenever publication or deletion changes that session's
31+ // on-disk namespace.
32+ storageEpochs map[string]uint64
33+
29 // maintenanceMu owns continuation state for bounded maintenance. Keeping34 // maintenanceMu owns continuation state for bounded maintenance. Keeping
30 // directory handles on the repository makes successive calls advance rather35 // directory handles on the repository makes successive calls advance rather
31 // than repeatedly reading the first batch of a large directory.36 // than repeatedly reading the first batch of a large directory.
@@ -61,7 +66,17 @@ var _ ports.LegacySnapshotSource = (*Repository)(nil)
61 66
62 // NewRepository creates a repository rooted at dir. It does not create files67 // NewRepository creates a repository rooted at dir. It does not create files
63 // until the first publication, so merely constructing it is side-effect free.68 // until the first publication, so merely constructing it is side-effect free.
64-func NewRepository(dir string) *Repository { return &Repository{dir: dir} }69+func NewRepository(dir string) *Repository {
70+ return &Repository{dir: dir, storageEpochs: make(map[string]uint64)}
71+}
72+
73+// invalidateStorageEpoch must be called with sessionLock(key) held.
74+func (r *Repository) invalidateStorageEpoch(key string) {
75+ r.storageEpochs[key]++
76+}
77+
78+// storageEpoch must be called with sessionLock(key) held.
79+func (r *Repository) storageEpoch(key string) uint64 { return r.storageEpochs[key] }
65 80
66 func (r *Repository) sessionLock(key string) *sync.Mutex {81 func (r *Repository) sessionLock(key string) *sync.Mutex {
67 lock, _ := r.locks.LoadOrStore(key, &sync.Mutex{})82 lock, _ := r.locks.LoadOrStore(key, &sync.Mutex{})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close