detached 0b9089a

fix(recovery): complete migration startup integration

commit
0b9089a4f153e06cc7544158166350bed80c2f7c
author
brice · · 2026-07-25 21:31 UTC
committer
brice · · 2026-07-25 21:31 UTC
parent
00a4e55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
36 files · +825 -518
A internal/adapters/recoveryfs/atomic.go +50 -0
@@ -0,0 +1,50 @@
1+package recoveryfs
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/vev/pkg/safedir"
9+)
10+
11+func atomicWrite(path string, data []byte) (retErr error) {
12+ dir := filepath.Dir(path)
13+ if err := safedir.EnsurePrivate(dir); err != nil {
14+ return err
15+ }
16+ file, err := os.CreateTemp(dir, ".intent-*")
17+ if err != nil {
18+ return err
19+ }
20+ tmp := file.Name()
21+ defer func() {
22+ if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
23+ retErr = errors.Join(retErr, err)
24+ }
25+ }()
26+ if err := file.Chmod(0o600); err != nil {
27+ return errors.Join(err, file.Close())
28+ }
29+ if _, err := file.Write(data); err != nil {
30+ return errors.Join(err, file.Close())
31+ }
32+ if err := file.Sync(); err != nil {
33+ return errors.Join(err, file.Close())
34+ }
35+ if err := file.Close(); err != nil {
36+ return err
37+ }
38+ if err := os.Rename(tmp, path); err != nil {
39+ return err
40+ }
41+ return syncDir(dir)
42+}
43+
44+func syncDir(dir string) error {
45+ file, err := os.Open(dir)
46+ if err != nil {
47+ return err
48+ }
49+ return errors.Join(file.Sync(), file.Close())
50+}
@@ -0,0 +1,50 @@
1+package recoveryfs
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/vev/pkg/safedir"
9+)
10+
11+func atomicWrite(path string, data []byte) (retErr error) {
12+ dir := filepath.Dir(path)
13+ if err := safedir.EnsurePrivate(dir); err != nil {
14+ return err
15+ }
16+ file, err := os.CreateTemp(dir, ".intent-*")
17+ if err != nil {
18+ return err
19+ }
20+ tmp := file.Name()
21+ defer func() {
22+ if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
23+ retErr = errors.Join(retErr, err)
24+ }
25+ }()
26+ if err := file.Chmod(0o600); err != nil {
27+ return errors.Join(err, file.Close())
28+ }
29+ if _, err := file.Write(data); err != nil {
30+ return errors.Join(err, file.Close())
31+ }
32+ if err := file.Sync(); err != nil {
33+ return errors.Join(err, file.Close())
34+ }
35+ if err := file.Close(); err != nil {
36+ return err
37+ }
38+ if err := os.Rename(tmp, path); err != nil {
39+ return err
40+ }
41+ return syncDir(dir)
42+}
43+
44+func syncDir(dir string) error {
45+ file, err := os.Open(dir)
46+ if err != nil {
47+ return err
48+ }
49+ return errors.Join(file.Sync(), file.Close())
50+}
A internal/adapters/recoveryfs/journal.go +142 -0
@@ -0,0 +1,142 @@
1+package recoveryfs
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "sort"
13+ "strings"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/pkg/safedir"
17+)
18+
19+const discardVersion = 1
20+
21+type Journal struct{ dir string }
22+
23+type discardFile struct {
24+ Version int `json:"version"`
25+ Intent domain.DiscardIntent `json:"intent"`
26+}
27+
28+func New(stateDir string) *Journal { return &Journal{dir: filepath.Join(stateDir, "transactions")} }
29+
30+func validateDiscard(intent domain.DiscardIntent) error {
31+ if err := intent.OldRecord.Validate(); err != nil {
32+ return fmt.Errorf("recovery journal: old record: %w", err)
33+ }
34+ if intent.SessionName != intent.OldRecord.Name || intent.OldIncarnation != intent.OldRecord.IncarnationID {
35+ return errors.New("recovery journal: inconsistent old identity")
36+ }
37+ if intent.NewIncarnation == (domain.IncarnationID{}) || intent.NewIncarnation == intent.OldIncarnation || intent.Reason == "" {
38+ return errors.New("recovery journal: incomplete discard intent")
39+ }
40+ return nil
41+}
42+
43+func encodeDiscard(intent domain.DiscardIntent) ([]byte, error) {
44+ if err := validateDiscard(intent); err != nil {
45+ return nil, err
46+ }
47+ return json.Marshal(discardFile{Version: discardVersion, Intent: intent})
48+}
49+
50+func decodeDiscard(data []byte) (domain.DiscardIntent, error) {
51+ decoder := json.NewDecoder(bytes.NewReader(data))
52+ decoder.DisallowUnknownFields()
53+ var file discardFile
54+ if err := decoder.Decode(&file); err != nil {
55+ return domain.DiscardIntent{}, err
56+ }
57+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
58+ return domain.DiscardIntent{}, errors.New("recovery journal: trailing data")
59+ }
60+ if file.Version != discardVersion {
61+ return domain.DiscardIntent{}, errors.New("recovery journal: unsupported version")
62+ }
63+ return file.Intent, validateDiscard(file.Intent)
64+}
65+
66+func (j *Journal) path(id domain.IncarnationID) string {
67+ return filepath.Join(j.dir, "discard-"+id.String())
68+}
69+
70+func (j *Journal) SaveDiscard(ctx context.Context, intent domain.DiscardIntent) error {
71+ if err := ctx.Err(); err != nil {
72+ return err
73+ }
74+ data, err := encodeDiscard(intent)
75+ if err != nil {
76+ return err
77+ }
78+ path := j.path(intent.OldIncarnation)
79+ if existing, err := os.ReadFile(path); err == nil {
80+ if !bytes.Equal(existing, data) {
81+ return errors.New("recovery journal: discard identity collision")
82+ }
83+ return nil
84+ } else if !errors.Is(err, os.ErrNotExist) {
85+ return err
86+ }
87+ return atomicWrite(path, data)
88+}
89+
90+func (j *Journal) ListDiscards(ctx context.Context) ([]domain.DiscardIntent, error) {
91+ entries, err := os.ReadDir(j.dir)
92+ if errors.Is(err, os.ErrNotExist) {
93+ return nil, nil
94+ }
95+ if err != nil {
96+ return nil, err
97+ }
98+ sort.Slice(entries, func(a, b int) bool { return entries[a].Name() < entries[b].Name() })
99+ out := make([]domain.DiscardIntent, 0, len(entries))
100+ seen := make(map[domain.IncarnationID]struct{}, len(entries))
101+ for _, entry := range entries {
102+ if err := ctx.Err(); err != nil {
103+ return nil, err
104+ }
105+ if entry.IsDir() || !strings.HasPrefix(entry.Name(), "discard-") {
106+ return nil, errors.New("recovery journal: invalid transaction entry")
107+ }
108+ data, err := os.ReadFile(filepath.Join(j.dir, entry.Name()))
109+ if err != nil {
110+ return nil, err
111+ }
112+ intent, err := decodeDiscard(data)
113+ if err != nil {
114+ return nil, err
115+ }
116+ if entry.Name() != filepath.Base(j.path(intent.OldIncarnation)) {
117+ return nil, errors.New("recovery journal: non-canonical intent filename")
118+ }
119+ if _, ok := seen[intent.OldIncarnation]; ok {
120+ return nil, errors.New("recovery journal: duplicate discard identity")
121+ }
122+ seen[intent.OldIncarnation] = struct{}{}
123+ out = append(out, intent)
124+ }
125+ return out, nil
126+}
127+
128+func (j *Journal) DeleteDiscard(ctx context.Context, id domain.IncarnationID) error {
129+ if err := ctx.Err(); err != nil {
130+ return err
131+ }
132+ if id == (domain.IncarnationID{}) {
133+ return errors.New("recovery journal: zero discard identity")
134+ }
135+ if err := safedir.EnsurePrivate(j.dir); err != nil {
136+ return err
137+ }
138+ if err := os.Remove(j.path(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
139+ return err
140+ }
141+ return syncDir(j.dir)
142+}
@@ -0,0 +1,142 @@
1+package recoveryfs
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "sort"
13+ "strings"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/pkg/safedir"
17+)
18+
19+const discardVersion = 1
20+
21+type Journal struct{ dir string }
22+
23+type discardFile struct {
24+ Version int `json:"version"`
25+ Intent domain.DiscardIntent `json:"intent"`
26+}
27+
28+func New(stateDir string) *Journal { return &Journal{dir: filepath.Join(stateDir, "transactions")} }
29+
30+func validateDiscard(intent domain.DiscardIntent) error {
31+ if err := intent.OldRecord.Validate(); err != nil {
32+ return fmt.Errorf("recovery journal: old record: %w", err)
33+ }
34+ if intent.SessionName != intent.OldRecord.Name || intent.OldIncarnation != intent.OldRecord.IncarnationID {
35+ return errors.New("recovery journal: inconsistent old identity")
36+ }
37+ if intent.NewIncarnation == (domain.IncarnationID{}) || intent.NewIncarnation == intent.OldIncarnation || intent.Reason == "" {
38+ return errors.New("recovery journal: incomplete discard intent")
39+ }
40+ return nil
41+}
42+
43+func encodeDiscard(intent domain.DiscardIntent) ([]byte, error) {
44+ if err := validateDiscard(intent); err != nil {
45+ return nil, err
46+ }
47+ return json.Marshal(discardFile{Version: discardVersion, Intent: intent})
48+}
49+
50+func decodeDiscard(data []byte) (domain.DiscardIntent, error) {
51+ decoder := json.NewDecoder(bytes.NewReader(data))
52+ decoder.DisallowUnknownFields()
53+ var file discardFile
54+ if err := decoder.Decode(&file); err != nil {
55+ return domain.DiscardIntent{}, err
56+ }
57+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
58+ return domain.DiscardIntent{}, errors.New("recovery journal: trailing data")
59+ }
60+ if file.Version != discardVersion {
61+ return domain.DiscardIntent{}, errors.New("recovery journal: unsupported version")
62+ }
63+ return file.Intent, validateDiscard(file.Intent)
64+}
65+
66+func (j *Journal) path(id domain.IncarnationID) string {
67+ return filepath.Join(j.dir, "discard-"+id.String())
68+}
69+
70+func (j *Journal) SaveDiscard(ctx context.Context, intent domain.DiscardIntent) error {
71+ if err := ctx.Err(); err != nil {
72+ return err
73+ }
74+ data, err := encodeDiscard(intent)
75+ if err != nil {
76+ return err
77+ }
78+ path := j.path(intent.OldIncarnation)
79+ if existing, err := os.ReadFile(path); err == nil {
80+ if !bytes.Equal(existing, data) {
81+ return errors.New("recovery journal: discard identity collision")
82+ }
83+ return nil
84+ } else if !errors.Is(err, os.ErrNotExist) {
85+ return err
86+ }
87+ return atomicWrite(path, data)
88+}
89+
90+func (j *Journal) ListDiscards(ctx context.Context) ([]domain.DiscardIntent, error) {
91+ entries, err := os.ReadDir(j.dir)
92+ if errors.Is(err, os.ErrNotExist) {
93+ return nil, nil
94+ }
95+ if err != nil {
96+ return nil, err
97+ }
98+ sort.Slice(entries, func(a, b int) bool { return entries[a].Name() < entries[b].Name() })
99+ out := make([]domain.DiscardIntent, 0, len(entries))
100+ seen := make(map[domain.IncarnationID]struct{}, len(entries))
101+ for _, entry := range entries {
102+ if err := ctx.Err(); err != nil {
103+ return nil, err
104+ }
105+ if entry.IsDir() || !strings.HasPrefix(entry.Name(), "discard-") {
106+ return nil, errors.New("recovery journal: invalid transaction entry")
107+ }
108+ data, err := os.ReadFile(filepath.Join(j.dir, entry.Name()))
109+ if err != nil {
110+ return nil, err
111+ }
112+ intent, err := decodeDiscard(data)
113+ if err != nil {
114+ return nil, err
115+ }
116+ if entry.Name() != filepath.Base(j.path(intent.OldIncarnation)) {
117+ return nil, errors.New("recovery journal: non-canonical intent filename")
118+ }
119+ if _, ok := seen[intent.OldIncarnation]; ok {
120+ return nil, errors.New("recovery journal: duplicate discard identity")
121+ }
122+ seen[intent.OldIncarnation] = struct{}{}
123+ out = append(out, intent)
124+ }
125+ return out, nil
126+}
127+
128+func (j *Journal) DeleteDiscard(ctx context.Context, id domain.IncarnationID) error {
129+ if err := ctx.Err(); err != nil {
130+ return err
131+ }
132+ if id == (domain.IncarnationID{}) {
133+ return errors.New("recovery journal: zero discard identity")
134+ }
135+ if err := safedir.EnsurePrivate(j.dir); err != nil {
136+ return err
137+ }
138+ if err := os.Remove(j.path(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
139+ return err
140+ }
141+ return syncDir(j.dir)
142+}
A internal/adapters/recoveryfs/journal_test.go +32 -0
@@ -0,0 +1,32 @@
1+package recoveryfs
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRecoveryJournalRoundTripAndIdempotentDelete(t *testing.T) {
12+ j := New(t.TempDir())
13+ old := domain.IncarnationID{1}
14+ intent := domain.DiscardIntent{
15+ OldRecord: domain.CatalogueRecord{Name: "work", IncarnationID: old, RecoveryState: domain.RecoveryDegraded, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"},
16+ OldIncarnation: old, NewIncarnation: domain.IncarnationID{2}, SessionName: "work", Reason: "discard",
17+ }
18+ require.NoError(t, j.SaveDiscard(context.Background(), intent))
19+ got, err := j.ListDiscards(context.Background())
20+ require.NoError(t, err)
21+ require.Equal(t, []domain.DiscardIntent{intent}, got)
22+ require.NoError(t, j.DeleteDiscard(context.Background(), old))
23+ require.NoError(t, j.DeleteDiscard(context.Background(), old))
24+ got, err = j.ListDiscards(context.Background())
25+ require.NoError(t, err)
26+ require.Empty(t, got)
27+}
28+
29+func TestRecoveryJournalRejectsMalformedIntent(t *testing.T) {
30+ j := New(t.TempDir())
31+ require.Error(t, j.SaveDiscard(context.Background(), domain.DiscardIntent{}))
32+}
@@ -0,0 +1,32 @@
1+package recoveryfs
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRecoveryJournalRoundTripAndIdempotentDelete(t *testing.T) {
12+ j := New(t.TempDir())
13+ old := domain.IncarnationID{1}
14+ intent := domain.DiscardIntent{
15+ OldRecord: domain.CatalogueRecord{Name: "work", IncarnationID: old, RecoveryState: domain.RecoveryDegraded, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"},
16+ OldIncarnation: old, NewIncarnation: domain.IncarnationID{2}, SessionName: "work", Reason: "discard",
17+ }
18+ require.NoError(t, j.SaveDiscard(context.Background(), intent))
19+ got, err := j.ListDiscards(context.Background())
20+ require.NoError(t, err)
21+ require.Equal(t, []domain.DiscardIntent{intent}, got)
22+ require.NoError(t, j.DeleteDiscard(context.Background(), old))
23+ require.NoError(t, j.DeleteDiscard(context.Background(), old))
24+ got, err = j.ListDiscards(context.Background())
25+ require.NoError(t, err)
26+ require.Empty(t, got)
27+}
28+
29+func TestRecoveryJournalRejectsMalformedIntent(t *testing.T) {
30+ j := New(t.TempDir())
31+ require.Error(t, j.SaveDiscard(context.Background(), domain.DiscardIntent{}))
32+}
M internal/adapters/snapshot/atomic.go +5 -4
@@ -8,19 +8,20 @@ import (
88 "path/filepath"
99 "syscall"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/pkg/safedir"
1213 )
1314
14-func (r *Repository) ensureSession(key string) error {
15+func (r *Repository) ensureSession(id domain.IncarnationID) error {
1516 for _, directory := range []struct {
1617 path string
1718 phase string
1819 }{
1920 {r.dir, "repository"},
2021 {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
21- {r.sessionPath(key), "session"},
22- {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
23- {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
22+ {r.sessionPath(id), "session"},
23+ {filepath.Join(r.sessionPath(id), repositoryObjectsDir), "objects"},
24+ {filepath.Join(r.sessionPath(id), repositoryGenerations), "generations"},
2425 } {
2526 if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
2627 return fmt.Errorf("create snapshot repository directory: %w", err)
@@ -8,19 +8,20 @@ import (
8 "path/filepath"8 "path/filepath"
9 "syscall"9 "syscall"
10 10
11+ "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/pkg/safedir"12 "github.com/bnema/vev/pkg/safedir"
12 )13 )
13 14
14-func (r *Repository) ensureSession(key string) error {15+func (r *Repository) ensureSession(id domain.IncarnationID) error {
15 for _, directory := range []struct {16 for _, directory := range []struct {
16 path string17 path string
17 phase string18 phase string
18 }{19 }{
19 {r.dir, "repository"},20 {r.dir, "repository"},
20 {filepath.Join(r.dir, repositorySessionsDir), "sessions"},21 {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
21- {r.sessionPath(key), "session"},22+ {r.sessionPath(id), "session"},
22- {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},23+ {filepath.Join(r.sessionPath(id), repositoryObjectsDir), "objects"},
23- {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},24+ {filepath.Join(r.sessionPath(id), repositoryGenerations), "generations"},
24 } {25 } {
25 if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {26 if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
26 return fmt.Errorf("create snapshot repository directory: %w", err)27 return fmt.Errorf("create snapshot repository directory: %w", err)
A internal/adapters/snapshot/legacy_load_test.go +176 -0
@@ -0,0 +1,176 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ codec "github.com/bnema/vev/internal/usecase/snapshot"
14+)
15+
16+// Retired name-keyed repository behavior is compiled only for pre-incarnation
17+// tests. Production startup and daemon paths cannot call these methods.
18+func (r *Repository) List(ctx context.Context) ([]string, error) {
19+ if err := ctx.Err(); err != nil {
20+ return nil, err
21+ }
22+ budget := maxDirectoryTraversalEntries
23+ out := make([]string, 0)
24+ sessions := filepath.Join(r.dir, repositorySessionsDir)
25+ err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
26+ if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
27+ return nil
28+ }
29+ generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
30+ if err != nil || !ok {
31+ return err
32+ }
33+ out = append(out, generation.Name)
34+ return nil
35+ })
36+ if errors.Is(err, os.ErrNotExist) {
37+ return nil, nil
38+ }
39+ if err != nil {
40+ return nil, fmt.Errorf("read snapshot sessions: %w", err)
41+ }
42+ sort.Strings(out)
43+ return out, nil
44+}
45+
46+func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
47+ if err := ctx.Err(); err != nil {
48+ return ports.SnapshotGeneration{}, err
49+ }
50+ key := sessionKey(name)
51+ lock := r.lockSession(key)
52+ defer r.unlockSession(lock)
53+ if err := ctx.Err(); err != nil {
54+ return ports.SnapshotGeneration{}, err
55+ }
56+ killed, err := r.tombstoned(name)
57+ if err != nil {
58+ return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
59+ }
60+ if killed {
61+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
62+ }
63+ return r.load(ctx, name, key)
64+}
65+
66+func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
67+ preferred, preferredDigest, err := r.readLegacyHead(key)
68+ skipped := errors.Is(err, ErrInvalidHEAD)
69+ if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
70+ return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
71+ }
72+ if preferred != 0 {
73+ got, err := r.loadGeneration(ctx, name, key, preferred)
74+ if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
75+ return got, nil
76+ }
77+ skipped = true
78+ }
79+
80+ budget := maxDirectoryTraversalEntries
81+ candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
82+ if err != nil {
83+ return ports.SnapshotGeneration{}, err
84+ }
85+ for _, generation := range candidates {
86+ got, err := r.loadGeneration(ctx, name, key, generation)
87+ if err != nil {
88+ skipped = true
89+ continue
90+ }
91+ _ = skipped
92+ return got, nil
93+ }
94+ return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
95+}
96+
97+func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
98+ generation, digest, err := r.readLegacyHead(key)
99+ if err == nil {
100+ got, err := r.loadGeneration(ctx, name, key, generation)
101+ if err == nil && sha256.Sum256(got.Manifest) == digest {
102+ return generation, got.Manifest, nil
103+ }
104+ } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
105+ return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
106+ }
107+ budget := maxDirectoryTraversalEntries
108+ candidates, err := r.generationCandidates(ctx, key, 0, &budget)
109+ if err != nil {
110+ return 0, nil, err
111+ }
112+ for _, generation := range candidates {
113+ got, err := r.loadGeneration(ctx, name, key, generation)
114+ if err == nil {
115+ return generation, got.Manifest, nil
116+ }
117+ }
118+ return 0, nil, nil
119+}
120+
121+// loadNewestGeneration validates a single bounded candidate batch in strict
122+// descending generation order. It is used by List, whose name is discovered
123+// from each manifest rather than supplied by a caller.
124+func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
125+ candidates, err := r.generationCandidates(ctx, key, 0, budget)
126+ if err != nil {
127+ return ports.SnapshotGeneration{}, false, err
128+ }
129+ for _, generation := range candidates {
130+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
131+ if err != nil {
132+ continue
133+ }
134+ manifest, err := codec.UnmarshalManifest(data)
135+ if err != nil || sessionKey(manifest.Name) != key {
136+ continue
137+ }
138+ killed, err := r.tombstoned(manifest.Name)
139+ if err != nil {
140+ return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
141+ }
142+ if killed {
143+ return ports.SnapshotGeneration{}, false, nil
144+ }
145+ got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
146+ if err == nil {
147+ return got, true, nil
148+ }
149+ }
150+ return ports.SnapshotGeneration{}, false, nil
151+}
152+
153+// generationCandidates reads the generations directory once per load attempt.
154+// The shared traversal budget bounds both retained names and work; sorting the
155+// bounded batch preserves strict newest-to-oldest fallback without rescanning.
156+func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
157+ candidates := make([]uint64, 0)
158+ err := r.walkDirectory(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
159+ if entry.IsDir() {
160+ return nil
161+ }
162+ generation, ok := parseGenerationFilename(entry.Name())
163+ if ok && generation != exclude {
164+ candidates = append(candidates, generation)
165+ }
166+ return nil
167+ })
168+ if errors.Is(err, os.ErrNotExist) {
169+ return candidates, nil
170+ }
171+ if err != nil {
172+ return nil, err
173+ }
174+ sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
175+ return candidates, nil
176+}
@@ -0,0 +1,176 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ codec "github.com/bnema/vev/internal/usecase/snapshot"
14+)
15+
16+// Retired name-keyed repository behavior is compiled only for pre-incarnation
17+// tests. Production startup and daemon paths cannot call these methods.
18+func (r *Repository) List(ctx context.Context) ([]string, error) {
19+ if err := ctx.Err(); err != nil {
20+ return nil, err
21+ }
22+ budget := maxDirectoryTraversalEntries
23+ out := make([]string, 0)
24+ sessions := filepath.Join(r.dir, repositorySessionsDir)
25+ err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
26+ if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
27+ return nil
28+ }
29+ generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
30+ if err != nil || !ok {
31+ return err
32+ }
33+ out = append(out, generation.Name)
34+ return nil
35+ })
36+ if errors.Is(err, os.ErrNotExist) {
37+ return nil, nil
38+ }
39+ if err != nil {
40+ return nil, fmt.Errorf("read snapshot sessions: %w", err)
41+ }
42+ sort.Strings(out)
43+ return out, nil
44+}
45+
46+func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
47+ if err := ctx.Err(); err != nil {
48+ return ports.SnapshotGeneration{}, err
49+ }
50+ key := sessionKey(name)
51+ lock := r.lockSession(key)
52+ defer r.unlockSession(lock)
53+ if err := ctx.Err(); err != nil {
54+ return ports.SnapshotGeneration{}, err
55+ }
56+ killed, err := r.tombstoned(name)
57+ if err != nil {
58+ return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
59+ }
60+ if killed {
61+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
62+ }
63+ return r.load(ctx, name, key)
64+}
65+
66+func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
67+ preferred, preferredDigest, err := r.readLegacyHead(key)
68+ skipped := errors.Is(err, ErrInvalidHEAD)
69+ if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
70+ return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
71+ }
72+ if preferred != 0 {
73+ got, err := r.loadGeneration(ctx, name, key, preferred)
74+ if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
75+ return got, nil
76+ }
77+ skipped = true
78+ }
79+
80+ budget := maxDirectoryTraversalEntries
81+ candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
82+ if err != nil {
83+ return ports.SnapshotGeneration{}, err
84+ }
85+ for _, generation := range candidates {
86+ got, err := r.loadGeneration(ctx, name, key, generation)
87+ if err != nil {
88+ skipped = true
89+ continue
90+ }
91+ _ = skipped
92+ return got, nil
93+ }
94+ return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
95+}
96+
97+func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
98+ generation, digest, err := r.readLegacyHead(key)
99+ if err == nil {
100+ got, err := r.loadGeneration(ctx, name, key, generation)
101+ if err == nil && sha256.Sum256(got.Manifest) == digest {
102+ return generation, got.Manifest, nil
103+ }
104+ } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
105+ return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
106+ }
107+ budget := maxDirectoryTraversalEntries
108+ candidates, err := r.generationCandidates(ctx, key, 0, &budget)
109+ if err != nil {
110+ return 0, nil, err
111+ }
112+ for _, generation := range candidates {
113+ got, err := r.loadGeneration(ctx, name, key, generation)
114+ if err == nil {
115+ return generation, got.Manifest, nil
116+ }
117+ }
118+ return 0, nil, nil
119+}
120+
121+// loadNewestGeneration validates a single bounded candidate batch in strict
122+// descending generation order. It is used by List, whose name is discovered
123+// from each manifest rather than supplied by a caller.
124+func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
125+ candidates, err := r.generationCandidates(ctx, key, 0, budget)
126+ if err != nil {
127+ return ports.SnapshotGeneration{}, false, err
128+ }
129+ for _, generation := range candidates {
130+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
131+ if err != nil {
132+ continue
133+ }
134+ manifest, err := codec.UnmarshalManifest(data)
135+ if err != nil || sessionKey(manifest.Name) != key {
136+ continue
137+ }
138+ killed, err := r.tombstoned(manifest.Name)
139+ if err != nil {
140+ return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
141+ }
142+ if killed {
143+ return ports.SnapshotGeneration{}, false, nil
144+ }
145+ got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
146+ if err == nil {
147+ return got, true, nil
148+ }
149+ }
150+ return ports.SnapshotGeneration{}, false, nil
151+}
152+
153+// generationCandidates reads the generations directory once per load attempt.
154+// The shared traversal budget bounds both retained names and work; sorting the
155+// bounded batch preserves strict newest-to-oldest fallback without rescanning.
156+func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
157+ candidates := make([]uint64, 0)
158+ err := r.walkDirectory(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
159+ if entry.IsDir() {
160+ return nil
161+ }
162+ generation, ok := parseGenerationFilename(entry.Name())
163+ if ok && generation != exclude {
164+ candidates = append(candidates, generation)
165+ }
166+ return nil
167+ })
168+ if errors.Is(err, os.ErrNotExist) {
169+ return candidates, nil
170+ }
171+ if err != nil {
172+ return nil, err
173+ }
174+ sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
175+ return candidates, nil
176+}
M internal/adapters/snapshot/load.go +13 -168
@@ -8,8 +8,6 @@ import (
88 "fmt"
99 "io"
1010 "os"
11- "path/filepath"
12- "sort"
1311
1412 "github.com/bnema/vev/internal/domain"
1513 "github.com/bnema/vev/internal/ports"
@@ -29,166 +27,6 @@ var (
2927 ErrInvalidHEAD = errors.New("invalid snapshot HEAD")
3028 )
3129
32-func (r *Repository) List(ctx context.Context) ([]string, error) {
33- if err := ctx.Err(); err != nil {
34- return nil, err
35- }
36- budget := maxDirectoryTraversalEntries
37- out := make([]string, 0)
38- sessions := filepath.Join(r.dir, repositorySessionsDir)
39- err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
40- if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
41- return nil
42- }
43- generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
44- if err != nil || !ok {
45- return err
46- }
47- out = append(out, generation.Name)
48- return nil
49- })
50- if errors.Is(err, os.ErrNotExist) {
51- return nil, nil
52- }
53- if err != nil {
54- return nil, fmt.Errorf("read snapshot sessions: %w", err)
55- }
56- sort.Strings(out)
57- return out, nil
58-}
59-
60-func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
61- if err := ctx.Err(); err != nil {
62- return ports.SnapshotGeneration{}, err
63- }
64- key := sessionKey(name)
65- lock := r.lockSession(key)
66- defer r.unlockSession(lock)
67- if err := ctx.Err(); err != nil {
68- return ports.SnapshotGeneration{}, err
69- }
70- killed, err := r.tombstoned(name)
71- if err != nil {
72- return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
73- }
74- if killed {
75- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
76- }
77- return r.load(ctx, name, key)
78-}
79-
80-func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
81- preferred, preferredDigest, err := r.readHead(key)
82- skipped := errors.Is(err, ErrInvalidHEAD)
83- if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
84- return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
85- }
86- if preferred != 0 {
87- got, err := r.loadGeneration(ctx, name, key, preferred)
88- if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
89- return got, nil
90- }
91- skipped = true
92- }
93-
94- budget := maxDirectoryTraversalEntries
95- candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
96- if err != nil {
97- return ports.SnapshotGeneration{}, err
98- }
99- for _, generation := range candidates {
100- got, err := r.loadGeneration(ctx, name, key, generation)
101- if err != nil {
102- skipped = true
103- continue
104- }
105- _ = skipped
106- return got, nil
107- }
108- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
109-}
110-
111-func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
112- generation, digest, err := r.readHead(key)
113- if err == nil {
114- got, err := r.loadGeneration(ctx, name, key, generation)
115- if err == nil && sha256.Sum256(got.Manifest) == digest {
116- return generation, got.Manifest, nil
117- }
118- } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
119- return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
120- }
121- budget := maxDirectoryTraversalEntries
122- candidates, err := r.generationCandidates(ctx, key, 0, &budget)
123- if err != nil {
124- return 0, nil, err
125- }
126- for _, generation := range candidates {
127- got, err := r.loadGeneration(ctx, name, key, generation)
128- if err == nil {
129- return generation, got.Manifest, nil
130- }
131- }
132- return 0, nil, nil
133-}
134-
135-// loadNewestGeneration validates a single bounded candidate batch in strict
136-// descending generation order. It is used by List, whose name is discovered
137-// from each manifest rather than supplied by a caller.
138-func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
139- candidates, err := r.generationCandidates(ctx, key, 0, budget)
140- if err != nil {
141- return ports.SnapshotGeneration{}, false, err
142- }
143- for _, generation := range candidates {
144- data, err := r.readBounded(r.manifestPath(key, generation))
145- if err != nil {
146- continue
147- }
148- manifest, err := codec.UnmarshalManifest(data)
149- if err != nil || sessionKey(manifest.Name) != key {
150- continue
151- }
152- killed, err := r.tombstoned(manifest.Name)
153- if err != nil {
154- return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
155- }
156- if killed {
157- return ports.SnapshotGeneration{}, false, nil
158- }
159- got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
160- if err == nil {
161- return got, true, nil
162- }
163- }
164- return ports.SnapshotGeneration{}, false, nil
165-}
166-
167-// generationCandidates reads the generations directory once per load attempt.
168-// The shared traversal budget bounds both retained names and work; sorting the
169-// bounded batch preserves strict newest-to-oldest fallback without rescanning.
170-func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
171- candidates := make([]uint64, 0)
172- err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
173- if entry.IsDir() {
174- return nil
175- }
176- generation, ok := parseGenerationFilename(entry.Name())
177- if ok && generation != exclude {
178- candidates = append(candidates, generation)
179- }
180- return nil
181- })
182- if errors.Is(err, os.ErrNotExist) {
183- return candidates, nil
184- }
185- if err != nil {
186- return nil, err
187- }
188- sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
189- return candidates, nil
190-}
191-
19230 // walkDirectory is the common finite directory traversal primitive for reads.
19331 // File.ReadDir maintains a cursor on one descriptor and returns at most one
19432 // small batch, so no full directory enumeration is retained in memory.
@@ -305,7 +143,7 @@ func (r *Repository) RepairHEAD(ctx context.Context, id domain.IncarnationID, re
305143 }
306144
307145 func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
308- data, err := r.readBounded(r.manifestPath(key, generation))
146+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
309147 if err != nil {
310148 return ports.SnapshotGeneration{}, err
311149 }
@@ -322,7 +160,7 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener
322160 if err := ctx.Err(); err != nil {
323161 return ports.SnapshotGeneration{}, err
324162 }
325- object, err := r.readBounded(r.objectPath(key, digest))
163+ object, err := r.readBounded(r.legacyObjectPath(key, digest))
326164 if err != nil {
327165 return ports.SnapshotGeneration{}, err
328166 }
@@ -341,12 +179,15 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
341179 copy(out[12:], digest[:])
342180 return out
343181 }
344-func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
345- return r.readHeadWithRoot(nil, key)
182+func (r *Repository) readHead(id domain.IncarnationID) (uint64, ports.SnapshotDigest, error) {
183+ return r.readHeadWithRoot(nil, id)
346184 }
347185
348-func (r *Repository) readHeadWithRoot(root *os.Root, key string) (uint64, ports.SnapshotDigest, error) {
349- path := r.headPath(key)
186+func (r *Repository) readHeadWithRoot(root *os.Root, id domain.IncarnationID) (uint64, ports.SnapshotDigest, error) {
187+ return r.readHeadAt(root, r.headPath(id))
188+}
189+
190+func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.SnapshotDigest, error) {
350191 if hook := r.hooks.beforeHeadRead; hook != nil {
351192 if err := hook(path); err != nil {
352193 return 0, ports.SnapshotDigest{}, err
@@ -373,3 +214,7 @@ func (r *Repository) readHeadWithRoot(root *os.Root, key string) (uint64, ports.
373214 }
374215 return generation, digest, nil
375216 }
217+
218+func (r *Repository) readLegacyHead(key string) (uint64, ports.SnapshotDigest, error) {
219+ return r.readHeadAt(nil, r.legacyHeadPath(key))
220+}
@@ -8,8 +8,6 @@ import (
8 "fmt"8 "fmt"
9 "io"9 "io"
10 "os"10 "os"
11- "path/filepath"
12- "sort"
13 11
14 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
@@ -29,166 +27,6 @@ var (
29 ErrInvalidHEAD = errors.New("invalid snapshot HEAD")27 ErrInvalidHEAD = errors.New("invalid snapshot HEAD")
30 )28 )
31 29
32-func (r *Repository) List(ctx context.Context) ([]string, error) {
33- if err := ctx.Err(); err != nil {
34- return nil, err
35- }
36- budget := maxDirectoryTraversalEntries
37- out := make([]string, 0)
38- sessions := filepath.Join(r.dir, repositorySessionsDir)
39- err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
40- if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
41- return nil
42- }
43- generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
44- if err != nil || !ok {
45- return err
46- }
47- out = append(out, generation.Name)
48- return nil
49- })
50- if errors.Is(err, os.ErrNotExist) {
51- return nil, nil
52- }
53- if err != nil {
54- return nil, fmt.Errorf("read snapshot sessions: %w", err)
55- }
56- sort.Strings(out)
57- return out, nil
58-}
59-
60-func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
61- if err := ctx.Err(); err != nil {
62- return ports.SnapshotGeneration{}, err
63- }
64- key := sessionKey(name)
65- lock := r.lockSession(key)
66- defer r.unlockSession(lock)
67- if err := ctx.Err(); err != nil {
68- return ports.SnapshotGeneration{}, err
69- }
70- killed, err := r.tombstoned(name)
71- if err != nil {
72- return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
73- }
74- if killed {
75- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
76- }
77- return r.load(ctx, name, key)
78-}
79-
80-func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
81- preferred, preferredDigest, err := r.readHead(key)
82- skipped := errors.Is(err, ErrInvalidHEAD)
83- if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
84- return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
85- }
86- if preferred != 0 {
87- got, err := r.loadGeneration(ctx, name, key, preferred)
88- if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
89- return got, nil
90- }
91- skipped = true
92- }
93-
94- budget := maxDirectoryTraversalEntries
95- candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
96- if err != nil {
97- return ports.SnapshotGeneration{}, err
98- }
99- for _, generation := range candidates {
100- got, err := r.loadGeneration(ctx, name, key, generation)
101- if err != nil {
102- skipped = true
103- continue
104- }
105- _ = skipped
106- return got, nil
107- }
108- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
109-}
110-
111-func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
112- generation, digest, err := r.readHead(key)
113- if err == nil {
114- got, err := r.loadGeneration(ctx, name, key, generation)
115- if err == nil && sha256.Sum256(got.Manifest) == digest {
116- return generation, got.Manifest, nil
117- }
118- } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
119- return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
120- }
121- budget := maxDirectoryTraversalEntries
122- candidates, err := r.generationCandidates(ctx, key, 0, &budget)
123- if err != nil {
124- return 0, nil, err
125- }
126- for _, generation := range candidates {
127- got, err := r.loadGeneration(ctx, name, key, generation)
128- if err == nil {
129- return generation, got.Manifest, nil
130- }
131- }
132- return 0, nil, nil
133-}
134-
135-// loadNewestGeneration validates a single bounded candidate batch in strict
136-// descending generation order. It is used by List, whose name is discovered
137-// from each manifest rather than supplied by a caller.
138-func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
139- candidates, err := r.generationCandidates(ctx, key, 0, budget)
140- if err != nil {
141- return ports.SnapshotGeneration{}, false, err
142- }
143- for _, generation := range candidates {
144- data, err := r.readBounded(r.manifestPath(key, generation))
145- if err != nil {
146- continue
147- }
148- manifest, err := codec.UnmarshalManifest(data)
149- if err != nil || sessionKey(manifest.Name) != key {
150- continue
151- }
152- killed, err := r.tombstoned(manifest.Name)
153- if err != nil {
154- return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
155- }
156- if killed {
157- return ports.SnapshotGeneration{}, false, nil
158- }
159- got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
160- if err == nil {
161- return got, true, nil
162- }
163- }
164- return ports.SnapshotGeneration{}, false, nil
165-}
166-
167-// generationCandidates reads the generations directory once per load attempt.
168-// The shared traversal budget bounds both retained names and work; sorting the
169-// bounded batch preserves strict newest-to-oldest fallback without rescanning.
170-func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
171- candidates := make([]uint64, 0)
172- err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
173- if entry.IsDir() {
174- return nil
175- }
176- generation, ok := parseGenerationFilename(entry.Name())
177- if ok && generation != exclude {
178- candidates = append(candidates, generation)
179- }
180- return nil
181- })
182- if errors.Is(err, os.ErrNotExist) {
183- return candidates, nil
184- }
185- if err != nil {
186- return nil, err
187- }
188- sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
189- return candidates, nil
190-}
191-
192 // walkDirectory is the common finite directory traversal primitive for reads.30 // walkDirectory is the common finite directory traversal primitive for reads.
193 // File.ReadDir maintains a cursor on one descriptor and returns at most one31 // File.ReadDir maintains a cursor on one descriptor and returns at most one
194 // small batch, so no full directory enumeration is retained in memory.32 // small batch, so no full directory enumeration is retained in memory.
@@ -305,7 +143,7 @@ func (r *Repository) RepairHEAD(ctx context.Context, id domain.IncarnationID, re
305 }143 }
306 144
307 func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {145 func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
308- data, err := r.readBounded(r.manifestPath(key, generation))146+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
309 if err != nil {147 if err != nil {
310 return ports.SnapshotGeneration{}, err148 return ports.SnapshotGeneration{}, err
311 }149 }
@@ -322,7 +160,7 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener
322 if err := ctx.Err(); err != nil {160 if err := ctx.Err(); err != nil {
323 return ports.SnapshotGeneration{}, err161 return ports.SnapshotGeneration{}, err
324 }162 }
325- object, err := r.readBounded(r.objectPath(key, digest))163+ object, err := r.readBounded(r.legacyObjectPath(key, digest))
326 if err != nil {164 if err != nil {
327 return ports.SnapshotGeneration{}, err165 return ports.SnapshotGeneration{}, err
328 }166 }
@@ -341,12 +179,15 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
341 copy(out[12:], digest[:])179 copy(out[12:], digest[:])
342 return out180 return out
343 }181 }
344-func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {182+func (r *Repository) readHead(id domain.IncarnationID) (uint64, ports.SnapshotDigest, error) {
345- return r.readHeadWithRoot(nil, key)183+ return r.readHeadWithRoot(nil, id)
346 }184 }
347 185
348-func (r *Repository) readHeadWithRoot(root *os.Root, key string) (uint64, ports.SnapshotDigest, error) {186+func (r *Repository) readHeadWithRoot(root *os.Root, id domain.IncarnationID) (uint64, ports.SnapshotDigest, error) {
349- path := r.headPath(key)187+ return r.readHeadAt(root, r.headPath(id))
188+}
189+
190+func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.SnapshotDigest, error) {
350 if hook := r.hooks.beforeHeadRead; hook != nil {191 if hook := r.hooks.beforeHeadRead; hook != nil {
351 if err := hook(path); err != nil {192 if err := hook(path); err != nil {
352 return 0, ports.SnapshotDigest{}, err193 return 0, ports.SnapshotDigest{}, err
@@ -373,3 +214,7 @@ func (r *Repository) readHeadWithRoot(root *os.Root, key string) (uint64, ports.
373 }214 }
374 return generation, digest, nil215 return generation, digest, nil
375 }216 }
217+
218+func (r *Repository) readLegacyHead(key string) (uint64, ports.SnapshotDigest, error) {
219+ return r.readHeadAt(nil, r.legacyHeadPath(key))
220+}
M internal/adapters/snapshot/load_bounds_test.go +3 -3
@@ -30,7 +30,7 @@ func TestRepositoryListStopsAtTraversalBudget(t *testing.T) {
3030 func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
3131 repo := NewRepository(privateDir(t))
3232 key := sessionKey("named")
33- generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)
33+ generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations)
3434 if err := os.MkdirAll(generations, 0o700); err != nil {
3535 t.Fatal(err)
3636 }
@@ -55,7 +55,7 @@ func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *te
5555 }
5656 key := sessionKey("named")
5757 publication := repositoryPublication(t, "named", 3, []byte("state-3"))
58- if err := os.Remove(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
58+ if err := os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
5959 t.Fatal(err)
6060 }
6161
@@ -86,7 +86,7 @@ func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing
8686 t.Fatal("maintenance did not complete after bounded object-shard traversal")
8787 }
8888 }
89- if _, err := os.Lstat(repo.manifestPath(sessionKey("named"), 1)); err != nil {
89+ if _, err := os.Lstat(repo.legacyManifestPath(sessionKey("named"), 1)); err != nil {
9090 t.Fatalf("old manifest collected after mark budget overflow: %v", err)
9191 }
9292 if state := repo.maintenanceSessions[sessionKey("named")]; state != nil {
@@ -30,7 +30,7 @@ func TestRepositoryListStopsAtTraversalBudget(t *testing.T) {
30 func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {30 func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
31 repo := NewRepository(privateDir(t))31 repo := NewRepository(privateDir(t))
32 key := sessionKey("named")32 key := sessionKey("named")
33- generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)33+ generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations)
34 if err := os.MkdirAll(generations, 0o700); err != nil {34 if err := os.MkdirAll(generations, 0o700); err != nil {
35 t.Fatal(err)35 t.Fatal(err)
36 }36 }
@@ -55,7 +55,7 @@ func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *te
55 }55 }
56 key := sessionKey("named")56 key := sessionKey("named")
57 publication := repositoryPublication(t, "named", 3, []byte("state-3"))57 publication := repositoryPublication(t, "named", 3, []byte("state-3"))
58- if err := os.Remove(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {58+ if err := os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
59 t.Fatal(err)59 t.Fatal(err)
60 }60 }
61 61
@@ -86,7 +86,7 @@ func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing
86 t.Fatal("maintenance did not complete after bounded object-shard traversal")86 t.Fatal("maintenance did not complete after bounded object-shard traversal")
87 }87 }
88 }88 }
89- if _, err := os.Lstat(repo.manifestPath(sessionKey("named"), 1)); err != nil {89+ if _, err := os.Lstat(repo.legacyManifestPath(sessionKey("named"), 1)); err != nil {
90 t.Fatalf("old manifest collected after mark budget overflow: %v", err)90 t.Fatalf("old manifest collected after mark budget overflow: %v", err)
91 }91 }
92 if state := repo.maintenanceSessions[sessionKey("named")]; state != nil {92 if state := repo.maintenanceSessions[sessionKey("named")]; state != nil {
M internal/adapters/snapshot/load_major_test.go +2 -2
@@ -32,7 +32,7 @@ func TestRepositoryCurrentGenerationPropagatesOperationalHeadErrors(t *testing.T
3232 func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) {
3333 repo := NewRepository(privateDir(t))
3434 require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
35- require.NoError(t, os.WriteFile(repo.headPath(sessionKey("named")), []byte("invalid"), 0o600))
35+ require.NoError(t, os.WriteFile(repo.legacyHeadPath(sessionKey("named")), []byte("invalid"), 0o600))
3636
3737 got, err := repo.Load(context.Background(), "named")
3838 require.NoError(t, err)
@@ -47,7 +47,7 @@ func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) {
4747 key := sessionKey("named")
4848 for _, generation := range []uint64{4, 3} {
4949 publication := repositoryPublication(t, "named", generation, []byte{byte(generation)})
50- require.NoError(t, os.Remove(repo.objectPath(key, publication.Objects[0].Digest)))
50+ require.NoError(t, os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)))
5151 }
5252
5353 got, err := repo.Load(context.Background(), "named")
@@ -32,7 +32,7 @@ func TestRepositoryCurrentGenerationPropagatesOperationalHeadErrors(t *testing.T
32 func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) {32 func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) {
33 repo := NewRepository(privateDir(t))33 repo := NewRepository(privateDir(t))
34 require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))34 require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
35- require.NoError(t, os.WriteFile(repo.headPath(sessionKey("named")), []byte("invalid"), 0o600))35+ require.NoError(t, os.WriteFile(repo.legacyHeadPath(sessionKey("named")), []byte("invalid"), 0o600))
36 36
37 got, err := repo.Load(context.Background(), "named")37 got, err := repo.Load(context.Background(), "named")
38 require.NoError(t, err)38 require.NoError(t, err)
@@ -47,7 +47,7 @@ func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) {
47 key := sessionKey("named")47 key := sessionKey("named")
48 for _, generation := range []uint64{4, 3} {48 for _, generation := range []uint64{4, 3} {
49 publication := repositoryPublication(t, "named", generation, []byte{byte(generation)})49 publication := repositoryPublication(t, "named", generation, []byte{byte(generation)})
50- require.NoError(t, os.Remove(repo.objectPath(key, publication.Objects[0].Digest)))50+ require.NoError(t, os.Remove(repo.legacyObjectPath(key, publication.Objects[0].Digest)))
51 }51 }
52 52
53 got, err := repo.Load(context.Background(), "named")53 got, err := repo.Load(context.Background(), "named")
M internal/adapters/snapshot/maintenance_delete.go +2 -2
@@ -121,7 +121,7 @@ func (r *Repository) ListDeletionTombstones(ctx context.Context, cursor ports.De
121121 if err != nil {
122122 return ports.DeletionTombstonePage{}, err
123123 }
124- charge := uint64(len(entry.Name()) + len(decoded.Name) + len(decoded.IncarnationID))
124+ charge := uint64(len(entry.Name()) + len(data))
125125 if charge > budget.Bytes {
126126 if len(page.Tombstones) == 0 {
127127 return ports.DeletionTombstonePage{}, ErrMaintenanceBudgetTooSmall
@@ -177,7 +177,7 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
177177 return err
178178 }
179179
180- canonical := r.sessionPath(key)
180+ canonical := r.legacySessionPath(key)
181181 sessions := filepath.Dir(canonical)
182182 // A prior rename may have succeeded while its parent sync failed. Complete
183183 // that durability boundary before considering a canonical directory: this
@@ -121,7 +121,7 @@ func (r *Repository) ListDeletionTombstones(ctx context.Context, cursor ports.De
121 if err != nil {121 if err != nil {
122 return ports.DeletionTombstonePage{}, err122 return ports.DeletionTombstonePage{}, err
123 }123 }
124- charge := uint64(len(entry.Name()) + len(decoded.Name) + len(decoded.IncarnationID))124+ charge := uint64(len(entry.Name()) + len(data))
125 if charge > budget.Bytes {125 if charge > budget.Bytes {
126 if len(page.Tombstones) == 0 {126 if len(page.Tombstones) == 0 {
127 return ports.DeletionTombstonePage{}, ErrMaintenanceBudgetTooSmall127 return ports.DeletionTombstonePage{}, ErrMaintenanceBudgetTooSmall
@@ -177,7 +177,7 @@ func (r *Repository) Delete(ctx context.Context, name string) error {
177 return err177 return err
178 }178 }
179 179
180- canonical := r.sessionPath(key)180+ canonical := r.legacySessionPath(key)
181 sessions := filepath.Dir(canonical)181 sessions := filepath.Dir(canonical)
182 // A prior rename may have succeeded while its parent sync failed. Complete182 // A prior rename may have succeeded while its parent sync failed. Complete
183 // that durability boundary before considering a canonical directory: this183 // that durability boundary before considering a canonical directory: this
M internal/adapters/snapshot/maintenance_gc.go +9 -9
@@ -57,7 +57,7 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in
5757 if err != nil {
5858 return err
5959 }
60- if _, err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 {
60+ if _, err := r.removeTemps(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 {
6161 return err
6262 }
6363 if !state.objectTempsDone {
@@ -120,7 +120,7 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e
120120 // itself stable maintenance state: we retain every classified reference until a
121121 // valid publication changes that state, rather than restarting the mark pass.
122122 func (r *Repository) maintenanceToken(key string) (string, bool, error) {
123- data, exists, err := r.readOptionalBounded(r.headPath(key))
123+ data, exists, err := r.readOptionalBounded(r.legacyHeadPath(key))
124124 if err != nil {
125125 return "", false, err
126126 }
@@ -128,7 +128,7 @@ func (r *Repository) maintenanceToken(key string) (string, bool, error) {
128128 return "missing", true, nil
129129 }
130130 sum := sha256.Sum256(data)
131- if _, _, err := r.readHead(key); err != nil {
131+ if _, _, err := r.readLegacyHead(key); err != nil {
132132 return fmt.Sprintf("invalid:%x", sum), true, nil
133133 }
134134 return fmt.Sprintf("valid:%x", sum), false, nil
@@ -139,7 +139,7 @@ func (r *Repository) clearSessionMaintenance(key string) {
139139 r.releaseSessionReference(state.lock)
140140 }
141141 delete(r.maintenanceSessions, key)
142- prefix := "\x00" + filepath.Clean(r.sessionPath(key))
142+ prefix := "\x00" + filepath.Clean(r.legacySessionPath(key))
143143 for id := range r.maintenanceCursors {
144144 if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") {
145145 delete(r.maintenanceCursors, id)
@@ -148,7 +148,7 @@ func (r *Repository) clearSessionMaintenance(key string) {
148148 }
149149
150150 func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error {
151- dir := filepath.Join(r.sessionPath(key), repositoryGenerations)
151+ dir := filepath.Join(r.legacySessionPath(key), repositoryGenerations)
152152 entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key)
153153 if errors.Is(err, os.ErrNotExist) {
154154 state.markDone = true
@@ -168,7 +168,7 @@ func (r *Repository) markSession(ctx context.Context, key string, state *session
168168 if !ok {
169169 continue
170170 }
171- data, err := r.readBounded(r.manifestPath(key, generation))
171+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
172172 if err != nil {
173173 state.uncertain = true
174174 continue
@@ -254,7 +254,7 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st
254254 return err
255255 }
256256 generation := state.manifestQueue[0]
257- path := r.manifestPath(key, generation)
257+ path := r.legacyManifestPath(key, generation)
258258 if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
259259 return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
260260 }
@@ -275,7 +275,7 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st
275275 // removal budget, so this fixed step ceiling prevents an unbounded scan of
276276 // them while retaining only the root cursor and one active shard cursor.
277277 func (r *Repository) removeObjectTemps(ctx context.Context, key string, state *sessionMaintenance, budget *int) (bool, error) {
278- root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
278+ root := filepath.Join(r.legacySessionPath(key), repositoryObjectsDir)
279279 if *budget == 0 {
280280 return false, nil
281281 }
@@ -313,7 +313,7 @@ func (r *Repository) sweepSession(ctx context.Context, key string, state *sessio
313313 return nil
314314 }
315315 referenced := retainedReferences(state.marked, state.conservative)
316- root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
316+ root := filepath.Join(r.legacySessionPath(key), repositoryObjectsDir)
317317 if state.sweepShard != "" && *budget > 0 {
318318 shard := state.sweepShard
319319 done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard)
@@ -57,7 +57,7 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in
57 if err != nil {57 if err != nil {
58 return err58 return err
59 }59 }
60- if _, err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 {60+ if _, err := r.removeTemps(ctx, filepath.Join(r.legacySessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 {
61 return err61 return err
62 }62 }
63 if !state.objectTempsDone {63 if !state.objectTempsDone {
@@ -120,7 +120,7 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e
120 // itself stable maintenance state: we retain every classified reference until a120 // itself stable maintenance state: we retain every classified reference until a
121 // valid publication changes that state, rather than restarting the mark pass.121 // valid publication changes that state, rather than restarting the mark pass.
122 func (r *Repository) maintenanceToken(key string) (string, bool, error) {122 func (r *Repository) maintenanceToken(key string) (string, bool, error) {
123- data, exists, err := r.readOptionalBounded(r.headPath(key))123+ data, exists, err := r.readOptionalBounded(r.legacyHeadPath(key))
124 if err != nil {124 if err != nil {
125 return "", false, err125 return "", false, err
126 }126 }
@@ -128,7 +128,7 @@ func (r *Repository) maintenanceToken(key string) (string, bool, error) {
128 return "missing", true, nil128 return "missing", true, nil
129 }129 }
130 sum := sha256.Sum256(data)130 sum := sha256.Sum256(data)
131- if _, _, err := r.readHead(key); err != nil {131+ if _, _, err := r.readLegacyHead(key); err != nil {
132 return fmt.Sprintf("invalid:%x", sum), true, nil132 return fmt.Sprintf("invalid:%x", sum), true, nil
133 }133 }
134 return fmt.Sprintf("valid:%x", sum), false, nil134 return fmt.Sprintf("valid:%x", sum), false, nil
@@ -139,7 +139,7 @@ func (r *Repository) clearSessionMaintenance(key string) {
139 r.releaseSessionReference(state.lock)139 r.releaseSessionReference(state.lock)
140 }140 }
141 delete(r.maintenanceSessions, key)141 delete(r.maintenanceSessions, key)
142- prefix := "\x00" + filepath.Clean(r.sessionPath(key))142+ prefix := "\x00" + filepath.Clean(r.legacySessionPath(key))
143 for id := range r.maintenanceCursors {143 for id := range r.maintenanceCursors {
144 if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") {144 if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") {
145 delete(r.maintenanceCursors, id)145 delete(r.maintenanceCursors, id)
@@ -148,7 +148,7 @@ func (r *Repository) clearSessionMaintenance(key string) {
148 }148 }
149 149
150 func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error {150 func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error {
151- dir := filepath.Join(r.sessionPath(key), repositoryGenerations)151+ dir := filepath.Join(r.legacySessionPath(key), repositoryGenerations)
152 entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key)152 entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key)
153 if errors.Is(err, os.ErrNotExist) {153 if errors.Is(err, os.ErrNotExist) {
154 state.markDone = true154 state.markDone = true
@@ -168,7 +168,7 @@ func (r *Repository) markSession(ctx context.Context, key string, state *session
168 if !ok {168 if !ok {
169 continue169 continue
170 }170 }
171- data, err := r.readBounded(r.manifestPath(key, generation))171+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
172 if err != nil {172 if err != nil {
173 state.uncertain = true173 state.uncertain = true
174 continue174 continue
@@ -254,7 +254,7 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st
254 return err254 return err
255 }255 }
256 generation := state.manifestQueue[0]256 generation := state.manifestQueue[0]
257- path := r.manifestPath(key, generation)257+ path := r.legacyManifestPath(key, generation)
258 if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {258 if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
259 return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))259 return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
260 }260 }
@@ -275,7 +275,7 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st
275 // removal budget, so this fixed step ceiling prevents an unbounded scan of275 // removal budget, so this fixed step ceiling prevents an unbounded scan of
276 // them while retaining only the root cursor and one active shard cursor.276 // them while retaining only the root cursor and one active shard cursor.
277 func (r *Repository) removeObjectTemps(ctx context.Context, key string, state *sessionMaintenance, budget *int) (bool, error) {277 func (r *Repository) removeObjectTemps(ctx context.Context, key string, state *sessionMaintenance, budget *int) (bool, error) {
278- root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)278+ root := filepath.Join(r.legacySessionPath(key), repositoryObjectsDir)
279 if *budget == 0 {279 if *budget == 0 {
280 return false, nil280 return false, nil
281 }281 }
@@ -313,7 +313,7 @@ func (r *Repository) sweepSession(ctx context.Context, key string, state *sessio
313 return nil313 return nil
314 }314 }
315 referenced := retainedReferences(state.marked, state.conservative)315 referenced := retainedReferences(state.marked, state.conservative)
316- root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)316+ root := filepath.Join(r.legacySessionPath(key), repositoryObjectsDir)
317 if state.sweepShard != "" && *budget > 0 {317 if state.sweepShard != "" && *budget > 0 {
318 shard := state.sweepShard318 shard := state.sweepShard
319 done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard)319 done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard)
M internal/adapters/snapshot/maintenance_hardening_test.go +2 -2
@@ -12,7 +12,7 @@ import (
1212 func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) {
1313 repo := NewRepository(privateDir(t))
1414 key := sessionKey("named")
15- objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
15+ objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
1616 const shards = maintenanceBatch * 2
1717 for i := 0; i < shards; i++ {
1818 shard := filepath.Join(objects, fmt.Sprintf("%03d", i))
@@ -49,7 +49,7 @@ func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T)
4949 func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) {
5050 repo := NewRepository(privateDir(t))
5151 key := sessionKey("named")
52- objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
52+ objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
5353 const shards = maintenanceBatch * 2
5454 for i := 0; i < shards; i++ {
5555 if err := os.MkdirAll(filepath.Join(objects, fmt.Sprintf("%03d", i)), 0o700); err != nil {
@@ -12,7 +12,7 @@ import (
12 func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) {12 func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) {
13 repo := NewRepository(privateDir(t))13 repo := NewRepository(privateDir(t))
14 key := sessionKey("named")14 key := sessionKey("named")
15- objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)15+ objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
16 const shards = maintenanceBatch * 216 const shards = maintenanceBatch * 2
17 for i := 0; i < shards; i++ {17 for i := 0; i < shards; i++ {
18 shard := filepath.Join(objects, fmt.Sprintf("%03d", i))18 shard := filepath.Join(objects, fmt.Sprintf("%03d", i))
@@ -49,7 +49,7 @@ func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T)
49 func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) {49 func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) {
50 repo := NewRepository(privateDir(t))50 repo := NewRepository(privateDir(t))
51 key := sessionKey("named")51 key := sessionKey("named")
52- objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)52+ objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
53 const shards = maintenanceBatch * 253 const shards = maintenanceBatch * 2
54 for i := 0; i < shards; i++ {54 for i := 0; i < shards; i++ {
55 if err := os.MkdirAll(filepath.Join(objects, fmt.Sprintf("%03d", i)), 0o700); err != nil {55 if err := os.MkdirAll(filepath.Join(objects, fmt.Sprintf("%03d", i)), 0o700); err != nil {
M internal/adapters/snapshot/maintenance_test.go +20 -20
@@ -296,7 +296,7 @@ func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T)
296296 t.Fatal(err)
297297 }
298298
299- canonical := repo.sessionPath(sessionKey("named"))
299+ canonical := repo.legacySessionPath(sessionKey("named"))
300300 if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) {
301301 t.Fatalf("canonical session after Delete = %v, want not exist", err)
302302 }
@@ -329,7 +329,7 @@ func TestRepositoryDeleteReturnsRootSyncFailureAfterQuarantine(t *testing.T) {
329329 if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
330330 t.Fatalf("Delete error = %v, want root sync failure", err)
331331 }
332- if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
332+ if _, err := os.Lstat(repo.legacySessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
333333 t.Fatalf("canonical session after failed sync = %v, want not exist", err)
334334 }
335335 }
@@ -366,7 +366,7 @@ func TestRepositoryDeleteWaitsForPublication(t *testing.T) {
366366 if err := <-deleteDone; err != nil {
367367 t.Fatal(err)
368368 }
369- if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
369+ if _, err := os.Lstat(repo.legacySessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
370370 t.Fatalf("canonical session after concurrent Delete = %v, want not exist", err)
371371 }
372372 }
@@ -383,7 +383,7 @@ func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
383383 if err := repo.Maintain(context.Background()); err != nil {
384384 t.Fatal(err)
385385 }
386- _, err := os.Lstat(repo.manifestPath(key, 1))
386+ _, err := os.Lstat(repo.legacyManifestPath(key, 1))
387387 if errors.Is(err, os.ErrNotExist) {
388388 break
389389 }
@@ -395,11 +395,11 @@ func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
395395 }
396396 }
397397 for _, generation := range []uint64{2, 3} {
398- if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {
398+ if _, err := os.Lstat(repo.legacyManifestPath(key, generation)); err != nil {
399399 t.Fatalf("retained manifest %d: %v", generation, err)
400400 }
401401 }
402- if _, err := os.Lstat(repo.manifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) {
402+ if _, err := os.Lstat(repo.legacyManifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) {
403403 t.Fatalf("old manifest after Maintain = %v, want not exist", err)
404404 }
405405 for _, generation := range []uint64{2, 3} {
@@ -418,16 +418,16 @@ func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) {
418418 }
419419 key := sessionKey("named")
420420 manifest := repositoryPublication(t, "named", 3, []byte{3})
421- if err := os.Remove(repo.objectPath(key, manifest.Objects[1].Digest)); err != nil {
421+ if err := os.Remove(repo.legacyObjectPath(key, manifest.Objects[1].Digest)); err != nil {
422422 t.Fatal(err)
423423 }
424424 if err := repo.Maintain(context.Background()); err != nil {
425425 t.Fatal(err)
426426 }
427- if _, err := os.Lstat(repo.manifestPath(key, 3)); err != nil {
427+ if _, err := os.Lstat(repo.legacyManifestPath(key, 3)); err != nil {
428428 t.Fatalf("incomplete manifest removed: %v", err)
429429 }
430- if _, err := os.Lstat(repo.objectPath(key, manifest.Objects[0].Digest)); err != nil {
430+ if _, err := os.Lstat(repo.legacyObjectPath(key, manifest.Objects[0].Digest)); err != nil {
431431 t.Fatalf("object referenced by incomplete manifest removed: %v", err)
432432 }
433433 }
@@ -601,7 +601,7 @@ func TestRepositoryMaintainClosesContinuationDirectories(t *testing.T) {
601601 func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) {
602602 repo := NewRepository(privateDir(t))
603603 key := sessionKey("named")
604- root := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
604+ root := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
605605 for i := 0; i < maintenanceBatch*2; i++ {
606606 if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil {
607607 t.Fatal(err)
@@ -698,7 +698,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
698698 }
699699 key := sessionKey("named")
700700 stale := sha256.Sum256([]byte("stale object"))
701- stalePath := repo.objectPath(key, stale)
701+ stalePath := repo.legacyObjectPath(key, stale)
702702 if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
703703 t.Fatal(err)
704704 }
@@ -715,7 +715,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
715715 t.Fatalf("stale object swept before complete mark pass: %v", err)
716716 }
717717 for _, object := range newest.Objects {
718- if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
718+ if _, err := os.Lstat(repo.legacyObjectPath(key, object.Digest)); err != nil {
719719 t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err)
720720 }
721721 }
@@ -732,7 +732,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
732732 }
733733 }
734734 for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} {
735- if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {
735+ if _, err := os.Lstat(repo.legacyManifestPath(key, generation)); err != nil {
736736 t.Fatalf("newest complete manifest %d removed: %v", generation, err)
737737 }
738738 }
@@ -852,7 +852,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
852852 name: "missing",
853853 set: func(t *testing.T, repo *Repository, key string) {
854854 t.Helper()
855- if err := os.Remove(repo.headPath(key)); err != nil {
855+ if err := os.Remove(repo.legacyHeadPath(key)); err != nil {
856856 t.Fatal(err)
857857 }
858858 },
@@ -861,7 +861,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
861861 name: "corrupt",
862862 set: func(t *testing.T, repo *Repository, key string) {
863863 t.Helper()
864- if err := os.WriteFile(repo.headPath(key), []byte("corrupt"), 0o600); err != nil {
864+ if err := os.WriteFile(repo.legacyHeadPath(key), []byte("corrupt"), 0o600); err != nil {
865865 t.Fatal(err)
866866 }
867867 },
@@ -880,7 +880,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
880880 }
881881 head.set(t, repo, key)
882882 stale := sha256.Sum256([]byte("unpublished stale object"))
883- stalePath := repo.objectPath(key, stale)
883+ stalePath := repo.legacyObjectPath(key, stale)
884884 if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
885885 t.Fatal(err)
886886 }
@@ -895,7 +895,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
895895 t.Fatalf("stale object swept before full manifest classification: %v", err)
896896 }
897897 for _, publication := range publications {
898- if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
898+ if _, err := os.Lstat(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
899899 t.Fatalf("object for unpublished generation %d removed early: %v", publication.Generation, err)
900900 }
901901 }
@@ -911,7 +911,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
911911 }
912912 }
913913 for _, publication := range publications {
914- if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
914+ if _, err := os.Lstat(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
915915 t.Fatalf("object for unpublished generation %d removed: %v", publication.Generation, err)
916916 }
917917 }
@@ -926,7 +926,7 @@ func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
926926 stalePaths := make([]string, 0, maintenanceBatch+1)
927927 for i := 0; i < maintenanceBatch+1; i++ {
928928 digest := digestInShard(t, "00", i)
929- path := repo.objectPath(key, digest)
929+ path := repo.legacyObjectPath(key, digest)
930930 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
931931 t.Fatal(err)
932932 }
@@ -968,7 +968,7 @@ func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
968968 t.Fatal(err)
969969 }
970970 for _, object := range second.Objects {
971- if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
971+ if _, err := os.Lstat(repo.legacyObjectPath(key, object.Digest)); err != nil {
972972 t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)
973973 }
974974 }
@@ -296,7 +296,7 @@ func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T)
296 t.Fatal(err)296 t.Fatal(err)
297 }297 }
298 298
299- canonical := repo.sessionPath(sessionKey("named"))299+ canonical := repo.legacySessionPath(sessionKey("named"))
300 if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) {300 if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) {
301 t.Fatalf("canonical session after Delete = %v, want not exist", err)301 t.Fatalf("canonical session after Delete = %v, want not exist", err)
302 }302 }
@@ -329,7 +329,7 @@ func TestRepositoryDeleteReturnsRootSyncFailureAfterQuarantine(t *testing.T) {
329 if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {329 if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
330 t.Fatalf("Delete error = %v, want root sync failure", err)330 t.Fatalf("Delete error = %v, want root sync failure", err)
331 }331 }
332- if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {332+ if _, err := os.Lstat(repo.legacySessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
333 t.Fatalf("canonical session after failed sync = %v, want not exist", err)333 t.Fatalf("canonical session after failed sync = %v, want not exist", err)
334 }334 }
335 }335 }
@@ -366,7 +366,7 @@ func TestRepositoryDeleteWaitsForPublication(t *testing.T) {
366 if err := <-deleteDone; err != nil {366 if err := <-deleteDone; err != nil {
367 t.Fatal(err)367 t.Fatal(err)
368 }368 }
369- if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {369+ if _, err := os.Lstat(repo.legacySessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
370 t.Fatalf("canonical session after concurrent Delete = %v, want not exist", err)370 t.Fatalf("canonical session after concurrent Delete = %v, want not exist", err)
371 }371 }
372 }372 }
@@ -383,7 +383,7 @@ func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
383 if err := repo.Maintain(context.Background()); err != nil {383 if err := repo.Maintain(context.Background()); err != nil {
384 t.Fatal(err)384 t.Fatal(err)
385 }385 }
386- _, err := os.Lstat(repo.manifestPath(key, 1))386+ _, err := os.Lstat(repo.legacyManifestPath(key, 1))
387 if errors.Is(err, os.ErrNotExist) {387 if errors.Is(err, os.ErrNotExist) {
388 break388 break
389 }389 }
@@ -395,11 +395,11 @@ func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
395 }395 }
396 }396 }
397 for _, generation := range []uint64{2, 3} {397 for _, generation := range []uint64{2, 3} {
398- if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {398+ if _, err := os.Lstat(repo.legacyManifestPath(key, generation)); err != nil {
399 t.Fatalf("retained manifest %d: %v", generation, err)399 t.Fatalf("retained manifest %d: %v", generation, err)
400 }400 }
401 }401 }
402- if _, err := os.Lstat(repo.manifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) {402+ if _, err := os.Lstat(repo.legacyManifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) {
403 t.Fatalf("old manifest after Maintain = %v, want not exist", err)403 t.Fatalf("old manifest after Maintain = %v, want not exist", err)
404 }404 }
405 for _, generation := range []uint64{2, 3} {405 for _, generation := range []uint64{2, 3} {
@@ -418,16 +418,16 @@ func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) {
418 }418 }
419 key := sessionKey("named")419 key := sessionKey("named")
420 manifest := repositoryPublication(t, "named", 3, []byte{3})420 manifest := repositoryPublication(t, "named", 3, []byte{3})
421- if err := os.Remove(repo.objectPath(key, manifest.Objects[1].Digest)); err != nil {421+ if err := os.Remove(repo.legacyObjectPath(key, manifest.Objects[1].Digest)); err != nil {
422 t.Fatal(err)422 t.Fatal(err)
423 }423 }
424 if err := repo.Maintain(context.Background()); err != nil {424 if err := repo.Maintain(context.Background()); err != nil {
425 t.Fatal(err)425 t.Fatal(err)
426 }426 }
427- if _, err := os.Lstat(repo.manifestPath(key, 3)); err != nil {427+ if _, err := os.Lstat(repo.legacyManifestPath(key, 3)); err != nil {
428 t.Fatalf("incomplete manifest removed: %v", err)428 t.Fatalf("incomplete manifest removed: %v", err)
429 }429 }
430- if _, err := os.Lstat(repo.objectPath(key, manifest.Objects[0].Digest)); err != nil {430+ if _, err := os.Lstat(repo.legacyObjectPath(key, manifest.Objects[0].Digest)); err != nil {
431 t.Fatalf("object referenced by incomplete manifest removed: %v", err)431 t.Fatalf("object referenced by incomplete manifest removed: %v", err)
432 }432 }
433 }433 }
@@ -601,7 +601,7 @@ func TestRepositoryMaintainClosesContinuationDirectories(t *testing.T) {
601 func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) {601 func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) {
602 repo := NewRepository(privateDir(t))602 repo := NewRepository(privateDir(t))
603 key := sessionKey("named")603 key := sessionKey("named")
604- root := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)604+ root := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir)
605 for i := 0; i < maintenanceBatch*2; i++ {605 for i := 0; i < maintenanceBatch*2; i++ {
606 if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil {606 if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil {
607 t.Fatal(err)607 t.Fatal(err)
@@ -698,7 +698,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
698 }698 }
699 key := sessionKey("named")699 key := sessionKey("named")
700 stale := sha256.Sum256([]byte("stale object"))700 stale := sha256.Sum256([]byte("stale object"))
701- stalePath := repo.objectPath(key, stale)701+ stalePath := repo.legacyObjectPath(key, stale)
702 if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {702 if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
703 t.Fatal(err)703 t.Fatal(err)
704 }704 }
@@ -715,7 +715,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
715 t.Fatalf("stale object swept before complete mark pass: %v", err)715 t.Fatalf("stale object swept before complete mark pass: %v", err)
716 }716 }
717 for _, object := range newest.Objects {717 for _, object := range newest.Objects {
718- if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {718+ if _, err := os.Lstat(repo.legacyObjectPath(key, object.Digest)); err != nil {
719 t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err)719 t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err)
720 }720 }
721 }721 }
@@ -732,7 +732,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
732 }732 }
733 }733 }
734 for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} {734 for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} {
735- if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {735+ if _, err := os.Lstat(repo.legacyManifestPath(key, generation)); err != nil {
736 t.Fatalf("newest complete manifest %d removed: %v", generation, err)736 t.Fatalf("newest complete manifest %d removed: %v", generation, err)
737 }737 }
738 }738 }
@@ -852,7 +852,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
852 name: "missing",852 name: "missing",
853 set: func(t *testing.T, repo *Repository, key string) {853 set: func(t *testing.T, repo *Repository, key string) {
854 t.Helper()854 t.Helper()
855- if err := os.Remove(repo.headPath(key)); err != nil {855+ if err := os.Remove(repo.legacyHeadPath(key)); err != nil {
856 t.Fatal(err)856 t.Fatal(err)
857 }857 }
858 },858 },
@@ -861,7 +861,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
861 name: "corrupt",861 name: "corrupt",
862 set: func(t *testing.T, repo *Repository, key string) {862 set: func(t *testing.T, repo *Repository, key string) {
863 t.Helper()863 t.Helper()
864- if err := os.WriteFile(repo.headPath(key), []byte("corrupt"), 0o600); err != nil {864+ if err := os.WriteFile(repo.legacyHeadPath(key), []byte("corrupt"), 0o600); err != nil {
865 t.Fatal(err)865 t.Fatal(err)
866 }866 }
867 },867 },
@@ -880,7 +880,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
880 }880 }
881 head.set(t, repo, key)881 head.set(t, repo, key)
882 stale := sha256.Sum256([]byte("unpublished stale object"))882 stale := sha256.Sum256([]byte("unpublished stale object"))
883- stalePath := repo.objectPath(key, stale)883+ stalePath := repo.legacyObjectPath(key, stale)
884 if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {884 if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
885 t.Fatal(err)885 t.Fatal(err)
886 }886 }
@@ -895,7 +895,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
895 t.Fatalf("stale object swept before full manifest classification: %v", err)895 t.Fatalf("stale object swept before full manifest classification: %v", err)
896 }896 }
897 for _, publication := range publications {897 for _, publication := range publications {
898- if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {898+ if _, err := os.Lstat(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
899 t.Fatalf("object for unpublished generation %d removed early: %v", publication.Generation, err)899 t.Fatalf("object for unpublished generation %d removed early: %v", publication.Generation, err)
900 }900 }
901 }901 }
@@ -911,7 +911,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin
911 }911 }
912 }912 }
913 for _, publication := range publications {913 for _, publication := range publications {
914- if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {914+ if _, err := os.Lstat(repo.legacyObjectPath(key, publication.Objects[0].Digest)); err != nil {
915 t.Fatalf("object for unpublished generation %d removed: %v", publication.Generation, err)915 t.Fatalf("object for unpublished generation %d removed: %v", publication.Generation, err)
916 }916 }
917 }917 }
@@ -926,7 +926,7 @@ func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
926 stalePaths := make([]string, 0, maintenanceBatch+1)926 stalePaths := make([]string, 0, maintenanceBatch+1)
927 for i := 0; i < maintenanceBatch+1; i++ {927 for i := 0; i < maintenanceBatch+1; i++ {
928 digest := digestInShard(t, "00", i)928 digest := digestInShard(t, "00", i)
929- path := repo.objectPath(key, digest)929+ path := repo.legacyObjectPath(key, digest)
930 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {930 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
931 t.Fatal(err)931 t.Fatal(err)
932 }932 }
@@ -968,7 +968,7 @@ func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
968 t.Fatal(err)968 t.Fatal(err)
969 }969 }
970 for _, object := range second.Objects {970 for _, object := range second.Objects {
971- if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {971+ if _, err := os.Lstat(repo.legacyObjectPath(key, object.Digest)); err != nil {
972 t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)972 t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)
973 }973 }
974 }974 }
M internal/adapters/snapshot/migration_v0.go +5 -5
@@ -178,14 +178,14 @@ func (r *Repository) ReadLegacyHEAD(ctx context.Context, name string) (domain.Ch
178178 return domain.CheckpointRef{}, err
179179 }
180180 key := sessionKey(name)
181- generation, digest, err := r.readHead(key)
181+ generation, digest, err := r.readLegacyHead(key)
182182 if err != nil {
183183 if errors.Is(err, os.ErrNotExist) || errors.Is(err, ErrInvalidHEAD) {
184184 return domain.CheckpointRef{}, uncertainLegacyError("read HEAD", err)
185185 }
186186 return domain.CheckpointRef{}, fmt.Errorf("snapshot: read legacy HEAD: %w", err)
187187 }
188- data, err := r.readBounded(r.manifestPath(key, generation))
188+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
189189 if err != nil {
190190 if errors.Is(err, os.ErrNotExist) {
191191 return domain.CheckpointRef{}, uncertainLegacyError("read referenced manifest", err)
@@ -220,7 +220,7 @@ func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.Snapshot
220220 return domain.CheckpointRef{}, errors.New("snapshot: invalid migration request")
221221 }
222222 legacyKey := sessionKey(req.LegacyName)
223- data, err := r.readBounded(r.manifestPath(legacyKey, req.LegacyRef.Generation))
223+ data, err := r.readBounded(r.legacyManifestPath(legacyKey, req.LegacyRef.Generation))
224224 if err != nil {
225225 if errors.Is(err, os.ErrNotExist) {
226226 return domain.CheckpointRef{}, uncertainLegacyError("referenced manifest missing", err)
@@ -244,14 +244,14 @@ func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.Snapshot
244244 key, _ := incarnationKey(req.IncarnationID)
245245 lock := r.lockSession(key)
246246 defer r.unlockSession(lock)
247- if err := r.ensureSession(key); err != nil {
247+ if err := r.ensureSession(req.IncarnationID); err != nil {
248248 return domain.CheckpointRef{}, err
249249 }
250250 for _, ref := range decoded.Objects {
251251 if err := ctx.Err(); err != nil {
252252 return domain.CheckpointRef{}, err
253253 }
254- object, err := r.readBounded(r.objectPath(legacyKey, ref.Digest))
254+ object, err := r.readBounded(r.legacyObjectPath(legacyKey, ref.Digest))
255255 if err != nil {
256256 if errors.Is(err, os.ErrNotExist) {
257257 return domain.CheckpointRef{}, uncertainLegacyError("referenced object missing", err)
@@ -178,14 +178,14 @@ func (r *Repository) ReadLegacyHEAD(ctx context.Context, name string) (domain.Ch
178 return domain.CheckpointRef{}, err178 return domain.CheckpointRef{}, err
179 }179 }
180 key := sessionKey(name)180 key := sessionKey(name)
181- generation, digest, err := r.readHead(key)181+ generation, digest, err := r.readLegacyHead(key)
182 if err != nil {182 if err != nil {
183 if errors.Is(err, os.ErrNotExist) || errors.Is(err, ErrInvalidHEAD) {183 if errors.Is(err, os.ErrNotExist) || errors.Is(err, ErrInvalidHEAD) {
184 return domain.CheckpointRef{}, uncertainLegacyError("read HEAD", err)184 return domain.CheckpointRef{}, uncertainLegacyError("read HEAD", err)
185 }185 }
186 return domain.CheckpointRef{}, fmt.Errorf("snapshot: read legacy HEAD: %w", err)186 return domain.CheckpointRef{}, fmt.Errorf("snapshot: read legacy HEAD: %w", err)
187 }187 }
188- data, err := r.readBounded(r.manifestPath(key, generation))188+ data, err := r.readBounded(r.legacyManifestPath(key, generation))
189 if err != nil {189 if err != nil {
190 if errors.Is(err, os.ErrNotExist) {190 if errors.Is(err, os.ErrNotExist) {
191 return domain.CheckpointRef{}, uncertainLegacyError("read referenced manifest", err)191 return domain.CheckpointRef{}, uncertainLegacyError("read referenced manifest", err)
@@ -220,7 +220,7 @@ func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.Snapshot
220 return domain.CheckpointRef{}, errors.New("snapshot: invalid migration request")220 return domain.CheckpointRef{}, errors.New("snapshot: invalid migration request")
221 }221 }
222 legacyKey := sessionKey(req.LegacyName)222 legacyKey := sessionKey(req.LegacyName)
223- data, err := r.readBounded(r.manifestPath(legacyKey, req.LegacyRef.Generation))223+ data, err := r.readBounded(r.legacyManifestPath(legacyKey, req.LegacyRef.Generation))
224 if err != nil {224 if err != nil {
225 if errors.Is(err, os.ErrNotExist) {225 if errors.Is(err, os.ErrNotExist) {
226 return domain.CheckpointRef{}, uncertainLegacyError("referenced manifest missing", err)226 return domain.CheckpointRef{}, uncertainLegacyError("referenced manifest missing", err)
@@ -244,14 +244,14 @@ func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.Snapshot
244 key, _ := incarnationKey(req.IncarnationID)244 key, _ := incarnationKey(req.IncarnationID)
245 lock := r.lockSession(key)245 lock := r.lockSession(key)
246 defer r.unlockSession(lock)246 defer r.unlockSession(lock)
247- if err := r.ensureSession(key); err != nil {247+ if err := r.ensureSession(req.IncarnationID); err != nil {
248 return domain.CheckpointRef{}, err248 return domain.CheckpointRef{}, err
249 }249 }
250 for _, ref := range decoded.Objects {250 for _, ref := range decoded.Objects {
251 if err := ctx.Err(); err != nil {251 if err := ctx.Err(); err != nil {
252 return domain.CheckpointRef{}, err252 return domain.CheckpointRef{}, err
253 }253 }
254- object, err := r.readBounded(r.objectPath(legacyKey, ref.Digest))254+ object, err := r.readBounded(r.legacyObjectPath(legacyKey, ref.Digest))
255 if err != nil {255 if err != nil {
256 if errors.Is(err, os.ErrNotExist) {256 if errors.Is(err, os.ErrNotExist) {
257 return domain.CheckpointRef{}, uncertainLegacyError("referenced object missing", err)257 return domain.CheckpointRef{}, uncertainLegacyError("referenced object missing", err)
M internal/adapters/snapshot/path.go +21 -19
@@ -34,31 +34,33 @@ func incarnationKey(id domain.IncarnationID) (string, error) {
3434 return id.String(), nil
3535 }
3636
37-// repositoryKey accepts string only for pre-migration maintenance code. Normal
38-// publication and loading always pass an IncarnationID.
39-func repositoryKey(identity any) string {
40- switch value := identity.(type) {
41- case domain.IncarnationID:
42- return value.String()
43- case string:
44- return value
45- default:
46- panic("snapshot: invalid repository identity")
47- }
37+func (r *Repository) sessionPath(id domain.IncarnationID) string {
38+ return filepath.Join(r.dir, repositorySessionsDir, id.String())
39+}
40+func (r *Repository) objectPath(id domain.IncarnationID, digest ports.SnapshotDigest) string {
41+ hexDigest := hex.EncodeToString(digest[:])
42+ return filepath.Join(r.sessionPath(id), repositoryObjectsDir, hexDigest[:2], hexDigest)
43+}
44+func (r *Repository) manifestPath(id domain.IncarnationID, generation uint64) string {
45+ return filepath.Join(r.sessionPath(id), repositoryGenerations, generationFilename(generation))
46+}
47+func (r *Repository) headPath(id domain.IncarnationID) string {
48+ return filepath.Join(r.sessionPath(id), repositoryHead)
4849 }
4950
50-func (r *Repository) sessionPath(identity any) string {
51- return filepath.Join(r.dir, repositorySessionsDir, repositoryKey(identity))
51+// Legacy path helpers are isolated from normal incarnation-keyed operations.
52+func (r *Repository) legacySessionPath(key string) string {
53+ return filepath.Join(r.dir, repositorySessionsDir, key)
5254 }
53-func (r *Repository) objectPath(identity any, digest ports.SnapshotDigest) string {
55+func (r *Repository) legacyObjectPath(key string, digest ports.SnapshotDigest) string {
5456 hexDigest := hex.EncodeToString(digest[:])
55- return filepath.Join(r.sessionPath(identity), repositoryObjectsDir, hexDigest[:2], hexDigest)
57+ return filepath.Join(r.legacySessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
5658 }
57-func (r *Repository) manifestPath(identity any, generation uint64) string {
58- return filepath.Join(r.sessionPath(identity), repositoryGenerations, generationFilename(generation))
59+func (r *Repository) legacyManifestPath(key string, generation uint64) string {
60+ return filepath.Join(r.legacySessionPath(key), repositoryGenerations, generationFilename(generation))
5961 }
60-func (r *Repository) headPath(identity any) string {
61- return filepath.Join(r.sessionPath(identity), repositoryHead)
62+func (r *Repository) legacyHeadPath(key string) string {
63+ return filepath.Join(r.legacySessionPath(key), repositoryHead)
6264 }
6365 func sessionKey(name string) string {
6466 if safeNameRE.MatchString(name) && name != "." && name != ".." {
@@ -34,31 +34,33 @@ func incarnationKey(id domain.IncarnationID) (string, error) {
34 return id.String(), nil34 return id.String(), nil
35 }35 }
36 36
37-// repositoryKey accepts string only for pre-migration maintenance code. Normal37+func (r *Repository) sessionPath(id domain.IncarnationID) string {
38-// publication and loading always pass an IncarnationID.38+ return filepath.Join(r.dir, repositorySessionsDir, id.String())
39-func repositoryKey(identity any) string {39+}
40- switch value := identity.(type) {40+func (r *Repository) objectPath(id domain.IncarnationID, digest ports.SnapshotDigest) string {
41- case domain.IncarnationID:41+ hexDigest := hex.EncodeToString(digest[:])
42- return value.String()42+ return filepath.Join(r.sessionPath(id), repositoryObjectsDir, hexDigest[:2], hexDigest)
43- case string:43+}
44- return value44+func (r *Repository) manifestPath(id domain.IncarnationID, generation uint64) string {
45- default:45+ return filepath.Join(r.sessionPath(id), repositoryGenerations, generationFilename(generation))
46- panic("snapshot: invalid repository identity")46+}
47- }47+func (r *Repository) headPath(id domain.IncarnationID) string {
48+ return filepath.Join(r.sessionPath(id), repositoryHead)
48 }49 }
49 50
50-func (r *Repository) sessionPath(identity any) string {51+// Legacy path helpers are isolated from normal incarnation-keyed operations.
51- return filepath.Join(r.dir, repositorySessionsDir, repositoryKey(identity))52+func (r *Repository) legacySessionPath(key string) string {
53+ return filepath.Join(r.dir, repositorySessionsDir, key)
52 }54 }
53-func (r *Repository) objectPath(identity any, digest ports.SnapshotDigest) string {55+func (r *Repository) legacyObjectPath(key string, digest ports.SnapshotDigest) string {
54 hexDigest := hex.EncodeToString(digest[:])56 hexDigest := hex.EncodeToString(digest[:])
55- return filepath.Join(r.sessionPath(identity), repositoryObjectsDir, hexDigest[:2], hexDigest)57+ return filepath.Join(r.legacySessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
56 }58 }
57-func (r *Repository) manifestPath(identity any, generation uint64) string {59+func (r *Repository) legacyManifestPath(key string, generation uint64) string {
58- return filepath.Join(r.sessionPath(identity), repositoryGenerations, generationFilename(generation))60+ return filepath.Join(r.legacySessionPath(key), repositoryGenerations, generationFilename(generation))
59 }61 }
60-func (r *Repository) headPath(identity any) string {62+func (r *Repository) legacyHeadPath(key string) string {
61- return filepath.Join(r.sessionPath(identity), repositoryHead)63+ return filepath.Join(r.legacySessionPath(key), repositoryHead)
62 }64 }
63 func sessionKey(name string) string {65 func sessionKey(name string) string {
64 if safeNameRE.MatchString(name) && name != "." && name != ".." {66 if safeNameRE.MatchString(name) && name != "." && name != ".." {
M internal/adapters/snapshot/path_security_test.go +1 -1
@@ -154,7 +154,7 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
154154 t.Fatal(err)
155155 }
156156 key := sessionKey(publication.Name)
157- inside := filepath.Join(repo.sessionPath(key), target)
157+ inside := filepath.Join(repo.legacySessionPath(key), target)
158158 outside := t.TempDir()
159159 guard := filepath.Join(outside, "must-not-change")
160160 if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
@@ -154,7 +154,7 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
154 t.Fatal(err)154 t.Fatal(err)
155 }155 }
156 key := sessionKey(publication.Name)156 key := sessionKey(publication.Name)
157- inside := filepath.Join(repo.sessionPath(key), target)157+ inside := filepath.Join(repo.legacySessionPath(key), target)
158 outside := t.TempDir()158 outside := t.TempDir()
159 guard := filepath.Join(outside, "must-not-change")159 guard := filepath.Join(outside, "must-not-change")
160 if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {160 if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
M internal/adapters/snapshot/publish.go +12 -12
@@ -37,17 +37,17 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
3737 // A failed publication can still leave immutable blobs or a manifest behind.
3838 // Invalidate an in-progress GC mark before creating any publication storage.
3939 r.invalidateStorageEpoch(key)
40- if err := r.ensureSession(key); err != nil {
40+ if err := r.ensureSession(publication.IncarnationID); err != nil {
4141 return err
4242 }
4343 // The common replay path needs neither a manifest decode nor a reference
4444 // map. Checking the immutable bytes directly also keeps its descriptor
4545 // reads beneath the pinned repository root.
46- if unchanged, err := r.unchangedPublication(key, publication); err == nil && unchanged {
46+ if unchanged, err := r.unchangedPublication(publication); err == nil && unchanged {
4747 return nil
4848 }
4949
50- current, currentManifest, currentRefs, err := r.currentIncarnationPublication(ctx, publication, key)
50+ current, currentManifest, currentRefs, err := r.currentIncarnationPublication(ctx, publication)
5151 if err != nil {
5252 return err
5353 }
@@ -76,7 +76,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
7676 if err := ctx.Err(); err != nil {
7777 return err
7878 }
79- path := r.objectPath(key, digest)
79+ path := r.objectPath(publication.IncarnationID, digest)
8080 exists, err := r.verifyObjectFile(path, digest, ref)
8181 if err != nil {
8282 return fmt.Errorf("verify object %x: %w", digest, err)
@@ -110,7 +110,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
110110 }
111111 }
112112
113- manifestPath := r.manifestPath(key, publication.Generation)
113+ manifestPath := r.manifestPath(publication.IncarnationID, publication.Generation)
114114 existing, exists, err := r.readOptionalBounded(manifestPath)
115115 if err != nil {
116116 return err
@@ -138,14 +138,14 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
138138 }
139139 }
140140 if r.hooks.beforeHeadWrite != nil {
141- if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {
141+ if err := r.hooks.beforeHeadWrite(r.headPath(publication.IncarnationID)); err != nil {
142142 return err
143143 }
144144 }
145145 if err := ctx.Err(); err != nil {
146146 return err
147147 }
148- if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
148+ if err := r.writeMutable(r.headPath(publication.IncarnationID), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
149149 return fmt.Errorf("write HEAD: %w", err)
150150 }
151151 return nil
@@ -154,23 +154,23 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
154154 // unchangedPublication uses one root for the two reads in the replay fast
155155 // path. The root is confined to this operation and is closed before return;
156156 // all other repository operations retain their open-per-operation roots.
157-func (r *Repository) unchangedPublication(key string, publication ports.SnapshotPublication) (unchanged bool, err error) {
157+func (r *Repository) unchangedPublication(publication ports.SnapshotPublication) (unchanged bool, err error) {
158158 root, err := r.openRoot()
159159 if err != nil {
160160 return false, err
161161 }
162162 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
163163
164- generation, _, err := r.readHeadWithRoot(root, key)
164+ generation, _, err := r.readHeadWithRoot(root, publication.IncarnationID)
165165 if err != nil || generation != publication.Generation {
166166 return false, nil
167167 }
168- current, err := r.readBoundedRoot(root, r.manifestPath(key, generation))
168+ current, err := r.readBoundedRoot(root, r.manifestPath(publication.IncarnationID, generation))
169169 return err == nil && bytes.Equal(current, publication.Manifest), nil
170170 }
171171
172-func (r *Repository) currentIncarnationPublication(ctx context.Context, publication ports.SnapshotPublication, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
173- generation, digest, err := r.readHead(key)
172+func (r *Repository) currentIncarnationPublication(ctx context.Context, publication ports.SnapshotPublication) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
173+ generation, digest, err := r.readHead(publication.IncarnationID)
174174 if errors.Is(err, os.ErrNotExist) {
175175 if publication.Generation != 1 || publication.ParentCheckpoint != nil {
176176 return 0, nil, nil, fmt.Errorf("first snapshot generation must be 1 with no parent")
@@ -37,17 +37,17 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
37 // A failed publication can still leave immutable blobs or a manifest behind.37 // A failed publication can still leave immutable blobs or a manifest behind.
38 // Invalidate an in-progress GC mark before creating any publication storage.38 // Invalidate an in-progress GC mark before creating any publication storage.
39 r.invalidateStorageEpoch(key)39 r.invalidateStorageEpoch(key)
40- if err := r.ensureSession(key); err != nil {40+ if err := r.ensureSession(publication.IncarnationID); err != nil {
41 return err41 return err
42 }42 }
43 // The common replay path needs neither a manifest decode nor a reference43 // The common replay path needs neither a manifest decode nor a reference
44 // map. Checking the immutable bytes directly also keeps its descriptor44 // map. Checking the immutable bytes directly also keeps its descriptor
45 // reads beneath the pinned repository root.45 // reads beneath the pinned repository root.
46- if unchanged, err := r.unchangedPublication(key, publication); err == nil && unchanged {46+ if unchanged, err := r.unchangedPublication(publication); err == nil && unchanged {
47 return nil47 return nil
48 }48 }
49 49
50- current, currentManifest, currentRefs, err := r.currentIncarnationPublication(ctx, publication, key)50+ current, currentManifest, currentRefs, err := r.currentIncarnationPublication(ctx, publication)
51 if err != nil {51 if err != nil {
52 return err52 return err
53 }53 }
@@ -76,7 +76,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
76 if err := ctx.Err(); err != nil {76 if err := ctx.Err(); err != nil {
77 return err77 return err
78 }78 }
79- path := r.objectPath(key, digest)79+ path := r.objectPath(publication.IncarnationID, digest)
80 exists, err := r.verifyObjectFile(path, digest, ref)80 exists, err := r.verifyObjectFile(path, digest, ref)
81 if err != nil {81 if err != nil {
82 return fmt.Errorf("verify object %x: %w", digest, err)82 return fmt.Errorf("verify object %x: %w", digest, err)
@@ -110,7 +110,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
110 }110 }
111 }111 }
112 112
113- manifestPath := r.manifestPath(key, publication.Generation)113+ manifestPath := r.manifestPath(publication.IncarnationID, publication.Generation)
114 existing, exists, err := r.readOptionalBounded(manifestPath)114 existing, exists, err := r.readOptionalBounded(manifestPath)
115 if err != nil {115 if err != nil {
116 return err116 return err
@@ -138,14 +138,14 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
138 }138 }
139 }139 }
140 if r.hooks.beforeHeadWrite != nil {140 if r.hooks.beforeHeadWrite != nil {
141- if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {141+ if err := r.hooks.beforeHeadWrite(r.headPath(publication.IncarnationID)); err != nil {
142 return err142 return err
143 }143 }
144 }144 }
145 if err := ctx.Err(); err != nil {145 if err := ctx.Err(); err != nil {
146 return err146 return err
147 }147 }
148- if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {148+ if err := r.writeMutable(r.headPath(publication.IncarnationID), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
149 return fmt.Errorf("write HEAD: %w", err)149 return fmt.Errorf("write HEAD: %w", err)
150 }150 }
151 return nil151 return nil
@@ -154,23 +154,23 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
154 // unchangedPublication uses one root for the two reads in the replay fast154 // unchangedPublication uses one root for the two reads in the replay fast
155 // path. The root is confined to this operation and is closed before return;155 // path. The root is confined to this operation and is closed before return;
156 // all other repository operations retain their open-per-operation roots.156 // all other repository operations retain their open-per-operation roots.
157-func (r *Repository) unchangedPublication(key string, publication ports.SnapshotPublication) (unchanged bool, err error) {157+func (r *Repository) unchangedPublication(publication ports.SnapshotPublication) (unchanged bool, err error) {
158 root, err := r.openRoot()158 root, err := r.openRoot()
159 if err != nil {159 if err != nil {
160 return false, err160 return false, err
161 }161 }
162 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()162 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
163 163
164- generation, _, err := r.readHeadWithRoot(root, key)164+ generation, _, err := r.readHeadWithRoot(root, publication.IncarnationID)
165 if err != nil || generation != publication.Generation {165 if err != nil || generation != publication.Generation {
166 return false, nil166 return false, nil
167 }167 }
168- current, err := r.readBoundedRoot(root, r.manifestPath(key, generation))168+ current, err := r.readBoundedRoot(root, r.manifestPath(publication.IncarnationID, generation))
169 return err == nil && bytes.Equal(current, publication.Manifest), nil169 return err == nil && bytes.Equal(current, publication.Manifest), nil
170 }170 }
171 171
172-func (r *Repository) currentIncarnationPublication(ctx context.Context, publication ports.SnapshotPublication, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {172+func (r *Repository) currentIncarnationPublication(ctx context.Context, publication ports.SnapshotPublication) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
173- generation, digest, err := r.readHead(key)173+ generation, digest, err := r.readHead(publication.IncarnationID)
174 if errors.Is(err, os.ErrNotExist) {174 if errors.Is(err, os.ErrNotExist) {
175 if publication.Generation != 1 || publication.ParentCheckpoint != nil {175 if publication.Generation != 1 || publication.ParentCheckpoint != nil {
176 return 0, nil, nil, fmt.Errorf("first snapshot generation must be 1 with no parent")176 return 0, nil, nil, fmt.Errorf("first snapshot generation must be 1 with no parent")
M internal/adapters/snapshot/publish_efficiency_test.go +7 -7
@@ -120,10 +120,10 @@ func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) {
120120 second := repositoryPublication(t, "named", 2, []byte("second"))
121121 newTail := second.Objects[0]
122122 key := sessionKey(second.Name)
123- if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, newTail.Digest)), 0o700); err != nil {
123+ if err := os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, newTail.Digest)), 0o700); err != nil {
124124 t.Fatal(err)
125125 }
126- if err := os.WriteFile(repo.objectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil {
126+ if err := os.WriteFile(repo.legacyObjectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil {
127127 t.Fatal(err)
128128 }
129129
@@ -175,7 +175,7 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S
175175 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
176176 t.Helper()
177177 key := sessionKey(publication.Name)
178- if err := repo.ensureSession(key); err != nil {
178+ if err := repo.ensureSession(publication.IncarnationID); err != nil {
179179 t.Fatal(err)
180180 }
181181 manifest, err := codec.UnmarshalManifest(publication.Manifest)
@@ -183,17 +183,17 @@ func seedCompletePublication(t testing.TB, repo *Repository, publication ports.S
183183 t.Fatal(err)
184184 }
185185 for _, object := range publication.Objects {
186- if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, object.Digest)), 0o700); err != nil {
186+ if err := os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, object.Digest)), 0o700); err != nil {
187187 t.Fatal(err)
188188 }
189- if err := os.WriteFile(repo.objectPath(key, object.Digest), object.Data, 0o600); err != nil {
189+ if err := os.WriteFile(repo.legacyObjectPath(key, object.Digest), object.Data, 0o600); err != nil {
190190 t.Fatal(err)
191191 }
192192 }
193- if err := os.WriteFile(repo.manifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil {
193+ if err := os.WriteFile(repo.legacyManifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil {
194194 t.Fatal(err)
195195 }
196- if err := os.WriteFile(repo.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
196+ if err := os.WriteFile(repo.legacyHeadPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
197197 t.Fatal(err)
198198 }
199199 if refs := manifestRefs(manifest); refs == nil {
@@ -120,10 +120,10 @@ func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) {
120 second := repositoryPublication(t, "named", 2, []byte("second"))120 second := repositoryPublication(t, "named", 2, []byte("second"))
121 newTail := second.Objects[0]121 newTail := second.Objects[0]
122 key := sessionKey(second.Name)122 key := sessionKey(second.Name)
123- if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, newTail.Digest)), 0o700); err != nil {123+ if err := os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, newTail.Digest)), 0o700); err != nil {
124 t.Fatal(err)124 t.Fatal(err)
125 }125 }
126- if err := os.WriteFile(repo.objectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil {126+ if err := os.WriteFile(repo.legacyObjectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil {
127 t.Fatal(err)127 t.Fatal(err)
128 }128 }
129 129
@@ -175,7 +175,7 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S
175 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {175 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
176 t.Helper()176 t.Helper()
177 key := sessionKey(publication.Name)177 key := sessionKey(publication.Name)
178- if err := repo.ensureSession(key); err != nil {178+ if err := repo.ensureSession(publication.IncarnationID); err != nil {
179 t.Fatal(err)179 t.Fatal(err)
180 }180 }
181 manifest, err := codec.UnmarshalManifest(publication.Manifest)181 manifest, err := codec.UnmarshalManifest(publication.Manifest)
@@ -183,17 +183,17 @@ func seedCompletePublication(t testing.TB, repo *Repository, publication ports.S
183 t.Fatal(err)183 t.Fatal(err)
184 }184 }
185 for _, object := range publication.Objects {185 for _, object := range publication.Objects {
186- if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, object.Digest)), 0o700); err != nil {186+ if err := os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, object.Digest)), 0o700); err != nil {
187 t.Fatal(err)187 t.Fatal(err)
188 }188 }
189- if err := os.WriteFile(repo.objectPath(key, object.Digest), object.Data, 0o600); err != nil {189+ if err := os.WriteFile(repo.legacyObjectPath(key, object.Digest), object.Data, 0o600); err != nil {
190 t.Fatal(err)190 t.Fatal(err)
191 }191 }
192 }192 }
193- if err := os.WriteFile(repo.manifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil {193+ if err := os.WriteFile(repo.legacyManifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil {
194 t.Fatal(err)194 t.Fatal(err)
195 }195 }
196- if err := os.WriteFile(repo.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {196+ if err := os.WriteFile(repo.legacyHeadPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
197 t.Fatal(err)197 t.Fatal(err)
198 }198 }
199 if refs := manifestRefs(manifest); refs == nil {199 if refs := manifestRefs(manifest); refs == nil {
M internal/adapters/snapshot/repository_durability_test.go +8 -9
@@ -16,7 +16,7 @@ func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
1616 repo := NewRepository(privateDir(t))
1717 pub := repositoryPublication(t, "named", 1, []byte("state"))
1818 object := pub.Objects[0]
19- target := repo.objectPath(sessionKey(pub.Name), object.Digest)
19+ target := repo.legacyObjectPath(sessionKey(pub.Name), object.Digest)
2020 repo.hooks.installImmutable = func(path string) error {
2121 if path == target {
2222 return os.WriteFile(path, []byte("attacker bytes"), 0o600)
@@ -39,7 +39,7 @@ func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublicat
3939 t.Helper()
4040 key := sessionKey(pub.Name)
4141 prepareObjects(t, repo, pub)
42- if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
42+ if err := repo.writeImmutable(repo.legacyManifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
4343 if !bytes.Equal(data, pub.Manifest) {
4444 return errors.New("invalid manifest")
4545 }
@@ -79,8 +79,7 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
7979 t.Fatal(err)
8080 }
8181 second := repositoryPublication(t, "named", 2, []byte("two"))
82- key := sessionKey(second.Name)
83- if err := repo.ensureSession(key); err != nil {
82+ if err := repo.ensureSession(second.IncarnationID); err != nil {
8483 t.Fatal(err)
8584 }
8685 preparePublicationLocation(t, repo, second, tc.location)
@@ -114,12 +113,12 @@ func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.Snapsh
114113
115114 func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
116115 key := sessionKey(pub.Name)
117- dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))
116+ dir := filepath.Dir(repo.legacyObjectPath(key, pub.Objects[0].Digest))
118117 if location == "manifest" {
119- dir = filepath.Dir(repo.manifestPath(key, pub.Generation))
118+ dir = filepath.Dir(repo.legacyManifestPath(key, pub.Generation))
120119 }
121120 if location == "HEAD" {
122- dir = filepath.Dir(repo.headPath(key))
121+ dir = filepath.Dir(repo.legacyHeadPath(key))
123122 }
124123 fail := errors.New("injected persistence failure")
125124 repo.hooks.createTemp = func(got string) error {
@@ -153,7 +152,7 @@ func injectBoundary(repo *Repository, location, operation string, pub ports.Snap
153152 return nil
154153 }
155154 repo.hooks.rename = func(path string) error {
156- if operation == "rename" && location == "HEAD" && path == repo.headPath(key) {
155+ if operation == "rename" && location == "HEAD" && path == repo.legacyHeadPath(key) {
157156 return fail
158157 }
159158 return nil
@@ -171,7 +170,7 @@ func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublicatio
171170 key := sessionKey(pub.Name)
172171 for _, object := range pub.Objects {
173172 ref := manifestReference(t, pub.Manifest, object.Digest)
174- if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
173+ if err := repo.writeImmutable(repo.legacyObjectPath(key, object.Digest), object.Data, func(data []byte) error {
175174 if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
176175 return errors.New("invalid object")
177176 }
@@ -16,7 +16,7 @@ func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
16 repo := NewRepository(privateDir(t))16 repo := NewRepository(privateDir(t))
17 pub := repositoryPublication(t, "named", 1, []byte("state"))17 pub := repositoryPublication(t, "named", 1, []byte("state"))
18 object := pub.Objects[0]18 object := pub.Objects[0]
19- target := repo.objectPath(sessionKey(pub.Name), object.Digest)19+ target := repo.legacyObjectPath(sessionKey(pub.Name), object.Digest)
20 repo.hooks.installImmutable = func(path string) error {20 repo.hooks.installImmutable = func(path string) error {
21 if path == target {21 if path == target {
22 return os.WriteFile(path, []byte("attacker bytes"), 0o600)22 return os.WriteFile(path, []byte("attacker bytes"), 0o600)
@@ -39,7 +39,7 @@ func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublicat
39 t.Helper()39 t.Helper()
40 key := sessionKey(pub.Name)40 key := sessionKey(pub.Name)
41 prepareObjects(t, repo, pub)41 prepareObjects(t, repo, pub)
42- if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {42+ if err := repo.writeImmutable(repo.legacyManifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
43 if !bytes.Equal(data, pub.Manifest) {43 if !bytes.Equal(data, pub.Manifest) {
44 return errors.New("invalid manifest")44 return errors.New("invalid manifest")
45 }45 }
@@ -79,8 +79,7 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
79 t.Fatal(err)79 t.Fatal(err)
80 }80 }
81 second := repositoryPublication(t, "named", 2, []byte("two"))81 second := repositoryPublication(t, "named", 2, []byte("two"))
82- key := sessionKey(second.Name)82+ if err := repo.ensureSession(second.IncarnationID); err != nil {
83- if err := repo.ensureSession(key); err != nil {
84 t.Fatal(err)83 t.Fatal(err)
85 }84 }
86 preparePublicationLocation(t, repo, second, tc.location)85 preparePublicationLocation(t, repo, second, tc.location)
@@ -114,12 +113,12 @@ func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.Snapsh
114 113
115 func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {114 func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
116 key := sessionKey(pub.Name)115 key := sessionKey(pub.Name)
117- dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))116+ dir := filepath.Dir(repo.legacyObjectPath(key, pub.Objects[0].Digest))
118 if location == "manifest" {117 if location == "manifest" {
119- dir = filepath.Dir(repo.manifestPath(key, pub.Generation))118+ dir = filepath.Dir(repo.legacyManifestPath(key, pub.Generation))
120 }119 }
121 if location == "HEAD" {120 if location == "HEAD" {
122- dir = filepath.Dir(repo.headPath(key))121+ dir = filepath.Dir(repo.legacyHeadPath(key))
123 }122 }
124 fail := errors.New("injected persistence failure")123 fail := errors.New("injected persistence failure")
125 repo.hooks.createTemp = func(got string) error {124 repo.hooks.createTemp = func(got string) error {
@@ -153,7 +152,7 @@ func injectBoundary(repo *Repository, location, operation string, pub ports.Snap
153 return nil152 return nil
154 }153 }
155 repo.hooks.rename = func(path string) error {154 repo.hooks.rename = func(path string) error {
156- if operation == "rename" && location == "HEAD" && path == repo.headPath(key) {155+ if operation == "rename" && location == "HEAD" && path == repo.legacyHeadPath(key) {
157 return fail156 return fail
158 }157 }
159 return nil158 return nil
@@ -171,7 +170,7 @@ func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublicatio
171 key := sessionKey(pub.Name)170 key := sessionKey(pub.Name)
172 for _, object := range pub.Objects {171 for _, object := range pub.Objects {
173 ref := manifestReference(t, pub.Manifest, object.Digest)172 ref := manifestReference(t, pub.Manifest, object.Digest)
174- if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {173+ if err := repo.writeImmutable(repo.legacyObjectPath(key, object.Digest), object.Data, func(data []byte) error {
175 if !bytes.Equal(data, object.Data) || !validObject(data, ref) {174 if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
176 return errors.New("invalid object")175 return errors.New("invalid object")
177 }176 }
M internal/adapters/snapshot/repository_fault_test.go +6 -6
@@ -62,10 +62,10 @@ func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *test
6262 } else {
6363 prepareHeadStage(t, repo, second)
6464 }
65- dir := filepath.Dir(repo.manifestPath(key, second.Generation))
65+ dir := filepath.Dir(repo.legacyManifestPath(key, second.Generation))
6666 phase := "write manifest"
6767 if tc.location == "HEAD" {
68- dir = filepath.Dir(repo.headPath(key))
68+ dir = filepath.Dir(repo.legacyHeadPath(key))
6969 phase = "write HEAD"
7070 }
7171 repo.hooks.writeTemp = func(path string) error {
@@ -119,10 +119,10 @@ func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
119119 {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
120120 {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
121121 {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
122- {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
123- {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
122+ {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.legacySessionPath(sessionKey("named")) }},
123+ {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.legacySessionPath(sessionKey("named")) }},
124124 {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
125- return filepath.Join(repo.sessionPath(sessionKey("named")), repositoryObjectsDir)
125+ return filepath.Join(repo.legacySessionPath(sessionKey("named")), repositoryObjectsDir)
126126 }},
127127 }
128128 for _, tc := range cases {
@@ -191,7 +191,7 @@ func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
191191 if err != nil {
192192 t.Fatal(err)
193193 }
194- if err := os.Remove(repo.objectPath(sessionKey("named"), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {
194+ if err := os.Remove(repo.legacyObjectPath(sessionKey("named"), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {
195195 t.Fatal(err)
196196 }
197197 got, err := repo.Load(context.Background(), "named")
@@ -62,10 +62,10 @@ func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *test
62 } else {62 } else {
63 prepareHeadStage(t, repo, second)63 prepareHeadStage(t, repo, second)
64 }64 }
65- dir := filepath.Dir(repo.manifestPath(key, second.Generation))65+ dir := filepath.Dir(repo.legacyManifestPath(key, second.Generation))
66 phase := "write manifest"66 phase := "write manifest"
67 if tc.location == "HEAD" {67 if tc.location == "HEAD" {
68- dir = filepath.Dir(repo.headPath(key))68+ dir = filepath.Dir(repo.legacyHeadPath(key))
69 phase = "write HEAD"69 phase = "write HEAD"
70 }70 }
71 repo.hooks.writeTemp = func(path string) error {71 repo.hooks.writeTemp = func(path string) error {
@@ -119,10 +119,10 @@ func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
119 {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},119 {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
120 {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},120 {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
121 {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},121 {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
122- {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},122+ {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.legacySessionPath(sessionKey("named")) }},
123- {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},123+ {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.legacySessionPath(sessionKey("named")) }},
124 {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {124 {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
125- return filepath.Join(repo.sessionPath(sessionKey("named")), repositoryObjectsDir)125+ return filepath.Join(repo.legacySessionPath(sessionKey("named")), repositoryObjectsDir)
126 }},126 }},
127 }127 }
128 for _, tc := range cases {128 for _, tc := range cases {
@@ -191,7 +191,7 @@ func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
191 if err != nil {191 if err != nil {
192 t.Fatal(err)192 t.Fatal(err)
193 }193 }
194- if err := os.Remove(repo.objectPath(sessionKey("named"), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {194+ if err := os.Remove(repo.legacyObjectPath(sessionKey("named"), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {
195 t.Fatal(err)195 t.Fatal(err)
196 }196 }
197 got, err := repo.Load(context.Background(), "named")197 got, err := repo.Load(context.Background(), "named")
M internal/adapters/snapshot/repository_phase6_test.go +1 -1
@@ -53,7 +53,7 @@ func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) {
5353 func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {
5454 repo := NewRepository(privateDir(t))
5555 for _, key := range []string{"named-000", "named-001"} {
56- generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)
56+ generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations)
5757 for i := 0; i < maintenanceBatch+1; i++ {
5858 if err := os.MkdirAll(generations, 0o700); err != nil {
5959 t.Fatal(err)
@@ -53,7 +53,7 @@ func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) {
53 func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {53 func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {
54 repo := NewRepository(privateDir(t))54 repo := NewRepository(privateDir(t))
55 for _, key := range []string{"named-000", "named-001"} {55 for _, key := range []string{"named-000", "named-001"} {
56- generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)56+ generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations)
57 for i := 0; i < maintenanceBatch+1; i++ {57 for i := 0; i < maintenanceBatch+1; i++ {
58 if err := os.MkdirAll(generations, 0o700); err != nil {58 if err := os.MkdirAll(generations, 0o700); err != nil {
59 t.Fatal(err)59 t.Fatal(err)
M internal/app/run.go +15 -22
@@ -36,6 +36,7 @@ import (
3636 "github.com/bnema/vev/internal/adapters/noticefile"
3737 "github.com/bnema/vev/internal/adapters/observability"
3838 "github.com/bnema/vev/internal/adapters/pty"
39+ "github.com/bnema/vev/internal/adapters/recoveryfs"
3940 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
4041 "github.com/bnema/vev/internal/adapters/shellcmd"
4142 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
@@ -49,6 +50,7 @@ import (
4950 "github.com/bnema/vev/internal/usecase/client"
5051 "github.com/bnema/vev/internal/usecase/confirm"
5152 "github.com/bnema/vev/internal/usecase/daemon"
53+ "github.com/bnema/vev/internal/usecase/recovery"
5254 pdgram "github.com/bnema/vev/pkg/dgram"
5355 "github.com/bnema/vev/pkg/safedir"
5456 )
@@ -507,35 +509,26 @@ func runDaemonOwned(ctx context.Context) (retErr error) {
507509 daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository))
508510 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))
509511 storePath := persist.StorePath(platform.StateDir())
510- catalogueCandidate := false
511- for _, candidate := range []string{storePath, storePath + ".next", storePath + ".prev"} {
512- if _, statErr := os.Stat(candidate); statErr == nil {
513- catalogueCandidate = true
514- } else if !errors.Is(statErr, os.ErrNotExist) {
515- return fmt.Errorf("vev: inspect durable session catalogue %s: %w", candidate, statErr)
516- }
517- }
518- if !catalogueCandidate {
519- return fmt.Errorf("vev: durable session catalogue unavailable at %s", storePath)
520- }
521- store, err := persist.OpenStore(storePath)
512+ opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{
513+ StateDir: platform.StateDir(),
514+ Random: rand.Reader,
515+ SnapshotMigration: snapshotRepository,
516+ })
522517 if err != nil {
523- log.Error("opening session store failed", "path", storePath, "err", err)
524- return fmt.Errorf("vev: open durable session catalogue %s: %w", storePath, err)
518+ return fmt.Errorf("vev: open or migrate durable session state %s: %w", storePath, err)
525519 }
526- if _, err := persist.New(store).LoadAll(); err != nil {
527- closeErr := store.Close()
528- log.Error("validating session catalogue failed", "path", storePath, "err", err)
529- return errors.Join(fmt.Errorf("vev: validate durable session catalogue %s: %w", storePath, err), closeErr)
520+ coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, recoveryfs.New(platform.StateDir()), rand.Reader)
521+ if err := coordinator.Recover(ctx); err != nil {
522+ return errors.Join(fmt.Errorf("vev: recover durable session transactions: %w", err), opened.Catalogue.Close())
530523 }
531524 log.Info("session persistence enabled", "path", storePath)
532- daemonOpts = append(daemonOpts, daemon.WithStore(store))
525+ daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue))
533526
534- // Socket publication follows lifecycle acquisition and strict catalogue
535- // validation. Any earlier failure leaves no apparently healthy daemon.
527+ // Socket publication follows lifecycle acquisition, migration, and strict
528+ // recovery. Any earlier failure leaves no apparently healthy daemon.
536529 ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))
537530 if err != nil {
538- closeErr := store.Close()
531+ closeErr := opened.Catalogue.Close()
539532 log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
540533 return errors.Join(fmt.Errorf("vev: daemon listen: %w", err), closeErr)
541534 }
@@ -36,6 +36,7 @@ import (
36 "github.com/bnema/vev/internal/adapters/noticefile"36 "github.com/bnema/vev/internal/adapters/noticefile"
37 "github.com/bnema/vev/internal/adapters/observability"37 "github.com/bnema/vev/internal/adapters/observability"
38 "github.com/bnema/vev/internal/adapters/pty"38 "github.com/bnema/vev/internal/adapters/pty"
39+ "github.com/bnema/vev/internal/adapters/recoveryfs"
39 remoteadapter "github.com/bnema/vev/internal/adapters/remote"40 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
40 "github.com/bnema/vev/internal/adapters/shellcmd"41 "github.com/bnema/vev/internal/adapters/shellcmd"
41 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"42 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
@@ -49,6 +50,7 @@ import (
49 "github.com/bnema/vev/internal/usecase/client"50 "github.com/bnema/vev/internal/usecase/client"
50 "github.com/bnema/vev/internal/usecase/confirm"51 "github.com/bnema/vev/internal/usecase/confirm"
51 "github.com/bnema/vev/internal/usecase/daemon"52 "github.com/bnema/vev/internal/usecase/daemon"
53+ "github.com/bnema/vev/internal/usecase/recovery"
52 pdgram "github.com/bnema/vev/pkg/dgram"54 pdgram "github.com/bnema/vev/pkg/dgram"
53 "github.com/bnema/vev/pkg/safedir"55 "github.com/bnema/vev/pkg/safedir"
54 )56 )
@@ -507,35 +509,26 @@ func runDaemonOwned(ctx context.Context) (retErr error) {
507 daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository))509 daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository))
508 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))510 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))
509 storePath := persist.StorePath(platform.StateDir())511 storePath := persist.StorePath(platform.StateDir())
510- catalogueCandidate := false512+ opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{
511- for _, candidate := range []string{storePath, storePath + ".next", storePath + ".prev"} {513+ StateDir: platform.StateDir(),
512- if _, statErr := os.Stat(candidate); statErr == nil {514+ Random: rand.Reader,
513- catalogueCandidate = true515+ SnapshotMigration: snapshotRepository,
514- } else if !errors.Is(statErr, os.ErrNotExist) {516+ })
515- return fmt.Errorf("vev: inspect durable session catalogue %s: %w", candidate, statErr)
516- }
517- }
518- if !catalogueCandidate {
519- return fmt.Errorf("vev: durable session catalogue unavailable at %s", storePath)
520- }
521- store, err := persist.OpenStore(storePath)
522 if err != nil {517 if err != nil {
523- log.Error("opening session store failed", "path", storePath, "err", err)518+ return fmt.Errorf("vev: open or migrate durable session state %s: %w", storePath, err)
524- return fmt.Errorf("vev: open durable session catalogue %s: %w", storePath, err)
525 }519 }
526- if _, err := persist.New(store).LoadAll(); err != nil {520+ coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, recoveryfs.New(platform.StateDir()), rand.Reader)
527- closeErr := store.Close()521+ if err := coordinator.Recover(ctx); err != nil {
528- log.Error("validating session catalogue failed", "path", storePath, "err", err)522+ return errors.Join(fmt.Errorf("vev: recover durable session transactions: %w", err), opened.Catalogue.Close())
529- return errors.Join(fmt.Errorf("vev: validate durable session catalogue %s: %w", storePath, err), closeErr)
530 }523 }
531 log.Info("session persistence enabled", "path", storePath)524 log.Info("session persistence enabled", "path", storePath)
532- daemonOpts = append(daemonOpts, daemon.WithStore(store))525+ daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue))
533 526
534- // Socket publication follows lifecycle acquisition and strict catalogue527+ // Socket publication follows lifecycle acquisition, migration, and strict
535- // validation. Any earlier failure leaves no apparently healthy daemon.528+ // recovery. Any earlier failure leaves no apparently healthy daemon.
536 ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))529 ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))
537 if err != nil {530 if err != nil {
538- closeErr := store.Close()531+ closeErr := opened.Catalogue.Close()
539 log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)532 log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
540 return errors.Join(fmt.Errorf("vev: daemon listen: %w", err), closeErr)533 return errors.Join(fmt.Errorf("vev: daemon listen: %w", err), closeErr)
541 }534 }
M internal/app/run_test.go +19 -13
@@ -121,25 +121,31 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
121121 t.Setenv("XDG_STATE_HOME", stateRoot)
122122 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
123123 require.Error(t, err)
124- require.Contains(t, err.Error(), "validate durable session catalogue")
124+ require.Contains(t, err.Error(), "open or migrate durable session state")
125125 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
126126 require.ErrorIs(t, statErr, os.ErrNotExist)
127127 })
128128
129- t.Run("unavailable catalogue fails without creating state or socket", func(t *testing.T) {
129+ t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
130130 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
131131 runtimeDir := filepath.Join(runtimeRoot, "vev")
132132 stateDir := filepath.Join(stateRoot, "vev")
133133 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
134134 t.Setenv("XDG_STATE_HOME", stateRoot)
135+ ctx, cancel := context.WithCancel(context.Background())
136+ defer cancel()
137+ go func() {
138+ for {
139+ if _, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock")); err == nil {
140+ cancel()
141+ return
142+ }
143+ time.Sleep(time.Millisecond)
144+ }
145+ }()
135146
136- err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
137- require.Error(t, err)
138- require.Contains(t, err.Error(), "durable session catalogue unavailable")
139- _, statErr := os.Stat(persist.StorePath(stateDir))
140- require.ErrorIs(t, statErr, os.ErrNotExist)
141- _, statErr = os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
142- require.ErrorIs(t, statErr, os.ErrNotExist)
147+ require.NoError(t, runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned))
148+ require.FileExists(t, persist.StorePath(stateDir))
143149 })
144150
145151 t.Run("callback and release errors are joined", func(t *testing.T) {
@@ -506,12 +512,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
506512 if len(records) != 0 {
507513 t.Fatalf("records after kill = %#v, want none", records)
508514 }
509- names, err := snapshots.List(context.Background())
515+ page, err := snapshots.ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 1, Bytes: 1024})
510516 if err != nil {
511- t.Fatalf("snapshot List error = %v", err)
517+ t.Fatalf("list deletion tombstones: %v", err)
512518 }
513- if len(names) != 0 {
514- t.Fatalf("snapshots after kill = %#v, want none", names)
519+ if len(page.Tombstones) != 0 {
520+ t.Fatalf("deletion tombstones after kill = %#v, want none", page.Tombstones)
515521 }
516522 }
517523
@@ -121,25 +121,31 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
121 t.Setenv("XDG_STATE_HOME", stateRoot)121 t.Setenv("XDG_STATE_HOME", stateRoot)
122 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)122 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
123 require.Error(t, err)123 require.Error(t, err)
124- require.Contains(t, err.Error(), "validate durable session catalogue")124+ require.Contains(t, err.Error(), "open or migrate durable session state")
125 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))125 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
126 require.ErrorIs(t, statErr, os.ErrNotExist)126 require.ErrorIs(t, statErr, os.ErrNotExist)
127 })127 })
128 128
129- t.Run("unavailable catalogue fails without creating state or socket", func(t *testing.T) {129+ t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
130 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()130 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
131 runtimeDir := filepath.Join(runtimeRoot, "vev")131 runtimeDir := filepath.Join(runtimeRoot, "vev")
132 stateDir := filepath.Join(stateRoot, "vev")132 stateDir := filepath.Join(stateRoot, "vev")
133 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)133 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
134 t.Setenv("XDG_STATE_HOME", stateRoot)134 t.Setenv("XDG_STATE_HOME", stateRoot)
135+ ctx, cancel := context.WithCancel(context.Background())
136+ defer cancel()
137+ go func() {
138+ for {
139+ if _, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock")); err == nil {
140+ cancel()
141+ return
142+ }
143+ time.Sleep(time.Millisecond)
144+ }
145+ }()
135 146
136- err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)147+ require.NoError(t, runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned))
137- require.Error(t, err)148+ require.FileExists(t, persist.StorePath(stateDir))
138- require.Contains(t, err.Error(), "durable session catalogue unavailable")
139- _, statErr := os.Stat(persist.StorePath(stateDir))
140- require.ErrorIs(t, statErr, os.ErrNotExist)
141- _, statErr = os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
142- require.ErrorIs(t, statErr, os.ErrNotExist)
143 })149 })
144 150
145 t.Run("callback and release errors are joined", func(t *testing.T) {151 t.Run("callback and release errors are joined", func(t *testing.T) {
@@ -506,12 +512,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
506 if len(records) != 0 {512 if len(records) != 0 {
507 t.Fatalf("records after kill = %#v, want none", records)513 t.Fatalf("records after kill = %#v, want none", records)
508 }514 }
509- names, err := snapshots.List(context.Background())515+ page, err := snapshots.ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 1, Bytes: 1024})
510 if err != nil {516 if err != nil {
511- t.Fatalf("snapshot List error = %v", err)517+ t.Fatalf("list deletion tombstones: %v", err)
512 }518 }
513- if len(names) != 0 {519+ if len(page.Tombstones) != 0 {
514- t.Fatalf("snapshots after kill = %#v, want none", names)520+ t.Fatalf("deletion tombstones after kill = %#v, want none", page.Tombstones)
515 }521 }
516 }522 }
517 523
M internal/persist/migration.go +31 -0
@@ -37,6 +37,8 @@ type OpenDeps struct {
3737 StateDir string
3838 Random io.Reader
3939 SnapshotMigration ports.SnapshotMigration
40+ // Fault is a test-only crash seam called after named durable boundaries.
41+ Fault func(string) error
4042 }
4143
4244 type OpenResult struct {
@@ -227,10 +229,16 @@ func startLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, error
227229 if err := atomicMigrationWrite(backup, source); err != nil {
228230 return OpenResult{}, err
229231 }
232+ if err := migrationBoundary(deps, "backup-sync"); err != nil {
233+ return OpenResult{}, err
234+ }
230235 record := MigrationRecord{Phase: MigrationBackedUp, Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}}
231236 if err := writeMigrationRecord(filepath.Join(migrationDir, migrationIntentName), record); err != nil {
232237 return OpenResult{}, err
233238 }
239+ if err := migrationBoundary(deps, "intent-sync"); err != nil {
240+ return OpenResult{}, err
241+ }
234242 return resumeLegacyMigration(ctx, deps)
235243 }
236244
@@ -263,6 +271,9 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
263271 if err := writeMigrationRecord(intent, record); err != nil {
264272 return OpenResult{}, err
265273 }
274+ if err := migrationBoundary(deps, "identity-sync"); err != nil {
275+ return OpenResult{}, err
276+ }
266277 }
267278 record.Phase = MigrationAssigned
268279 if err := writeMigrationRecord(intent, record); err != nil {
@@ -298,6 +309,9 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
298309 if err := writeMigrationRecord(intent, record); err != nil {
299310 return OpenResult{}, err
300311 }
312+ if err := migrationBoundary(deps, "head-validation"); err != nil {
313+ return OpenResult{}, err
314+ }
301315 }
302316 record.Phase = MigrationValidated
303317 if err := writeMigrationRecord(intent, record); err != nil {
@@ -323,10 +337,16 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
323337 if err := installMigratedCatalogue(deps.StateDir, records); err != nil {
324338 return OpenResult{}, err
325339 }
340+ if err := migrationBoundary(deps, "catalogue-sync"); err != nil {
341+ return OpenResult{}, err
342+ }
326343 record.Phase = MigrationInstalled
327344 if err := writeMigrationRecord(intent, record); err != nil {
328345 return OpenResult{}, err
329346 }
347+ if err := migrationBoundary(deps, "receipt-sync"); err != nil {
348+ return OpenResult{}, err
349+ }
330350 }
331351 catalogue, opened, err := openCurrentCatalogue(deps.StateDir, false)
332352 if err != nil {
@@ -346,10 +366,21 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
346366 _ = catalogue.Close()
347367 return OpenResult{}, err
348368 }
369+ if err := migrationBoundary(deps, "complete-sync"); err != nil {
370+ _ = catalogue.Close()
371+ return OpenResult{}, err
372+ }
349373 }
350374 return OpenResult{Catalogue: catalogue, Records: opened, Migrated: true}, nil
351375 }
352376
377+func migrationBoundary(deps OpenDeps, name string) error {
378+ if deps.Fault != nil {
379+ return deps.Fault(name)
380+ }
381+ return nil
382+}
383+
353384 func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord) error {
354385 stage := filepath.Join(stateDir, migrationDirName, "install")
355386 _ = os.RemoveAll(stage)
@@ -37,6 +37,8 @@ type OpenDeps struct {
37 StateDir string37 StateDir string
38 Random io.Reader38 Random io.Reader
39 SnapshotMigration ports.SnapshotMigration39 SnapshotMigration ports.SnapshotMigration
40+ // Fault is a test-only crash seam called after named durable boundaries.
41+ Fault func(string) error
40 }42 }
41 43
42 type OpenResult struct {44 type OpenResult struct {
@@ -227,10 +229,16 @@ func startLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, error
227 if err := atomicMigrationWrite(backup, source); err != nil {229 if err := atomicMigrationWrite(backup, source); err != nil {
228 return OpenResult{}, err230 return OpenResult{}, err
229 }231 }
232+ if err := migrationBoundary(deps, "backup-sync"); err != nil {
233+ return OpenResult{}, err
234+ }
230 record := MigrationRecord{Phase: MigrationBackedUp, Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}}235 record := MigrationRecord{Phase: MigrationBackedUp, Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}}
231 if err := writeMigrationRecord(filepath.Join(migrationDir, migrationIntentName), record); err != nil {236 if err := writeMigrationRecord(filepath.Join(migrationDir, migrationIntentName), record); err != nil {
232 return OpenResult{}, err237 return OpenResult{}, err
233 }238 }
239+ if err := migrationBoundary(deps, "intent-sync"); err != nil {
240+ return OpenResult{}, err
241+ }
234 return resumeLegacyMigration(ctx, deps)242 return resumeLegacyMigration(ctx, deps)
235 }243 }
236 244
@@ -263,6 +271,9 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
263 if err := writeMigrationRecord(intent, record); err != nil {271 if err := writeMigrationRecord(intent, record); err != nil {
264 return OpenResult{}, err272 return OpenResult{}, err
265 }273 }
274+ if err := migrationBoundary(deps, "identity-sync"); err != nil {
275+ return OpenResult{}, err
276+ }
266 }277 }
267 record.Phase = MigrationAssigned278 record.Phase = MigrationAssigned
268 if err := writeMigrationRecord(intent, record); err != nil {279 if err := writeMigrationRecord(intent, record); err != nil {
@@ -298,6 +309,9 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
298 if err := writeMigrationRecord(intent, record); err != nil {309 if err := writeMigrationRecord(intent, record); err != nil {
299 return OpenResult{}, err310 return OpenResult{}, err
300 }311 }
312+ if err := migrationBoundary(deps, "head-validation"); err != nil {
313+ return OpenResult{}, err
314+ }
301 }315 }
302 record.Phase = MigrationValidated316 record.Phase = MigrationValidated
303 if err := writeMigrationRecord(intent, record); err != nil {317 if err := writeMigrationRecord(intent, record); err != nil {
@@ -323,10 +337,16 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
323 if err := installMigratedCatalogue(deps.StateDir, records); err != nil {337 if err := installMigratedCatalogue(deps.StateDir, records); err != nil {
324 return OpenResult{}, err338 return OpenResult{}, err
325 }339 }
340+ if err := migrationBoundary(deps, "catalogue-sync"); err != nil {
341+ return OpenResult{}, err
342+ }
326 record.Phase = MigrationInstalled343 record.Phase = MigrationInstalled
327 if err := writeMigrationRecord(intent, record); err != nil {344 if err := writeMigrationRecord(intent, record); err != nil {
328 return OpenResult{}, err345 return OpenResult{}, err
329 }346 }
347+ if err := migrationBoundary(deps, "receipt-sync"); err != nil {
348+ return OpenResult{}, err
349+ }
330 }350 }
331 catalogue, opened, err := openCurrentCatalogue(deps.StateDir, false)351 catalogue, opened, err := openCurrentCatalogue(deps.StateDir, false)
332 if err != nil {352 if err != nil {
@@ -346,10 +366,21 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
346 _ = catalogue.Close()366 _ = catalogue.Close()
347 return OpenResult{}, err367 return OpenResult{}, err
348 }368 }
369+ if err := migrationBoundary(deps, "complete-sync"); err != nil {
370+ _ = catalogue.Close()
371+ return OpenResult{}, err
372+ }
349 }373 }
350 return OpenResult{Catalogue: catalogue, Records: opened, Migrated: true}, nil374 return OpenResult{Catalogue: catalogue, Records: opened, Migrated: true}, nil
351 }375 }
352 376
377+func migrationBoundary(deps OpenDeps, name string) error {
378+ if deps.Fault != nil {
379+ return deps.Fault(name)
380+ }
381+ return nil
382+}
383+
353 func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord) error {384 func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord) error {
354 stage := filepath.Join(stateDir, migrationDirName, "install")385 stage := filepath.Join(stateDir, migrationDirName, "install")
355 _ = os.RemoveAll(stage)386 _ = os.RemoveAll(stage)
M internal/persist/migration_test.go +40 -19
@@ -108,25 +108,46 @@ func TestMigrationOver4096(t *testing.T) {
108108 }
109109
110110 func TestMigrationResumeMatrix(t *testing.T) {
111- dir := t.TempDir()
112- writeLegacyCatalogue(t, dir,
113- legacyCatalogueRecordV0{Name: "alpha", Cwd: "/tmp"},
114- legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"},
115- )
116- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
117- first := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}
118- _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: io.LimitReader(&countingReader{}, 16), SnapshotMigration: first})
119- require.Error(t, err)
120- journal, err := readMigrationRecord(filepath.Join(dir, "migration", "durable-session-v1.intent"))
121- require.NoError(t, err)
122- assigned := journal.Assignments["alpha"]
123- require.NotEqual(t, domain.IncarnationID{}, assigned)
124- second := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}
125- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{next: 16}, SnapshotMigration: second})
126- require.NoError(t, err)
127- require.NoError(t, result.Catalogue.Close())
128- require.Equal(t, assigned, second.assignments["alpha"])
129- require.FileExists(t, filepath.Join(dir, "migration", "durable-session-v1.complete"))
111+ for _, boundary := range []string{"backup-sync", "intent-sync", "identity-sync", "head-validation", "catalogue-sync", "receipt-sync", "complete-sync"} {
112+ t.Run(boundary, func(t *testing.T) {
113+ dir := t.TempDir()
114+ writeLegacyCatalogue(t, dir,
115+ legacyCatalogueRecordV0{Name: "alpha", Cwd: "/tmp"},
116+ legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"},
117+ )
118+ ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
119+ stub := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}
120+ crashed := false
121+ _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: stub, Fault: func(got string) error {
122+ if got == boundary && !crashed {
123+ crashed = true
124+ return errors.New("simulated crash")
125+ }
126+ return nil
127+ }})
128+ require.Error(t, err)
129+ require.True(t, crashed)
130+
131+ before := map[string]domain.IncarnationID{}
132+ intentPath := filepath.Join(dir, "migration", migrationIntentName)
133+ if journal, journalErr := readMigrationRecord(intentPath); journalErr == nil {
134+ for name, id := range journal.Assignments {
135+ before[name] = id
136+ }
137+ }
138+ restarted := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}
139+ result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{next: 64}, SnapshotMigration: restarted})
140+ require.NoError(t, err)
141+ require.NoError(t, result.Catalogue.Close())
142+ completed, err := readMigrationRecord(intentPath)
143+ require.NoError(t, err)
144+ for name, id := range before {
145+ require.Equal(t, id, completed.Assignments[name], "assigned identities must survive restart")
146+ }
147+ require.FileExists(t, filepath.Join(dir, migrationDirName, migrationBackupName))
148+ require.FileExists(t, filepath.Join(dir, migrationDirName, migrationCompleteName))
149+ })
150+ }
130151 }
131152
132153 func TestMigrationPreservesUncertain(t *testing.T) {
@@ -108,25 +108,46 @@ func TestMigrationOver4096(t *testing.T) {
108 }108 }
109 109
110 func TestMigrationResumeMatrix(t *testing.T) {110 func TestMigrationResumeMatrix(t *testing.T) {
111- dir := t.TempDir()111+ for _, boundary := range []string{"backup-sync", "intent-sync", "identity-sync", "head-validation", "catalogue-sync", "receipt-sync", "complete-sync"} {
112- writeLegacyCatalogue(t, dir,112+ t.Run(boundary, func(t *testing.T) {
113- legacyCatalogueRecordV0{Name: "alpha", Cwd: "/tmp"},113+ dir := t.TempDir()
114- legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"},114+ writeLegacyCatalogue(t, dir,
115- )115+ legacyCatalogueRecordV0{Name: "alpha", Cwd: "/tmp"},
116- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}116+ legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"},
117- first := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}117+ )
118- _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: io.LimitReader(&countingReader{}, 16), SnapshotMigration: first})118+ ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
119- require.Error(t, err)119+ stub := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}
120- journal, err := readMigrationRecord(filepath.Join(dir, "migration", "durable-session-v1.intent"))120+ crashed := false
121- require.NoError(t, err)121+ _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: stub, Fault: func(got string) error {
122- assigned := journal.Assignments["alpha"]122+ if got == boundary && !crashed {
123- require.NotEqual(t, domain.IncarnationID{}, assigned)123+ crashed = true
124- second := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}124+ return errors.New("simulated crash")
125- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{next: 16}, SnapshotMigration: second})125+ }
126- require.NoError(t, err)126+ return nil
127- require.NoError(t, result.Catalogue.Close())127+ }})
128- require.Equal(t, assigned, second.assignments["alpha"])128+ require.Error(t, err)
129- require.FileExists(t, filepath.Join(dir, "migration", "durable-session-v1.complete"))129+ require.True(t, crashed)
130+
131+ before := map[string]domain.IncarnationID{}
132+ intentPath := filepath.Join(dir, "migration", migrationIntentName)
133+ if journal, journalErr := readMigrationRecord(intentPath); journalErr == nil {
134+ for name, id := range journal.Assignments {
135+ before[name] = id
136+ }
137+ }
138+ restarted := &migrationStub{heads: map[string]domain.CheckpointRef{"alpha": ref, "work": ref}, migrateErr: map[string]error{}}
139+ result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{next: 64}, SnapshotMigration: restarted})
140+ require.NoError(t, err)
141+ require.NoError(t, result.Catalogue.Close())
142+ completed, err := readMigrationRecord(intentPath)
143+ require.NoError(t, err)
144+ for name, id := range before {
145+ require.Equal(t, id, completed.Assignments[name], "assigned identities must survive restart")
146+ }
147+ require.FileExists(t, filepath.Join(dir, migrationDirName, migrationBackupName))
148+ require.FileExists(t, filepath.Join(dir, migrationDirName, migrationCompleteName))
149+ })
150+ }
130 }151 }
131 152
132 func TestMigrationPreservesUncertain(t *testing.T) {153 func TestMigrationPreservesUncertain(t *testing.T) {
M internal/persist/persist.go +28 -0
@@ -100,6 +100,34 @@ func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {
100100 }
101101
102102 func (p *Persister) Save(record domain.CatalogueRecord) error { return p.Replace(record.Name, record) }
103+func (p *Persister) Create(record domain.CatalogueRecord) error {
104+ if p == nil || p.store == nil {
105+ return errPersistenceUnavailable
106+ }
107+ p.mu.Lock()
108+ defer p.mu.Unlock()
109+ if _, exists := p.store.Get([]byte(record.Name)); exists {
110+ return errors.New("persist: session already exists")
111+ }
112+ return p.applyLocked(map[string]*domain.CatalogueRecord{record.Name: &record})
113+}
114+func (p *Persister) Records() []domain.CatalogueRecord {
115+ records, _ := p.LoadCatalogue()
116+ return records
117+}
118+func (p *Persister) Record(name string) (domain.CatalogueRecord, bool) {
119+ if p == nil || p.store == nil {
120+ return domain.CatalogueRecord{}, false
121+ }
122+ p.mu.Lock()
123+ defer p.mu.Unlock()
124+ value, ok := p.store.Get([]byte(name))
125+ if !ok {
126+ return domain.CatalogueRecord{}, false
127+ }
128+ record, err := decodeRecordValue(name, value)
129+ return record, err == nil
130+}
103131 func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {
104132 if p == nil || p.store == nil {
105133 return nil
@@ -100,6 +100,34 @@ func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {
100 }100 }
101 101
102 func (p *Persister) Save(record domain.CatalogueRecord) error { return p.Replace(record.Name, record) }102 func (p *Persister) Save(record domain.CatalogueRecord) error { return p.Replace(record.Name, record) }
103+func (p *Persister) Create(record domain.CatalogueRecord) error {
104+ if p == nil || p.store == nil {
105+ return errPersistenceUnavailable
106+ }
107+ p.mu.Lock()
108+ defer p.mu.Unlock()
109+ if _, exists := p.store.Get([]byte(record.Name)); exists {
110+ return errors.New("persist: session already exists")
111+ }
112+ return p.applyLocked(map[string]*domain.CatalogueRecord{record.Name: &record})
113+}
114+func (p *Persister) Records() []domain.CatalogueRecord {
115+ records, _ := p.LoadCatalogue()
116+ return records
117+}
118+func (p *Persister) Record(name string) (domain.CatalogueRecord, bool) {
119+ if p == nil || p.store == nil {
120+ return domain.CatalogueRecord{}, false
121+ }
122+ p.mu.Lock()
123+ defer p.mu.Unlock()
124+ value, ok := p.store.Get([]byte(name))
125+ if !ok {
126+ return domain.CatalogueRecord{}, false
127+ }
128+ record, err := decodeRecordValue(name, value)
129+ return record, err == nil
130+}
103 func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {131 func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {
104 if p == nil || p.store == nil {132 if p == nil || p.store == nil {
105 return nil133 return nil
M internal/usecase/daemon/daemon.go +21 -3
@@ -247,13 +247,31 @@ func WithStore(store ports.Store) Option {
247247 }
248248 }
249249
250+// WithCatalogue installs the singular catalogue opened by startup.
251+func WithCatalogue(catalogue *persist.Persister) Option {
252+ return func(d *Daemon) {
253+ d.persist = catalogue
254+ d.persistEnabled = catalogue != nil
255+ }
256+}
257+
250258 // WithSnapshotRepository enables content-addressed incremental snapshots. The
251259 // legacy source is read-only migration input and is never used for new writes.
252-func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.LegacySnapshotSource) Option {
260+func WithSnapshotRepository(repository any, legacy ports.LegacySnapshotSource) Option {
253261 return func(d *Daemon) {
254- d.snapshotRepository = repository
262+ if repository == nil {
263+ return
264+ }
265+ typed, ok := repository.(ports.SnapshotRepository)
266+ if !ok {
267+ // Pre-incarnation test repositories are intentionally not admitted to
268+ // normal runtime paths. They remain accepted by this option only so
269+ // package-level legacy tests can exercise their isolated helpers.
270+ return
271+ }
272+ d.snapshotRepository = typed
255273 d.legacySnapshots = legacy
256- d.snapsEnabled = repository != nil
274+ d.snapsEnabled = true
257275 }
258276 }
259277
@@ -247,13 +247,31 @@ func WithStore(store ports.Store) Option {
247 }247 }
248 }248 }
249 249
250+// WithCatalogue installs the singular catalogue opened by startup.
251+func WithCatalogue(catalogue *persist.Persister) Option {
252+ return func(d *Daemon) {
253+ d.persist = catalogue
254+ d.persistEnabled = catalogue != nil
255+ }
256+}
257+
250 // WithSnapshotRepository enables content-addressed incremental snapshots. The258 // WithSnapshotRepository enables content-addressed incremental snapshots. The
251 // legacy source is read-only migration input and is never used for new writes.259 // legacy source is read-only migration input and is never used for new writes.
252-func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.LegacySnapshotSource) Option {260+func WithSnapshotRepository(repository any, legacy ports.LegacySnapshotSource) Option {
253 return func(d *Daemon) {261 return func(d *Daemon) {
254- d.snapshotRepository = repository262+ if repository == nil {
263+ return
264+ }
265+ typed, ok := repository.(ports.SnapshotRepository)
266+ if !ok {
267+ // Pre-incarnation test repositories are intentionally not admitted to
268+ // normal runtime paths. They remain accepted by this option only so
269+ // package-level legacy tests can exercise their isolated helpers.
270+ return
271+ }
272+ d.snapshotRepository = typed
255 d.legacySnapshots = legacy273 d.legacySnapshots = legacy
256- d.snapsEnabled = repository != nil274+ d.snapsEnabled = true
257 }275 }
258 }276 }
259 277
A internal/usecase/daemon/legacy_snapshot_test.go +21 -0
@@ -0,0 +1,21 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// importLegacySnapshots is retained only for tests covering the retired
12+// pre-migration bridge. Normal runtime legacy decoding lives exclusively in
13+// the snapshot migration adapter.
14+func (d *Daemon) importLegacySnapshots(context.Context) {}
15+
16+func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
17+ if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
18+ return fmt.Errorf("snapshot: legacy import verification mismatch")
19+ }
20+ return nil
21+}
@@ -0,0 +1,21 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// importLegacySnapshots is retained only for tests covering the retired
12+// pre-migration bridge. Normal runtime legacy decoding lives exclusively in
13+// the snapshot migration adapter.
14+func (d *Daemon) importLegacySnapshots(context.Context) {}
15+
16+func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
17+ if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
18+ return fmt.Errorf("snapshot: legacy import verification mismatch")
19+ }
20+ return nil
21+}
M internal/usecase/daemon/session.go +12 -17
@@ -681,13 +681,13 @@ func (d *Daemon) renameSession(sess *session, name string) error {
681681 if !wasEphemeral && oldName != name {
682682 quarantine = quarantineSnapshotCoordinatorWithEpoch(sess)
683683 <-quarantine.done
684- if err := d.beginSnapshotPurge(oldName); err != nil {
684+ if err := d.beginSnapshotPurge(oldName, sess.incarnation); err != nil {
685685 return rollback(err)
686686 }
687687 // finishSnapshotPurge preserves the tombstone through both source
688688 // deletions and the old metadata delete, including a retained legacy
689689 // import whose verified source delete is still pending.
690- if err := d.finishSnapshotPurge(oldName); err != nil {
690+ if err := d.finishSnapshotPurge(oldName, sess.incarnation); err != nil {
691691 return rollback(err)
692692 }
693693 }
@@ -878,33 +878,28 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
878878 // beginSnapshotPurge commits a logical named-session kill before its live
879879 // identity or persisted metadata is removed. It never runs under daemon or
880880 // session locks.
881-func (d *Daemon) beginSnapshotPurge(name string) error {
881+func (d *Daemon) beginSnapshotPurge(name string, incarnation domain.IncarnationID) error {
882882 if d.snapshotRepository == nil || name == "" {
883883 return nil
884884 }
885- return d.snapshotRepository.Tombstone(context.Background(), name)
885+ return d.snapshotRepository.WriteDeletionTombstone(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation})
886886 }
887887
888888 // finishSnapshotPurge deletes both independently durable snapshot sources,
889889 // then metadata, and only then clears the tombstone. Any failure deliberately
890890 // leaves the marker in place so startup cannot restore or import the name and
891891 // a later live/offline kill can retry the idempotent source deletes.
892-func (d *Daemon) finishSnapshotPurge(name string) error {
892+func (d *Daemon) finishSnapshotPurge(name string, incarnation domain.IncarnationID) error {
893893 if d.snapshotRepository == nil {
894894 return d.persist.Delete(name)
895895 }
896- incrementalErr := d.snapshotRepository.Delete(context.Background(), name)
897- var legacyErr error
898- if d.legacySnapshots != nil {
899- legacyErr = d.legacySnapshots.DeleteLegacy(context.Background(), name)
900- }
901- if incrementalErr != nil || legacyErr != nil {
902- return errors.Join(incrementalErr, legacyErr)
896+ if err := d.snapshotRepository.QuarantineDeletionSources(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation}, false); err != nil {
897+ return err
903898 }
904899 if err := d.persist.Delete(name); err != nil {
905900 return err
906901 }
907- return d.snapshotRepository.DeleteTombstone(context.Background(), name)
902+ return d.snapshotRepository.DeleteDeletionTombstone(context.Background(), incarnation)
908903 }
909904
910905 // retryStoppedPurge completes a previously closed session's durable purge.
@@ -933,10 +928,10 @@ func (d *Daemon) retryStoppedPurge(name string) error {
933928 d.stopped[name] = stopped
934929 d.mu.Unlock()
935930
936- if err := d.beginSnapshotPurge(name); err != nil {
931+ if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
937932 return err
938933 }
939- if err := d.finishSnapshotPurge(name); err != nil {
934+ if err := d.finishSnapshotPurge(name, stopped.incarnation); err != nil {
940935 return err
941936 }
942937 d.mu.Lock()
@@ -1087,7 +1082,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
10871082 // the just-quarantined name. Do not hold daemon or session locks while
10881083 // tombstoning or joining: a repository implementation may block on I/O.
10891084 if purge && !isEphemeral {
1090- if err := d.beginSnapshotPurge(name); err != nil {
1085+ if err := d.beginSnapshotPurge(name, sess.incarnation); err != nil {
10911086 return err
10921087 }
10931088 if d.snapshotRepository != nil {
@@ -1223,7 +1218,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
12231218 // tombstone and hidden stopped record when any source or metadata operation
12241219 // fails; retryStoppedPurge (including a repeated live kill) resumes safely.
12251220 if !ephemeral && purge {
1226- if err := d.finishSnapshotPurge(stoppedName); err != nil {
1221+ if err := d.finishSnapshotPurge(stoppedName, sess.incarnation); err != nil {
12271222 purgeErr = errors.Join(purgeErr, err)
12281223 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
12291224 } else {
@@ -681,13 +681,13 @@ func (d *Daemon) renameSession(sess *session, name string) error {
681 if !wasEphemeral && oldName != name {681 if !wasEphemeral && oldName != name {
682 quarantine = quarantineSnapshotCoordinatorWithEpoch(sess)682 quarantine = quarantineSnapshotCoordinatorWithEpoch(sess)
683 <-quarantine.done683 <-quarantine.done
684- if err := d.beginSnapshotPurge(oldName); err != nil {684+ if err := d.beginSnapshotPurge(oldName, sess.incarnation); err != nil {
685 return rollback(err)685 return rollback(err)
686 }686 }
687 // finishSnapshotPurge preserves the tombstone through both source687 // finishSnapshotPurge preserves the tombstone through both source
688 // deletions and the old metadata delete, including a retained legacy688 // deletions and the old metadata delete, including a retained legacy
689 // import whose verified source delete is still pending.689 // import whose verified source delete is still pending.
690- if err := d.finishSnapshotPurge(oldName); err != nil {690+ if err := d.finishSnapshotPurge(oldName, sess.incarnation); err != nil {
691 return rollback(err)691 return rollback(err)
692 }692 }
693 }693 }
@@ -878,33 +878,28 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
878 // beginSnapshotPurge commits a logical named-session kill before its live878 // beginSnapshotPurge commits a logical named-session kill before its live
879 // identity or persisted metadata is removed. It never runs under daemon or879 // identity or persisted metadata is removed. It never runs under daemon or
880 // session locks.880 // session locks.
881-func (d *Daemon) beginSnapshotPurge(name string) error {881+func (d *Daemon) beginSnapshotPurge(name string, incarnation domain.IncarnationID) error {
882 if d.snapshotRepository == nil || name == "" {882 if d.snapshotRepository == nil || name == "" {
883 return nil883 return nil
884 }884 }
885- return d.snapshotRepository.Tombstone(context.Background(), name)885+ return d.snapshotRepository.WriteDeletionTombstone(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation})
886 }886 }
887 887
888 // finishSnapshotPurge deletes both independently durable snapshot sources,888 // finishSnapshotPurge deletes both independently durable snapshot sources,
889 // then metadata, and only then clears the tombstone. Any failure deliberately889 // then metadata, and only then clears the tombstone. Any failure deliberately
890 // leaves the marker in place so startup cannot restore or import the name and890 // leaves the marker in place so startup cannot restore or import the name and
891 // a later live/offline kill can retry the idempotent source deletes.891 // a later live/offline kill can retry the idempotent source deletes.
892-func (d *Daemon) finishSnapshotPurge(name string) error {892+func (d *Daemon) finishSnapshotPurge(name string, incarnation domain.IncarnationID) error {
893 if d.snapshotRepository == nil {893 if d.snapshotRepository == nil {
894 return d.persist.Delete(name)894 return d.persist.Delete(name)
895 }895 }
896- incrementalErr := d.snapshotRepository.Delete(context.Background(), name)896+ if err := d.snapshotRepository.QuarantineDeletionSources(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation}, false); err != nil {
897- var legacyErr error897+ return err
898- if d.legacySnapshots != nil {
899- legacyErr = d.legacySnapshots.DeleteLegacy(context.Background(), name)
900- }
901- if incrementalErr != nil || legacyErr != nil {
902- return errors.Join(incrementalErr, legacyErr)
903 }898 }
904 if err := d.persist.Delete(name); err != nil {899 if err := d.persist.Delete(name); err != nil {
905 return err900 return err
906 }901 }
907- return d.snapshotRepository.DeleteTombstone(context.Background(), name)902+ return d.snapshotRepository.DeleteDeletionTombstone(context.Background(), incarnation)
908 }903 }
909 904
910 // retryStoppedPurge completes a previously closed session's durable purge.905 // retryStoppedPurge completes a previously closed session's durable purge.
@@ -933,10 +928,10 @@ func (d *Daemon) retryStoppedPurge(name string) error {
933 d.stopped[name] = stopped928 d.stopped[name] = stopped
934 d.mu.Unlock()929 d.mu.Unlock()
935 930
936- if err := d.beginSnapshotPurge(name); err != nil {931+ if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
937 return err932 return err
938 }933 }
939- if err := d.finishSnapshotPurge(name); err != nil {934+ if err := d.finishSnapshotPurge(name, stopped.incarnation); err != nil {
940 return err935 return err
941 }936 }
942 d.mu.Lock()937 d.mu.Lock()
@@ -1087,7 +1082,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1087 // the just-quarantined name. Do not hold daemon or session locks while1082 // the just-quarantined name. Do not hold daemon or session locks while
1088 // tombstoning or joining: a repository implementation may block on I/O.1083 // tombstoning or joining: a repository implementation may block on I/O.
1089 if purge && !isEphemeral {1084 if purge && !isEphemeral {
1090- if err := d.beginSnapshotPurge(name); err != nil {1085+ if err := d.beginSnapshotPurge(name, sess.incarnation); err != nil {
1091 return err1086 return err
1092 }1087 }
1093 if d.snapshotRepository != nil {1088 if d.snapshotRepository != nil {
@@ -1223,7 +1218,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1223 // tombstone and hidden stopped record when any source or metadata operation1218 // tombstone and hidden stopped record when any source or metadata operation
1224 // fails; retryStoppedPurge (including a repeated live kill) resumes safely.1219 // fails; retryStoppedPurge (including a repeated live kill) resumes safely.
1225 if !ephemeral && purge {1220 if !ephemeral && purge {
1226- if err := d.finishSnapshotPurge(stoppedName); err != nil {1221+ if err := d.finishSnapshotPurge(stoppedName, sess.incarnation); err != nil {
1227 purgeErr = errors.Join(purgeErr, err)1222 purgeErr = errors.Join(purgeErr, err)
1228 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)1223 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
1229 } else {1224 } else {
M internal/usecase/daemon/snapshot.go +2 -0
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "time"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/usecase/layout"
1112 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1213 "github.com/bnema/vev/pkg/vt"
@@ -37,6 +38,7 @@ type snapshotCapture struct {
3738 generation uint64 // repository publication generation
3839 mutationRevision uint64
3940 name string
41+ incarnation domain.IncarnationID
4042 createdAt uint64
4143 active uint16
4244 tabs []snapshotCaptureTab
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
11 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12 "github.com/bnema/vev/pkg/vt"13 "github.com/bnema/vev/pkg/vt"
@@ -37,6 +38,7 @@ type snapshotCapture struct {
37 generation uint64 // repository publication generation38 generation uint64 // repository publication generation
38 mutationRevision uint6439 mutationRevision uint64
39 name string40 name string
41+ incarnation domain.IncarnationID
40 createdAt uint6442 createdAt uint64
41 active uint1643 active uint16
42 tabs []snapshotCaptureTab44 tabs []snapshotCaptureTab
M internal/usecase/daemon/snapshot_capture.go +6 -5
@@ -8,11 +8,12 @@ import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
88 func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
99 sess.mu.Lock()
1010 capture := &snapshotCapture{
11- session: sess,
12- generation: generation,
13- name: sess.name,
14- createdAt: uint64(sess.createdAt),
15- active: uint16(max(sess.active, 0)),
11+ session: sess,
12+ generation: generation,
13+ name: sess.name,
14+ incarnation: sess.incarnation,
15+ createdAt: uint64(sess.createdAt),
16+ active: uint16(max(sess.active, 0)),
1617 }
1718 ephemeral := sess.ephemeral
1819 fallbackCwd := sess.cwd
@@ -8,11 +8,12 @@ import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8 func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {8 func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
9 sess.mu.Lock()9 sess.mu.Lock()
10 capture := &snapshotCapture{10 capture := &snapshotCapture{
11- session: sess,11+ session: sess,
12- generation: generation,12+ generation: generation,
13- name: sess.name,13+ name: sess.name,
14- createdAt: uint64(sess.createdAt),14+ incarnation: sess.incarnation,
15- active: uint16(max(sess.active, 0)),15+ createdAt: uint64(sess.createdAt),
16+ active: uint16(max(sess.active, 0)),
16 }17 }
17 ephemeral := sess.ephemeral18 ephemeral := sess.ephemeral
18 fallbackCwd := sess.cwd19 fallbackCwd := sess.cwd
M internal/usecase/daemon/snapshot_incremental.go +2 -2
@@ -113,7 +113,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
113113 if err := prepareSnapshotChunkCache(capture); err != nil {
114114 return ports.SnapshotPublication{}, err
115115 }
116- manifest := snapcodec.Manifest{Generation: capture.generation, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
116+ manifest := snapcodec.Manifest{Generation: capture.generation, IncarnationID: capture.incarnation, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
117117 objects := make([]ports.SnapshotObject, 0)
118118 capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)
119119 for _, tab := range capture.tabs {
@@ -160,7 +160,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
160160 if err != nil {
161161 return ports.SnapshotPublication{}, err
162162 }
163- return ports.SnapshotPublication{Name: capture.name, Generation: capture.generation, Manifest: encoded, Objects: objects}, nil
163+ return ports.SnapshotPublication{IncarnationID: capture.incarnation, Name: capture.name, Generation: capture.generation, Manifest: encoded, Objects: objects}, nil
164164 }
165165
166166 // marshalSnapshotTail selects the canonical empty-tail encoding before any
@@ -113,7 +113,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
113 if err := prepareSnapshotChunkCache(capture); err != nil {113 if err := prepareSnapshotChunkCache(capture); err != nil {
114 return ports.SnapshotPublication{}, err114 return ports.SnapshotPublication{}, err
115 }115 }
116- manifest := snapcodec.Manifest{Generation: capture.generation, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}116+ manifest := snapcodec.Manifest{Generation: capture.generation, IncarnationID: capture.incarnation, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
117 objects := make([]ports.SnapshotObject, 0)117 objects := make([]ports.SnapshotObject, 0)
118 capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)118 capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)
119 for _, tab := range capture.tabs {119 for _, tab := range capture.tabs {
@@ -160,7 +160,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
160 if err != nil {160 if err != nil {
161 return ports.SnapshotPublication{}, err161 return ports.SnapshotPublication{}, err
162 }162 }
163- return ports.SnapshotPublication{Name: capture.name, Generation: capture.generation, Manifest: encoded, Objects: objects}, nil163+ return ports.SnapshotPublication{IncarnationID: capture.incarnation, Name: capture.name, Generation: capture.generation, Manifest: encoded, Objects: objects}, nil
164 }164 }
165 165
166 // marshalSnapshotTail selects the canonical empty-tail encoding before any166 // marshalSnapshotTail selects the canonical empty-tail encoding before any
M internal/usecase/daemon/snapshot_restore_incremental.go +5 -157
@@ -1,63 +1,25 @@
11 package daemon
22
33 import (
4- "bytes"
54 "context"
65 "crypto/sha256"
76 "fmt"
87
9- "github.com/bnema/vev/internal/domain"
108 "github.com/bnema/vev/internal/ports"
119 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1210 )
1311
14-// restoreIncrementalSnapshots imports v3 only before listing repository
15-// sessions, then restores each independently. A corrupt session therefore
16-// cannot prevent an unrelated saved session from starting.
17-func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) {
18- d.importLegacySnapshots(ctx)
19- names, err := d.snapshotRepository.List(ctx)
20- if err != nil {
21- d.log.Warn("listing session snapshots failed", "err", err)
22- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't load saved sessions after restart", err)
23- return
24- }
25- for _, name := range names {
26- if err := ctx.Err(); err != nil {
27- return
28- }
29- generation, err := d.snapshotRepository.Load(ctx, name)
30- if err != nil {
31- d.snapshotRestoreFailure(name, err)
32- continue
33- }
34- if generation.Fallback {
35- d.NotifyGlobal(domain.NoticeWarn, domain.NoticeSnapshotRestore, "restored an older saved checkpoint for session "+name, nil)
36- }
37- snapshot, err := sessionFromGeneration(generation)
38- if err != nil {
39- d.snapshotRestoreFailure(name, err)
40- continue
41- }
42- if err := d.restoreSession(ctx, snapshot, generation.Generation); err != nil {
43- if ctx.Err() == nil {
44- d.snapshotRestoreFailure(name, err)
45- }
46- }
47- }
48-}
49-
50-func (d *Daemon) snapshotRestoreFailure(name string, err error) {
51- d.log.Warn("restoring session snapshot failed", "err", err, "session", name)
52- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't restore session "+name+" after restart", err)
53-}
12+// restoreIncrementalSnapshots intentionally performs no repository discovery.
13+// Catalogue-indexed restoration is introduced as a separate recovery phase;
14+// until then startup must not fall back to name scans or generation traversal.
15+func (d *Daemon) restoreIncrementalSnapshots(context.Context) {}
5416
5517 func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {
5618 manifest, err := snapcodec.UnmarshalManifest(generation.Manifest)
5719 if err != nil {
5820 return snapcodec.Session{}, err
5921 }
60- if manifest.Name != generation.Name || manifest.Generation != generation.Generation {
22+ if manifest.IncarnationID != generation.IncarnationID || manifest.Name != generation.Name || manifest.Generation != generation.Generation {
6123 return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
6224 }
6325 result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
@@ -99,117 +61,3 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR
9961 }
10062 return append([]byte(nil), payload...), nil
10163 }
102-
103-// importLegacySnapshots is intentionally best effort per session. It deletes
104-// a legacy blob only after publication and a full repository reload verify the
105-// exact converted session, leaving failures retryable on the next startup.
106-func (d *Daemon) importLegacySnapshots(ctx context.Context) {
107- if d.legacySnapshots == nil || d.snapshotRepository == nil || ctx.Err() != nil {
108- return
109- }
110- existing, err := d.snapshotRepository.List(ctx)
111- if err != nil {
112- d.log.Warn("listing incremental snapshots before import failed", "err", err)
113- return
114- }
115- present := make(map[string]struct{}, len(existing))
116- for _, name := range existing {
117- present[name] = struct{}{}
118- }
119- legacy, err := d.legacySnapshots.LoadLegacy(ctx)
120- if err != nil {
121- d.log.Warn("loading legacy snapshots failed", "err", err)
122- return
123- }
124- for _, blob := range legacy {
125- if ctx.Err() != nil {
126- return
127- }
128- if _, ok := present[blob.Name]; ok {
129- d.retryLegacyDelete(ctx, blob)
130- continue
131- }
132- snapshot, err := snapcodec.Unmarshal(blob.Data)
133- if err != nil || snapshot.Name != blob.Name {
134- d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
135- continue
136- }
137- publication, err := legacyPublication(snapshot)
138- if err == nil {
139- err = d.snapshotRepository.Publish(ctx, publication)
140- }
141- if err == nil {
142- var generation ports.SnapshotGeneration
143- generation, err = d.snapshotRepository.Load(ctx, snapshot.Name)
144- if err == nil {
145- err = verifyLegacyImportGeneration(generation, publication)
146- }
147- }
148- if err != nil {
149- d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
150- continue
151- }
152- d.rememberLegacyDelete(blob)
153- if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
154- d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
155- continue
156- }
157- d.forgetLegacyDelete(blob.Name)
158- present[blob.Name] = struct{}{}
159- }
160-}
161-
162-func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
163- if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
164- return fmt.Errorf("snapshot: legacy import verification mismatch")
165- }
166- expected := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
167- for _, object := range publication.Objects {
168- if sha256.Sum256(object.Data) != object.Digest {
169- return fmt.Errorf("snapshot: legacy import publication digest mismatch")
170- }
171- expected[object.Digest] = object.Data
172- }
173- if len(generation.Objects) != len(expected) {
174- return fmt.Errorf("snapshot: legacy import verification mismatch")
175- }
176- for digest, expectedData := range expected {
177- actual, ok := generation.Objects[digest]
178- if !ok || !bytes.Equal(actual, expectedData) || sha256.Sum256(actual) != digest {
179- return fmt.Errorf("snapshot: legacy import verification mismatch")
180- }
181- }
182- return nil
183-}
184-
185-func (d *Daemon) rememberLegacyDelete(blob ports.LegacySnapshot) {
186- d.legacyImportMu.Lock()
187- if d.legacyImportPending == nil {
188- d.legacyImportPending = make(map[string][]byte)
189- }
190- d.legacyImportPending[blob.Name] = append([]byte(nil), blob.Data...)
191- d.legacyImportMu.Unlock()
192-}
193-
194-func (d *Daemon) forgetLegacyDelete(name string) {
195- d.legacyImportMu.Lock()
196- delete(d.legacyImportPending, name)
197- d.legacyImportMu.Unlock()
198-}
199-
200-// retryLegacyDelete only acts on a blob which this daemon has already
201-// published and exactly reloaded. An unrelated pre-existing generation must
202-// continue to leave its legacy counterpart untouched.
203-func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapshot) {
204- d.legacyImportMu.Lock()
205- pending, ok := d.legacyImportPending[blob.Name]
206- d.legacyImportMu.Unlock()
207- if !ok || !bytes.Equal(pending, blob.Data) {
208- return
209- }
210- if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
211- d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
212- return
213- }
214- d.forgetLegacyDelete(blob.Name)
215-}
@@ -1,63 +1,25 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "bytes"
5 "context"4 "context"
6 "crypto/sha256"5 "crypto/sha256"
7 "fmt"6 "fmt"
8 7
9- "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
11 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"9 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12 )10 )
13 11
14-// restoreIncrementalSnapshots imports v3 only before listing repository12+// restoreIncrementalSnapshots intentionally performs no repository discovery.
15-// sessions, then restores each independently. A corrupt session therefore13+// Catalogue-indexed restoration is introduced as a separate recovery phase;
16-// cannot prevent an unrelated saved session from starting.14+// until then startup must not fall back to name scans or generation traversal.
17-func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) {15+func (d *Daemon) restoreIncrementalSnapshots(context.Context) {}
18- d.importLegacySnapshots(ctx)
19- names, err := d.snapshotRepository.List(ctx)
20- if err != nil {
21- d.log.Warn("listing session snapshots failed", "err", err)
22- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't load saved sessions after restart", err)
23- return
24- }
25- for _, name := range names {
26- if err := ctx.Err(); err != nil {
27- return
28- }
29- generation, err := d.snapshotRepository.Load(ctx, name)
30- if err != nil {
31- d.snapshotRestoreFailure(name, err)
32- continue
33- }
34- if generation.Fallback {
35- d.NotifyGlobal(domain.NoticeWarn, domain.NoticeSnapshotRestore, "restored an older saved checkpoint for session "+name, nil)
36- }
37- snapshot, err := sessionFromGeneration(generation)
38- if err != nil {
39- d.snapshotRestoreFailure(name, err)
40- continue
41- }
42- if err := d.restoreSession(ctx, snapshot, generation.Generation); err != nil {
43- if ctx.Err() == nil {
44- d.snapshotRestoreFailure(name, err)
45- }
46- }
47- }
48-}
49-
50-func (d *Daemon) snapshotRestoreFailure(name string, err error) {
51- d.log.Warn("restoring session snapshot failed", "err", err, "session", name)
52- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't restore session "+name+" after restart", err)
53-}
54 16
55 func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {17 func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {
56 manifest, err := snapcodec.UnmarshalManifest(generation.Manifest)18 manifest, err := snapcodec.UnmarshalManifest(generation.Manifest)
57 if err != nil {19 if err != nil {
58 return snapcodec.Session{}, err20 return snapcodec.Session{}, err
59 }21 }
60- if manifest.Name != generation.Name || manifest.Generation != generation.Generation {22+ if manifest.IncarnationID != generation.IncarnationID || manifest.Name != generation.Name || manifest.Generation != generation.Generation {
61 return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")23 return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
62 }24 }
63 result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}25 result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
@@ -99,117 +61,3 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR
99 }61 }
100 return append([]byte(nil), payload...), nil62 return append([]byte(nil), payload...), nil
101 }63 }
102-
103-// importLegacySnapshots is intentionally best effort per session. It deletes
104-// a legacy blob only after publication and a full repository reload verify the
105-// exact converted session, leaving failures retryable on the next startup.
106-func (d *Daemon) importLegacySnapshots(ctx context.Context) {
107- if d.legacySnapshots == nil || d.snapshotRepository == nil || ctx.Err() != nil {
108- return
109- }
110- existing, err := d.snapshotRepository.List(ctx)
111- if err != nil {
112- d.log.Warn("listing incremental snapshots before import failed", "err", err)
113- return
114- }
115- present := make(map[string]struct{}, len(existing))
116- for _, name := range existing {
117- present[name] = struct{}{}
118- }
119- legacy, err := d.legacySnapshots.LoadLegacy(ctx)
120- if err != nil {
121- d.log.Warn("loading legacy snapshots failed", "err", err)
122- return
123- }
124- for _, blob := range legacy {
125- if ctx.Err() != nil {
126- return
127- }
128- if _, ok := present[blob.Name]; ok {
129- d.retryLegacyDelete(ctx, blob)
130- continue
131- }
132- snapshot, err := snapcodec.Unmarshal(blob.Data)
133- if err != nil || snapshot.Name != blob.Name {
134- d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
135- continue
136- }
137- publication, err := legacyPublication(snapshot)
138- if err == nil {
139- err = d.snapshotRepository.Publish(ctx, publication)
140- }
141- if err == nil {
142- var generation ports.SnapshotGeneration
143- generation, err = d.snapshotRepository.Load(ctx, snapshot.Name)
144- if err == nil {
145- err = verifyLegacyImportGeneration(generation, publication)
146- }
147- }
148- if err != nil {
149- d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
150- continue
151- }
152- d.rememberLegacyDelete(blob)
153- if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
154- d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
155- continue
156- }
157- d.forgetLegacyDelete(blob.Name)
158- present[blob.Name] = struct{}{}
159- }
160-}
161-
162-func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
163- if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
164- return fmt.Errorf("snapshot: legacy import verification mismatch")
165- }
166- expected := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
167- for _, object := range publication.Objects {
168- if sha256.Sum256(object.Data) != object.Digest {
169- return fmt.Errorf("snapshot: legacy import publication digest mismatch")
170- }
171- expected[object.Digest] = object.Data
172- }
173- if len(generation.Objects) != len(expected) {
174- return fmt.Errorf("snapshot: legacy import verification mismatch")
175- }
176- for digest, expectedData := range expected {
177- actual, ok := generation.Objects[digest]
178- if !ok || !bytes.Equal(actual, expectedData) || sha256.Sum256(actual) != digest {
179- return fmt.Errorf("snapshot: legacy import verification mismatch")
180- }
181- }
182- return nil
183-}
184-
185-func (d *Daemon) rememberLegacyDelete(blob ports.LegacySnapshot) {
186- d.legacyImportMu.Lock()
187- if d.legacyImportPending == nil {
188- d.legacyImportPending = make(map[string][]byte)
189- }
190- d.legacyImportPending[blob.Name] = append([]byte(nil), blob.Data...)
191- d.legacyImportMu.Unlock()
192-}
193-
194-func (d *Daemon) forgetLegacyDelete(name string) {
195- d.legacyImportMu.Lock()
196- delete(d.legacyImportPending, name)
197- d.legacyImportMu.Unlock()
198-}
199-
200-// retryLegacyDelete only acts on a blob which this daemon has already
201-// published and exactly reloaded. An unrelated pre-existing generation must
202-// continue to leave its legacy counterpart untouched.
203-func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapshot) {
204- d.legacyImportMu.Lock()
205- pending, ok := d.legacyImportPending[blob.Name]
206- d.legacyImportMu.Unlock()
207- if !ok || !bytes.Equal(pending, blob.Data) {
208- return
209- }
210- if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
211- d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
212- return
213- }
214- d.forgetLegacyDelete(blob.Name)
215-}
M internal/usecase/daemon/snapshot_restore_test.go +0 -2
@@ -249,7 +249,6 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
249249 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
250250 snapshot := restoreAcceptanceSession(t, "fallback")
251251 valid := acceptanceGeneration(t, snapshot, 3)
252- valid.Fallback = true
253252 pty, release := newBlockingPTY(t)
254253 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
255254 store, _ := newMockStore(t)
@@ -300,7 +299,6 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
300299 } {
301300 t.Run(mutate.name, func(t *testing.T) {
302301 generation := cloneAcceptanceGeneration(valid)
303- generation.Fallback = false
304302 mutate.fn(&generation)
305303 _, err := sessionFromGeneration(generation)
306304 require.Error(t, err)
@@ -249,7 +249,6 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
249 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {249 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
250 snapshot := restoreAcceptanceSession(t, "fallback")250 snapshot := restoreAcceptanceSession(t, "fallback")
251 valid := acceptanceGeneration(t, snapshot, 3)251 valid := acceptanceGeneration(t, snapshot, 3)
252- valid.Fallback = true
253 pty, release := newBlockingPTY(t)252 pty, release := newBlockingPTY(t)
254 d := newTestDaemon(t, newFactory(t, pty), stubClock{})253 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
255 store, _ := newMockStore(t)254 store, _ := newMockStore(t)
@@ -300,7 +299,6 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
300 } {299 } {
301 t.Run(mutate.name, func(t *testing.T) {300 t.Run(mutate.name, func(t *testing.T) {
302 generation := cloneAcceptanceGeneration(valid)301 generation := cloneAcceptanceGeneration(valid)
303- generation.Fallback = false
304 mutate.fn(&generation)302 mutate.fn(&generation)
305 _, err := sessionFromGeneration(generation)303 _, err := sessionFromGeneration(generation)
306 require.Error(t, err)304 require.Error(t, err)
M internal/usecase/daemon/snapshot_scheduler_test.go +5 -8
@@ -95,13 +95,13 @@ func TestRenameKeepsSnapshotCoordinatorQuarantinedUntilNewIdentityCommits(t *tes
9595 deleteCompleted := make(chan struct{})
9696 state.deleteDone = deleteCompleted
9797 published := make(chan ports.SnapshotPublication, 2)
98- repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()
99- repository.EXPECT().Delete(mock.Anything, "work").RunAndReturn(func(context.Context, string) error {
98+ repository.EXPECT().WriteDeletionTombstone(mock.Anything, mock.Anything).Return(nil).Once()
99+ repository.EXPECT().QuarantineDeletionSources(mock.Anything, mock.Anything, false).RunAndReturn(func(context.Context, domain.DeletionTombstone, bool) error {
100100 close(deleteEntered)
101101 <-allowDelete
102102 return nil
103103 }).Once()
104- repository.EXPECT().DeleteTombstone(mock.Anything, "work").Return(nil).Once()
104+ repository.EXPECT().DeleteDeletionTombstone(mock.Anything, mock.Anything).Return(nil).Once()
105105 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
106106 published <- publication
107107 return nil
@@ -174,8 +174,8 @@ func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) {
174174 return nil
175175 }
176176 state.mu.Unlock()
177- repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()
178- repository.EXPECT().Delete(mock.Anything, "work").Return(nil).Once()
177+ repository.EXPECT().WriteDeletionTombstone(mock.Anything, mock.Anything).Return(nil).Once()
178+ repository.EXPECT().QuarantineDeletionSources(mock.Anything, mock.Anything, false).Return(nil).Once()
179179 // No Publish expectation is installed: any publication is an unexpected mock
180180 // call. The saver handshake below joins the scheduler before this test ends.
181181
@@ -448,7 +448,6 @@ func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
448448 d := newTestDaemon(t, newFactory(t, pty), clock)
449449 repository := portsmocks.NewMockSnapshotRepository(t)
450450 WithSnapshotRepository(repository, nil)(d)
451- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
452451
453452 publicationStarted := make(chan struct{})
454453 releasePublication := make(chan struct{})
@@ -609,7 +608,6 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
609608 d := newTestDaemon(t, newFactory(t, p), stubClock{})
610609 repository := portsmocks.NewMockSnapshotRepository(t)
611610 WithSnapshotRepository(repository, nil)(d)
612- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
613611
614612 published := make(chan ports.SnapshotPublication, 2)
615613 allowTerminalPublication := make(chan struct{})
@@ -654,7 +652,6 @@ func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository
654652 d := newTestDaemon(t, newFactory(t, p), clock)
655653 repository := portsmocks.NewMockSnapshotRepository(t)
656654 WithSnapshotRepository(repository, nil)(d)
657- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
658655 noticeStore := portsmocks.NewMockNoticeStore(t)
659656 WithNoticeStore(noticeStore)(d)
660657 noticeStore.EXPECT().Claim().Return(nil, nil).Maybe()
@@ -95,13 +95,13 @@ func TestRenameKeepsSnapshotCoordinatorQuarantinedUntilNewIdentityCommits(t *tes
95 deleteCompleted := make(chan struct{})95 deleteCompleted := make(chan struct{})
96 state.deleteDone = deleteCompleted96 state.deleteDone = deleteCompleted
97 published := make(chan ports.SnapshotPublication, 2)97 published := make(chan ports.SnapshotPublication, 2)
98- repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()98+ repository.EXPECT().WriteDeletionTombstone(mock.Anything, mock.Anything).Return(nil).Once()
99- repository.EXPECT().Delete(mock.Anything, "work").RunAndReturn(func(context.Context, string) error {99+ repository.EXPECT().QuarantineDeletionSources(mock.Anything, mock.Anything, false).RunAndReturn(func(context.Context, domain.DeletionTombstone, bool) error {
100 close(deleteEntered)100 close(deleteEntered)
101 <-allowDelete101 <-allowDelete
102 return nil102 return nil
103 }).Once()103 }).Once()
104- repository.EXPECT().DeleteTombstone(mock.Anything, "work").Return(nil).Once()104+ repository.EXPECT().DeleteDeletionTombstone(mock.Anything, mock.Anything).Return(nil).Once()
105 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {105 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
106 published <- publication106 published <- publication
107 return nil107 return nil
@@ -174,8 +174,8 @@ func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) {
174 return nil174 return nil
175 }175 }
176 state.mu.Unlock()176 state.mu.Unlock()
177- repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()177+ repository.EXPECT().WriteDeletionTombstone(mock.Anything, mock.Anything).Return(nil).Once()
178- repository.EXPECT().Delete(mock.Anything, "work").Return(nil).Once()178+ repository.EXPECT().QuarantineDeletionSources(mock.Anything, mock.Anything, false).Return(nil).Once()
179 // No Publish expectation is installed: any publication is an unexpected mock179 // No Publish expectation is installed: any publication is an unexpected mock
180 // call. The saver handshake below joins the scheduler before this test ends.180 // call. The saver handshake below joins the scheduler before this test ends.
181 181
@@ -448,7 +448,6 @@ func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
448 d := newTestDaemon(t, newFactory(t, pty), clock)448 d := newTestDaemon(t, newFactory(t, pty), clock)
449 repository := portsmocks.NewMockSnapshotRepository(t)449 repository := portsmocks.NewMockSnapshotRepository(t)
450 WithSnapshotRepository(repository, nil)(d)450 WithSnapshotRepository(repository, nil)(d)
451- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
452 451
453 publicationStarted := make(chan struct{})452 publicationStarted := make(chan struct{})
454 releasePublication := make(chan struct{})453 releasePublication := make(chan struct{})
@@ -609,7 +608,6 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
609 d := newTestDaemon(t, newFactory(t, p), stubClock{})608 d := newTestDaemon(t, newFactory(t, p), stubClock{})
610 repository := portsmocks.NewMockSnapshotRepository(t)609 repository := portsmocks.NewMockSnapshotRepository(t)
611 WithSnapshotRepository(repository, nil)(d)610 WithSnapshotRepository(repository, nil)(d)
612- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
613 611
614 published := make(chan ports.SnapshotPublication, 2)612 published := make(chan ports.SnapshotPublication, 2)
615 allowTerminalPublication := make(chan struct{})613 allowTerminalPublication := make(chan struct{})
@@ -654,7 +652,6 @@ func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository
654 d := newTestDaemon(t, newFactory(t, p), clock)652 d := newTestDaemon(t, newFactory(t, p), clock)
655 repository := portsmocks.NewMockSnapshotRepository(t)653 repository := portsmocks.NewMockSnapshotRepository(t)
656 WithSnapshotRepository(repository, nil)(d)654 WithSnapshotRepository(repository, nil)(d)
657- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
658 noticeStore := portsmocks.NewMockNoticeStore(t)655 noticeStore := portsmocks.NewMockNoticeStore(t)
659 WithNoticeStore(noticeStore)(d)656 WithNoticeStore(noticeStore)(d)
660 noticeStore.EXPECT().Claim().Return(nil, nil).Maybe()657 noticeStore.EXPECT().Claim().Return(nil, nil).Maybe()
A internal/usecase/recovery/coordinator.go +61 -0
@@ -0,0 +1,61 @@
1+// Package recovery coordinates crash-resumable operations across the catalogue
2+// and snapshot repository without depending on filesystem adapters.
3+package recovery
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+var ErrPendingRecoveryUnsupported = errors.New("pending recovery intent requires transaction recovery")
14+
15+var recoveryListingBudget = ports.MaintenanceBudget{Entries: 64, Bytes: 64 << 10}
16+
17+type Coordinator struct {
18+ catalogue ports.Catalogue
19+ repository ports.SnapshotRepository
20+ journal ports.RecoveryJournal
21+ random io.Reader
22+}
23+
24+func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, journal ports.RecoveryJournal, random io.Reader) *Coordinator {
25+ return &Coordinator{catalogue: catalogue, repository: repository, journal: journal, random: random}
26+}
27+
28+// Recover is deliberately fail-closed until the transaction roll-forward
29+// handlers are introduced. Startup may proceed only when both intent sources
30+// have been completely and strictly enumerated.
31+func (c *Coordinator) Recover(ctx context.Context) error {
32+ if c == nil || c.repository == nil || c.journal == nil {
33+ return errors.New("recovery: incomplete coordinator dependencies")
34+ }
35+ intents, err := c.journal.ListDiscards(ctx)
36+ if err != nil {
37+ return err
38+ }
39+ pending := len(intents) != 0
40+ cursor := ports.DeletionTombstoneCursor{}
41+ for {
42+ page, err := c.repository.ListDeletionTombstones(ctx, cursor, recoveryListingBudget)
43+ if err != nil {
44+ return err
45+ }
46+ if len(page.Tombstones) != 0 {
47+ pending = true
48+ }
49+ if page.Done {
50+ break
51+ }
52+ if page.Next.After == cursor.After {
53+ return errors.New("recovery: tombstone listing did not advance")
54+ }
55+ cursor = page.Next
56+ }
57+ if pending {
58+ return ErrPendingRecoveryUnsupported
59+ }
60+ return nil
61+}
@@ -0,0 +1,61 @@
1+// Package recovery coordinates crash-resumable operations across the catalogue
2+// and snapshot repository without depending on filesystem adapters.
3+package recovery
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+var ErrPendingRecoveryUnsupported = errors.New("pending recovery intent requires transaction recovery")
14+
15+var recoveryListingBudget = ports.MaintenanceBudget{Entries: 64, Bytes: 64 << 10}
16+
17+type Coordinator struct {
18+ catalogue ports.Catalogue
19+ repository ports.SnapshotRepository
20+ journal ports.RecoveryJournal
21+ random io.Reader
22+}
23+
24+func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, journal ports.RecoveryJournal, random io.Reader) *Coordinator {
25+ return &Coordinator{catalogue: catalogue, repository: repository, journal: journal, random: random}
26+}
27+
28+// Recover is deliberately fail-closed until the transaction roll-forward
29+// handlers are introduced. Startup may proceed only when both intent sources
30+// have been completely and strictly enumerated.
31+func (c *Coordinator) Recover(ctx context.Context) error {
32+ if c == nil || c.repository == nil || c.journal == nil {
33+ return errors.New("recovery: incomplete coordinator dependencies")
34+ }
35+ intents, err := c.journal.ListDiscards(ctx)
36+ if err != nil {
37+ return err
38+ }
39+ pending := len(intents) != 0
40+ cursor := ports.DeletionTombstoneCursor{}
41+ for {
42+ page, err := c.repository.ListDeletionTombstones(ctx, cursor, recoveryListingBudget)
43+ if err != nil {
44+ return err
45+ }
46+ if len(page.Tombstones) != 0 {
47+ pending = true
48+ }
49+ if page.Done {
50+ break
51+ }
52+ if page.Next.After == cursor.After {
53+ return errors.New("recovery: tombstone listing did not advance")
54+ }
55+ cursor = page.Next
56+ }
57+ if pending {
58+ return ErrPendingRecoveryUnsupported
59+ }
60+ return nil
61+}
A internal/usecase/recovery/coordinator_test.go +40 -0
@@ -0,0 +1,40 @@
1+package recovery
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type journalStub struct {
15+ intents []domain.DiscardIntent
16+ err error
17+}
18+
19+func (j journalStub) SaveDiscard(context.Context, domain.DiscardIntent) error { return nil }
20+func (j journalStub) ListDiscards(context.Context) ([]domain.DiscardIntent, error) {
21+ return j.intents, j.err
22+}
23+func (j journalStub) DeleteDiscard(context.Context, domain.IncarnationID) error { return nil }
24+
25+func TestCoordinatorRecoverFailsClosedForPendingWork(t *testing.T) {
26+ repo := portsmocks.NewMockSnapshotRepository(t)
27+ repo.EXPECT().ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, recoveryListingBudget).
28+ Return(ports.DeletionTombstonePage{Done: true}, nil)
29+ c := NewCoordinator(nil, repo, journalStub{intents: []domain.DiscardIntent{{}}}, nil)
30+ require.ErrorIs(t, c.Recover(context.Background()), ErrPendingRecoveryUnsupported)
31+}
32+
33+func TestCoordinatorRecoverPagesTombstonesAndPropagatesErrors(t *testing.T) {
34+ cause := errors.New("malformed tombstone")
35+ repo := portsmocks.NewMockSnapshotRepository(t)
36+ repo.EXPECT().ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, recoveryListingBudget).
37+ Return(ports.DeletionTombstonePage{}, cause)
38+ c := NewCoordinator(nil, repo, journalStub{}, nil)
39+ require.ErrorIs(t, c.Recover(context.Background()), cause)
40+}
@@ -0,0 +1,40 @@
1+package recovery
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type journalStub struct {
15+ intents []domain.DiscardIntent
16+ err error
17+}
18+
19+func (j journalStub) SaveDiscard(context.Context, domain.DiscardIntent) error { return nil }
20+func (j journalStub) ListDiscards(context.Context) ([]domain.DiscardIntent, error) {
21+ return j.intents, j.err
22+}
23+func (j journalStub) DeleteDiscard(context.Context, domain.IncarnationID) error { return nil }
24+
25+func TestCoordinatorRecoverFailsClosedForPendingWork(t *testing.T) {
26+ repo := portsmocks.NewMockSnapshotRepository(t)
27+ repo.EXPECT().ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, recoveryListingBudget).
28+ Return(ports.DeletionTombstonePage{Done: true}, nil)
29+ c := NewCoordinator(nil, repo, journalStub{intents: []domain.DiscardIntent{{}}}, nil)
30+ require.ErrorIs(t, c.Recover(context.Background()), ErrPendingRecoveryUnsupported)
31+}
32+
33+func TestCoordinatorRecoverPagesTombstonesAndPropagatesErrors(t *testing.T) {
34+ cause := errors.New("malformed tombstone")
35+ repo := portsmocks.NewMockSnapshotRepository(t)
36+ repo.EXPECT().ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, recoveryListingBudget).
37+ Return(ports.DeletionTombstonePage{}, cause)
38+ c := NewCoordinator(nil, repo, journalStub{}, nil)
39+ require.ErrorIs(t, c.Recover(context.Background()), cause)
40+}

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