refactor(recovery): reduce recovery to one state and one action
- commit
- cbe62e5c0a6c89d65a6720eaaba00f05ffd44db7
- author
- brice · · 2026-07-26 10:44 UTC
- parent
- 5297bf6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +78 -1153
M internal/domain/recovery.go +6 -28
| @@ -39,7 +39,6 @@ type CatalogueRecord struct { | |||
| 39 | 39 | TabNames []string | |
| 40 | 40 | RecoveryState RecoveryState | |
| 41 | 41 | Committed *CheckpointRef | |
| 42 | - | Fallbacks [2]*CheckpointRef | |
| 43 | 42 | DegradedReason string | |
| 44 | 43 | } | |
| 45 | 44 | ||
| @@ -57,8 +56,7 @@ func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | |||
| 57 | 56 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | |
| 58 | 57 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | |
| 59 | 58 | slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState && | |
| 60 | - | r.Committed.Equal(other.Committed) && r.Fallbacks[0].Equal(other.Fallbacks[0]) && | |
| 61 | - | r.Fallbacks[1].Equal(other.Fallbacks[1]) && r.DegradedReason == other.DegradedReason | |
| 59 | + | r.Committed.Equal(other.Committed) && r.DegradedReason == other.DegradedReason | |
| 62 | 60 | } | |
| 63 | 61 | ||
| 64 | 62 | // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue | |
| @@ -169,8 +167,8 @@ func (r CatalogueRecord) Validate() error { | |||
| 169 | 167 | ||
| 170 | 168 | switch r.RecoveryState { | |
| 171 | 169 | case RecoveryFresh: | |
| 172 | - | if r.Committed != nil || r.Fallbacks[0] != nil || r.Fallbacks[1] != nil { | |
| 173 | - | return errors.New("fresh session has checkpoint references") | |
| 170 | + | if r.Committed != nil { | |
| 171 | + | return errors.New("fresh session has checkpoint reference") | |
| 174 | 172 | } | |
| 175 | 173 | case RecoveryHealthy: | |
| 176 | 174 | if r.Committed == nil { | |
| @@ -192,33 +190,13 @@ func (r CatalogueRecord) Validate() error { | |||
| 192 | 190 | if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" { | |
| 193 | 191 | return errors.New("non-degraded session has degraded reason") | |
| 194 | 192 | } | |
| 195 | - | if r.Fallbacks[1] != nil && r.Fallbacks[0] == nil { | |
| 196 | - | return errors.New("second fallback populated without first fallback") | |
| 197 | - | } | |
| 198 | - | if r.Committed == nil && (r.Fallbacks[0] != nil || r.Fallbacks[1] != nil) { | |
| 199 | - | return errors.New("fallback populated without committed checkpoint") | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | seen := make(map[uint64]struct{}, 3) | |
| 203 | - | var previous uint64 | |
| 204 | - | for i, ref := range []*CheckpointRef{r.Committed, r.Fallbacks[0], r.Fallbacks[1]} { | |
| 205 | - | if ref == nil { | |
| 206 | - | continue | |
| 207 | - | } | |
| 208 | - | if ref.Generation == 0 { | |
| 193 | + | if r.Committed != nil { | |
| 194 | + | if r.Committed.Generation == 0 { | |
| 209 | 195 | return errors.New("zero checkpoint generation") | |
| 210 | 196 | } | |
| 211 | - | if ref.ManifestDigest == ([32]byte{}) { | |
| 197 | + | if r.Committed.ManifestDigest == ([32]byte{}) { | |
| 212 | 198 | return errors.New("zero checkpoint manifest digest") | |
| 213 | 199 | } | |
| 214 | - | if _, ok := seen[ref.Generation]; ok { | |
| 215 | - | return errors.New("duplicate checkpoint generation") | |
| 216 | - | } | |
| 217 | - | seen[ref.Generation] = struct{}{} | |
| 218 | - | if i > 0 && previous <= ref.Generation { | |
| 219 | - | return errors.New("checkpoint generations are not newest to oldest") | |
| 220 | - | } | |
| 221 | - | previous = ref.Generation | |
| 222 | 200 | } | |
| 223 | 201 | return nil | |
| 224 | 202 | } | |
| @@ -39,7 +39,6 @@ type CatalogueRecord struct { | |||||
| 39 | TabNames []string | 39 | TabNames []string | ||
| 40 | RecoveryState RecoveryState | 40 | RecoveryState RecoveryState | ||
| 41 | Committed *CheckpointRef | 41 | Committed *CheckpointRef | ||
| 42 | - | Fallbacks [2]*CheckpointRef | |||
| 43 | DegradedReason string | 42 | DegradedReason string | ||
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| @@ -57,8 +56,7 @@ func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | |||||
| 57 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | 56 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | ||
| 58 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | 57 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | ||
| 59 | slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState && | 58 | slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState && | ||
| 60 | - | r.Committed.Equal(other.Committed) && r.Fallbacks[0].Equal(other.Fallbacks[0]) && | 59 | + | r.Committed.Equal(other.Committed) && r.DegradedReason == other.DegradedReason |
| 61 | - | r.Fallbacks[1].Equal(other.Fallbacks[1]) && r.DegradedReason == other.DegradedReason | |||
| 62 | } | 60 | } | ||
| 63 | 61 | ||||
| 64 | // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue | 62 | // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue | ||
| @@ -169,8 +167,8 @@ func (r CatalogueRecord) Validate() error { | |||||
| 169 | 167 | ||||
| 170 | switch r.RecoveryState { | 168 | switch r.RecoveryState { | ||
| 171 | case RecoveryFresh: | 169 | case RecoveryFresh: | ||
| 172 | - | if r.Committed != nil || r.Fallbacks[0] != nil || r.Fallbacks[1] != nil { | 170 | + | if r.Committed != nil { |
| 173 | - | return errors.New("fresh session has checkpoint references") | 171 | + | return errors.New("fresh session has checkpoint reference") |
| 174 | } | 172 | } | ||
| 175 | case RecoveryHealthy: | 173 | case RecoveryHealthy: | ||
| 176 | if r.Committed == nil { | 174 | if r.Committed == nil { | ||
| @@ -192,33 +190,13 @@ func (r CatalogueRecord) Validate() error { | |||||
| 192 | if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" { | 190 | if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" { | ||
| 193 | return errors.New("non-degraded session has degraded reason") | 191 | return errors.New("non-degraded session has degraded reason") | ||
| 194 | } | 192 | } | ||
| 195 | - | if r.Fallbacks[1] != nil && r.Fallbacks[0] == nil { | 193 | + | if r.Committed != nil { |
| 196 | - | return errors.New("second fallback populated without first fallback") | 194 | + | if r.Committed.Generation == 0 { |
| 197 | - | } | |||
| 198 | - | if r.Committed == nil && (r.Fallbacks[0] != nil || r.Fallbacks[1] != nil) { | |||
| 199 | - | return errors.New("fallback populated without committed checkpoint") | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | seen := make(map[uint64]struct{}, 3) | |||
| 203 | - | var previous uint64 | |||
| 204 | - | for i, ref := range []*CheckpointRef{r.Committed, r.Fallbacks[0], r.Fallbacks[1]} { | |||
| 205 | - | if ref == nil { | |||
| 206 | - | continue | |||
| 207 | - | } | |||
| 208 | - | if ref.Generation == 0 { | |||
| 209 | return errors.New("zero checkpoint generation") | 195 | return errors.New("zero checkpoint generation") | ||
| 210 | } | 196 | } | ||
| 211 | - | if ref.ManifestDigest == ([32]byte{}) { | 197 | + | if r.Committed.ManifestDigest == ([32]byte{}) { |
| 212 | return errors.New("zero checkpoint manifest digest") | 198 | return errors.New("zero checkpoint manifest digest") | ||
| 213 | } | 199 | } | ||
| 214 | - | if _, ok := seen[ref.Generation]; ok { | |||
| 215 | - | return errors.New("duplicate checkpoint generation") | |||
| 216 | - | } | |||
| 217 | - | seen[ref.Generation] = struct{}{} | |||
| 218 | - | if i > 0 && previous <= ref.Generation { | |||
| 219 | - | return errors.New("checkpoint generations are not newest to oldest") | |||
| 220 | - | } | |||
| 221 | - | previous = ref.Generation | |||
| 222 | } | 200 | } | ||
| 223 | return nil | 201 | return nil | ||
| 224 | } | 202 | } | ||
M internal/domain/recovery_test.go +0 -3
| @@ -39,7 +39,6 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||
| 39 | 39 | }{ | |
| 40 | 40 | {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true}, | |
| 41 | 41 | {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true}, | |
| 42 | - | {name: "duplicate generation invalid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}, Fallbacks: [2]*CheckpointRef{{Generation: 7, ManifestDigest: [32]byte{2}}}}}, | |
| 43 | 42 | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}}, | |
| 44 | 43 | {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}}, | |
| 45 | 44 | {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}}, | |
| @@ -50,8 +49,6 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||
| 50 | 49 | {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}}, | |
| 51 | 50 | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | |
| 52 | 51 | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}}, | |
| 53 | - | {name: "second fallback without first", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, Fallbacks: [2]*CheckpointRef{nil, {Generation: 6, ManifestDigest: [32]byte{2}}}}}, | |
| 54 | - | {name: "fallback newer than committed", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 6, ManifestDigest: [32]byte{1}}, Fallbacks: [2]*CheckpointRef{{Generation: 7, ManifestDigest: [32]byte{2}}}}}, | |
| 55 | 52 | } | |
| 56 | 53 | for _, tt := range tests { | |
| 57 | 54 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -39,7 +39,6 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||||
| 39 | }{ | 39 | }{ | ||
| 40 | {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true}, | 40 | {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true}, | ||
| 41 | {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true}, | 41 | {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true}, | ||
| 42 | - | {name: "duplicate generation invalid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}, Fallbacks: [2]*CheckpointRef{{Generation: 7, ManifestDigest: [32]byte{2}}}}}, | |||
| 43 | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}}, | 42 | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}}, | ||
| 44 | {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}}, | 43 | {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}}, | ||
| 45 | {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}}, | 44 | {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}}, | ||
| @@ -50,8 +49,6 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||||
| 50 | {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}}, | 49 | {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}}, | ||
| 51 | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | 50 | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | ||
| 52 | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}}, | 51 | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}}, | ||
| 53 | - | {name: "second fallback without first", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, Fallbacks: [2]*CheckpointRef{nil, {Generation: 6, ManifestDigest: [32]byte{2}}}}}, | |||
| 54 | - | {name: "fallback newer than committed", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 6, ManifestDigest: [32]byte{1}}, Fallbacks: [2]*CheckpointRef{{Generation: 7, ManifestDigest: [32]byte{2}}}}}, | |||
| 55 | } | 52 | } | ||
| 56 | for _, tt := range tests { | 53 | for _, tt := range tests { | ||
| 57 | t.Run(tt.name, func(t *testing.T) { | 54 | t.Run(tt.name, func(t *testing.T) { | ||
M internal/persist/catalogue_test.go +2 -8
| @@ -24,13 +24,10 @@ func TestCatalogue(t *testing.T) { | |||
| 24 | 24 | ||
| 25 | 25 | func testCatalogueRecordRoundTrip(t *testing.T) { | |
| 26 | 26 | ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | |
| 27 | - | ref2 := &domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{2}} | |
| 28 | - | ref3 := &domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{3}} | |
| 29 | 27 | tests := []domain.CatalogueRecord{ | |
| 30 | 28 | {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh}, | |
| 31 | 29 | {Name: "healthy", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy, Committed: ref1}, | |
| 32 | - | {Name: "healthy-one-fallback", IncarnationID: domain.IncarnationID{3}, RecoveryState: domain.RecoveryHealthy, Committed: ref1, Fallbacks: [2]*domain.CheckpointRef{ref2}}, | |
| 33 | - | {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, Fallbacks: [2]*domain.CheckpointRef{ref2, ref3}, DegradedReason: "invalid manifest"}, | |
| 30 | + | {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, DegradedReason: "invalid manifest"}, | |
| 34 | 31 | {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting}, | |
| 35 | 32 | } | |
| 36 | 33 | for _, record := range tests { | |
| @@ -129,13 +126,10 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) { | |||
| 129 | 126 | defer func() { require.NoError(t, p.Close()) }() | |
| 130 | 127 | ||
| 131 | 128 | committed := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | |
| 132 | - | fallback1 := &domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{2}} | |
| 133 | - | fallback2 := &domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{3}} | |
| 134 | 129 | original := domain.CatalogueRecord{ | |
| 135 | 130 | Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11, | |
| 136 | 131 | UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"}, | |
| 137 | - | RecoveryState: domain.RecoveryDegraded, Committed: committed, | |
| 138 | - | Fallbacks: [2]*domain.CheckpointRef{fallback1, fallback2}, DegradedReason: "repair pending", | |
| 132 | + | RecoveryState: domain.RecoveryDegraded, Committed: committed, DegradedReason: "repair pending", | |
| 139 | 133 | } | |
| 140 | 134 | require.NoError(t, p.Create(original)) | |
| 141 | 135 | ||
| @@ -24,13 +24,10 @@ func TestCatalogue(t *testing.T) { | |||||
| 24 | 24 | ||||
| 25 | func testCatalogueRecordRoundTrip(t *testing.T) { | 25 | func testCatalogueRecordRoundTrip(t *testing.T) { | ||
| 26 | ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | 26 | ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | ||
| 27 | - | ref2 := &domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{2}} | |||
| 28 | - | ref3 := &domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{3}} | |||
| 29 | tests := []domain.CatalogueRecord{ | 27 | tests := []domain.CatalogueRecord{ | ||
| 30 | {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh}, | 28 | {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh}, | ||
| 31 | {Name: "healthy", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy, Committed: ref1}, | 29 | {Name: "healthy", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy, Committed: ref1}, | ||
| 32 | - | {Name: "healthy-one-fallback", IncarnationID: domain.IncarnationID{3}, RecoveryState: domain.RecoveryHealthy, Committed: ref1, Fallbacks: [2]*domain.CheckpointRef{ref2}}, | 30 | + | {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, DegradedReason: "invalid manifest"}, |
| 33 | - | {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, Fallbacks: [2]*domain.CheckpointRef{ref2, ref3}, DegradedReason: "invalid manifest"}, | |||
| 34 | {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting}, | 31 | {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting}, | ||
| 35 | } | 32 | } | ||
| 36 | for _, record := range tests { | 33 | for _, record := range tests { | ||
| @@ -129,13 +126,10 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) { | |||||
| 129 | defer func() { require.NoError(t, p.Close()) }() | 126 | defer func() { require.NoError(t, p.Close()) }() | ||
| 130 | 127 | ||||
| 131 | committed := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | 128 | committed := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | ||
| 132 | - | fallback1 := &domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{2}} | |||
| 133 | - | fallback2 := &domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{3}} | |||
| 134 | original := domain.CatalogueRecord{ | 129 | original := domain.CatalogueRecord{ | ||
| 135 | Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11, | 130 | Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11, | ||
| 136 | UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"}, | 131 | UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"}, | ||
| 137 | - | RecoveryState: domain.RecoveryDegraded, Committed: committed, | 132 | + | RecoveryState: domain.RecoveryDegraded, Committed: committed, DegradedReason: "repair pending", |
| 138 | - | Fallbacks: [2]*domain.CheckpointRef{fallback1, fallback2}, DegradedReason: "repair pending", | |||
| 139 | } | 133 | } | ||
| 140 | require.NoError(t, p.Create(original)) | 134 | require.NoError(t, p.Create(original)) | ||
| 141 | 135 | ||||
M internal/persist/record.go +1 -9
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | const catalogueRecordVersion uint16 = 1 | |
| 12 | + | const catalogueRecordVersion uint16 = 2 | |
| 13 | 13 | ||
| 14 | 14 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | |
| 15 | 15 | ||
| @@ -46,8 +46,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) { | |||
| 46 | 46 | } | |
| 47 | 47 | buf = append(buf, byte(record.RecoveryState)) | |
| 48 | 48 | buf = appendRef(buf, record.Committed) | |
| 49 | - | buf = appendRef(buf, record.Fallbacks[0]) | |
| 50 | - | buf = appendRef(buf, record.Fallbacks[1]) | |
| 51 | 49 | buf, err = appendCheckedString(buf, record.DegradedReason) | |
| 52 | 50 | return buf, err | |
| 53 | 51 | } | |
| @@ -110,12 +108,6 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||
| 110 | 108 | if record.Committed, ok = reader.ref(); !ok { | |
| 111 | 109 | return domain.CatalogueRecord{}, errMalformedRecord | |
| 112 | 110 | } | |
| 113 | - | if record.Fallbacks[0], ok = reader.ref(); !ok { | |
| 114 | - | return domain.CatalogueRecord{}, errMalformedRecord | |
| 115 | - | } | |
| 116 | - | if record.Fallbacks[1], ok = reader.ref(); !ok { | |
| 117 | - | return domain.CatalogueRecord{}, errMalformedRecord | |
| 118 | - | } | |
| 119 | 111 | if record.DegradedReason, ok = reader.str(); !ok || reader.remaining() != 0 { | |
| 120 | 112 | return domain.CatalogueRecord{}, errMalformedRecord | |
| 121 | 113 | } | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | const catalogueRecordVersion uint16 = 1 | 12 | + | const catalogueRecordVersion uint16 = 2 |
| 13 | 13 | ||||
| 14 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | 14 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | ||
| 15 | 15 | ||||
| @@ -46,8 +46,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) { | |||||
| 46 | } | 46 | } | ||
| 47 | buf = append(buf, byte(record.RecoveryState)) | 47 | buf = append(buf, byte(record.RecoveryState)) | ||
| 48 | buf = appendRef(buf, record.Committed) | 48 | buf = appendRef(buf, record.Committed) | ||
| 49 | - | buf = appendRef(buf, record.Fallbacks[0]) | |||
| 50 | - | buf = appendRef(buf, record.Fallbacks[1]) | |||
| 51 | buf, err = appendCheckedString(buf, record.DegradedReason) | 49 | buf, err = appendCheckedString(buf, record.DegradedReason) | ||
| 52 | return buf, err | 50 | return buf, err | ||
| 53 | } | 51 | } | ||
| @@ -110,12 +108,6 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||||
| 110 | if record.Committed, ok = reader.ref(); !ok { | 108 | if record.Committed, ok = reader.ref(); !ok { | ||
| 111 | return domain.CatalogueRecord{}, errMalformedRecord | 109 | return domain.CatalogueRecord{}, errMalformedRecord | ||
| 112 | } | 110 | } | ||
| 113 | - | if record.Fallbacks[0], ok = reader.ref(); !ok { | |||
| 114 | - | return domain.CatalogueRecord{}, errMalformedRecord | |||
| 115 | - | } | |||
| 116 | - | if record.Fallbacks[1], ok = reader.ref(); !ok { | |||
| 117 | - | return domain.CatalogueRecord{}, errMalformedRecord | |||
| 118 | - | } | |||
| 119 | if record.DegradedReason, ok = reader.str(); !ok || reader.remaining() != 0 { | 111 | if record.DegradedReason, ok = reader.str(); !ok || reader.remaining() != 0 { | ||
| 120 | return domain.CatalogueRecord{}, errMalformedRecord | 112 | return domain.CatalogueRecord{}, errMalformedRecord | ||
| 121 | } | 113 | } | ||
M internal/ports/mocks/recovery_mock.go +0 -328
| @@ -6,7 +6,6 @@ package portsmocks | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | - | "io" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/vev/internal/domain" | |
| 12 | 11 | "github.com/bnema/vev/internal/ports" | |
| @@ -243,78 +242,6 @@ func (_m *MockCheckpointCoordinator) EXPECT() *MockCheckpointCoordinator_Expecte | |||
| 243 | 242 | return &MockCheckpointCoordinator_Expecter{mock: &_m.Mock} | |
| 244 | 243 | } | |
| 245 | 244 | ||
| 246 | - | // PromoteFallback provides a mock function for the type MockCheckpointCoordinator | |
| 247 | - | func (_mock *MockCheckpointCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |
| 248 | - | ret := _mock.Called(context1, s, checkpointRef) | |
| 249 | - | ||
| 250 | - | if len(ret) == 0 { | |
| 251 | - | panic("no return value specified for PromoteFallback") | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | var r0 ports.FallbackPromotionOutcome | |
| 255 | - | var r1 error | |
| 256 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)); ok { | |
| 257 | - | return returnFunc(context1, s, checkpointRef) | |
| 258 | - | } | |
| 259 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) ports.FallbackPromotionOutcome); ok { | |
| 260 | - | r0 = returnFunc(context1, s, checkpointRef) | |
| 261 | - | } else { | |
| 262 | - | r0 = ret.Get(0).(ports.FallbackPromotionOutcome) | |
| 263 | - | } | |
| 264 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.CheckpointRef) error); ok { | |
| 265 | - | r1 = returnFunc(context1, s, checkpointRef) | |
| 266 | - | } else { | |
| 267 | - | r1 = ret.Error(1) | |
| 268 | - | } | |
| 269 | - | return r0, r1 | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // MockCheckpointCoordinator_PromoteFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PromoteFallback' | |
| 273 | - | type MockCheckpointCoordinator_PromoteFallback_Call struct { | |
| 274 | - | *mock.Call | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | // PromoteFallback is a helper method to define mock.On call | |
| 278 | - | // - context1 context.Context | |
| 279 | - | // - s string | |
| 280 | - | // - checkpointRef domain.CheckpointRef | |
| 281 | - | func (_e *MockCheckpointCoordinator_Expecter) PromoteFallback(context1 any, s any, checkpointRef any) *MockCheckpointCoordinator_PromoteFallback_Call { | |
| 282 | - | return &MockCheckpointCoordinator_PromoteFallback_Call{Call: _e.mock.On("PromoteFallback", context1, s, checkpointRef)} | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Run(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef)) *MockCheckpointCoordinator_PromoteFallback_Call { | |
| 286 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 287 | - | var arg0 context.Context | |
| 288 | - | if args[0] != nil { | |
| 289 | - | arg0 = args[0].(context.Context) | |
| 290 | - | } | |
| 291 | - | var arg1 string | |
| 292 | - | if args[1] != nil { | |
| 293 | - | arg1 = args[1].(string) | |
| 294 | - | } | |
| 295 | - | var arg2 domain.CheckpointRef | |
| 296 | - | if args[2] != nil { | |
| 297 | - | arg2 = args[2].(domain.CheckpointRef) | |
| 298 | - | } | |
| 299 | - | run( | |
| 300 | - | arg0, | |
| 301 | - | arg1, | |
| 302 | - | arg2, | |
| 303 | - | ) | |
| 304 | - | }) | |
| 305 | - | return _c | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Return(fallbackPromotionOutcome ports.FallbackPromotionOutcome, err error) *MockCheckpointCoordinator_PromoteFallback_Call { | |
| 309 | - | _c.Call.Return(fallbackPromotionOutcome, err) | |
| 310 | - | return _c | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | func (_c *MockCheckpointCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)) *MockCheckpointCoordinator_PromoteFallback_Call { | |
| 314 | - | _c.Call.Return(run) | |
| 315 | - | return _c | |
| 316 | - | } | |
| 317 | - | ||
| 318 | 245 | // PublishCheckpoint provides a mock function for the type MockCheckpointCoordinator | |
| 319 | 246 | func (_mock *MockCheckpointCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) { | |
| 320 | 247 | ret := _mock.Called(context1, s, snapshotPublication) | |
| @@ -537,78 +464,6 @@ func (_c *MockSessionLifecycleCoordinator_Delete_Call) RunAndReturn(run func(con | |||
| 537 | 464 | return _c | |
| 538 | 465 | } | |
| 539 | 466 | ||
| 540 | - | // PromoteFallback provides a mock function for the type MockSessionLifecycleCoordinator | |
| 541 | - | func (_mock *MockSessionLifecycleCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |
| 542 | - | ret := _mock.Called(context1, s, checkpointRef) | |
| 543 | - | ||
| 544 | - | if len(ret) == 0 { | |
| 545 | - | panic("no return value specified for PromoteFallback") | |
| 546 | - | } | |
| 547 | - | ||
| 548 | - | var r0 ports.FallbackPromotionOutcome | |
| 549 | - | var r1 error | |
| 550 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)); ok { | |
| 551 | - | return returnFunc(context1, s, checkpointRef) | |
| 552 | - | } | |
| 553 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) ports.FallbackPromotionOutcome); ok { | |
| 554 | - | r0 = returnFunc(context1, s, checkpointRef) | |
| 555 | - | } else { | |
| 556 | - | r0 = ret.Get(0).(ports.FallbackPromotionOutcome) | |
| 557 | - | } | |
| 558 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.CheckpointRef) error); ok { | |
| 559 | - | r1 = returnFunc(context1, s, checkpointRef) | |
| 560 | - | } else { | |
| 561 | - | r1 = ret.Error(1) | |
| 562 | - | } | |
| 563 | - | return r0, r1 | |
| 564 | - | } | |
| 565 | - | ||
| 566 | - | // MockSessionLifecycleCoordinator_PromoteFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PromoteFallback' | |
| 567 | - | type MockSessionLifecycleCoordinator_PromoteFallback_Call struct { | |
| 568 | - | *mock.Call | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | // PromoteFallback is a helper method to define mock.On call | |
| 572 | - | // - context1 context.Context | |
| 573 | - | // - s string | |
| 574 | - | // - checkpointRef domain.CheckpointRef | |
| 575 | - | func (_e *MockSessionLifecycleCoordinator_Expecter) PromoteFallback(context1 any, s any, checkpointRef any) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |
| 576 | - | return &MockSessionLifecycleCoordinator_PromoteFallback_Call{Call: _e.mock.On("PromoteFallback", context1, s, checkpointRef)} | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | func (_c *MockSessionLifecycleCoordinator_PromoteFallback_Call) Run(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef)) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |
| 580 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 581 | - | var arg0 context.Context | |
| 582 | - | if args[0] != nil { | |
| 583 | - | arg0 = args[0].(context.Context) | |
| 584 | - | } | |
| 585 | - | var arg1 string | |
| 586 | - | if args[1] != nil { | |
| 587 | - | arg1 = args[1].(string) | |
| 588 | - | } | |
| 589 | - | var arg2 domain.CheckpointRef | |
| 590 | - | if args[2] != nil { | |
| 591 | - | arg2 = args[2].(domain.CheckpointRef) | |
| 592 | - | } | |
| 593 | - | run( | |
| 594 | - | arg0, | |
| 595 | - | arg1, | |
| 596 | - | arg2, | |
| 597 | - | ) | |
| 598 | - | }) | |
| 599 | - | return _c | |
| 600 | - | } | |
| 601 | - | ||
| 602 | - | func (_c *MockSessionLifecycleCoordinator_PromoteFallback_Call) Return(fallbackPromotionOutcome ports.FallbackPromotionOutcome, err error) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |
| 603 | - | _c.Call.Return(fallbackPromotionOutcome, err) | |
| 604 | - | return _c | |
| 605 | - | } | |
| 606 | - | ||
| 607 | - | func (_c *MockSessionLifecycleCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |
| 608 | - | _c.Call.Return(run) | |
| 609 | - | return _c | |
| 610 | - | } | |
| 611 | - | ||
| 612 | 467 | // PublishCheckpoint provides a mock function for the type MockSessionLifecycleCoordinator | |
| 613 | 468 | func (_mock *MockSessionLifecycleCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) { | |
| 614 | 469 | ret := _mock.Called(context1, s, snapshotPublication) | |
| @@ -852,189 +707,6 @@ func (_c *MockDegradedRecoveryCoordinator_Discard_Call) RunAndReturn(run func(co | |||
| 852 | 707 | return _c | |
| 853 | 708 | } | |
| 854 | 709 | ||
| 855 | - | // Export provides a mock function for the type MockDegradedRecoveryCoordinator | |
| 856 | - | func (_mock *MockDegradedRecoveryCoordinator) Export(context1 context.Context, s string, writer io.Writer) error { | |
| 857 | - | ret := _mock.Called(context1, s, writer) | |
| 858 | - | ||
| 859 | - | if len(ret) == 0 { | |
| 860 | - | panic("no return value specified for Export") | |
| 861 | - | } | |
| 862 | - | ||
| 863 | - | var r0 error | |
| 864 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Writer) error); ok { | |
| 865 | - | r0 = returnFunc(context1, s, writer) | |
| 866 | - | } else { | |
| 867 | - | r0 = ret.Error(0) | |
| 868 | - | } | |
| 869 | - | return r0 | |
| 870 | - | } | |
| 871 | - | ||
| 872 | - | // MockDegradedRecoveryCoordinator_Export_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Export' | |
| 873 | - | type MockDegradedRecoveryCoordinator_Export_Call struct { | |
| 874 | - | *mock.Call | |
| 875 | - | } | |
| 876 | - | ||
| 877 | - | // Export is a helper method to define mock.On call | |
| 878 | - | // - context1 context.Context | |
| 879 | - | // - s string | |
| 880 | - | // - writer io.Writer | |
| 881 | - | func (_e *MockDegradedRecoveryCoordinator_Expecter) Export(context1 any, s any, writer any) *MockDegradedRecoveryCoordinator_Export_Call { | |
| 882 | - | return &MockDegradedRecoveryCoordinator_Export_Call{Call: _e.mock.On("Export", context1, s, writer)} | |
| 883 | - | } | |
| 884 | - | ||
| 885 | - | func (_c *MockDegradedRecoveryCoordinator_Export_Call) Run(run func(context1 context.Context, s string, writer io.Writer)) *MockDegradedRecoveryCoordinator_Export_Call { | |
| 886 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 887 | - | var arg0 context.Context | |
| 888 | - | if args[0] != nil { | |
| 889 | - | arg0 = args[0].(context.Context) | |
| 890 | - | } | |
| 891 | - | var arg1 string | |
| 892 | - | if args[1] != nil { | |
| 893 | - | arg1 = args[1].(string) | |
| 894 | - | } | |
| 895 | - | var arg2 io.Writer | |
| 896 | - | if args[2] != nil { | |
| 897 | - | arg2 = args[2].(io.Writer) | |
| 898 | - | } | |
| 899 | - | run( | |
| 900 | - | arg0, | |
| 901 | - | arg1, | |
| 902 | - | arg2, | |
| 903 | - | ) | |
| 904 | - | }) | |
| 905 | - | return _c | |
| 906 | - | } | |
| 907 | - | ||
| 908 | - | func (_c *MockDegradedRecoveryCoordinator_Export_Call) Return(err error) *MockDegradedRecoveryCoordinator_Export_Call { | |
| 909 | - | _c.Call.Return(err) | |
| 910 | - | return _c | |
| 911 | - | } | |
| 912 | - | ||
| 913 | - | func (_c *MockDegradedRecoveryCoordinator_Export_Call) RunAndReturn(run func(context1 context.Context, s string, writer io.Writer) error) *MockDegradedRecoveryCoordinator_Export_Call { | |
| 914 | - | _c.Call.Return(run) | |
| 915 | - | return _c | |
| 916 | - | } | |
| 917 | - | ||
| 918 | - | // RestoreFallback provides a mock function for the type MockDegradedRecoveryCoordinator | |
| 919 | - | func (_mock *MockDegradedRecoveryCoordinator) RestoreFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) error { | |
| 920 | - | ret := _mock.Called(context1, s, checkpointRef) | |
| 921 | - | ||
| 922 | - | if len(ret) == 0 { | |
| 923 | - | panic("no return value specified for RestoreFallback") | |
| 924 | - | } | |
| 925 | - | ||
| 926 | - | var r0 error | |
| 927 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) error); ok { | |
| 928 | - | r0 = returnFunc(context1, s, checkpointRef) | |
| 929 | - | } else { | |
| 930 | - | r0 = ret.Error(0) | |
| 931 | - | } | |
| 932 | - | return r0 | |
| 933 | - | } | |
| 934 | - | ||
| 935 | - | // MockDegradedRecoveryCoordinator_RestoreFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreFallback' | |
| 936 | - | type MockDegradedRecoveryCoordinator_RestoreFallback_Call struct { | |
| 937 | - | *mock.Call | |
| 938 | - | } | |
| 939 | - | ||
| 940 | - | // RestoreFallback is a helper method to define mock.On call | |
| 941 | - | // - context1 context.Context | |
| 942 | - | // - s string | |
| 943 | - | // - checkpointRef domain.CheckpointRef | |
| 944 | - | func (_e *MockDegradedRecoveryCoordinator_Expecter) RestoreFallback(context1 any, s any, checkpointRef any) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |
| 945 | - | return &MockDegradedRecoveryCoordinator_RestoreFallback_Call{Call: _e.mock.On("RestoreFallback", context1, s, checkpointRef)} | |
| 946 | - | } | |
| 947 | - | ||
| 948 | - | func (_c *MockDegradedRecoveryCoordinator_RestoreFallback_Call) Run(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef)) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |
| 949 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 950 | - | var arg0 context.Context | |
| 951 | - | if args[0] != nil { | |
| 952 | - | arg0 = args[0].(context.Context) | |
| 953 | - | } | |
| 954 | - | var arg1 string | |
| 955 | - | if args[1] != nil { | |
| 956 | - | arg1 = args[1].(string) | |
| 957 | - | } | |
| 958 | - | var arg2 domain.CheckpointRef | |
| 959 | - | if args[2] != nil { | |
| 960 | - | arg2 = args[2].(domain.CheckpointRef) | |
| 961 | - | } | |
| 962 | - | run( | |
| 963 | - | arg0, | |
| 964 | - | arg1, | |
| 965 | - | arg2, | |
| 966 | - | ) | |
| 967 | - | }) | |
| 968 | - | return _c | |
| 969 | - | } | |
| 970 | - | ||
| 971 | - | func (_c *MockDegradedRecoveryCoordinator_RestoreFallback_Call) Return(err error) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |
| 972 | - | _c.Call.Return(err) | |
| 973 | - | return _c | |
| 974 | - | } | |
| 975 | - | ||
| 976 | - | func (_c *MockDegradedRecoveryCoordinator_RestoreFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) error) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |
| 977 | - | _c.Call.Return(run) | |
| 978 | - | return _c | |
| 979 | - | } | |
| 980 | - | ||
| 981 | - | // Retry provides a mock function for the type MockDegradedRecoveryCoordinator | |
| 982 | - | func (_mock *MockDegradedRecoveryCoordinator) Retry(context1 context.Context, s string) error { | |
| 983 | - | ret := _mock.Called(context1, s) | |
| 984 | - | ||
| 985 | - | if len(ret) == 0 { | |
| 986 | - | panic("no return value specified for Retry") | |
| 987 | - | } | |
| 988 | - | ||
| 989 | - | var r0 error | |
| 990 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 991 | - | r0 = returnFunc(context1, s) | |
| 992 | - | } else { | |
| 993 | - | r0 = ret.Error(0) | |
| 994 | - | } | |
| 995 | - | return r0 | |
| 996 | - | } | |
| 997 | - | ||
| 998 | - | // MockDegradedRecoveryCoordinator_Retry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retry' | |
| 999 | - | type MockDegradedRecoveryCoordinator_Retry_Call struct { | |
| 1000 | - | *mock.Call | |
| 1001 | - | } | |
| 1002 | - | ||
| 1003 | - | // Retry is a helper method to define mock.On call | |
| 1004 | - | // - context1 context.Context | |
| 1005 | - | // - s string | |
| 1006 | - | func (_e *MockDegradedRecoveryCoordinator_Expecter) Retry(context1 any, s any) *MockDegradedRecoveryCoordinator_Retry_Call { | |
| 1007 | - | return &MockDegradedRecoveryCoordinator_Retry_Call{Call: _e.mock.On("Retry", context1, s)} | |
| 1008 | - | } | |
| 1009 | - | ||
| 1010 | - | func (_c *MockDegradedRecoveryCoordinator_Retry_Call) Run(run func(context1 context.Context, s string)) *MockDegradedRecoveryCoordinator_Retry_Call { | |
| 1011 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1012 | - | var arg0 context.Context | |
| 1013 | - | if args[0] != nil { | |
| 1014 | - | arg0 = args[0].(context.Context) | |
| 1015 | - | } | |
| 1016 | - | var arg1 string | |
| 1017 | - | if args[1] != nil { | |
| 1018 | - | arg1 = args[1].(string) | |
| 1019 | - | } | |
| 1020 | - | run( | |
| 1021 | - | arg0, | |
| 1022 | - | arg1, | |
| 1023 | - | ) | |
| 1024 | - | }) | |
| 1025 | - | return _c | |
| 1026 | - | } | |
| 1027 | - | ||
| 1028 | - | func (_c *MockDegradedRecoveryCoordinator_Retry_Call) Return(err error) *MockDegradedRecoveryCoordinator_Retry_Call { | |
| 1029 | - | _c.Call.Return(err) | |
| 1030 | - | return _c | |
| 1031 | - | } | |
| 1032 | - | ||
| 1033 | - | func (_c *MockDegradedRecoveryCoordinator_Retry_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockDegradedRecoveryCoordinator_Retry_Call { | |
| 1034 | - | _c.Call.Return(run) | |
| 1035 | - | return _c | |
| 1036 | - | } | |
| 1037 | - | ||
| 1038 | 710 | // NewMockCatalogue creates a new instance of MockCatalogue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1039 | 711 | // The first argument is typically a *testing.T value. | |
| 1040 | 712 | func NewMockCatalogue(t interface { | |
| @@ -6,7 +6,6 @@ package portsmocks | |||||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | - | "io" | |||
| 10 | 9 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| @@ -243,78 +242,6 @@ func (_m *MockCheckpointCoordinator) EXPECT() *MockCheckpointCoordinator_Expecte | |||||
| 243 | return &MockCheckpointCoordinator_Expecter{mock: &_m.Mock} | 242 | return &MockCheckpointCoordinator_Expecter{mock: &_m.Mock} | ||
| 244 | } | 243 | } | ||
| 245 | 244 | ||||
| 246 | - | // PromoteFallback provides a mock function for the type MockCheckpointCoordinator | |||
| 247 | - | func (_mock *MockCheckpointCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |||
| 248 | - | ret := _mock.Called(context1, s, checkpointRef) | |||
| 249 | - | ||||
| 250 | - | if len(ret) == 0 { | |||
| 251 | - | panic("no return value specified for PromoteFallback") | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | var r0 ports.FallbackPromotionOutcome | |||
| 255 | - | var r1 error | |||
| 256 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)); ok { | |||
| 257 | - | return returnFunc(context1, s, checkpointRef) | |||
| 258 | - | } | |||
| 259 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) ports.FallbackPromotionOutcome); ok { | |||
| 260 | - | r0 = returnFunc(context1, s, checkpointRef) | |||
| 261 | - | } else { | |||
| 262 | - | r0 = ret.Get(0).(ports.FallbackPromotionOutcome) | |||
| 263 | - | } | |||
| 264 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.CheckpointRef) error); ok { | |||
| 265 | - | r1 = returnFunc(context1, s, checkpointRef) | |||
| 266 | - | } else { | |||
| 267 | - | r1 = ret.Error(1) | |||
| 268 | - | } | |||
| 269 | - | return r0, r1 | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | // MockCheckpointCoordinator_PromoteFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PromoteFallback' | |||
| 273 | - | type MockCheckpointCoordinator_PromoteFallback_Call struct { | |||
| 274 | - | *mock.Call | |||
| 275 | - | } | |||
| 276 | - | ||||
| 277 | - | // PromoteFallback is a helper method to define mock.On call | |||
| 278 | - | // - context1 context.Context | |||
| 279 | - | // - s string | |||
| 280 | - | // - checkpointRef domain.CheckpointRef | |||
| 281 | - | func (_e *MockCheckpointCoordinator_Expecter) PromoteFallback(context1 any, s any, checkpointRef any) *MockCheckpointCoordinator_PromoteFallback_Call { | |||
| 282 | - | return &MockCheckpointCoordinator_PromoteFallback_Call{Call: _e.mock.On("PromoteFallback", context1, s, checkpointRef)} | |||
| 283 | - | } | |||
| 284 | - | ||||
| 285 | - | func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Run(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef)) *MockCheckpointCoordinator_PromoteFallback_Call { | |||
| 286 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 287 | - | var arg0 context.Context | |||
| 288 | - | if args[0] != nil { | |||
| 289 | - | arg0 = args[0].(context.Context) | |||
| 290 | - | } | |||
| 291 | - | var arg1 string | |||
| 292 | - | if args[1] != nil { | |||
| 293 | - | arg1 = args[1].(string) | |||
| 294 | - | } | |||
| 295 | - | var arg2 domain.CheckpointRef | |||
| 296 | - | if args[2] != nil { | |||
| 297 | - | arg2 = args[2].(domain.CheckpointRef) | |||
| 298 | - | } | |||
| 299 | - | run( | |||
| 300 | - | arg0, | |||
| 301 | - | arg1, | |||
| 302 | - | arg2, | |||
| 303 | - | ) | |||
| 304 | - | }) | |||
| 305 | - | return _c | |||
| 306 | - | } | |||
| 307 | - | ||||
| 308 | - | func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Return(fallbackPromotionOutcome ports.FallbackPromotionOutcome, err error) *MockCheckpointCoordinator_PromoteFallback_Call { | |||
| 309 | - | _c.Call.Return(fallbackPromotionOutcome, err) | |||
| 310 | - | return _c | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func (_c *MockCheckpointCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)) *MockCheckpointCoordinator_PromoteFallback_Call { | |||
| 314 | - | _c.Call.Return(run) | |||
| 315 | - | return _c | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | // PublishCheckpoint provides a mock function for the type MockCheckpointCoordinator | 245 | // PublishCheckpoint provides a mock function for the type MockCheckpointCoordinator | ||
| 319 | func (_mock *MockCheckpointCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) { | 246 | func (_mock *MockCheckpointCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) { | ||
| 320 | ret := _mock.Called(context1, s, snapshotPublication) | 247 | ret := _mock.Called(context1, s, snapshotPublication) | ||
| @@ -537,78 +464,6 @@ func (_c *MockSessionLifecycleCoordinator_Delete_Call) RunAndReturn(run func(con | |||||
| 537 | return _c | 464 | return _c | ||
| 538 | } | 465 | } | ||
| 539 | 466 | ||||
| 540 | - | // PromoteFallback provides a mock function for the type MockSessionLifecycleCoordinator | |||
| 541 | - | func (_mock *MockSessionLifecycleCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |||
| 542 | - | ret := _mock.Called(context1, s, checkpointRef) | |||
| 543 | - | ||||
| 544 | - | if len(ret) == 0 { | |||
| 545 | - | panic("no return value specified for PromoteFallback") | |||
| 546 | - | } | |||
| 547 | - | ||||
| 548 | - | var r0 ports.FallbackPromotionOutcome | |||
| 549 | - | var r1 error | |||
| 550 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)); ok { | |||
| 551 | - | return returnFunc(context1, s, checkpointRef) | |||
| 552 | - | } | |||
| 553 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) ports.FallbackPromotionOutcome); ok { | |||
| 554 | - | r0 = returnFunc(context1, s, checkpointRef) | |||
| 555 | - | } else { | |||
| 556 | - | r0 = ret.Get(0).(ports.FallbackPromotionOutcome) | |||
| 557 | - | } | |||
| 558 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.CheckpointRef) error); ok { | |||
| 559 | - | r1 = returnFunc(context1, s, checkpointRef) | |||
| 560 | - | } else { | |||
| 561 | - | r1 = ret.Error(1) | |||
| 562 | - | } | |||
| 563 | - | return r0, r1 | |||
| 564 | - | } | |||
| 565 | - | ||||
| 566 | - | // MockSessionLifecycleCoordinator_PromoteFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PromoteFallback' | |||
| 567 | - | type MockSessionLifecycleCoordinator_PromoteFallback_Call struct { | |||
| 568 | - | *mock.Call | |||
| 569 | - | } | |||
| 570 | - | ||||
| 571 | - | // PromoteFallback is a helper method to define mock.On call | |||
| 572 | - | // - context1 context.Context | |||
| 573 | - | // - s string | |||
| 574 | - | // - checkpointRef domain.CheckpointRef | |||
| 575 | - | func (_e *MockSessionLifecycleCoordinator_Expecter) PromoteFallback(context1 any, s any, checkpointRef any) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |||
| 576 | - | return &MockSessionLifecycleCoordinator_PromoteFallback_Call{Call: _e.mock.On("PromoteFallback", context1, s, checkpointRef)} | |||
| 577 | - | } | |||
| 578 | - | ||||
| 579 | - | func (_c *MockSessionLifecycleCoordinator_PromoteFallback_Call) Run(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef)) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |||
| 580 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 581 | - | var arg0 context.Context | |||
| 582 | - | if args[0] != nil { | |||
| 583 | - | arg0 = args[0].(context.Context) | |||
| 584 | - | } | |||
| 585 | - | var arg1 string | |||
| 586 | - | if args[1] != nil { | |||
| 587 | - | arg1 = args[1].(string) | |||
| 588 | - | } | |||
| 589 | - | var arg2 domain.CheckpointRef | |||
| 590 | - | if args[2] != nil { | |||
| 591 | - | arg2 = args[2].(domain.CheckpointRef) | |||
| 592 | - | } | |||
| 593 | - | run( | |||
| 594 | - | arg0, | |||
| 595 | - | arg1, | |||
| 596 | - | arg2, | |||
| 597 | - | ) | |||
| 598 | - | }) | |||
| 599 | - | return _c | |||
| 600 | - | } | |||
| 601 | - | ||||
| 602 | - | func (_c *MockSessionLifecycleCoordinator_PromoteFallback_Call) Return(fallbackPromotionOutcome ports.FallbackPromotionOutcome, err error) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |||
| 603 | - | _c.Call.Return(fallbackPromotionOutcome, err) | |||
| 604 | - | return _c | |||
| 605 | - | } | |||
| 606 | - | ||||
| 607 | - | func (_c *MockSessionLifecycleCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)) *MockSessionLifecycleCoordinator_PromoteFallback_Call { | |||
| 608 | - | _c.Call.Return(run) | |||
| 609 | - | return _c | |||
| 610 | - | } | |||
| 611 | - | ||||
| 612 | // PublishCheckpoint provides a mock function for the type MockSessionLifecycleCoordinator | 467 | // PublishCheckpoint provides a mock function for the type MockSessionLifecycleCoordinator | ||
| 613 | func (_mock *MockSessionLifecycleCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) { | 468 | func (_mock *MockSessionLifecycleCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) { | ||
| 614 | ret := _mock.Called(context1, s, snapshotPublication) | 469 | ret := _mock.Called(context1, s, snapshotPublication) | ||
| @@ -852,189 +707,6 @@ func (_c *MockDegradedRecoveryCoordinator_Discard_Call) RunAndReturn(run func(co | |||||
| 852 | return _c | 707 | return _c | ||
| 853 | } | 708 | } | ||
| 854 | 709 | ||||
| 855 | - | // Export provides a mock function for the type MockDegradedRecoveryCoordinator | |||
| 856 | - | func (_mock *MockDegradedRecoveryCoordinator) Export(context1 context.Context, s string, writer io.Writer) error { | |||
| 857 | - | ret := _mock.Called(context1, s, writer) | |||
| 858 | - | ||||
| 859 | - | if len(ret) == 0 { | |||
| 860 | - | panic("no return value specified for Export") | |||
| 861 | - | } | |||
| 862 | - | ||||
| 863 | - | var r0 error | |||
| 864 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Writer) error); ok { | |||
| 865 | - | r0 = returnFunc(context1, s, writer) | |||
| 866 | - | } else { | |||
| 867 | - | r0 = ret.Error(0) | |||
| 868 | - | } | |||
| 869 | - | return r0 | |||
| 870 | - | } | |||
| 871 | - | ||||
| 872 | - | // MockDegradedRecoveryCoordinator_Export_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Export' | |||
| 873 | - | type MockDegradedRecoveryCoordinator_Export_Call struct { | |||
| 874 | - | *mock.Call | |||
| 875 | - | } | |||
| 876 | - | ||||
| 877 | - | // Export is a helper method to define mock.On call | |||
| 878 | - | // - context1 context.Context | |||
| 879 | - | // - s string | |||
| 880 | - | // - writer io.Writer | |||
| 881 | - | func (_e *MockDegradedRecoveryCoordinator_Expecter) Export(context1 any, s any, writer any) *MockDegradedRecoveryCoordinator_Export_Call { | |||
| 882 | - | return &MockDegradedRecoveryCoordinator_Export_Call{Call: _e.mock.On("Export", context1, s, writer)} | |||
| 883 | - | } | |||
| 884 | - | ||||
| 885 | - | func (_c *MockDegradedRecoveryCoordinator_Export_Call) Run(run func(context1 context.Context, s string, writer io.Writer)) *MockDegradedRecoveryCoordinator_Export_Call { | |||
| 886 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 887 | - | var arg0 context.Context | |||
| 888 | - | if args[0] != nil { | |||
| 889 | - | arg0 = args[0].(context.Context) | |||
| 890 | - | } | |||
| 891 | - | var arg1 string | |||
| 892 | - | if args[1] != nil { | |||
| 893 | - | arg1 = args[1].(string) | |||
| 894 | - | } | |||
| 895 | - | var arg2 io.Writer | |||
| 896 | - | if args[2] != nil { | |||
| 897 | - | arg2 = args[2].(io.Writer) | |||
| 898 | - | } | |||
| 899 | - | run( | |||
| 900 | - | arg0, | |||
| 901 | - | arg1, | |||
| 902 | - | arg2, | |||
| 903 | - | ) | |||
| 904 | - | }) | |||
| 905 | - | return _c | |||
| 906 | - | } | |||
| 907 | - | ||||
| 908 | - | func (_c *MockDegradedRecoveryCoordinator_Export_Call) Return(err error) *MockDegradedRecoveryCoordinator_Export_Call { | |||
| 909 | - | _c.Call.Return(err) | |||
| 910 | - | return _c | |||
| 911 | - | } | |||
| 912 | - | ||||
| 913 | - | func (_c *MockDegradedRecoveryCoordinator_Export_Call) RunAndReturn(run func(context1 context.Context, s string, writer io.Writer) error) *MockDegradedRecoveryCoordinator_Export_Call { | |||
| 914 | - | _c.Call.Return(run) | |||
| 915 | - | return _c | |||
| 916 | - | } | |||
| 917 | - | ||||
| 918 | - | // RestoreFallback provides a mock function for the type MockDegradedRecoveryCoordinator | |||
| 919 | - | func (_mock *MockDegradedRecoveryCoordinator) RestoreFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) error { | |||
| 920 | - | ret := _mock.Called(context1, s, checkpointRef) | |||
| 921 | - | ||||
| 922 | - | if len(ret) == 0 { | |||
| 923 | - | panic("no return value specified for RestoreFallback") | |||
| 924 | - | } | |||
| 925 | - | ||||
| 926 | - | var r0 error | |||
| 927 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) error); ok { | |||
| 928 | - | r0 = returnFunc(context1, s, checkpointRef) | |||
| 929 | - | } else { | |||
| 930 | - | r0 = ret.Error(0) | |||
| 931 | - | } | |||
| 932 | - | return r0 | |||
| 933 | - | } | |||
| 934 | - | ||||
| 935 | - | // MockDegradedRecoveryCoordinator_RestoreFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreFallback' | |||
| 936 | - | type MockDegradedRecoveryCoordinator_RestoreFallback_Call struct { | |||
| 937 | - | *mock.Call | |||
| 938 | - | } | |||
| 939 | - | ||||
| 940 | - | // RestoreFallback is a helper method to define mock.On call | |||
| 941 | - | // - context1 context.Context | |||
| 942 | - | // - s string | |||
| 943 | - | // - checkpointRef domain.CheckpointRef | |||
| 944 | - | func (_e *MockDegradedRecoveryCoordinator_Expecter) RestoreFallback(context1 any, s any, checkpointRef any) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |||
| 945 | - | return &MockDegradedRecoveryCoordinator_RestoreFallback_Call{Call: _e.mock.On("RestoreFallback", context1, s, checkpointRef)} | |||
| 946 | - | } | |||
| 947 | - | ||||
| 948 | - | func (_c *MockDegradedRecoveryCoordinator_RestoreFallback_Call) Run(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef)) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |||
| 949 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 950 | - | var arg0 context.Context | |||
| 951 | - | if args[0] != nil { | |||
| 952 | - | arg0 = args[0].(context.Context) | |||
| 953 | - | } | |||
| 954 | - | var arg1 string | |||
| 955 | - | if args[1] != nil { | |||
| 956 | - | arg1 = args[1].(string) | |||
| 957 | - | } | |||
| 958 | - | var arg2 domain.CheckpointRef | |||
| 959 | - | if args[2] != nil { | |||
| 960 | - | arg2 = args[2].(domain.CheckpointRef) | |||
| 961 | - | } | |||
| 962 | - | run( | |||
| 963 | - | arg0, | |||
| 964 | - | arg1, | |||
| 965 | - | arg2, | |||
| 966 | - | ) | |||
| 967 | - | }) | |||
| 968 | - | return _c | |||
| 969 | - | } | |||
| 970 | - | ||||
| 971 | - | func (_c *MockDegradedRecoveryCoordinator_RestoreFallback_Call) Return(err error) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |||
| 972 | - | _c.Call.Return(err) | |||
| 973 | - | return _c | |||
| 974 | - | } | |||
| 975 | - | ||||
| 976 | - | func (_c *MockDegradedRecoveryCoordinator_RestoreFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) error) *MockDegradedRecoveryCoordinator_RestoreFallback_Call { | |||
| 977 | - | _c.Call.Return(run) | |||
| 978 | - | return _c | |||
| 979 | - | } | |||
| 980 | - | ||||
| 981 | - | // Retry provides a mock function for the type MockDegradedRecoveryCoordinator | |||
| 982 | - | func (_mock *MockDegradedRecoveryCoordinator) Retry(context1 context.Context, s string) error { | |||
| 983 | - | ret := _mock.Called(context1, s) | |||
| 984 | - | ||||
| 985 | - | if len(ret) == 0 { | |||
| 986 | - | panic("no return value specified for Retry") | |||
| 987 | - | } | |||
| 988 | - | ||||
| 989 | - | var r0 error | |||
| 990 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |||
| 991 | - | r0 = returnFunc(context1, s) | |||
| 992 | - | } else { | |||
| 993 | - | r0 = ret.Error(0) | |||
| 994 | - | } | |||
| 995 | - | return r0 | |||
| 996 | - | } | |||
| 997 | - | ||||
| 998 | - | // MockDegradedRecoveryCoordinator_Retry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retry' | |||
| 999 | - | type MockDegradedRecoveryCoordinator_Retry_Call struct { | |||
| 1000 | - | *mock.Call | |||
| 1001 | - | } | |||
| 1002 | - | ||||
| 1003 | - | // Retry is a helper method to define mock.On call | |||
| 1004 | - | // - context1 context.Context | |||
| 1005 | - | // - s string | |||
| 1006 | - | func (_e *MockDegradedRecoveryCoordinator_Expecter) Retry(context1 any, s any) *MockDegradedRecoveryCoordinator_Retry_Call { | |||
| 1007 | - | return &MockDegradedRecoveryCoordinator_Retry_Call{Call: _e.mock.On("Retry", context1, s)} | |||
| 1008 | - | } | |||
| 1009 | - | ||||
| 1010 | - | func (_c *MockDegradedRecoveryCoordinator_Retry_Call) Run(run func(context1 context.Context, s string)) *MockDegradedRecoveryCoordinator_Retry_Call { | |||
| 1011 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1012 | - | var arg0 context.Context | |||
| 1013 | - | if args[0] != nil { | |||
| 1014 | - | arg0 = args[0].(context.Context) | |||
| 1015 | - | } | |||
| 1016 | - | var arg1 string | |||
| 1017 | - | if args[1] != nil { | |||
| 1018 | - | arg1 = args[1].(string) | |||
| 1019 | - | } | |||
| 1020 | - | run( | |||
| 1021 | - | arg0, | |||
| 1022 | - | arg1, | |||
| 1023 | - | ) | |||
| 1024 | - | }) | |||
| 1025 | - | return _c | |||
| 1026 | - | } | |||
| 1027 | - | ||||
| 1028 | - | func (_c *MockDegradedRecoveryCoordinator_Retry_Call) Return(err error) *MockDegradedRecoveryCoordinator_Retry_Call { | |||
| 1029 | - | _c.Call.Return(err) | |||
| 1030 | - | return _c | |||
| 1031 | - | } | |||
| 1032 | - | ||||
| 1033 | - | func (_c *MockDegradedRecoveryCoordinator_Retry_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockDegradedRecoveryCoordinator_Retry_Call { | |||
| 1034 | - | _c.Call.Return(run) | |||
| 1035 | - | return _c | |||
| 1036 | - | } | |||
| 1037 | - | ||||
| 1038 | // NewMockCatalogue creates a new instance of MockCatalogue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 710 | // NewMockCatalogue creates a new instance of MockCatalogue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1039 | // The first argument is typically a *testing.T value. | 711 | // The first argument is typically a *testing.T value. | ||
| 1040 | func NewMockCatalogue(t interface { | 712 | func NewMockCatalogue(t interface { | ||
M internal/ports/recovery.go +1 -12
| @@ -3,7 +3,6 @@ package ports | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "io" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/vev/internal/domain" | |
| 9 | 8 | ) | |
| @@ -112,15 +111,8 @@ type RecoveryJournal interface { | |||
| 112 | 111 | DeleteDiscard(context.Context, domain.IncarnationID) error | |
| 113 | 112 | } | |
| 114 | 113 | ||
| 115 | - | type FallbackPromotionOutcome struct { | |
| 116 | - | Record domain.CatalogueRecord | |
| 117 | - | CatalogueCommitted bool | |
| 118 | - | HEADRepairError error | |
| 119 | - | } | |
| 120 | - | ||
| 121 | 114 | type CheckpointCoordinator interface { | |
| 122 | 115 | PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error) | |
| 123 | - | PromoteFallback(context.Context, string, domain.CheckpointRef) (FallbackPromotionOutcome, error) | |
| 124 | 116 | } | |
| 125 | 117 | ||
| 126 | 118 | // SessionLifecycleCoordinator owns durable named-session create, rename, and | |
| @@ -133,11 +125,8 @@ type SessionLifecycleCoordinator interface { | |||
| 133 | 125 | Delete(context.Context, string) error | |
| 134 | 126 | } | |
| 135 | 127 | ||
| 136 | - | // DegradedRecoveryCoordinator exposes only explicit operator recovery actions. | |
| 128 | + | // DegradedRecoveryCoordinator exposes only the explicit operator discard action. | |
| 137 | 129 | type DegradedRecoveryCoordinator interface { | |
| 138 | - | Retry(context.Context, string) error | |
| 139 | - | RestoreFallback(context.Context, string, domain.CheckpointRef) error | |
| 140 | - | Export(context.Context, string, io.Writer) error | |
| 141 | 130 | Discard(context.Context, string, string) (domain.CatalogueRecord, error) | |
| 142 | 131 | } | |
| 143 | 132 | ||
| @@ -3,7 +3,6 @@ package ports | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | - | "io" | |||
| 7 | 6 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 9 | ) | 8 | ) | ||
| @@ -112,15 +111,8 @@ type RecoveryJournal interface { | |||||
| 112 | DeleteDiscard(context.Context, domain.IncarnationID) error | 111 | DeleteDiscard(context.Context, domain.IncarnationID) error | ||
| 113 | } | 112 | } | ||
| 114 | 113 | ||||
| 115 | - | type FallbackPromotionOutcome struct { | |||
| 116 | - | Record domain.CatalogueRecord | |||
| 117 | - | CatalogueCommitted bool | |||
| 118 | - | HEADRepairError error | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | type CheckpointCoordinator interface { | 114 | type CheckpointCoordinator interface { | ||
| 122 | PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error) | 115 | PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error) | ||
| 123 | - | PromoteFallback(context.Context, string, domain.CheckpointRef) (FallbackPromotionOutcome, error) | |||
| 124 | } | 116 | } | ||
| 125 | 117 | ||||
| 126 | // SessionLifecycleCoordinator owns durable named-session create, rename, and | 118 | // SessionLifecycleCoordinator owns durable named-session create, rename, and | ||
| @@ -133,11 +125,8 @@ type SessionLifecycleCoordinator interface { | |||||
| 133 | Delete(context.Context, string) error | 125 | Delete(context.Context, string) error | ||
| 134 | } | 126 | } | ||
| 135 | 127 | ||||
| 136 | - | // DegradedRecoveryCoordinator exposes only explicit operator recovery actions. | 128 | + | // DegradedRecoveryCoordinator exposes only the explicit operator discard action. |
| 137 | type DegradedRecoveryCoordinator interface { | 129 | type DegradedRecoveryCoordinator interface { | ||
| 138 | - | Retry(context.Context, string) error | |||
| 139 | - | RestoreFallback(context.Context, string, domain.CheckpointRef) error | |||
| 140 | - | Export(context.Context, string, io.Writer) error | |||
| 141 | Discard(context.Context, string, string) (domain.CatalogueRecord, error) | 130 | Discard(context.Context, string, string) (domain.CatalogueRecord, error) | ||
| 142 | } | 131 | } | ||
| 143 | 132 | ||||
M internal/usecase/command/command.go +1 -1
| @@ -133,7 +133,7 @@ type ControlContext interface { | |||
| 133 | 133 | RenameSessionTo(name string) error | |
| 134 | 134 | RenameTabTo(name string) error | |
| 135 | 135 | Toast(severity, message string) error | |
| 136 | - | SessionRecovery(action, argument string) (string, error) | |
| 136 | + | SessionRecovery(action string) (string, error) | |
| 137 | 137 | ListSessions(json bool) (string, error) | |
| 138 | 138 | ListTabs(json bool) (string, error) | |
| 139 | 139 | ListPanes(json bool) (string, error) | |
| @@ -133,7 +133,7 @@ type ControlContext interface { | |||||
| 133 | RenameSessionTo(name string) error | 133 | RenameSessionTo(name string) error | ||
| 134 | RenameTabTo(name string) error | 134 | RenameTabTo(name string) error | ||
| 135 | Toast(severity, message string) error | 135 | Toast(severity, message string) error | ||
| 136 | - | SessionRecovery(action, argument string) (string, error) | 136 | + | SessionRecovery(action string) (string, error) |
| 137 | ListSessions(json bool) (string, error) | 137 | ListSessions(json bool) (string, error) | ||
| 138 | ListTabs(json bool) (string, error) | 138 | ListTabs(json bool) (string, error) | ||
| 139 | ListPanes(json bool) (string, error) | 139 | ListPanes(json bool) (string, error) | ||
M internal/usecase/command/registry.go +5 -19
| @@ -1,9 +1,6 @@ | |||
| 1 | 1 | package command | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "path/filepath" | |
| 5 | - | "strings" | |
| 6 | - | ) | |
| 3 | + | import "strings" | |
| 7 | 4 | ||
| 8 | 5 | // Registry returns all palette and control commands in stable display order. | |
| 9 | 6 | func Registry() []Command { | |
| @@ -95,25 +92,14 @@ func toastCommand() Command { | |||
| 95 | 92 | ||
| 96 | 93 | func sessionRecoveryCommand() Command { | |
| 97 | 94 | return Command{ | |
| 98 | - | Slug: "session-recovery", Name: "Session recovery", Desc: "Explicitly recover a degraded durable session", | |
| 99 | - | Usage: "session-recovery retry\nsession-recovery restore <generation>\nsession-recovery export <path>\nsession-recovery discard", | |
| 95 | + | Slug: "session-recovery", Name: "Session recovery", Desc: "Discard a broken durable session's persisted state", | |
| 96 | + | Usage: "session-recovery discard", | |
| 100 | 97 | Scriptable: true, Target: TargetNone, | |
| 101 | 98 | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 102 | - | var action, argument string | |
| 103 | - | switch { | |
| 104 | - | case len(args) == 1 && (args[0] == "retry" || args[0] == "discard"): | |
| 105 | - | action = args[0] | |
| 106 | - | case len(args) == 2 && args[0] == "restore": | |
| 107 | - | if _, err := ParsePositiveUint64(args[1:]); err != nil { | |
| 108 | - | return ControlResult{}, ErrInvalidArguments | |
| 109 | - | } | |
| 110 | - | action, argument = args[0], args[1] | |
| 111 | - | case len(args) == 2 && args[0] == "export" && filepath.IsAbs(args[1]): | |
| 112 | - | action, argument = args[0], args[1] | |
| 113 | - | default: | |
| 99 | + | if len(args) != 1 || args[0] != "discard" { | |
| 114 | 100 | return ControlResult{}, ErrInvalidArguments | |
| 115 | 101 | } | |
| 116 | - | output, err := ctx.SessionRecovery(action, argument) | |
| 102 | + | output, err := ctx.SessionRecovery("discard") | |
| 117 | 103 | return ControlResult{Output: output}, err | |
| 118 | 104 | }, | |
| 119 | 105 | } | |
| @@ -1,9 +1,6 @@ | |||||
| 1 | package command | 1 | package command | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "strings" |
| 4 | - | "path/filepath" | |||
| 5 | - | "strings" | |||
| 6 | - | ) | |||
| 7 | 4 | ||||
| 8 | // Registry returns all palette and control commands in stable display order. | 5 | // Registry returns all palette and control commands in stable display order. | ||
| 9 | func Registry() []Command { | 6 | func Registry() []Command { | ||
| @@ -95,25 +92,14 @@ func toastCommand() Command { | |||||
| 95 | 92 | ||||
| 96 | func sessionRecoveryCommand() Command { | 93 | func sessionRecoveryCommand() Command { | ||
| 97 | return Command{ | 94 | return Command{ | ||
| 98 | - | Slug: "session-recovery", Name: "Session recovery", Desc: "Explicitly recover a degraded durable session", | 95 | + | Slug: "session-recovery", Name: "Session recovery", Desc: "Discard a broken durable session's persisted state", |
| 99 | - | Usage: "session-recovery retry\nsession-recovery restore <generation>\nsession-recovery export <path>\nsession-recovery discard", | 96 | + | Usage: "session-recovery discard", |
| 100 | Scriptable: true, Target: TargetNone, | 97 | Scriptable: true, Target: TargetNone, | ||
| 101 | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | 98 | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | ||
| 102 | - | var action, argument string | 99 | + | if len(args) != 1 || args[0] != "discard" { |
| 103 | - | switch { | |||
| 104 | - | case len(args) == 1 && (args[0] == "retry" || args[0] == "discard"): | |||
| 105 | - | action = args[0] | |||
| 106 | - | case len(args) == 2 && args[0] == "restore": | |||
| 107 | - | if _, err := ParsePositiveUint64(args[1:]); err != nil { | |||
| 108 | - | return ControlResult{}, ErrInvalidArguments | |||
| 109 | - | } | |||
| 110 | - | action, argument = args[0], args[1] | |||
| 111 | - | case len(args) == 2 && args[0] == "export" && filepath.IsAbs(args[1]): | |||
| 112 | - | action, argument = args[0], args[1] | |||
| 113 | - | default: | |||
| 114 | return ControlResult{}, ErrInvalidArguments | 100 | return ControlResult{}, ErrInvalidArguments | ||
| 115 | } | 101 | } | ||
| 116 | - | output, err := ctx.SessionRecovery(action, argument) | 102 | + | output, err := ctx.SessionRecovery("discard") |
| 117 | return ControlResult{Output: output}, err | 103 | return ControlResult{Output: output}, err | ||
| 118 | }, | 104 | }, | ||
| 119 | } | 105 | } | ||
M internal/usecase/command/registry_test.go +6 -16
| @@ -115,21 +115,11 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||
| 115 | 115 | wantCall string | |
| 116 | 116 | wantErr error | |
| 117 | 117 | }{ | |
| 118 | - | {args: []string{"retry"}, wantCall: "session-recovery:retry:"}, | |
| 119 | - | {args: []string{"restore", "7"}, wantCall: "session-recovery:restore:7"}, | |
| 120 | - | {args: []string{"restore", "18446744073709551615"}, wantCall: "session-recovery:restore:18446744073709551615"}, | |
| 121 | - | {args: []string{"export", "/tmp/export"}, wantCall: "session-recovery:export:/tmp/export"}, | |
| 122 | - | {args: []string{"discard"}, wantCall: "session-recovery:discard:"}, | |
| 118 | + | {args: []string{"discard"}, wantCall: "session-recovery:discard"}, | |
| 119 | + | {args: []string{"retry"}, wantErr: ErrInvalidArguments}, | |
| 120 | + | {args: []string{"restore", "7"}, wantErr: ErrInvalidArguments}, | |
| 121 | + | {args: []string{"export", "/tmp/export"}, wantErr: ErrInvalidArguments}, | |
| 123 | 122 | {args: nil, wantErr: ErrInvalidArguments}, | |
| 124 | - | {args: []string{"retry", "extra"}, wantErr: ErrInvalidArguments}, | |
| 125 | - | {args: []string{"restore"}, wantErr: ErrInvalidArguments}, | |
| 126 | - | {args: []string{"restore", "0"}, wantErr: ErrInvalidArguments}, | |
| 127 | - | {args: []string{"restore", "01"}, wantErr: ErrInvalidArguments}, | |
| 128 | - | {args: []string{"restore", "18446744073709551616"}, wantErr: ErrInvalidArguments}, | |
| 129 | - | {args: []string{"export", "relative/path"}, wantErr: ErrInvalidArguments}, | |
| 130 | - | {args: []string{"export"}, wantErr: ErrInvalidArguments}, | |
| 131 | - | {args: []string{"discard", "extra"}, wantErr: ErrInvalidArguments}, | |
| 132 | - | {args: []string{"unknown"}, wantErr: ErrInvalidArguments}, | |
| 133 | 123 | } | |
| 134 | 124 | for _, test := range tests { | |
| 135 | 125 | ctx := &controlSpy{recoveryOutput: "recovery output"} | |
| @@ -220,8 +210,8 @@ func (s *controlSpy) RenameTabTo(v string) error { return s.record("renam | |||
| 220 | 210 | func (s *controlSpy) Toast(level, message string) error { | |
| 221 | 211 | return s.record("toast:" + level + ":" + message) | |
| 222 | 212 | } | |
| 223 | - | func (s *controlSpy) SessionRecovery(action, argument string) (string, error) { | |
| 224 | - | return s.recoveryOutput, s.record("session-recovery:" + action + ":" + argument) | |
| 213 | + | func (s *controlSpy) SessionRecovery(action string) (string, error) { | |
| 214 | + | return s.recoveryOutput, s.record("session-recovery:" + action) | |
| 225 | 215 | } | |
| 226 | 216 | func (s *controlSpy) ListSessions(json bool) (string, error) { | |
| 227 | 217 | _ = s.record("list-sessions:" + strconv.FormatBool(json)) | |
| @@ -115,21 +115,11 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||||
| 115 | wantCall string | 115 | wantCall string | ||
| 116 | wantErr error | 116 | wantErr error | ||
| 117 | }{ | 117 | }{ | ||
| 118 | - | {args: []string{"retry"}, wantCall: "session-recovery:retry:"}, | 118 | + | {args: []string{"discard"}, wantCall: "session-recovery:discard"}, |
| 119 | - | {args: []string{"restore", "7"}, wantCall: "session-recovery:restore:7"}, | 119 | + | {args: []string{"retry"}, wantErr: ErrInvalidArguments}, |
| 120 | - | {args: []string{"restore", "18446744073709551615"}, wantCall: "session-recovery:restore:18446744073709551615"}, | 120 | + | {args: []string{"restore", "7"}, wantErr: ErrInvalidArguments}, |
| 121 | - | {args: []string{"export", "/tmp/export"}, wantCall: "session-recovery:export:/tmp/export"}, | 121 | + | {args: []string{"export", "/tmp/export"}, wantErr: ErrInvalidArguments}, |
| 122 | - | {args: []string{"discard"}, wantCall: "session-recovery:discard:"}, | |||
| 123 | {args: nil, wantErr: ErrInvalidArguments}, | 122 | {args: nil, wantErr: ErrInvalidArguments}, | ||
| 124 | - | {args: []string{"retry", "extra"}, wantErr: ErrInvalidArguments}, | |||
| 125 | - | {args: []string{"restore"}, wantErr: ErrInvalidArguments}, | |||
| 126 | - | {args: []string{"restore", "0"}, wantErr: ErrInvalidArguments}, | |||
| 127 | - | {args: []string{"restore", "01"}, wantErr: ErrInvalidArguments}, | |||
| 128 | - | {args: []string{"restore", "18446744073709551616"}, wantErr: ErrInvalidArguments}, | |||
| 129 | - | {args: []string{"export", "relative/path"}, wantErr: ErrInvalidArguments}, | |||
| 130 | - | {args: []string{"export"}, wantErr: ErrInvalidArguments}, | |||
| 131 | - | {args: []string{"discard", "extra"}, wantErr: ErrInvalidArguments}, | |||
| 132 | - | {args: []string{"unknown"}, wantErr: ErrInvalidArguments}, | |||
| 133 | } | 123 | } | ||
| 134 | for _, test := range tests { | 124 | for _, test := range tests { | ||
| 135 | ctx := &controlSpy{recoveryOutput: "recovery output"} | 125 | ctx := &controlSpy{recoveryOutput: "recovery output"} | ||
| @@ -220,8 +210,8 @@ func (s *controlSpy) RenameTabTo(v string) error { return s.record("renam | |||||
| 220 | func (s *controlSpy) Toast(level, message string) error { | 210 | func (s *controlSpy) Toast(level, message string) error { | ||
| 221 | return s.record("toast:" + level + ":" + message) | 211 | return s.record("toast:" + level + ":" + message) | ||
| 222 | } | 212 | } | ||
| 223 | - | func (s *controlSpy) SessionRecovery(action, argument string) (string, error) { | 213 | + | func (s *controlSpy) SessionRecovery(action string) (string, error) { |
| 224 | - | return s.recoveryOutput, s.record("session-recovery:" + action + ":" + argument) | 214 | + | return s.recoveryOutput, s.record("session-recovery:" + action) |
| 225 | } | 215 | } | ||
| 226 | func (s *controlSpy) ListSessions(json bool) (string, error) { | 216 | func (s *controlSpy) ListSessions(json bool) (string, error) { | ||
| 227 | _ = s.record("list-sessions:" + strconv.FormatBool(json)) | 217 | _ = s.record("list-sessions:" + strconv.FormatBool(json)) | ||
M internal/usecase/daemon/control.go +6 -90
| @@ -5,11 +5,8 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 8 | "slices" | |
| 11 | 9 | "sort" | |
| 12 | - | "strconv" | |
| 13 | 10 | "strings" | |
| 14 | 11 | ||
| 15 | 12 | "github.com/bnema/vev/internal/domain" | |
| @@ -457,8 +454,8 @@ func (e controlExec) Toast(severity, message string) error { | |||
| 457 | 454 | return nil | |
| 458 | 455 | } | |
| 459 | 456 | ||
| 460 | - | func (e controlExec) SessionRecovery(action, argument string) (string, error) { | |
| 461 | - | if e.d == nil || e.d.degradedRecovery == nil || e.recoveryName == "" { | |
| 457 | + | func (e controlExec) SessionRecovery(action string) (string, error) { | |
| 458 | + | if e.d == nil || e.d.degradedRecovery == nil || e.recoveryName == "" || action != "discard" { | |
| 462 | 459 | return "", command.ErrInvalidArguments | |
| 463 | 460 | } | |
| 464 | 461 | ctx := e.ctx | |
| @@ -468,92 +465,11 @@ func (e controlExec) SessionRecovery(action, argument string) (string, error) { | |||
| 468 | 465 | if ctx == nil { | |
| 469 | 466 | ctx = context.Background() | |
| 470 | 467 | } | |
| 471 | - | switch action { | |
| 472 | - | case "retry": | |
| 473 | - | if err := e.d.degradedRecovery.Retry(ctx, e.recoveryName); err != nil { | |
| 474 | - | return "", err | |
| 475 | - | } | |
| 476 | - | return "", e.restoreExplicitRecovery(ctx) | |
| 477 | - | case "restore": | |
| 478 | - | if e.d.catalogue == nil { | |
| 479 | - | return "", errors.New("durable session catalogue unavailable") | |
| 480 | - | } | |
| 481 | - | generation, err := strconv.ParseUint(argument, 10, 64) | |
| 482 | - | if err != nil || generation == 0 { | |
| 483 | - | return "", command.ErrInvalidArguments | |
| 484 | - | } | |
| 485 | - | record, ok, err := e.d.catalogue.Record(e.recoveryName) | |
| 486 | - | if err != nil { | |
| 487 | - | return "", err | |
| 488 | - | } | |
| 489 | - | if !ok { | |
| 490 | - | return "", errors.New("session recovery record not found") | |
| 491 | - | } | |
| 492 | - | for _, fallback := range record.Fallbacks { | |
| 493 | - | if fallback != nil && fallback.Generation == generation { | |
| 494 | - | if err := e.d.degradedRecovery.RestoreFallback(ctx, e.recoveryName, *fallback); err != nil { | |
| 495 | - | return "", err | |
| 496 | - | } | |
| 497 | - | return "", e.restoreExplicitRecovery(ctx) | |
| 498 | - | } | |
| 499 | - | } | |
| 500 | - | return "", errors.New("requested recovery fallback is not available") | |
| 501 | - | case "export": | |
| 502 | - | if !filepath.IsAbs(argument) { | |
| 503 | - | return "", command.ErrInvalidArguments | |
| 504 | - | } | |
| 505 | - | file, err := os.OpenFile(argument, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |
| 506 | - | if err != nil { | |
| 507 | - | return "", err | |
| 508 | - | } | |
| 509 | - | exportErr := e.d.degradedRecovery.Export(ctx, e.recoveryName, file) | |
| 510 | - | closeErr := file.Close() | |
| 511 | - | if exportErr != nil || closeErr != nil { | |
| 512 | - | removeErr := os.Remove(argument) | |
| 513 | - | return "", errors.Join(exportErr, closeErr, removeErr) | |
| 514 | - | } | |
| 515 | - | return argument, nil | |
| 516 | - | case "discard": | |
| 517 | - | record, err := e.d.degradedRecovery.Discard(ctx, e.recoveryName, "explicit operator discard") | |
| 518 | - | if err == nil { | |
| 519 | - | e.d.setStoppedRecovery(record, runtimeFresh) | |
| 520 | - | } | |
| 521 | - | return "", err | |
| 522 | - | default: | |
| 523 | - | return "", command.ErrInvalidArguments | |
| 524 | - | } | |
| 525 | - | } | |
| 526 | - | ||
| 527 | - | func (e controlExec) restoreExplicitRecovery(ctx context.Context) error { | |
| 528 | - | if e.d.catalogue == nil { | |
| 529 | - | return errors.New("durable session catalogue unavailable") | |
| 530 | - | } | |
| 531 | - | record, ok, err := e.d.catalogue.Record(e.recoveryName) | |
| 532 | - | if err != nil { | |
| 533 | - | return err | |
| 534 | - | } | |
| 535 | - | if !ok { | |
| 536 | - | return errors.New("session recovery record not found") | |
| 468 | + | record, err := e.d.degradedRecovery.Discard(ctx, e.recoveryName, "explicit operator discard") | |
| 469 | + | if err == nil { | |
| 470 | + | e.d.setStoppedRecovery(record, runtimeFresh) | |
| 537 | 471 | } | |
| 538 | - | done := make(chan struct{}) | |
| 539 | - | e.d.mu.Lock() | |
| 540 | - | entry, exists := e.d.stopped[record.Name] | |
| 541 | - | if !exists || entry.incarnation != record.IncarnationID { | |
| 542 | - | e.d.mu.Unlock() | |
| 543 | - | return errors.New("session recovery runtime record not found") | |
| 544 | - | } | |
| 545 | - | if entry.purging || entry.state != runtimeDegraded { | |
| 546 | - | e.d.mu.Unlock() | |
| 547 | - | return errors.New("session recovery is not available in the current state") | |
| 548 | - | } | |
| 549 | - | entry.record = record | |
| 550 | - | entry.state = runtimeRestoring | |
| 551 | - | entry.restoreDone = done | |
| 552 | - | e.d.stopped[record.Name] = entry | |
| 553 | - | e.d.mu.Unlock() | |
| 554 | - | err = e.d.restoreRecord(ctx, record) | |
| 555 | - | e.d.finishExplicitRecordRestore(record, err, done) | |
| 556 | - | return err | |
| 472 | + | return "", err | |
| 557 | 473 | } | |
| 558 | 474 | ||
| 559 | 475 | func (e controlExec) ListSessions(asJSON bool) (string, error) { | |
| @@ -5,11 +5,8 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | - | "os" | |||
| 9 | - | "path/filepath" | |||
| 10 | "slices" | 8 | "slices" | ||
| 11 | "sort" | 9 | "sort" | ||
| 12 | - | "strconv" | |||
| 13 | "strings" | 10 | "strings" | ||
| 14 | 11 | ||||
| 15 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| @@ -457,8 +454,8 @@ func (e controlExec) Toast(severity, message string) error { | |||||
| 457 | return nil | 454 | return nil | ||
| 458 | } | 455 | } | ||
| 459 | 456 | ||||
| 460 | - | func (e controlExec) SessionRecovery(action, argument string) (string, error) { | 457 | + | func (e controlExec) SessionRecovery(action string) (string, error) { |
| 461 | - | if e.d == nil || e.d.degradedRecovery == nil || e.recoveryName == "" { | 458 | + | if e.d == nil || e.d.degradedRecovery == nil || e.recoveryName == "" || action != "discard" { |
| 462 | return "", command.ErrInvalidArguments | 459 | return "", command.ErrInvalidArguments | ||
| 463 | } | 460 | } | ||
| 464 | ctx := e.ctx | 461 | ctx := e.ctx | ||
| @@ -468,92 +465,11 @@ func (e controlExec) SessionRecovery(action, argument string) (string, error) { | |||||
| 468 | if ctx == nil { | 465 | if ctx == nil { | ||
| 469 | ctx = context.Background() | 466 | ctx = context.Background() | ||
| 470 | } | 467 | } | ||
| 471 | - | switch action { | 468 | + | record, err := e.d.degradedRecovery.Discard(ctx, e.recoveryName, "explicit operator discard") |
| 472 | - | case "retry": | 469 | + | if err == nil { |
| 473 | - | if err := e.d.degradedRecovery.Retry(ctx, e.recoveryName); err != nil { | 470 | + | e.d.setStoppedRecovery(record, runtimeFresh) |
| 474 | - | return "", err | |||
| 475 | - | } | |||
| 476 | - | return "", e.restoreExplicitRecovery(ctx) | |||
| 477 | - | case "restore": | |||
| 478 | - | if e.d.catalogue == nil { | |||
| 479 | - | return "", errors.New("durable session catalogue unavailable") | |||
| 480 | - | } | |||
| 481 | - | generation, err := strconv.ParseUint(argument, 10, 64) | |||
| 482 | - | if err != nil || generation == 0 { | |||
| 483 | - | return "", command.ErrInvalidArguments | |||
| 484 | - | } | |||
| 485 | - | record, ok, err := e.d.catalogue.Record(e.recoveryName) | |||
| 486 | - | if err != nil { | |||
| 487 | - | return "", err | |||
| 488 | - | } | |||
| 489 | - | if !ok { | |||
| 490 | - | return "", errors.New("session recovery record not found") | |||
| 491 | - | } | |||
| 492 | - | for _, fallback := range record.Fallbacks { | |||
| 493 | - | if fallback != nil && fallback.Generation == generation { | |||
| 494 | - | if err := e.d.degradedRecovery.RestoreFallback(ctx, e.recoveryName, *fallback); err != nil { | |||
| 495 | - | return "", err | |||
| 496 | - | } | |||
| 497 | - | return "", e.restoreExplicitRecovery(ctx) | |||
| 498 | - | } | |||
| 499 | - | } | |||
| 500 | - | return "", errors.New("requested recovery fallback is not available") | |||
| 501 | - | case "export": | |||
| 502 | - | if !filepath.IsAbs(argument) { | |||
| 503 | - | return "", command.ErrInvalidArguments | |||
| 504 | - | } | |||
| 505 | - | file, err := os.OpenFile(argument, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |||
| 506 | - | if err != nil { | |||
| 507 | - | return "", err | |||
| 508 | - | } | |||
| 509 | - | exportErr := e.d.degradedRecovery.Export(ctx, e.recoveryName, file) | |||
| 510 | - | closeErr := file.Close() | |||
| 511 | - | if exportErr != nil || closeErr != nil { | |||
| 512 | - | removeErr := os.Remove(argument) | |||
| 513 | - | return "", errors.Join(exportErr, closeErr, removeErr) | |||
| 514 | - | } | |||
| 515 | - | return argument, nil | |||
| 516 | - | case "discard": | |||
| 517 | - | record, err := e.d.degradedRecovery.Discard(ctx, e.recoveryName, "explicit operator discard") | |||
| 518 | - | if err == nil { | |||
| 519 | - | e.d.setStoppedRecovery(record, runtimeFresh) | |||
| 520 | - | } | |||
| 521 | - | return "", err | |||
| 522 | - | default: | |||
| 523 | - | return "", command.ErrInvalidArguments | |||
| 524 | - | } | |||
| 525 | - | } | |||
| 526 | - | ||||
| 527 | - | func (e controlExec) restoreExplicitRecovery(ctx context.Context) error { | |||
| 528 | - | if e.d.catalogue == nil { | |||
| 529 | - | return errors.New("durable session catalogue unavailable") | |||
| 530 | - | } | |||
| 531 | - | record, ok, err := e.d.catalogue.Record(e.recoveryName) | |||
| 532 | - | if err != nil { | |||
| 533 | - | return err | |||
| 534 | - | } | |||
| 535 | - | if !ok { | |||
| 536 | - | return errors.New("session recovery record not found") | |||
| 537 | } | 471 | } | ||
| 538 | - | done := make(chan struct{}) | 472 | + | return "", err |
| 539 | - | e.d.mu.Lock() | |||
| 540 | - | entry, exists := e.d.stopped[record.Name] | |||
| 541 | - | if !exists || entry.incarnation != record.IncarnationID { | |||
| 542 | - | e.d.mu.Unlock() | |||
| 543 | - | return errors.New("session recovery runtime record not found") | |||
| 544 | - | } | |||
| 545 | - | if entry.purging || entry.state != runtimeDegraded { | |||
| 546 | - | e.d.mu.Unlock() | |||
| 547 | - | return errors.New("session recovery is not available in the current state") | |||
| 548 | - | } | |||
| 549 | - | entry.record = record | |||
| 550 | - | entry.state = runtimeRestoring | |||
| 551 | - | entry.restoreDone = done | |||
| 552 | - | e.d.stopped[record.Name] = entry | |||
| 553 | - | e.d.mu.Unlock() | |||
| 554 | - | err = e.d.restoreRecord(ctx, record) | |||
| 555 | - | e.d.finishExplicitRecordRestore(record, err, done) | |||
| 556 | - | return err | |||
| 557 | } | 473 | } | ||
| 558 | 474 | ||||
| 559 | func (e controlExec) ListSessions(asJSON bool) (string, error) { | 475 | func (e controlExec) ListSessions(asJSON bool) (string, error) { | ||
M internal/usecase/daemon/durable_recovery_test.go +4 -153
| @@ -2,7 +2,6 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "crypto/sha256" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "io" | |
| @@ -19,7 +18,6 @@ import ( | |||
| 19 | 18 | "github.com/bnema/vev/internal/domain" | |
| 20 | 19 | "github.com/bnema/vev/internal/ports" | |
| 21 | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 22 | - | "github.com/bnema/vev/internal/usecase/command" | |
| 23 | 21 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 24 | 22 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 25 | 23 | ) | |
| @@ -284,63 +282,6 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||
| 284 | 282 | require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime") | |
| 285 | 283 | }) | |
| 286 | 284 | ||
| 287 | - | t.Run("fallback promotion", func(t *testing.T) { | |
| 288 | - | fallback := durableRecoveryRecord(2) | |
| 289 | - | fallbackRef := domain.CheckpointRef{Generation: fallback.Committed.Generation - 1} | |
| 290 | - | fallbackSnapshotRecord := fallback | |
| 291 | - | fallbackSnapshotRecord.Committed = &fallbackRef | |
| 292 | - | fallbackGeneration := validGeneration(t, fallbackSnapshotRecord) | |
| 293 | - | fallbackRef.ManifestDigest = sha256.Sum256(fallbackGeneration.Manifest) | |
| 294 | - | fallback.Fallbacks[0] = &fallbackRef | |
| 295 | - | fallbackRepository := &durableRecoveryRepository{ | |
| 296 | - | generations: map[string]ports.SnapshotGeneration{fallback.Name: fallbackGeneration}, | |
| 297 | - | errors: make(map[string]error), | |
| 298 | - | loads: make(map[string]int), | |
| 299 | - | repairs: make(map[string]domain.CheckpointRef), | |
| 300 | - | } | |
| 301 | - | fallbackDaemon, fallbackCatalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fallback}, fallbackRepository) | |
| 302 | - | fallbackDaemon.restoreCatalogue(context.Background(), mustDurableRecords(t, fallbackCatalogue)) | |
| 303 | - | promoted, ok, _ := fallbackCatalogue.Record(fallback.Name) | |
| 304 | - | require.True(t, ok) | |
| 305 | - | require.Equal(t, fallbackRef, *promoted.Committed) | |
| 306 | - | require.Equal(t, runtimeHealthy, fallbackDaemon.stopped[fallback.Name].state) | |
| 307 | - | require.Equal(t, 3, fallbackRepository.loads[fallback.Name]) | |
| 308 | - | require.Equal(t, fallbackRef, fallbackRepository.repairs[fallback.IncarnationID.String()]) | |
| 309 | - | }) | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | func TestCatalogueRestoreKeepsCommittedFallbackHealthyWhenHEADRepairFails(t *testing.T) { | |
| 313 | - | record := durableRecoveryRecord(2) | |
| 314 | - | fallbackRef := domain.CheckpointRef{Generation: record.Committed.Generation - 1} | |
| 315 | - | fallbackRecord := record | |
| 316 | - | fallbackRecord.Committed = &fallbackRef | |
| 317 | - | generation := validGeneration(t, fallbackRecord) | |
| 318 | - | fallbackRef.ManifestDigest = sha256.Sum256(generation.Manifest) | |
| 319 | - | record.Fallbacks[0] = &fallbackRef | |
| 320 | - | repository := &durableRecoveryRepository{ | |
| 321 | - | generations: map[string]ports.SnapshotGeneration{record.Name: generation}, | |
| 322 | - | errors: make(map[string]error), | |
| 323 | - | loads: make(map[string]int), | |
| 324 | - | repairs: make(map[string]domain.CheckpointRef), | |
| 325 | - | repairErr: errors.New("head repair unavailable"), | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 329 | - | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 330 | - | ||
| 331 | - | committed, ok, _ := catalogue.Record(record.Name) | |
| 332 | - | require.True(t, ok) | |
| 333 | - | require.Equal(t, domain.RecoveryHealthy, committed.RecoveryState) | |
| 334 | - | require.Equal(t, fallbackRef, *committed.Committed) | |
| 335 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | |
| 336 | - | require.Equal(t, 1, repository.repairCalls) | |
| 337 | - | ||
| 338 | - | repository.repairErr = nil | |
| 339 | - | next, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{committed}, repository) | |
| 340 | - | next.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 341 | - | require.Equal(t, runtimeHealthy, next.stopped[record.Name].state) | |
| 342 | - | require.Equal(t, 2, repository.repairCalls, "the next startup retries the pending HEAD repair") | |
| 343 | - | require.Equal(t, fallbackRef, repository.repairs[record.IncarnationID.String()]) | |
| 344 | 285 | } | |
| 345 | 286 | ||
| 346 | 287 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | |
| @@ -414,14 +355,6 @@ type explicitRecoveryStub struct { | |||
| 414 | 355 | record domain.CatalogueRecord | |
| 415 | 356 | } | |
| 416 | 357 | ||
| 417 | - | func (s explicitRecoveryStub) Retry(context.Context, string) error { return nil } | |
| 418 | - | func (s explicitRecoveryStub) RestoreFallback(context.Context, string, domain.CheckpointRef) error { | |
| 419 | - | return nil | |
| 420 | - | } | |
| 421 | - | func (s explicitRecoveryStub) Export(_ context.Context, _ string, w io.Writer) error { | |
| 422 | - | _, err := w.Write([]byte("export")) | |
| 423 | - | return err | |
| 424 | - | } | |
| 425 | 358 | func (s explicitRecoveryStub) Discard(context.Context, string, string) (domain.CatalogueRecord, error) { | |
| 426 | 359 | return s.record, nil | |
| 427 | 360 | } | |
| @@ -434,9 +367,9 @@ type contextRecoveryStub struct { | |||
| 434 | 367 | err error | |
| 435 | 368 | } | |
| 436 | 369 | ||
| 437 | - | func (s *contextRecoveryStub) Retry(ctx context.Context, _ string) error { | |
| 370 | + | func (s *contextRecoveryStub) Discard(ctx context.Context, _, _ string) (domain.CatalogueRecord, error) { | |
| 438 | 371 | s.seen = ctx.Value(s.wantKey) == s.want | |
| 439 | - | return s.err | |
| 372 | + | return domain.CatalogueRecord{}, s.err | |
| 440 | 373 | } | |
| 441 | 374 | ||
| 442 | 375 | func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) { | |
| @@ -448,7 +381,7 @@ func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) { | |||
| 448 | 381 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 449 | 382 | d.degradedRecovery = stub | |
| 450 | 383 | ||
| 451 | - | _, err := (controlExec{ctx: ctx, d: d, recoveryName: "work"}).SessionRecovery("retry", "") | |
| 384 | + | _, err := (controlExec{ctx: ctx, d: d, recoveryName: "work"}).SessionRecovery("discard") | |
| 452 | 385 | require.ErrorIs(t, err, wantErr) | |
| 453 | 386 | require.True(t, stub.seen) | |
| 454 | 387 | } | |
| @@ -482,61 +415,6 @@ func TestRetryableRestoreLoadErrorClassification(t *testing.T) { | |||
| 482 | 415 | } | |
| 483 | 416 | } | |
| 484 | 417 | ||
| 485 | - | func TestExplicitRecoveryRemainsRetryableAfterRepeatedTransientFailures(t *testing.T) { | |
| 486 | - | record := durableRecoveryRecord(0) | |
| 487 | - | repository := &durableRecoveryRepository{ | |
| 488 | - | generations: map[string]ports.SnapshotGeneration{record.Name: validGeneration(t, record)}, | |
| 489 | - | errors: map[string]error{record.Name: ports.ErrBudgetExhausted}, | |
| 490 | - | loads: make(map[string]int), | |
| 491 | - | repairs: make(map[string]domain.CheckpointRef), | |
| 492 | - | } | |
| 493 | - | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 494 | - | d.setStoppedRecovery(record, runtimeDegraded) | |
| 495 | - | exec := controlExec{d: d, recoveryName: record.Name} | |
| 496 | - | ||
| 497 | - | var attemptBarriers []chan struct{} | |
| 498 | - | for range 2 { | |
| 499 | - | err := exec.restoreExplicitRecovery(t.Context()) | |
| 500 | - | require.ErrorIs(t, err, errRetryableRestoreLoad) | |
| 501 | - | require.ErrorIs(t, err, ports.ErrBudgetExhausted) | |
| 502 | - | ||
| 503 | - | d.mu.Lock() | |
| 504 | - | entry := d.stopped[record.Name] | |
| 505 | - | d.mu.Unlock() | |
| 506 | - | require.Equal(t, runtimeDegraded, entry.state) | |
| 507 | - | require.Equal(t, domain.RecoveryHealthy, entry.record.RecoveryState) | |
| 508 | - | select { | |
| 509 | - | case <-entry.restoreDone: | |
| 510 | - | default: | |
| 511 | - | t.Fatal("failed attempt completion barrier remained open") | |
| 512 | - | } | |
| 513 | - | for _, prior := range attemptBarriers { | |
| 514 | - | require.NotEqual(t, prior, entry.restoreDone, "a closed completion barrier must not be reused") | |
| 515 | - | } | |
| 516 | - | attemptBarriers = append(attemptBarriers, entry.restoreDone) | |
| 517 | - | ||
| 518 | - | persisted, ok, catalogueErr := catalogue.Record(record.Name) | |
| 519 | - | require.NoError(t, catalogueErr) | |
| 520 | - | require.True(t, ok) | |
| 521 | - | require.Equal(t, record, persisted, "transient restore failure must preserve healthy durable authority") | |
| 522 | - | } | |
| 523 | - | ||
| 524 | - | repository.mu.Lock() | |
| 525 | - | delete(repository.errors, record.Name) | |
| 526 | - | repository.mu.Unlock() | |
| 527 | - | require.NoError(t, exec.restoreExplicitRecovery(t.Context())) | |
| 528 | - | d.mu.Lock() | |
| 529 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | |
| 530 | - | d.mu.Unlock() | |
| 531 | - | persisted, ok, err := catalogue.Record(record.Name) | |
| 532 | - | require.NoError(t, err) | |
| 533 | - | require.True(t, ok) | |
| 534 | - | require.Equal(t, domain.RecoveryHealthy, persisted.RecoveryState) | |
| 535 | - | repository.mu.Lock() | |
| 536 | - | require.Equal(t, 3, repository.loads[record.Name]) | |
| 537 | - | repository.mu.Unlock() | |
| 538 | - | } | |
| 539 | - | ||
| 540 | 418 | func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) { | |
| 541 | 419 | for _, tt := range []struct { | |
| 542 | 420 | name string | |
| @@ -600,32 +478,6 @@ func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testi | |||
| 600 | 478 | require.Empty(t, d.sessions) | |
| 601 | 479 | } | |
| 602 | 480 | ||
| 603 | - | func TestExplicitRecoveryRejectsConcurrentRestore(t *testing.T) { | |
| 604 | - | record := durableRecoveryRecord(0) | |
| 605 | - | record.RecoveryState = domain.RecoveryDegraded | |
| 606 | - | record.DegradedReason = "checkpoint validation failed" | |
| 607 | - | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 608 | - | d.mu.Lock() | |
| 609 | - | entry := d.stopped[record.Name] | |
| 610 | - | entry.state = runtimeRestoring | |
| 611 | - | originalDone := entry.restoreDone | |
| 612 | - | d.stopped[record.Name] = entry | |
| 613 | - | d.mu.Unlock() | |
| 614 | - | ||
| 615 | - | err := (controlExec{d: d, recoveryName: record.Name}).restoreExplicitRecovery(t.Context()) | |
| 616 | - | require.ErrorContains(t, err, "current state") | |
| 617 | - | d.mu.Lock() | |
| 618 | - | require.Equal(t, originalDone, d.stopped[record.Name].restoreDone) | |
| 619 | - | d.mu.Unlock() | |
| 620 | - | } | |
| 621 | - | ||
| 622 | - | func TestSessionRecoveryExportRequiresAbsolutePath(t *testing.T) { | |
| 623 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 624 | - | d.degradedRecovery = explicitRecoveryStub{} | |
| 625 | - | _, err := (controlExec{ctx: t.Context(), d: d, recoveryName: "work"}).SessionRecovery("export", "relative.snapshot") | |
| 626 | - | require.ErrorIs(t, err, command.ErrInvalidArguments) | |
| 627 | - | } | |
| 628 | - | ||
| 629 | 481 | func TestSessionRecoveryCommand(t *testing.T) { | |
| 630 | 482 | degraded := durableRecoveryRecord(2) | |
| 631 | 483 | degraded.RecoveryState = domain.RecoveryDegraded | |
| @@ -635,11 +487,10 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||
| 635 | 487 | fresh.IncarnationID = domain.IncarnationID{9} | |
| 636 | 488 | fresh.RecoveryState = domain.RecoveryFresh | |
| 637 | 489 | fresh.Committed = nil | |
| 638 | - | fresh.Fallbacks = [2]*domain.CheckpointRef{} | |
| 639 | 490 | fresh.DegradedReason = "" | |
| 640 | 491 | d.degradedRecovery = explicitRecoveryStub{record: fresh} | |
| 641 | 492 | ||
| 642 | - | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard", "") | |
| 493 | + | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard") | |
| 643 | 494 | require.NoError(t, err) | |
| 644 | 495 | require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state) | |
| 645 | 496 | require.Equal(t, fresh, d.stopped[degraded.Name].record) | |
| @@ -2,7 +2,6 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "crypto/sha256" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "io" | 7 | "io" | ||
| @@ -19,7 +18,6 @@ import ( | |||||
| 19 | "github.com/bnema/vev/internal/domain" | 18 | "github.com/bnema/vev/internal/domain" | ||
| 20 | "github.com/bnema/vev/internal/ports" | 19 | "github.com/bnema/vev/internal/ports" | ||
| 21 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 22 | - | "github.com/bnema/vev/internal/usecase/command" | |||
| 23 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 21 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 24 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 22 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 25 | ) | 23 | ) | ||
| @@ -284,63 +282,6 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||||
| 284 | require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime") | 282 | require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime") | ||
| 285 | }) | 283 | }) | ||
| 286 | 284 | ||||
| 287 | - | t.Run("fallback promotion", func(t *testing.T) { | |||
| 288 | - | fallback := durableRecoveryRecord(2) | |||
| 289 | - | fallbackRef := domain.CheckpointRef{Generation: fallback.Committed.Generation - 1} | |||
| 290 | - | fallbackSnapshotRecord := fallback | |||
| 291 | - | fallbackSnapshotRecord.Committed = &fallbackRef | |||
| 292 | - | fallbackGeneration := validGeneration(t, fallbackSnapshotRecord) | |||
| 293 | - | fallbackRef.ManifestDigest = sha256.Sum256(fallbackGeneration.Manifest) | |||
| 294 | - | fallback.Fallbacks[0] = &fallbackRef | |||
| 295 | - | fallbackRepository := &durableRecoveryRepository{ | |||
| 296 | - | generations: map[string]ports.SnapshotGeneration{fallback.Name: fallbackGeneration}, | |||
| 297 | - | errors: make(map[string]error), | |||
| 298 | - | loads: make(map[string]int), | |||
| 299 | - | repairs: make(map[string]domain.CheckpointRef), | |||
| 300 | - | } | |||
| 301 | - | fallbackDaemon, fallbackCatalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fallback}, fallbackRepository) | |||
| 302 | - | fallbackDaemon.restoreCatalogue(context.Background(), mustDurableRecords(t, fallbackCatalogue)) | |||
| 303 | - | promoted, ok, _ := fallbackCatalogue.Record(fallback.Name) | |||
| 304 | - | require.True(t, ok) | |||
| 305 | - | require.Equal(t, fallbackRef, *promoted.Committed) | |||
| 306 | - | require.Equal(t, runtimeHealthy, fallbackDaemon.stopped[fallback.Name].state) | |||
| 307 | - | require.Equal(t, 3, fallbackRepository.loads[fallback.Name]) | |||
| 308 | - | require.Equal(t, fallbackRef, fallbackRepository.repairs[fallback.IncarnationID.String()]) | |||
| 309 | - | }) | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | - | func TestCatalogueRestoreKeepsCommittedFallbackHealthyWhenHEADRepairFails(t *testing.T) { | |||
| 313 | - | record := durableRecoveryRecord(2) | |||
| 314 | - | fallbackRef := domain.CheckpointRef{Generation: record.Committed.Generation - 1} | |||
| 315 | - | fallbackRecord := record | |||
| 316 | - | fallbackRecord.Committed = &fallbackRef | |||
| 317 | - | generation := validGeneration(t, fallbackRecord) | |||
| 318 | - | fallbackRef.ManifestDigest = sha256.Sum256(generation.Manifest) | |||
| 319 | - | record.Fallbacks[0] = &fallbackRef | |||
| 320 | - | repository := &durableRecoveryRepository{ | |||
| 321 | - | generations: map[string]ports.SnapshotGeneration{record.Name: generation}, | |||
| 322 | - | errors: make(map[string]error), | |||
| 323 | - | loads: make(map[string]int), | |||
| 324 | - | repairs: make(map[string]domain.CheckpointRef), | |||
| 325 | - | repairErr: errors.New("head repair unavailable"), | |||
| 326 | - | } | |||
| 327 | - | ||||
| 328 | - | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 329 | - | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 330 | - | ||||
| 331 | - | committed, ok, _ := catalogue.Record(record.Name) | |||
| 332 | - | require.True(t, ok) | |||
| 333 | - | require.Equal(t, domain.RecoveryHealthy, committed.RecoveryState) | |||
| 334 | - | require.Equal(t, fallbackRef, *committed.Committed) | |||
| 335 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | |||
| 336 | - | require.Equal(t, 1, repository.repairCalls) | |||
| 337 | - | ||||
| 338 | - | repository.repairErr = nil | |||
| 339 | - | next, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{committed}, repository) | |||
| 340 | - | next.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 341 | - | require.Equal(t, runtimeHealthy, next.stopped[record.Name].state) | |||
| 342 | - | require.Equal(t, 2, repository.repairCalls, "the next startup retries the pending HEAD repair") | |||
| 343 | - | require.Equal(t, fallbackRef, repository.repairs[record.IncarnationID.String()]) | |||
| 344 | } | 285 | } | ||
| 345 | 286 | ||||
| 346 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | 287 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | ||
| @@ -414,14 +355,6 @@ type explicitRecoveryStub struct { | |||||
| 414 | record domain.CatalogueRecord | 355 | record domain.CatalogueRecord | ||
| 415 | } | 356 | } | ||
| 416 | 357 | ||||
| 417 | - | func (s explicitRecoveryStub) Retry(context.Context, string) error { return nil } | |||
| 418 | - | func (s explicitRecoveryStub) RestoreFallback(context.Context, string, domain.CheckpointRef) error { | |||
| 419 | - | return nil | |||
| 420 | - | } | |||
| 421 | - | func (s explicitRecoveryStub) Export(_ context.Context, _ string, w io.Writer) error { | |||
| 422 | - | _, err := w.Write([]byte("export")) | |||
| 423 | - | return err | |||
| 424 | - | } | |||
| 425 | func (s explicitRecoveryStub) Discard(context.Context, string, string) (domain.CatalogueRecord, error) { | 358 | func (s explicitRecoveryStub) Discard(context.Context, string, string) (domain.CatalogueRecord, error) { | ||
| 426 | return s.record, nil | 359 | return s.record, nil | ||
| 427 | } | 360 | } | ||
| @@ -434,9 +367,9 @@ type contextRecoveryStub struct { | |||||
| 434 | err error | 367 | err error | ||
| 435 | } | 368 | } | ||
| 436 | 369 | ||||
| 437 | - | func (s *contextRecoveryStub) Retry(ctx context.Context, _ string) error { | 370 | + | func (s *contextRecoveryStub) Discard(ctx context.Context, _, _ string) (domain.CatalogueRecord, error) { |
| 438 | s.seen = ctx.Value(s.wantKey) == s.want | 371 | s.seen = ctx.Value(s.wantKey) == s.want | ||
| 439 | - | return s.err | 372 | + | return domain.CatalogueRecord{}, s.err |
| 440 | } | 373 | } | ||
| 441 | 374 | ||||
| 442 | func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) { | 375 | func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) { | ||
| @@ -448,7 +381,7 @@ func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) { | |||||
| 448 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 381 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 449 | d.degradedRecovery = stub | 382 | d.degradedRecovery = stub | ||
| 450 | 383 | ||||
| 451 | - | _, err := (controlExec{ctx: ctx, d: d, recoveryName: "work"}).SessionRecovery("retry", "") | 384 | + | _, err := (controlExec{ctx: ctx, d: d, recoveryName: "work"}).SessionRecovery("discard") |
| 452 | require.ErrorIs(t, err, wantErr) | 385 | require.ErrorIs(t, err, wantErr) | ||
| 453 | require.True(t, stub.seen) | 386 | require.True(t, stub.seen) | ||
| 454 | } | 387 | } | ||
| @@ -482,61 +415,6 @@ func TestRetryableRestoreLoadErrorClassification(t *testing.T) { | |||||
| 482 | } | 415 | } | ||
| 483 | } | 416 | } | ||
| 484 | 417 | ||||
| 485 | - | func TestExplicitRecoveryRemainsRetryableAfterRepeatedTransientFailures(t *testing.T) { | |||
| 486 | - | record := durableRecoveryRecord(0) | |||
| 487 | - | repository := &durableRecoveryRepository{ | |||
| 488 | - | generations: map[string]ports.SnapshotGeneration{record.Name: validGeneration(t, record)}, | |||
| 489 | - | errors: map[string]error{record.Name: ports.ErrBudgetExhausted}, | |||
| 490 | - | loads: make(map[string]int), | |||
| 491 | - | repairs: make(map[string]domain.CheckpointRef), | |||
| 492 | - | } | |||
| 493 | - | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 494 | - | d.setStoppedRecovery(record, runtimeDegraded) | |||
| 495 | - | exec := controlExec{d: d, recoveryName: record.Name} | |||
| 496 | - | ||||
| 497 | - | var attemptBarriers []chan struct{} | |||
| 498 | - | for range 2 { | |||
| 499 | - | err := exec.restoreExplicitRecovery(t.Context()) | |||
| 500 | - | require.ErrorIs(t, err, errRetryableRestoreLoad) | |||
| 501 | - | require.ErrorIs(t, err, ports.ErrBudgetExhausted) | |||
| 502 | - | ||||
| 503 | - | d.mu.Lock() | |||
| 504 | - | entry := d.stopped[record.Name] | |||
| 505 | - | d.mu.Unlock() | |||
| 506 | - | require.Equal(t, runtimeDegraded, entry.state) | |||
| 507 | - | require.Equal(t, domain.RecoveryHealthy, entry.record.RecoveryState) | |||
| 508 | - | select { | |||
| 509 | - | case <-entry.restoreDone: | |||
| 510 | - | default: | |||
| 511 | - | t.Fatal("failed attempt completion barrier remained open") | |||
| 512 | - | } | |||
| 513 | - | for _, prior := range attemptBarriers { | |||
| 514 | - | require.NotEqual(t, prior, entry.restoreDone, "a closed completion barrier must not be reused") | |||
| 515 | - | } | |||
| 516 | - | attemptBarriers = append(attemptBarriers, entry.restoreDone) | |||
| 517 | - | ||||
| 518 | - | persisted, ok, catalogueErr := catalogue.Record(record.Name) | |||
| 519 | - | require.NoError(t, catalogueErr) | |||
| 520 | - | require.True(t, ok) | |||
| 521 | - | require.Equal(t, record, persisted, "transient restore failure must preserve healthy durable authority") | |||
| 522 | - | } | |||
| 523 | - | ||||
| 524 | - | repository.mu.Lock() | |||
| 525 | - | delete(repository.errors, record.Name) | |||
| 526 | - | repository.mu.Unlock() | |||
| 527 | - | require.NoError(t, exec.restoreExplicitRecovery(t.Context())) | |||
| 528 | - | d.mu.Lock() | |||
| 529 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | |||
| 530 | - | d.mu.Unlock() | |||
| 531 | - | persisted, ok, err := catalogue.Record(record.Name) | |||
| 532 | - | require.NoError(t, err) | |||
| 533 | - | require.True(t, ok) | |||
| 534 | - | require.Equal(t, domain.RecoveryHealthy, persisted.RecoveryState) | |||
| 535 | - | repository.mu.Lock() | |||
| 536 | - | require.Equal(t, 3, repository.loads[record.Name]) | |||
| 537 | - | repository.mu.Unlock() | |||
| 538 | - | } | |||
| 539 | - | ||||
| 540 | func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) { | 418 | func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) { | ||
| 541 | for _, tt := range []struct { | 419 | for _, tt := range []struct { | ||
| 542 | name string | 420 | name string | ||
| @@ -600,32 +478,6 @@ func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testi | |||||
| 600 | require.Empty(t, d.sessions) | 478 | require.Empty(t, d.sessions) | ||
| 601 | } | 479 | } | ||
| 602 | 480 | ||||
| 603 | - | func TestExplicitRecoveryRejectsConcurrentRestore(t *testing.T) { | |||
| 604 | - | record := durableRecoveryRecord(0) | |||
| 605 | - | record.RecoveryState = domain.RecoveryDegraded | |||
| 606 | - | record.DegradedReason = "checkpoint validation failed" | |||
| 607 | - | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |||
| 608 | - | d.mu.Lock() | |||
| 609 | - | entry := d.stopped[record.Name] | |||
| 610 | - | entry.state = runtimeRestoring | |||
| 611 | - | originalDone := entry.restoreDone | |||
| 612 | - | d.stopped[record.Name] = entry | |||
| 613 | - | d.mu.Unlock() | |||
| 614 | - | ||||
| 615 | - | err := (controlExec{d: d, recoveryName: record.Name}).restoreExplicitRecovery(t.Context()) | |||
| 616 | - | require.ErrorContains(t, err, "current state") | |||
| 617 | - | d.mu.Lock() | |||
| 618 | - | require.Equal(t, originalDone, d.stopped[record.Name].restoreDone) | |||
| 619 | - | d.mu.Unlock() | |||
| 620 | - | } | |||
| 621 | - | ||||
| 622 | - | func TestSessionRecoveryExportRequiresAbsolutePath(t *testing.T) { | |||
| 623 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 624 | - | d.degradedRecovery = explicitRecoveryStub{} | |||
| 625 | - | _, err := (controlExec{ctx: t.Context(), d: d, recoveryName: "work"}).SessionRecovery("export", "relative.snapshot") | |||
| 626 | - | require.ErrorIs(t, err, command.ErrInvalidArguments) | |||
| 627 | - | } | |||
| 628 | - | ||||
| 629 | func TestSessionRecoveryCommand(t *testing.T) { | 481 | func TestSessionRecoveryCommand(t *testing.T) { | ||
| 630 | degraded := durableRecoveryRecord(2) | 482 | degraded := durableRecoveryRecord(2) | ||
| 631 | degraded.RecoveryState = domain.RecoveryDegraded | 483 | degraded.RecoveryState = domain.RecoveryDegraded | ||
| @@ -635,11 +487,10 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||||
| 635 | fresh.IncarnationID = domain.IncarnationID{9} | 487 | fresh.IncarnationID = domain.IncarnationID{9} | ||
| 636 | fresh.RecoveryState = domain.RecoveryFresh | 488 | fresh.RecoveryState = domain.RecoveryFresh | ||
| 637 | fresh.Committed = nil | 489 | fresh.Committed = nil | ||
| 638 | - | fresh.Fallbacks = [2]*domain.CheckpointRef{} | |||
| 639 | fresh.DegradedReason = "" | 490 | fresh.DegradedReason = "" | ||
| 640 | d.degradedRecovery = explicitRecoveryStub{record: fresh} | 491 | d.degradedRecovery = explicitRecoveryStub{record: fresh} | ||
| 641 | 492 | ||||
| 642 | - | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard", "") | 493 | + | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard") |
| 643 | require.NoError(t, err) | 494 | require.NoError(t, err) | ||
| 644 | require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state) | 495 | require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state) | ||
| 645 | require.Equal(t, fresh, d.stopped[degraded.Name].record) | 496 | require.Equal(t, fresh, d.stopped[degraded.Name].record) | ||
M internal/usecase/daemon/maintenance.go +0 -5
| @@ -53,11 +53,6 @@ func retentionPlan(record domain.CatalogueRecord, unresolved, restoring bool) po | |||
| 53 | 53 | if record.Committed != nil { | |
| 54 | 54 | plan.Keep = append(plan.Keep, *record.Committed) | |
| 55 | 55 | } | |
| 56 | - | for _, fallback := range record.Fallbacks { | |
| 57 | - | if fallback != nil { | |
| 58 | - | plan.Keep = append(plan.Keep, *fallback) | |
| 59 | - | } | |
| 60 | - | } | |
| 61 | 56 | return plan | |
| 62 | 57 | } | |
| 63 | 58 | ||
| @@ -53,11 +53,6 @@ func retentionPlan(record domain.CatalogueRecord, unresolved, restoring bool) po | |||||
| 53 | if record.Committed != nil { | 53 | if record.Committed != nil { | ||
| 54 | plan.Keep = append(plan.Keep, *record.Committed) | 54 | plan.Keep = append(plan.Keep, *record.Committed) | ||
| 55 | } | 55 | } | ||
| 56 | - | for _, fallback := range record.Fallbacks { | |||
| 57 | - | if fallback != nil { | |||
| 58 | - | plan.Keep = append(plan.Keep, *fallback) | |||
| 59 | - | } | |||
| 60 | - | } | |||
| 61 | return plan | 56 | return plan | ||
| 62 | } | 57 | } | ||
| 63 | 58 | ||||
M internal/usecase/daemon/maintenance_test.go +6 -8
| @@ -83,7 +83,7 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |||
| 83 | 83 | fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |
| 84 | 84 | record := domain.CatalogueRecord{ | |
| 85 | 85 | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | |
| 86 | - | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | |
| 86 | + | Committed: &committed, | |
| 87 | 87 | } | |
| 88 | 88 | wrongParent := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{9}} | |
| 89 | 89 | ||
| @@ -98,14 +98,14 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |||
| 98 | 98 | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |
| 99 | 99 | Name: record.Name, IncarnationID: record.IncarnationID, Ref: committed, Parent: &fallback1, | |
| 100 | 100 | }}, | |
| 101 | - | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |
| 101 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}}, | |
| 102 | 102 | }, | |
| 103 | 103 | { | |
| 104 | 104 | name: "known predecessor is conclusively not forward", | |
| 105 | 105 | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |
| 106 | 106 | Name: record.Name, IncarnationID: record.IncarnationID, Ref: fallback1, Parent: &fallback2, | |
| 107 | 107 | }}, | |
| 108 | - | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |
| 108 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}}, | |
| 109 | 109 | }, | |
| 110 | 110 | { | |
| 111 | 111 | name: "non-forward candidate kind remains pinned", | |
| @@ -171,11 +171,9 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |||
| 171 | 171 | ||
| 172 | 172 | func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) { | |
| 173 | 173 | committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}} | |
| 174 | - | fallback1 := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | |
| 175 | - | fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |
| 176 | 174 | healthy := domain.CatalogueRecord{ | |
| 177 | 175 | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | |
| 178 | - | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | |
| 176 | + | Committed: &committed, | |
| 179 | 177 | } | |
| 180 | 178 | ||
| 181 | 179 | for _, tc := range []struct { | |
| @@ -196,8 +194,8 @@ func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) { | |||
| 196 | 194 | if plan.PinAll != tc.wantPin { | |
| 197 | 195 | t.Fatalf("PinAll = %v, want %v", plan.PinAll, tc.wantPin) | |
| 198 | 196 | } | |
| 199 | - | if !tc.wantPin && len(plan.Keep) != 3 { | |
| 200 | - | t.Fatalf("healthy keep set = %v, want committed plus two fallbacks", plan.Keep) | |
| 197 | + | if !tc.wantPin && len(plan.Keep) != 1 { | |
| 198 | + | t.Fatalf("healthy keep set = %v, want committed only", plan.Keep) | |
| 201 | 199 | } | |
| 202 | 200 | }) | |
| 203 | 201 | } | |
| @@ -83,7 +83,7 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |||||
| 83 | fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | 83 | fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | ||
| 84 | record := domain.CatalogueRecord{ | 84 | record := domain.CatalogueRecord{ | ||
| 85 | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | 85 | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | ||
| 86 | - | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | 86 | + | Committed: &committed, |
| 87 | } | 87 | } | ||
| 88 | wrongParent := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{9}} | 88 | wrongParent := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{9}} | ||
| 89 | 89 | ||||
| @@ -98,14 +98,14 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |||||
| 98 | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | 98 | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | ||
| 99 | Name: record.Name, IncarnationID: record.IncarnationID, Ref: committed, Parent: &fallback1, | 99 | Name: record.Name, IncarnationID: record.IncarnationID, Ref: committed, Parent: &fallback1, | ||
| 100 | }}, | 100 | }}, | ||
| 101 | - | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | 101 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}}, |
| 102 | }, | 102 | }, | ||
| 103 | { | 103 | { | ||
| 104 | name: "known predecessor is conclusively not forward", | 104 | name: "known predecessor is conclusively not forward", | ||
| 105 | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | 105 | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | ||
| 106 | Name: record.Name, IncarnationID: record.IncarnationID, Ref: fallback1, Parent: &fallback2, | 106 | Name: record.Name, IncarnationID: record.IncarnationID, Ref: fallback1, Parent: &fallback2, | ||
| 107 | }}, | 107 | }}, | ||
| 108 | - | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | 108 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}}, |
| 109 | }, | 109 | }, | ||
| 110 | { | 110 | { | ||
| 111 | name: "non-forward candidate kind remains pinned", | 111 | name: "non-forward candidate kind remains pinned", | ||
| @@ -171,11 +171,9 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |||||
| 171 | 171 | ||||
| 172 | func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) { | 172 | func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) { | ||
| 173 | committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}} | 173 | committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}} | ||
| 174 | - | fallback1 := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | |||
| 175 | - | fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |||
| 176 | healthy := domain.CatalogueRecord{ | 174 | healthy := domain.CatalogueRecord{ | ||
| 177 | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | 175 | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | ||
| 178 | - | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | 176 | + | Committed: &committed, |
| 179 | } | 177 | } | ||
| 180 | 178 | ||||
| 181 | for _, tc := range []struct { | 179 | for _, tc := range []struct { | ||
| @@ -196,8 +194,8 @@ func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) { | |||||
| 196 | if plan.PinAll != tc.wantPin { | 194 | if plan.PinAll != tc.wantPin { | ||
| 197 | t.Fatalf("PinAll = %v, want %v", plan.PinAll, tc.wantPin) | 195 | t.Fatalf("PinAll = %v, want %v", plan.PinAll, tc.wantPin) | ||
| 198 | } | 196 | } | ||
| 199 | - | if !tc.wantPin && len(plan.Keep) != 3 { | 197 | + | if !tc.wantPin && len(plan.Keep) != 1 { |
| 200 | - | t.Fatalf("healthy keep set = %v, want committed plus two fallbacks", plan.Keep) | 198 | + | t.Fatalf("healthy keep set = %v, want committed only", plan.Keep) |
| 201 | } | 199 | } | ||
| 202 | }) | 200 | }) | ||
| 203 | } | 201 | } | ||
M internal/usecase/daemon/snapshot_restore_incremental.go +28 -81
| @@ -138,10 +138,6 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||
| 138 | 138 | d.finishRecordRestoreAttempt(record, restoreErr, done, false) | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | - | func (d *Daemon) finishExplicitRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) { | |
| 142 | - | d.finishRecordRestoreAttempt(record, restoreErr, done, true) | |
| 143 | - | } | |
| 144 | - | ||
| 145 | 141 | func (d *Daemon) finishRecordRestoreAttempt(record domain.CatalogueRecord, restoreErr error, done chan struct{}, resetRetryable bool) { | |
| 146 | 142 | retryable := errors.Is(restoreErr, errRetryableRestoreLoad) | |
| 147 | 143 | degraded := false | |
| @@ -215,75 +211,39 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||
| 215 | 211 | return errors.New("snapshot: repository unavailable") | |
| 216 | 212 | } | |
| 217 | 213 | ||
| 218 | - | candidates := checkpointCandidates(record) | |
| 219 | - | if len(candidates) == 0 { | |
| 214 | + | if record.Committed == nil { | |
| 220 | 215 | return errors.New("snapshot: healthy record has no checkpoint") | |
| 221 | 216 | } | |
| 222 | - | var selected domain.CheckpointRef | |
| 223 | - | var selectedGeneration ports.SnapshotGeneration | |
| 224 | - | var selectedSnapshot snapcodec.Session | |
| 225 | - | selectedIndex := -1 | |
| 226 | - | selectedFallback := false | |
| 227 | - | var retryableLoadErr error | |
| 228 | - | for i, candidate := range candidates { | |
| 229 | - | fallback := record.Committed == nil || i > 0 | |
| 230 | - | generation, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, candidate) | |
| 231 | - | if err != nil { | |
| 232 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 233 | - | return ctxErr | |
| 234 | - | } | |
| 235 | - | if isRetryableRestoreLoadError(err) { | |
| 236 | - | retryableLoadErr = err | |
| 237 | - | } | |
| 238 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "load-failed", err) | |
| 239 | - | continue | |
| 240 | - | } | |
| 241 | - | snapshot, decodeErr := sessionFromGeneration(generation) | |
| 242 | - | if decodeErr != nil { | |
| 243 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "decode-failed", decodeErr) | |
| 244 | - | continue | |
| 245 | - | } | |
| 246 | - | if generation.IncarnationID != record.IncarnationID { | |
| 247 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "incarnation-mismatch", nil) | |
| 248 | - | continue | |
| 249 | - | } | |
| 250 | - | if generation.Name != record.Name { | |
| 251 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "name-mismatch", nil) | |
| 252 | - | continue | |
| 217 | + | selected := *record.Committed | |
| 218 | + | selectedGeneration, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, selected) | |
| 219 | + | if err != nil { | |
| 220 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 221 | + | return ctxErr | |
| 253 | 222 | } | |
| 254 | - | if generation.Generation != candidate.Generation { | |
| 255 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "generation-mismatch", nil) | |
| 256 | - | continue | |
| 223 | + | if isRetryableRestoreLoadError(err) { | |
| 224 | + | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) | |
| 257 | 225 | } | |
| 258 | - | selected, selectedGeneration, selectedSnapshot, selectedIndex, selectedFallback = candidate, generation, snapshot, i, fallback | |
| 259 | - | break | |
| 226 | + | d.logRejectedRestoreCandidate(record, selected, false, "load-failed", err) | |
| 227 | + | return errors.New("snapshot: committed checkpoint did not validate") | |
| 260 | 228 | } | |
| 261 | - | if selectedIndex < 0 { | |
| 262 | - | if retryableLoadErr != nil { | |
| 263 | - | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, retryableLoadErr) | |
| 264 | - | } | |
| 265 | - | return errors.New("snapshot: no catalogue checkpoint validated") | |
| 229 | + | selectedSnapshot, err := sessionFromGeneration(selectedGeneration) | |
| 230 | + | if err != nil { | |
| 231 | + | d.logRejectedRestoreCandidate(record, selected, false, "decode-failed", err) | |
| 232 | + | return errors.New("snapshot: committed checkpoint did not validate") | |
| 266 | 233 | } | |
| 267 | - | ||
| 268 | - | if selectedFallback { | |
| 269 | - | if d.checkpointRecovery == nil { | |
| 270 | - | return errors.New("snapshot: checkpoint coordinator unavailable for fallback promotion") | |
| 271 | - | } | |
| 272 | - | outcome, err := d.checkpointRecovery.PromoteFallback(ctx, record.Name, selected) | |
| 273 | - | if err != nil { | |
| 274 | - | return fmt.Errorf("snapshot: promote fallback: %w", err) | |
| 275 | - | } | |
| 276 | - | if !outcome.CatalogueCommitted { | |
| 277 | - | return errors.New("snapshot: fallback promotion did not commit catalogue") | |
| 278 | - | } | |
| 279 | - | record = outcome.Record | |
| 280 | - | if outcome.HEADRepairError != nil { | |
| 281 | - | d.log.Warn("snapshot_head_repair_pending", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation, "reason_code", "repair-failed") | |
| 282 | - | } else { | |
| 283 | - | d.log.Info("fallback_checkpoint_promoted", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation) | |
| 284 | - | d.log.Info("snapshot_head_repair_complete", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation) | |
| 285 | - | } | |
| 286 | - | } else if err := d.snapshotRepository.RepairHEAD(ctx, record.IncarnationID, selected); err != nil { | |
| 234 | + | if selectedGeneration.IncarnationID != record.IncarnationID { | |
| 235 | + | d.logRejectedRestoreCandidate(record, selected, false, "incarnation-mismatch", nil) | |
| 236 | + | return errors.New("snapshot: committed checkpoint did not validate") | |
| 237 | + | } | |
| 238 | + | if selectedGeneration.Name != record.Name { | |
| 239 | + | d.logRejectedRestoreCandidate(record, selected, false, "name-mismatch", nil) | |
| 240 | + | return errors.New("snapshot: committed checkpoint did not validate") | |
| 241 | + | } | |
| 242 | + | if selectedGeneration.Generation != selected.Generation { | |
| 243 | + | d.logRejectedRestoreCandidate(record, selected, false, "generation-mismatch", nil) | |
| 244 | + | return errors.New("snapshot: committed checkpoint did not validate") | |
| 245 | + | } | |
| 246 | + | if err := d.snapshotRepository.RepairHEAD(ctx, record.IncarnationID, selected); err != nil { | |
| 287 | 247 | d.log.Warn("snapshot_head_repair_pending", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation, "reason_code", "repair-failed") | |
| 288 | 248 | } else { | |
| 289 | 249 | d.log.Info("snapshot_head_repair_complete", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation) | |
| @@ -293,7 +253,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||
| 293 | 253 | return err | |
| 294 | 254 | } | |
| 295 | 255 | d.setStoppedRecovery(record, runtimeHealthy) | |
| 296 | - | d.logSessionRestoreComplete(record, selected.Generation, selectedFallback) | |
| 256 | + | d.logSessionRestoreComplete(record, selected.Generation, false) | |
| 297 | 257 | return nil | |
| 298 | 258 | } | |
| 299 | 259 | ||
| @@ -334,19 +294,6 @@ func (d *Daemon) logRejectedRestoreCandidate(record domain.CatalogueRecord, cand | |||
| 334 | 294 | ) | |
| 335 | 295 | } | |
| 336 | 296 | ||
| 337 | - | func checkpointCandidates(record domain.CatalogueRecord) []domain.CheckpointRef { | |
| 338 | - | candidates := make([]domain.CheckpointRef, 0, 3) | |
| 339 | - | if record.Committed != nil { | |
| 340 | - | candidates = append(candidates, *record.Committed) | |
| 341 | - | } | |
| 342 | - | for _, fallback := range record.Fallbacks { | |
| 343 | - | if fallback != nil { | |
| 344 | - | candidates = append(candidates, *fallback) | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | return candidates | |
| 348 | - | } | |
| 349 | - | ||
| 350 | 297 | func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) { | |
| 351 | 298 | manifest, err := snapcodec.UnmarshalManifest(generation.Manifest) | |
| 352 | 299 | if err != nil { | |
| @@ -138,10 +138,6 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||||
| 138 | d.finishRecordRestoreAttempt(record, restoreErr, done, false) | 138 | d.finishRecordRestoreAttempt(record, restoreErr, done, false) | ||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | - | func (d *Daemon) finishExplicitRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) { | |||
| 142 | - | d.finishRecordRestoreAttempt(record, restoreErr, done, true) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | func (d *Daemon) finishRecordRestoreAttempt(record domain.CatalogueRecord, restoreErr error, done chan struct{}, resetRetryable bool) { | 141 | func (d *Daemon) finishRecordRestoreAttempt(record domain.CatalogueRecord, restoreErr error, done chan struct{}, resetRetryable bool) { | ||
| 146 | retryable := errors.Is(restoreErr, errRetryableRestoreLoad) | 142 | retryable := errors.Is(restoreErr, errRetryableRestoreLoad) | ||
| 147 | degraded := false | 143 | degraded := false | ||
| @@ -215,75 +211,39 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||||
| 215 | return errors.New("snapshot: repository unavailable") | 211 | return errors.New("snapshot: repository unavailable") | ||
| 216 | } | 212 | } | ||
| 217 | 213 | ||||
| 218 | - | candidates := checkpointCandidates(record) | 214 | + | if record.Committed == nil { |
| 219 | - | if len(candidates) == 0 { | |||
| 220 | return errors.New("snapshot: healthy record has no checkpoint") | 215 | return errors.New("snapshot: healthy record has no checkpoint") | ||
| 221 | } | 216 | } | ||
| 222 | - | var selected domain.CheckpointRef | 217 | + | selected := *record.Committed |
| 223 | - | var selectedGeneration ports.SnapshotGeneration | 218 | + | selectedGeneration, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, selected) |
| 224 | - | var selectedSnapshot snapcodec.Session | 219 | + | if err != nil { |
| 225 | - | selectedIndex := -1 | 220 | + | if ctxErr := ctx.Err(); ctxErr != nil { |
| 226 | - | selectedFallback := false | 221 | + | return ctxErr |
| 227 | - | var retryableLoadErr error | |||
| 228 | - | for i, candidate := range candidates { | |||
| 229 | - | fallback := record.Committed == nil || i > 0 | |||
| 230 | - | generation, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, candidate) | |||
| 231 | - | if err != nil { | |||
| 232 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 233 | - | return ctxErr | |||
| 234 | - | } | |||
| 235 | - | if isRetryableRestoreLoadError(err) { | |||
| 236 | - | retryableLoadErr = err | |||
| 237 | - | } | |||
| 238 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "load-failed", err) | |||
| 239 | - | continue | |||
| 240 | - | } | |||
| 241 | - | snapshot, decodeErr := sessionFromGeneration(generation) | |||
| 242 | - | if decodeErr != nil { | |||
| 243 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "decode-failed", decodeErr) | |||
| 244 | - | continue | |||
| 245 | - | } | |||
| 246 | - | if generation.IncarnationID != record.IncarnationID { | |||
| 247 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "incarnation-mismatch", nil) | |||
| 248 | - | continue | |||
| 249 | - | } | |||
| 250 | - | if generation.Name != record.Name { | |||
| 251 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "name-mismatch", nil) | |||
| 252 | - | continue | |||
| 253 | } | 222 | } | ||
| 254 | - | if generation.Generation != candidate.Generation { | 223 | + | if isRetryableRestoreLoadError(err) { |
| 255 | - | d.logRejectedRestoreCandidate(record, candidate, fallback, "generation-mismatch", nil) | 224 | + | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) |
| 256 | - | continue | |||
| 257 | } | 225 | } | ||
| 258 | - | selected, selectedGeneration, selectedSnapshot, selectedIndex, selectedFallback = candidate, generation, snapshot, i, fallback | 226 | + | d.logRejectedRestoreCandidate(record, selected, false, "load-failed", err) |
| 259 | - | break | 227 | + | return errors.New("snapshot: committed checkpoint did not validate") |
| 260 | } | 228 | } | ||
| 261 | - | if selectedIndex < 0 { | 229 | + | selectedSnapshot, err := sessionFromGeneration(selectedGeneration) |
| 262 | - | if retryableLoadErr != nil { | 230 | + | if err != nil { |
| 263 | - | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, retryableLoadErr) | 231 | + | d.logRejectedRestoreCandidate(record, selected, false, "decode-failed", err) |
| 264 | - | } | 232 | + | return errors.New("snapshot: committed checkpoint did not validate") |
| 265 | - | return errors.New("snapshot: no catalogue checkpoint validated") | |||
| 266 | } | 233 | } | ||
| 267 | - | 234 | + | if selectedGeneration.IncarnationID != record.IncarnationID { | |
| 268 | - | if selectedFallback { | 235 | + | d.logRejectedRestoreCandidate(record, selected, false, "incarnation-mismatch", nil) |
| 269 | - | if d.checkpointRecovery == nil { | 236 | + | return errors.New("snapshot: committed checkpoint did not validate") |
| 270 | - | return errors.New("snapshot: checkpoint coordinator unavailable for fallback promotion") | 237 | + | } |
| 271 | - | } | 238 | + | if selectedGeneration.Name != record.Name { |
| 272 | - | outcome, err := d.checkpointRecovery.PromoteFallback(ctx, record.Name, selected) | 239 | + | d.logRejectedRestoreCandidate(record, selected, false, "name-mismatch", nil) |
| 273 | - | if err != nil { | 240 | + | return errors.New("snapshot: committed checkpoint did not validate") |
| 274 | - | return fmt.Errorf("snapshot: promote fallback: %w", err) | 241 | + | } |
| 275 | - | } | 242 | + | if selectedGeneration.Generation != selected.Generation { |
| 276 | - | if !outcome.CatalogueCommitted { | 243 | + | d.logRejectedRestoreCandidate(record, selected, false, "generation-mismatch", nil) |
| 277 | - | return errors.New("snapshot: fallback promotion did not commit catalogue") | 244 | + | return errors.New("snapshot: committed checkpoint did not validate") |
| 278 | - | } | 245 | + | } |
| 279 | - | record = outcome.Record | 246 | + | if err := d.snapshotRepository.RepairHEAD(ctx, record.IncarnationID, selected); err != nil { |
| 280 | - | if outcome.HEADRepairError != nil { | |||
| 281 | - | d.log.Warn("snapshot_head_repair_pending", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation, "reason_code", "repair-failed") | |||
| 282 | - | } else { | |||
| 283 | - | d.log.Info("fallback_checkpoint_promoted", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation) | |||
| 284 | - | d.log.Info("snapshot_head_repair_complete", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation) | |||
| 285 | - | } | |||
| 286 | - | } else if err := d.snapshotRepository.RepairHEAD(ctx, record.IncarnationID, selected); err != nil { | |||
| 287 | d.log.Warn("snapshot_head_repair_pending", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation, "reason_code", "repair-failed") | 247 | d.log.Warn("snapshot_head_repair_pending", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation, "reason_code", "repair-failed") | ||
| 288 | } else { | 248 | } else { | ||
| 289 | d.log.Info("snapshot_head_repair_complete", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation) | 249 | d.log.Info("snapshot_head_repair_complete", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation) | ||
| @@ -293,7 +253,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||||
| 293 | return err | 253 | return err | ||
| 294 | } | 254 | } | ||
| 295 | d.setStoppedRecovery(record, runtimeHealthy) | 255 | d.setStoppedRecovery(record, runtimeHealthy) | ||
| 296 | - | d.logSessionRestoreComplete(record, selected.Generation, selectedFallback) | 256 | + | d.logSessionRestoreComplete(record, selected.Generation, false) |
| 297 | return nil | 257 | return nil | ||
| 298 | } | 258 | } | ||
| 299 | 259 | ||||
| @@ -334,19 +294,6 @@ func (d *Daemon) logRejectedRestoreCandidate(record domain.CatalogueRecord, cand | |||||
| 334 | ) | 294 | ) | ||
| 335 | } | 295 | } | ||
| 336 | 296 | ||||
| 337 | - | func checkpointCandidates(record domain.CatalogueRecord) []domain.CheckpointRef { | |||
| 338 | - | candidates := make([]domain.CheckpointRef, 0, 3) | |||
| 339 | - | if record.Committed != nil { | |||
| 340 | - | candidates = append(candidates, *record.Committed) | |||
| 341 | - | } | |||
| 342 | - | for _, fallback := range record.Fallbacks { | |||
| 343 | - | if fallback != nil { | |||
| 344 | - | candidates = append(candidates, *fallback) | |||
| 345 | - | } | |||
| 346 | - | } | |||
| 347 | - | return candidates | |||
| 348 | - | } | |||
| 349 | - | ||||
| 350 | func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) { | 297 | func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) { | ||
| 351 | manifest, err := snapcodec.UnmarshalManifest(generation.Manifest) | 298 | manifest, err := snapcodec.UnmarshalManifest(generation.Manifest) | ||
| 352 | if err != nil { | 299 | if err != nil { | ||
M internal/usecase/daemon/snapshot_restore_test.go +1 -6
| @@ -262,13 +262,11 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||
| 262 | 262 | generation := acceptanceGeneration(t, snapshot, 9) | |
| 263 | 263 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | |
| 264 | 264 | committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | |
| 265 | - | fallback1 := domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{8}} | |
| 266 | - | fallback2 := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | |
| 267 | 265 | record := domain.CatalogueRecord{ | |
| 268 | 266 | Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", | |
| 269 | 267 | CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17, | |
| 270 | 268 | TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy, | |
| 271 | - | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | |
| 269 | + | Committed: &committed, | |
| 272 | 270 | } | |
| 273 | 271 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |
| 274 | 272 | ||
| @@ -296,7 +294,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||
| 296 | 294 | require.Equal(t, record.IncarnationID, updated.IncarnationID) | |
| 297 | 295 | require.Equal(t, record.RecoveryState, updated.RecoveryState) | |
| 298 | 296 | require.Equal(t, record.Committed, updated.Committed) | |
| 299 | - | require.Equal(t, record.Fallbacks, updated.Fallbacks) | |
| 300 | 297 | require.Equal(t, record.DegradedReason, updated.DegradedReason) | |
| 301 | 298 | require.Equal(t, []string{"after"}, updated.TabNames) | |
| 302 | 299 | ||
| @@ -314,8 +311,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||
| 314 | 311 | require.NoError(t, err) | |
| 315 | 312 | require.True(t, ok) | |
| 316 | 313 | require.Equal(t, uint64(10), published.Committed.Generation) | |
| 317 | - | require.Equal(t, committed, *published.Fallbacks[0]) | |
| 318 | - | require.Equal(t, fallback1, *published.Fallbacks[1]) | |
| 319 | 314 | } | |
| 320 | 315 | ||
| 321 | 316 | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |
| @@ -262,13 +262,11 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||||
| 262 | generation := acceptanceGeneration(t, snapshot, 9) | 262 | generation := acceptanceGeneration(t, snapshot, 9) | ||
| 263 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | 263 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | ||
| 264 | committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | 264 | committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | ||
| 265 | - | fallback1 := domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{8}} | |||
| 266 | - | fallback2 := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | |||
| 267 | record := domain.CatalogueRecord{ | 265 | record := domain.CatalogueRecord{ | ||
| 268 | Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", | 266 | Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", | ||
| 269 | CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17, | 267 | CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17, | ||
| 270 | TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy, | 268 | TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy, | ||
| 271 | - | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | 269 | + | Committed: &committed, |
| 272 | } | 270 | } | ||
| 273 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | 271 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | ||
| 274 | 272 | ||||
| @@ -296,7 +294,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||||
| 296 | require.Equal(t, record.IncarnationID, updated.IncarnationID) | 294 | require.Equal(t, record.IncarnationID, updated.IncarnationID) | ||
| 297 | require.Equal(t, record.RecoveryState, updated.RecoveryState) | 295 | require.Equal(t, record.RecoveryState, updated.RecoveryState) | ||
| 298 | require.Equal(t, record.Committed, updated.Committed) | 296 | require.Equal(t, record.Committed, updated.Committed) | ||
| 299 | - | require.Equal(t, record.Fallbacks, updated.Fallbacks) | |||
| 300 | require.Equal(t, record.DegradedReason, updated.DegradedReason) | 297 | require.Equal(t, record.DegradedReason, updated.DegradedReason) | ||
| 301 | require.Equal(t, []string{"after"}, updated.TabNames) | 298 | require.Equal(t, []string{"after"}, updated.TabNames) | ||
| 302 | 299 | ||||
| @@ -314,8 +311,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||||
| 314 | require.NoError(t, err) | 311 | require.NoError(t, err) | ||
| 315 | require.True(t, ok) | 312 | require.True(t, ok) | ||
| 316 | require.Equal(t, uint64(10), published.Committed.Generation) | 313 | require.Equal(t, uint64(10), published.Committed.Generation) | ||
| 317 | - | require.Equal(t, committed, *published.Fallbacks[0]) | |||
| 318 | - | require.Equal(t, fallback1, *published.Fallbacks[1]) | |||
| 319 | } | 314 | } | ||
| 320 | 315 | ||||
| 321 | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | 316 | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | ||
M internal/usecase/recovery/checkpoint.go +3 -89
| @@ -13,20 +13,8 @@ import ( | |||
| 13 | 13 | var ( | |
| 14 | 14 | ErrCheckpointRecordNotFound = errors.New("recovery: checkpoint catalogue record not found") | |
| 15 | 15 | ErrCheckpointConflict = errors.New("recovery: checkpoint publication conflicts with catalogue") | |
| 16 | - | ErrFallbackNotFound = errors.New("recovery: checkpoint is not a catalogue fallback") | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| 19 | - | func shiftedCheckpoint(record domain.CatalogueRecord, next domain.CheckpointRef) domain.CatalogueRecord { | |
| 20 | - | prior := copyCheckpointRef(record.Committed) | |
| 21 | - | priorFallback := copyCheckpointRef(record.Fallbacks[0]) | |
| 22 | - | nextRecord := record | |
| 23 | - | nextRecord.RecoveryState = domain.RecoveryHealthy | |
| 24 | - | nextRecord.Committed = copyCheckpointRef(&next) | |
| 25 | - | nextRecord.Fallbacks = [2]*domain.CheckpointRef{prior, priorFallback} | |
| 26 | - | nextRecord.DegradedReason = "" | |
| 27 | - | return nextRecord | |
| 28 | - | } | |
| 29 | - | ||
| 30 | 18 | // PublishCheckpoint publishes repository data before atomically making its | |
| 31 | 19 | // reference authoritative in the catalogue. A catalogue failure deliberately | |
| 32 | 20 | // leaves the durable publication as a same-incarnation forward orphan. | |
| @@ -54,91 +42,17 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public | |||
| 54 | 42 | } | |
| 55 | 43 | ||
| 56 | 44 | ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)} | |
| 57 | - | next := shiftedCheckpoint(record, ref) | |
| 58 | - | if err := next.Validate(); err != nil { | |
| 59 | - | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid checkpoint transition: %w", err) | |
| 60 | - | } | |
| 61 | - | if err := c.catalogue.Replace(name, next); err != nil { | |
| 62 | - | return domain.CatalogueRecord{}, err | |
| 63 | - | } | |
| 64 | - | return next, nil | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // PromoteFallback validates a directly indexed fallback and any alternatives, | |
| 68 | - | // commits the bounded validated set, and repairs HEAD only after the catalogue | |
| 69 | - | // commit is durable. | |
| 70 | - | func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |
| 71 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil { | |
| 72 | - | return ports.FallbackPromotionOutcome{}, errors.New("recovery: incomplete checkpoint coordinator dependencies") | |
| 73 | - | } | |
| 74 | - | unlock := c.locks.Lock([]string{name}) | |
| 75 | - | defer unlock() | |
| 76 | - | return c.promoteFallbackLocked(ctx, name, ref) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func (c *Coordinator) promoteFallbackLocked(ctx context.Context, name string, ref domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |
| 80 | - | record, ok, err := c.catalogue.Record(name) | |
| 81 | - | if err != nil { | |
| 82 | - | return ports.FallbackPromotionOutcome{}, err | |
| 83 | - | } | |
| 84 | - | if !ok { | |
| 85 | - | return ports.FallbackPromotionOutcome{}, ErrCheckpointRecordNotFound | |
| 86 | - | } | |
| 87 | - | found := false | |
| 88 | - | for _, fallback := range record.Fallbacks { | |
| 89 | - | if fallback != nil && *fallback == ref { | |
| 90 | - | found = true | |
| 91 | - | break | |
| 92 | - | } | |
| 93 | - | } | |
| 94 | - | if !found { | |
| 95 | - | return ports.FallbackPromotionOutcome{}, ErrFallbackNotFound | |
| 96 | - | } | |
| 97 | - | if err := c.validateCheckpoint(ctx, record, ref); err != nil { | |
| 98 | - | return ports.FallbackPromotionOutcome{}, err | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | alternatives := make([]domain.CheckpointRef, 0, 2) | |
| 102 | - | candidates := make([]*domain.CheckpointRef, 0, 3) | |
| 103 | - | candidates = append(candidates, record.Committed) | |
| 104 | - | candidates = append(candidates, record.Fallbacks[:]...) | |
| 105 | - | for _, candidate := range candidates { | |
| 106 | - | if len(alternatives) == 2 { | |
| 107 | - | break | |
| 108 | - | } | |
| 109 | - | if candidate == nil || *candidate == ref || candidate.Generation >= ref.Generation { | |
| 110 | - | continue | |
| 111 | - | } | |
| 112 | - | if err := c.validateCheckpoint(ctx, record, *candidate); err == nil { | |
| 113 | - | alternatives = append(alternatives, *candidate) | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | ||
| 117 | 45 | next := record | |
| 118 | 46 | next.RecoveryState = domain.RecoveryHealthy | |
| 119 | 47 | next.Committed = copyCheckpointRef(&ref) | |
| 120 | - | next.Fallbacks = [2]*domain.CheckpointRef{} | |
| 121 | 48 | next.DegradedReason = "" | |
| 122 | - | for i := range alternatives { | |
| 123 | - | next.Fallbacks[i] = copyCheckpointRef(&alternatives[i]) | |
| 124 | - | } | |
| 125 | 49 | if err := next.Validate(); err != nil { | |
| 126 | - | return ports.FallbackPromotionOutcome{}, fmt.Errorf("recovery: invalid fallback promotion: %w", err) | |
| 50 | + | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid checkpoint transition: %w", err) | |
| 127 | 51 | } | |
| 128 | 52 | if err := c.catalogue.Replace(name, next); err != nil { | |
| 129 | - | return ports.FallbackPromotionOutcome{}, err | |
| 130 | - | } | |
| 131 | - | outcome := ports.FallbackPromotionOutcome{Record: next, CatalogueCommitted: true} | |
| 132 | - | outcome.HEADRepairError = c.repository.RepairHEAD(ctx, record.IncarnationID, ref) | |
| 133 | - | return outcome, nil | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | func (c *Coordinator) validateCheckpoint(ctx context.Context, record domain.CatalogueRecord, ref domain.CheckpointRef) error { | |
| 137 | - | generation, err := c.repository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, ref) | |
| 138 | - | if err != nil { | |
| 139 | - | return err | |
| 53 | + | return domain.CatalogueRecord{}, err | |
| 140 | 54 | } | |
| 141 | - | return validateLoadedGeneration(record, ref, generation) | |
| 55 | + | return next, nil | |
| 142 | 56 | } | |
| 143 | 57 | ||
| 144 | 58 | func equalCheckpointRefs(a, b *domain.CheckpointRef) bool { return a.Equal(b) } | |
| @@ -13,20 +13,8 @@ import ( | |||||
| 13 | var ( | 13 | var ( | ||
| 14 | ErrCheckpointRecordNotFound = errors.New("recovery: checkpoint catalogue record not found") | 14 | ErrCheckpointRecordNotFound = errors.New("recovery: checkpoint catalogue record not found") | ||
| 15 | ErrCheckpointConflict = errors.New("recovery: checkpoint publication conflicts with catalogue") | 15 | ErrCheckpointConflict = errors.New("recovery: checkpoint publication conflicts with catalogue") | ||
| 16 | - | ErrFallbackNotFound = errors.New("recovery: checkpoint is not a catalogue fallback") | |||
| 17 | ) | 16 | ) | ||
| 18 | 17 | ||||
| 19 | - | func shiftedCheckpoint(record domain.CatalogueRecord, next domain.CheckpointRef) domain.CatalogueRecord { | |||
| 20 | - | prior := copyCheckpointRef(record.Committed) | |||
| 21 | - | priorFallback := copyCheckpointRef(record.Fallbacks[0]) | |||
| 22 | - | nextRecord := record | |||
| 23 | - | nextRecord.RecoveryState = domain.RecoveryHealthy | |||
| 24 | - | nextRecord.Committed = copyCheckpointRef(&next) | |||
| 25 | - | nextRecord.Fallbacks = [2]*domain.CheckpointRef{prior, priorFallback} | |||
| 26 | - | nextRecord.DegradedReason = "" | |||
| 27 | - | return nextRecord | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | // PublishCheckpoint publishes repository data before atomically making its | 18 | // PublishCheckpoint publishes repository data before atomically making its | ||
| 31 | // reference authoritative in the catalogue. A catalogue failure deliberately | 19 | // reference authoritative in the catalogue. A catalogue failure deliberately | ||
| 32 | // leaves the durable publication as a same-incarnation forward orphan. | 20 | // leaves the durable publication as a same-incarnation forward orphan. | ||
| @@ -54,91 +42,17 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public | |||||
| 54 | } | 42 | } | ||
| 55 | 43 | ||||
| 56 | ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)} | 44 | ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)} | ||
| 57 | - | next := shiftedCheckpoint(record, ref) | |||
| 58 | - | if err := next.Validate(); err != nil { | |||
| 59 | - | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid checkpoint transition: %w", err) | |||
| 60 | - | } | |||
| 61 | - | if err := c.catalogue.Replace(name, next); err != nil { | |||
| 62 | - | return domain.CatalogueRecord{}, err | |||
| 63 | - | } | |||
| 64 | - | return next, nil | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | // PromoteFallback validates a directly indexed fallback and any alternatives, | |||
| 68 | - | // commits the bounded validated set, and repairs HEAD only after the catalogue | |||
| 69 | - | // commit is durable. | |||
| 70 | - | func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |||
| 71 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil { | |||
| 72 | - | return ports.FallbackPromotionOutcome{}, errors.New("recovery: incomplete checkpoint coordinator dependencies") | |||
| 73 | - | } | |||
| 74 | - | unlock := c.locks.Lock([]string{name}) | |||
| 75 | - | defer unlock() | |||
| 76 | - | return c.promoteFallbackLocked(ctx, name, ref) | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func (c *Coordinator) promoteFallbackLocked(ctx context.Context, name string, ref domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) { | |||
| 80 | - | record, ok, err := c.catalogue.Record(name) | |||
| 81 | - | if err != nil { | |||
| 82 | - | return ports.FallbackPromotionOutcome{}, err | |||
| 83 | - | } | |||
| 84 | - | if !ok { | |||
| 85 | - | return ports.FallbackPromotionOutcome{}, ErrCheckpointRecordNotFound | |||
| 86 | - | } | |||
| 87 | - | found := false | |||
| 88 | - | for _, fallback := range record.Fallbacks { | |||
| 89 | - | if fallback != nil && *fallback == ref { | |||
| 90 | - | found = true | |||
| 91 | - | break | |||
| 92 | - | } | |||
| 93 | - | } | |||
| 94 | - | if !found { | |||
| 95 | - | return ports.FallbackPromotionOutcome{}, ErrFallbackNotFound | |||
| 96 | - | } | |||
| 97 | - | if err := c.validateCheckpoint(ctx, record, ref); err != nil { | |||
| 98 | - | return ports.FallbackPromotionOutcome{}, err | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | alternatives := make([]domain.CheckpointRef, 0, 2) | |||
| 102 | - | candidates := make([]*domain.CheckpointRef, 0, 3) | |||
| 103 | - | candidates = append(candidates, record.Committed) | |||
| 104 | - | candidates = append(candidates, record.Fallbacks[:]...) | |||
| 105 | - | for _, candidate := range candidates { | |||
| 106 | - | if len(alternatives) == 2 { | |||
| 107 | - | break | |||
| 108 | - | } | |||
| 109 | - | if candidate == nil || *candidate == ref || candidate.Generation >= ref.Generation { | |||
| 110 | - | continue | |||
| 111 | - | } | |||
| 112 | - | if err := c.validateCheckpoint(ctx, record, *candidate); err == nil { | |||
| 113 | - | alternatives = append(alternatives, *candidate) | |||
| 114 | - | } | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | next := record | 45 | next := record | ||
| 118 | next.RecoveryState = domain.RecoveryHealthy | 46 | next.RecoveryState = domain.RecoveryHealthy | ||
| 119 | next.Committed = copyCheckpointRef(&ref) | 47 | next.Committed = copyCheckpointRef(&ref) | ||
| 120 | - | next.Fallbacks = [2]*domain.CheckpointRef{} | |||
| 121 | next.DegradedReason = "" | 48 | next.DegradedReason = "" | ||
| 122 | - | for i := range alternatives { | |||
| 123 | - | next.Fallbacks[i] = copyCheckpointRef(&alternatives[i]) | |||
| 124 | - | } | |||
| 125 | if err := next.Validate(); err != nil { | 49 | if err := next.Validate(); err != nil { | ||
| 126 | - | return ports.FallbackPromotionOutcome{}, fmt.Errorf("recovery: invalid fallback promotion: %w", err) | 50 | + | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid checkpoint transition: %w", err) |
| 127 | } | 51 | } | ||
| 128 | if err := c.catalogue.Replace(name, next); err != nil { | 52 | if err := c.catalogue.Replace(name, next); err != nil { | ||
| 129 | - | return ports.FallbackPromotionOutcome{}, err | 53 | + | return domain.CatalogueRecord{}, err |
| 130 | - | } | |||
| 131 | - | outcome := ports.FallbackPromotionOutcome{Record: next, CatalogueCommitted: true} | |||
| 132 | - | outcome.HEADRepairError = c.repository.RepairHEAD(ctx, record.IncarnationID, ref) | |||
| 133 | - | return outcome, nil | |||
| 134 | - | } | |||
| 135 | - | ||||
| 136 | - | func (c *Coordinator) validateCheckpoint(ctx context.Context, record domain.CatalogueRecord, ref domain.CheckpointRef) error { | |||
| 137 | - | generation, err := c.repository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, ref) | |||
| 138 | - | if err != nil { | |||
| 139 | - | return err | |||
| 140 | } | 54 | } | ||
| 141 | - | return validateLoadedGeneration(record, ref, generation) | 55 | + | return next, nil |
| 142 | } | 56 | } | ||
| 143 | 57 | ||||
| 144 | func equalCheckpointRefs(a, b *domain.CheckpointRef) bool { return a.Equal(b) } | 58 | func equalCheckpointRefs(a, b *domain.CheckpointRef) bool { return a.Equal(b) } | ||
M internal/usecase/recovery/checkpoint_test.go +3 -69
| @@ -2,7 +2,6 @@ package recovery | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "crypto/sha256" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "sync" | |
| 8 | 7 | "testing" | |
| @@ -108,16 +107,6 @@ func checkpointPublication(record domain.CatalogueRecord, generation uint64) por | |||
| 108 | 107 | } | |
| 109 | 108 | } | |
| 110 | 109 | ||
| 111 | - | func populatedGenerations(refs [2]*domain.CheckpointRef) []uint64 { | |
| 112 | - | var generations []uint64 | |
| 113 | - | for _, ref := range refs { | |
| 114 | - | if ref != nil { | |
| 115 | - | generations = append(generations, ref.Generation) | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | return generations | |
| 119 | - | } | |
| 120 | - | ||
| 121 | 110 | func runCheckpointCommit(t *testing.T, count int) (domain.CatalogueRecord, *checkpointCatalogue, *checkpointRepository) { | |
| 122 | 111 | t.Helper() | |
| 123 | 112 | events := make([]string, 0, count*2) | |
| @@ -140,17 +129,15 @@ func TestCheckpointCommits(t *testing.T) { | |||
| 140 | 129 | name string | |
| 141 | 130 | count int | |
| 142 | 131 | wantCommitted uint64 | |
| 143 | - | wantFallbacks []uint64 | |
| 144 | 132 | }{ | |
| 145 | 133 | {name: "first", count: 1, wantCommitted: 1}, | |
| 146 | - | {name: "second", count: 2, wantCommitted: 2, wantFallbacks: []uint64{1}}, | |
| 147 | - | {name: "third", count: 3, wantCommitted: 3, wantFallbacks: []uint64{2, 1}}, | |
| 148 | - | {name: "fourth", count: 4, wantCommitted: 4, wantFallbacks: []uint64{3, 2}}, | |
| 134 | + | {name: "second", count: 2, wantCommitted: 2}, | |
| 135 | + | {name: "third", count: 3, wantCommitted: 3}, | |
| 136 | + | {name: "fourth", count: 4, wantCommitted: 4}, | |
| 149 | 137 | } { | |
| 150 | 138 | t.Run(tc.name, func(t *testing.T) { | |
| 151 | 139 | record, catalogue, _ := runCheckpointCommit(t, tc.count) | |
| 152 | 140 | require.Equal(t, tc.wantCommitted, record.Committed.Generation) | |
| 153 | - | require.Equal(t, tc.wantFallbacks, populatedGenerations(record.Fallbacks)) | |
| 154 | 141 | require.Equal(t, domain.RecoveryHealthy, record.RecoveryState) | |
| 155 | 142 | require.Empty(t, record.DegradedReason) | |
| 156 | 143 | require.Equal(t, tc.count, catalogue.replaces) | |
| @@ -158,59 +145,6 @@ func TestCheckpointCommits(t *testing.T) { | |||
| 158 | 145 | } | |
| 159 | 146 | } | |
| 160 | 147 | ||
| 161 | - | func TestCheckpointCommitThirdPromotesOnlyValidatedDirectFallbacks(t *testing.T) { | |
| 162 | - | refs := []domain.CheckpointRef{ | |
| 163 | - | {Generation: 3, ManifestDigest: sha256.Sum256([]byte("third"))}, | |
| 164 | - | {Generation: 2, ManifestDigest: sha256.Sum256([]byte("second"))}, | |
| 165 | - | {Generation: 1, ManifestDigest: sha256.Sum256([]byte("first"))}, | |
| 166 | - | } | |
| 167 | - | record := checkpointRecord() | |
| 168 | - | record.RecoveryState = domain.RecoveryHealthy | |
| 169 | - | record.Committed = &refs[0] | |
| 170 | - | record.Fallbacks = [2]*domain.CheckpointRef{&refs[1], &refs[2]} | |
| 171 | - | catalogue := &checkpointCatalogue{record: record} | |
| 172 | - | repository := &checkpointRepository{ | |
| 173 | - | generations: map[uint64]ports.SnapshotGeneration{ | |
| 174 | - | 2: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 2, Manifest: []byte("second")}, | |
| 175 | - | 1: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 1, Manifest: []byte("first")}, | |
| 176 | - | }, | |
| 177 | - | } | |
| 178 | - | coordinator := NewCoordinator(catalogue, repository, nil, nil) | |
| 179 | - | ||
| 180 | - | promoted, err := coordinator.PromoteFallback(context.Background(), record.Name, refs[1]) | |
| 181 | - | require.NoError(t, err) | |
| 182 | - | require.Equal(t, refs[1], *promoted.Record.Committed) | |
| 183 | - | require.Equal(t, []uint64{1}, populatedGenerations(promoted.Record.Fallbacks)) | |
| 184 | - | require.Equal(t, []uint64{2, 1}, repository.loads, "only the selected and older catalogue-indexed fallbacks are read directly") | |
| 185 | - | require.Equal(t, []domain.CheckpointRef{refs[1]}, repository.repairs) | |
| 186 | - | require.Equal(t, 1, catalogue.replaces) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | func TestPromoteFallbackReportsPostCommitHEADRepairFailure(t *testing.T) { | |
| 190 | - | selected := domain.CheckpointRef{Generation: 2, ManifestDigest: sha256.Sum256([]byte("second"))} | |
| 191 | - | record := checkpointRecord() | |
| 192 | - | record.RecoveryState = domain.RecoveryHealthy | |
| 193 | - | record.Committed = &domain.CheckpointRef{Generation: 3, ManifestDigest: sha256.Sum256([]byte("third"))} | |
| 194 | - | record.Fallbacks[0] = &selected | |
| 195 | - | catalogue := &checkpointCatalogue{record: record} | |
| 196 | - | repairErr := errors.New("head repair failed") | |
| 197 | - | repository := &checkpointRepository{ | |
| 198 | - | generations: map[uint64]ports.SnapshotGeneration{ | |
| 199 | - | 2: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 2, Manifest: []byte("second")}, | |
| 200 | - | }, | |
| 201 | - | repairErr: repairErr, | |
| 202 | - | } | |
| 203 | - | coordinator := NewCoordinator(catalogue, repository, nil, nil) | |
| 204 | - | ||
| 205 | - | outcome, err := coordinator.PromoteFallback(context.Background(), record.Name, selected) | |
| 206 | - | require.NoError(t, err) | |
| 207 | - | require.True(t, outcome.CatalogueCommitted) | |
| 208 | - | require.ErrorIs(t, outcome.HEADRepairError, repairErr) | |
| 209 | - | require.Equal(t, selected, *outcome.Record.Committed) | |
| 210 | - | require.Equal(t, domain.RecoveryHealthy, catalogue.record.RecoveryState) | |
| 211 | - | require.Equal(t, selected, *catalogue.record.Committed) | |
| 212 | - | } | |
| 213 | - | ||
| 214 | 148 | func TestCheckpointCommitPublishOrphan(t *testing.T) { | |
| 215 | 149 | prior := checkpointRecord() | |
| 216 | 150 | priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | |
| @@ -2,7 +2,6 @@ package recovery | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "crypto/sha256" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "sync" | 6 | "sync" | ||
| 8 | "testing" | 7 | "testing" | ||
| @@ -108,16 +107,6 @@ func checkpointPublication(record domain.CatalogueRecord, generation uint64) por | |||||
| 108 | } | 107 | } | ||
| 109 | } | 108 | } | ||
| 110 | 109 | ||||
| 111 | - | func populatedGenerations(refs [2]*domain.CheckpointRef) []uint64 { | |||
| 112 | - | var generations []uint64 | |||
| 113 | - | for _, ref := range refs { | |||
| 114 | - | if ref != nil { | |||
| 115 | - | generations = append(generations, ref.Generation) | |||
| 116 | - | } | |||
| 117 | - | } | |||
| 118 | - | return generations | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | func runCheckpointCommit(t *testing.T, count int) (domain.CatalogueRecord, *checkpointCatalogue, *checkpointRepository) { | 110 | func runCheckpointCommit(t *testing.T, count int) (domain.CatalogueRecord, *checkpointCatalogue, *checkpointRepository) { | ||
| 122 | t.Helper() | 111 | t.Helper() | ||
| 123 | events := make([]string, 0, count*2) | 112 | events := make([]string, 0, count*2) | ||
| @@ -140,17 +129,15 @@ func TestCheckpointCommits(t *testing.T) { | |||||
| 140 | name string | 129 | name string | ||
| 141 | count int | 130 | count int | ||
| 142 | wantCommitted uint64 | 131 | wantCommitted uint64 | ||
| 143 | - | wantFallbacks []uint64 | |||
| 144 | }{ | 132 | }{ | ||
| 145 | {name: "first", count: 1, wantCommitted: 1}, | 133 | {name: "first", count: 1, wantCommitted: 1}, | ||
| 146 | - | {name: "second", count: 2, wantCommitted: 2, wantFallbacks: []uint64{1}}, | 134 | + | {name: "second", count: 2, wantCommitted: 2}, |
| 147 | - | {name: "third", count: 3, wantCommitted: 3, wantFallbacks: []uint64{2, 1}}, | 135 | + | {name: "third", count: 3, wantCommitted: 3}, |
| 148 | - | {name: "fourth", count: 4, wantCommitted: 4, wantFallbacks: []uint64{3, 2}}, | 136 | + | {name: "fourth", count: 4, wantCommitted: 4}, |
| 149 | } { | 137 | } { | ||
| 150 | t.Run(tc.name, func(t *testing.T) { | 138 | t.Run(tc.name, func(t *testing.T) { | ||
| 151 | record, catalogue, _ := runCheckpointCommit(t, tc.count) | 139 | record, catalogue, _ := runCheckpointCommit(t, tc.count) | ||
| 152 | require.Equal(t, tc.wantCommitted, record.Committed.Generation) | 140 | require.Equal(t, tc.wantCommitted, record.Committed.Generation) | ||
| 153 | - | require.Equal(t, tc.wantFallbacks, populatedGenerations(record.Fallbacks)) | |||
| 154 | require.Equal(t, domain.RecoveryHealthy, record.RecoveryState) | 141 | require.Equal(t, domain.RecoveryHealthy, record.RecoveryState) | ||
| 155 | require.Empty(t, record.DegradedReason) | 142 | require.Empty(t, record.DegradedReason) | ||
| 156 | require.Equal(t, tc.count, catalogue.replaces) | 143 | require.Equal(t, tc.count, catalogue.replaces) | ||
| @@ -158,59 +145,6 @@ func TestCheckpointCommits(t *testing.T) { | |||||
| 158 | } | 145 | } | ||
| 159 | } | 146 | } | ||
| 160 | 147 | ||||
| 161 | - | func TestCheckpointCommitThirdPromotesOnlyValidatedDirectFallbacks(t *testing.T) { | |||
| 162 | - | refs := []domain.CheckpointRef{ | |||
| 163 | - | {Generation: 3, ManifestDigest: sha256.Sum256([]byte("third"))}, | |||
| 164 | - | {Generation: 2, ManifestDigest: sha256.Sum256([]byte("second"))}, | |||
| 165 | - | {Generation: 1, ManifestDigest: sha256.Sum256([]byte("first"))}, | |||
| 166 | - | } | |||
| 167 | - | record := checkpointRecord() | |||
| 168 | - | record.RecoveryState = domain.RecoveryHealthy | |||
| 169 | - | record.Committed = &refs[0] | |||
| 170 | - | record.Fallbacks = [2]*domain.CheckpointRef{&refs[1], &refs[2]} | |||
| 171 | - | catalogue := &checkpointCatalogue{record: record} | |||
| 172 | - | repository := &checkpointRepository{ | |||
| 173 | - | generations: map[uint64]ports.SnapshotGeneration{ | |||
| 174 | - | 2: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 2, Manifest: []byte("second")}, | |||
| 175 | - | 1: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 1, Manifest: []byte("first")}, | |||
| 176 | - | }, | |||
| 177 | - | } | |||
| 178 | - | coordinator := NewCoordinator(catalogue, repository, nil, nil) | |||
| 179 | - | ||||
| 180 | - | promoted, err := coordinator.PromoteFallback(context.Background(), record.Name, refs[1]) | |||
| 181 | - | require.NoError(t, err) | |||
| 182 | - | require.Equal(t, refs[1], *promoted.Record.Committed) | |||
| 183 | - | require.Equal(t, []uint64{1}, populatedGenerations(promoted.Record.Fallbacks)) | |||
| 184 | - | require.Equal(t, []uint64{2, 1}, repository.loads, "only the selected and older catalogue-indexed fallbacks are read directly") | |||
| 185 | - | require.Equal(t, []domain.CheckpointRef{refs[1]}, repository.repairs) | |||
| 186 | - | require.Equal(t, 1, catalogue.replaces) | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | func TestPromoteFallbackReportsPostCommitHEADRepairFailure(t *testing.T) { | |||
| 190 | - | selected := domain.CheckpointRef{Generation: 2, ManifestDigest: sha256.Sum256([]byte("second"))} | |||
| 191 | - | record := checkpointRecord() | |||
| 192 | - | record.RecoveryState = domain.RecoveryHealthy | |||
| 193 | - | record.Committed = &domain.CheckpointRef{Generation: 3, ManifestDigest: sha256.Sum256([]byte("third"))} | |||
| 194 | - | record.Fallbacks[0] = &selected | |||
| 195 | - | catalogue := &checkpointCatalogue{record: record} | |||
| 196 | - | repairErr := errors.New("head repair failed") | |||
| 197 | - | repository := &checkpointRepository{ | |||
| 198 | - | generations: map[uint64]ports.SnapshotGeneration{ | |||
| 199 | - | 2: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 2, Manifest: []byte("second")}, | |||
| 200 | - | }, | |||
| 201 | - | repairErr: repairErr, | |||
| 202 | - | } | |||
| 203 | - | coordinator := NewCoordinator(catalogue, repository, nil, nil) | |||
| 204 | - | ||||
| 205 | - | outcome, err := coordinator.PromoteFallback(context.Background(), record.Name, selected) | |||
| 206 | - | require.NoError(t, err) | |||
| 207 | - | require.True(t, outcome.CatalogueCommitted) | |||
| 208 | - | require.ErrorIs(t, outcome.HEADRepairError, repairErr) | |||
| 209 | - | require.Equal(t, selected, *outcome.Record.Committed) | |||
| 210 | - | require.Equal(t, domain.RecoveryHealthy, catalogue.record.RecoveryState) | |||
| 211 | - | require.Equal(t, selected, *catalogue.record.Committed) | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | func TestCheckpointCommitPublishOrphan(t *testing.T) { | 148 | func TestCheckpointCommitPublishOrphan(t *testing.T) { | ||
| 215 | prior := checkpointRecord() | 149 | prior := checkpointRecord() | ||
| 216 | priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | 150 | priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | ||
M internal/usecase/recovery/coordinator.go +0 -1
| @@ -160,7 +160,6 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord) | |||
| 160 | 160 | record.IncarnationID = id | |
| 161 | 161 | record.RecoveryState = domain.RecoveryFresh | |
| 162 | 162 | record.Committed = nil | |
| 163 | - | record.Fallbacks = [2]*domain.CheckpointRef{} | |
| 164 | 163 | record.DegradedReason = "" | |
| 165 | 164 | if err := record.Validate(); err != nil { | |
| 166 | 165 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid fresh record: %w", err) | |
| @@ -160,7 +160,6 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord) | |||||
| 160 | record.IncarnationID = id | 160 | record.IncarnationID = id | ||
| 161 | record.RecoveryState = domain.RecoveryFresh | 161 | record.RecoveryState = domain.RecoveryFresh | ||
| 162 | record.Committed = nil | 162 | record.Committed = nil | ||
| 163 | - | record.Fallbacks = [2]*domain.CheckpointRef{} | |||
| 164 | record.DegradedReason = "" | 163 | record.DegradedReason = "" | ||
| 165 | if err := record.Validate(); err != nil { | 164 | if err := record.Validate(); err != nil { | ||
| 166 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid fresh record: %w", err) | 165 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid fresh record: %w", err) | ||
M internal/usecase/recovery/discard.go +0 -154
| @@ -1,27 +1,11 @@ | |||
| 1 | 1 | package recovery | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "context" | |
| 6 | - | "crypto/sha256" | |
| 7 | - | "encoding/binary" | |
| 8 | 5 | "errors" | |
| 9 | 6 | "fmt" | |
| 10 | - | "io" | |
| 11 | - | "slices" | |
| 12 | 7 | ||
| 13 | 8 | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // RecoveryAction identifies an explicit operator choice for degraded state. | |
| 18 | - | type RecoveryAction uint8 | |
| 19 | - | ||
| 20 | - | const ( | |
| 21 | - | RecoveryRetry RecoveryAction = iota + 1 | |
| 22 | - | RecoveryRestoreFallback | |
| 23 | - | RecoveryExport | |
| 24 | - | RecoveryDiscard | |
| 25 | 9 | ) | |
| 26 | 10 | ||
| 27 | 11 | var ( | |
| @@ -31,143 +15,6 @@ var ( | |||
| 31 | 15 | ErrDiscardIntentInvalid = errors.New("recovery: discard intent is unusable") | |
| 32 | 16 | ) | |
| 33 | 17 | ||
| 34 | - | // Retry directly validates the committed checkpoint. A failed validation is | |
| 35 | - | // read-only; only a successful validation is allowed to clear degraded state. | |
| 36 | - | func (c *Coordinator) Retry(ctx context.Context, name string) error { | |
| 37 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil { | |
| 38 | - | return errors.New("recovery: incomplete retry dependencies") | |
| 39 | - | } | |
| 40 | - | unlock := c.locks.Lock([]string{name}) | |
| 41 | - | defer unlock() | |
| 42 | - | record, ok, err := c.catalogue.Record(name) | |
| 43 | - | if err != nil { | |
| 44 | - | return err | |
| 45 | - | } | |
| 46 | - | if !ok { | |
| 47 | - | return ErrRecoveryRecordNotFound | |
| 48 | - | } | |
| 49 | - | if record.RecoveryState != domain.RecoveryDegraded || record.Committed == nil { | |
| 50 | - | return ErrSessionNotDegraded | |
| 51 | - | } | |
| 52 | - | if err := c.validateCheckpoint(ctx, record, *record.Committed); err != nil { | |
| 53 | - | return err | |
| 54 | - | } | |
| 55 | - | next := record | |
| 56 | - | next.RecoveryState = domain.RecoveryHealthy | |
| 57 | - | next.DegradedReason = "" | |
| 58 | - | if err := next.Validate(); err != nil { | |
| 59 | - | return fmt.Errorf("recovery: invalid retry transition: %w", err) | |
| 60 | - | } | |
| 61 | - | return c.catalogue.Replace(name, next) | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // RestoreFallback promotes only a fallback already displayed from the | |
| 65 | - | // catalogue. PromoteFallback performs direct validation before replacement. | |
| 66 | - | func (c *Coordinator) RestoreFallback(ctx context.Context, name string, ref domain.CheckpointRef) error { | |
| 67 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil { | |
| 68 | - | return errors.New("recovery: incomplete fallback dependencies") | |
| 69 | - | } | |
| 70 | - | unlock := c.locks.Lock([]string{name}) | |
| 71 | - | defer unlock() | |
| 72 | - | record, ok, err := c.catalogue.Record(name) | |
| 73 | - | if err != nil { | |
| 74 | - | return err | |
| 75 | - | } | |
| 76 | - | if !ok { | |
| 77 | - | return ErrRecoveryRecordNotFound | |
| 78 | - | } | |
| 79 | - | if record.RecoveryState != domain.RecoveryDegraded { | |
| 80 | - | return ErrSessionNotDegraded | |
| 81 | - | } | |
| 82 | - | outcome, err := c.promoteFallbackLocked(ctx, name, ref) | |
| 83 | - | if err != nil { | |
| 84 | - | return err | |
| 85 | - | } | |
| 86 | - | return outcome.HEADRepairError | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // Export writes a deterministic, self-contained copy of the committed | |
| 90 | - | // generation without changing catalogue or repository state. | |
| 91 | - | func (c *Coordinator) Export(ctx context.Context, name string, w io.Writer) error { | |
| 92 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil || w == nil { | |
| 93 | - | return errors.New("recovery: incomplete export dependencies") | |
| 94 | - | } | |
| 95 | - | unlock := c.locks.Lock([]string{name}) | |
| 96 | - | defer unlock() | |
| 97 | - | record, ok, err := c.catalogue.Record(name) | |
| 98 | - | if err != nil { | |
| 99 | - | return err | |
| 100 | - | } | |
| 101 | - | if !ok { | |
| 102 | - | return ErrRecoveryRecordNotFound | |
| 103 | - | } | |
| 104 | - | if record.RecoveryState != domain.RecoveryDegraded || record.Committed == nil { | |
| 105 | - | return ErrSessionNotDegraded | |
| 106 | - | } | |
| 107 | - | generation, err := c.repository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, *record.Committed) | |
| 108 | - | if err != nil { | |
| 109 | - | return err | |
| 110 | - | } | |
| 111 | - | if err := validateLoadedGeneration(record, *record.Committed, generation); err != nil { | |
| 112 | - | return err | |
| 113 | - | } | |
| 114 | - | encoded, err := encodeExport(generation) | |
| 115 | - | if err != nil { | |
| 116 | - | return err | |
| 117 | - | } | |
| 118 | - | _, err = w.Write(encoded) | |
| 119 | - | return err | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func validateLoadedGeneration(record domain.CatalogueRecord, ref domain.CheckpointRef, generation ports.SnapshotGeneration) error { | |
| 123 | - | if generation.IncarnationID != record.IncarnationID || generation.Name != record.Name || | |
| 124 | - | generation.Generation != ref.Generation || sha256.Sum256(generation.Manifest) != ref.ManifestDigest { | |
| 125 | - | return ErrCheckpointConflict | |
| 126 | - | } | |
| 127 | - | return nil | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func encodeExport(generation ports.SnapshotGeneration) ([]byte, error) { | |
| 131 | - | var out bytes.Buffer | |
| 132 | - | out.WriteString("VEVX") | |
| 133 | - | _ = binary.Write(&out, binary.BigEndian, uint16(1)) | |
| 134 | - | out.Write(generation.IncarnationID[:]) | |
| 135 | - | _ = binary.Write(&out, binary.BigEndian, generation.Generation) | |
| 136 | - | if err := writeExportBytes(&out, []byte(generation.Name)); err != nil { | |
| 137 | - | return nil, err | |
| 138 | - | } | |
| 139 | - | if err := writeExportBytes(&out, generation.Manifest); err != nil { | |
| 140 | - | return nil, err | |
| 141 | - | } | |
| 142 | - | digests := make([]ports.SnapshotDigest, 0, len(generation.Objects)) | |
| 143 | - | for digest := range generation.Objects { | |
| 144 | - | digests = append(digests, digest) | |
| 145 | - | } | |
| 146 | - | slices.SortFunc(digests, func(a, b ports.SnapshotDigest) int { return bytes.Compare(a[:], b[:]) }) | |
| 147 | - | if uint64(len(digests)) > uint64(^uint32(0)) { | |
| 148 | - | return nil, errors.New("recovery: too many export objects") | |
| 149 | - | } | |
| 150 | - | _ = binary.Write(&out, binary.BigEndian, uint32(len(digests))) | |
| 151 | - | for _, digest := range digests { | |
| 152 | - | out.Write(digest[:]) | |
| 153 | - | if err := writeExportBytes(&out, generation.Objects[digest]); err != nil { | |
| 154 | - | return nil, err | |
| 155 | - | } | |
| 156 | - | } | |
| 157 | - | return out.Bytes(), nil | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | func writeExportBytes(w io.Writer, data []byte) error { | |
| 161 | - | if uint64(len(data)) > uint64(^uint32(0)) { | |
| 162 | - | return errors.New("recovery: export field too large") | |
| 163 | - | } | |
| 164 | - | if err := binary.Write(w, binary.BigEndian, uint32(len(data))); err != nil { | |
| 165 | - | return err | |
| 166 | - | } | |
| 167 | - | _, err := w.Write(data) | |
| 168 | - | return err | |
| 169 | - | } | |
| 170 | - | ||
| 171 | 18 | // Discard commits to a new incarnation by saving the complete intent before | |
| 172 | 19 | // any quarantine or catalogue mutation. There is no rollback after that point. | |
| 173 | 20 | func (c *Coordinator) Discard(ctx context.Context, name, reason string) (domain.CatalogueRecord, error) { | |
| @@ -289,7 +136,6 @@ func freshReplacement(intent domain.DiscardIntent) domain.CatalogueRecord { | |||
| 289 | 136 | next.IncarnationID = intent.NewIncarnation | |
| 290 | 137 | next.RecoveryState = domain.RecoveryFresh | |
| 291 | 138 | next.Committed = nil | |
| 292 | - | next.Fallbacks = [2]*domain.CheckpointRef{} | |
| 293 | 139 | next.DegradedReason = "" | |
| 294 | 140 | return next | |
| 295 | 141 | } | |
| @@ -1,27 +1,11 @@ | |||||
| 1 | package recovery | 1 | package recovery | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | "context" | 4 | "context" | ||
| 6 | - | "crypto/sha256" | |||
| 7 | - | "encoding/binary" | |||
| 8 | "errors" | 5 | "errors" | ||
| 9 | "fmt" | 6 | "fmt" | ||
| 10 | - | "io" | |||
| 11 | - | "slices" | |||
| 12 | 7 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 14 | - | "github.com/bnema/vev/internal/ports" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // RecoveryAction identifies an explicit operator choice for degraded state. | |||
| 18 | - | type RecoveryAction uint8 | |||
| 19 | - | ||||
| 20 | - | const ( | |||
| 21 | - | RecoveryRetry RecoveryAction = iota + 1 | |||
| 22 | - | RecoveryRestoreFallback | |||
| 23 | - | RecoveryExport | |||
| 24 | - | RecoveryDiscard | |||
| 25 | ) | 9 | ) | ||
| 26 | 10 | ||||
| 27 | var ( | 11 | var ( | ||
| @@ -31,143 +15,6 @@ var ( | |||||
| 31 | ErrDiscardIntentInvalid = errors.New("recovery: discard intent is unusable") | 15 | ErrDiscardIntentInvalid = errors.New("recovery: discard intent is unusable") | ||
| 32 | ) | 16 | ) | ||
| 33 | 17 | ||||
| 34 | - | // Retry directly validates the committed checkpoint. A failed validation is | |||
| 35 | - | // read-only; only a successful validation is allowed to clear degraded state. | |||
| 36 | - | func (c *Coordinator) Retry(ctx context.Context, name string) error { | |||
| 37 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil { | |||
| 38 | - | return errors.New("recovery: incomplete retry dependencies") | |||
| 39 | - | } | |||
| 40 | - | unlock := c.locks.Lock([]string{name}) | |||
| 41 | - | defer unlock() | |||
| 42 | - | record, ok, err := c.catalogue.Record(name) | |||
| 43 | - | if err != nil { | |||
| 44 | - | return err | |||
| 45 | - | } | |||
| 46 | - | if !ok { | |||
| 47 | - | return ErrRecoveryRecordNotFound | |||
| 48 | - | } | |||
| 49 | - | if record.RecoveryState != domain.RecoveryDegraded || record.Committed == nil { | |||
| 50 | - | return ErrSessionNotDegraded | |||
| 51 | - | } | |||
| 52 | - | if err := c.validateCheckpoint(ctx, record, *record.Committed); err != nil { | |||
| 53 | - | return err | |||
| 54 | - | } | |||
| 55 | - | next := record | |||
| 56 | - | next.RecoveryState = domain.RecoveryHealthy | |||
| 57 | - | next.DegradedReason = "" | |||
| 58 | - | if err := next.Validate(); err != nil { | |||
| 59 | - | return fmt.Errorf("recovery: invalid retry transition: %w", err) | |||
| 60 | - | } | |||
| 61 | - | return c.catalogue.Replace(name, next) | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // RestoreFallback promotes only a fallback already displayed from the | |||
| 65 | - | // catalogue. PromoteFallback performs direct validation before replacement. | |||
| 66 | - | func (c *Coordinator) RestoreFallback(ctx context.Context, name string, ref domain.CheckpointRef) error { | |||
| 67 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil { | |||
| 68 | - | return errors.New("recovery: incomplete fallback dependencies") | |||
| 69 | - | } | |||
| 70 | - | unlock := c.locks.Lock([]string{name}) | |||
| 71 | - | defer unlock() | |||
| 72 | - | record, ok, err := c.catalogue.Record(name) | |||
| 73 | - | if err != nil { | |||
| 74 | - | return err | |||
| 75 | - | } | |||
| 76 | - | if !ok { | |||
| 77 | - | return ErrRecoveryRecordNotFound | |||
| 78 | - | } | |||
| 79 | - | if record.RecoveryState != domain.RecoveryDegraded { | |||
| 80 | - | return ErrSessionNotDegraded | |||
| 81 | - | } | |||
| 82 | - | outcome, err := c.promoteFallbackLocked(ctx, name, ref) | |||
| 83 | - | if err != nil { | |||
| 84 | - | return err | |||
| 85 | - | } | |||
| 86 | - | return outcome.HEADRepairError | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | // Export writes a deterministic, self-contained copy of the committed | |||
| 90 | - | // generation without changing catalogue or repository state. | |||
| 91 | - | func (c *Coordinator) Export(ctx context.Context, name string, w io.Writer) error { | |||
| 92 | - | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil || w == nil { | |||
| 93 | - | return errors.New("recovery: incomplete export dependencies") | |||
| 94 | - | } | |||
| 95 | - | unlock := c.locks.Lock([]string{name}) | |||
| 96 | - | defer unlock() | |||
| 97 | - | record, ok, err := c.catalogue.Record(name) | |||
| 98 | - | if err != nil { | |||
| 99 | - | return err | |||
| 100 | - | } | |||
| 101 | - | if !ok { | |||
| 102 | - | return ErrRecoveryRecordNotFound | |||
| 103 | - | } | |||
| 104 | - | if record.RecoveryState != domain.RecoveryDegraded || record.Committed == nil { | |||
| 105 | - | return ErrSessionNotDegraded | |||
| 106 | - | } | |||
| 107 | - | generation, err := c.repository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, *record.Committed) | |||
| 108 | - | if err != nil { | |||
| 109 | - | return err | |||
| 110 | - | } | |||
| 111 | - | if err := validateLoadedGeneration(record, *record.Committed, generation); err != nil { | |||
| 112 | - | return err | |||
| 113 | - | } | |||
| 114 | - | encoded, err := encodeExport(generation) | |||
| 115 | - | if err != nil { | |||
| 116 | - | return err | |||
| 117 | - | } | |||
| 118 | - | _, err = w.Write(encoded) | |||
| 119 | - | return err | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func validateLoadedGeneration(record domain.CatalogueRecord, ref domain.CheckpointRef, generation ports.SnapshotGeneration) error { | |||
| 123 | - | if generation.IncarnationID != record.IncarnationID || generation.Name != record.Name || | |||
| 124 | - | generation.Generation != ref.Generation || sha256.Sum256(generation.Manifest) != ref.ManifestDigest { | |||
| 125 | - | return ErrCheckpointConflict | |||
| 126 | - | } | |||
| 127 | - | return nil | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | func encodeExport(generation ports.SnapshotGeneration) ([]byte, error) { | |||
| 131 | - | var out bytes.Buffer | |||
| 132 | - | out.WriteString("VEVX") | |||
| 133 | - | _ = binary.Write(&out, binary.BigEndian, uint16(1)) | |||
| 134 | - | out.Write(generation.IncarnationID[:]) | |||
| 135 | - | _ = binary.Write(&out, binary.BigEndian, generation.Generation) | |||
| 136 | - | if err := writeExportBytes(&out, []byte(generation.Name)); err != nil { | |||
| 137 | - | return nil, err | |||
| 138 | - | } | |||
| 139 | - | if err := writeExportBytes(&out, generation.Manifest); err != nil { | |||
| 140 | - | return nil, err | |||
| 141 | - | } | |||
| 142 | - | digests := make([]ports.SnapshotDigest, 0, len(generation.Objects)) | |||
| 143 | - | for digest := range generation.Objects { | |||
| 144 | - | digests = append(digests, digest) | |||
| 145 | - | } | |||
| 146 | - | slices.SortFunc(digests, func(a, b ports.SnapshotDigest) int { return bytes.Compare(a[:], b[:]) }) | |||
| 147 | - | if uint64(len(digests)) > uint64(^uint32(0)) { | |||
| 148 | - | return nil, errors.New("recovery: too many export objects") | |||
| 149 | - | } | |||
| 150 | - | _ = binary.Write(&out, binary.BigEndian, uint32(len(digests))) | |||
| 151 | - | for _, digest := range digests { | |||
| 152 | - | out.Write(digest[:]) | |||
| 153 | - | if err := writeExportBytes(&out, generation.Objects[digest]); err != nil { | |||
| 154 | - | return nil, err | |||
| 155 | - | } | |||
| 156 | - | } | |||
| 157 | - | return out.Bytes(), nil | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | func writeExportBytes(w io.Writer, data []byte) error { | |||
| 161 | - | if uint64(len(data)) > uint64(^uint32(0)) { | |||
| 162 | - | return errors.New("recovery: export field too large") | |||
| 163 | - | } | |||
| 164 | - | if err := binary.Write(w, binary.BigEndian, uint32(len(data))); err != nil { | |||
| 165 | - | return err | |||
| 166 | - | } | |||
| 167 | - | _, err := w.Write(data) | |||
| 168 | - | return err | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | // Discard commits to a new incarnation by saving the complete intent before | 18 | // Discard commits to a new incarnation by saving the complete intent before | ||
| 172 | // any quarantine or catalogue mutation. There is no rollback after that point. | 19 | // any quarantine or catalogue mutation. There is no rollback after that point. | ||
| 173 | func (c *Coordinator) Discard(ctx context.Context, name, reason string) (domain.CatalogueRecord, error) { | 20 | func (c *Coordinator) Discard(ctx context.Context, name, reason string) (domain.CatalogueRecord, error) { | ||
| @@ -289,7 +136,6 @@ func freshReplacement(intent domain.DiscardIntent) domain.CatalogueRecord { | |||||
| 289 | next.IncarnationID = intent.NewIncarnation | 136 | next.IncarnationID = intent.NewIncarnation | ||
| 290 | next.RecoveryState = domain.RecoveryFresh | 137 | next.RecoveryState = domain.RecoveryFresh | ||
| 291 | next.Committed = nil | 138 | next.Committed = nil | ||
| 292 | - | next.Fallbacks = [2]*domain.CheckpointRef{} | |||
| 293 | next.DegradedReason = "" | 139 | next.DegradedReason = "" | ||
| 294 | return next | 140 | return next | ||
| 295 | } | 141 | } | ||
M internal/usecase/recovery/reconcile.go +3 -1
| @@ -160,7 +160,9 @@ func (c *Coordinator) PublishReconciledCheckpoint(ctx context.Context, name stri | |||
| 160 | 160 | if record.RecoveryState != domain.RecoveryHealthy || candidate.Name != name || candidate.IncarnationID != record.IncarnationID || record.Committed == nil || !validForwardChain(*record.Committed, candidate, ancestors) { | |
| 161 | 161 | return domain.CatalogueRecord{}, ErrCheckpointConflict | |
| 162 | 162 | } | |
| 163 | - | next := shiftedCheckpoint(record, candidate.Ref) | |
| 163 | + | next := record | |
| 164 | + | next.Committed = copyCheckpointRef(&candidate.Ref) | |
| 165 | + | next.DegradedReason = "" | |
| 164 | 166 | if err := next.Validate(); err != nil { | |
| 165 | 167 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid reconciled checkpoint transition: %w", err) | |
| 166 | 168 | } | |
| @@ -160,7 +160,9 @@ func (c *Coordinator) PublishReconciledCheckpoint(ctx context.Context, name stri | |||||
| 160 | if record.RecoveryState != domain.RecoveryHealthy || candidate.Name != name || candidate.IncarnationID != record.IncarnationID || record.Committed == nil || !validForwardChain(*record.Committed, candidate, ancestors) { | 160 | if record.RecoveryState != domain.RecoveryHealthy || candidate.Name != name || candidate.IncarnationID != record.IncarnationID || record.Committed == nil || !validForwardChain(*record.Committed, candidate, ancestors) { | ||
| 161 | return domain.CatalogueRecord{}, ErrCheckpointConflict | 161 | return domain.CatalogueRecord{}, ErrCheckpointConflict | ||
| 162 | } | 162 | } | ||
| 163 | - | next := shiftedCheckpoint(record, candidate.Ref) | 163 | + | next := record |
| 164 | + | next.Committed = copyCheckpointRef(&candidate.Ref) | |||
| 165 | + | next.DegradedReason = "" | |||
| 164 | if err := next.Validate(); err != nil { | 166 | if err := next.Validate(); err != nil { | ||
| 165 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid reconciled checkpoint transition: %w", err) | 167 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid reconciled checkpoint transition: %w", err) | ||
| 166 | } | 168 | } | ||
M internal/usecase/recovery/reconcile_test.go +2 -2
| @@ -124,7 +124,7 @@ func TestReconciliationDefersConflictAndContinues(t *testing.T) { | |||
| 124 | 124 | first.Name = "first" | |
| 125 | 125 | staleRecord := first | |
| 126 | 126 | stale := forwardCandidate(staleRecord, 2, staleRecord.Committed) | |
| 127 | - | first = shiftedCheckpoint(first, domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{9}}) | |
| 127 | + | first.Committed = &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{9}} | |
| 128 | 128 | second := healthyReconcileRecord() | |
| 129 | 129 | second.Name = "second" | |
| 130 | 130 | adoptable := forwardCandidate(second, 2, second.Committed) | |
| @@ -213,7 +213,7 @@ func TestForwardOrphanAdoption(t *testing.T) { | |||
| 213 | 213 | record := healthyReconcileRecord() | |
| 214 | 214 | candidate := forwardCandidate(record, 2, record.Committed) | |
| 215 | 215 | concurrent := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{7}} | |
| 216 | - | record = shiftedCheckpoint(record, concurrent) | |
| 216 | + | record.Committed = &concurrent | |
| 217 | 217 | catalogue := &checkpointCatalogue{record: record} | |
| 218 | 218 | repository := &reconcileRepository{} | |
| 219 | 219 | coordinator := NewCoordinator(catalogue, repository, nil, nil) | |
| @@ -124,7 +124,7 @@ func TestReconciliationDefersConflictAndContinues(t *testing.T) { | |||||
| 124 | first.Name = "first" | 124 | first.Name = "first" | ||
| 125 | staleRecord := first | 125 | staleRecord := first | ||
| 126 | stale := forwardCandidate(staleRecord, 2, staleRecord.Committed) | 126 | stale := forwardCandidate(staleRecord, 2, staleRecord.Committed) | ||
| 127 | - | first = shiftedCheckpoint(first, domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{9}}) | 127 | + | first.Committed = &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{9}} |
| 128 | second := healthyReconcileRecord() | 128 | second := healthyReconcileRecord() | ||
| 129 | second.Name = "second" | 129 | second.Name = "second" | ||
| 130 | adoptable := forwardCandidate(second, 2, second.Committed) | 130 | adoptable := forwardCandidate(second, 2, second.Committed) | ||
| @@ -213,7 +213,7 @@ func TestForwardOrphanAdoption(t *testing.T) { | |||||
| 213 | record := healthyReconcileRecord() | 213 | record := healthyReconcileRecord() | ||
| 214 | candidate := forwardCandidate(record, 2, record.Committed) | 214 | candidate := forwardCandidate(record, 2, record.Committed) | ||
| 215 | concurrent := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{7}} | 215 | concurrent := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{7}} | ||
| 216 | - | record = shiftedCheckpoint(record, concurrent) | 216 | + | record.Committed = &concurrent |
| 217 | catalogue := &checkpointCatalogue{record: record} | 217 | catalogue := &checkpointCatalogue{record: record} | ||
| 218 | repository := &reconcileRepository{} | 218 | repository := &reconcileRepository{} | ||
| 219 | coordinator := NewCoordinator(catalogue, repository, nil, nil) | 219 | coordinator := NewCoordinator(catalogue, repository, nil, nil) | ||
M internal/usecase/recovery/transactions_test.go +0 -70
| @@ -1,10 +1,7 @@ | |||
| 1 | 1 | package recovery | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "context" | |
| 6 | - | "crypto/sha256" | |
| 7 | - | "encoding/hex" | |
| 8 | 5 | "errors" | |
| 9 | 6 | "sync" | |
| 10 | 7 | "testing" | |
| @@ -212,77 +209,10 @@ func degradedTransactionRecord() domain.CatalogueRecord { | |||
| 212 | 209 | return domain.CatalogueRecord{ | |
| 213 | 210 | Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded, | |
| 214 | 211 | Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | |
| 215 | - | Fallbacks: [2]*domain.CheckpointRef{{Generation: 2, ManifestDigest: [32]byte{2}}, {Generation: 1, ManifestDigest: [32]byte{1}}}, | |
| 216 | 212 | DegradedReason: "checkpoint unreadable", | |
| 217 | 213 | } | |
| 218 | 214 | } | |
| 219 | 215 | ||
| 220 | - | func TestDegradedRetryIsReadOnly(t *testing.T) { | |
| 221 | - | record := degradedTransactionRecord() | |
| 222 | - | catalogue := newTransactionCatalogue(record) | |
| 223 | - | repository := newTransactionRepository() | |
| 224 | - | repository.loadErr = errors.New("corrupt checkpoint") | |
| 225 | - | coordinator := NewCoordinator(catalogue, repository, newTransactionJournal(), bytes.NewReader(bytes.Repeat([]byte{9}, 16))) | |
| 226 | - | ||
| 227 | - | require.Error(t, coordinator.Retry(context.Background(), record.Name)) | |
| 228 | - | require.Equal(t, record, catalogue.records[record.Name]) | |
| 229 | - | require.Empty(t, catalogue.events) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | func TestFallbackPromotion(t *testing.T) { | |
| 233 | - | record := degradedTransactionRecord() | |
| 234 | - | fallback := *record.Fallbacks[0] | |
| 235 | - | catalogue := newTransactionCatalogue(record) | |
| 236 | - | repository := newTransactionRepository() | |
| 237 | - | manifest := []byte("fallback") | |
| 238 | - | fallback.ManifestDigest = checkpointDigest(manifest) | |
| 239 | - | record.Fallbacks[0] = &fallback | |
| 240 | - | catalogue.records[record.Name] = record | |
| 241 | - | repository.generations[fallback] = ports.SnapshotGeneration{ | |
| 242 | - | IncarnationID: record.IncarnationID, Name: record.Name, Generation: fallback.Generation, | |
| 243 | - | Manifest: manifest, | |
| 244 | - | } | |
| 245 | - | coordinator := NewCoordinator(catalogue, repository, newTransactionJournal(), nil) | |
| 246 | - | ||
| 247 | - | require.NoError(t, coordinator.RestoreFallback(context.Background(), record.Name, fallback)) | |
| 248 | - | got := catalogue.records[record.Name] | |
| 249 | - | require.Equal(t, domain.RecoveryHealthy, got.RecoveryState) | |
| 250 | - | require.Equal(t, fallback, *got.Committed) | |
| 251 | - | require.Equal(t, []domain.CheckpointRef{fallback}, repository.repaired) | |
| 252 | - | require.Equal(t, []string{"load", "load", "repair-head"}, repository.events) | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | func checkpointDigest(data []byte) [32]byte { | |
| 256 | - | return sha256.Sum256(data) | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | func TestDegradedExportIsReadOnly(t *testing.T) { | |
| 260 | - | record := degradedTransactionRecord() | |
| 261 | - | manifest := []byte("manifest") | |
| 262 | - | ref := *record.Committed | |
| 263 | - | ref.ManifestDigest = checkpointDigest(manifest) | |
| 264 | - | record.Committed = &ref | |
| 265 | - | catalogue := newTransactionCatalogue(record) | |
| 266 | - | repository := newTransactionRepository() | |
| 267 | - | repository.generations[ref] = ports.SnapshotGeneration{ | |
| 268 | - | IncarnationID: record.IncarnationID, Name: record.Name, Generation: ref.Generation, | |
| 269 | - | Manifest: manifest, Objects: map[ports.SnapshotDigest][]byte{{2}: []byte("second"), {1}: []byte("first")}, | |
| 270 | - | } | |
| 271 | - | coordinator := NewCoordinator(catalogue, repository, newTransactionJournal(), nil) | |
| 272 | - | var exported, repeated bytes.Buffer | |
| 273 | - | ||
| 274 | - | require.NoError(t, coordinator.Export(context.Background(), record.Name, &exported)) | |
| 275 | - | require.NoError(t, coordinator.Export(context.Background(), record.Name, &repeated)) | |
| 276 | - | golden, err := hex.DecodeString("5645565800010100000000000000000000000000000000000000000000030000000662726f6b656e000000086d616e69666573740000000201000000000000000000000000000000000000000000000000000000000000000000000566697273740200000000000000000000000000000000000000000000000000000000000000000000067365636f6e64") | |
| 277 | - | require.NoError(t, err) | |
| 278 | - | require.Equal(t, golden, exported.Bytes()) | |
| 279 | - | require.Equal(t, exported.Bytes(), repeated.Bytes()) | |
| 280 | - | require.Equal(t, record, catalogue.records[record.Name]) | |
| 281 | - | require.Empty(t, catalogue.events) | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | // An absent catalogue record means the discard no longer has durable name | |
| 285 | - | // authority to replace, so recovery removes the completed intent. | |
| 286 | 216 | func TestDiscardRecoveryRemovesIntentWhenCatalogueRecordIsAbsent(t *testing.T) { | |
| 287 | 217 | old := degradedTransactionRecord() | |
| 288 | 218 | intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"} | |
| @@ -1,10 +1,7 @@ | |||||
| 1 | package recovery | 1 | package recovery | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | "context" | 4 | "context" | ||
| 6 | - | "crypto/sha256" | |||
| 7 | - | "encoding/hex" | |||
| 8 | "errors" | 5 | "errors" | ||
| 9 | "sync" | 6 | "sync" | ||
| 10 | "testing" | 7 | "testing" | ||
| @@ -212,77 +209,10 @@ func degradedTransactionRecord() domain.CatalogueRecord { | |||||
| 212 | return domain.CatalogueRecord{ | 209 | return domain.CatalogueRecord{ | ||
| 213 | Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded, | 210 | Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded, | ||
| 214 | Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | 211 | Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | ||
| 215 | - | Fallbacks: [2]*domain.CheckpointRef{{Generation: 2, ManifestDigest: [32]byte{2}}, {Generation: 1, ManifestDigest: [32]byte{1}}}, | |||
| 216 | DegradedReason: "checkpoint unreadable", | 212 | DegradedReason: "checkpoint unreadable", | ||
| 217 | } | 213 | } | ||
| 218 | } | 214 | } | ||
| 219 | 215 | ||||
| 220 | - | func TestDegradedRetryIsReadOnly(t *testing.T) { | |||
| 221 | - | record := degradedTransactionRecord() | |||
| 222 | - | catalogue := newTransactionCatalogue(record) | |||
| 223 | - | repository := newTransactionRepository() | |||
| 224 | - | repository.loadErr = errors.New("corrupt checkpoint") | |||
| 225 | - | coordinator := NewCoordinator(catalogue, repository, newTransactionJournal(), bytes.NewReader(bytes.Repeat([]byte{9}, 16))) | |||
| 226 | - | ||||
| 227 | - | require.Error(t, coordinator.Retry(context.Background(), record.Name)) | |||
| 228 | - | require.Equal(t, record, catalogue.records[record.Name]) | |||
| 229 | - | require.Empty(t, catalogue.events) | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | func TestFallbackPromotion(t *testing.T) { | |||
| 233 | - | record := degradedTransactionRecord() | |||
| 234 | - | fallback := *record.Fallbacks[0] | |||
| 235 | - | catalogue := newTransactionCatalogue(record) | |||
| 236 | - | repository := newTransactionRepository() | |||
| 237 | - | manifest := []byte("fallback") | |||
| 238 | - | fallback.ManifestDigest = checkpointDigest(manifest) | |||
| 239 | - | record.Fallbacks[0] = &fallback | |||
| 240 | - | catalogue.records[record.Name] = record | |||
| 241 | - | repository.generations[fallback] = ports.SnapshotGeneration{ | |||
| 242 | - | IncarnationID: record.IncarnationID, Name: record.Name, Generation: fallback.Generation, | |||
| 243 | - | Manifest: manifest, | |||
| 244 | - | } | |||
| 245 | - | coordinator := NewCoordinator(catalogue, repository, newTransactionJournal(), nil) | |||
| 246 | - | ||||
| 247 | - | require.NoError(t, coordinator.RestoreFallback(context.Background(), record.Name, fallback)) | |||
| 248 | - | got := catalogue.records[record.Name] | |||
| 249 | - | require.Equal(t, domain.RecoveryHealthy, got.RecoveryState) | |||
| 250 | - | require.Equal(t, fallback, *got.Committed) | |||
| 251 | - | require.Equal(t, []domain.CheckpointRef{fallback}, repository.repaired) | |||
| 252 | - | require.Equal(t, []string{"load", "load", "repair-head"}, repository.events) | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | func checkpointDigest(data []byte) [32]byte { | |||
| 256 | - | return sha256.Sum256(data) | |||
| 257 | - | } | |||
| 258 | - | ||||
| 259 | - | func TestDegradedExportIsReadOnly(t *testing.T) { | |||
| 260 | - | record := degradedTransactionRecord() | |||
| 261 | - | manifest := []byte("manifest") | |||
| 262 | - | ref := *record.Committed | |||
| 263 | - | ref.ManifestDigest = checkpointDigest(manifest) | |||
| 264 | - | record.Committed = &ref | |||
| 265 | - | catalogue := newTransactionCatalogue(record) | |||
| 266 | - | repository := newTransactionRepository() | |||
| 267 | - | repository.generations[ref] = ports.SnapshotGeneration{ | |||
| 268 | - | IncarnationID: record.IncarnationID, Name: record.Name, Generation: ref.Generation, | |||
| 269 | - | Manifest: manifest, Objects: map[ports.SnapshotDigest][]byte{{2}: []byte("second"), {1}: []byte("first")}, | |||
| 270 | - | } | |||
| 271 | - | coordinator := NewCoordinator(catalogue, repository, newTransactionJournal(), nil) | |||
| 272 | - | var exported, repeated bytes.Buffer | |||
| 273 | - | ||||
| 274 | - | require.NoError(t, coordinator.Export(context.Background(), record.Name, &exported)) | |||
| 275 | - | require.NoError(t, coordinator.Export(context.Background(), record.Name, &repeated)) | |||
| 276 | - | golden, err := hex.DecodeString("5645565800010100000000000000000000000000000000000000000000030000000662726f6b656e000000086d616e69666573740000000201000000000000000000000000000000000000000000000000000000000000000000000566697273740200000000000000000000000000000000000000000000000000000000000000000000067365636f6e64") | |||
| 277 | - | require.NoError(t, err) | |||
| 278 | - | require.Equal(t, golden, exported.Bytes()) | |||
| 279 | - | require.Equal(t, exported.Bytes(), repeated.Bytes()) | |||
| 280 | - | require.Equal(t, record, catalogue.records[record.Name]) | |||
| 281 | - | require.Empty(t, catalogue.events) | |||
| 282 | - | } | |||
| 283 | - | ||||
| 284 | - | // An absent catalogue record means the discard no longer has durable name | |||
| 285 | - | // authority to replace, so recovery removes the completed intent. | |||
| 286 | func TestDiscardRecoveryRemovesIntentWhenCatalogueRecordIsAbsent(t *testing.T) { | 216 | func TestDiscardRecoveryRemovesIntentWhenCatalogueRecordIsAbsent(t *testing.T) { | ||
| 287 | old := degradedTransactionRecord() | 217 | old := degradedTransactionRecord() | ||
| 288 | intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"} | 218 | intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"} | ||