detached 6bbcb8d

refactor(snapshot): replace tombstoned deletion with startup garbage collection

commit
6bbcb8d1eb4d00573a0629ccb8ed2591dffc23ee
author
brice · · 2026-07-26 11:34 UTC
parent
d5c98ee
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
39 files · +376 -4777
D internal/adapters/snapshot/dirent_cookie_darwin.go +0 -25
@@ -1,25 +0,0 @@
1-//go:build darwin
2-
3-package snapshot
4-
5-import (
6- "io"
7- "syscall"
8- "unsafe"
9-)
10-
11-// syscall.ReadDirent on Darwin implements Getdirentries by storing the number
12-// of returned entries in the descriptor seek offset. Dirent.Seekoff is not
13-// that cursor. A max-sized record buffer lets each call be drained before the
14-// descriptor-maintained count is saved.
15-const maintenanceDirentBufferSize = int(unsafe.Sizeof(syscall.Dirent{}))
16-
17-func directoryCookie(file maintenanceDirectory, _ *syscall.Dirent, remaining int) (int64, error) {
18- end, err := file.Seek(0, io.SeekCurrent)
19- if err != nil {
20- return 0, err
21- }
22- return end - int64(remaining), nil
23-}
24-
25-func drainMaintenanceDirentBatch() bool { return true }
D internal/adapters/snapshot/dirent_cookie_darwin_test.go +0 -31
@@ -1,31 +0,0 @@
1-//go:build darwin
2-
3-package snapshot
4-
5-import (
6- "syscall"
7- "testing"
8-)
9-
10-func TestDirectoryCookieUsesDescriptorEntryCount(t *testing.T) {
11- file := fakeMaintenanceDirectory{seekOffset: 42}
12- dirent := syscall.Dirent{Seekoff: 99}
13- got, err := directoryCookie(file, &dirent, 0)
14- if err != nil {
15- t.Fatal(err)
16- }
17- if got != 42 {
18- t.Fatalf("directoryCookie() = %d, want descriptor entry count 42", got)
19- }
20-}
21-
22-func TestDirectoryCookieSubtractsBufferedEntries(t *testing.T) {
23- file := fakeMaintenanceDirectory{seekOffset: 42}
24- got, err := directoryCookie(file, &syscall.Dirent{}, 3)
25- if err != nil {
26- t.Fatal(err)
27- }
28- if got != 39 {
29- t.Fatalf("directoryCookie() = %d, want per-entry cursor 39", got)
30- }
31-}
D internal/adapters/snapshot/dirent_cookie_linux.go +0 -14
@@ -1,14 +0,0 @@
1-//go:build linux
2-
3-package snapshot
4-
5-import "syscall"
6-
7-const maintenanceDirentBufferSize = 8192
8-
9-// directoryCookie is d_off, the resumable getdents(2) cookie for Linux.
10-func directoryCookie(_ maintenanceDirectory, dirent *syscall.Dirent, _ int) (int64, error) {
11- return dirent.Off, nil
12-}
13-
14-func drainMaintenanceDirentBatch() bool { return false }
D internal/adapters/snapshot/dirent_cookie_linux_test.go +0 -19
@@ -1,19 +0,0 @@
1-//go:build linux
2-
3-package snapshot
4-
5-import (
6- "syscall"
7- "testing"
8-)
9-
10-func TestDirectoryCookieUsesLinuxOffset(t *testing.T) {
11- dirent := syscall.Dirent{Off: 42}
12- got, err := directoryCookie(fakeMaintenanceDirectory{}, &dirent, 0)
13- if err != nil {
14- t.Fatal(err)
15- }
16- if got != 42 {
17- t.Fatalf("directoryCookie() = %d, want 42", got)
18- }
19-}
D internal/adapters/snapshot/load_bounds_test.go +0 -52
@@ -1,52 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "fmt"
6- "os"
7- "testing"
8-)
9-
10-func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) {
11- repo := NewRepository(privateDir(t))
12- for i := range maxMaintenanceMarkedGenerations + 1 {
13- generation := uint64(i + 1)
14- if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, fmt.Appendf(nil, "state-%d", generation))); err != nil {
15- t.Fatal(err)
16- }
17- }
18- for pass := range maxMaintenanceMarkedGenerations * 3 {
19- if err := repo.Maintain(context.Background()); err != nil {
20- t.Fatal(err)
21- }
22- if repo.maintenanceSessions[legacyIncarnationID("named").String()] == nil {
23- break
24- }
25- if pass == maxMaintenanceMarkedGenerations*3-1 {
26- t.Fatal("maintenance did not complete after bounded object-shard traversal")
27- }
28- }
29- if _, err := os.Lstat(repo.legacyManifestPath(legacyIncarnationID("named").String(), 1)); err != nil {
30- t.Fatalf("old manifest collected after mark budget overflow: %v", err)
31- }
32- if state := repo.maintenanceSessions[legacyIncarnationID("named").String()]; state != nil {
33- t.Fatalf("overflow maintenance state retained = %#v, want reset", state)
34- }
35-}
36-
37-func TestMaintenanceMarkStateIsCapped(t *testing.T) {
38- references := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)}
39- if !references.canRetainManifest(maxMaintenanceReferences) {
40- t.Fatal("state rejected references at its documented ceiling")
41- }
42- if references.canRetainManifest(1) {
43- t.Fatal("state accepted references beyond its documented ceiling")
44- }
45- generations := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)}
46- for generation := uint64(1); generation <= maxMaintenanceMarkedGenerations; generation++ {
47- generations.marked[generation] = manifestMaintenance{}
48- }
49- if generations.canRetainManifest(0) {
50- t.Fatal("state accepted generations beyond its documented ceiling")
51- }
52-}
D internal/adapters/snapshot/maintenance.go +0 -129
@@ -1,129 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9-)
10-
11-const maintenanceBatch = 64
12-
13-// These caps bound metadata retained across resumable GC passes. If a session
14-// exceeds either cap, marking remains conservative and no destructive sweep or
15-// generation collection occurs in that cycle.
16-const (
17- maxMaintenanceMarkedGenerations = 128
18- maxMaintenanceReferences = 8192
19-)
20-
21-// Maintain reaps a bounded amount of stale state. Continuation handles are
22-// repository-owned, so a stable prefix cannot starve entries later in a large
23-// directory. Cancellation and errors discard those handles.
24-func (r *Repository) Maintain(ctx context.Context) (err error) {
25- r.maintenanceMu.Lock()
26- defer r.maintenanceMu.Unlock()
27- if err := ctx.Err(); err != nil {
28- r.resetMaintenance()
29- return err
30- }
31- defer func() {
32- if err != nil {
33- r.resetMaintenance()
34- }
35- }()
36- if r.maintenanceCursors == nil {
37- r.maintenanceCursors = make(map[string]*maintenanceCursor)
38- r.maintenanceSessions = make(map[string]*sessionMaintenance)
39- }
40-
41- budget := maintenanceBatch
42- if _, err := r.removeTemps(ctx, r.dir, &budget, "root-temps"); err != nil || budget == 0 {
43- return err
44- }
45- if r.maintenanceQuarantine != nil {
46- changed, done, err := r.maintainQuarantine(ctx, &budget)
47- if err != nil {
48- return err
49- }
50- if changed {
51- if err := r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)); err != nil {
52- return fmt.Errorf("sync snapshot maintenance directory: %w", safeFilesystemError(err))
53- }
54- }
55- if done {
56- r.maintenanceQuarantine = nil
57- }
58- return nil
59- }
60- // Resume an incomplete session before discovering another one. This keeps
61- // retained marks (and their session lock/epoch references) bounded even
62- // when a hostile repository contains arbitrarily many partial sessions.
63- for key := range r.maintenanceSessions {
64- lock := r.lockSession(key)
65- err = r.maintainSession(ctx, key, &budget)
66- r.unlockSession(lock)
67- return err
68- }
69- sessions := filepath.Join(r.dir, repositorySessionsDir)
70- entries, _, err := r.readMaintenanceDir(sessions, maintenanceBatch, "sessions")
71- if errors.Is(err, os.ErrNotExist) {
72- return nil
73- }
74- if err != nil {
75- return fmt.Errorf("read snapshot maintenance sessions: %w", safeFilesystemError(err))
76- }
77- for i, entry := range entries {
78- if budget == 0 {
79- r.requeueMaintenanceEntries(sessions, "sessions", entries[i:])
80- return nil
81- }
82- if err := ctx.Err(); err != nil {
83- return err
84- }
85- path := filepath.Join(sessions, entry.name)
86- if isQuarantine(entry.name) {
87- r.maintenanceQuarantine = &quarantineMaintenance{root: path, current: path}
88- changed, done, err := r.maintainQuarantine(ctx, &budget)
89- if err != nil && !errors.Is(err, os.ErrNotExist) {
90- return fmt.Errorf("remove snapshot quarantine %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
91- }
92- if changed {
93- if err := ctx.Err(); err != nil {
94- return err
95- }
96- if err := r.syncDirectory(sessions); err != nil {
97- return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
98- }
99- }
100- if done {
101- r.maintenanceQuarantine = nil
102- }
103- if i+1 < len(entries) {
104- r.requeueMaintenanceEntries(sessions, "sessions", entries[i+1:])
105- }
106- return nil
107- }
108- if !entry.isDir || !canonicalSessionKey(entry.name) {
109- continue
110- }
111- lock := r.lockSession(entry.name)
112- err = r.maintainSession(ctx, entry.name, &budget)
113- r.unlockSession(lock)
114- if err != nil {
115- return err
116- }
117- if r.maintenanceSessions[entry.name] != nil {
118- return nil
119- }
120- }
121- return nil
122-}
123-
124-// maintenanceCursor is deliberately reopenable metadata, not an open file.
125-// On Linux, d_off is the seek cookie returned by getdents(2); seeking a newly
126-// opened descriptor to it resumes the directory stream. Go's File.ReadDir
127-// cannot be used here: its 8 KiB internal getdents buffer advances the kernel
128-// offset beyond a short ReadDir result (see os/dir_unix.go), so saving SeekCur
129-// after ReadDir would skip buffered entries.
D internal/adapters/snapshot/maintenance_cursor.go +0 -307
@@ -1,307 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "os"
9- "path/filepath"
10- "strings"
11- "syscall"
12- "unsafe"
13-)
14-
15-// maxMaintenanceCursors bounds all retained directory seek cookies, including
16-// cursors for attacker-controlled object shards. Maintenance processes only
17-// one shard at a time, so normal operation stays well below this ceiling.
18-const maxMaintenanceCursors = 8
19-
20-var errMaintenanceCursorLimit = errors.New("snapshot maintenance cursor limit")
21-
22-type maintenanceCursor struct {
23- offset int64
24- pending []maintenanceDirEntry
25- done bool
26-}
27-
28-type maintenanceDirEntry struct {
29- name string
30- isDir bool
31- cookie int64
32-}
33-
34-type maintenanceDirentConfig struct {
35- drainBuffer bool
36- cookie func(maintenanceDirectory, *syscall.Dirent, int) (int64, error)
37-}
38-
39-func (r *Repository) resetMaintenance() {
40- for key := range r.maintenanceSessions {
41- r.clearSessionMaintenance(key)
42- }
43- r.maintenanceCursors = nil
44- r.maintenanceSessions = nil
45- r.maintenanceQuarantine = nil
46-}
47-
48-func maintenanceCursorID(dir, purpose string) string { return purpose + "\x00" + dir }
49-
50-// readMaintenanceDir advances one named directory cursor. Its continuation is
51-// only a Linux seek cookie and at most one call's worth of pending names. The
52-// descriptor used to read a call is always closed before this method returns.
53-// Callers must return entries they could not process with
54-// requeueMaintenanceEntries before yielding for budget exhaustion.
55-func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]maintenanceDirEntry, bool, error) {
56- id := maintenanceCursorID(dir, purpose)
57- cursor := r.maintenanceCursors[id]
58- if cursor == nil {
59- if len(r.maintenanceCursors) >= maxMaintenanceCursors {
60- return nil, false, errMaintenanceCursorLimit
61- }
62- cursor = &maintenanceCursor{}
63- r.maintenanceCursors[id] = cursor
64- }
65-
66- entries, done, err := r.readMaintenanceDirent(dir, n, cursor)
67- if err != nil {
68- delete(r.maintenanceCursors, id)
69- return nil, false, err
70- }
71- if done {
72- delete(r.maintenanceCursors, id)
73- }
74- return entries, done, nil
75-}
76-
77-// maintenanceDirectory is the small syscall surface needed to resume a
78-// bounded getdents cursor. It permits deterministic fault injection without
79-// retaining directory descriptors between maintenance passes.
80-type maintenanceDirectory interface {
81- Seek(int64, int) (int64, error)
82- ReadDirent([]byte) (int, error)
83- Close() error
84-}
85-
86-type osMaintenanceDirectory struct{ file *os.File }
87-
88-func (d osMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
89- return syscall.Seek(int(d.file.Fd()), offset, whence)
90-}
91-
92-func (d osMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
93- return syscall.ReadDirent(int(d.file.Fd()), buffer)
94-}
95-
96-func (d osMaintenanceDirectory) Close() error { return d.file.Close() }
97-
98-func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, error) {
99- if open := r.hooks.openMaintenanceDirectory; open != nil {
100- return open(dir)
101- }
102- file, err := r.openDirectory(dir)
103- if err != nil {
104- return nil, err
105- }
106- return osMaintenanceDirectory{file: file}, nil
107-}
108-
109-// maintenanceDirectoryError adds stable operation context and removes any
110-// filesystem path while retaining the underlying cause for errors.Is.
111-func maintenanceDirectoryError(operation string, err error) error {
112- return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))
113-}
114-
115-// readMaintenanceDirent saves a resumable directory cursor. Linux d_off lets
116-// a large getdents batch stop at limit. Darwin's syscall.ReadDirent instead
117-// stores an entry count in the descriptor offset, so every returned buffer
118-// must be drained before that descriptor-maintained count is saved.
119-func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
120- if len(cursor.pending) != 0 {
121- count := min(limit, len(cursor.pending))
122- entries = cursor.pending[:count]
123- cursor.pending = cursor.pending[count:]
124- if len(entries) != 0 {
125- cursor.offset = entries[len(entries)-1].cookie
126- }
127- return entries, cursor.done && len(cursor.pending) == 0, nil
128- }
129- config := maintenanceDirentConfig{drainBuffer: drainMaintenanceDirentBatch(), cookie: directoryCookie}
130- if r.hooks.maintenanceDirentConfig != nil {
131- config = *r.hooks.maintenanceDirentConfig
132- }
133- return r.readMaintenanceDirentWithDrain(dir, limit, cursor, config.drainBuffer, config.cookie)
134-}
135-
136-// readMaintenanceDirentWithDrain contains the shared traversal logic. The
137-// explicit parameters permit platform-independent verification of Darwin's
138-// buffer-draining semantics with deterministic fake directories.
139-func (r *Repository) readMaintenanceDirentWithDrain(dir string, limit int, cursor *maintenanceCursor, drainBuffer bool, cookie func(maintenanceDirectory, *syscall.Dirent, int) (int64, error)) (entries []maintenanceDirEntry, done bool, err error) {
140- file, err := r.openMaintenanceDirectory(dir)
141- if err != nil {
142- return nil, false, maintenanceDirectoryError("open maintenance directory", err)
143- }
144- defer func() {
145- if closeErr := file.Close(); closeErr != nil {
146- closeErr = maintenanceDirectoryError("close maintenance directory", closeErr)
147- if err != nil {
148- err = errors.Join(err, closeErr)
149- } else {
150- entries = nil
151- done = false
152- err = closeErr
153- }
154- }
155- }()
156- if cursor.offset != 0 {
157- if _, err := file.Seek(cursor.offset, io.SeekStart); err != nil {
158- return nil, false, maintenanceDirectoryError("seek maintenance directory", err)
159- }
160- }
161-
162- entries = make([]maintenanceDirEntry, 0, limit)
163- buffer := make([]byte, maintenanceDirentBufferSize)
164- for {
165- count, err := file.ReadDirent(buffer)
166- if err != nil {
167- return nil, false, maintenanceDirectoryError("read maintenance directory", err)
168- }
169- if count < 0 || count > len(buffer) {
170- return nil, false, syscall.EIO
171- }
172- if count == 0 {
173- return entries, true, nil
174- }
175- remainingRecords, err := countMaintenanceDirents(buffer[:count])
176- if err != nil {
177- return nil, false, err
178- }
179- for data := buffer[:count]; len(data) != 0 && (len(entries) < limit || drainBuffer); {
180- record, nameBytes, reclen, decodeErr := decodeMaintenanceDirent(data)
181- if decodeErr != nil {
182- return nil, false, decodeErr
183- }
184- data = data[reclen:]
185- remainingRecords--
186- // Advance past every raw record, including dot and disappeared entries.
187- offset, cookieErr := cookie(file, record, remainingRecords)
188- if cookieErr != nil {
189- return nil, false, maintenanceDirectoryError("tell maintenance directory", cookieErr)
190- }
191- cursor.offset = offset
192- if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
193- nameBytes = nameBytes[:end]
194- }
195- name := string(nameBytes)
196- if name == "." || name == ".." || name == "" {
197- continue
198- }
199- stat, err := r.stat(filepath.Join(dir, name))
200- if errors.Is(err, os.ErrNotExist) {
201- continue
202- }
203- if err != nil {
204- return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)
205- }
206- entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.IsDir(), cookie: offset})
207- }
208- if len(entries) >= limit {
209- if drainBuffer && len(entries) > limit {
210- // The descriptor is past the whole buffer. Retain the excess and
211- // expose only the last entry actually consumed as the logical cursor.
212- cursor.pending = append(cursor.pending, entries[limit:]...)
213- cursor.offset = entries[limit-1].cookie
214- }
215- return entries[:limit], false, nil
216- }
217- }
218-}
219-
220-func countMaintenanceDirents(data []byte) (int, error) {
221- count := 0
222- for len(data) != 0 {
223- _, _, reclen, err := decodeMaintenanceDirent(data)
224- if err != nil {
225- return 0, err
226- }
227- data = data[reclen:]
228- count++
229- }
230- return count, nil
231-}
232-
233-// decodeMaintenanceDirent copies a bounded raw record into aligned storage
234-// before reading its fields. syscall.ReadDirent writes to a byte buffer, whose
235-// start and record boundaries are not guaranteed to satisfy syscall.Dirent's
236-// alignment requirements on Darwin.
237-func decodeMaintenanceDirent(data []byte) (*syscall.Dirent, []byte, int, error) {
238- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
239- if len(data) < nameOffset {
240- return nil, nil, 0, syscall.EIO
241- }
242-
243- var record syscall.Dirent
244- header := unsafe.Slice((*byte)(unsafe.Pointer(&record)), nameOffset)
245- copy(header, data[:nameOffset])
246- reclen := int(record.Reclen)
247- if reclen < nameOffset || reclen > len(data) || reclen > int(unsafe.Sizeof(record)) {
248- return nil, nil, 0, syscall.EIO
249- }
250-
251- recordBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record)), reclen)
252- copy(recordBytes, data[:reclen])
253- name := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
254- return &record, name, reclen, nil
255-}
256-
257-func (r *Repository) requeueMaintenanceEntries(dir, purpose string, entries []maintenanceDirEntry) {
258- if len(entries) == 0 {
259- return
260- }
261- id := maintenanceCursorID(dir, purpose)
262- cursor := r.maintenanceCursors[id]
263- if cursor == nil {
264- cursor = &maintenanceCursor{done: true}
265- r.maintenanceCursors[id] = cursor
266- }
267- cursor.pending = append(entries, cursor.pending...)
268-}
269-
270-func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int, purpose string) (bool, error) {
271- if *budget == 0 {
272- return false, nil
273- }
274- entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, purpose)
275- if errors.Is(err, os.ErrNotExist) {
276- return true, nil
277- }
278- if err != nil {
279- return false, fmt.Errorf("read snapshot maintenance directory: %w", safeFilesystemError(err))
280- }
281- for i, entry := range entries {
282- if *budget == 0 {
283- r.requeueMaintenanceEntries(dir, purpose, entries[i:])
284- return false, nil
285- }
286- if err := ctx.Err(); err != nil {
287- return false, err
288- }
289- if entry.isDir || !strings.HasPrefix(entry.name, ".tmp-") {
290- continue
291- }
292- path := filepath.Join(dir, entry.name)
293- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
294- return false, fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
295- }
296- if err := ctx.Err(); err != nil {
297- return false, err
298- }
299- if err := r.syncDirectory(dir); err != nil {
300- return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
301- }
302- *budget--
303- }
304- return done, nil
305-}
306-
307-// maintenancePath avoids exposing the repository root while retaining a
M internal/adapters/snapshot/maintenance_delete.go +2 -539
@@ -1,554 +1,17 @@
11 package snapshot
22
33 import (
4- "bytes"
54 "context"
6- "encoding/base64"
7- "encoding/binary"
8- "encoding/json"
95 "errors"
106 "fmt"
11- "io"
127 "os"
138 "path/filepath"
14- "sort"
15- "strings"
169
1710 "github.com/bnema/vev/internal/domain"
18- "github.com/bnema/vev/internal/ports"
1911 )
2012
21-const (
22- deletionTombstoneVersion = uint16(1)
23- deletionTombstoneMagic = "VEVD"
24- deletionTombstonesDir = "deletions"
25-)
26-
27-var ErrMaintenanceBudgetTooSmall = errors.New("snapshot: maintenance budget too small")
28-
29-func encodeDeletionTombstone(tombstone domain.DeletionTombstone) ([]byte, error) {
30- if err := tombstone.Validate(); err != nil {
31- return nil, err
32- }
33- if len(tombstone.Name) > 200 {
34- return nil, fmt.Errorf("snapshot: deletion tombstone name too long")
35- }
36- encoded := make([]byte, 4+2+2+len(tombstone.Name)+len(tombstone.IncarnationID))
37- copy(encoded, deletionTombstoneMagic)
38- binary.BigEndian.PutUint16(encoded[4:6], deletionTombstoneVersion)
39- binary.BigEndian.PutUint16(encoded[6:8], uint16(len(tombstone.Name)))
40- copy(encoded[8:], tombstone.Name)
41- copy(encoded[8+len(tombstone.Name):], tombstone.IncarnationID[:])
42- return encoded, nil
43-}
44-
45-func decodeDeletionTombstone(encoded []byte) (domain.DeletionTombstone, error) {
46- if len(encoded) < 8 {
47- return domain.DeletionTombstone{}, fmt.Errorf("snapshot: truncated deletion tombstone")
48- }
49- if string(encoded[:4]) != deletionTombstoneMagic || binary.BigEndian.Uint16(encoded[4:6]) != deletionTombstoneVersion {
50- return domain.DeletionTombstone{}, fmt.Errorf("snapshot: invalid deletion tombstone header")
51- }
52- nameLength := int(binary.BigEndian.Uint16(encoded[6:8]))
53- want := 8 + nameLength + len(domain.IncarnationID{})
54- if len(encoded) != want {
55- return domain.DeletionTombstone{}, fmt.Errorf("snapshot: malformed deletion tombstone length")
56- }
57- tombstone := domain.DeletionTombstone{Name: string(encoded[8 : 8+nameLength])}
58- copy(tombstone.IncarnationID[:], encoded[8+nameLength:])
59- if err := tombstone.Validate(); err != nil {
60- return domain.DeletionTombstone{}, err
61- }
62- return tombstone, nil
63-}
64-
65-func (r *Repository) deletionTombstonePath(id domain.IncarnationID) string {
66- return filepath.Join(r.dir, deletionTombstonesDir, id.String()+".tombstone")
67-}
68-
69-func (r *Repository) WriteDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
70- if err := ctx.Err(); err != nil {
71- return err
72- }
73- encoded, err := encodeDeletionTombstone(tombstone)
74- if err != nil {
75- return err
76- }
77- dir := filepath.Join(r.dir, deletionTombstonesDir)
78- if err := r.ensurePrivateDirectory(dir); err != nil {
79- return err
80- }
81- path := r.deletionTombstonePath(tombstone.IncarnationID)
82- return r.writeImmutable(path, encoded, func(existing []byte) error {
83- decoded, err := decodeDeletionTombstone(existing)
84- if err != nil || decoded != tombstone {
85- return fmt.Errorf("snapshot: deletion tombstone identity mismatch")
86- }
87- return nil
88- })
89-}
90-
91-const (
92- deletionListingCursorPrefix = "dt1:"
93- maxDeletionListingCursor = 64 << 10
94-)
95-
96-type deletionListingState struct {
97- After string `json:"after,omitempty"`
98- Offset int64 `json:"offset,omitempty"`
99- Pending []maintenanceDirEntry `json:"pending,omitempty"`
100- PendingAt int `json:"pending_at,omitempty"`
101- ScanDone bool `json:"scan_done,omitempty"`
102- Candidates []string `json:"candidates,omitempty"`
103- More bool `json:"more,omitempty"`
104- BatchLimit int `json:"batch_limit,omitempty"`
105-}
106-
107-// MarshalJSON keeps maintenanceDirEntry private while making a continuation
108-// self-contained. Pending entries are bounded by one directory read buffer.
109-func (entry maintenanceDirEntry) MarshalJSON() ([]byte, error) {
110- return json.Marshal(struct {
111- Name string `json:"name"`
112- IsDir bool `json:"is_dir,omitempty"`
113- }{Name: entry.name, IsDir: entry.isDir})
114-}
115-
116-func (entry *maintenanceDirEntry) UnmarshalJSON(encoded []byte) error {
117- var wire struct {
118- Name string `json:"name"`
119- IsDir bool `json:"is_dir,omitempty"`
120- }
121- decoder := json.NewDecoder(bytes.NewReader(encoded))
122- decoder.DisallowUnknownFields()
123- if err := decoder.Decode(&wire); err != nil {
124- return err
125- }
126- entry.name, entry.isDir = wire.Name, wire.IsDir
127- return nil
128-}
129-
130-func canonicalDeletionTombstoneFilename(name string) bool {
131- if len(name) != 32+len(".tombstone") || !strings.HasSuffix(name, ".tombstone") {
132- return false
133- }
134- var id domain.IncarnationID
135- key := strings.TrimSuffix(name, ".tombstone")
136- return id.UnmarshalText([]byte(key)) == nil && id.String()+".tombstone" == name
137-}
138-
139-func decodeDeletionListingCursor(cursor ports.DeletionTombstoneCursor) (deletionListingState, error) {
140- if cursor.After == "" {
141- return deletionListingState{}, nil
142- }
143- if len(cursor.After) > maxDeletionListingCursor || !strings.HasPrefix(cursor.After, deletionListingCursorPrefix) {
144- return deletionListingState{}, fmt.Errorf("snapshot: malformed deletion tombstone continuation")
145- }
146- encoded, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cursor.After, deletionListingCursorPrefix))
147- if err != nil {
148- return deletionListingState{}, fmt.Errorf("snapshot: malformed deletion tombstone continuation: %w", err)
149- }
150- var state deletionListingState
151- decoder := json.NewDecoder(bytes.NewReader(encoded))
152- decoder.DisallowUnknownFields()
153- if err := decoder.Decode(&state); err != nil {
154- return deletionListingState{}, fmt.Errorf("snapshot: malformed deletion tombstone continuation: %w", err)
155- }
156- if decoder.Decode(&struct{}{}) != io.EOF {
157- return deletionListingState{}, fmt.Errorf("snapshot: malformed deletion tombstone continuation")
158- }
159- if err := validateDeletionListingState(state); err != nil {
160- return deletionListingState{}, err
161- }
162- return state, nil
163-}
164-
165-func validateDeletionListingState(state deletionListingState) error {
166- if state.After != "" && !canonicalDeletionTombstoneFilename(state.After) {
167- return fmt.Errorf("snapshot: non-canonical deletion tombstone continuation key")
168- }
169- if state.Offset < 0 || state.PendingAt < 0 || state.PendingAt > len(state.Pending) || state.BatchLimit < 0 || state.BatchLimit > maintenanceBatch || len(state.Candidates) > state.BatchLimit {
170- return fmt.Errorf("snapshot: malformed deletion tombstone continuation")
171- }
172- if state.After == "" && state.Offset == 0 && len(state.Pending) == 0 && !state.ScanDone && len(state.Candidates) == 0 {
173- return fmt.Errorf("snapshot: non-advancing deletion tombstone continuation")
174- }
175- for i, candidate := range state.Candidates {
176- if !canonicalDeletionTombstoneFilename(candidate) || candidate <= state.After || (i > 0 && candidate <= state.Candidates[i-1]) {
177- return fmt.Errorf("snapshot: non-advancing deletion tombstone continuation")
178- }
179- }
180- if len(state.Pending) > maintenanceDirentBufferSize {
181- return fmt.Errorf("snapshot: malformed deletion tombstone continuation")
182- }
183- for _, entry := range state.Pending {
184- if entry.isDir || !canonicalDeletionTombstoneFilename(entry.name) {
185- return fmt.Errorf("snapshot: non-canonical deletion tombstone continuation entry")
186- }
187- }
188- return nil
189-}
190-
191-func encodeDeletionListingCursor(state deletionListingState) (ports.DeletionTombstoneCursor, error) {
192- if err := validateDeletionListingState(state); err != nil {
193- return ports.DeletionTombstoneCursor{}, err
194- }
195- encoded, err := json.Marshal(state)
196- if err != nil {
197- return ports.DeletionTombstoneCursor{}, err
198- }
199- return ports.DeletionTombstoneCursor{After: deletionListingCursorPrefix + base64.RawURLEncoding.EncodeToString(encoded)}, nil
200-}
201-
202-func (r *Repository) readDeletionListingEntries(dir string, limit int, state *deletionListingState) ([]maintenanceDirEntry, bool, error) {
203- if state.PendingAt < len(state.Pending) {
204- end := min(state.PendingAt+limit, len(state.Pending))
205- entries := state.Pending[state.PendingAt:end]
206- state.PendingAt = end
207- done := state.ScanDone && end == len(state.Pending)
208- return entries, done, nil
209- }
210- cursor := &maintenanceCursor{offset: state.Offset}
211- entries, done, err := r.readMaintenanceDirent(dir, limit, cursor)
212- state.Offset = cursor.offset
213- state.Pending = cursor.pending
214- state.PendingAt = 0
215- state.ScanDone = done || cursor.done
216- return entries, done && len(cursor.pending) == 0, err
217-}
218-
219-func (r *Repository) ListDeletionTombstones(ctx context.Context, cursor ports.DeletionTombstoneCursor, budget ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {
220- if budget.Entries == 0 || budget.Bytes == 0 {
221- return ports.DeletionTombstonePage{}, ErrMaintenanceBudgetTooSmall
222- }
223- if err := ctx.Err(); err != nil {
224- return ports.DeletionTombstonePage{}, err
225- }
226- state, err := decodeDeletionListingCursor(cursor)
227- if err != nil {
228- return ports.DeletionTombstonePage{}, err
229- }
230- dir := filepath.Join(r.dir, deletionTombstonesDir)
231- r.maintenanceMu.Lock()
232- defer r.maintenanceMu.Unlock()
233-
234- batchLimit := int(min(budget.Entries, uint64(maintenanceBatch)))
235- if state.BatchLimit == 0 {
236- state.BatchLimit = batchLimit
237- } else if state.BatchLimit > batchLimit {
238- state.BatchLimit = batchLimit
239- if len(state.Candidates) > state.BatchLimit {
240- state.Candidates = state.Candidates[:state.BatchLimit]
241- state.More = true
242- }
243- }
244- remainingEntries := budget.Entries
245- done := state.ScanDone
246- for !done && remainingEntries > 0 {
247- limit := remainingEntries
248- if maxInt := uint64(^uint(0) >> 1); limit > maxInt {
249- limit = maxInt
250- }
251- entries, batchDone, err := r.readDeletionListingEntries(dir, int(limit), &state)
252- if errors.Is(err, os.ErrNotExist) {
253- return ports.DeletionTombstonePage{Done: true}, nil
254- }
255- if err != nil {
256- return ports.DeletionTombstonePage{}, err
257- }
258- remainingEntries -= uint64(len(entries))
259- for _, entry := range entries {
260- if err := ctx.Err(); err != nil {
261- return ports.DeletionTombstonePage{}, err
262- }
263- if entry.isDir || !canonicalDeletionTombstoneFilename(entry.name) {
264- return ports.DeletionTombstonePage{}, fmt.Errorf("snapshot: invalid deletion tombstone entry")
265- }
266- if entry.name <= state.After {
267- continue
268- }
269- at := sort.SearchStrings(state.Candidates, entry.name)
270- if at < len(state.Candidates) && state.Candidates[at] == entry.name {
271- continue
272- }
273- state.Candidates = append(state.Candidates, "")
274- copy(state.Candidates[at+1:], state.Candidates[at:])
275- state.Candidates[at] = entry.name
276- if len(state.Candidates) > state.BatchLimit {
277- state.Candidates = state.Candidates[:state.BatchLimit]
278- state.More = true
279- }
280- }
281- done = batchDone
282- if len(entries) == 0 && !batchDone {
283- break
284- }
285- }
286- if !done {
287- next, err := encodeDeletionListingCursor(state)
288- if err != nil {
289- return ports.DeletionTombstonePage{}, err
290- }
291- if next.After == cursor.After {
292- return ports.DeletionTombstonePage{}, fmt.Errorf("snapshot: deletion tombstone continuation did not advance")
293- }
294- return ports.DeletionTombstonePage{Next: next}, nil
295- }
296- if len(state.Candidates) == 0 {
297- return ports.DeletionTombstonePage{Done: true}, nil
298- }
299-
300- remainingBytes := budget.Bytes
301- tombstones := make([]domain.DeletionTombstone, 0, len(state.Candidates))
302- last := ""
303- for _, candidate := range state.Candidates {
304- path := filepath.Join(dir, candidate)
305- info, err := r.stat(path)
306- if err != nil {
307- return ports.DeletionTombstonePage{}, err
308- }
309- charge := uint64(len(candidate))
310- if info.Size() < 0 || uint64(info.Size()) > remainingBytes || charge > remainingBytes-uint64(info.Size()) {
311- if len(tombstones) == 0 {
312- return ports.DeletionTombstonePage{}, ErrMaintenanceBudgetTooSmall
313- }
314- state.More = true
315- break
316- }
317- remainingBytes -= charge + uint64(info.Size())
318- if hook := r.hooks.beforeDeletionTombstoneRead; hook != nil {
319- hook(path)
320- }
321- data, err := r.readBounded(path)
322- if err != nil {
323- return ports.DeletionTombstonePage{}, err
324- }
325- decoded, err := decodeDeletionTombstone(data)
326- if err != nil {
327- return ports.DeletionTombstonePage{}, err
328- }
329- if candidate != decoded.IncarnationID.String()+".tombstone" {
330- return ports.DeletionTombstonePage{}, fmt.Errorf("snapshot: non-canonical deletion tombstone filename")
331- }
332- tombstones = append(tombstones, decoded)
333- last = candidate
334- }
335- next, err := encodeDeletionListingCursor(deletionListingState{After: last})
336- if err != nil {
337- return ports.DeletionTombstonePage{}, err
338- }
339- return ports.DeletionTombstonePage{Tombstones: tombstones, Next: next, Done: !state.More && len(tombstones) == len(state.Candidates)}, nil
340-}
341-
342-func (r *Repository) DeleteDeletionTombstone(ctx context.Context, id domain.IncarnationID) error {
343- if err := ctx.Err(); err != nil {
344- return err
345- }
346- if _, err := incarnationKey(id); err != nil {
347- return err
348- }
349- path := r.deletionTombstonePath(id)
350- dir := filepath.Dir(path)
351- if _, err := os.Lstat(dir); errors.Is(err, os.ErrNotExist) {
352- return nil
353- } else if err != nil {
354- return err
355- }
356- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
357- return err
358- }
359- return r.syncDirectory(dir)
360-}
361-
362-// Delete makes a session unavailable by durably moving it out of the canonical
363-// namespace. Maintain reaps the private quarantine later; Delete never restores
364-// a quarantined directory.
365-func (r *Repository) Delete(ctx context.Context, name string) error {
366- if err := ctx.Err(); err != nil {
367- return err
368- }
369- key, err := r.legacyRepositoryKey(name)
370- if err != nil {
371- return err
372- }
373- lock := r.lockSession(key)
374- defer r.unlockSession(lock)
375- if err := ctx.Err(); err != nil {
376- return err
377- }
378-
379- canonical := r.legacySessionPath(key)
380- sessions := filepath.Dir(canonical)
381- // A prior rename may have succeeded while its parent sync failed. Complete
382- // that durability boundary before considering a canonical directory: this
383- // also leaves a newly recreated session untouched.
384- pending, err := r.pendingQuarantine(sessions, key)
385- if err != nil {
386- return fmt.Errorf("stat deleting snapshot session %q: %w", name, safeFilesystemError(err))
387- }
388- if pending {
389- if err := ctx.Err(); err != nil {
390- return err
391- }
392- if err := r.syncDirectory(sessions); err != nil {
393- return fmt.Errorf("sync deleted snapshot session directory %q: %w", name, safeFilesystemError(err))
394- }
395- return nil
396- }
397- if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) {
398- return nil
399- } else if err != nil {
400- return fmt.Errorf("stat snapshot session %q: %w", name, safeFilesystemError(err))
401- } else if err := dir.Close(); err != nil {
402- return fmt.Errorf("close snapshot session %q: %w", name, safeFilesystemError(err))
403- }
404- quarantine := filepath.Join(sessions, deletingSessionName(key))
405- if err := ctx.Err(); err != nil {
406- return err
407- }
408- // A renamed session can be replaced by a later publication. Invalidate any
409- // mark made for the old namespace before that replacement becomes possible.
410- r.invalidateStorageEpoch(key)
411- if err := r.rename(canonical, quarantine); err != nil {
412- return fmt.Errorf("quarantine snapshot session %q: %w", name, safeFilesystemError(err))
413- }
414- if err := ctx.Err(); err != nil {
415- return err
416- }
417- if err := r.syncDirectory(sessions); err != nil {
418- return fmt.Errorf("sync deleted snapshot session directory %q: %w", name, safeFilesystemError(err))
419- }
420- return nil
421-}
422-
423-// deletingSessionName is a deterministic pending-delete record. Its presence
424-// is checked directly rather than scanning an attacker-controlled sessions
425-// directory, and it prevents a retry from deleting a session republished after
426-// the initial rename reached disk but its directory sync failed.
427-func deletingSessionName(key string) string { return ".deleting-" + key }
428-
429-func (r *Repository) pendingQuarantine(dir, key string) (bool, error) {
430- path := filepath.Join(dir, deletingSessionName(key))
431- if hook := r.hooks.beforePendingQuarantineCheck; hook != nil {
432- hook(path)
433- }
434- file, err := r.openDirectory(path)
435- if errors.Is(err, os.ErrNotExist) {
436- return false, nil
437- }
438- if err != nil {
439- return false, err
440- }
441- return true, file.Close()
442-}
443-
444-// Maintain reaps a bounded amount of stale state. Continuation handles are
445-// repository-owned, so a stable prefix cannot starve entries later in a large
446-// directory. Cancellation and errors discard those handles: a restarted
447-// repository (and the next fresh cycle) always begins at a safe boundary.
448-func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") }
449-
450-// quarantineMaintenance retains one root-to-current path, never an unbounded
451-// stack of directory handles or names. Parent paths are recovered after a
452-// removal, while descent resumes from current on the next budgeted step.
453-type quarantineMaintenance struct {
454- root string
455- current string
456-}
457-
458-// consumeQuarantineWork charges every filesystem operation that can be driven
459-// by quarantine contents. This makes deep and wide trees subject to exactly
460-// the same maintenanceBatch ceiling as removal work.
461-func (r *Repository) consumeQuarantineWork(budget *int, operation string) bool {
462- if *budget == 0 {
463- return false
464- }
465- *budget--
466- if hook := r.hooks.beforeMaintenanceWork; hook != nil {
467- hook(operation)
468- }
469- return true
470-}
471-
472-// maintainQuarantine makes one bounded depth-first traversal step at a time.
473-// It deliberately reopens a directory to select its first child: every
474-// successful child removal changes that prefix, so no directory cursor is
475-// retained at each hostile nesting level.
476-func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (changed, done bool, err error) {
477- state := r.maintenanceQuarantine
478- if state == nil {
479- return false, true, nil
480- }
481- for *budget > 0 {
482- if err := ctx.Err(); err != nil {
483- return changed, false, err
484- }
485- if !r.consumeQuarantineWork(budget, "stat") {
486- return changed, false, nil
487- }
488- stat, err := r.stat(state.current)
489- if errors.Is(err, os.ErrNotExist) {
490- if state.current == state.root {
491- return changed, true, nil
492- }
493- state.current = filepath.Dir(state.current)
494- continue
495- }
496- if err != nil {
497- return changed, false, err
498- }
499- if !stat.IsDir() {
500- if !r.consumeQuarantineWork(budget, "remove") {
501- return changed, false, nil
502- }
503- if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) {
504- return changed, false, err
505- }
506- changed = true
507- state.current = filepath.Dir(state.current)
508- continue
509- }
510-
511- if !r.consumeQuarantineWork(budget, "open") {
512- return changed, false, nil
513- }
514- dir, err := r.openDirectory(state.current)
515- if err != nil {
516- return changed, false, err
517- }
518- if !r.consumeQuarantineWork(budget, "read") {
519- _ = dir.Close()
520- return changed, false, nil
521- }
522- entries, readErr := dir.ReadDir(1)
523- closeErr := dir.Close()
524- if readErr != nil && !errors.Is(readErr, io.EOF) {
525- return changed, false, readErr
526- }
527- if closeErr != nil {
528- return changed, false, closeErr
529- }
530- if len(entries) == 0 {
531- if !r.consumeQuarantineWork(budget, "remove") {
532- return changed, false, nil
533- }
534- if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) {
535- return changed, false, err
536- }
537- changed = true
538- if state.current == state.root {
539- return changed, true, nil
540- }
541- state.current = filepath.Dir(state.current)
542- continue
543- }
544- if !r.consumeQuarantineWork(budget, "descend") {
545- return changed, false, nil
546- }
547- state.current = filepath.Join(state.current, entries[0].Name())
548- }
549- return changed, false, nil
550-}
551-
13+// DeleteIncarnation removes one incarnation-keyed snapshot namespace. It is
14+// retry-idempotent: an incarnation already absent is a successful deletion.
55215 func (r *Repository) DeleteIncarnation(ctx context.Context, id domain.IncarnationID) error {
55316 if err := ctx.Err(); err != nil {
55417 return err
D internal/adapters/snapshot/maintenance_error.go +0 -38
@@ -1,38 +0,0 @@
1-package snapshot
2-
3-import (
4- "errors"
5- "os"
6- "path/filepath"
7- "strings"
8-)
9-
10-func maintenancePath(root, path string) string {
11- rel, err := filepath.Rel(root, path)
12- if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
13- return filepath.Base(path)
14- }
15- return rel
16-}
17-
18-type safePathError struct {
19- op string
20- cause error
21-}
22-
23-func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() }
24-func (e safePathError) Unwrap() error { return e.cause }
25-
26-// safeFilesystemError preserves error matching without including a filesystem
27-// path from terminal-facing error text.
28-func safeFilesystemError(err error) error {
29- var linkError *os.LinkError
30- if errors.As(err, &linkError) {
31- return safePathError{op: linkError.Op, cause: linkError.Err}
32- }
33- var pathError *os.PathError
34- if errors.As(err, &pathError) {
35- return safePathError{op: pathError.Op, cause: pathError.Err}
36- }
37- return err
38-}
M internal/adapters/snapshot/maintenance_gc.go +148 -728
@@ -7,817 +7,237 @@ import (
77 "fmt"
88 "os"
99 "path/filepath"
10- "sort"
11- "strings"
12- "sync/atomic"
1310
1411 "github.com/bnema/vev/internal/domain"
1512 "github.com/bnema/vev/internal/ports"
1613 codec "github.com/bnema/vev/internal/usecase/snapshot"
1714 )
1815
19-type retentionMaintenance struct {
20- invalidated atomic.Bool
21- token string
22- cursors map[string]*maintenanceCursor
23- references map[ports.SnapshotDigest]struct{}
24- validateQueue []ports.CheckpointRef
25- validateIndex int
26- validated bool
27- manifestsDone bool
28- objectShard string
29- objectRootDone bool
30-}
31-
32-type manifestMaintenance struct {
33- refs map[ports.SnapshotDigest]codec.ObjectRef
34- complete bool
35-}
36-
37-type retentionEntryKind uint8
38-
39-const (
40- retentionManifest retentionEntryKind = iota + 1
41- retentionObject
42-)
43-
44-type retentionEntryPolicy struct {
45- kind retentionEntryKind
46- generations map[uint64]ports.SnapshotDigest
47- objects map[ports.SnapshotDigest]struct{}
48-}
49-
50-func (p retentionEntryPolicy) delete(entry maintenanceDirEntry) bool {
51- if entry.isDir {
52- return false
53- }
54- switch p.kind {
55- case retentionManifest:
56- generation, canonical := parseGenerationFilename(entry.name)
57- _, retained := p.generations[generation]
58- return canonical && !retained
59- case retentionObject:
60- digest, canonical := parseObjectDigest(entry.name)
61- _, retained := p.objects[digest]
62- return canonical && !retained
63- default:
64- return false
65- }
66-}
67-
68-type sessionMaintenance struct {
69- lock *sessionMutex
70- token string
71- epoch uint64
72- marked map[uint64]manifestMaintenance
73- referenceCount int
74- uncertain bool
75- conservative bool
76- markDone bool
77- manifestQueue []uint64
78- objectTempShard string
79- objectTempsDone bool
80- sweepShard string
81- sweepRootDone bool
82-}
83-
84-// canRetainManifest reserves bounded state before retaining a manifest's
85-// reference map. The caller must leave the map unretained when it returns
86-// false, and make the cycle conservative instead.
87-func (s *sessionMaintenance) canRetainManifest(referenceCount int) bool {
88- if referenceCount < 0 || len(s.marked) >= maxMaintenanceMarkedGenerations {
89- return false
90- }
91- if referenceCount > maxMaintenanceReferences-s.referenceCount {
92- return false
93- }
94- s.referenceCount += referenceCount
95- return true
96-}
97-
98-func (r *Repository) maintainSession(ctx context.Context, key string, budget *int) error {
16+// CollectGarbage applies the complete snapshot retention policy. The keep map
17+// must come from a catalogue that loaded and validated successfully: only then
18+// is an incarnation absent from keep known to be an orphan.
19+func (r *Repository) CollectGarbage(ctx context.Context, keep map[domain.IncarnationID]domain.CheckpointRef) error {
9920 if err := ctx.Err(); err != nil {
10021 return err
10122 }
102- state, err := r.sessionMaintenanceState(key)
103- if err != nil {
104- return err
105- }
106- if _, err := r.removeTemps(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 {
107- return err
108- }
109- if !state.objectTempsDone {
110- done, err := r.removeObjectTemps(ctx, key, state, budget)
111- if err != nil || !done || *budget == 0 {
112- return err
113- }
114- }
115- if !state.markDone {
116- if err := r.markSession(ctx, key, state); err != nil {
117- return err
118- }
119- if !state.markDone {
120- return nil
121- }
122- }
123- if state.epoch != r.storageEpoch(key) {
124- // A publication may have left an unpublished manifest after this mark.
125- // Discard all mark and sweep cursors; the next pass starts from it.
126- r.clearSessionMaintenance(key)
127- return nil
128- }
129- if state.uncertain {
130- // An unreadable, invalid, or unretained manifest may reference any
131- // existing blob. Do not collect manifests or sweep in this cycle; retry
132- // marking from a fresh directory pass.
133- r.clearSessionMaintenance(key)
134- return nil
135- }
136- if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 {
137- return err
138- }
139- if err := r.sweepSession(ctx, key, state, budget); err != nil {
140- return err
141- }
142- return nil
143-}
144-
145-func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, error) {
146- token, conservative, err := r.maintenanceToken(key)
147- if err != nil {
148- return nil, err
149- }
150- state := r.maintenanceSessions[key]
151- if state == nil || state.token != token || state.epoch != r.storageEpoch(key) {
152- r.clearSessionMaintenance(key)
153- state = &sessionMaintenance{
154- lock: r.retainSessionState(key),
155- token: token,
156- epoch: r.storageEpoch(key),
157- conservative: conservative,
158- marked: make(map[uint64]manifestMaintenance),
159- }
160- r.maintenanceSessions[key] = state
161- }
162- return state, nil
163-}
164-
165-// maintenanceToken is the publication boundary. A missing or corrupt HEAD is
166-// itself stable maintenance state: we retain every classified reference until a
167-// valid publication changes that state, rather than restarting the mark pass.
168-func (r *Repository) maintenanceToken(key string) (string, bool, error) {
169- data, exists, err := r.readOptionalBounded(r.legacyHeadPath(key))
170- if err != nil {
171- return "", false, err
172- }
173- if !exists {
174- return "missing", true, nil
175- }
176- sum := sha256.Sum256(data)
177- if _, _, err := r.readLegacyHead(key); err != nil {
178- return fmt.Sprintf("invalid:%x", sum), true, nil
179- }
180- return fmt.Sprintf("valid:%x", sum), false, nil
181-}
182-
183-func (r *Repository) clearSessionMaintenance(key string) {
184- if state := r.maintenanceSessions[key]; state != nil && state.lock != nil {
185- r.releaseSessionReference(state.lock)
186- }
187- delete(r.maintenanceSessions, key)
188- prefix := "\x00" + filepath.Clean(r.legacySessionPath(key))
189- for id := range r.maintenanceCursors {
190- if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") {
191- delete(r.maintenanceCursors, id)
192- }
193- }
194-}
195-
196-func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error {
197- dir := filepath.Join(r.legacySessionPath(key), repositoryGenerations)
198- entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key)
23+ sessions := filepath.Join(r.dir, repositorySessionsDir)
24+ entries, err := r.readGarbageCollectionDirectory(sessions)
19925 if errors.Is(err, os.ErrNotExist) {
200- state.markDone = true
20126 return nil
20227 }
20328 if err != nil {
204- return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err))
29+ return fmt.Errorf("read snapshot incarnations: %w", safeFilesystemError(err))
20530 }
31+
32+ var collected error
20633 for _, entry := range entries {
20734 if err := ctx.Err(); err != nil {
208- return err
209- }
210- if entry.isDir {
211- continue
35+ return errors.Join(collected, err)
21236 }
213- generation, ok := parseGenerationFilename(entry.name)
214- if !ok {
37+ if !entry.IsDir() {
21538 continue
21639 }
217- data, err := r.readBounded(r.legacyManifestPath(key, generation))
218- if err != nil {
219- state.uncertain = true
220- continue
221- }
222- manifest, err := codec.UnmarshalManifest(data)
223- if err != nil || manifest.Generation != generation || manifest.IncarnationID.String() != key {
224- state.uncertain = true
40+ var id domain.IncarnationID
41+ if err := id.UnmarshalText([]byte(entry.Name())); err != nil {
22542 continue
22643 }
227- refs, validRefs := maintenanceManifestRefs(manifest)
228- if !validRefs || !withinGenerationBudget(len(data), refs) || !state.canRetainManifest(len(refs)) {
229- state.uncertain = true
230- continue
231- }
232- // Valid but incomplete generations are marked too. This protects blobs
233- // written before a failed or in-flight publication reaches HEAD.
234- _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation)
235- state.marked[generation] = manifestMaintenance{refs: refs, complete: loadErr == nil}
236- }
237- if done {
238- state.markDone = true
239- if !state.conservative {
240- state.manifestQueue = retainedManifestQueue(state.marked)
241- }
242- }
243- return nil
244-}
245-
246-// maintenanceManifestRefs applies the GC reference ceiling while building its
247-// map, rather than first materializing a hostile manifest's entire reference
248-// set. Conflicting references are invalid just as they are for publication.
249-func maintenanceManifestRefs(manifest codec.Manifest) (map[ports.SnapshotDigest]codec.ObjectRef, bool) {
250- refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
251- add := func(ref codec.ObjectRef) bool {
252- if old, ok := refs[ref.Digest]; ok {
253- return old == ref
254- }
255- if len(refs) == maxMaintenanceReferences {
256- return false
257- }
258- refs[ref.Digest] = ref
259- return true
260- }
261- for _, tab := range manifest.Tabs {
262- for _, pane := range tab.Panes {
263- for _, ref := range pane.Sealed {
264- if !add(ref) {
265- return nil, false
266- }
267- }
268- if !add(pane.Tail) || !add(pane.Visible) {
269- return nil, false
44+ committed, known := keep[id]
45+ if !known {
46+ path := r.sessionPath(id)
47+ if err := os.RemoveAll(path); err != nil {
48+ collected = errors.Join(collected, fmt.Errorf("remove orphan snapshot incarnation %s: %w", id.String(), safeFilesystemError(err)))
49+ continue
27050 }
51+ r.log.Info("snapshot_garbage_collected", "incarnation", id.String(), "action", "remove-incarnation")
52+ continue
27153 }
272- }
273- return refs, true
274-}
275-
276-// completeGenerationsDescending returns only complete generations in canonical
277-// newest-first order for both manifest and object retention.
278-func completeGenerationsDescending(marked map[uint64]manifestMaintenance) []uint64 {
279- complete := make([]uint64, 0, len(marked))
280- for generation, item := range marked {
281- if item.complete {
282- complete = append(complete, generation)
54+ if err := r.pruneGenerations(ctx, id, committed); err != nil {
55+ collected = errors.Join(collected, err)
28356 }
28457 }
285- sort.Slice(complete, func(i, j int) bool { return complete[i] > complete[j] })
286- return complete
287-}
288-
289-func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 {
290- complete := completeGenerationsDescending(marked)
291- if len(complete) <= 2 {
292- return nil
58+ if err := r.syncDirectory(sessions); err != nil {
59+ collected = errors.Join(collected, fmt.Errorf("sync snapshot sessions directory: %w", safeFilesystemError(err)))
29360 }
294- return complete[2:]
61+ return collected
29562 }
29663
297-func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
298- for len(state.manifestQueue) != 0 && *budget > 0 {
299- if err := ctx.Err(); err != nil {
300- return err
301- }
302- generation := state.manifestQueue[0]
303- path := r.legacyManifestPath(key, generation)
304- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
305- return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
306- }
307- if err := ctx.Err(); err != nil {
308- return err
309- }
310- if err := r.syncDirectory(filepath.Dir(path)); err != nil {
311- return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
312- }
313- state.manifestQueue = state.manifestQueue[1:]
314- *budget--
64+// pruneGenerations keeps the committed generation and its immediate
65+// predecessor. Everything else, including a forward orphan newer than the
66+// catalogue commit, is removed before unreferenced objects are swept.
67+func (r *Repository) pruneGenerations(ctx context.Context, id domain.IncarnationID, committed domain.CheckpointRef) error {
68+ key, err := incarnationKey(id)
69+ if err != nil {
70+ return err
31571 }
316- return nil
317-}
72+ lock := r.lockSession(key)
73+ defer r.unlockSession(lock)
31874
319-// removeObjectTemps performs at most one shard traversal or one root-entry
320-// discovery per Maintain call. Empty attacker-controlled shards consume no
321-// removal budget, so this fixed step ceiling prevents an unbounded scan of
322-// them while retaining only the root cursor and one active shard cursor.
323-func (r *Repository) removeObjectTemps(ctx context.Context, key string, state *sessionMaintenance, budget *int) (bool, error) {
324- root := filepath.Join(r.legacySessionPath(key), repositoryObjectsDir)
325- if *budget == 0 {
326- return false, nil
327- }
328- if state.objectTempShard != "" {
329- done, err := r.removeTemps(ctx, filepath.Join(root, state.objectTempShard), budget, "object-temps:"+key+":"+state.objectTempShard)
330- if err != nil || !done {
331- return false, err
332- }
333- state.objectTempShard = ""
334- return state.objectTempsDone, nil
335- }
336- if state.objectTempsDone {
337- return true, nil
338- }
339- entries, done, err := r.readMaintenanceDir(root, 1, "object-temps-shards:"+key)
75+ generationsDir := filepath.Join(r.sessionPath(id), repositoryGenerations)
76+ entries, err := r.readGarbageCollectionDirectory(generationsDir)
34077 if errors.Is(err, os.ErrNotExist) {
341- state.objectTempsDone = true
342- return true, nil
78+ return nil
34379 }
34480 if err != nil {
345- return false, fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err))
346- }
347- state.objectTempsDone = done
348- if len(entries) != 0 && entries[0].isDir {
349- state.objectTempShard = entries[0].name
81+ return fmt.Errorf("read snapshot generations for %s: %w", id.String(), safeFilesystemError(err))
35082 }
351- return state.objectTempsDone && state.objectTempShard == "", nil
352-}
35383
354-func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
355- // Every bounded sweep batch is tied to the mark's storage epoch. Do not use
356- // stale references after any publication or replacement.
357- if state.epoch != r.storageEpoch(key) {
358- r.clearSessionMaintenance(key)
359- return nil
84+ kept := make(map[uint64]struct{}, 2)
85+ if committed.Generation > 0 {
86+ kept[committed.Generation] = struct{}{}
36087 }
361- referenced := retainedReferences(state.marked, state.conservative)
362- root := filepath.Join(r.legacySessionPath(key), repositoryObjectsDir)
363- if state.sweepShard != "" && *budget > 0 {
364- shard := state.sweepShard
365- done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard)
366- if err != nil {
367- return err
368- }
369- if !done {
370- return nil
371- }
372- state.sweepShard = ""
373- // Release this shard's cursor before discovering another shard.
374- return nil
375- }
376- if !state.sweepRootDone {
377- entries, done, err := r.readMaintenanceDir(root, 1, "sweep-shards:"+key)
378- if errors.Is(err, os.ErrNotExist) {
379- state.sweepRootDone = true
380- } else if err != nil {
381- return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err))
382- } else {
383- state.sweepRootDone = done
384- if len(entries) != 0 && entries[0].isDir {
385- state.sweepShard = entries[0].name
386- }
387- }
88+ if committed.Generation > 1 {
89+ kept[committed.Generation-1] = struct{}{}
38890 }
389- if state.sweepRootDone && state.sweepShard == "" {
390- r.clearSessionMaintenance(key)
391- }
392- return nil
393-}
394-
395-// keepSet is the complete catalogue-owned retention index. Directory order and
396-// HEAD never grant retention authority.
397-func keepSet(plan ports.RetentionPlan) map[uint64]ports.SnapshotDigest {
398- keep := make(map[uint64]ports.SnapshotDigest, len(plan.Keep))
399- for _, ref := range plan.Keep {
400- keep[ref.Generation] = ref.ManifestDigest
91+ references, err := r.referencesForGenerations(id, entries, kept, committed)
92+ if err != nil {
93+ return fmt.Errorf("mark snapshot objects for %s: %w", id.String(), err)
40194 }
402- return keep
403-}
40495
405-// MaintainSession incrementally validates and retains only catalogue-indexed
406-// checkpoints. Every incarnation owns its budget and continuation state.
407-// processRetentionEntries charges stat-reported size for every entry, requeues
408-// the unadmitted suffix, and removes only canonical entries rejected by policy.
409-func (r *Repository) processRetentionEntries(ctx context.Context, state *retentionMaintenance, dir, purpose string, entries []maintenanceDirEntry, budget *ports.MaintenanceBudget, consumedBefore bool, policy retentionEntryPolicy) (admitted bool, err error) {
410- removed := false
411- defer func() {
412- if !removed {
413- return
414- }
415- if syncErr := r.syncDirectory(dir); syncErr != nil {
416- admitted = false
417- err = errors.Join(err, syncErr)
418- }
419- }()
420- for i, entry := range entries {
421- if state.invalidated.Load() {
422- return false, nil
423- }
96+ removed := 0
97+ for _, entry := range entries {
42498 if err := ctx.Err(); err != nil {
425- return false, err
426- }
427- path := filepath.Join(dir, entry.name)
428- info, statErr := r.stat(path)
429- if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
430- return false, statErr
431- }
432- size := uint64(0)
433- if statErr == nil && info.Size() > 0 {
434- size = uint64(info.Size())
435- }
436- if budget.Entries == 0 || size > budget.Bytes {
437- r.requeueRetentionEntries(state, dir, purpose, entries[i:])
438- if !consumedBefore && i == 0 {
439- return false, ErrMaintenanceBudgetTooSmall
440- }
441- return false, nil
99+ return err
442100 }
443- budget.Entries--
444- budget.Bytes -= size
445- if !policy.delete(entry) {
101+ generation, canonical := parseGenerationFilename(entry.Name())
102+ if !canonical || entry.IsDir() {
446103 continue
447104 }
448- if err := r.remove(path); err != nil {
449- if !errors.Is(err, os.ErrNotExist) {
450- return false, err
451- }
452- } else {
453- removed = true
454- }
455- }
456- return true, nil
457-}
458-
459-func (r *Repository) MaintainSession(ctx context.Context, plan ports.RetentionPlan, budget ports.MaintenanceBudget) (done bool, err error) {
460- requested := budget
461- if err := ctx.Err(); err != nil {
462- return false, err
463- }
464- key, err := incarnationKey(plan.IncarnationID)
465- if err != nil {
466- return false, err
467- }
468- defer func() {
469- r.log.Info("snapshot_maintenance_progress",
470- "incarnation", plan.IncarnationID.String(),
471- "action", "retention",
472- "retained", len(plan.Keep),
473- "cursor", key,
474- "consumed_entries", requested.Entries-budget.Entries,
475- "consumed_bytes", requested.Bytes-budget.Bytes,
476- "done", done,
477- "budget_exhausted", !done && err == nil,
478- "err", err,
479- )
480- }()
481- if plan.PinAll {
482- r.maintenanceMu.Lock()
483- if state := r.retentionSessions[key]; state != nil {
484- state.invalidated.Store(true)
485- }
486- r.clearRetentionMaintenance(key)
487- r.maintenanceMu.Unlock()
488- return true, nil
489- }
490- if budget.Entries == 0 || budget.Bytes == 0 {
491- return false, ErrMaintenanceBudgetTooSmall
492- }
493-
494- // Establish per-incarnation state under maintenanceMu, then retain only the
495- // session mutex across payload I/O. Never reacquire maintenanceMu while the
496- // session mutex is held; PinAll invalidates active state without waiting.
497- r.maintenanceMu.Lock()
498- lock := r.lockSession(key)
499- if r.retentionSessions == nil {
500- r.retentionSessions = make(map[string]*retentionMaintenance)
501- }
502-
503- token := retentionToken(plan)
504- state := r.retentionSessions[key]
505- if state == nil || state.token != token {
506- r.clearRetentionMaintenance(key)
507- state = &retentionMaintenance{
508- token: token,
509- cursors: make(map[string]*maintenanceCursor),
510- references: make(map[ports.SnapshotDigest]struct{}),
511- validateQueue: append([]ports.CheckpointRef(nil), plan.Keep...),
512- }
513- r.retentionSessions[key] = state
514- }
515- r.maintenanceMu.Unlock()
516- defer func() {
517- r.unlockSession(lock)
518- if done {
519- r.maintenanceMu.Lock()
520- if r.retentionSessions[key] == state {
521- delete(r.retentionSessions, key)
522- }
523- r.maintenanceMu.Unlock()
105+ if _, retain := kept[generation]; retain {
106+ continue
524107 }
525- }()
526- keep := keepSet(plan)
527- if !state.validated {
528- for state.validateIndex < len(state.validateQueue) {
529- ref := state.validateQueue[state.validateIndex]
530- if budget.Entries == 0 {
531- return false, nil
532- }
533- validated, consumedBytes, admitted, err := r.validateMaintenanceCheckpoint(ctx, plan.IncarnationID, ref, budget.Bytes)
534- if err != nil {
535- return false, fmt.Errorf("validate retained manifest %d: %w", ref.Generation, err)
536- }
537- if !admitted {
538- if requested.Entries == budget.Entries && requested.Bytes == budget.Bytes {
539- return false, ErrMaintenanceBudgetTooSmall
540- }
541- return false, nil
542- }
543- budget.Entries--
544- budget.Bytes -= consumedBytes
545- for digest := range validated.refs {
546- state.references[digest] = struct{}{}
547- }
548- state.validateIndex++
108+ if err := r.remove(filepath.Join(generationsDir, entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
109+ return fmt.Errorf("remove snapshot generation %d for %s: %w", generation, id.String(), safeFilesystemError(err))
549110 }
550- state.validated = true
111+ removed++
551112 }
552-
553- purpose := "retention-manifests:" + key + ":" + token
554- for !state.manifestsDone && budget.Entries > 0 && budget.Bytes > 0 {
555- limit := int(min(budget.Entries, uint64(maintenanceBatch)))
556- entries, done, err := r.readRetentionDir(state, filepath.Join(r.sessionPath(plan.IncarnationID), repositoryGenerations), limit, purpose)
557- if errors.Is(err, os.ErrNotExist) {
558- state.manifestsDone = true
559- break
560- }
561- if err != nil {
562- return false, err
563- }
564- dir := filepath.Join(r.sessionPath(plan.IncarnationID), repositoryGenerations)
565- consumedBefore := requested.Entries != budget.Entries || requested.Bytes != budget.Bytes
566- admitted, err := r.processRetentionEntries(ctx, state, dir, purpose, entries, &budget, consumedBefore, retentionEntryPolicy{kind: retentionManifest, generations: keep})
567- if err != nil {
568- return false, err
569- }
570- if !admitted {
571- return false, nil
572- }
573- state.manifestsDone = done
574- if len(entries) == 0 && !done {
575- return false, nil
113+ if removed > 0 {
114+ if err := r.syncDirectory(generationsDir); err != nil {
115+ return fmt.Errorf("sync snapshot generations for %s: %w", id.String(), safeFilesystemError(err))
576116 }
117+ r.log.Info("snapshot_garbage_collected", "incarnation", id.String(), "action", "remove-generations", "removed", removed)
577118 }
578- if !state.manifestsDone {
579- return false, nil
580- }
119+ return r.sweepUnreferencedObjects(ctx, id, references)
120+}
581121
582- objectsRoot := filepath.Join(r.sessionPath(plan.IncarnationID), repositoryObjectsDir)
583- for budget.Entries > 0 && budget.Bytes > 0 {
584- if state.objectShard != "" {
585- shard := state.objectShard
586- dir := filepath.Join(objectsRoot, shard)
587- objectPurpose := "retention-objects:" + key + ":" + shard + ":" + token
588- entries, done, err := r.readRetentionDir(state, dir, int(min(budget.Entries, uint64(maintenanceBatch))), objectPurpose)
589- if errors.Is(err, os.ErrNotExist) {
590- state.objectShard = ""
591- continue
592- }
593- if err != nil {
594- return false, err
595- }
596- consumedBefore := requested.Entries != budget.Entries || requested.Bytes != budget.Bytes
597- admitted, err := r.processRetentionEntries(ctx, state, dir, objectPurpose, entries, &budget, consumedBefore, retentionEntryPolicy{kind: retentionObject, objects: state.references})
598- if err != nil {
599- return false, err
600- }
601- if !admitted {
602- return false, nil
603- }
604- if done {
605- state.objectShard = ""
606- } else if len(entries) == 0 {
607- return false, nil
608- }
122+func (r *Repository) referencesForGenerations(id domain.IncarnationID, entries []os.DirEntry, keep map[uint64]struct{}, committed domain.CheckpointRef) (map[ports.SnapshotDigest]struct{}, error) {
123+ references := make(map[ports.SnapshotDigest]struct{})
124+ committedFound := committed.Generation == 0
125+ for _, entry := range entries {
126+ generation, canonical := parseGenerationFilename(entry.Name())
127+ if !canonical || entry.IsDir() {
609128 continue
610129 }
611- if state.objectRootDone {
612- return true, nil
613- }
614- entries, done, err := r.readRetentionDir(state, objectsRoot, 1, "retention-shards:"+key+":"+token)
615- if errors.Is(err, os.ErrNotExist) {
616- return true, nil
130+ if _, retained := keep[generation]; !retained {
131+ continue
617132 }
133+ data, err := r.readBounded(r.manifestPath(id, generation))
618134 if err != nil {
619- return false, err
620- }
621- state.objectRootDone = done
622- if len(entries) == 0 {
623- if !done {
624- return false, nil
625- }
626- continue
135+ return nil, err
627136 }
628- budget.Entries--
629- if entries[0].isDir {
630- state.objectShard = entries[0].name
137+ manifest, err := codec.UnmarshalManifest(data)
138+ if err != nil || manifest.IncarnationID != id || manifest.Generation != generation {
139+ return nil, fmt.Errorf("invalid retained manifest generation %d", generation)
631140 }
632- }
633- return false, nil
634-}
635-
636-func (r *Repository) clearRetentionMaintenance(key string) {
637- delete(r.retentionSessions, key)
638-}
639-
640-func (r *Repository) readRetentionDir(state *retentionMaintenance, dir string, n int, purpose string) ([]maintenanceDirEntry, bool, error) {
641- id := maintenanceCursorID(dir, purpose)
642- cursor := state.cursors[id]
643- if cursor != nil && cursor.pending != nil {
644- count := min(n, len(cursor.pending))
645- entries := cursor.pending[:count]
646- cursor.pending = cursor.pending[count:]
647- if len(entries) != 0 {
648- cursor.offset = entries[len(entries)-1].cookie
141+ if generation == committed.Generation {
142+ committedFound = true
143+ if sha256.Sum256(data) != committed.ManifestDigest {
144+ return nil, fmt.Errorf("committed manifest digest mismatch")
145+ }
649146 }
650- done := cursor.done && len(cursor.pending) == 0
651- if done {
652- delete(state.cursors, id)
653- } else if count > 0 && len(cursor.pending) == 0 {
654- cursor.pending = nil
147+ refs := manifestRefs(manifest)
148+ if refs == nil || !withinGenerationBudget(len(data), refs) {
149+ return nil, fmt.Errorf("invalid retained manifest references generation %d", generation)
655150 }
656- return entries, done, nil
657- }
658- if cursor == nil {
659- if len(state.cursors) >= maxMaintenanceCursors {
660- return nil, false, errMaintenanceCursorLimit
151+ for digest := range refs {
152+ references[digest] = struct{}{}
661153 }
662- cursor = &maintenanceCursor{}
663- state.cursors[id] = cursor
664- }
665- entries, done, err := r.readMaintenanceDirent(dir, n, cursor)
666- if err != nil || done {
667- delete(state.cursors, id)
668- }
669- return entries, done, err
670-}
671-
672-func (r *Repository) requeueRetentionEntries(state *retentionMaintenance, dir, purpose string, entries []maintenanceDirEntry) {
673- if len(entries) == 0 {
674- return
675154 }
676- id := maintenanceCursorID(dir, purpose)
677- cursor := state.cursors[id]
678- if cursor == nil {
679- cursor = &maintenanceCursor{done: true}
680- state.cursors[id] = cursor
155+ if !committedFound {
156+ return nil, fmt.Errorf("committed manifest generation %d is missing", committed.Generation)
681157 }
682- pending := make([]maintenanceDirEntry, 0, len(entries)+len(cursor.pending))
683- pending = append(pending, entries...)
684- pending = append(pending, cursor.pending...)
685- cursor.pending = pending
158+ return references, nil
686159 }
687160
688-func retentionToken(plan ports.RetentionPlan) string {
689- var b strings.Builder
690- b.WriteString(plan.IncarnationID.String())
691- for _, ref := range plan.Keep {
692- fmt.Fprintf(&b, ":%d:%x", ref.Generation, ref.ManifestDigest)
693- }
694- return b.String()
161+type safePathError struct {
162+ op string
163+ cause error
695164 }
696165
697-type validatedMaintenanceCheckpoint struct {
698- refs map[ports.SnapshotDigest]codec.ObjectRef
699- parent *domain.CheckpointRef
700-}
166+func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() }
167+func (e safePathError) Unwrap() error { return e.cause }
701168
702-// validateMaintenanceCheckpoint admits every payload by stat-reported size
703-// before reading it. It validates directly instead of calling LoadCheckpoint,
704-// which would materialize a complete generation before maintenance could
705-// enforce its byte budget.
706-func (r *Repository) validateMaintenanceCheckpoint(ctx context.Context, id domain.IncarnationID, ref ports.CheckpointRef, byteBudget uint64) (validatedMaintenanceCheckpoint, uint64, bool, error) {
707- manifestPath := r.manifestPath(id, ref.Generation)
708- info, err := r.stat(manifestPath)
709- if err != nil {
710- return validatedMaintenanceCheckpoint{}, 0, false, err
711- }
712- if info.Size() < 0 || uint64(info.Size()) > byteBudget {
713- return validatedMaintenanceCheckpoint{}, 0, false, nil
714- }
715- consumed := uint64(info.Size())
716- if hook := r.hooks.beforeMaintenancePayloadRead; hook != nil {
717- hook(manifestPath)
718- }
719- data, err := r.readBounded(manifestPath)
720- if err != nil {
721- return validatedMaintenanceCheckpoint{}, consumed, false, err
169+// safeFilesystemError preserves error matching without exposing repository
170+// paths in terminal-facing errors.
171+func safeFilesystemError(err error) error {
172+ var linkError *os.LinkError
173+ if errors.As(err, &linkError) {
174+ return safePathError{op: linkError.Op, cause: linkError.Err}
722175 }
723- manifest, err := codec.UnmarshalManifest(data)
724- if err != nil || manifest.Generation != ref.Generation || manifest.IncarnationID != id || sha256.Sum256(data) != ref.ManifestDigest {
725- return validatedMaintenanceCheckpoint{}, consumed, false, errors.New("catalogue reference mismatch")
726- }
727- refs, valid := maintenanceManifestRefs(manifest)
728- if !valid || !withinGenerationBudget(len(data), refs) {
729- return validatedMaintenanceCheckpoint{}, consumed, false, errors.New("invalid manifest references")
730- }
731- for digest, objectRef := range refs {
732- if err := ctx.Err(); err != nil {
733- return validatedMaintenanceCheckpoint{}, consumed, false, err
734- }
735- path := r.objectPath(id, digest)
736- info, err := r.stat(path)
737- if err != nil {
738- return validatedMaintenanceCheckpoint{}, consumed, false, err
739- }
740- if info.Size() < 0 || uint64(info.Size()) > byteBudget-consumed {
741- return validatedMaintenanceCheckpoint{}, consumed, false, nil
742- }
743- consumed += uint64(info.Size())
744- if hook := r.hooks.beforeMaintenancePayloadRead; hook != nil {
745- hook(path)
746- }
747- object, err := r.readBounded(path)
748- if err != nil {
749- return validatedMaintenanceCheckpoint{}, consumed, false, err
750- }
751- if sha256.Sum256(object) != digest || !validObject(object, objectRef) {
752- return validatedMaintenanceCheckpoint{}, consumed, false, errors.New("invalid retained object")
753- }
176+ var pathError *os.PathError
177+ if errors.As(err, &pathError) {
178+ return safePathError{op: pathError.Op, cause: pathError.Err}
754179 }
755- return validatedMaintenanceCheckpoint{refs: refs, parent: manifest.ParentCheckpoint}, consumed, true, nil
180+ return err
756181 }
757182
758-func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool) map[ports.SnapshotDigest]struct{} {
759- if conservative {
760- references := make(map[ports.SnapshotDigest]struct{})
761- for _, item := range marked {
762- for digest := range item.refs {
763- references[digest] = struct{}{}
764- }
765- }
766- return references
767- }
768- complete := completeGenerationsDescending(marked)
769- keep := make(map[uint64]bool, 2)
770- for _, generation := range complete {
771- if len(keep) == 2 {
772- break
773- }
774- keep[generation] = true
183+func (r *Repository) readGarbageCollectionDirectory(path string) (entries []os.DirEntry, err error) {
184+ directory, err := r.openDirectory(path)
185+ if err != nil {
186+ return nil, err
775187 }
776- references := make(map[ports.SnapshotDigest]struct{})
777- for generation, item := range marked {
778- if !item.complete || keep[generation] {
779- for digest := range item.refs {
780- references[digest] = struct{}{}
781- }
188+ defer func() {
189+ if closeErr := directory.Close(); closeErr != nil {
190+ err = errors.Join(err, closeErr)
782191 }
783- }
784- return references
192+ }()
193+ return directory.ReadDir(-1)
785194 }
786195
787-func (r *Repository) sweepShard(ctx context.Context, dir string, referenced map[ports.SnapshotDigest]struct{}, budget *int, key, shard string) (bool, error) {
788- entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "sweep-objects:"+key+":"+shard)
196+func (r *Repository) sweepUnreferencedObjects(ctx context.Context, id domain.IncarnationID, references map[ports.SnapshotDigest]struct{}) error {
197+ objectsRoot := filepath.Join(r.sessionPath(id), repositoryObjectsDir)
198+ shards, err := r.readGarbageCollectionDirectory(objectsRoot)
789199 if errors.Is(err, os.ErrNotExist) {
790- return true, nil
200+ return nil
791201 }
792202 if err != nil {
793- return false, fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err))
203+ return fmt.Errorf("read snapshot objects for %s: %w", id.String(), safeFilesystemError(err))
794204 }
795- for i, object := range entries {
796- if *budget == 0 {
797- r.requeueMaintenanceEntries(dir, "sweep-objects:"+key+":"+shard, entries[i:])
798- return false, nil
799- }
205+ removed := 0
206+ for _, shard := range shards {
800207 if err := ctx.Err(); err != nil {
801- return false, err
802- }
803- digest, ok := parseObjectDigest(object.name)
804- if !ok || object.isDir {
805- continue
208+ return err
806209 }
807- if _, used := referenced[digest]; used {
210+ if !shard.IsDir() || len(shard.Name()) != 2 {
808211 continue
809212 }
810- path := filepath.Join(dir, object.name)
811- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
812- return false, fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
213+ dir := filepath.Join(objectsRoot, shard.Name())
214+ objects, err := r.readGarbageCollectionDirectory(dir)
215+ if err != nil {
216+ return fmt.Errorf("read snapshot object shard for %s: %w", id.String(), safeFilesystemError(err))
813217 }
814- if err := ctx.Err(); err != nil {
815- return false, err
218+ shardRemoved := false
219+ for _, object := range objects {
220+ digest, canonical := parseObjectDigest(object.Name())
221+ if !canonical || object.IsDir() {
222+ continue
223+ }
224+ if _, retained := references[digest]; retained {
225+ continue
226+ }
227+ if err := r.remove(filepath.Join(dir, object.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
228+ return fmt.Errorf("remove snapshot object for %s: %w", id.String(), safeFilesystemError(err))
229+ }
230+ removed++
231+ shardRemoved = true
816232 }
817- if err := r.syncDirectory(dir); err != nil {
818- return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
233+ if shardRemoved {
234+ if err := r.syncDirectory(dir); err != nil {
235+ return fmt.Errorf("sync snapshot object shard for %s: %w", id.String(), safeFilesystemError(err))
236+ }
819237 }
820- *budget--
821238 }
822- return done, nil
239+ if removed > 0 {
240+ r.log.Info("snapshot_garbage_collected", "incarnation", id.String(), "action", "remove-objects", "removed", removed)
241+ }
242+ return nil
823243 }
A internal/adapters/snapshot/maintenance_gc_test.go +91 -0
@@ -0,0 +1,91 @@
1+package snapshot
2+
3+import (
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+
16+func TestCollectGarbage(t *testing.T) {
17+ t.Parallel()
18+ tests := []struct {
19+ name string
20+ committed uint64
21+ known bool
22+ wantGenerations []uint64
23+ }{
24+ {name: "incarnation absent from catalogue is removed whole"},
25+ {name: "committed and immediate predecessor survive", committed: 3, known: true, wantGenerations: []uint64{2, 3}},
26+ {name: "forward orphan newer than committed is removed", committed: 3, known: true, wantGenerations: []uint64{2, 3}},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ t.Parallel()
31+ repository := NewRepository(privateDir(t))
32+ publications := publishGarbageCollectionGenerations(t, repository, "work", 4)
33+ id := publications[0].IncarnationID
34+ keep := make(map[domain.IncarnationID]domain.CheckpointRef)
35+ if tt.known {
36+ publication := publications[tt.committed-1]
37+ keep[id] = domain.CheckpointRef{Generation: tt.committed, ManifestDigest: sha256.Sum256(publication.Manifest)}
38+ }
39+
40+ require.NoError(t, repository.CollectGarbage(context.Background(), keep))
41+ if !tt.known {
42+ _, err := os.Stat(repository.sessionPath(id))
43+ require.ErrorIs(t, err, os.ErrNotExist)
44+ return
45+ }
46+ require.Equal(t, tt.wantGenerations, garbageCollectionGenerations(t, repository, id))
47+ retainedObjects := make(map[ports.SnapshotDigest]struct{})
48+ for _, publication := range publications[tt.committed-2 : tt.committed] {
49+ for _, object := range publication.Objects {
50+ retainedObjects[object.Digest] = struct{}{}
51+ }
52+ }
53+ for _, publication := range publications {
54+ for _, object := range publication.Objects {
55+ _, retained := retainedObjects[object.Digest]
56+ _, err := os.Stat(repository.objectPath(id, object.Digest))
57+ if retained {
58+ require.NoError(t, err)
59+ } else {
60+ require.ErrorIs(t, err, os.ErrNotExist)
61+ }
62+ }
63+ }
64+ })
65+ }
66+}
67+
68+func publishGarbageCollectionGenerations(t *testing.T, repository *Repository, name string, count uint64) []ports.SnapshotPublication {
69+ t.Helper()
70+ publications := make([]ports.SnapshotPublication, 0, count)
71+ for generation := uint64(1); generation <= count; generation++ {
72+ publication := repositoryPublicationAfter(t, repository, name, generation, []byte{byte(generation)})
73+ require.NoError(t, repository.Publish(context.Background(), publication))
74+ publications = append(publications, publication)
75+ }
76+ return publications
77+}
78+
79+func garbageCollectionGenerations(t *testing.T, repository *Repository, id domain.IncarnationID) []uint64 {
80+ t.Helper()
81+ entries, err := os.ReadDir(filepath.Join(repository.sessionPath(id), repositoryGenerations))
82+ require.NoError(t, err)
83+ generations := make([]uint64, 0, len(entries))
84+ for _, entry := range entries {
85+ if generation, ok := parseGenerationFilename(entry.Name()); ok {
86+ generations = append(generations, generation)
87+ }
88+ }
89+ slices.Sort(generations)
90+ return generations
91+}
D internal/adapters/snapshot/maintenance_hardening_test.go +0 -141
@@ -1,141 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "testing"
10-)
11-
12-func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- key := legacyIncarnationID("named").String()
15- objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
16- const shards = maintenanceBatch * 2
17- for i := 0; i < shards; i++ {
18- shard := filepath.Join(objects, fmt.Sprintf("%03d", i))
19- if err := os.MkdirAll(shard, 0o700); err != nil {
20- t.Fatal(err)
21- }
22- if err := os.WriteFile(filepath.Join(shard, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
23- t.Fatal(err)
24- }
25- // Force a resumable cursor in every attacker-controlled shard.
26- for j := 0; j < maintenanceBatch+1; j++ {
27- if err := os.WriteFile(filepath.Join(shard, fmt.Sprintf("live-%03d", j)), []byte("live"), 0o600); err != nil {
28- t.Fatal(err)
29- }
30- }
31- }
32-
33- for pass := 0; pass < shards*4; pass++ {
34- if err := repo.Maintain(context.Background()); err != nil {
35- t.Fatal(err)
36- }
37- if got := len(repo.maintenanceCursors); got > maxMaintenanceCursors {
38- t.Fatalf("retained maintenance cursors = %d, want at most %d", got, maxMaintenanceCursors)
39- }
40- }
41- for i := 0; i < shards; i++ {
42- _, err := os.Lstat(filepath.Join(objects, fmt.Sprintf("%03d", i), ".tmp-stale"))
43- if !errors.Is(err, os.ErrNotExist) {
44- t.Fatalf("stale temporary object in shard %d = %v, want removed", i, err)
45- }
46- }
47-}
48-
49-func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) {
50- repo := NewRepository(privateDir(t))
51- key := legacyIncarnationID("named").String()
52- objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
53- const shards = maintenanceBatch * 2
54- for i := 0; i < shards; i++ {
55- if err := os.MkdirAll(filepath.Join(objects, fmt.Sprintf("%03d", i)), 0o700); err != nil {
56- t.Fatal(err)
57- }
58- }
59-
60- openedShards := make(map[string]struct{}, shards)
61- opensThisPass := 0
62- tempPass := false
63- repo.hooks.openMaintenanceDirectory = func(dir string) (maintenanceDirectory, error) {
64- if tempPass && filepath.Dir(dir) == objects {
65- opensThisPass++
66- openedShards[dir] = struct{}{}
67- }
68- file, err := os.Open(dir)
69- if err != nil {
70- return nil, err
71- }
72- return osMaintenanceDirectory{file: file}, nil
73- }
74-
75- for pass := 0; pass < shards*3; pass++ {
76- opensThisPass = 0
77- tempPass = true
78- if err := repo.Maintain(context.Background()); err != nil {
79- t.Fatal(err)
80- }
81- tempPass = false
82- if opensThisPass > 1 {
83- t.Fatalf("empty object shards opened in maintenance pass %d = %d, want at most 1", pass, opensThisPass)
84- }
85- state := repo.maintenanceSessions[key]
86- if state != nil && state.objectTempsDone {
87- if got := len(openedShards); got != shards {
88- t.Fatalf("empty object shards opened = %d, want %d", got, shards)
89- }
90- return
91- }
92- }
93- t.Fatal("empty object temp shards were not eventually traversed")
94-}
95-
96-func TestRepositoryMaintainBoundsAndResumesDeepWideQuarantine(t *testing.T) {
97- repo := NewRepository(privateDir(t))
98- quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-hostile")
99- deep := quarantine
100- for i := 0; i < maintenanceBatch*2; i++ {
101- deep = filepath.Join(deep, "d")
102- }
103- leaf := filepath.Join(deep, "leaf")
104- if err := os.MkdirAll(filepath.Dir(leaf), 0o700); err != nil {
105- t.Fatal(err)
106- }
107- if err := os.WriteFile(leaf, []byte("stale"), 0o600); err != nil {
108- t.Fatal(err)
109- }
110- for i := 0; i < maintenanceBatch*2; i++ {
111- if err := os.WriteFile(filepath.Join(quarantine, fmt.Sprintf("wide-%03d", i)), []byte("stale"), 0o600); err != nil {
112- t.Fatal(err)
113- }
114- }
115-
116- work := 0
117- repo.hooks.beforeMaintenanceWork = func(string) { work++ }
118- if err := repo.Maintain(context.Background()); err != nil {
119- t.Fatal(err)
120- }
121- if work == 0 || work > maintenanceBatch {
122- t.Fatalf("quarantine work in one maintenance call = %d, want 1..%d", work, maintenanceBatch)
123- }
124- if _, err := os.Lstat(leaf); err != nil {
125- t.Fatalf("deep quarantine leaf removed despite bounded traversal: %v", err)
126- }
127-
128- for pass := 0; pass < 1000; pass++ {
129- work = 0
130- if err := repo.Maintain(context.Background()); err != nil {
131- t.Fatal(err)
132- }
133- if work > maintenanceBatch {
134- t.Fatalf("quarantine work in pass %d = %d, want at most %d", pass, work, maintenanceBatch)
135- }
136- if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
137- return
138- }
139- }
140- t.Fatal("deep and wide quarantine was not eventually removed")
141-}
D internal/adapters/snapshot/maintenance_test.go +0 -1354
@@ -1,1354 +0,0 @@
1-package snapshot
2-
3-import (
4- "cmp"
5- "context"
6- "crypto/sha256"
7- "encoding/binary"
8- "errors"
9- "fmt"
10- "io"
11- "os"
12- "path/filepath"
13- "slices"
14- "strings"
15- "sync"
16- "syscall"
17- "testing"
18- "time"
19- "unsafe"
20-
21- "github.com/bnema/vev/internal/domain"
22- "github.com/bnema/vev/internal/ports"
23- "github.com/bnema/vev/pkg/safedir"
24-)
25-
26-func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *testing.T) {
27- privatePath := filepath.Join(t.TempDir(), "private-maintenance-directory")
28- closeCause := errors.New("injected close failure")
29- seekCause := errors.New("injected seek failure")
30- readCause := errors.New("injected read failure")
31-
32- for _, tc := range []struct {
33- name string
34- offset int64
35- seekErr error
36- readErr error
37- wantCause []error
38- wantOps []string
39- }{
40- {
41- name: "close only",
42- wantCause: []error{closeCause},
43- wantOps: []string{"close maintenance directory"},
44- },
45- {
46- name: "seek and close",
47- offset: 1,
48- seekErr: &os.PathError{Op: "seek", Path: privatePath, Err: seekCause},
49- wantCause: []error{seekCause, closeCause},
50- wantOps: []string{"seek maintenance directory", "close maintenance directory"},
51- },
52- {
53- name: "read and close",
54- readErr: &os.PathError{Op: "read", Path: privatePath, Err: readCause},
55- wantCause: []error{readCause, closeCause},
56- wantOps: []string{"read maintenance directory", "close maintenance directory"},
57- },
58- } {
59- t.Run(tc.name, func(t *testing.T) {
60- repo := NewRepository(privateDir(t))
61- repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
62- return fakeMaintenanceDirectory{
63- seekErr: tc.seekErr,
64- readErr: tc.readErr,
65- closeErr: &os.PathError{Op: "close", Path: privatePath, Err: closeCause},
66- }, nil
67- }
68-
69- _, _, err := repo.readMaintenanceDirent(privatePath, 1, &maintenanceCursor{offset: tc.offset})
70- if err == nil {
71- t.Fatal("readMaintenanceDirent error = nil, want failure")
72- }
73- for _, cause := range tc.wantCause {
74- if !errors.Is(err, cause) {
75- t.Errorf("errors.Is(%v, %v) = false, want true", err, cause)
76- }
77- }
78- for _, operation := range tc.wantOps {
79- if !strings.Contains(err.Error(), operation) {
80- t.Errorf("error = %q, want stable operation context %q", err, operation)
81- }
82- }
83- if strings.Contains(err.Error(), privatePath) {
84- t.Errorf("error leaks private path: %q", err)
85- }
86- })
87- }
88-}
89-
90-func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) {
91- repo := NewRepository(privateDir(t))
92- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
93- recordData := make([]byte, nameOffset)
94- reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
95- binary.NativeEndian.PutUint16(recordData[reclenOffset:], 1)
96-
97- repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
98- return fakeMaintenanceDirectory{data: recordData}, nil
99- }
100- _, _, err := repo.readMaintenanceDirent("unused", 1, &maintenanceCursor{})
101- if !errors.Is(err, syscall.EIO) {
102- t.Fatalf("readMaintenanceDirent error = %v, want EIO", err)
103- }
104-}
105-
106-func TestDecodeMaintenanceDirentBoundsRecords(t *testing.T) {
107- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
108- reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
109- valid := make([]byte, nameOffset+len("entry")+1)
110- binary.NativeEndian.PutUint16(valid[reclenOffset:], uint16(len(valid)))
111- copy(valid[nameOffset:], "entry")
112-
113- tests := []struct {
114- name string
115- data []byte
116- wantName string
117- wantErr bool
118- }{
119- {name: "truncated header", data: make([]byte, nameOffset-1), wantErr: true},
120- {name: "short record", data: append([]byte(nil), valid...), wantErr: true},
121- {name: "truncated record", data: valid[:nameOffset], wantErr: true},
122- {name: "record exceeds aligned storage", data: make([]byte, int(unsafe.Sizeof(syscall.Dirent{}))+1), wantErr: true},
123- {name: "valid", data: valid, wantName: "entry"},
124- }
125- binary.NativeEndian.PutUint16(tests[1].data[reclenOffset:], uint16(nameOffset-1))
126- binary.NativeEndian.PutUint16(tests[2].data[reclenOffset:], uint16(len(valid)))
127- binary.NativeEndian.PutUint16(tests[3].data[reclenOffset:], uint16(len(tests[3].data)))
128-
129- for _, tc := range tests {
130- t.Run(tc.name, func(t *testing.T) {
131- record, name, reclen, err := decodeMaintenanceDirent(tc.data)
132- if tc.wantErr {
133- if !errors.Is(err, syscall.EIO) {
134- t.Fatalf("decodeMaintenanceDirent() error = %v, want EIO", err)
135- }
136- return
137- }
138- if err != nil {
139- t.Fatal(err)
140- }
141- if record == nil {
142- t.Fatal("decodeMaintenanceDirent() record = nil")
143- }
144- if reclen != len(valid) {
145- t.Fatalf("decodeMaintenanceDirent() reclen = %d, want %d", reclen, len(valid))
146- }
147- if got := string(name[:len(tc.wantName)]); got != tc.wantName {
148- t.Fatalf("decodeMaintenanceDirent() name = %q, want %q", got, tc.wantName)
149- }
150- })
151- }
152-}
153-
154-type fakeMaintenanceDirectory struct {
155- data []byte
156- seekOffset int64
157- seekErr error
158- readErr error
159- closeErr error
160-}
161-
162-func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return d.seekOffset, d.seekErr }
163-func (d fakeMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
164- if d.readErr != nil {
165- return 0, d.readErr
166- }
167- return copy(buffer, d.data), nil
168-}
169-func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
170-
171-func TestReadMaintenanceDirentDrainsDarwinBatchesWithoutStarvation(t *testing.T) {
172- dir := privateDir(t)
173- if err := os.Mkdir(dir, 0o700); err != nil {
174- t.Fatal(err)
175- }
176- batches := [][]string{
177- {"one", "two"},
178- {"three", "four", "five", "six"},
179- }
180- for _, batch := range batches {
181- for _, name := range batch {
182- if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil {
183- t.Fatal(err)
184- }
185- }
186- }
187-
188- file := &fakeMultiBatchMaintenanceDirectory{batches: [][]byte{
189- maintenanceDirentBatch(batches[0]...),
190- maintenanceDirentBatch(batches[1]...),
191- }}
192- repo := NewRepository(dir)
193- repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) { return file, nil }
194- cursor := &maintenanceCursor{}
195-
196- entries, done, err := repo.readMaintenanceDirentWithDrain(dir, 4, cursor, true, maintenanceTestDirectoryCookie)
197- if err != nil {
198- t.Fatal(err)
199- }
200- if done {
201- t.Fatal("readMaintenanceDirentWithDrain() done = true, want false before EOF")
202- }
203- if got, want := maintenanceEntryNames(entries), []string{"one", "two", "three", "four"}; !slices.Equal(got, want) {
204- t.Fatalf("first entries = %v, want %v", got, want)
205- }
206- if got, want := maintenanceEntryNames(cursor.pending), []string{"five", "six"}; !slices.Equal(got, want) {
207- t.Fatalf("pending entries = %v, want exactly final-buffer excess %v", got, want)
208- }
209- if got := file.reads; got != 2 {
210- t.Fatalf("ReadDirent calls = %d, want 2 to fill the requested batch", got)
211- }
212-
213- id := maintenanceCursorID(dir, "test")
214- repo.maintenanceCursors = map[string]*maintenanceCursor{id: cursor}
215- entries, done, err = repo.readMaintenanceDir(dir, 4, "test")
216- if err != nil {
217- t.Fatal(err)
218- }
219- if done {
220- t.Fatal("pending batch done = true, want false before EOF")
221- }
222- if got, want := maintenanceEntryNames(entries), []string{"five", "six"}; !slices.Equal(got, want) {
223- t.Fatalf("pending entries = %v, want %v", got, want)
224- }
225- if got := file.reads; got != 2 {
226- t.Fatalf("ReadDirent calls while delivering pending entries = %d, want 2", got)
227- }
228-
229- entries, done, err = repo.readMaintenanceDir(dir, 4, "test")
230- if err != nil {
231- t.Fatal(err)
232- }
233- if !done {
234- t.Fatal("EOF batch done = false, want true")
235- }
236- if len(entries) != 0 {
237- t.Fatalf("EOF entries = %v, want none", entries)
238- }
239- if _, ok := repo.maintenanceCursors[id]; ok {
240- t.Fatal("EOF cursor retained after pending entries were delivered")
241- }
242-}
243-
244-func maintenanceEntryNames(entries []maintenanceDirEntry) []string {
245- names := make([]string, len(entries))
246- for i, entry := range entries {
247- names[i] = entry.name
248- }
249- return names
250-}
251-
252-func maintenanceDirentBatch(names ...string) []byte {
253- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
254- reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
255- var data []byte
256- for _, name := range names {
257- record := make([]byte, nameOffset+len(name)+1)
258- binary.NativeEndian.PutUint16(record[reclenOffset:], uint16(len(record)))
259- copy(record[nameOffset:], name)
260- data = append(data, record...)
261- }
262- return data
263-}
264-
265-type fakeMultiBatchMaintenanceDirectory struct {
266- batches [][]byte
267- index int
268- reads int
269-}
270-
271-func (d *fakeMultiBatchMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
272- if whence == io.SeekCurrent {
273- return int64(d.index), nil
274- }
275- d.index = int(offset)
276- return offset, nil
277-}
278-
279-func (d *fakeMultiBatchMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
280- d.reads++
281- if d.index == len(d.batches) {
282- return 0, nil
283- }
284- batch := d.batches[d.index]
285- d.index++
286- return copy(buffer, batch), nil
287-}
288-
289-func (d *fakeMultiBatchMaintenanceDirectory) Close() error { return nil }
290-
291-func maintenanceTestDirectoryCookie(file maintenanceDirectory, _ *syscall.Dirent, _ int) (int64, error) {
292- return file.Seek(0, io.SeekCurrent)
293-}
294-
295-func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {
296- repo := NewRepository(privateDir(t))
297- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
298- t.Fatal(err)
299- }
300- if err := repo.Delete(context.Background(), "named"); err != nil {
301- t.Fatal(err)
302- }
303-
304- canonical := repo.legacySessionPath(legacyIncarnationID("named").String())
305- if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) {
306- t.Fatalf("canonical session after Delete = %v, want not exist", err)
307- }
308- entries, err := os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir))
309- if err != nil {
310- t.Fatal(err)
311- }
312- if len(entries) != 1 || !isQuarantine(entries[0].Name()) || !entries[0].IsDir() {
313- t.Fatalf("session entries after Delete = %#v, want one quarantine", entries)
314- }
315- if err := repo.Maintain(context.Background()); err != nil {
316- t.Fatal(err)
317- }
318- entries, err = os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir))
319- if err != nil {
320- t.Fatal(err)
321- }
322- if len(entries) != 0 {
323- t.Fatalf("session entries after Maintain = %#v, want none", entries)
324- }
325-}
326-
327-func TestRepositoryDeleteReturnsRootSyncFailureAfterQuarantine(t *testing.T) {
328- repo := NewRepository(privateDir(t))
329- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
330- t.Fatal(err)
331- }
332- injected := errors.New("sessions sync")
333- repo.hooks.syncDirectory = func(string) error { return injected }
334- if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
335- t.Fatalf("Delete error = %v, want root sync failure", err)
336- }
337- if _, err := os.Lstat(repo.legacySessionPath(legacyIncarnationID("named").String())); !errors.Is(err, os.ErrNotExist) {
338- t.Fatalf("canonical session after failed sync = %v, want not exist", err)
339- }
340-}
341-
342-func TestRepositoryDeleteWaitsForPublication(t *testing.T) {
343- repo := NewRepository(privateDir(t))
344- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("first"))); err != nil {
345- t.Fatal(err)
346- }
347- entered := make(chan struct{})
348- release := make(chan struct{})
349- repo.hooks.beforeHeadWrite = func(string) error {
350- close(entered)
351- <-release
352- return nil
353- }
354- publishDone := make(chan error, 1)
355- go func() {
356- publishDone <- repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", 2, []byte("second")))
357- }()
358- <-entered
359- deleteReached := make(chan struct{})
360- deleteRelease := make(chan struct{})
361- repo.hooks.beforeSessionLock = waitAtSessionLock(deleteReached, deleteRelease)
362- deleteDone := make(chan error, 1)
363- go func() { deleteDone <- repo.Delete(context.Background(), "named") }()
364- <-deleteReached
365- // Delete is parked at the session-lock boundary while Publish owns it.
366- close(deleteRelease)
367- close(release)
368- if err := <-publishDone; err != nil {
369- t.Fatal(err)
370- }
371- if err := <-deleteDone; err != nil {
372- t.Fatal(err)
373- }
374- if _, err := os.Lstat(repo.legacySessionPath(legacyIncarnationID("named").String())); !errors.Is(err, os.ErrNotExist) {
375- t.Fatalf("canonical session after concurrent Delete = %v, want not exist", err)
376- }
377-}
378-
379-func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
380- repo := NewRepository(privateDir(t))
381- for generation := uint64(1); generation <= 3; generation++ {
382- if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, []byte{byte(generation)})); err != nil {
383- t.Fatal(err)
384- }
385- }
386- key := legacyIncarnationID("named").String()
387- for pass := range maintenanceBatch {
388- if err := repo.Maintain(context.Background()); err != nil {
389- t.Fatal(err)
390- }
391- _, err := os.Lstat(repo.legacyManifestPath(key, 1))
392- if errors.Is(err, os.ErrNotExist) {
393- break
394- }
395- if err != nil {
396- t.Fatalf("old manifest during Maintain = %v", err)
397- }
398- if pass == maintenanceBatch-1 {
399- t.Fatal("old manifest was not eventually collected")
400- }
401- }
402- for _, generation := range []uint64{2, 3} {
403- if _, err := os.Lstat(repo.legacyManifestPath(key, generation)); err != nil {
404- t.Fatalf("retained manifest %d: %v", generation, err)
405- }
406- }
407- if _, err := os.Lstat(repo.legacyManifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) {
408- t.Fatalf("old manifest after Maintain = %v, want not exist", err)
409- }
410- for _, generation := range []uint64{2, 3} {
411- if _, err := loadGenerationCheckpoint(context.Background(), repo, legacyIncarnationID("named"), "named", generation); err != nil {
412- t.Fatalf("LoadCheckpoint after collecting generation %d: %v", generation, err)
413- }
414- }
415-}
416-
417-func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) {
418- repo := NewRepository(privateDir(t))
419- for generation := uint64(1); generation <= 3; generation++ {
420- if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, []byte{byte(generation)})); err != nil {
421- t.Fatal(err)
422- }
423- }
424- key := legacyIncarnationID("named").String()
425- manifest := repositoryPublication(t, "named", 3, []byte{3})
426- if err := os.Remove(repo.legacyObjectPath(key, manifest.Objects[1].Digest)); err != nil {
427- t.Fatal(err)
428- }
429- if err := repo.Maintain(context.Background()); err != nil {
430- t.Fatal(err)
431- }
432- if _, err := os.Lstat(repo.legacyManifestPath(key, 3)); err != nil {
433- t.Fatalf("incomplete manifest removed: %v", err)
434- }
435- if _, err := os.Lstat(repo.legacyObjectPath(key, manifest.Objects[0].Digest)); err != nil {
436- t.Fatalf("object referenced by incomplete manifest removed: %v", err)
437- }
438-}
439-
440-func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.T) {
441- t.Run("publish", func(t *testing.T) {
442- repo := NewRepository(privateDir(t))
443- key := legacyIncarnationID("named").String()
444- lock := repo.lockSession(key)
445- ctx, cancel := context.WithCancel(context.Background())
446- reached := make(chan struct{})
447- release := make(chan struct{})
448- repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
449- done := make(chan error, 1)
450- go func() { done <- repo.Publish(ctx, repositoryPublication(t, "named", 1, []byte("state"))) }()
451- <-reached
452- cancel()
453- close(release)
454- repo.unlockSession(lock)
455- if err := <-done; !errors.Is(err, context.Canceled) {
456- t.Fatalf("Publish error = %v, want canceled", err)
457- }
458- if _, err := os.Lstat(repo.dir); !errors.Is(err, os.ErrNotExist) {
459- t.Fatalf("Publish mutated canceled repository: %v", err)
460- }
461- })
462- t.Run("load and delete", func(t *testing.T) {
463- repo := NewRepository(privateDir(t))
464- publication := repositoryPublication(t, "named", 1, []byte("state"))
465- if err := repo.Publish(context.Background(), publication); err != nil {
466- t.Fatal(err)
467- }
468- key := publication.IncarnationID.String()
469- for _, operation := range []struct {
470- name string
471- run func(context.Context) error
472- }{
473- {"load", func(ctx context.Context) error { _, err := loadPublication(ctx, repo, publication); return err }},
474- {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }},
475- } {
476- t.Run(operation.name, func(t *testing.T) {
477- lock := repo.lockSession(key)
478- ctx, cancel := context.WithCancel(context.Background())
479- reached := make(chan struct{})
480- release := make(chan struct{})
481- repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
482- done := make(chan error, 1)
483- go func() { done <- operation.run(ctx) }()
484- <-reached
485- cancel()
486- close(release)
487- repo.unlockSession(lock)
488- if err := <-done; !errors.Is(err, context.Canceled) {
489- t.Fatalf("%s error = %v, want canceled", operation.name, err)
490- }
491- repo.hooks.beforeSessionLock = nil
492- if _, err := loadPublication(context.Background(), repo, publication); err != nil {
493- t.Fatalf("%s mutated session: %v", operation.name, err)
494- }
495- })
496- }
497- })
498- t.Run("maintain", func(t *testing.T) {
499- repo := NewRepository(privateDir(t))
500- publication := repositoryPublication(t, "named", 1, []byte("state"))
501- if err := repo.Publish(context.Background(), publication); err != nil {
502- t.Fatal(err)
503- }
504- lock := repo.lockSession(publication.IncarnationID.String())
505- ctx, cancel := context.WithCancel(context.Background())
506- reached := make(chan struct{})
507- release := make(chan struct{})
508- repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
509- done := make(chan error, 1)
510- go func() { done <- repo.Maintain(ctx) }()
511- <-reached
512- cancel()
513- close(release)
514- repo.unlockSession(lock)
515- if err := <-done; !errors.Is(err, context.Canceled) {
516- t.Fatalf("Maintain error = %v, want canceled", err)
517- }
518- repo.hooks.beforeSessionLock = nil
519- if _, err := loadPublication(context.Background(), repo, publication); err != nil {
520- t.Fatalf("Maintain mutated session: %v", err)
521- }
522- })
523-}
524-
525-func waitAtSessionLock(reached chan<- struct{}, release <-chan struct{}) func(string) {
526- return func(string) {
527- close(reached)
528- <-release
529- }
530-}
531-
532-func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSession(t *testing.T) {
533- repo := NewRepository(privateDir(t))
534- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("old"))); err != nil {
535- t.Fatal(err)
536- }
537- injected := errors.New("sessions sync")
538- repo.hooks.syncDirectory = func(string) error { return injected }
539- if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
540- t.Fatalf("Delete error = %v, want sync failure", err)
541- }
542- repo.hooks.syncDirectory = nil
543- recreated := repositoryPublication(t, "named", 1, []byte("new"))
544- if err := repo.Publish(context.Background(), recreated); err != nil {
545- t.Fatal(err)
546- }
547- if err := repo.Delete(context.Background(), "named"); err != nil {
548- t.Fatalf("retry Delete: %v", err)
549- }
550- if _, err := loadPublication(context.Background(), repo, recreated); err != nil {
551- t.Fatalf("recreated session was deleted: %v", err)
552- }
553-}
554-
555-func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) {
556- repo := NewRepository(privateDir(t))
557- quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-named-test")
558- for i := range maintenanceBatch + 1 {
559- path := filepath.Join(quarantine, "nested", string(rune('a'+i)))
560- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
561- t.Fatal(err)
562- }
563- if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
564- t.Fatal(err)
565- }
566- }
567- if err := repo.Maintain(context.Background()); err != nil {
568- t.Fatal(err)
569- }
570- if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
571- t.Fatalf("quarantine after one bounded call = %v, want remaining tree", err)
572- }
573- for range 12 {
574- if err := repo.Maintain(context.Background()); err != nil {
575- t.Fatal(err)
576- }
577- if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
578- return
579- }
580- }
581- if _, err := os.Lstat(quarantine); !errors.Is(err, os.ErrNotExist) {
582- t.Fatalf("quarantine after resumed maintenance = %v, want removed", err)
583- }
584-}
585-
586-func TestRepositoryMaintainClosesContinuationDirectories(t *testing.T) {
587- repo := NewRepository(privateDir(t))
588- sessions := filepath.Join(repo.dir, repositorySessionsDir)
589- for i := range maintenanceBatch + 1 {
590- generations := filepath.Join(sessions, fmt.Sprintf("named-%03d", i), repositoryGenerations)
591- if err := os.MkdirAll(generations, 0o700); err != nil {
592- t.Fatal(err)
593- }
594- for j := range maintenanceBatch + 1 {
595- if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", j)), []byte("state"), 0o600); err != nil {
596- t.Fatal(err)
597- }
598- }
599- }
600- before := openDescriptorCount(t)
601- if err := repo.Maintain(context.Background()); err != nil {
602- t.Fatal(err)
603- }
604- if got := openDescriptorCount(t); got > before+4 {
605- t.Fatalf("open descriptors after bounded maintenance = %d, want at most %d", got, before+4)
606- }
607-}
608-
609-func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) {
610- repo := NewRepository(privateDir(t))
611- key := legacyIncarnationID("named").String()
612- root := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
613- for i := range maintenanceBatch * 2 {
614- if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil {
615- t.Fatal(err)
616- }
617- }
618- repo.maintenanceCursors = make(map[string]*maintenanceCursor)
619- state := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance), markDone: true}
620- for range maintenanceBatch + 2 {
621- budget := maintenanceBatch
622- if err := repo.sweepSession(context.Background(), key, state, &budget); err != nil {
623- t.Fatal(err)
624- }
625- if state.sweepShard != "" && len(repo.maintenanceCursors) > maxMaintenanceCursors {
626- t.Fatalf("retained maintenance cursors = %d, want at most %d", len(repo.maintenanceCursors), maxMaintenanceCursors)
627- }
628- }
629-}
630-
631-func TestRepositoryMaintainResumesCursorAfterDirectoryMutation(t *testing.T) {
632- repo := NewRepository(privateDir(t))
633- if err := os.Mkdir(repo.dir, 0o700); err != nil {
634- t.Fatal(err)
635- }
636- for i := range maintenanceBatch + 1 {
637- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("live-%03d", i)), []byte("live"), 0o600); err != nil {
638- t.Fatal(err)
639- }
640- }
641- if err := repo.Maintain(context.Background()); err != nil {
642- t.Fatal(err)
643- }
644- // Mutating entries ahead of the saved cookie must not reset traversal to
645- // the prefix or lose a stale entry appended after that cookie.
646- if err := os.Remove(filepath.Join(repo.dir, "live-000")); err != nil {
647- t.Fatal(err)
648- }
649- stale := filepath.Join(repo.dir, ".tmp-appended")
650- if err := os.WriteFile(stale, []byte("stale"), 0o600); err != nil {
651- t.Fatal(err)
652- }
653- for range 4 {
654- if err := repo.Maintain(context.Background()); err != nil {
655- t.Fatal(err)
656- }
657- if _, err := os.Lstat(stale); errors.Is(err, os.ErrNotExist) {
658- return
659- }
660- }
661- t.Fatal("stale entry appended after cursor mutation was not eventually removed")
662-}
663-
664-func TestRepositoryMaintainDoesNotStarveLaterTemporaryEntries(t *testing.T) {
665- repo := NewRepository(privateDir(t))
666- if err := os.Mkdir(repo.dir, 0o700); err != nil {
667- t.Fatal(err)
668- }
669- // The live entries precede the stale entries in directory insertion order.
670- // A fresh ReadDir from the start on every call would never reach the latter.
671- for i := range maintenanceBatch + 1 {
672- if err := os.WriteFile(filepath.Join(repo.dir, "live-"+string(rune('a'+i))), []byte("live"), 0o600); err != nil {
673- t.Fatal(err)
674- }
675- }
676- for i := range maintenanceBatch + 1 {
677- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-later-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil {
678- t.Fatal(err)
679- }
680- }
681- for range 4 {
682- if err := repo.Maintain(context.Background()); err != nil {
683- t.Fatal(err)
684- }
685- }
686- entries, err := os.ReadDir(repo.dir)
687- if err != nil {
688- t.Fatal(err)
689- }
690- for _, entry := range entries {
691- if strings.HasPrefix(entry.Name(), ".tmp-") {
692- t.Fatalf("stale entry %q was starved", entry.Name())
693- }
694- }
695-}
696-
697-func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
698- repo := NewRepository(privateDir(t))
699- var newest ports.SnapshotPublication
700- for generation := uint64(1); generation <= maintenanceBatch+2; generation++ {
701- publication := repositoryPublicationAfter(t, repo, "named", generation, fmt.Appendf(nil, "state-%d", generation))
702- if err := repo.Publish(context.Background(), publication); err != nil {
703- t.Fatal(err)
704- }
705- newest = publication
706- }
707- key := legacyIncarnationID("named").String()
708- stale := sha256.Sum256([]byte("stale object"))
709- stalePath := repo.legacyObjectPath(key, stale)
710- if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
711- t.Fatal(err)
712- }
713- if err := os.WriteFile(stalePath, []byte("stale object"), 0o600); err != nil {
714- t.Fatal(err)
715- }
716-
717- // The first call has classified only one manifest batch. It must not sweep
718- // even an otherwise stale object, nor a blob owned by a later manifest.
719- if err := repo.Maintain(context.Background()); err != nil {
720- t.Fatal(err)
721- }
722- if _, err := os.Lstat(stalePath); err != nil {
723- t.Fatalf("stale object swept before complete mark pass: %v", err)
724- }
725- for _, object := range newest.Objects {
726- if _, err := os.Lstat(repo.legacyObjectPath(key, object.Digest)); err != nil {
727- t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err)
728- }
729- }
730-
731- for i := range 600 {
732- if err := repo.Maintain(context.Background()); err != nil {
733- t.Fatal(err)
734- }
735- if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) {
736- break
737- }
738- if i == 599 {
739- t.Fatal("stale object was not eventually swept")
740- }
741- }
742- for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} {
743- if _, err := os.Lstat(repo.legacyManifestPath(key, generation)); err != nil {
744- t.Fatalf("newest complete manifest %d removed: %v", generation, err)
745- }
746- }
747-}
748-
749-func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) {
750- repo := NewRepository(privateDir(t))
751- if err := os.Mkdir(repo.dir, 0o700); err != nil {
752- t.Fatal(err)
753- }
754- for i := range maintenanceBatch + 1 {
755- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil {
756- t.Fatal(err)
757- }
758- }
759- if err := repo.Maintain(context.Background()); err != nil {
760- t.Fatal(err)
761- }
762- entries, err := os.ReadDir(repo.dir)
763- if err != nil {
764- t.Fatal(err)
765- }
766- if len(entries) != 1 {
767- t.Fatalf("remaining stale entries = %d, want 1 after one batch", len(entries))
768- }
769-}
770-
771-func TestRepositoryMaintainQueuesFetchedSessionsPastWorkBudget(t *testing.T) {
772- repo := NewRepository(privateDir(t))
773- if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil {
774- t.Fatal(err)
775- }
776- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-consume-budget"), []byte("stale"), 0o600); err != nil {
777- t.Fatal(err)
778- }
779-
780- removed := make(map[string]int)
781- for i := range maintenanceBatch + 1 {
782- path := filepath.Join(repo.dir, repositorySessionsDir, fmt.Sprintf(".deleting-%03d", i))
783- if err := os.Mkdir(path, 0o700); err != nil {
784- t.Fatal(err)
785- }
786- }
787- repo.hooks.remove = func(path string) error {
788- if strings.HasPrefix(filepath.Base(path), ".deleting-") {
789- removed[path]++
790- }
791- return nil
792- }
793-
794- sessions := filepath.Join(repo.dir, repositorySessionsDir)
795- f, err := os.Open(sessions)
796- if err != nil {
797- t.Fatal(err)
798- }
799- fetched, err := f.ReadDir(maintenanceBatch)
800- if err != nil {
801- t.Fatal(err)
802- }
803- unread, err := f.ReadDir(1)
804- if err != nil {
805- t.Fatal(err)
806- }
807- if err := f.Close(); err != nil {
808- t.Fatal(err)
809- }
810- queued := fetched[len(fetched)-1].Name()
811- for i := range maintenanceBatch + 4 {
812- if err := repo.Maintain(context.Background()); err != nil {
813- t.Fatal(err)
814- }
815- if _, err := os.Lstat(filepath.Join(sessions, queued)); errors.Is(err, os.ErrNotExist) {
816- break
817- } else if i == maintenanceBatch+3 {
818- t.Fatalf("fetched but unprocessed session %q = %v, want eventually removed", queued, err)
819- }
820- }
821- if _, err := os.Lstat(filepath.Join(sessions, unread[0].Name())); err != nil {
822- t.Fatalf("unread session %q = %v, want queued entry to run first", unread[0].Name(), err)
823- }
824-
825- for range maintenanceBatch + 4 {
826- if err := repo.Maintain(context.Background()); err != nil {
827- t.Fatal(err)
828- }
829- entries, err := os.ReadDir(sessions)
830- if err != nil {
831- t.Fatal(err)
832- }
833- if len(entries) == 0 {
834- break
835- }
836- }
837- entries, err := os.ReadDir(sessions)
838- if err != nil {
839- t.Fatal(err)
840- }
841- if len(entries) != 0 {
842- t.Fatalf("queued sessions after resumed maintenance = %d, want none", len(entries))
843- }
844- if len(removed) != maintenanceBatch+1 {
845- t.Fatalf("removed sessions = %d, want %d", len(removed), maintenanceBatch+1)
846- }
847- for path, calls := range removed {
848- if calls != 1 {
849- t.Fatalf("remove calls for %q = %d, want one", path, calls)
850- }
851- }
852-}
853-
854-func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testing.T) {
855- for _, head := range []struct {
856- name string
857- set func(t *testing.T, repo *Repository, key string)
858- }{
859- {
860- name: "missing",
861- set: func(t *testing.T, repo *Repository, key string) {
862- t.Helper()
863- if err := os.Remove(repo.legacyHeadPath(key)); err != nil {
864- t.Fatal(err)
865- }
866- },
867- },
868- {
869- name: "corrupt",
870- set: func(t *testing.T, repo *Repository, key string) {
871- t.Helper()
872- if err := os.WriteFile(repo.legacyHeadPath(key), []byte("corrupt"), 0o600); err != nil {
873- t.Fatal(err)
874- }
875- },
876- },
877- } {
878- t.Run(head.name, func(t *testing.T) {
879- repo := NewRepository(privateDir(t))
880- key := legacyIncarnationID("named").String()
881- publications := make([]ports.SnapshotPublication, 0, maintenanceBatch+2)
882- for generation := uint64(1); generation <= maintenanceBatch+2; generation++ {
883- publication := repositoryPublicationAfter(t, repo, "named", generation, fmt.Appendf(nil, "state-%d", generation))
884- if err := repo.Publish(context.Background(), publication); err != nil {
885- t.Fatal(err)
886- }
887- publications = append(publications, publication)
888- }
889- head.set(t, repo, key)
890- stale := sha256.Sum256([]byte("unpublished stale object"))
891- stalePath := repo.legacyObjectPath(key, stale)
892- if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
893- t.Fatal(err)
894- }
895- if err := os.WriteFile(stalePath, []byte("unpublished stale object"), 0o600); err != nil {
896- t.Fatal(err)
897- }
898-
899- if err := repo.Maintain(context.Background()); err != nil {
900- t.Fatal(err)
901- }
902- if _, err := os.Lstat(stalePath); err != nil {
903- t.Fatalf("stale object swept before full manifest classification: %v", err)
904- }
905- for _, publication := range publications {
906- if _, err := os.Lstat(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
907- t.Fatalf("object for unpublished generation %d removed early: %v", publication.Generation, err)
908- }
909- }
910-
911- for i := range 600 {
912- if err := repo.Maintain(context.Background()); err != nil {
913- t.Fatal(err)
914- }
915- if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) {
916- break
917- } else if i == 599 {
918- t.Fatal("stale object was not swept after complete classification")
919- }
920- }
921- for _, publication := range publications {
922- if _, err := os.Lstat(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
923- t.Fatalf("object for unpublished generation %d removed: %v", publication.Generation, err)
924- }
925- }
926- })
927- }
928-}
929-
930-func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
931- repo := NewRepository(privateDir(t))
932- key := legacyIncarnationID("named").String()
933- first := publicationWithTailShard(t, "named", 1, "ff")
934- stalePaths := make([]string, 0, maintenanceBatch+1)
935- for i := range maintenanceBatch + 1 {
936- digest := digestInShard(t, "00", i)
937- path := repo.legacyObjectPath(key, digest)
938- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
939- t.Fatal(err)
940- }
941- if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
942- t.Fatal(err)
943- }
944- stalePaths = append(stalePaths, path)
945- }
946- if err := repo.Publish(context.Background(), first); err != nil {
947- t.Fatal(err)
948- }
949-
950- // This completes marking but exhausts the sweep budget in the stale shard,
951- // leaving the next sweep batch pending.
952- if err := repo.Maintain(context.Background()); err != nil {
953- t.Fatal(err)
954- }
955- staleRemaining := false
956- for _, path := range stalePaths {
957- if _, err := os.Lstat(path); err == nil {
958- staleRemaining = true
959- break
960- }
961- }
962- if !staleRemaining {
963- t.Fatal("sweep did not leave a pending stale object")
964- }
965-
966- second := publicationWithCurrentParent(t, repo, publicationWithTailShard(t, "named", 2, "ff"))
967- injected := errors.New("before HEAD")
968- repo.hooks.beforeHeadWrite = func(string) error { return injected }
969- if err := repo.Publish(context.Background(), second); !errors.Is(err, injected) {
970- t.Fatalf("Publish error = %v, want injected failure", err)
971- }
972- repo.hooks.beforeHeadWrite = nil
973-
974- for i := range 20 {
975- if err := repo.Maintain(context.Background()); err != nil {
976- t.Fatal(err)
977- }
978- for _, object := range second.Objects {
979- if _, err := os.Lstat(repo.legacyObjectPath(key, object.Digest)); err != nil {
980- t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)
981- }
982- }
983- staleRemaining = false
984- for _, path := range stalePaths {
985- if _, err := os.Lstat(path); err == nil {
986- staleRemaining = true
987- break
988- }
989- }
990- }
991- if staleRemaining {
992- t.Fatal("stale object was not eventually swept")
993- }
994-}
995-
996-func publicationWithTailShard(t *testing.T, name string, generation uint64, shard string) ports.SnapshotPublication {
997- t.Helper()
998- for i := range 4096 {
999- publication := repositoryPublication(t, name, generation, fmt.Appendf(nil, "state-%d-%d", generation, i))
1000- if fmt.Sprintf("%02x", publication.Objects[0].Digest[0]) == shard {
1001- return publication
1002- }
1003- }
1004- t.Fatalf("did not find publication tail in shard %q", shard)
1005- return ports.SnapshotPublication{}
1006-}
1007-
1008-func digestInShard(t *testing.T, shard string, n int) ports.SnapshotDigest {
1009- t.Helper()
1010- for i := range 4096 {
1011- digest := sha256.Sum256(fmt.Appendf(nil, "stale-%d-%d", n, i))
1012- if fmt.Sprintf("%02x", digest[0]) == shard {
1013- return digest
1014- }
1015- }
1016- t.Fatalf("did not find stale digest in shard %q", shard)
1017- return ports.SnapshotDigest{}
1018-}
1019-
1020-func TestRetentionSlots(t *testing.T) {
1021- for _, tc := range []struct {
1022- name string
1023- refs []uint64
1024- want []uint64
1025- }{
1026- {name: "first checkpoint", refs: []uint64{1}, want: []uint64{1}},
1027- {name: "second checkpoint", refs: []uint64{2, 1}, want: []uint64{2, 1}},
1028- {name: "committed and two predecessors", refs: []uint64{4, 3, 2}, want: []uint64{4, 3, 2}},
1029- {name: "fallback hole", refs: []uint64{4, 2}, want: []uint64{4, 2}},
1030- } {
1031- t.Run(tc.name, func(t *testing.T) {
1032- repo := NewRepository(privateDir(t))
1033- publications := publishMaintenanceGenerations(t, repo, "work", 4)
1034- plan := ports.RetentionPlan{IncarnationID: publications[0].IncarnationID}
1035- for _, generation := range tc.refs {
1036- plan.Keep = append(plan.Keep, checkpointRefForPublication(publications[generation-1]))
1037- }
1038- done, err := repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1039- if err != nil {
1040- t.Fatal(err)
1041- }
1042- if !done {
1043- t.Fatal("MaintainSession() done = false with sufficient budget")
1044- }
1045- if got := remainingMaintenanceGenerations(t, repo, plan.IncarnationID); !slices.Equal(got, tc.want) {
1046- t.Fatalf("remaining generations = %v, want %v", got, tc.want)
1047- }
1048- })
1049- }
1050-}
1051-
1052-func TestUnresolvedDataPinned(t *testing.T) {
1053- repo := NewRepository(privateDir(t))
1054- publications := publishMaintenanceGenerations(t, repo, "unresolved", 4)
1055- done, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: publications[0].IncarnationID, PinAll: true}, ports.MaintenanceBudget{})
1056- if err != nil {
1057- t.Fatal(err)
1058- }
1059- if !done {
1060- t.Fatal("pinned maintenance did not complete without traversing")
1061- }
1062- if got := remainingMaintenanceGenerations(t, repo, publications[0].IncarnationID); !slices.Equal(got, []uint64{4, 3, 2, 1}) {
1063- t.Fatalf("pinned generations = %v", got)
1064- }
1065-}
1066-
1067-func TestPinAllDoesNotWaitForSameSessionRetention(t *testing.T) {
1068- repo := NewRepository(privateDir(t))
1069- publications := publishMaintenanceGenerations(t, repo, "pin-active", 2)
1070- publication := publications[0]
1071- plan := ports.RetentionPlan{IncarnationID: publication.IncarnationID, Keep: []ports.CheckpointRef{checkpointRefForPublication(publication)}}
1072-
1073- entered := make(chan struct{})
1074- release := make(chan struct{})
1075- var enteredOnce sync.Once
1076- repo.hooks.beforeMaintenancePayloadRead = func(string) {
1077- enteredOnce.Do(func() { close(entered) })
1078- <-release
1079- }
1080- activeDone := make(chan error, 1)
1081- go func() {
1082- _, err := repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1083- activeDone <- err
1084- }()
1085- <-entered
1086-
1087- pinDone := make(chan error, 1)
1088- go func() {
1089- _, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: publication.IncarnationID, PinAll: true}, ports.MaintenanceBudget{})
1090- pinDone <- err
1091- }()
1092- select {
1093- case err := <-pinDone:
1094- if err != nil {
1095- t.Fatal(err)
1096- }
1097- case <-time.After(remainingTestTime(t) / 2):
1098- close(release)
1099- t.Fatal("PinAll waited for active retention on the same incarnation")
1100- }
1101- close(release)
1102- if err := <-activeDone; err != nil {
1103- t.Fatal(err)
1104- }
1105- if got := remainingMaintenanceGenerations(t, repo, publication.IncarnationID); !slices.Equal(got, []uint64{2, 1}) {
1106- t.Fatalf("PinAll allowed active retention to delete generations: %v", got)
1107- }
1108-}
1109-
1110-func TestMaintainSessionRejectsZeroBudget(t *testing.T) {
1111- repo := NewRepository(privateDir(t))
1112- id := domain.IncarnationID{1}
1113- done, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: id}, ports.MaintenanceBudget{})
1114- if done || !errors.Is(err, ErrMaintenanceBudgetTooSmall) {
1115- t.Fatalf("zero budget: done=%v err=%v", done, err)
1116- }
1117-}
1118-
1119-func TestProcessRetentionEntriesSyncsRemovalBatchOnce(t *testing.T) {
1120- repo := NewRepository(privateDir(t))
1121- dir := filepath.Join(repo.dir, "batch")
1122- if err := safedir.EnsurePrivate(dir); err != nil {
1123- t.Fatal(err)
1124- }
1125- entries := []maintenanceDirEntry{{name: generationFilename(1), cookie: 1}, {name: generationFilename(2), cookie: 2}}
1126- for _, entry := range entries {
1127- if err := os.WriteFile(filepath.Join(dir, entry.name), []byte("x"), 0o600); err != nil {
1128- t.Fatal(err)
1129- }
1130- }
1131- syncs := 0
1132- repo.hooks.syncDirectory = func(got string) error {
1133- if got == dir {
1134- syncs++
1135- }
1136- return nil
1137- }
1138- state := &retentionMaintenance{cursors: make(map[string]*maintenanceCursor)}
1139- budget := ports.MaintenanceBudget{Entries: 2, Bytes: 2}
1140- admitted, err := repo.processRetentionEntries(context.Background(), state, dir, "batch", entries, &budget, false, retentionEntryPolicy{kind: retentionManifest})
1141- if err != nil || !admitted {
1142- t.Fatalf("batch processing: admitted=%v err=%v", admitted, err)
1143- }
1144- if syncs != 1 {
1145- t.Fatalf("directory syncs = %d, want 1", syncs)
1146- }
1147-}
1148-
1149-func TestRequeueRetentionEntriesDoesNotAliasInput(t *testing.T) {
1150- repo := NewRepository(privateDir(t))
1151- state := &retentionMaintenance{cursors: make(map[string]*maintenanceCursor)}
1152- entries := []maintenanceDirEntry{{name: "one", cookie: 1}}
1153- repo.requeueRetentionEntries(state, "/dir", "purpose", entries)
1154- entries[0].name = "mutated"
1155- pending := state.cursors[maintenanceCursorID("/dir", "purpose")].pending
1156- if pending[0].name != "one" {
1157- t.Fatalf("queued entry aliased caller input: %q", pending[0].name)
1158- }
1159-}
1160-
1161-func TestMaintenanceBudgetAdmitsPayloadsBeforeRead(t *testing.T) {
1162- repo := NewRepository(privateDir(t))
1163- publications := publishMaintenanceGenerations(t, repo, "budget", 1)
1164- plan := ports.RetentionPlan{IncarnationID: publications[0].IncarnationID, Keep: []ports.CheckpointRef{checkpointRefForPublication(publications[0])}}
1165-
1166- var reads []string
1167- repo.hooks.beforeMaintenancePayloadRead = func(path string) { reads = append(reads, path) }
1168- done, err := repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 1, Bytes: 1})
1169- if !errors.Is(err, ErrMaintenanceBudgetTooSmall) {
1170- t.Fatalf("undersized manifest budget error = %v, want ErrMaintenanceBudgetTooSmall", err)
1171- }
1172- if done || len(reads) != 0 {
1173- t.Fatalf("undersized manifest budget: done=%v payload reads=%v", done, reads)
1174- }
1175-
1176- manifestPath := repo.manifestPath(plan.IncarnationID, 1)
1177- info, err := repo.stat(manifestPath)
1178- if err != nil {
1179- t.Fatal(err)
1180- }
1181- reads = nil
1182- done, err = repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 1, Bytes: uint64(info.Size())})
1183- if !errors.Is(err, ErrMaintenanceBudgetTooSmall) {
1184- t.Fatalf("manifest-only budget error = %v, want ErrMaintenanceBudgetTooSmall", err)
1185- }
1186- if done {
1187- t.Fatal("manifest-only budget unexpectedly admitted the generation objects")
1188- }
1189- if len(reads) != 1 || reads[0] != manifestPath {
1190- t.Fatalf("payload reads = %v, want only admitted manifest %q", reads, manifestPath)
1191- }
1192-}
1193-
1194-func TestMaintainSessionDistinguishesInitialOversizeFromResumableExhaustion(t *testing.T) {
1195- repo := NewRepository(privateDir(t))
1196- publications := publishMaintenanceGenerations(t, repo, "budget-semantics", 2)
1197- plan := ports.RetentionPlan{IncarnationID: publications[0].IncarnationID}
1198- firstInfo, err := os.Stat(repo.manifestPath(plan.IncarnationID, 1))
1199- if err != nil {
1200- t.Fatal(err)
1201- }
1202- secondInfo, err := os.Stat(repo.manifestPath(plan.IncarnationID, 2))
1203- if err != nil {
1204- t.Fatal(err)
1205- }
1206- firstSize, secondSize := uint64(firstInfo.Size()), uint64(secondInfo.Size())
1207-
1208- done, err := repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 2, Bytes: min(firstSize, secondSize) - 1})
1209- if done || !errors.Is(err, ErrMaintenanceBudgetTooSmall) {
1210- t.Fatalf("initial oversized item: done=%v err=%v", done, err)
1211- }
1212-
1213- done, err = repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 2, Bytes: firstSize + secondSize - 1})
1214- if err != nil || done {
1215- t.Fatalf("mid-pass exhaustion must be resumable: done=%v err=%v", done, err)
1216- }
1217- done, err = repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1218- if err != nil || !done {
1219- t.Fatalf("resumed maintenance: done=%v err=%v", done, err)
1220- }
1221-}
1222-
1223-func TestMaintainSessionYieldsOnEmptyNonDoneObjectShardBatch(t *testing.T) {
1224- repo := NewRepository(privateDir(t))
1225- publication := publishMaintenanceGenerations(t, repo, "empty-shard-batch", 1)[0]
1226- plan := ports.RetentionPlan{IncarnationID: publication.IncarnationID}
1227- key := publication.IncarnationID.String()
1228- token := retentionToken(plan)
1229- root := filepath.Join(repo.sessionPath(plan.IncarnationID), repositoryObjectsDir)
1230- purpose := "retention-shards:" + key + ":" + token
1231- state := &retentionMaintenance{
1232- token: token,
1233- cursors: map[string]*maintenanceCursor{maintenanceCursorID(root, purpose): {pending: []maintenanceDirEntry{}}},
1234- references: make(map[ports.SnapshotDigest]struct{}),
1235- validated: true,
1236- manifestsDone: true,
1237- }
1238- repo.retentionSessions[key] = state
1239-
1240- done, err := repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 1, Bytes: 1024})
1241- if err != nil || done {
1242- t.Fatalf("empty non-done shard batch: done=%v err=%v", done, err)
1243- }
1244- if state.objectRootDone {
1245- t.Fatal("empty non-done shard batch advanced object-root completion")
1246- }
1247-}
1248-
1249-func TestMaintainSessionDoesNotHoldMaintenanceLockDuringPayloadIO(t *testing.T) {
1250- repo := NewRepository(privateDir(t))
1251- active := publishMaintenanceGenerations(t, repo, "active-lock", 1)
1252- other := publishMaintenanceGenerations(t, repo, "other-lock", 1)
1253- plan := ports.RetentionPlan{IncarnationID: active[0].IncarnationID, Keep: []ports.CheckpointRef{checkpointRefForPublication(active[0])}}
1254-
1255- entered := make(chan struct{})
1256- release := make(chan struct{})
1257- var once sync.Once
1258- repo.hooks.beforeMaintenancePayloadRead = func(string) {
1259- once.Do(func() { close(entered) })
1260- <-release
1261- }
1262- activeDone := make(chan error, 1)
1263- go func() {
1264- _, err := repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1265- activeDone <- err
1266- }()
1267- <-entered
1268-
1269- pinDone := make(chan error, 1)
1270- go func() {
1271- _, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: other[0].IncarnationID, PinAll: true}, ports.MaintenanceBudget{Entries: 1, Bytes: 1})
1272- pinDone <- err
1273- }()
1274- select {
1275- case err := <-pinDone:
1276- if err != nil {
1277- t.Fatal(err)
1278- }
1279- case <-time.After(remainingTestTime(t) / 2):
1280- close(release)
1281- t.Fatal("unrelated PinAll was blocked by retention payload I/O")
1282- }
1283- close(release)
1284- if err := <-activeDone; err != nil {
1285- t.Fatal(err)
1286- }
1287-}
1288-
1289-func remainingTestTime(t *testing.T) time.Duration {
1290- t.Helper()
1291- if deadline, ok := t.Deadline(); ok {
1292- return time.Until(deadline)
1293- }
1294- return 30 * time.Second
1295-}
1296-
1297-func TestPerSessionBudgetIsolation(t *testing.T) {
1298- repo := NewRepository(privateDir(t))
1299- large := publishMaintenanceGenerations(t, repo, "large", 4)
1300- small := publishMaintenanceGenerations(t, repo, "small", 2)
1301- largePlan := ports.RetentionPlan{IncarnationID: large[0].IncarnationID, Keep: []ports.CheckpointRef{checkpointRefForPublication(large[3])}}
1302- smallPlan := ports.RetentionPlan{IncarnationID: small[0].IncarnationID, Keep: []ports.CheckpointRef{checkpointRefForPublication(small[1])}}
1303-
1304- done, err := repo.MaintainSession(context.Background(), largePlan, ports.MaintenanceBudget{Entries: 1, Bytes: 1})
1305- if !errors.Is(err, ErrMaintenanceBudgetTooSmall) {
1306- t.Fatalf("large session error = %v, want ErrMaintenanceBudgetTooSmall", err)
1307- }
1308- if done {
1309- t.Fatal("large session unexpectedly exhausted no budget")
1310- }
1311- done, err = repo.MaintainSession(context.Background(), smallPlan, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1312- if err != nil {
1313- t.Fatal(err)
1314- }
1315- if !done {
1316- t.Fatal("small session did not progress after unrelated exhaustion")
1317- }
1318- if got := remainingMaintenanceGenerations(t, repo, small[0].IncarnationID); !slices.Equal(got, []uint64{2}) {
1319- t.Fatalf("small generations = %v, want [2]", got)
1320- }
1321-}
1322-
1323-func publishMaintenanceGenerations(t *testing.T, repo *Repository, name string, count uint64) []ports.SnapshotPublication {
1324- t.Helper()
1325- publications := make([]ports.SnapshotPublication, 0, count)
1326- for generation := uint64(1); generation <= count; generation++ {
1327- publication := repositoryPublicationAfter(t, repo, name, generation, []byte{byte(generation)})
1328- if err := repo.Publish(context.Background(), publication); err != nil {
1329- t.Fatal(err)
1330- }
1331- publications = append(publications, publication)
1332- }
1333- return publications
1334-}
1335-
1336-func checkpointRefForPublication(publication ports.SnapshotPublication) domain.CheckpointRef {
1337- return domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}
1338-}
1339-
1340-func remainingMaintenanceGenerations(t *testing.T, repo *Repository, id domain.IncarnationID) []uint64 {
1341- t.Helper()
1342- entries, err := os.ReadDir(filepath.Join(repo.sessionPath(id), repositoryGenerations))
1343- if err != nil {
1344- t.Fatal(err)
1345- }
1346- generations := make([]uint64, 0, len(entries))
1347- for _, entry := range entries {
1348- if generation, ok := parseGenerationFilename(entry.Name()); ok {
1349- generations = append(generations, generation)
1350- }
1351- }
1352- slices.SortFunc(generations, func(a, b uint64) int { return cmp.Compare(b, a) })
1353- return generations
1354-}
M internal/adapters/snapshot/path_security_test.go +9 -4
@@ -8,6 +8,9 @@ import (
88 "strings"
99 "syscall"
1010 "testing"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ codec "github.com/bnema/vev/internal/usecase/snapshot"
1114 )
1215
1316 func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
@@ -41,8 +44,9 @@ func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
4144 if _, err := loadPublication(context.Background(), repo, publication); err == nil {
4245 t.Fatal("LoadCheckpoint succeeded through replaced configured-root symlink")
4346 }
44- if err := repo.Maintain(context.Background()); err == nil {
45- t.Fatal("Maintain succeeded through replaced configured-root symlink")
47+ keep := map[domain.IncarnationID]domain.CheckpointRef{publication.IncarnationID: {Generation: publication.Generation, ManifestDigest: codec.ManifestDigest(publication.Manifest)}}
48+ if err := repo.CollectGarbage(context.Background(), keep); err == nil {
49+ t.Fatal("CollectGarbage succeeded through replaced configured-root symlink")
4650 }
4751 loaded, err := loadPublication(context.Background(), external, externalPublication)
4852 if err != nil {
@@ -168,8 +172,9 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
168172 if _, err := loadPublication(context.Background(), repo, publication); err == nil {
169173 t.Fatal("LoadCheckpoint succeeded through symlinked repository component")
170174 }
171- if err := repo.Maintain(context.Background()); err == nil {
172- t.Fatal("Maintain succeeded through symlinked repository component")
175+ keep := map[domain.IncarnationID]domain.CheckpointRef{publication.IncarnationID: {Generation: publication.Generation, ManifestDigest: codec.ManifestDigest(publication.Manifest)}}
176+ if err := repo.CollectGarbage(context.Background(), keep); err == nil {
177+ t.Fatal("CollectGarbage succeeded through symlinked repository component")
173178 }
174179 got, err := os.ReadFile(guard)
175180 if err != nil || string(got) != "guard" {
D internal/adapters/snapshot/recovery_observability_test.go +0 -81
@@ -1,81 +0,0 @@
1-package snapshot
2-
3-import (
4- "bufio"
5- "bytes"
6- "context"
7- "encoding/json"
8- "log/slog"
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-
16-func TestGCBudgetDiagnostic(t *testing.T) {
17- for _, tc := range []struct {
18- name string
19- arrange func(*testing.T, *Repository) ports.RetentionPlan
20- budget ports.MaintenanceBudget
21- wantDone bool
22- wantErr error
23- wantExhausted bool
24- wantConsumption bool
25- }{
26- {
27- name: "exhausted",
28- arrange: func(_ *testing.T, _ *Repository) ports.RetentionPlan {
29- return ports.RetentionPlan{IncarnationID: domain.IncarnationID{1}, Keep: []ports.CheckpointRef{{Generation: 3}, {Generation: 2}, {Generation: 1}}}
30- },
31- wantErr: ErrMaintenanceBudgetTooSmall,
32- },
33- {
34- name: "sufficient",
35- arrange: func(t *testing.T, repository *Repository) ports.RetentionPlan {
36- publication := publishMaintenanceGenerations(t, repository, "diagnostic", 1)[0]
37- return ports.RetentionPlan{IncarnationID: publication.IncarnationID}
38- },
39- budget: ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20},
40- wantDone: true,
41- wantConsumption: true,
42- },
43- } {
44- t.Run(tc.name, func(t *testing.T) {
45- var buffer bytes.Buffer
46- log := slog.New(slog.NewJSONHandler(&buffer, nil))
47- repository := NewRepository(privateDir(t), log)
48- plan := tc.arrange(t, repository)
49- buffer.Reset()
50-
51- done, err := repository.MaintainSession(context.Background(), plan, tc.budget)
52- if tc.wantErr == nil {
53- require.NoError(t, err)
54- } else {
55- require.ErrorIs(t, err, tc.wantErr)
56- }
57- require.Equal(t, tc.wantDone, done)
58-
59- scanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes()))
60- require.True(t, scanner.Scan())
61- var entry map[string]any
62- require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
63- require.NoError(t, scanner.Err())
64- require.Equal(t, "snapshot_maintenance_progress", entry["msg"])
65- require.Equal(t, plan.IncarnationID.String(), entry["incarnation"])
66- require.Contains(t, entry, "cursor")
67- require.Equal(t, tc.wantExhausted, entry["budget_exhausted"])
68- consumedEntries, entriesOK := entry["consumed_entries"].(float64)
69- consumedBytes, bytesOK := entry["consumed_bytes"].(float64)
70- require.True(t, entriesOK)
71- require.True(t, bytesOK)
72- if tc.wantConsumption {
73- require.Positive(t, consumedEntries)
74- require.Positive(t, consumedBytes)
75- } else {
76- require.Zero(t, consumedEntries)
77- require.Zero(t, consumedBytes)
78- }
79- })
80- }
81-}
M internal/adapters/snapshot/repository.go +21 -44
@@ -29,15 +29,6 @@ type Repository struct {
2929 locks map[string]*sessionMutex
3030 storageEpochs map[string]uint64
3131 nextEpoch uint64
32-
33- // maintenanceMu owns bounded continuation metadata: seek cookies and
34- // pending directory entries. Directory descriptors are opened and closed
35- // for each maintenance call.
36- maintenanceMu sync.Mutex
37- maintenanceCursors map[string]*maintenanceCursor
38- maintenanceSessions map[string]*sessionMaintenance
39- retentionSessions map[string]*retentionMaintenance
40- maintenanceQuarantine *quarantineMaintenance
4132 }
4233
4334 // repositoryHooks makes each persistence boundary fault-injectable. Hooks run
@@ -50,38 +41,27 @@ type repositoryHooks struct {
5041 // the steady-state cost of retained history observable without exposing a
5142 // production metrics surface.
5243 beforeObjectRead func(string)
53- // beforeMaintenancePayloadRead instruments payload reads after maintenance
54- // has admitted their stat-reported size against the current byte budget.
55- beforeMaintenancePayloadRead func(string)
5644 // beforePayloadRead observes every bounded repository payload read. It is
5745 // test-only accounting instrumentation and runs immediately before the read.
58- beforePayloadRead func(string)
59- beforeObjectHash func([]byte)
60- beforeObjectCopy func([]byte)
61- beforeHeadRead func(string) error
62- beforeHeadWrite func(string) error
63- beforeTombstoneCheck func(string)
64- beforePendingQuarantineCheck func(string)
65- createTemp func(string) error
66- writeTemp func(string) error
67- syncFile func(string) error
68- closeFile func(string) error
69- installImmutable func(string) error
70- rename func(string) error
71- syncDirectory func(string) error
72- remove func(string) error
46+ beforePayloadRead func(string)
47+ beforeObjectHash func([]byte)
48+ beforeObjectCopy func([]byte)
49+ beforeHeadRead func(string) error
50+ beforeHeadWrite func(string) error
51+ createTemp func(string) error
52+ writeTemp func(string) error
53+ syncFile func(string) error
54+ closeFile func(string) error
55+ installImmutable func(string) error
56+ rename func(string) error
57+ syncDirectory func(string) error
58+ remove func(string) error
7359 // afterOpenRoot and closeRoot make descriptor race and close-error paths
7460 // deterministic in package tests. closeRoot never replaces the real close.
75- afterOpenRoot func()
76- closeRoot func() error
77- openMaintenanceDirectory func(string) (maintenanceDirectory, error)
78- maintenanceDirentConfig *maintenanceDirentConfig
79- beforeDirectoryRead func(string)
80- beforeDeletionTombstoneRead func(string)
81- // beforeMaintenanceWork observes each budgeted quarantine filesystem step.
82- // It is test-only instrumentation for hostile traversal bounds.
83- beforeMaintenanceWork func(string)
84- beforeSessionLock func(string)
61+ afterOpenRoot func()
62+ closeRoot func() error
63+ beforeDirectoryRead func(string)
64+ beforeSessionLock func(string)
8565 }
8666
8767 var _ ports.SnapshotRepository = (*Repository)(nil)
@@ -94,13 +74,10 @@ func NewRepository(dir string, logs ...*slog.Logger) *Repository {
9474 log = logs[0]
9575 }
9676 return &Repository{
97- dir: dir,
98- log: log,
99- locks: make(map[string]*sessionMutex),
100- storageEpochs: make(map[string]uint64),
101- maintenanceCursors: make(map[string]*maintenanceCursor),
102- maintenanceSessions: make(map[string]*sessionMaintenance),
103- retentionSessions: make(map[string]*retentionMaintenance),
77+ dir: dir,
78+ log: log,
79+ locks: make(map[string]*sessionMutex),
80+ storageEpochs: make(map[string]uint64),
10481 }
10582 }
10683
D internal/adapters/snapshot/repository_maintenance_test.go +0 -69
@@ -1,69 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "strings"
9- "testing"
10-)
11-
12-func TestSafeFilesystemErrorDoesNotExposePath(t *testing.T) {
13- cause := errors.New("permission denied")
14- err := safeFilesystemError(&os.PathError{Op: "remove", Path: "\x1b[31munsafe", Err: cause})
15- if !errors.Is(err, cause) || strings.Contains(err.Error(), "\x1b[31munsafe") {
16- t.Fatalf("safe filesystem error = %q", err)
17- }
18-}
19-
20-func TestSafeFilesystemErrorDoesNotExposeLinkErrorPath(t *testing.T) {
21- err := safeFilesystemError(&os.LinkError{Op: "rename", Old: "/unsafe/old", New: "/unsafe/new", Err: os.ErrExist})
22- if !errors.Is(err, os.ErrExist) || strings.Contains(err.Error(), "/unsafe/") {
23- t.Fatalf("safe link error = %q", err)
24- }
25-}
26-
27-func TestRepositoryMaintenanceFilesystemErrorsHaveSafeContext(t *testing.T) {
28- injected := errors.New("injected filesystem failure")
29- cases := []struct {
30- name string
31- run func(*Repository) error
32- want string
33- }{
34- {
35- name: "delete sync",
36- run: func(repo *Repository) error {
37- publication := repositoryPublication(t, "named", 1, []byte("state"))
38- if err := repo.Publish(context.Background(), publication); err != nil {
39- t.Fatal(err)
40- }
41- repo.hooks.syncDirectory = func(string) error { return injected }
42- return repo.Delete(context.Background(), "named")
43- },
44- want: `sync deleted snapshot session directory "named"`,
45- },
46- {
47- name: "maintain remove",
48- run: func(repo *Repository) error {
49- if err := os.Mkdir(repo.dir, 0o700); err != nil {
50- t.Fatal(err)
51- }
52- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
53- t.Fatal(err)
54- }
55- repo.hooks.remove = func(string) error { return injected }
56- return repo.Maintain(context.Background())
57- },
58- want: `remove snapshot maintenance file ".tmp-stale"`,
59- },
60- }
61- for _, tc := range cases {
62- t.Run(tc.name, func(t *testing.T) {
63- err := tc.run(NewRepository(privateDir(t)))
64- if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.want) {
65- t.Fatalf("error = %v, want context %q and injected error", err, tc.want)
66- }
67- })
68- }
69-}
D internal/adapters/snapshot/repository_phase6_test.go +0 -107
@@ -1,107 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "sync"
10- "sync/atomic"
11- "testing"
12-
13- "github.com/bnema/vev/internal/ports"
14-)
15-
16-func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) {
17- repo := NewRepository(privateDir(t))
18- const sessions = 128
19-
20- publications := make([]ports.SnapshotPublication, sessions)
21- for i := range publications {
22- name := fmt.Sprintf("named-%03d", i)
23- publications[i] = repositoryPublication(t, name, 1, []byte(name))
24- if err := repo.Publish(context.Background(), publications[i]); err != nil {
25- t.Fatalf("initial Publish(%q): %v", name, err)
26- }
27- }
28-
29- var wg sync.WaitGroup
30- for _, publication := range publications {
31- wg.Add(2)
32- go func() {
33- defer wg.Done()
34- if err := repo.Publish(context.Background(), publication); err != nil {
35- t.Errorf("Publish(%q): %v", publication.Name, err)
36- }
37- }()
38- go func() {
39- defer wg.Done()
40- if err := repo.Delete(context.Background(), publication.Name); err != nil {
41- t.Errorf("Delete(%q): %v", publication.Name, err)
42- }
43- }()
44- }
45- wg.Wait()
46-
47- locks, epochs := repo.sessionStateCounts()
48- if locks != 0 || epochs != 0 {
49- t.Fatalf("retained session state = %d locks, %d epochs, want none", locks, epochs)
50- }
51-}
52-
53-func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {
54- repo := NewRepository(privateDir(t))
55- for _, key := range []string{"named-000", "named-001"} {
56- generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations)
57- for i := 0; i < maintenanceBatch+1; i++ {
58- if err := os.MkdirAll(generations, 0o700); err != nil {
59- t.Fatal(err)
60- }
61- if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", i)), []byte("state"), 0o600); err != nil {
62- t.Fatal(err)
63- }
64- }
65- }
66- if err := repo.Maintain(context.Background()); err != nil {
67- t.Fatal(err)
68- }
69- locks, epochs := repo.sessionStateCounts()
70- if locks != 1 || epochs != 0 {
71- t.Fatalf("partial maintenance state = %d locks, %d epochs, want one lock and no epochs", locks, epochs)
72- }
73-
74- ctx, cancel := context.WithCancel(context.Background())
75- cancel()
76- if err := repo.Maintain(ctx); !errors.Is(err, context.Canceled) {
77- t.Fatalf("canceled Maintain error = %v, want context canceled", err)
78- }
79- locks, epochs = repo.sessionStateCounts()
80- if locks != 0 || epochs != 0 {
81- t.Fatalf("session state after maintenance reset = %d locks, %d epochs, want none", locks, epochs)
82- }
83-}
84-
85-func TestRepositoryDeleteUsesDeterministicPendingQuarantine(t *testing.T) {
86- repo := NewRepository(privateDir(t))
87- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
88- t.Fatal(err)
89- }
90- sessions := filepath.Join(repo.dir, repositorySessionsDir)
91- for i := 0; i < maxDirectoryTraversalEntries*2; i++ {
92- if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("unrelated-%05d", i)), 0o700); err != nil {
93- t.Fatal(err)
94- }
95- }
96- var checks atomic.Int32
97- repo.hooks.beforePendingQuarantineCheck = func(string) { checks.Add(1) }
98- if err := repo.Delete(context.Background(), "named"); err != nil {
99- t.Fatal(err)
100- }
101- if got := checks.Load(); got != 1 {
102- t.Fatalf("pending quarantine checks = %d, want one deterministic lookup", got)
103- }
104- if _, err := os.Lstat(filepath.Join(sessions, deletingSessionName(legacyIncarnationID("named").String()))); err != nil {
105- t.Fatalf("deterministic quarantine missing: %v", err)
106- }
107-}
M internal/app/daemon_integration_test.go +5 -12
@@ -564,7 +564,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
564564 shutdownClock,
565565 discardLog(),
566566 daemon.WithSnapshotRepository(repository),
567- daemon.WithDurableMaintenance(catalogue, maintenanceRepository, nil),
567+ daemon.WithDurableMaintenance(catalogue, maintenanceRepository),
568568 daemon.WithCatalogue(catalogue, nil),
569569 )
570570 return d.Serve(ctx, listener)
@@ -604,11 +604,11 @@ type lifecycleBlockingMaintenanceRepository struct {
604604 returnOnce sync.Once
605605 }
606606
607-func (r *lifecycleBlockingMaintenanceRepository) MaintainSession(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error) {
607+func (r *lifecycleBlockingMaintenanceRepository) CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error {
608608 r.enterOnce.Do(func() { close(r.entered) })
609609 <-r.release // Intentionally ignore cancellation: lifecycle ownership must outlive this call.
610610 r.returnOnce.Do(func() { close(r.returned) })
611- return true, nil
611+ return nil
612612 }
613613
614614 type lifecycleObservedCatalogue struct {
@@ -746,7 +746,6 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
746746 opened, err := persist.OpenOrCreate(stateDir)
747747 require.NoError(t, err)
748748 coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
749- require.NoError(t, coordinator.Recover(ctx))
750749 listener, err := ipc.Listen(runtimeDir)
751750 require.NoError(t, err)
752751 d := daemon.New(
@@ -997,8 +996,6 @@ func (l *lifecycleObservedListener) Close() error {
997996 func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
998997 runtimeDir := filepath.Join(t.TempDir(), "runtime")
999998 stateDir := filepath.Join(t.TempDir(), "state")
1000- snapshotRepository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
1001-
1002999 oldOwner, err := lifecycle.TryAcquire(runtimeDir)
10031000 require.NoError(t, err)
10041001 oldOpened, err := persist.OpenOrCreate(stateDir)
@@ -1056,11 +1053,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
10561053 if err != nil {
10571054 return err
10581055 }
1059- coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader)
1060- if err := coordinator.Recover(ctx); err != nil {
1061- return errors.Join(err, opened.Catalogue.Close())
1062- }
1063- startupEvents <- "durable-open-recover"
1056+ startupEvents <- "durable-open"
10641057
10651058 close(newListen)
10661059 listener, err := ipc.Listen(runtimeDir)
@@ -1111,7 +1104,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
11111104 <-teardownEvents,
11121105 <-teardownEvents,
11131106 })
1114- require.Equal(t, []string{"owner-acquired", "durable-open-recover", "listen"}, []string{
1107+ require.Equal(t, []string{"owner-acquired", "durable-open", "listen"}, []string{
11151108 <-startupEvents,
11161109 <-startupEvents,
11171110 <-startupEvents,
M internal/app/offline_kill_retry_test.go +0 -1
@@ -76,7 +76,6 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
7676
7777 require.NoError(t, runKill(context.Background(), "named", false, false))
7878 require.NoDirExists(t, source)
79- require.NoDirExists(t, filepath.Join(stateDir, "snapshots", "quarantine", id.String()))
8079 })
8180 }
8281 }
M internal/app/run.go +1 -172
@@ -504,134 +504,6 @@ func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, re
504504 log.Info("catalogue_compaction_recovery_complete", "recovery", recoveryMode)
505505 }
506506
507-// logTransactionRecovery reports the recovery outcome. A startup that fenced
508-// any session is never reported as clean: the daemon comes up, but one or more
509-// sessions were skipped and still need an operator decision.
510-func logTransactionRecovery(log *slog.Logger, examined, fenced int) {
511- outcome := "clean"
512- if fenced > 0 {
513- outcome = "fenced"
514- }
515- log.Info("interrupted_transaction_recovery_complete", "sessions_examined", examined, "sessions_fenced", fenced, "outcome", outcome)
516-}
517-
518-type interruptedRecoveryIdentity struct {
519- name string
520- id domain.IncarnationID
521-}
522-
523-func pendingInterruptedRecoveries(ctx context.Context, records []domain.CatalogueRecord, repository ports.SnapshotRepository) ([]interruptedRecoveryIdentity, error) {
524- pending := make([]interruptedRecoveryIdentity, 0)
525- for _, record := range records {
526- if record.RecoveryState == domain.RecoveryDeleting {
527- pending = append(pending, interruptedRecoveryIdentity{name: record.Name, id: record.IncarnationID})
528- }
529- }
530- cursor := ports.DeletionTombstoneCursor{}
531- for {
532- page, err := repository.ListDeletionTombstones(ctx, cursor, ports.MaintenanceBudget{Entries: 64, Bytes: 64 << 10})
533- if err != nil {
534- return nil, err
535- }
536- for _, tombstone := range page.Tombstones {
537- pending = append(pending, interruptedRecoveryIdentity{name: tombstone.Name, id: tombstone.IncarnationID})
538- }
539- if page.Done {
540- return pending, nil
541- }
542- if page.Next.After == cursor.After {
543- return nil, errors.New("vev: recovery tombstone listing did not advance")
544- }
545- cursor = page.Next
546- }
547-}
548-
549-func persistTransactionRecoveryNotices(store ports.NoticeStore, recovered []interruptedRecoveryIdentity, now time.Time) error {
550- for _, item := range recovered {
551- if err := store.Append(domain.Notification{
552- Code: domain.NoticeSnapshotRestore,
553- Severity: domain.NoticeWarn,
554- Message: "recovered an interrupted durable session transaction",
555- Details: "session=" + item.name + " incarnation=" + item.id.String(),
556- Time: now,
557- SessionID: domain.SessionID(item.id.String()),
558- }); err != nil {
559- return fmt.Errorf("persist interrupted transaction recovery notice for session %q incarnation %s: %w", item.name, item.id.String(), err)
560- }
561- }
562- return nil
563-}
564-
565-// fencedRecoveryIdentity is one session that recovery fenced instead of failing
566-// startup for. Only the session identity and a sentinel-derived reason code are
567-// retained: raw causes can embed paths or operator input.
568-type fencedRecoveryIdentity struct {
569- name string
570- id domain.IncarnationID
571- kind string
572- reasonCode string
573-}
574-
575-// fencedRecoveries resolves each conflict against the post-recovery catalogue
576-// so a fenced session is reported with the incarnation it actually holds now.
577-func fencedRecoveries(conflicts []recovery.RecoveryConflict, catalogue ports.Catalogue) ([]fencedRecoveryIdentity, error) {
578- fenced := make([]fencedRecoveryIdentity, 0, len(conflicts))
579- for _, conflict := range conflicts {
580- identity := fencedRecoveryIdentity{name: conflict.Session, kind: conflict.Kind, reasonCode: fencedRecoveryReasonCode(conflict.Err)}
581- if catalogue != nil {
582- record, ok, err := catalogue.Record(conflict.Session)
583- if err != nil {
584- return nil, err
585- }
586- if ok {
587- identity.id = record.IncarnationID
588- }
589- }
590- fenced = append(fenced, identity)
591- }
592- return fenced, nil
593-}
594-
595-func fencedRecoveryReasonCode(err error) string {
596- switch {
597- case errors.Is(err, recovery.ErrDeletionTombstoneConflict):
598- return "deletion-tombstone-conflict"
599- case errors.Is(err, recovery.ErrDeletionTombstoneInvalid):
600- return "deletion-tombstone-invalid"
601- case errors.Is(err, recovery.ErrSessionRecordInvalid):
602- return "catalogue-record-invalid"
603- default:
604- return "unclassified"
605- }
606-}
607-
608-func logFencedRecoveries(log *slog.Logger, fenced []fencedRecoveryIdentity) {
609- for _, item := range fenced {
610- log.Warn("interrupted_transaction_fenced",
611- "session", item.name,
612- "incarnation", item.id.String(),
613- "kind", item.kind,
614- "reason_code", item.reasonCode,
615- )
616- }
617-}
618-
619-func persistFencedRecoveryNotices(store ports.NoticeStore, fenced []fencedRecoveryIdentity, now time.Time) error {
620- for _, item := range fenced {
621- if err := store.Append(domain.Notification{
622- Code: domain.NoticeSnapshotRestore,
623- Severity: domain.NoticeWarn,
624- Message: "skipped a durable session transaction that conflicts with its session state",
625- Details: "session=" + item.name + " incarnation=" + item.id.String() + " kind=" + item.kind + " reason_code=" + item.reasonCode,
626- Time: now,
627- SessionID: domain.SessionID(item.id.String()),
628- }); err != nil {
629- return fmt.Errorf("persist fenced transaction notice for session %q incarnation %s: %w", item.name, item.id.String(), err)
630- }
631- }
632- return nil
633-}
634-
635507 func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
636508 healthy, fresh, degraded := 0, 0, 0
637509 for _, record := range records {
@@ -665,13 +537,6 @@ func runDaemonOwned(ctx context.Context) (retErr error) {
665537 return runDaemonOwnedWithLogger(ctx, log)
666538 }
667539
668-func recoverAuthoritativeCatalogue(ctx context.Context, coordinator *recovery.Coordinator, catalogue ports.Catalogue) ([]domain.CatalogueRecord, error) {
669- if err := coordinator.Recover(ctx); err != nil {
670- return nil, err
671- }
672- return catalogue.Records()
673-}
674-
675540 func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr error) {
676541 clk := clock.New()
677542 observer, observerCloser, err := newPerformanceTrace(clk)
@@ -726,42 +591,9 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
726591 recoveryMode = "new-install"
727592 }
728593 logCatalogueRecovery(log, opened.Records, recoveryMode)
729- pendingRecoveries, err := pendingInterruptedRecoveries(ctx, opened.Records, snapshotRepository)
730- if err != nil {
731- return errors.Join(fmt.Errorf("vev: inspect durable session transactions: %w", err), opened.Catalogue.Close())
732- }
733594 coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader)
734- // Recovery may roll catalogue changes forward, so reload the authoritative
735- // snapshot before constructing runtime reservations. A failed reload aborts
736- // startup rather than treating durable sessions as absent.
737- recoveredRecords, err := recoverAuthoritativeCatalogue(ctx, coordinator, opened.Catalogue)
738- if err != nil {
739- return errors.Join(fmt.Errorf("vev: recover and reload durable session transactions: %w", err), opened.Catalogue.Close())
740- }
741- opened.Records = recoveredRecords
742- // Recovery fences a session instead of failing startup when only that
743- // session's durable state is self-inconsistent. The daemon then comes up
744- // looking healthy, so every fenced session must be reported and persisted.
745- fenced, err := fencedRecoveries(coordinator.Conflicts(), opened.Catalogue)
746- if err != nil {
747- return errors.Join(fmt.Errorf("vev: read fenced recovery identities: %w", err), opened.Catalogue.Close())
748- }
749- logFencedRecoveries(log, fenced)
750- if err := persistTransactionRecoveryNotices(noticeStore, pendingRecoveries, clk.Now()); err != nil {
751- log.Error("transaction_recovery_notice_failed", "err", err)
752- return errors.Join(fmt.Errorf("vev: persist durable transaction recovery notice: %w", err), opened.Catalogue.Close())
753- }
754- if err := persistFencedRecoveryNotices(noticeStore, fenced, clk.Now()); err != nil {
755- log.Error("transaction_recovery_notice_failed", "reason_code", "fenced-notice-failed")
756- return errors.Join(fmt.Errorf("vev: persist fenced transaction notice: %w", err), opened.Catalogue.Close())
757- }
758- logTransactionRecovery(log, len(pendingRecoveries), len(fenced))
759595 daemonOpts = append(daemonOpts, daemon.WithRecoveryCoordinator(coordinator))
760- unresolved := make([]domain.IncarnationID, 0, len(pendingRecoveries))
761- for _, pending := range pendingRecoveries {
762- unresolved = append(unresolved, pending.id)
763- }
764- daemonOpts = append(daemonOpts, daemon.WithDurableMaintenance(opened.Catalogue, snapshotRepository, unresolved))
596+ daemonOpts = append(daemonOpts, daemon.WithDurableMaintenance(opened.Catalogue, snapshotRepository))
765597 log.Info("session persistence enabled", "path", storePath)
766598 daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records))
767599
@@ -1564,9 +1396,6 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
15641396 return fmt.Errorf("vev: no such session: %s", name)
15651397 }
15661398 coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
1567- if err := coordinator.Recover(ctx); err != nil {
1568- return fmt.Errorf("vev: recovering stored session transactions: %w", err)
1569- }
15701399 if err := coordinator.Delete(ctx, name); err != nil {
15711400 return fmt.Errorf("vev: deleting stored session: %w", err)
15721401 }
M internal/app/run_test.go +2 -186
@@ -4,10 +4,8 @@ import (
44 "bufio"
55 "bytes"
66 "context"
7- "crypto/rand"
87 "encoding/json"
98 "errors"
10- "fmt"
119 "io"
1210 "log/slog"
1311 "net"
@@ -15,13 +13,10 @@ import (
1513 "os/exec"
1614 "path/filepath"
1715 "strings"
18- "sync"
1916 "testing"
2017 "time"
2118
2219 "github.com/bnema/vev/internal/adapters/lifecycle"
23- "github.com/bnema/vev/internal/adapters/noticefile"
24- "github.com/bnema/vev/internal/adapters/snapshot"
2520 "github.com/bnema/vev/internal/domain"
2621 "github.com/bnema/vev/internal/persist"
2722 "github.com/bnema/vev/internal/ports"
@@ -29,7 +24,6 @@ import (
2924 "github.com/bnema/vev/internal/usecase/client"
3025 "github.com/bnema/vev/internal/usecase/confirm"
3126 "github.com/bnema/vev/internal/usecase/daemon"
32- "github.com/bnema/vev/internal/usecase/recovery"
3327 "github.com/bnema/vev/pkg/kv"
3428 "github.com/stretchr/testify/mock"
3529 "github.com/stretchr/testify/require"
@@ -86,19 +80,14 @@ func TestRecoveryObservability(t *testing.T) {
8680 {RecoveryState: domain.RecoveryFresh},
8781 {RecoveryState: domain.RecoveryDegraded},
8882 }
89- noticeStore := portsmocks.NewMockNoticeStore(t)
90- var recoveryNotice domain.Notification
91- noticeStore.EXPECT().Append(mock.Anything).Run(func(n domain.Notification) { recoveryNotice = n }).Return(nil).Once()
9283 require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
9384 logCatalogueRecovery(log, records, "current")
94- logTransactionRecovery(log, 2, 0)
95- require.NoError(t, persistTransactionRecoveryNotices(noticeStore, []interruptedRecoveryIdentity{{name: "work", id: domain.IncarnationID{9}}}, time.Unix(1, 0)))
9685 logStartupRecoveryCounts(log, records, 0)
9786 return nil
9887 }, deps))
9988
10089 entries := decodeJSONLogs(t, logBuffer.Bytes())
101- for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "catalogue_compaction_recovery_complete", "interrupted_transaction_recovery_complete", "daemon_startup_complete", "lifecycle_owner_released"} {
90+ for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "catalogue_compaction_recovery_complete", "daemon_startup_complete", "lifecycle_owner_released"} {
10291 require.Contains(t, eventNames(entries), name)
10392 }
10493 startup := requireEvent(t, entries, "daemon_startup_complete")
@@ -106,176 +95,12 @@ func TestRecoveryObservability(t *testing.T) {
10695 require.EqualValues(t, 1, startup["fresh"])
10796 require.EqualValues(t, 0, startup["restoring"])
10897 require.EqualValues(t, 1, startup["degraded"])
109- recovered := requireEvent(t, entries, "interrupted_transaction_recovery_complete")
110- require.EqualValues(t, 0, recovered["sessions_fenced"])
111- require.Equal(t, "clean", recovered["outcome"])
112- require.NotContains(t, eventNames(entries), "interrupted_transaction_fenced")
113-
114- require.NotContains(t, fmt.Sprint(requireEvent(t, entries, "interrupted_transaction_recovery_complete")), "terminal contents")
115- require.Equal(t, domain.NoticeSnapshotRestore, recoveryNotice.Code)
116- require.Equal(t, domain.SessionID(domain.IncarnationID{9}.String()), recoveryNotice.SessionID)
117- require.NotContains(t, fmt.Sprint(recoveryNotice), "terminal contents")
98+ require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
11899 }
119100
120101 // A session-scoped conflict no longer aborts startup, so the daemon comes up
121102 // looking healthy. Startup must therefore name the fenced session in the log,
122103 // persist a notice for it, and never report the recovery as clean.
123-func TestStartupReloadsCatalogueAfterRolledForwardDeletion(t *testing.T) {
124- ctx := t.Context()
125- stateDir := filepath.Join(t.TempDir(), "vev")
126- catalogue := newTestPersister(t, stateDir)
127- t.Cleanup(func() { _ = catalogue.Close() })
128- repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
129- record := domain.CatalogueRecord{
130- Name: "work", IncarnationID: domain.IncarnationID{9}, Cwd: t.TempDir(),
131- CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryDeleting,
132- }
133- require.NoError(t, catalogue.Save(record))
134- coordinator := recovery.NewCoordinator(catalogue, repository, rand.Reader)
135-
136- records, err := recoverAuthoritativeCatalogue(ctx, coordinator, catalogue)
137- require.NoError(t, err)
138- require.Empty(t, records, "daemon startup must not retain the pre-recovery reservation")
139- _, exists, err := catalogue.Record(record.Name)
140- require.NoError(t, err)
141- require.False(t, exists, "rolled-forward deletion must remain absent in authoritative state")
142-}
143-
144-func TestFencedRecoveryObservability(t *testing.T) {
145- // Keep the runtime path short enough for Darwin's smaller unix socket limit.
146- runtimeRoot, err := os.MkdirTemp("/tmp", "vev-r-")
147- require.NoError(t, err)
148- t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
149- stateRoot := t.TempDir()
150- t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
151- t.Setenv("XDG_STATE_HOME", stateRoot)
152- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
153- t.Setenv("VEV_PPROF_ADDR", "")
154-
155- stateDir := filepath.Join(stateRoot, "vev")
156- repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
157- catalogue := newTestPersister(t, stateDir)
158- live := domain.CatalogueRecord{
159- Name: "work", IncarnationID: domain.IncarnationID{7}, Cwd: t.TempDir(),
160- CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryHealthy,
161- Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}},
162- }
163- require.NoError(t, catalogue.Save(live))
164- require.NoError(t, catalogue.Close(), "startup must reopen the on-disk catalogue")
165- require.NoError(t, repository.WriteDeletionTombstone(t.Context(), domain.DeletionTombstone{
166- Name: live.Name, IncarnationID: live.IncarnationID,
167- }))
168-
169- // Pause immediately before socket publication so the test can reopen and
170- // claim the notice before the daemon's restore worker consumes it.
171- originalListenDaemon := listenDaemon
172- listenEntered := make(chan struct{})
173- allowListen := make(chan struct{})
174- var allowListenOnce sync.Once
175- releaseListen := func() { allowListenOnce.Do(func() { close(allowListen) }) }
176- listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {
177- close(listenEntered)
178- <-allowListen
179- return originalListenDaemon(dir, observer)
180- }
181- t.Cleanup(func() {
182- releaseListen()
183- listenDaemon = originalListenDaemon
184- })
185-
186- var logBuffer bytes.Buffer
187- log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
188- ctx, cancel := context.WithCancel(t.Context())
189- started := make(chan error, 1)
190- go func() { started <- runDaemonOwnedWithLogger(ctx, log) }()
191-
192- var shutdownErr error
193- var shutdownOnce sync.Once
194- shutdown := func() error {
195- shutdownOnce.Do(func() {
196- releaseListen()
197- cancel()
198- select {
199- case shutdownErr = <-started:
200- case <-time.After(5 * time.Second):
201- shutdownErr = errors.New("daemon did not stop after cancellation")
202- }
203- })
204- return shutdownErr
205- }
206- t.Cleanup(func() { require.NoError(t, shutdown()) })
207-
208- select {
209- case <-listenEntered:
210- case <-time.After(5 * time.Second):
211- t.Fatal("startup did not reach socket publication")
212- }
213- reopenedNotices := noticefile.New(stateDir)
214- pending, err := reopenedNotices.Claim()
215- require.NoError(t, err)
216- require.Len(t, pending, 2, "startup persists both the interrupted and fenced recovery notices")
217- var fencedNotice *domain.Notification
218- for i := range pending {
219- if strings.Contains(pending[i].Details, "reason_code=deletion-tombstone-conflict") {
220- fencedNotice = &pending[i]
221- }
222- }
223- require.NotNil(t, fencedNotice)
224- require.Equal(t, domain.NoticeSnapshotRestore, fencedNotice.Code)
225- require.Equal(t, domain.NoticeWarn, fencedNotice.Severity)
226- require.Equal(t, domain.SessionID(live.IncarnationID.String()), fencedNotice.SessionID)
227- require.Contains(t, fencedNotice.Details, "session="+live.Name)
228- require.NotContains(t, fmt.Sprint(fencedNotice), "conflicts with catalogue")
229- require.NoError(t, reopenedNotices.Ack(), "notice claim lock must be released before daemon startup resumes")
230-
231- releaseListen()
232- socketPath := filepath.Join(runtimeRoot, "vev", "daemon.sock")
233- require.Eventually(t, func() bool {
234- _, statErr := os.Stat(socketPath)
235- return statErr == nil
236- }, 5*time.Second, time.Millisecond, "fenced recovery must not prevent socket publication")
237- require.NoError(t, shutdown(), "fenced recovery must allow clean daemon startup and shutdown")
238-
239- entries := decodeJSONLogs(t, logBuffer.Bytes())
240- event := requireEvent(t, entries, "interrupted_transaction_fenced")
241- require.Equal(t, "WARN", event["level"])
242- require.Equal(t, live.Name, event["session"])
243- require.Equal(t, live.IncarnationID.String(), event["incarnation"])
244- require.Equal(t, "deletion-tombstone", event["kind"])
245- require.Equal(t, "deletion-tombstone-conflict", event["reason_code"])
246- recovered := requireEvent(t, entries, "interrupted_transaction_recovery_complete")
247- require.EqualValues(t, 1, recovered["sessions_fenced"])
248- require.Equal(t, "fenced", recovered["outcome"])
249- startup := requireEvent(t, entries, "daemon_startup_complete")
250- require.EqualValues(t, 1, startup["degraded"])
251- require.NotContains(t, logBuffer.String(), "conflicts with catalogue", "raw conflict text must not be logged")
252-
253- reopenedCatalogue := newTestPersister(t, stateDir)
254- t.Cleanup(func() { require.NoError(t, reopenedCatalogue.Close()) })
255- degraded, ok, err := reopenedCatalogue.Record(live.Name)
256- require.NoError(t, err)
257- require.True(t, ok)
258- require.Equal(t, domain.RecoveryDegraded, degraded.RecoveryState)
259-}
260-
261-func TestFencedRecoveryNoticeFailurePropagates(t *testing.T) {
262- store := portsmocks.NewMockNoticeStore(t)
263- store.EXPECT().Append(mock.Anything).Return(errors.New("disk full")).Once()
264- err := persistFencedRecoveryNotices(store, []fencedRecoveryIdentity{{
265- name: "work", id: domain.IncarnationID{1}, kind: "discard-intent", reasonCode: "discard-intent-conflict",
266- }}, time.Unix(1, 0))
267- require.ErrorContains(t, err, "session \"work\"")
268- require.ErrorContains(t, err, domain.IncarnationID{1}.String())
269-}
270-
271-func TestTransactionRecoveryNoticeFailurePropagates(t *testing.T) {
272- store := portsmocks.NewMockNoticeStore(t)
273- store.EXPECT().Append(mock.Anything).Return(errors.New("disk full")).Once()
274- err := persistTransactionRecoveryNotices(store, []interruptedRecoveryIdentity{{name: "work", id: domain.IncarnationID{1}}}, time.Unix(1, 0))
275- require.ErrorContains(t, err, "session \"work\"")
276- require.ErrorContains(t, err, domain.IncarnationID{1}.String())
277-}
278-
279104 type fakeLifecycleOwnership struct {
280105 release func() error
281106 }
@@ -917,8 +742,6 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
917742 if err := p.Close(); err != nil {
918743 t.Fatalf("Close error = %v", err)
919744 }
920- snapshots := snapshot.NewRepository(filepath.Join(stateRoot, "vev", "snapshots"))
921-
922745 got := captureStdout(t, func() {
923746 if err := runKill(context.Background(), "stored", false, false); err != nil {
924747 t.Fatalf("runKill error = %v", err)
@@ -934,13 +757,6 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
934757 if len(records) != 0 {
935758 t.Fatalf("records after kill = %#v, want none", records)
936759 }
937- page, err := snapshots.ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 1, Bytes: 1024})
938- if err != nil {
939- t.Fatalf("list deletion tombstones: %v", err)
940- }
941- if len(page.Tombstones) != 0 {
942- t.Fatalf("deletion tombstones after kill = %#v, want none", page.Tombstones)
943- }
944760 }
945761
946762 func captureStdout(t *testing.T, fn func()) string {
M internal/domain/recovery.go +0 -17
@@ -81,23 +81,6 @@ func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate {
8181 }
8282 }
8383
84-// DeletionTombstone identifies exactly one deleted session incarnation.
85-type DeletionTombstone struct {
86- Name string
87- IncarnationID IncarnationID
88-}
89-
90-// Validate rejects incomplete deletion identities.
91-func (t DeletionTombstone) Validate() error {
92- if err := ValidateSessionName(t.Name); err != nil {
93- return err
94- }
95- if t.IncarnationID == (IncarnationID{}) {
96- return errors.New("zero deletion incarnation ID")
97- }
98- return nil
99-}
100-
10184 // NewIncarnationID reads a new non-zero identity from r.
10285 func NewIncarnationID(r io.Reader) (IncarnationID, error) {
10386 var id IncarnationID
M internal/domain/recovery_test.go +0 -22
@@ -76,25 +76,3 @@ func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) {
7676 require.True(t, (*CheckpointRef)(nil).Equal(nil))
7777 require.False(t, left.Committed.Equal(nil))
7878 }
79-
80-func TestDeletionTombstoneValidate(t *testing.T) {
81- tests := []struct {
82- name string
83- tombstone DeletionTombstone
84- wantErr bool
85- }{
86- {name: "valid", tombstone: DeletionTombstone{Name: "work", IncarnationID: IncarnationID{1}}},
87- {name: "invalid name", tombstone: DeletionTombstone{Name: "bad/name", IncarnationID: IncarnationID{1}}, wantErr: true},
88- {name: "zero incarnation", tombstone: DeletionTombstone{Name: "work"}, wantErr: true},
89- }
90- for _, tt := range tests {
91- t.Run(tt.name, func(t *testing.T) {
92- err := tt.tombstone.Validate()
93- if tt.wantErr {
94- require.Error(t, err)
95- return
96- }
97- require.NoError(t, err)
98- })
99- }
100-}
M internal/ports/mocks/recovery_mock.go +17 -218
@@ -975,44 +975,44 @@ func (_m *MockSnapshotRepository) EXPECT() *MockSnapshotRepository_Expecter {
975975 return &MockSnapshotRepository_Expecter{mock: &_m.Mock}
976976 }
977977
978-// DeleteDeletionTombstone provides a mock function for the type MockSnapshotRepository
979-func (_mock *MockSnapshotRepository) DeleteDeletionTombstone(context1 context.Context, incarnationID domain.IncarnationID) error {
980- ret := _mock.Called(context1, incarnationID)
978+// CollectGarbage provides a mock function for the type MockSnapshotRepository
979+func (_mock *MockSnapshotRepository) CollectGarbage(context1 context.Context, incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef) error {
980+ ret := _mock.Called(context1, incarnationIDToCheckpointRef)
981981
982982 if len(ret) == 0 {
983- panic("no return value specified for DeleteDeletionTombstone")
983+ panic("no return value specified for CollectGarbage")
984984 }
985985
986986 var r0 error
987- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID) error); ok {
988- r0 = returnFunc(context1, incarnationID)
987+ if returnFunc, ok := ret.Get(0).(func(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error); ok {
988+ r0 = returnFunc(context1, incarnationIDToCheckpointRef)
989989 } else {
990990 r0 = ret.Error(0)
991991 }
992992 return r0
993993 }
994994
995-// MockSnapshotRepository_DeleteDeletionTombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteDeletionTombstone'
996-type MockSnapshotRepository_DeleteDeletionTombstone_Call struct {
995+// MockSnapshotRepository_CollectGarbage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectGarbage'
996+type MockSnapshotRepository_CollectGarbage_Call struct {
997997 *mock.Call
998998 }
999999
1000-// DeleteDeletionTombstone is a helper method to define mock.On call
1000+// CollectGarbage is a helper method to define mock.On call
10011001 // - context1 context.Context
1002-// - incarnationID domain.IncarnationID
1003-func (_e *MockSnapshotRepository_Expecter) DeleteDeletionTombstone(context1 any, incarnationID any) *MockSnapshotRepository_DeleteDeletionTombstone_Call {
1004- return &MockSnapshotRepository_DeleteDeletionTombstone_Call{Call: _e.mock.On("DeleteDeletionTombstone", context1, incarnationID)}
1002+// - incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef
1003+func (_e *MockSnapshotRepository_Expecter) CollectGarbage(context1 any, incarnationIDToCheckpointRef any) *MockSnapshotRepository_CollectGarbage_Call {
1004+ return &MockSnapshotRepository_CollectGarbage_Call{Call: _e.mock.On("CollectGarbage", context1, incarnationIDToCheckpointRef)}
10051005 }
10061006
1007-func (_c *MockSnapshotRepository_DeleteDeletionTombstone_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID)) *MockSnapshotRepository_DeleteDeletionTombstone_Call {
1007+func (_c *MockSnapshotRepository_CollectGarbage_Call) Run(run func(context1 context.Context, incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef)) *MockSnapshotRepository_CollectGarbage_Call {
10081008 _c.Call.Run(func(args mock.Arguments) {
10091009 var arg0 context.Context
10101010 if args[0] != nil {
10111011 arg0 = args[0].(context.Context)
10121012 }
1013- var arg1 domain.IncarnationID
1013+ var arg1 map[domain.IncarnationID]domain.CheckpointRef
10141014 if args[1] != nil {
1015- arg1 = args[1].(domain.IncarnationID)
1015+ arg1 = args[1].(map[domain.IncarnationID]domain.CheckpointRef)
10161016 }
10171017 run(
10181018 arg0,
@@ -1022,12 +1022,12 @@ func (_c *MockSnapshotRepository_DeleteDeletionTombstone_Call) Run(run func(cont
10221022 return _c
10231023 }
10241024
1025-func (_c *MockSnapshotRepository_DeleteDeletionTombstone_Call) Return(err error) *MockSnapshotRepository_DeleteDeletionTombstone_Call {
1025+func (_c *MockSnapshotRepository_CollectGarbage_Call) Return(err error) *MockSnapshotRepository_CollectGarbage_Call {
10261026 _c.Call.Return(err)
10271027 return _c
10281028 }
10291029
1030-func (_c *MockSnapshotRepository_DeleteDeletionTombstone_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID) error) *MockSnapshotRepository_DeleteDeletionTombstone_Call {
1030+func (_c *MockSnapshotRepository_CollectGarbage_Call) RunAndReturn(run func(context1 context.Context, incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef) error) *MockSnapshotRepository_CollectGarbage_Call {
10311031 _c.Call.Return(run)
10321032 return _c
10331033 }
@@ -1089,78 +1089,6 @@ func (_c *MockSnapshotRepository_DeleteIncarnation_Call) RunAndReturn(run func(c
10891089 return _c
10901090 }
10911091
1092-// ListDeletionTombstones provides a mock function for the type MockSnapshotRepository
1093-func (_mock *MockSnapshotRepository) ListDeletionTombstones(context1 context.Context, deletionTombstoneCursor ports.DeletionTombstoneCursor, maintenanceBudget ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {
1094- ret := _mock.Called(context1, deletionTombstoneCursor, maintenanceBudget)
1095-
1096- if len(ret) == 0 {
1097- panic("no return value specified for ListDeletionTombstones")
1098- }
1099-
1100- var r0 ports.DeletionTombstonePage
1101- var r1 error
1102- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) (ports.DeletionTombstonePage, error)); ok {
1103- return returnFunc(context1, deletionTombstoneCursor, maintenanceBudget)
1104- }
1105- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) ports.DeletionTombstonePage); ok {
1106- r0 = returnFunc(context1, deletionTombstoneCursor, maintenanceBudget)
1107- } else {
1108- r0 = ret.Get(0).(ports.DeletionTombstonePage)
1109- }
1110- if returnFunc, ok := ret.Get(1).(func(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) error); ok {
1111- r1 = returnFunc(context1, deletionTombstoneCursor, maintenanceBudget)
1112- } else {
1113- r1 = ret.Error(1)
1114- }
1115- return r0, r1
1116-}
1117-
1118-// MockSnapshotRepository_ListDeletionTombstones_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListDeletionTombstones'
1119-type MockSnapshotRepository_ListDeletionTombstones_Call struct {
1120- *mock.Call
1121-}
1122-
1123-// ListDeletionTombstones is a helper method to define mock.On call
1124-// - context1 context.Context
1125-// - deletionTombstoneCursor ports.DeletionTombstoneCursor
1126-// - maintenanceBudget ports.MaintenanceBudget
1127-func (_e *MockSnapshotRepository_Expecter) ListDeletionTombstones(context1 any, deletionTombstoneCursor any, maintenanceBudget any) *MockSnapshotRepository_ListDeletionTombstones_Call {
1128- return &MockSnapshotRepository_ListDeletionTombstones_Call{Call: _e.mock.On("ListDeletionTombstones", context1, deletionTombstoneCursor, maintenanceBudget)}
1129-}
1130-
1131-func (_c *MockSnapshotRepository_ListDeletionTombstones_Call) Run(run func(context1 context.Context, deletionTombstoneCursor ports.DeletionTombstoneCursor, maintenanceBudget ports.MaintenanceBudget)) *MockSnapshotRepository_ListDeletionTombstones_Call {
1132- _c.Call.Run(func(args mock.Arguments) {
1133- var arg0 context.Context
1134- if args[0] != nil {
1135- arg0 = args[0].(context.Context)
1136- }
1137- var arg1 ports.DeletionTombstoneCursor
1138- if args[1] != nil {
1139- arg1 = args[1].(ports.DeletionTombstoneCursor)
1140- }
1141- var arg2 ports.MaintenanceBudget
1142- if args[2] != nil {
1143- arg2 = args[2].(ports.MaintenanceBudget)
1144- }
1145- run(
1146- arg0,
1147- arg1,
1148- arg2,
1149- )
1150- })
1151- return _c
1152-}
1153-
1154-func (_c *MockSnapshotRepository_ListDeletionTombstones_Call) Return(deletionTombstonePage ports.DeletionTombstonePage, err error) *MockSnapshotRepository_ListDeletionTombstones_Call {
1155- _c.Call.Return(deletionTombstonePage, err)
1156- return _c
1157-}
1158-
1159-func (_c *MockSnapshotRepository_ListDeletionTombstones_Call) RunAndReturn(run func(context1 context.Context, deletionTombstoneCursor ports.DeletionTombstoneCursor, maintenanceBudget ports.MaintenanceBudget) (ports.DeletionTombstonePage, error)) *MockSnapshotRepository_ListDeletionTombstones_Call {
1160- _c.Call.Return(run)
1161- return _c
1162-}
1163-
11641092 // LoadCheckpoint provides a mock function for the type MockSnapshotRepository
11651093 func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef) (ports.SnapshotGeneration, error) {
11661094 ret := _mock.Called(context1, incarnationID, s, v)
@@ -1239,78 +1167,6 @@ func (_c *MockSnapshotRepository_LoadCheckpoint_Call) RunAndReturn(run func(cont
12391167 return _c
12401168 }
12411169
1242-// MaintainSession provides a mock function for the type MockSnapshotRepository
1243-func (_mock *MockSnapshotRepository) MaintainSession(context1 context.Context, retentionPlan ports.RetentionPlan, maintenanceBudget ports.MaintenanceBudget) (bool, error) {
1244- ret := _mock.Called(context1, retentionPlan, maintenanceBudget)
1245-
1246- if len(ret) == 0 {
1247- panic("no return value specified for MaintainSession")
1248- }
1249-
1250- var r0 bool
1251- var r1 error
1252- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error)); ok {
1253- return returnFunc(context1, retentionPlan, maintenanceBudget)
1254- }
1255- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) bool); ok {
1256- r0 = returnFunc(context1, retentionPlan, maintenanceBudget)
1257- } else {
1258- r0 = ret.Get(0).(bool)
1259- }
1260- if returnFunc, ok := ret.Get(1).(func(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) error); ok {
1261- r1 = returnFunc(context1, retentionPlan, maintenanceBudget)
1262- } else {
1263- r1 = ret.Error(1)
1264- }
1265- return r0, r1
1266-}
1267-
1268-// MockSnapshotRepository_MaintainSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MaintainSession'
1269-type MockSnapshotRepository_MaintainSession_Call struct {
1270- *mock.Call
1271-}
1272-
1273-// MaintainSession is a helper method to define mock.On call
1274-// - context1 context.Context
1275-// - retentionPlan ports.RetentionPlan
1276-// - maintenanceBudget ports.MaintenanceBudget
1277-func (_e *MockSnapshotRepository_Expecter) MaintainSession(context1 any, retentionPlan any, maintenanceBudget any) *MockSnapshotRepository_MaintainSession_Call {
1278- return &MockSnapshotRepository_MaintainSession_Call{Call: _e.mock.On("MaintainSession", context1, retentionPlan, maintenanceBudget)}
1279-}
1280-
1281-func (_c *MockSnapshotRepository_MaintainSession_Call) Run(run func(context1 context.Context, retentionPlan ports.RetentionPlan, maintenanceBudget ports.MaintenanceBudget)) *MockSnapshotRepository_MaintainSession_Call {
1282- _c.Call.Run(func(args mock.Arguments) {
1283- var arg0 context.Context
1284- if args[0] != nil {
1285- arg0 = args[0].(context.Context)
1286- }
1287- var arg1 ports.RetentionPlan
1288- if args[1] != nil {
1289- arg1 = args[1].(ports.RetentionPlan)
1290- }
1291- var arg2 ports.MaintenanceBudget
1292- if args[2] != nil {
1293- arg2 = args[2].(ports.MaintenanceBudget)
1294- }
1295- run(
1296- arg0,
1297- arg1,
1298- arg2,
1299- )
1300- })
1301- return _c
1302-}
1303-
1304-func (_c *MockSnapshotRepository_MaintainSession_Call) Return(b bool, err error) *MockSnapshotRepository_MaintainSession_Call {
1305- _c.Call.Return(b, err)
1306- return _c
1307-}
1308-
1309-func (_c *MockSnapshotRepository_MaintainSession_Call) RunAndReturn(run func(context1 context.Context, retentionPlan ports.RetentionPlan, maintenanceBudget ports.MaintenanceBudget) (bool, error)) *MockSnapshotRepository_MaintainSession_Call {
1310- _c.Call.Return(run)
1311- return _c
1312-}
1313-
13141170 // Publish provides a mock function for the type MockSnapshotRepository
13151171 func (_mock *MockSnapshotRepository) Publish(context1 context.Context, snapshotPublication ports.SnapshotPublication) error {
13161172 ret := _mock.Called(context1, snapshotPublication)
@@ -1430,60 +1286,3 @@ func (_c *MockSnapshotRepository_RepairHEAD_Call) RunAndReturn(run func(context1
14301286 _c.Call.Return(run)
14311287 return _c
14321288 }
1433-
1434-// WriteDeletionTombstone provides a mock function for the type MockSnapshotRepository
1435-func (_mock *MockSnapshotRepository) WriteDeletionTombstone(context1 context.Context, deletionTombstone domain.DeletionTombstone) error {
1436- ret := _mock.Called(context1, deletionTombstone)
1437-
1438- if len(ret) == 0 {
1439- panic("no return value specified for WriteDeletionTombstone")
1440- }
1441-
1442- var r0 error
1443- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.DeletionTombstone) error); ok {
1444- r0 = returnFunc(context1, deletionTombstone)
1445- } else {
1446- r0 = ret.Error(0)
1447- }
1448- return r0
1449-}
1450-
1451-// MockSnapshotRepository_WriteDeletionTombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteDeletionTombstone'
1452-type MockSnapshotRepository_WriteDeletionTombstone_Call struct {
1453- *mock.Call
1454-}
1455-
1456-// WriteDeletionTombstone is a helper method to define mock.On call
1457-// - context1 context.Context
1458-// - deletionTombstone domain.DeletionTombstone
1459-func (_e *MockSnapshotRepository_Expecter) WriteDeletionTombstone(context1 any, deletionTombstone any) *MockSnapshotRepository_WriteDeletionTombstone_Call {
1460- return &MockSnapshotRepository_WriteDeletionTombstone_Call{Call: _e.mock.On("WriteDeletionTombstone", context1, deletionTombstone)}
1461-}
1462-
1463-func (_c *MockSnapshotRepository_WriteDeletionTombstone_Call) Run(run func(context1 context.Context, deletionTombstone domain.DeletionTombstone)) *MockSnapshotRepository_WriteDeletionTombstone_Call {
1464- _c.Call.Run(func(args mock.Arguments) {
1465- var arg0 context.Context
1466- if args[0] != nil {
1467- arg0 = args[0].(context.Context)
1468- }
1469- var arg1 domain.DeletionTombstone
1470- if args[1] != nil {
1471- arg1 = args[1].(domain.DeletionTombstone)
1472- }
1473- run(
1474- arg0,
1475- arg1,
1476- )
1477- })
1478- return _c
1479-}
1480-
1481-func (_c *MockSnapshotRepository_WriteDeletionTombstone_Call) Return(err error) *MockSnapshotRepository_WriteDeletionTombstone_Call {
1482- _c.Call.Return(err)
1483- return _c
1484-}
1485-
1486-func (_c *MockSnapshotRepository_WriteDeletionTombstone_Call) RunAndReturn(run func(context1 context.Context, deletionTombstone domain.DeletionTombstone) error) *MockSnapshotRepository_WriteDeletionTombstone_Call {
1487- _c.Call.Return(run)
1488- return _c
1489-}
M internal/ports/recovery.go +1 -26
@@ -2,7 +2,6 @@ package ports
22
33 import (
44 "context"
5- "errors"
65
76 "github.com/bnema/vev/internal/domain"
87 )
@@ -27,27 +26,6 @@ type SnapshotGeneration struct {
2726 Objects map[SnapshotDigest][]byte
2827 }
2928
30-type RetentionPlan struct {
31- IncarnationID domain.IncarnationID
32- Keep []CheckpointRef
33- PinAll bool
34-}
35-
36-type MaintenanceBudget struct {
37- Entries uint64
38- Bytes uint64
39-}
40-
41-type DeletionTombstoneCursor struct{ After string }
42-
43-type DeletionTombstonePage struct {
44- Tombstones []domain.DeletionTombstone
45- Next DeletionTombstoneCursor
46- Done bool
47-}
48-
49-var ErrBudgetExhausted = errors.New("snapshot maintenance budget exhausted")
50-
5129 type CheckpointCoordinator interface {
5230 PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error)
5331 }
@@ -82,9 +60,6 @@ type SnapshotRepository interface {
8260 Publish(context.Context, SnapshotPublication) error
8361 LoadCheckpoint(context.Context, domain.IncarnationID, string, CheckpointRef) (SnapshotGeneration, error)
8462 RepairHEAD(context.Context, domain.IncarnationID, CheckpointRef) error
85- WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error
86- ListDeletionTombstones(context.Context, DeletionTombstoneCursor, MaintenanceBudget) (DeletionTombstonePage, error)
87- DeleteDeletionTombstone(context.Context, domain.IncarnationID) error
8863 DeleteIncarnation(context.Context, domain.IncarnationID) error
89- MaintainSession(context.Context, RetentionPlan, MaintenanceBudget) (bool, error)
64+ CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error
9065 }
M internal/usecase/daemon/coderabbit_regression_test.go +2 -1
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "sync"
6+ "syscall"
67 "testing"
78
89 "github.com/stretchr/testify/require"
@@ -14,7 +15,7 @@ import (
1415 func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) {
1516 record := durableRecoveryRecord(0)
1617 repository := &durableRecoveryRepository{
17- errors: map[string]error{record.Name: ports.ErrBudgetExhausted},
18+ errors: map[string]error{record.Name: syscall.ENOSPC},
1819 loads: make(map[string]int),
1920 repairs: make(map[string]domain.CheckpointRef),
2021 }
M internal/usecase/daemon/daemon.go +6 -7
@@ -325,11 +325,11 @@ func WithNoticeStore(store ports.NoticeStore) Option {
325325 return func(d *Daemon) { d.noticeStore = store }
326326 }
327327
328-// WithDurableMaintenance schedules bounded retention after catalogue-driven
329-// restoration. Unresolved incarnation IDs remain pinned for this daemon lifetime.
330-func WithDurableMaintenance(catalogue ports.Catalogue, repository ports.SnapshotRepository, unresolved []domain.IncarnationID) Option {
328+// WithDurableMaintenance schedules one garbage-collection pass after
329+// catalogue-driven restoration completes.
330+func WithDurableMaintenance(catalogue ports.Catalogue, repository ports.SnapshotRepository) Option {
331331 return func(d *Daemon) {
332- d.maintenance = newMaintenanceDependencies(catalogue, repository, unresolved)
332+ d.maintenance = newMaintenanceDependencies(catalogue, repository)
333333 }
334334 }
335335
@@ -865,9 +865,8 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
865865 if target == nil {
866866 if _, ok := d.stopped[k.Name]; ok {
867867 d.mu.Unlock()
868- // Stopped sessions follow the same tombstone-backed transaction as
869- // live and offline purges. In particular, a retained legacy source
870- // must never outlive metadata after an interrupted control kill.
868+ // Stopped sessions use the same catalogue-first, incarnation-second
869+ // deletion order as live and offline purges.
871870 if err := d.retryStoppedPurge(k.Name); err != nil {
872871 d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name)
873872 _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session failed"))
M internal/usecase/daemon/durable_authority_test.go +1 -4
@@ -15,13 +15,10 @@ import (
1515
1616 type testLifecycleRepository struct{ ports.SnapshotRepository }
1717
18-func (testLifecycleRepository) WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error {
19- return nil
20-}
2118 func (testLifecycleRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
2219 return nil
2320 }
24-func (testLifecycleRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {
21+func (testLifecycleRepository) CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error {
2522 return nil
2623 }
2724
M internal/usecase/daemon/durable_recovery_test.go +0 -1
@@ -397,7 +397,6 @@ func TestRetryableRestoreLoadErrorClassification(t *testing.T) {
397397 err error
398398 retryable bool
399399 }{
400- {name: "budget exhausted", err: fmt.Errorf("wrapped: %w", ports.ErrBudgetExhausted), retryable: true},
401400 {name: "missing path", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.ENOENT})},
402401 {name: "permission denied", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EACCES})},
403402 {name: "invalid path operation", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EINVAL})},
M internal/usecase/daemon/maintenance.go +21 -92
@@ -2,50 +2,18 @@ package daemon
22
33 import (
44 "context"
5- "time"
65
76 "github.com/bnema/vev/internal/domain"
87 "github.com/bnema/vev/internal/ports"
98 )
109
11-const (
12- maintenanceInterval = time.Minute
13- maintenanceEntries = 64
14- maintenanceBytes = 8 << 20
15-)
16-
17-// maintenanceDependencies is exclusively owned by runDurableMaintenance's
18-// single goroutine after startup. Shutdown joins that goroutine before these
19-// cursor and repair sets can be observed or reused.
2010 type maintenanceDependencies struct {
2111 catalogue ports.Catalogue
2212 repository ports.SnapshotRepository
23- unresolved map[domain.IncarnationID]struct{}
24-}
25-
26-func newMaintenanceDependencies(catalogue ports.Catalogue, repository ports.SnapshotRepository, unresolved []domain.IncarnationID) maintenanceDependencies {
27- deps := maintenanceDependencies{
28- catalogue: catalogue, repository: repository,
29- unresolved: make(map[domain.IncarnationID]struct{}, len(unresolved)),
30- }
31- for _, id := range unresolved {
32- if id != (domain.IncarnationID{}) {
33- deps.unresolved[id] = struct{}{}
34- }
35- }
36- return deps
3713 }
3814
39-func retentionPlan(record domain.CatalogueRecord, unresolved, restoring bool) ports.RetentionPlan {
40- plan := ports.RetentionPlan{IncarnationID: record.IncarnationID}
41- if unresolved || restoring || record.RecoveryState == domain.RecoveryDegraded || record.RecoveryState == domain.RecoveryDeleting {
42- plan.PinAll = true
43- return plan
44- }
45- if record.Committed != nil {
46- plan.Keep = append(plan.Keep, *record.Committed)
47- }
48- return plan
15+func newMaintenanceDependencies(catalogue ports.Catalogue, repository ports.SnapshotRepository) maintenanceDependencies {
16+ return maintenanceDependencies{catalogue: catalogue, repository: repository}
4917 }
5018
5119 func (d *Daemon) startDurableMaintenance() {
@@ -63,81 +31,42 @@ func (d *Daemon) startDurableMaintenance() {
6331 done := d.maintenanceWorkerDone
6432 d.snapshotWorkerMu.Unlock()
6533
66- // Register the completion channel before launch so shutdown can never begin
67- // waiting while this durable writer is still unregistered.
6834 go func() {
6935 defer close(done)
70- d.runDurableMaintenance(ctx)
36+ d.runStartupGarbageCollection(ctx)
7137 }()
7238 }
7339
74-func (d *Daemon) runDurableMaintenance(ctx context.Context) {
40+// runStartupGarbageCollection waits for catalogue-driven restoration, then
41+// takes a fresh catalogue snapshot and applies GC exactly once. A catalogue
42+// read failure never produces a keep set, so destructive collection cannot run
43+// without known-good catalogue state.
44+func (d *Daemon) runStartupGarbageCollection(ctx context.Context) {
7545 select {
7646 case <-ctx.Done():
7747 return
7848 case <-d.restoreDone:
7949 }
80- for {
81- incomplete := d.runDurableMaintenanceTick(ctx)
82- if ctx.Err() != nil {
83- return
84- }
85- if incomplete {
86- continue
87- }
88- timer := d.clock.NewTimer(maintenanceInterval)
89- select {
90- case <-ctx.Done():
91- timer.Stop()
92- return
93- case <-timer.C():
94- }
95- }
96-}
97-
98-// runDurableMaintenanceTick reports whether bounded work remains and should be
99-// continued immediately rather than delayed until the periodic interval.
100-func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) bool {
10150 deps := &d.maintenance
10251 if deps.catalogue == nil || deps.repository == nil {
103- return false
52+ return
10453 }
105-
10654 records, err := deps.catalogue.Records()
10755 if err != nil {
108- d.log.Warn("durable_maintenance_tick", "action", "catalogue-read", "done", false, "err", err)
109- return false
110- }
111- d.mu.Lock()
112- restoringIncarnations := make(map[domain.IncarnationID]struct{})
113- for _, stopped := range d.stopped {
114- if stopped.state == runtimeRestoring {
115- restoringIncarnations[stopped.incarnation] = struct{}{}
116- }
56+ d.log.Warn("snapshot_garbage_collection_skipped", "reason", "catalogue-read-failed", "err", err)
57+ return
11758 }
118- d.mu.Unlock()
119-
120- incomplete := false
59+ keep := make(map[domain.IncarnationID]domain.CheckpointRef, len(records))
12160 for _, record := range records {
122- if err := ctx.Err(); err != nil {
123- return false
61+ var committed domain.CheckpointRef
62+ if record.Committed != nil {
63+ committed = *record.Committed
12464 }
125- _, unresolved := deps.unresolved[record.IncarnationID]
126- _, restoring := restoringIncarnations[record.IncarnationID]
127- plan := retentionPlan(record, unresolved, restoring)
128- done, err := deps.repository.MaintainSession(ctx, plan, ports.MaintenanceBudget{Entries: maintenanceEntries, Bytes: maintenanceBytes})
129- if err == nil && !done {
130- incomplete = true
131- }
132- d.log.Info("durable_maintenance_session",
133- "incarnation", record.IncarnationID.String(),
134- "action", "retention",
135- "pin_all", plan.PinAll,
136- "requested_entries", maintenanceEntries,
137- "requested_bytes", maintenanceBytes,
138- "done", done,
139- "err", err,
140- )
65+ keep[record.IncarnationID] = committed
66+ }
67+ if err := deps.repository.CollectGarbage(ctx, keep); err != nil {
68+ d.log.Warn("snapshot_garbage_collection_failed", "incarnations", len(keep), "err", err)
69+ return
14170 }
142- return incomplete
71+ d.log.Info("snapshot_garbage_collection_complete", "incarnations", len(keep))
14372 }
M internal/usecase/daemon/maintenance_test.go +24 -36
@@ -2,55 +2,43 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
99 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1010 "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212 )
1313
14-func TestProductionMaintenanceUsesCatalogue(t *testing.T) {
15- record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}
14+func TestStartupGarbageCollectionUsesValidatedCatalogue(t *testing.T) {
15+ committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}
16+ records := []domain.CatalogueRecord{
17+ {Name: "work", IncarnationID: domain.IncarnationID{1}, Committed: &committed},
18+ {Name: "fresh", IncarnationID: domain.IncarnationID{2}},
19+ }
1620 catalogue := portsmocks.NewMockCatalogue(t)
17- catalogue.EXPECT().Records().Return([]domain.CatalogueRecord{record}, nil).Once()
21+ catalogue.EXPECT().Records().Return(records, nil).Once()
1822 repository := portsmocks.NewMockSnapshotRepository(t)
19- repository.EXPECT().MaintainSession(mock.Anything, mock.MatchedBy(func(plan ports.RetentionPlan) bool {
20- return plan.IncarnationID == record.IncarnationID && !plan.PinAll && len(plan.Keep) == 0
21- }), ports.MaintenanceBudget{Entries: maintenanceEntries, Bytes: maintenanceBytes}).Return(true, nil).Once()
23+ repository.EXPECT().CollectGarbage(mock.Anything, mock.MatchedBy(func(keep map[domain.IncarnationID]domain.CheckpointRef) bool {
24+ return len(keep) == 2 && keep[records[0].IncarnationID] == committed && keep[records[1].IncarnationID] == (domain.CheckpointRef{})
25+ })).Return(nil).Once()
2226 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
23- WithDurableMaintenance(catalogue, repository, nil)(d)
27+ WithDurableMaintenance(catalogue, repository)(d)
28+ d.closeRestoreDone()
2429
25- require.False(t, d.runDurableMaintenanceTick(context.Background()))
30+ d.runStartupGarbageCollection(context.Background())
2631 }
2732
28-func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) {
29- committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}
30- healthy := domain.CatalogueRecord{
31- Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy,
32- Committed: &committed,
33- }
33+func TestStartupGarbageCollectionSkipsUnknownCatalogueState(t *testing.T) {
34+ catalogue := portsmocks.NewMockCatalogue(t)
35+ catalogue.EXPECT().Records().Return(nil, errors.New("catalogue read failed")).Once()
36+ repository := portsmocks.NewMockSnapshotRepository(t)
37+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
38+ WithDurableMaintenance(catalogue, repository)(d)
39+ d.closeRestoreDone()
3440
35- for _, tc := range []struct {
36- name string
37- record domain.CatalogueRecord
38- unresolved bool
39- restoring bool
40- wantPin bool
41- }{
42- {name: "healthy", record: healthy},
43- {name: "pending deletion", record: healthy, unresolved: true, wantPin: true},
44- {name: "restoring", record: healthy, restoring: true, wantPin: true},
45- {name: "degraded", record: func() domain.CatalogueRecord { r := healthy; r.RecoveryState = domain.RecoveryDegraded; return r }(), wantPin: true},
46- {name: "deleting", record: func() domain.CatalogueRecord { r := healthy; r.RecoveryState = domain.RecoveryDeleting; return r }(), wantPin: true},
47- } {
48- t.Run(tc.name, func(t *testing.T) {
49- plan := retentionPlan(tc.record, tc.unresolved, tc.restoring)
50- require.Equal(t, tc.wantPin, plan.PinAll)
51- if !tc.wantPin {
52- require.Equal(t, []ports.CheckpointRef{committed}, plan.Keep)
53- }
54- })
55- }
41+ d.runStartupGarbageCollection(context.Background())
42+ repository.AssertNotCalled(t, "CollectGarbage", mock.Anything, mock.Anything)
43+ require.NotNil(t, d.maintenance.catalogue)
5644 }
M internal/usecase/daemon/session.go +9 -15
@@ -728,10 +728,8 @@ func (d *Daemon) renameSession(sess *session, name string) error {
728728 return err
729729 }
730730
731- // Reserve the rename while state is inspected, then perform persistence and
732- // repository I/O without daemon/session locks. The old identity is first
733- // quarantined and durably purged; committing the new record before that
734- // transaction completes could leave a restorable old legacy source.
731+ // Reserve the rename while state is inspected, then persist the new name
732+ // without changing the incarnation-keyed snapshot namespace.
735733 d.mu.Lock()
736734 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
737735 d.mu.Unlock()
@@ -1036,10 +1034,9 @@ func (d *Daemon) beginSnapshotPurge(_ string, _ domain.IncarnationID) error {
10361034 return nil
10371035 }
10381036
1039-// finishSnapshotPurge deletes both independently durable snapshot sources,
1040-// then metadata, and only then clears the tombstone. Any failure deliberately
1041-// leaves the marker in place so startup cannot restore or import the name and
1042-// a later live/offline kill can retry the idempotent source deletes.
1037+// finishSnapshotPurge removes catalogue metadata first, then deletes the
1038+// incarnation directory. Startup garbage collection removes the directory if
1039+// the second step is interrupted.
10431040 func (d *Daemon) finishSnapshotPurge(ctx context.Context, name string, _ domain.IncarnationID) error {
10441041 if !d.persistEnabled {
10451042 return nil
@@ -1240,10 +1237,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
12401237 name := sess.name
12411238 incarnation := sess.incarnation
12421239 sess.mu.Unlock()
1243- // Commit the tombstone before changing live identity or metadata. A
1244- // repository delete must never race an older publication that could recreate
1245- // the just-quarantined name. Do not hold daemon or session locks while
1246- // tombstoning or joining: a repository implementation may block on I/O.
1240+ // Join snapshot publication before changing live identity or deleting the
1241+ // incarnation directory, so an older publication cannot recreate it.
12471242 if purge && !isEphemeral {
12481243 if err := d.beginSnapshotPurge(name, incarnation); err != nil {
12491244 return err
@@ -1377,9 +1372,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
13771372 }
13781373 }
13791374 // The coordinator and all panes are now stopped, so no producer can publish
1380- // another generation after this destructive source deletion. Keep the
1381- // tombstone and hidden stopped record when any source or metadata operation
1382- // fails; retryStoppedPurge (including a repeated live kill) resumes safely.
1375+ // another generation after this destructive source deletion. Keep the hidden
1376+ // stopped record when deletion fails so a repeated live kill can retry.
13831377 if !ephemeral && purge {
13841378 if err := d.finishSnapshotPurge(d.serveCtx, stoppedName, incarnation); err != nil {
13851379 purgeErr = errors.Join(purgeErr, err)
M internal/usecase/daemon/session_purge_control_test.go +2 -3
@@ -25,12 +25,11 @@ func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) {
2525 d.stopped["work"] = stoppedSession{name: "work", incarnation: record.IncarnationID, record: record}
2626
2727 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)
28- require.True(t, repository.tombstoned["work"])
29- require.Equal(t, []string{"tombstone", "delete incarnation"}, repository.calls)
28+ require.Empty(t, repository.calls, "snapshot deletion must wait for catalogue removal")
3029
3130 state.mu.Lock()
3231 state.deleteErr = nil
3332 state.mu.Unlock()
3433 require.NoError(t, d.retryStoppedPurge("work"))
35- require.Equal(t, []string{"tombstone", "delete incarnation", "tombstone", "delete incarnation", "clear tombstone"}, repository.calls)
34+ require.Equal(t, []string{"delete incarnation"}, repository.calls)
3635 }
M internal/usecase/daemon/session_purge_retry_test.go +6 -35
@@ -15,26 +15,10 @@ import (
1515 type retryablePurgeRepository struct {
1616 noOpSnapshotRepository
1717 deleteErr error
18- tombstoned map[string]bool
1918 calls []string
2019 deadlineScoped []bool
2120 }
2221
23-func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {
24- return nil
25-}
26-
27-func (r *retryablePurgeRepository) WriteDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
28- _, hasDeadline := ctx.Deadline()
29- r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
30- if r.tombstoned == nil {
31- r.tombstoned = make(map[string]bool)
32- }
33- r.calls = append(r.calls, "tombstone")
34- r.tombstoned[tombstone.Name] = true
35- return nil
36-}
37-
3822 func (r *retryablePurgeRepository) DeleteIncarnation(ctx context.Context, _ domain.IncarnationID) error {
3923 _, hasDeadline := ctx.Deadline()
4024 r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
@@ -42,15 +26,7 @@ func (r *retryablePurgeRepository) DeleteIncarnation(ctx context.Context, _ doma
4226 return r.deleteErr
4327 }
4428
45-func (r *retryablePurgeRepository) DeleteDeletionTombstone(ctx context.Context, _ domain.IncarnationID) error {
46- _, hasDeadline := ctx.Deadline()
47- r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
48- r.calls = append(r.calls, "clear tombstone")
49- clear(r.tombstoned)
50- return nil
51-}
52-
53-func TestLivePurgeRetainsTombstoneAcrossPartialDeletion(t *testing.T) {
29+func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *testing.T) {
5430 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
5531 repository := &retryablePurgeRepository{deleteErr: errors.New("delete failed")}
5632 WithSnapshotRepository(repository)(d)
@@ -66,14 +42,9 @@ func TestLivePurgeRetainsTombstoneAcrossPartialDeletion(t *testing.T) {
6642 d.sessions = map[domain.SessionID]*session{sess.id: sess}
6743
6844 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
69- require.True(t, repository.tombstoned["work"])
70- require.Equal(t, []string{"tombstone", "delete incarnation"}, repository.calls)
71-
72- repository.deleteErr = nil
73- require.NoError(t, d.retryStoppedPurge("work"))
74- require.False(t, repository.tombstoned["work"])
75- require.Equal(t, []string{"tombstone", "delete incarnation", "tombstone", "delete incarnation", "clear tombstone"}, repository.calls)
76- for _, scoped := range repository.deadlineScoped {
77- require.True(t, scoped)
78- }
45+ require.Equal(t, []string{"delete incarnation"}, repository.calls)
46+ _, exists, err := d.catalogue.Record("work")
47+ require.NoError(t, err)
48+ require.False(t, exists, "catalogue removal commits before best-effort directory deletion")
49+ require.Equal(t, []bool{true}, repository.deadlineScoped)
7950 }
M internal/usecase/daemon/snapshot_restore_incremental.go +1 -2
@@ -260,8 +260,7 @@ func isRetryableRestoreLoadError(err error) bool {
260260 if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
261261 return false
262262 }
263- if errors.Is(err, ports.ErrBudgetExhausted) ||
264- errors.Is(err, syscall.ENOSPC) ||
263+ if errors.Is(err, syscall.ENOSPC) ||
265264 errors.Is(err, syscall.ENOMEM) ||
266265 errors.Is(err, syscall.EMFILE) ||
267266 errors.Is(err, syscall.ENFILE) ||
M internal/usecase/daemon/snapshot_test.go +2 -11
@@ -101,20 +101,11 @@ func (noOpSnapshotRepository) LoadCheckpoint(context.Context, domain.Incarnation
101101 func (noOpSnapshotRepository) RepairHEAD(context.Context, domain.IncarnationID, ports.CheckpointRef) error {
102102 return nil
103103 }
104-func (noOpSnapshotRepository) WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error {
105- return nil
106-}
107-func (noOpSnapshotRepository) ListDeletionTombstones(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {
108- return ports.DeletionTombstonePage{Done: true}, nil
109-}
110-func (noOpSnapshotRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {
111- return nil
112-}
113104 func (noOpSnapshotRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
114105 return nil
115106 }
116-func (noOpSnapshotRepository) MaintainSession(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error) {
117- return true, nil
107+func (noOpSnapshotRepository) CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error {
108+ return nil
118109 }
119110
120111 func awaitSnapshotIdle(t testing.TB, sess *session) {
M internal/usecase/recovery/coordinator.go +5 -204
@@ -1,5 +1,5 @@
1-// Package recovery coordinates crash-resumable operations across the catalogue
2-// and snapshot repository without depending on filesystem adapters.
1+// Package recovery coordinates idempotent operations across the catalogue and
2+// snapshot repository without depending on filesystem adapters.
33 package recovery
44
55 import (
@@ -14,8 +14,6 @@ import (
1414 "github.com/bnema/vev/internal/ports"
1515 )
1616
17-var recoveryListingBudget = ports.MaintenanceBudget{Entries: 64, Bytes: 64 << 10}
18-
1917 type KeyLocks struct {
2018 mu sync.Mutex
2119 refs map[string]*keyLockRef
@@ -83,52 +81,11 @@ func compactStrings(values []string) []string {
8381 return out
8482 }
8583
86-var (
87- // ErrDeletionTombstoneInvalid marks a tombstone whose own identity cannot be
88- // used. ErrDeletionTombstoneConflict marks a tombstone that disagrees with a
89- // live catalogue record. Both are session-scoped: they fence one session
90- // instead of failing startup.
91- ErrDeletionTombstoneInvalid = errors.New("recovery: deletion tombstone is unusable")
92- ErrDeletionTombstoneConflict = errors.New("recovery: deletion tombstone conflicts with catalogue")
93- // ErrSessionRecordInvalid marks a catalogue record whose own contents cannot
94- // be advanced deterministically.
95- ErrSessionRecordInvalid = errors.New("recovery: catalogue record is unusable")
96-)
97-
98-// Degraded reasons are content-free by construction: they never carry paths,
99-// terminal content, or operator input.
100-const (
101- degradedReasonTombstone = "deletion tombstone conflict"
102- degradedReasonRecord = "catalogue record conflict"
103-)
104-
10584 type Coordinator struct {
10685 catalogue ports.Catalogue
10786 repository ports.SnapshotRepository
10887 locks *KeyLocks
10988 random io.Reader
110-
111- conflictMu sync.Mutex
112- conflicts []RecoveryConflict
113-}
114-
115-// RecoveryConflict is one session-scoped item Recover fenced off. Recover keeps
116-// the durable source (intent or tombstone) so a later operator action can still
117-// resolve it; startup continues for every other session.
118-type RecoveryConflict struct {
119- Session string
120- Kind string
121- Err error
122-}
123-
124-// Conflicts returns the session-scoped items fenced by the most recent Recover call.
125-func (c *Coordinator) Conflicts() []RecoveryConflict {
126- if c == nil {
127- return nil
128- }
129- c.conflictMu.Lock()
130- defer c.conflictMu.Unlock()
131- return append([]RecoveryConflict(nil), c.conflicts...)
13289 }
13390
13491 func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, random io.Reader) *Coordinator {
@@ -203,8 +160,8 @@ func (c *Coordinator) Rename(ctx context.Context, oldName, newName string) (doma
203160 return record, nil
204161 }
205162
206-// Delete executes the durable deletion protocol. Every completed boundary is
207-// independently sufficient for Recover to roll the operation forward.
163+// Delete removes the catalogue record before deleting its incarnation. A crash
164+// between those steps leaves an orphan that startup garbage collection removes.
208165 func (c *Coordinator) Delete(ctx context.Context, name string) error {
209166 if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
210167 return errors.New("recovery: incomplete delete dependencies")
@@ -221,164 +178,8 @@ func (c *Coordinator) Delete(ctx context.Context, name string) error {
221178 if !ok {
222179 return nil
223180 }
224- return c.deleteLocked(ctx, record)
225-}
226-
227-func (c *Coordinator) deleteLocked(ctx context.Context, record domain.CatalogueRecord) error {
228- if err := ctx.Err(); err != nil {
229- return err
230- }
231- if record.RecoveryState != domain.RecoveryDeleting {
232- record.RecoveryState = domain.RecoveryDeleting
233- record.DegradedReason = ""
234- if err := record.Validate(); err != nil {
235- return fmt.Errorf("%w: invalid deleting record: %w", ErrSessionRecordInvalid, err)
236- }
237- if err := c.catalogue.Replace(record.Name, record); err != nil {
238- return err
239- }
240- }
241- tombstone := domain.DeletionTombstone{Name: record.Name, IncarnationID: record.IncarnationID}
242- if err := c.repository.WriteDeletionTombstone(ctx, tombstone); err != nil {
243- return err
244- }
245- if err := c.repository.DeleteIncarnation(ctx, record.IncarnationID); err != nil {
246- return err
247- }
248181 if err := c.catalogue.Delete(record.Name); err != nil {
249182 return err
250183 }
251- return c.repository.DeleteDeletionTombstone(ctx, record.IncarnationID)
252-}
253-
254-// Recover rolls deleting records and strict deletion tombstones forward. The
255-// two sources are enumerated independently because catalogue removal precedes
256-// tombstone removal in the commit protocol.
257-//
258-// A per-item failure that only proves one session's durable state disagrees
259-// with itself fences that session and lets startup continue: healthy sessions
260-// must still restore next to a corrupt neighbour. Infrastructure failures
261-// (listing, repository, or catalogue IO, and a non-advancing cursor) still
262-// abort startup, because they say nothing about any single session.
263-func (c *Coordinator) Recover(ctx context.Context) error {
264- if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
265- return errors.New("recovery: incomplete coordinator dependencies")
266- }
267- c.conflictMu.Lock()
268- c.conflicts = nil
269- c.conflictMu.Unlock()
270-
271- records, err := c.catalogue.Records()
272- if err != nil {
273- return err
274- }
275- for _, candidate := range records {
276- if candidate.RecoveryState != domain.RecoveryDeleting {
277- continue
278- }
279- unlock := c.locks.Lock([]string{candidate.Name})
280- var deleteErr error
281- current, ok, readErr := c.catalogue.Record(candidate.Name)
282- if readErr != nil {
283- unlock()
284- return readErr
285- }
286- if ok && current.IncarnationID == candidate.IncarnationID && current.RecoveryState == domain.RecoveryDeleting {
287- deleteErr = c.deleteLocked(ctx, current)
288- }
289- unlock()
290- if deleteErr != nil {
291- if err := c.fenceSession(candidate.Name, "catalogue-record", degradedReasonRecord, deleteErr); err != nil {
292- return err
293- }
294- }
295- }
296-
297- cursor := ports.DeletionTombstoneCursor{}
298- for {
299- page, err := c.repository.ListDeletionTombstones(ctx, cursor, recoveryListingBudget)
300- if err != nil {
301- return err
302- }
303- for _, tombstone := range page.Tombstones {
304- // A fenced tombstone is never deleted here: the next startup or an
305- // explicit operator action must still be able to observe it.
306- if err := c.recoverDeletionTombstone(ctx, tombstone); err != nil {
307- if err := c.fenceSession(tombstone.Name, "deletion-tombstone", degradedReasonTombstone, err); err != nil {
308- return err
309- }
310- }
311- }
312- if page.Done {
313- return nil
314- }
315- if page.Next.After == cursor.After {
316- return errors.New("recovery: tombstone listing did not advance")
317- }
318- cursor = page.Next
319- }
320-}
321-
322-// fenceSession returns cause unchanged when it is not session-scoped, so the
323-// caller aborts startup. For a session-scoped cause it records the conflict and
324-// durably marks the session degraded when the record can represent that state.
325-func (c *Coordinator) fenceSession(name, kind, reason string, cause error) error {
326- if !isSessionScopedConflict(cause) {
327- return cause
328- }
329- c.conflictMu.Lock()
330- c.conflicts = append(c.conflicts, RecoveryConflict{Session: name, Kind: kind, Err: cause})
331- c.conflictMu.Unlock()
332-
333- unlock := c.locks.Lock([]string{name})
334- defer unlock()
335- record, ok, err := c.catalogue.Record(name)
336- if err != nil {
337- return err
338- }
339- if !ok || record.RecoveryState != domain.RecoveryHealthy {
340- // Fresh records cannot represent degraded state (it requires a committed
341- // checkpoint), and deleting or already degraded records must keep the
342- // state their own protocol owns. Fencing is then the recorded skip.
343- return nil
344- }
345- record.RecoveryState = domain.RecoveryDegraded
346- record.DegradedReason = reason
347- if err := record.Validate(); err != nil {
348- return nil
349- }
350- // A catalogue write failure here is infrastructure, not one session's data.
351- return c.catalogue.Replace(name, record)
352-}
353-
354-// isSessionScopedConflict reports whether err proves only that one session's
355-// durable state is self-inconsistent. Everything else - IO, decode, traversal,
356-// context cancellation - is treated as infrastructure and aborts startup.
357-func isSessionScopedConflict(err error) bool {
358- return errors.Is(err, ErrDeletionTombstoneConflict) || errors.Is(err, ErrDeletionTombstoneInvalid) ||
359- errors.Is(err, ErrSessionRecordInvalid)
360-}
361-
362-func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
363- if err := tombstone.Validate(); err != nil {
364- return fmt.Errorf("%w: %w", ErrDeletionTombstoneInvalid, err)
365- }
366- unlock := c.locks.Lock([]string{tombstone.Name})
367- defer unlock()
368- record, exists, err := c.catalogue.Record(tombstone.Name)
369- if err != nil {
370- return err
371- }
372- if exists && record.IncarnationID == tombstone.IncarnationID && record.RecoveryState != domain.RecoveryDeleting {
373- return fmt.Errorf("%w: non-deleting session %q", ErrDeletionTombstoneConflict, tombstone.Name)
374- }
375- if err := c.repository.DeleteIncarnation(ctx, tombstone.IncarnationID); err != nil {
376- return err
377- }
378- if exists && record.IncarnationID == tombstone.IncarnationID {
379- if err := c.catalogue.Delete(tombstone.Name); err != nil {
380- return err
381- }
382- }
383- return c.repository.DeleteDeletionTombstone(ctx, tombstone.IncarnationID)
184+ return c.repository.DeleteIncarnation(ctx, record.IncarnationID)
384185 }
D internal/usecase/recovery/coordinator_test.go +0 -30
@@ -1,30 +0,0 @@
1-package recovery
2-
3-import (
4- "context"
5- "errors"
6- "testing"
7-
8- "github.com/bnema/vev/internal/ports"
9- portsmocks "github.com/bnema/vev/internal/ports/mocks"
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestDeleteChecksCancellationBeforeCatalogueAccess(t *testing.T) {
14- ctx, cancel := context.WithCancel(t.Context())
15- cancel()
16- catalogue := portsmocks.NewMockCatalogue(t)
17- repository := portsmocks.NewMockSnapshotRepository(t)
18- coordinator := NewCoordinator(catalogue, repository, nil)
19-
20- require.ErrorIs(t, coordinator.Delete(ctx, "work"), context.Canceled)
21-}
22-
23-func TestCoordinatorRecoverPagesTombstonesAndPropagatesErrors(t *testing.T) {
24- cause := errors.New("malformed tombstone")
25- repo := portsmocks.NewMockSnapshotRepository(t)
26- repo.EXPECT().ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, recoveryListingBudget).
27- Return(ports.DeletionTombstonePage{}, cause)
28- c := NewCoordinator(newTransactionCatalogue(), repo, nil)
29- require.ErrorIs(t, c.Recover(context.Background()), cause)
30-}

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