1package snapshot
2
3import (
4 "context"
5 "crypto/sha256"
6 "os"
7 "path/filepath"
8 "slices"
9 "testing"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13 "github.com/stretchr/testify/require"
14)
15
16func TestCollectGarbageClearsUncommittedCheckpointHead(t *testing.T) {
17 t.Parallel()
18 repository := NewRepository(privateDir(t))
19 publication := repositoryPublication(t, "work", 1, []byte("first checkpoint"))
20 require.NoError(t, repository.Publish(context.Background(), publication))
21
22 // Repository publication completed, but the catalogue commit did not.
23 // Startup GC knows the incarnation but has no committed checkpoint for it.
24 keep := map[domain.IncarnationID]domain.CheckpointRef{publication.IncarnationID: {}}
25 require.NoError(t, repository.CollectGarbage(context.Background(), keep))
26 _, err := os.Stat(repository.headPath(publication.IncarnationID))
27 require.ErrorIs(t, err, os.ErrNotExist)
28 require.Empty(t, garbageCollectionGenerations(t, repository, publication.IncarnationID))
29
30 // Retrying starts from an empty repository, then the resulting checkpoint
31 // can be committed to the catalogue and restored.
32 require.NoError(t, repository.Publish(context.Background(), publication))
33 committed := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}
34 keep[publication.IncarnationID] = committed
35 require.NoError(t, repository.CollectGarbage(context.Background(), keep))
36 _, err = repository.LoadCheckpoint(context.Background(), publication.IncarnationID, publication.Name, committed)
37 require.NoError(t, err)
38}
39
40func TestCollectGarbageRejectsMalformedAuthorityWithoutMutation(t *testing.T) {
41 for _, tt := range []struct {
42 name string
43 mutate func(*testing.T, *Repository, domain.IncarnationID, domain.CheckpointRef) domain.CheckpointRef
44 }{
45 {
46 name: "committed digest mismatch",
47 mutate: func(_ *testing.T, _ *Repository, _ domain.IncarnationID, committed domain.CheckpointRef) domain.CheckpointRef {
48 committed.ManifestDigest[0]++
49 return committed
50 },
51 },
52 {
53 name: "committed manifest missing",
54 mutate: func(t *testing.T, repository *Repository, id domain.IncarnationID, committed domain.CheckpointRef) domain.CheckpointRef {
55 t.Helper()
56 require.NoError(t, os.Remove(repository.manifestPath(id, committed.Generation)))
57 return committed
58 },
59 },
60 {
61 name: "committed manifest corrupt",
62 mutate: func(t *testing.T, repository *Repository, id domain.IncarnationID, committed domain.CheckpointRef) domain.CheckpointRef {
63 t.Helper()
64 require.NoError(t, os.WriteFile(repository.manifestPath(id, committed.Generation), []byte("corrupt"), 0o600))
65 return committed
66 },
67 },
68 } {
69 t.Run(tt.name, func(t *testing.T) {
70 repository := NewRepository(privateDir(t))
71 publications := publishGarbageCollectionGenerations(t, repository, "work", 3)
72 id := publications[0].IncarnationID
73 committed := domain.CheckpointRef{Generation: 3, ManifestDigest: sha256.Sum256(publications[2].Manifest)}
74 committed = tt.mutate(t, repository, id, committed)
75 before := snapshotRepositoryFiles(t, repository.dir)
76
77 require.Error(t, repository.CollectGarbage(t.Context(), map[domain.IncarnationID]domain.CheckpointRef{id: committed}))
78 require.Equal(t, before, snapshotRepositoryFiles(t, repository.dir), "malformed catalogue authority must fail closed")
79 })
80 }
81}
82
83func TestCollectGarbage(t *testing.T) {
84 t.Parallel()
85 tests := []struct {
86 name string
87 committed uint64
88 known bool
89 wantGenerations []uint64
90 }{
91 {name: "incarnation absent from catalogue is removed whole"},
92 {name: "committed checkpoint prunes forward orphan and keeps predecessor", committed: 3, known: true, wantGenerations: []uint64{2, 3}},
93 }
94 for _, tt := range tests {
95 t.Run(tt.name, func(t *testing.T) {
96 t.Parallel()
97 repository := NewRepository(privateDir(t))
98 publications := publishGarbageCollectionGenerations(t, repository, "work", 4)
99 id := publications[0].IncarnationID
100 keep := make(map[domain.IncarnationID]domain.CheckpointRef)
101 if tt.known {
102 publication := publications[tt.committed-1]
103 keep[id] = domain.CheckpointRef{Generation: tt.committed, ManifestDigest: sha256.Sum256(publication.Manifest)}
104 }
105
106 require.NoError(t, repository.CollectGarbage(context.Background(), keep))
107 if !tt.known {
108 _, err := os.Stat(repository.sessionPath(id))
109 require.ErrorIs(t, err, os.ErrNotExist)
110 return
111 }
112 require.Equal(t, tt.wantGenerations, garbageCollectionGenerations(t, repository, id))
113 retainedObjects := make(map[ports.SnapshotDigest]struct{})
114 for _, publication := range publications[tt.committed-2 : tt.committed] {
115 for _, object := range publication.Objects {
116 retainedObjects[object.Digest] = struct{}{}
117 }
118 }
119 for _, publication := range publications {
120 for _, object := range publication.Objects {
121 _, retained := retainedObjects[object.Digest]
122 _, err := os.Stat(repository.objectPath(id, object.Digest))
123 if retained {
124 require.NoError(t, err)
125 } else {
126 require.ErrorIs(t, err, os.ErrNotExist)
127 }
128 }
129 }
130 })
131 }
132}
133
134func publishGarbageCollectionGenerations(t *testing.T, repository *Repository, name string, count uint64) []ports.SnapshotPublication {
135 t.Helper()
136 publications := make([]ports.SnapshotPublication, 0, count)
137 for generation := uint64(1); generation <= count; generation++ {
138 publication := repositoryPublicationAfter(t, repository, name, generation, []byte{byte(generation)})
139 require.NoError(t, repository.Publish(context.Background(), publication))
140 publications = append(publications, publication)
141 }
142 return publications
143}
144
145func garbageCollectionGenerations(t *testing.T, repository *Repository, id domain.IncarnationID) []uint64 {
146 t.Helper()
147 entries, err := os.ReadDir(filepath.Join(repository.sessionPath(id), repositoryGenerations))
148 require.NoError(t, err)
149 generations := make([]uint64, 0, len(entries))
150 for _, entry := range entries {
151 if generation, ok := parseGenerationFilename(entry.Name()); ok {
152 generations = append(generations, generation)
153 }
154 }
155 slices.Sort(generations)
156 return generations
157}