v0.3.0 99dc266

maintenance_gc_test.go RawHistoryFolder

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}

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