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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "syscall" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | 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 | 16 | for _, directory := range []struct { | |
| 16 | 17 | path string | |
| 17 | 18 | phase string | |
| 18 | 19 | }{ | |
| 19 | 20 | {r.dir, "repository"}, | |
| 20 | 21 | {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"}, | |
| 24 | 25 | } { | |
| 25 | 26 | if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil { | |
| 26 | 27 | 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 string | 17 | path string | ||
| 17 | phase string | 18 | 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 ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "os" | |
| 11 | - | "path/filepath" | |
| 12 | - | "sort" | |
| 13 | 11 | ||
| 14 | 12 | "github.com/bnema/vev/internal/domain" | |
| 15 | 13 | "github.com/bnema/vev/internal/ports" | |
| @@ -29,166 +27,6 @@ var ( | |||
| 29 | 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 | 30 | // walkDirectory is the common finite directory traversal primitive for reads. | |
| 193 | 31 | // File.ReadDir maintains a cursor on one descriptor and returns at most one | |
| 194 | 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 | 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 | 147 | if err != nil { | |
| 310 | 148 | return ports.SnapshotGeneration{}, err | |
| 311 | 149 | } | |
| @@ -322,7 +160,7 @@ func (r *Repository) loadGeneration(ctx context.Context, name, key string, gener | |||
| 322 | 160 | if err := ctx.Err(); err != nil { | |
| 323 | 161 | 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 | 164 | if err != nil { | |
| 327 | 165 | return ports.SnapshotGeneration{}, err | |
| 328 | 166 | } | |
| @@ -341,12 +179,15 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte { | |||
| 341 | 179 | copy(out[12:], digest[:]) | |
| 342 | 180 | return out | |
| 343 | 181 | } | |
| 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) | |
| 346 | 184 | } | |
| 347 | 185 | ||
| 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) { | |
| 350 | 191 | if hook := r.hooks.beforeHeadRead; hook != nil { | |
| 351 | 192 | if err := hook(path); err != nil { | |
| 352 | 193 | return 0, ports.SnapshotDigest{}, err | |
| @@ -373,3 +214,7 @@ func (r *Repository) readHeadWithRoot(root *os.Root, key string) (uint64, ports. | |||
| 373 | 214 | } | |
| 374 | 215 | 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 | + | } | |
| @@ -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 one | 31 | // 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{}, err | 148 | 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{}, err | 161 | 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{}, err | 165 | 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 out | 180 | 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{}, err | 193 | 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, nil | 215 | 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) { | |||
| 30 | 30 | func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) { | |
| 31 | 31 | repo := NewRepository(privateDir(t)) | |
| 32 | 32 | key := sessionKey("named") | |
| 33 | - | generations := filepath.Join(repo.sessionPath(key), repositoryGenerations) | |
| 33 | + | generations := filepath.Join(repo.legacySessionPath(key), repositoryGenerations) | |
| 34 | 34 | if err := os.MkdirAll(generations, 0o700); err != nil { | |
| 35 | 35 | t.Fatal(err) | |
| 36 | 36 | } | |
| @@ -55,7 +55,7 @@ func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *te | |||
| 55 | 55 | } | |
| 56 | 56 | key := sessionKey("named") | |
| 57 | 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 | 59 | t.Fatal(err) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| @@ -86,7 +86,7 @@ func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing | |||
| 86 | 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 | 90 | t.Fatalf("old manifest collected after mark budget overflow: %v", err) | |
| 91 | 91 | } | |
| 92 | 92 | 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 | |||
| 32 | 32 | func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) { | |
| 33 | 33 | repo := NewRepository(privateDir(t)) | |
| 34 | 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 | 37 | got, err := repo.Load(context.Background(), "named") | |
| 38 | 38 | require.NoError(t, err) | |
| @@ -47,7 +47,7 @@ func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) { | |||
| 47 | 47 | key := sessionKey("named") | |
| 48 | 48 | for _, generation := range []uint64{4, 3} { | |
| 49 | 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 | 53 | 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 | |||
| 121 | 121 | if err != nil { | |
| 122 | 122 | 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 | 125 | if charge > budget.Bytes { | |
| 126 | 126 | if len(page.Tombstones) == 0 { | |
| 127 | 127 | return ports.DeletionTombstonePage{}, ErrMaintenanceBudgetTooSmall | |
| @@ -177,7 +177,7 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||
| 177 | 177 | return err | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | - | canonical := r.sessionPath(key) | |
| 180 | + | canonical := r.legacySessionPath(key) | |
| 181 | 181 | sessions := filepath.Dir(canonical) | |
| 182 | 182 | // A prior rename may have succeeded while its parent sync failed. Complete | |
| 183 | 183 | // 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{}, err | 122 | 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{}, ErrMaintenanceBudgetTooSmall | 127 | return ports.DeletionTombstonePage{}, ErrMaintenanceBudgetTooSmall | ||
| @@ -177,7 +177,7 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||||
| 177 | return err | 177 | 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. Complete | 182 | // A prior rename may have succeeded while its parent sync failed. Complete | ||
| 183 | // that durability boundary before considering a canonical directory: this | 183 | // 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 | |||
| 57 | 57 | if err != nil { | |
| 58 | 58 | 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 | 61 | return err | |
| 62 | 62 | } | |
| 63 | 63 | if !state.objectTempsDone { | |
| @@ -120,7 +120,7 @@ func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, e | |||
| 120 | 120 | // itself stable maintenance state: we retain every classified reference until a | |
| 121 | 121 | // valid publication changes that state, rather than restarting the mark pass. | |
| 122 | 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 | 124 | if err != nil { | |
| 125 | 125 | return "", false, err | |
| 126 | 126 | } | |
| @@ -128,7 +128,7 @@ func (r *Repository) maintenanceToken(key string) (string, bool, error) { | |||
| 128 | 128 | return "missing", true, nil | |
| 129 | 129 | } | |
| 130 | 130 | sum := sha256.Sum256(data) | |
| 131 | - | if _, _, err := r.readHead(key); err != nil { | |
| 131 | + | if _, _, err := r.readLegacyHead(key); err != nil { | |
| 132 | 132 | return fmt.Sprintf("invalid:%x", sum), true, nil | |
| 133 | 133 | } | |
| 134 | 134 | return fmt.Sprintf("valid:%x", sum), false, nil | |
| @@ -139,7 +139,7 @@ func (r *Repository) clearSessionMaintenance(key string) { | |||
| 139 | 139 | r.releaseSessionReference(state.lock) | |
| 140 | 140 | } | |
| 141 | 141 | delete(r.maintenanceSessions, key) | |
| 142 | - | prefix := "\x00" + filepath.Clean(r.sessionPath(key)) | |
| 142 | + | prefix := "\x00" + filepath.Clean(r.legacySessionPath(key)) | |
| 143 | 143 | for id := range r.maintenanceCursors { | |
| 144 | 144 | if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") { | |
| 145 | 145 | delete(r.maintenanceCursors, id) | |
| @@ -148,7 +148,7 @@ func (r *Repository) clearSessionMaintenance(key string) { | |||
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | 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 | 152 | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key) | |
| 153 | 153 | if errors.Is(err, os.ErrNotExist) { | |
| 154 | 154 | state.markDone = true | |
| @@ -168,7 +168,7 @@ func (r *Repository) markSession(ctx context.Context, key string, state *session | |||
| 168 | 168 | if !ok { | |
| 169 | 169 | continue | |
| 170 | 170 | } | |
| 171 | - | data, err := r.readBounded(r.manifestPath(key, generation)) | |
| 171 | + | data, err := r.readBounded(r.legacyManifestPath(key, generation)) | |
| 172 | 172 | if err != nil { | |
| 173 | 173 | state.uncertain = true | |
| 174 | 174 | continue | |
| @@ -254,7 +254,7 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st | |||
| 254 | 254 | return err | |
| 255 | 255 | } | |
| 256 | 256 | generation := state.manifestQueue[0] | |
| 257 | - | path := r.manifestPath(key, generation) | |
| 257 | + | path := r.legacyManifestPath(key, generation) | |
| 258 | 258 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 259 | 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 | 275 | // removal budget, so this fixed step ceiling prevents an unbounded scan of | |
| 276 | 276 | // them while retaining only the root cursor and one active shard cursor. | |
| 277 | 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 | 279 | if *budget == 0 { | |
| 280 | 280 | return false, nil | |
| 281 | 281 | } | |
| @@ -313,7 +313,7 @@ func (r *Repository) sweepSession(ctx context.Context, key string, state *sessio | |||
| 313 | 313 | return nil | |
| 314 | 314 | } | |
| 315 | 315 | referenced := retainedReferences(state.marked, state.conservative) | |
| 316 | - | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 316 | + | root := filepath.Join(r.legacySessionPath(key), repositoryObjectsDir) | |
| 317 | 317 | if state.sweepShard != "" && *budget > 0 { | |
| 318 | 318 | shard := state.sweepShard | |
| 319 | 319 | 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 err | 58 | 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 err | 61 | 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 a | 120 | // 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, err | 125 | return "", false, err | ||
| 126 | } | 126 | } | ||
| @@ -128,7 +128,7 @@ func (r *Repository) maintenanceToken(key string) (string, bool, error) { | |||||
| 128 | return "missing", true, nil | 128 | 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, nil | 132 | return fmt.Sprintf("invalid:%x", sum), true, nil | ||
| 133 | } | 133 | } | ||
| 134 | return fmt.Sprintf("valid:%x", sum), false, nil | 134 | 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 = true | 154 | 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 | continue | 169 | 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 = true | 173 | state.uncertain = true | ||
| 174 | continue | 174 | continue | ||
| @@ -254,7 +254,7 @@ func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, st | |||||
| 254 | return err | 254 | 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 of | 275 | // 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, nil | 280 | return false, nil | ||
| 281 | } | 281 | } | ||
| @@ -313,7 +313,7 @@ func (r *Repository) sweepSession(ctx context.Context, key string, state *sessio | |||||
| 313 | return nil | 313 | 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.sweepShard | 318 | 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 ( | |||
| 12 | 12 | func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) { | |
| 13 | 13 | repo := NewRepository(privateDir(t)) | |
| 14 | 14 | key := sessionKey("named") | |
| 15 | - | objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir) | |
| 15 | + | objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir) | |
| 16 | 16 | const shards = maintenanceBatch * 2 | |
| 17 | 17 | for i := 0; i < shards; i++ { | |
| 18 | 18 | shard := filepath.Join(objects, fmt.Sprintf("%03d", i)) | |
| @@ -49,7 +49,7 @@ func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) | |||
| 49 | 49 | func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) { | |
| 50 | 50 | repo := NewRepository(privateDir(t)) | |
| 51 | 51 | key := sessionKey("named") | |
| 52 | - | objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir) | |
| 52 | + | objects := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir) | |
| 53 | 53 | const shards = maintenanceBatch * 2 | |
| 54 | 54 | for i := 0; i < shards; i++ { | |
| 55 | 55 | 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 * 2 | 16 | 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 * 2 | 53 | 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) | |||
| 296 | 296 | t.Fatal(err) | |
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | - | canonical := repo.sessionPath(sessionKey("named")) | |
| 299 | + | canonical := repo.legacySessionPath(sessionKey("named")) | |
| 300 | 300 | if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) { | |
| 301 | 301 | t.Fatalf("canonical session after Delete = %v, want not exist", err) | |
| 302 | 302 | } | |
| @@ -329,7 +329,7 @@ func TestRepositoryDeleteReturnsRootSyncFailureAfterQuarantine(t *testing.T) { | |||
| 329 | 329 | if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) { | |
| 330 | 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 | 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 | 366 | if err := <-deleteDone; err != nil { | |
| 367 | 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 | 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 | 383 | if err := repo.Maintain(context.Background()); err != nil { | |
| 384 | 384 | t.Fatal(err) | |
| 385 | 385 | } | |
| 386 | - | _, err := os.Lstat(repo.manifestPath(key, 1)) | |
| 386 | + | _, err := os.Lstat(repo.legacyManifestPath(key, 1)) | |
| 387 | 387 | if errors.Is(err, os.ErrNotExist) { | |
| 388 | 388 | break | |
| 389 | 389 | } | |
| @@ -395,11 +395,11 @@ func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) { | |||
| 395 | 395 | } | |
| 396 | 396 | } | |
| 397 | 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 | 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 | 403 | t.Fatalf("old manifest after Maintain = %v, want not exist", err) | |
| 404 | 404 | } | |
| 405 | 405 | for _, generation := range []uint64{2, 3} { | |
| @@ -418,16 +418,16 @@ func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) { | |||
| 418 | 418 | } | |
| 419 | 419 | key := sessionKey("named") | |
| 420 | 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 | 422 | t.Fatal(err) | |
| 423 | 423 | } | |
| 424 | 424 | if err := repo.Maintain(context.Background()); err != nil { | |
| 425 | 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 | 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 | 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 | 601 | func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) { | |
| 602 | 602 | repo := NewRepository(privateDir(t)) | |
| 603 | 603 | key := sessionKey("named") | |
| 604 | - | root := filepath.Join(repo.sessionPath(key), repositoryObjectsDir) | |
| 604 | + | root := filepath.Join(repo.legacySessionPath(key), repositoryObjectsDir) | |
| 605 | 605 | for i := 0; i < maintenanceBatch*2; i++ { | |
| 606 | 606 | if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil { | |
| 607 | 607 | t.Fatal(err) | |
| @@ -698,7 +698,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) { | |||
| 698 | 698 | } | |
| 699 | 699 | key := sessionKey("named") | |
| 700 | 700 | stale := sha256.Sum256([]byte("stale object")) | |
| 701 | - | stalePath := repo.objectPath(key, stale) | |
| 701 | + | stalePath := repo.legacyObjectPath(key, stale) | |
| 702 | 702 | if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil { | |
| 703 | 703 | t.Fatal(err) | |
| 704 | 704 | } | |
| @@ -715,7 +715,7 @@ func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) { | |||
| 715 | 715 | t.Fatalf("stale object swept before complete mark pass: %v", err) | |
| 716 | 716 | } | |
| 717 | 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 | 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 | 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 | 736 | t.Fatalf("newest complete manifest %d removed: %v", generation, err) | |
| 737 | 737 | } | |
| 738 | 738 | } | |
| @@ -852,7 +852,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin | |||
| 852 | 852 | name: "missing", | |
| 853 | 853 | set: func(t *testing.T, repo *Repository, key string) { | |
| 854 | 854 | t.Helper() | |
| 855 | - | if err := os.Remove(repo.headPath(key)); err != nil { | |
| 855 | + | if err := os.Remove(repo.legacyHeadPath(key)); err != nil { | |
| 856 | 856 | t.Fatal(err) | |
| 857 | 857 | } | |
| 858 | 858 | }, | |
| @@ -861,7 +861,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin | |||
| 861 | 861 | name: "corrupt", | |
| 862 | 862 | set: func(t *testing.T, repo *Repository, key string) { | |
| 863 | 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 | 865 | t.Fatal(err) | |
| 866 | 866 | } | |
| 867 | 867 | }, | |
| @@ -880,7 +880,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin | |||
| 880 | 880 | } | |
| 881 | 881 | head.set(t, repo, key) | |
| 882 | 882 | stale := sha256.Sum256([]byte("unpublished stale object")) | |
| 883 | - | stalePath := repo.objectPath(key, stale) | |
| 883 | + | stalePath := repo.legacyObjectPath(key, stale) | |
| 884 | 884 | if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil { | |
| 885 | 885 | t.Fatal(err) | |
| 886 | 886 | } | |
| @@ -895,7 +895,7 @@ func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testin | |||
| 895 | 895 | t.Fatalf("stale object swept before full manifest classification: %v", err) | |
| 896 | 896 | } | |
| 897 | 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 | 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 | 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 | 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 | 926 | stalePaths := make([]string, 0, maintenanceBatch+1) | |
| 927 | 927 | for i := 0; i < maintenanceBatch+1; i++ { | |
| 928 | 928 | digest := digestInShard(t, "00", i) | |
| 929 | - | path := repo.objectPath(key, digest) | |
| 929 | + | path := repo.legacyObjectPath(key, digest) | |
| 930 | 930 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 931 | 931 | t.Fatal(err) | |
| 932 | 932 | } | |
| @@ -968,7 +968,7 @@ func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) { | |||
| 968 | 968 | t.Fatal(err) | |
| 969 | 969 | } | |
| 970 | 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 | 972 | t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err) | |
| 973 | 973 | } | |
| 974 | 974 | } | |
| @@ -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 | break | 388 | 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 | |||
| 178 | 178 | return domain.CheckpointRef{}, err | |
| 179 | 179 | } | |
| 180 | 180 | key := sessionKey(name) | |
| 181 | - | generation, digest, err := r.readHead(key) | |
| 181 | + | generation, digest, err := r.readLegacyHead(key) | |
| 182 | 182 | if err != nil { | |
| 183 | 183 | if errors.Is(err, os.ErrNotExist) || errors.Is(err, ErrInvalidHEAD) { | |
| 184 | 184 | return domain.CheckpointRef{}, uncertainLegacyError("read HEAD", err) | |
| 185 | 185 | } | |
| 186 | 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 | 189 | if err != nil { | |
| 190 | 190 | if errors.Is(err, os.ErrNotExist) { | |
| 191 | 191 | return domain.CheckpointRef{}, uncertainLegacyError("read referenced manifest", err) | |
| @@ -220,7 +220,7 @@ func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.Snapshot | |||
| 220 | 220 | return domain.CheckpointRef{}, errors.New("snapshot: invalid migration request") | |
| 221 | 221 | } | |
| 222 | 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 | 224 | if err != nil { | |
| 225 | 225 | if errors.Is(err, os.ErrNotExist) { | |
| 226 | 226 | return domain.CheckpointRef{}, uncertainLegacyError("referenced manifest missing", err) | |
| @@ -244,14 +244,14 @@ func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.Snapshot | |||
| 244 | 244 | key, _ := incarnationKey(req.IncarnationID) | |
| 245 | 245 | lock := r.lockSession(key) | |
| 246 | 246 | defer r.unlockSession(lock) | |
| 247 | - | if err := r.ensureSession(key); err != nil { | |
| 247 | + | if err := r.ensureSession(req.IncarnationID); err != nil { | |
| 248 | 248 | return domain.CheckpointRef{}, err | |
| 249 | 249 | } | |
| 250 | 250 | for _, ref := range decoded.Objects { | |
| 251 | 251 | if err := ctx.Err(); err != nil { | |
| 252 | 252 | 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 | 255 | if err != nil { | |
| 256 | 256 | if errors.Is(err, os.ErrNotExist) { | |
| 257 | 257 | 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{}, err | 178 | 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{}, err | 248 | 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{}, err | 252 | 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) { | |||
| 34 | 34 | return id.String(), nil | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 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) | |
| 48 | 49 | } | |
| 49 | 50 | ||
| 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) | |
| 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 | 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 { | |
| 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)) | |
| 59 | 61 | } | |
| 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) | |
| 62 | 64 | } | |
| 63 | 65 | func sessionKey(name string) string { | |
| 64 | 66 | if safeNameRE.MatchString(name) && name != "." && name != ".." { | |
| @@ -34,31 +34,33 @@ func incarnationKey(id domain.IncarnationID) (string, error) { | |||||
| 34 | return id.String(), nil | 34 | return id.String(), nil | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | // repositoryKey accepts string only for pre-migration maintenance code. Normal | 37 | + | 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 value | 44 | + | 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) { | |||
| 154 | 154 | t.Fatal(err) | |
| 155 | 155 | } | |
| 156 | 156 | key := sessionKey(publication.Name) | |
| 157 | - | inside := filepath.Join(repo.sessionPath(key), target) | |
| 157 | + | inside := filepath.Join(repo.legacySessionPath(key), target) | |
| 158 | 158 | outside := t.TempDir() | |
| 159 | 159 | guard := filepath.Join(outside, "must-not-change") | |
| 160 | 160 | 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 | |||
| 37 | 37 | // A failed publication can still leave immutable blobs or a manifest behind. | |
| 38 | 38 | // Invalidate an in-progress GC mark before creating any publication storage. | |
| 39 | 39 | r.invalidateStorageEpoch(key) | |
| 40 | - | if err := r.ensureSession(key); err != nil { | |
| 40 | + | if err := r.ensureSession(publication.IncarnationID); err != nil { | |
| 41 | 41 | return err | |
| 42 | 42 | } | |
| 43 | 43 | // The common replay path needs neither a manifest decode nor a reference | |
| 44 | 44 | // map. Checking the immutable bytes directly also keeps its descriptor | |
| 45 | 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 | 47 | 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 | 51 | if err != nil { | |
| 52 | 52 | return err | |
| 53 | 53 | } | |
| @@ -76,7 +76,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 76 | 76 | if err := ctx.Err(); err != nil { | |
| 77 | 77 | return err | |
| 78 | 78 | } | |
| 79 | - | path := r.objectPath(key, digest) | |
| 79 | + | path := r.objectPath(publication.IncarnationID, digest) | |
| 80 | 80 | exists, err := r.verifyObjectFile(path, digest, ref) | |
| 81 | 81 | if err != nil { | |
| 82 | 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 | 114 | existing, exists, err := r.readOptionalBounded(manifestPath) | |
| 115 | 115 | if err != nil { | |
| 116 | 116 | return err | |
| @@ -138,14 +138,14 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 138 | 138 | } | |
| 139 | 139 | } | |
| 140 | 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 | 142 | return err | |
| 143 | 143 | } | |
| 144 | 144 | } | |
| 145 | 145 | if err := ctx.Err(); err != nil { | |
| 146 | 146 | 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 | 149 | return fmt.Errorf("write HEAD: %w", err) | |
| 150 | 150 | } | |
| 151 | 151 | return nil | |
| @@ -154,23 +154,23 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 154 | 154 | // unchangedPublication uses one root for the two reads in the replay fast | |
| 155 | 155 | // path. The root is confined to this operation and is closed before return; | |
| 156 | 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 | 158 | root, err := r.openRoot() | |
| 159 | 159 | if err != nil { | |
| 160 | 160 | return false, err | |
| 161 | 161 | } | |
| 162 | 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 | 165 | if err != nil || generation != publication.Generation { | |
| 166 | 166 | 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 | 169 | 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) { | |
| 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) | |
| 174 | 174 | if errors.Is(err, os.ErrNotExist) { | |
| 175 | 175 | if publication.Generation != 1 || publication.ParentCheckpoint != nil { | |
| 176 | 176 | 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 err | 41 | return err | ||
| 42 | } | 42 | } | ||
| 43 | // The common replay path needs neither a manifest decode nor a reference | 43 | // The common replay path needs neither a manifest decode nor a reference | ||
| 44 | // map. Checking the immutable bytes directly also keeps its descriptor | 44 | // 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 nil | 47 | 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 err | 52 | 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 err | 77 | 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 err | 116 | 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 err | 142 | return err | ||
| 143 | } | 143 | } | ||
| 144 | } | 144 | } | ||
| 145 | if err := ctx.Err(); err != nil { | 145 | if err := ctx.Err(); err != nil { | ||
| 146 | return err | 146 | 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 nil | 151 | 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 fast | 154 | // 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, err | 160 | 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, nil | 166 | 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), nil | 169 | 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) { | |||
| 120 | 120 | second := repositoryPublication(t, "named", 2, []byte("second")) | |
| 121 | 121 | newTail := second.Objects[0] | |
| 122 | 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 | 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 | 127 | t.Fatal(err) | |
| 128 | 128 | } | |
| 129 | 129 | ||
| @@ -175,7 +175,7 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S | |||
| 175 | 175 | func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) { | |
| 176 | 176 | t.Helper() | |
| 177 | 177 | key := sessionKey(publication.Name) | |
| 178 | - | if err := repo.ensureSession(key); err != nil { | |
| 178 | + | if err := repo.ensureSession(publication.IncarnationID); err != nil { | |
| 179 | 179 | t.Fatal(err) | |
| 180 | 180 | } | |
| 181 | 181 | manifest, err := codec.UnmarshalManifest(publication.Manifest) | |
| @@ -183,17 +183,17 @@ func seedCompletePublication(t testing.TB, repo *Repository, publication ports.S | |||
| 183 | 183 | t.Fatal(err) | |
| 184 | 184 | } | |
| 185 | 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 | 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 | 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 | 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 | 197 | t.Fatal(err) | |
| 198 | 198 | } | |
| 199 | 199 | 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) { | |||
| 16 | 16 | repo := NewRepository(privateDir(t)) | |
| 17 | 17 | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 18 | 18 | object := pub.Objects[0] | |
| 19 | - | target := repo.objectPath(sessionKey(pub.Name), object.Digest) | |
| 19 | + | target := repo.legacyObjectPath(sessionKey(pub.Name), object.Digest) | |
| 20 | 20 | repo.hooks.installImmutable = func(path string) error { | |
| 21 | 21 | if path == target { | |
| 22 | 22 | return os.WriteFile(path, []byte("attacker bytes"), 0o600) | |
| @@ -39,7 +39,7 @@ func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublicat | |||
| 39 | 39 | t.Helper() | |
| 40 | 40 | key := sessionKey(pub.Name) | |
| 41 | 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 | 43 | if !bytes.Equal(data, pub.Manifest) { | |
| 44 | 44 | return errors.New("invalid manifest") | |
| 45 | 45 | } | |
| @@ -79,8 +79,7 @@ func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) { | |||
| 79 | 79 | t.Fatal(err) | |
| 80 | 80 | } | |
| 81 | 81 | 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 { | |
| 84 | 83 | t.Fatal(err) | |
| 85 | 84 | } | |
| 86 | 85 | preparePublicationLocation(t, repo, second, tc.location) | |
| @@ -114,12 +113,12 @@ func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.Snapsh | |||
| 114 | 113 | ||
| 115 | 114 | func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) { | |
| 116 | 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 | 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 | 120 | if location == "HEAD" { | |
| 122 | - | dir = filepath.Dir(repo.headPath(key)) | |
| 121 | + | dir = filepath.Dir(repo.legacyHeadPath(key)) | |
| 123 | 122 | } | |
| 124 | 123 | fail := errors.New("injected persistence failure") | |
| 125 | 124 | repo.hooks.createTemp = func(got string) error { | |
| @@ -153,7 +152,7 @@ func injectBoundary(repo *Repository, location, operation string, pub ports.Snap | |||
| 153 | 152 | return nil | |
| 154 | 153 | } | |
| 155 | 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 | 156 | return fail | |
| 158 | 157 | } | |
| 159 | 158 | return nil | |
| @@ -171,7 +170,7 @@ func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublicatio | |||
| 171 | 170 | key := sessionKey(pub.Name) | |
| 172 | 171 | for _, object := range pub.Objects { | |
| 173 | 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 | 174 | if !bytes.Equal(data, object.Data) || !validObject(data, ref) { | |
| 176 | 175 | return errors.New("invalid object") | |
| 177 | 176 | } | |
| @@ -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 nil | 152 | 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 fail | 156 | return fail | ||
| 158 | } | 157 | } | ||
| 159 | return nil | 158 | 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 | |||
| 62 | 62 | } else { | |
| 63 | 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 | 66 | phase := "write manifest" | |
| 67 | 67 | if tc.location == "HEAD" { | |
| 68 | - | dir = filepath.Dir(repo.headPath(key)) | |
| 68 | + | dir = filepath.Dir(repo.legacyHeadPath(key)) | |
| 69 | 69 | phase = "write HEAD" | |
| 70 | 70 | } | |
| 71 | 71 | repo.hooks.writeTemp = func(path string) error { | |
| @@ -119,10 +119,10 @@ func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) { | |||
| 119 | 119 | {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }}, | |
| 120 | 120 | {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }}, | |
| 121 | 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")) }}, | |
| 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")) }}, | |
| 124 | 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 | 128 | for _, tc := range cases { | |
| @@ -191,7 +191,7 @@ func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) { | |||
| 191 | 191 | if err != nil { | |
| 192 | 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 | 195 | t.Fatal(err) | |
| 196 | 196 | } | |
| 197 | 197 | 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) { | |||
| 53 | 53 | func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) { | |
| 54 | 54 | repo := NewRepository(privateDir(t)) | |
| 55 | 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 | 57 | for i := 0; i < maintenanceBatch+1; i++ { | |
| 58 | 58 | if err := os.MkdirAll(generations, 0o700); err != nil { | |
| 59 | 59 | 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 ( | |||
| 36 | 36 | "github.com/bnema/vev/internal/adapters/noticefile" | |
| 37 | 37 | "github.com/bnema/vev/internal/adapters/observability" | |
| 38 | 38 | "github.com/bnema/vev/internal/adapters/pty" | |
| 39 | + | "github.com/bnema/vev/internal/adapters/recoveryfs" | |
| 39 | 40 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 40 | 41 | "github.com/bnema/vev/internal/adapters/shellcmd" | |
| 41 | 42 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |
| @@ -49,6 +50,7 @@ import ( | |||
| 49 | 50 | "github.com/bnema/vev/internal/usecase/client" | |
| 50 | 51 | "github.com/bnema/vev/internal/usecase/confirm" | |
| 51 | 52 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 53 | + | "github.com/bnema/vev/internal/usecase/recovery" | |
| 52 | 54 | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 53 | 55 | "github.com/bnema/vev/pkg/safedir" | |
| 54 | 56 | ) | |
| @@ -507,35 +509,26 @@ func runDaemonOwned(ctx context.Context) (retErr error) { | |||
| 507 | 509 | daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository)) | |
| 508 | 510 | daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir()))) | |
| 509 | 511 | 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 | + | }) | |
| 522 | 517 | 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) | |
| 525 | 519 | } | |
| 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()) | |
| 530 | 523 | } | |
| 531 | 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 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. | |
| 536 | 529 | ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer)) | |
| 537 | 530 | if err != nil { | |
| 538 | - | closeErr := store.Close() | |
| 531 | + | closeErr := opened.Catalogue.Close() | |
| 539 | 532 | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | |
| 540 | 533 | return errors.Join(fmt.Errorf("vev: daemon listen: %w", err), closeErr) | |
| 541 | 534 | } | |
| @@ -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 := false | 512 | + | 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 = true | 515 | + | 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 catalogue | 527 | + | // 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) { | |||
| 121 | 121 | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 122 | 122 | err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned) | |
| 123 | 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 | 125 | _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock")) | |
| 126 | 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 | 130 | runtimeRoot, stateRoot := t.TempDir(), t.TempDir() | |
| 131 | 131 | runtimeDir := filepath.Join(runtimeRoot, "vev") | |
| 132 | 132 | stateDir := filepath.Join(stateRoot, "vev") | |
| 133 | 133 | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 134 | 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) | |
| 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)) | |
| 143 | 149 | }) | |
| 144 | 150 | ||
| 145 | 151 | t.Run("callback and release errors are joined", func(t *testing.T) { | |
| @@ -506,12 +512,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 506 | 512 | if len(records) != 0 { | |
| 507 | 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 | 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 { | |
| 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) | |
| 515 | 521 | } | |
| 516 | 522 | } | |
| 517 | 523 | ||
| @@ -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 { | |||
| 37 | 37 | StateDir string | |
| 38 | 38 | Random io.Reader | |
| 39 | 39 | 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 | 44 | type OpenResult struct { | |
| @@ -227,10 +229,16 @@ func startLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, error | |||
| 227 | 229 | if err := atomicMigrationWrite(backup, source); err != nil { | |
| 228 | 230 | return OpenResult{}, err | |
| 229 | 231 | } | |
| 232 | + | if err := migrationBoundary(deps, "backup-sync"); err != nil { | |
| 233 | + | return OpenResult{}, err | |
| 234 | + | } | |
| 230 | 235 | record := MigrationRecord{Phase: MigrationBackedUp, Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}} | |
| 231 | 236 | if err := writeMigrationRecord(filepath.Join(migrationDir, migrationIntentName), record); err != nil { | |
| 232 | 237 | return OpenResult{}, err | |
| 233 | 238 | } | |
| 239 | + | if err := migrationBoundary(deps, "intent-sync"); err != nil { | |
| 240 | + | return OpenResult{}, err | |
| 241 | + | } | |
| 234 | 242 | return resumeLegacyMigration(ctx, deps) | |
| 235 | 243 | } | |
| 236 | 244 | ||
| @@ -263,6 +271,9 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro | |||
| 263 | 271 | if err := writeMigrationRecord(intent, record); err != nil { | |
| 264 | 272 | return OpenResult{}, err | |
| 265 | 273 | } | |
| 274 | + | if err := migrationBoundary(deps, "identity-sync"); err != nil { | |
| 275 | + | return OpenResult{}, err | |
| 276 | + | } | |
| 266 | 277 | } | |
| 267 | 278 | record.Phase = MigrationAssigned | |
| 268 | 279 | if err := writeMigrationRecord(intent, record); err != nil { | |
| @@ -298,6 +309,9 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro | |||
| 298 | 309 | if err := writeMigrationRecord(intent, record); err != nil { | |
| 299 | 310 | return OpenResult{}, err | |
| 300 | 311 | } | |
| 312 | + | if err := migrationBoundary(deps, "head-validation"); err != nil { | |
| 313 | + | return OpenResult{}, err | |
| 314 | + | } | |
| 301 | 315 | } | |
| 302 | 316 | record.Phase = MigrationValidated | |
| 303 | 317 | if err := writeMigrationRecord(intent, record); err != nil { | |
| @@ -323,10 +337,16 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro | |||
| 323 | 337 | if err := installMigratedCatalogue(deps.StateDir, records); err != nil { | |
| 324 | 338 | return OpenResult{}, err | |
| 325 | 339 | } | |
| 340 | + | if err := migrationBoundary(deps, "catalogue-sync"); err != nil { | |
| 341 | + | return OpenResult{}, err | |
| 342 | + | } | |
| 326 | 343 | record.Phase = MigrationInstalled | |
| 327 | 344 | if err := writeMigrationRecord(intent, record); err != nil { | |
| 328 | 345 | return OpenResult{}, err | |
| 329 | 346 | } | |
| 347 | + | if err := migrationBoundary(deps, "receipt-sync"); err != nil { | |
| 348 | + | return OpenResult{}, err | |
| 349 | + | } | |
| 330 | 350 | } | |
| 331 | 351 | catalogue, opened, err := openCurrentCatalogue(deps.StateDir, false) | |
| 332 | 352 | if err != nil { | |
| @@ -346,10 +366,21 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro | |||
| 346 | 366 | _ = catalogue.Close() | |
| 347 | 367 | 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 | 374 | 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 | 384 | func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord) error { | |
| 354 | 385 | stage := filepath.Join(stateDir, migrationDirName, "install") | |
| 355 | 386 | _ = os.RemoveAll(stage) | |
| @@ -37,6 +37,8 @@ type OpenDeps struct { | |||||
| 37 | StateDir string | 37 | StateDir string | ||
| 38 | Random io.Reader | 38 | Random io.Reader | ||
| 39 | SnapshotMigration ports.SnapshotMigration | 39 | 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{}, err | 230 | 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{}, err | 237 | 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{}, err | 272 | return OpenResult{}, err | ||
| 265 | } | 273 | } | ||
| 274 | + | if err := migrationBoundary(deps, "identity-sync"); err != nil { | |||
| 275 | + | return OpenResult{}, err | |||
| 276 | + | } | |||
| 266 | } | 277 | } | ||
| 267 | record.Phase = MigrationAssigned | 278 | 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{}, err | 310 | return OpenResult{}, err | ||
| 300 | } | 311 | } | ||
| 312 | + | if err := migrationBoundary(deps, "head-validation"); err != nil { | |||
| 313 | + | return OpenResult{}, err | |||
| 314 | + | } | |||
| 301 | } | 315 | } | ||
| 302 | record.Phase = MigrationValidated | 316 | 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{}, err | 338 | return OpenResult{}, err | ||
| 325 | } | 339 | } | ||
| 340 | + | if err := migrationBoundary(deps, "catalogue-sync"); err != nil { | |||
| 341 | + | return OpenResult{}, err | |||
| 342 | + | } | |||
| 326 | record.Phase = MigrationInstalled | 343 | record.Phase = MigrationInstalled | ||
| 327 | if err := writeMigrationRecord(intent, record); err != nil { | 344 | if err := writeMigrationRecord(intent, record); err != nil { | ||
| 328 | return OpenResult{}, err | 345 | 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{}, err | 367 | 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}, nil | 374 | 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) { | |||
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | 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 | + | } | |
| 130 | 151 | } | |
| 131 | 152 | ||
| 132 | 153 | 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) { | |||
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | 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 | 131 | func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error { | |
| 104 | 132 | if p == nil || p.store == nil { | |
| 105 | 133 | 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 nil | 133 | return nil | ||
M internal/usecase/daemon/daemon.go +21 -3
| @@ -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 | 258 | // WithSnapshotRepository enables content-addressed incremental snapshots. The | |
| 251 | 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 | 261 | 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 | |
| 255 | 273 | d.legacySnapshots = legacy | |
| 256 | - | d.snapsEnabled = repository != nil | |
| 274 | + | d.snapsEnabled = true | |
| 257 | 275 | } | |
| 258 | 276 | } | |
| 259 | 277 | ||
| @@ -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. The | 258 | // 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 = 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 | |||
| 255 | d.legacySnapshots = legacy | 273 | d.legacySnapshots = legacy | ||
| 256 | - | d.snapsEnabled = repository != nil | 274 | + | 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 { | |||
| 681 | 681 | if !wasEphemeral && oldName != name { | |
| 682 | 682 | quarantine = quarantineSnapshotCoordinatorWithEpoch(sess) | |
| 683 | 683 | <-quarantine.done | |
| 684 | - | if err := d.beginSnapshotPurge(oldName); err != nil { | |
| 684 | + | if err := d.beginSnapshotPurge(oldName, sess.incarnation); err != nil { | |
| 685 | 685 | return rollback(err) | |
| 686 | 686 | } | |
| 687 | 687 | // finishSnapshotPurge preserves the tombstone through both source | |
| 688 | 688 | // deletions and the old metadata delete, including a retained legacy | |
| 689 | 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 | 691 | return rollback(err) | |
| 692 | 692 | } | |
| 693 | 693 | } | |
| @@ -878,33 +878,28 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||
| 878 | 878 | // beginSnapshotPurge commits a logical named-session kill before its live | |
| 879 | 879 | // identity or persisted metadata is removed. It never runs under daemon or | |
| 880 | 880 | // session locks. | |
| 881 | - | func (d *Daemon) beginSnapshotPurge(name string) error { | |
| 881 | + | func (d *Daemon) beginSnapshotPurge(name string, incarnation domain.IncarnationID) error { | |
| 882 | 882 | if d.snapshotRepository == nil || name == "" { | |
| 883 | 883 | 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 | 888 | // finishSnapshotPurge deletes both independently durable snapshot sources, | |
| 889 | 889 | // then metadata, and only then clears the tombstone. Any failure deliberately | |
| 890 | 890 | // leaves the marker in place so startup cannot restore or import the name and | |
| 891 | 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 | 893 | if d.snapshotRepository == nil { | |
| 894 | 894 | return d.persist.Delete(name) | |
| 895 | 895 | } | |
| 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 | |
| 903 | 898 | } | |
| 904 | 899 | if err := d.persist.Delete(name); err != nil { | |
| 905 | 900 | 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 | 905 | // retryStoppedPurge completes a previously closed session's durable purge. | |
| @@ -933,10 +928,10 @@ func (d *Daemon) retryStoppedPurge(name string) error { | |||
| 933 | 928 | d.stopped[name] = stopped | |
| 934 | 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 | 932 | return err | |
| 938 | 933 | } | |
| 939 | - | if err := d.finishSnapshotPurge(name); err != nil { | |
| 934 | + | if err := d.finishSnapshotPurge(name, stopped.incarnation); err != nil { | |
| 940 | 935 | return err | |
| 941 | 936 | } | |
| 942 | 937 | d.mu.Lock() | |
| @@ -1087,7 +1082,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1087 | 1082 | // the just-quarantined name. Do not hold daemon or session locks while | |
| 1088 | 1083 | // tombstoning or joining: a repository implementation may block on I/O. | |
| 1089 | 1084 | if purge && !isEphemeral { | |
| 1090 | - | if err := d.beginSnapshotPurge(name); err != nil { | |
| 1085 | + | if err := d.beginSnapshotPurge(name, sess.incarnation); err != nil { | |
| 1091 | 1086 | return err | |
| 1092 | 1087 | } | |
| 1093 | 1088 | if d.snapshotRepository != nil { | |
| @@ -1223,7 +1218,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1223 | 1218 | // tombstone and hidden stopped record when any source or metadata operation | |
| 1224 | 1219 | // fails; retryStoppedPurge (including a repeated live kill) resumes safely. | |
| 1225 | 1220 | if !ephemeral && purge { | |
| 1226 | - | if err := d.finishSnapshotPurge(stoppedName); err != nil { | |
| 1221 | + | if err := d.finishSnapshotPurge(stoppedName, sess.incarnation); err != nil { | |
| 1227 | 1222 | purgeErr = errors.Join(purgeErr, err) | |
| 1228 | 1223 | d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName) | |
| 1229 | 1224 | } 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.done | 683 | <-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 source | 687 | // finishSnapshotPurge preserves the tombstone through both source | ||
| 688 | // deletions and the old metadata delete, including a retained legacy | 688 | // 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 live | 878 | // beginSnapshotPurge commits a logical named-session kill before its live | ||
| 879 | // identity or persisted metadata is removed. It never runs under daemon or | 879 | // 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 nil | 883 | 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 deliberately | 889 | // 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 and | 890 | // 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 error | 897 | + | 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 err | 900 | 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] = stopped | 928 | 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 err | 932 | return err | ||
| 938 | } | 933 | } | ||
| 939 | - | if err := d.finishSnapshotPurge(name); err != nil { | 934 | + | if err := d.finishSnapshotPurge(name, stopped.incarnation); err != nil { |
| 940 | return err | 935 | 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 while | 1082 | // 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 err | 1086 | 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 operation | 1218 | // 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 ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | 13 | "github.com/bnema/vev/pkg/vt" | |
| @@ -37,6 +38,7 @@ type snapshotCapture struct { | |||
| 37 | 38 | generation uint64 // repository publication generation | |
| 38 | 39 | mutationRevision uint64 | |
| 39 | 40 | name string | |
| 41 | + | incarnation domain.IncarnationID | |
| 40 | 42 | createdAt uint64 | |
| 41 | 43 | active uint16 | |
| 42 | 44 | 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 generation | 38 | generation uint64 // repository publication generation | ||
| 38 | mutationRevision uint64 | 39 | mutationRevision uint64 | ||
| 39 | name string | 40 | name string | ||
| 41 | + | incarnation domain.IncarnationID | |||
| 40 | createdAt uint64 | 42 | createdAt uint64 | ||
| 41 | active uint16 | 43 | active uint16 | ||
| 42 | tabs []snapshotCaptureTab | 44 | tabs []snapshotCaptureTab | ||
M internal/usecase/daemon/snapshot_capture.go +6 -5
| @@ -8,11 +8,12 @@ import snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 8 | 8 | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |
| 9 | 9 | sess.mu.Lock() | |
| 10 | 10 | 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)), | |
| 16 | 17 | } | |
| 17 | 18 | ephemeral := sess.ephemeral | |
| 18 | 19 | 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.ephemeral | 18 | ephemeral := sess.ephemeral | ||
| 18 | fallbackCwd := sess.cwd | 19 | fallbackCwd := sess.cwd | ||
M internal/usecase/daemon/snapshot_incremental.go +2 -2
| @@ -113,7 +113,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||
| 113 | 113 | if err := prepareSnapshotChunkCache(capture); err != nil { | |
| 114 | 114 | 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 | 117 | objects := make([]ports.SnapshotObject, 0) | |
| 118 | 118 | capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef) | |
| 119 | 119 | for _, tab := range capture.tabs { | |
| @@ -160,7 +160,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||
| 160 | 160 | if err != nil { | |
| 161 | 161 | return ports.SnapshotPublication{}, err | |
| 162 | 162 | } | |
| 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 | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | 166 | // 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{}, err | 114 | 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{}, err | 161 | return ports.SnapshotPublication{}, err | ||
| 162 | } | 162 | } | ||
| 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 |
| 164 | } | 164 | } | ||
| 165 | 165 | ||||
| 166 | // marshalSnapshotTail selects the canonical empty-tail encoding before any | 166 | // marshalSnapshotTail selects the canonical empty-tail encoding before any | ||
M internal/usecase/daemon/snapshot_restore_incremental.go +5 -157
| @@ -1,63 +1,25 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "context" | |
| 6 | 5 | "crypto/sha256" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | ||
| 9 | - | "github.com/bnema/vev/internal/domain" | |
| 10 | 8 | "github.com/bnema/vev/internal/ports" | |
| 11 | 9 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | 10 | ) | |
| 13 | 11 | ||
| 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) {} | |
| 54 | 16 | ||
| 55 | 17 | func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) { | |
| 56 | 18 | manifest, err := snapcodec.UnmarshalManifest(generation.Manifest) | |
| 57 | 19 | if err != nil { | |
| 58 | 20 | 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 | 23 | return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch") | |
| 62 | 24 | } | |
| 63 | 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 | 62 | 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 | - | } | |
| @@ -1,63 +1,25 @@ | |||||
| 1 | package daemon | 1 | 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 repository | 12 | + | // restoreIncrementalSnapshots intentionally performs no repository discovery. |
| 15 | - | // sessions, then restores each independently. A corrupt session therefore | 13 | + | // 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{}, err | 20 | 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...), nil | 62 | 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) { | |||
| 249 | 249 | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |
| 250 | 250 | snapshot := restoreAcceptanceSession(t, "fallback") | |
| 251 | 251 | valid := acceptanceGeneration(t, snapshot, 3) | |
| 252 | - | valid.Fallback = true | |
| 253 | 252 | pty, release := newBlockingPTY(t) | |
| 254 | 253 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 255 | 254 | store, _ := newMockStore(t) | |
| @@ -300,7 +299,6 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |||
| 300 | 299 | } { | |
| 301 | 300 | t.Run(mutate.name, func(t *testing.T) { | |
| 302 | 301 | generation := cloneAcceptanceGeneration(valid) | |
| 303 | - | generation.Fallback = false | |
| 304 | 302 | mutate.fn(&generation) | |
| 305 | 303 | _, err := sessionFromGeneration(generation) | |
| 306 | 304 | 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 | |||
| 95 | 95 | deleteCompleted := make(chan struct{}) | |
| 96 | 96 | state.deleteDone = deleteCompleted | |
| 97 | 97 | 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 { | |
| 100 | 100 | close(deleteEntered) | |
| 101 | 101 | <-allowDelete | |
| 102 | 102 | return nil | |
| 103 | 103 | }).Once() | |
| 104 | - | repository.EXPECT().DeleteTombstone(mock.Anything, "work").Return(nil).Once() | |
| 104 | + | repository.EXPECT().DeleteDeletionTombstone(mock.Anything, mock.Anything).Return(nil).Once() | |
| 105 | 105 | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 106 | 106 | published <- publication | |
| 107 | 107 | return nil | |
| @@ -174,8 +174,8 @@ func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) { | |||
| 174 | 174 | return nil | |
| 175 | 175 | } | |
| 176 | 176 | 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() | |
| 179 | 179 | // No Publish expectation is installed: any publication is an unexpected mock | |
| 180 | 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 | 448 | d := newTestDaemon(t, newFactory(t, pty), clock) | |
| 449 | 449 | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 450 | 450 | WithSnapshotRepository(repository, nil)(d) | |
| 451 | - | repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe() | |
| 452 | 451 | ||
| 453 | 452 | publicationStarted := make(chan struct{}) | |
| 454 | 453 | releasePublication := make(chan struct{}) | |
| @@ -609,7 +608,6 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) { | |||
| 609 | 608 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 610 | 609 | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 611 | 610 | WithSnapshotRepository(repository, nil)(d) | |
| 612 | - | repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe() | |
| 613 | 611 | ||
| 614 | 612 | published := make(chan ports.SnapshotPublication, 2) | |
| 615 | 613 | allowTerminalPublication := make(chan struct{}) | |
| @@ -654,7 +652,6 @@ func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository | |||
| 654 | 652 | d := newTestDaemon(t, newFactory(t, p), clock) | |
| 655 | 653 | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 656 | 654 | WithSnapshotRepository(repository, nil)(d) | |
| 657 | - | repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe() | |
| 658 | 655 | noticeStore := portsmocks.NewMockNoticeStore(t) | |
| 659 | 656 | WithNoticeStore(noticeStore)(d) | |
| 660 | 657 | 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 = deleteCompleted | 96 | 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 | <-allowDelete | 101 | <-allowDelete | ||
| 102 | return nil | 102 | 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 <- publication | 106 | published <- publication | ||
| 107 | return nil | 107 | return nil | ||
| @@ -174,8 +174,8 @@ func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) { | |||||
| 174 | return nil | 174 | 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 mock | 179 | // 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 | + | } | |||