detached cbe62e5

refactor(recovery): reduce recovery to one state and one action

commit
cbe62e5c0a6c89d65a6720eaaba00f05ffd44db7
author
brice · · 2026-07-26 10:44 UTC
parent
5297bf6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
3939 TabNames []string
4040 RecoveryState RecoveryState
4141 Committed *CheckpointRef
42- Fallbacks [2]*CheckpointRef
4342 DegradedReason string
4443 }
4544
@@ -57,8 +56,7 @@ func (r CatalogueRecord) Equal(other CatalogueRecord) bool {
5756 return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd &&
5857 r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq &&
5958 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
6260 }
6361
6462 // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue
@@ -169,8 +167,8 @@ func (r CatalogueRecord) Validate() error {
169167
170168 switch r.RecoveryState {
171169 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")
174172 }
175173 case RecoveryHealthy:
176174 if r.Committed == nil {
@@ -192,33 +190,13 @@ func (r CatalogueRecord) Validate() error {
192190 if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" {
193191 return errors.New("non-degraded session has degraded reason")
194192 }
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 {
209195 return errors.New("zero checkpoint generation")
210196 }
211- if ref.ManifestDigest == ([32]byte{}) {
197+ if r.Committed.ManifestDigest == ([32]byte{}) {
212198 return errors.New("zero checkpoint manifest digest")
213199 }
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
222200 }
223201 return nil
224202 }
@@ -39,7 +39,6 @@ type CatalogueRecord struct {
39 TabNames []string39 TabNames []string
40 RecoveryState RecoveryState40 RecoveryState RecoveryState
41 Committed *CheckpointRef41 Committed *CheckpointRef
42- Fallbacks [2]*CheckpointRef
43 DegradedReason string42 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 catalogue62 // 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 nil201 return nil
224 }202 }
M internal/domain/recovery_test.go +0 -3
@@ -39,7 +39,6 @@ func TestCatalogueRecordValidate(t *testing.T) {
3939 }{
4040 {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true},
4141 {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}}}}},
4342 {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}},
4443 {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}},
4544 {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}},
@@ -50,8 +49,6 @@ func TestCatalogueRecordValidate(t *testing.T) {
5049 {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}},
5150 {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
5251 {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}}}}},
5552 }
5653 for _, tt := range tests {
5754 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) {
2424
2525 func testCatalogueRecordRoundTrip(t *testing.T) {
2626 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}}
2927 tests := []domain.CatalogueRecord{
3028 {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh},
3129 {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"},
3431 {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting},
3532 }
3633 for _, record := range tests {
@@ -129,13 +126,10 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
129126 defer func() { require.NoError(t, p.Close()) }()
130127
131128 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}}
134129 original := domain.CatalogueRecord{
135130 Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,
136131 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",
139133 }
140134 require.NoError(t, p.Create(original))
141135
@@ -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 (
99 "github.com/bnema/vev/internal/domain"
1010 )
1111
12-const catalogueRecordVersion uint16 = 1
12+const catalogueRecordVersion uint16 = 2
1313
1414 var errMalformedRecord = errors.New("persist: malformed catalogue record")
1515
@@ -46,8 +46,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
4646 }
4747 buf = append(buf, byte(record.RecoveryState))
4848 buf = appendRef(buf, record.Committed)
49- buf = appendRef(buf, record.Fallbacks[0])
50- buf = appendRef(buf, record.Fallbacks[1])
5149 buf, err = appendCheckedString(buf, record.DegradedReason)
5250 return buf, err
5351 }
@@ -110,12 +108,6 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error
110108 if record.Committed, ok = reader.ref(); !ok {
111109 return domain.CatalogueRecord{}, errMalformedRecord
112110 }
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- }
119111 if record.DegradedReason, ok = reader.str(); !ok || reader.remaining() != 0 {
120112 return domain.CatalogueRecord{}, errMalformedRecord
121113 }
@@ -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 = 112+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, err50 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{}, errMalformedRecord109 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{}, errMalformedRecord112 return domain.CatalogueRecord{}, errMalformedRecord
121 }113 }
M internal/ports/mocks/recovery_mock.go +0 -328
@@ -6,7 +6,6 @@ package portsmocks
66
77 import (
88 "context"
9- "io"
109
1110 "github.com/bnema/vev/internal/domain"
1211 "github.com/bnema/vev/internal/ports"
@@ -243,78 +242,6 @@ func (_m *MockCheckpointCoordinator) EXPECT() *MockCheckpointCoordinator_Expecte
243242 return &MockCheckpointCoordinator_Expecter{mock: &_m.Mock}
244243 }
245244
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-
318245 // PublishCheckpoint provides a mock function for the type MockCheckpointCoordinator
319246 func (_mock *MockCheckpointCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) {
320247 ret := _mock.Called(context1, s, snapshotPublication)
@@ -537,78 +464,6 @@ func (_c *MockSessionLifecycleCoordinator_Delete_Call) RunAndReturn(run func(con
537464 return _c
538465 }
539466
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-
612467 // PublishCheckpoint provides a mock function for the type MockSessionLifecycleCoordinator
613468 func (_mock *MockSessionLifecycleCoordinator) PublishCheckpoint(context1 context.Context, s string, snapshotPublication ports.SnapshotPublication) (domain.CatalogueRecord, error) {
614469 ret := _mock.Called(context1, s, snapshotPublication)
@@ -852,189 +707,6 @@ func (_c *MockDegradedRecoveryCoordinator_Discard_Call) RunAndReturn(run func(co
852707 return _c
853708 }
854709
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-
1038710 // 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.
1039711 // The first argument is typically a *testing.T value.
1040712 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 MockCheckpointCoordinator245 // 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 _c464 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 MockSessionLifecycleCoordinator467 // 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 _c707 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
33 import (
44 "context"
55 "errors"
6- "io"
76
87 "github.com/bnema/vev/internal/domain"
98 )
@@ -112,15 +111,8 @@ type RecoveryJournal interface {
112111 DeleteDiscard(context.Context, domain.IncarnationID) error
113112 }
114113
115-type FallbackPromotionOutcome struct {
116- Record domain.CatalogueRecord
117- CatalogueCommitted bool
118- HEADRepairError error
119-}
120-
121114 type CheckpointCoordinator interface {
122115 PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error)
123- PromoteFallback(context.Context, string, domain.CheckpointRef) (FallbackPromotionOutcome, error)
124116 }
125117
126118 // SessionLifecycleCoordinator owns durable named-session create, rename, and
@@ -133,11 +125,8 @@ type SessionLifecycleCoordinator interface {
133125 Delete(context.Context, string) error
134126 }
135127
136-// DegradedRecoveryCoordinator exposes only explicit operator recovery actions.
128+// DegradedRecoveryCoordinator exposes only the explicit operator discard action.
137129 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
141130 Discard(context.Context, string, string) (domain.CatalogueRecord, error)
142131 }
143132
@@ -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) error111 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, and118 // SessionLifecycleCoordinator owns durable named-session create, rename, and
@@ -133,11 +125,8 @@ type SessionLifecycleCoordinator interface {
133 Delete(context.Context, string) error125 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 {
133133 RenameSessionTo(name string) error
134134 RenameTabTo(name string) error
135135 Toast(severity, message string) error
136- SessionRecovery(action, argument string) (string, error)
136+ SessionRecovery(action string) (string, error)
137137 ListSessions(json bool) (string, error)
138138 ListTabs(json bool) (string, error)
139139 ListPanes(json bool) (string, error)
@@ -133,7 +133,7 @@ type ControlContext interface {
133 RenameSessionTo(name string) error133 RenameSessionTo(name string) error
134 RenameTabTo(name string) error134 RenameTabTo(name string) error
135 Toast(severity, message string) error135 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 @@
11 package command
22
3-import (
4- "path/filepath"
5- "strings"
6-)
3+import "strings"
74
85 // Registry returns all palette and control commands in stable display order.
96 func Registry() []Command {
@@ -95,25 +92,14 @@ func toastCommand() Command {
9592
9693 func sessionRecoveryCommand() Command {
9794 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",
10097 Scriptable: true, Target: TargetNone,
10198 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" {
114100 return ControlResult{}, ErrInvalidArguments
115101 }
116- output, err := ctx.SessionRecovery(action, argument)
102+ output, err := ctx.SessionRecovery("discard")
117103 return ControlResult{Output: output}, err
118104 },
119105 }
@@ -1,9 +1,6 @@
1 package command1 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 string99+ 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{}, ErrInvalidArguments100 return ControlResult{}, ErrInvalidArguments
115 }101 }
116- output, err := ctx.SessionRecovery(action, argument)102+ output, err := ctx.SessionRecovery("discard")
117 return ControlResult{Output: output}, err103 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) {
115115 wantCall string
116116 wantErr error
117117 }{
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},
123122 {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},
133123 }
134124 for _, test := range tests {
135125 ctx := &controlSpy{recoveryOutput: "recovery output"}
@@ -220,8 +210,8 @@ func (s *controlSpy) RenameTabTo(v string) error { return s.record("renam
220210 func (s *controlSpy) Toast(level, message string) error {
221211 return s.record("toast:" + level + ":" + message)
222212 }
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)
225215 }
226216 func (s *controlSpy) ListSessions(json bool) (string, error) {
227217 _ = s.record("list-sessions:" + strconv.FormatBool(json))
@@ -115,21 +115,11 @@ func TestSessionRecoveryCommand(t *testing.T) {
115 wantCall string115 wantCall string
116 wantErr error116 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 (
55 "encoding/json"
66 "errors"
77 "fmt"
8- "os"
9- "path/filepath"
108 "slices"
119 "sort"
12- "strconv"
1310 "strings"
1411
1512 "github.com/bnema/vev/internal/domain"
@@ -457,8 +454,8 @@ func (e controlExec) Toast(severity, message string) error {
457454 return nil
458455 }
459456
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" {
462459 return "", command.ErrInvalidArguments
463460 }
464461 ctx := e.ctx
@@ -468,92 +465,11 @@ func (e controlExec) SessionRecovery(action, argument string) (string, error) {
468465 if ctx == nil {
469466 ctx = context.Background()
470467 }
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)
537471 }
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
557473 }
558474
559475 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 nil454 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.ErrInvalidArguments459 return "", command.ErrInvalidArguments
463 }460 }
464 ctx := e.ctx461 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
22
33 import (
44 "context"
5- "crypto/sha256"
65 "errors"
76 "fmt"
87 "io"
@@ -19,7 +18,6 @@ import (
1918 "github.com/bnema/vev/internal/domain"
2019 "github.com/bnema/vev/internal/ports"
2120 portsmocks "github.com/bnema/vev/internal/ports/mocks"
22- "github.com/bnema/vev/internal/usecase/command"
2321 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
2422 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2523 )
@@ -284,63 +282,6 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
284282 require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime")
285283 })
286284
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()])
344285 }
345286
346287 func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
@@ -414,14 +355,6 @@ type explicitRecoveryStub struct {
414355 record domain.CatalogueRecord
415356 }
416357
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-}
425358 func (s explicitRecoveryStub) Discard(context.Context, string, string) (domain.CatalogueRecord, error) {
426359 return s.record, nil
427360 }
@@ -434,9 +367,9 @@ type contextRecoveryStub struct {
434367 err error
435368 }
436369
437-func (s *contextRecoveryStub) Retry(ctx context.Context, _ string) error {
370+func (s *contextRecoveryStub) Discard(ctx context.Context, _, _ string) (domain.CatalogueRecord, error) {
438371 s.seen = ctx.Value(s.wantKey) == s.want
439- return s.err
372+ return domain.CatalogueRecord{}, s.err
440373 }
441374
442375 func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {
@@ -448,7 +381,7 @@ func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {
448381 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
449382 d.degradedRecovery = stub
450383
451- _, err := (controlExec{ctx: ctx, d: d, recoveryName: "work"}).SessionRecovery("retry", "")
384+ _, err := (controlExec{ctx: ctx, d: d, recoveryName: "work"}).SessionRecovery("discard")
452385 require.ErrorIs(t, err, wantErr)
453386 require.True(t, stub.seen)
454387 }
@@ -482,61 +415,6 @@ func TestRetryableRestoreLoadErrorClassification(t *testing.T) {
482415 }
483416 }
484417
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-
540418 func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) {
541419 for _, tt := range []struct {
542420 name string
@@ -600,32 +478,6 @@ func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testi
600478 require.Empty(t, d.sessions)
601479 }
602480
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-
629481 func TestSessionRecoveryCommand(t *testing.T) {
630482 degraded := durableRecoveryRecord(2)
631483 degraded.RecoveryState = domain.RecoveryDegraded
@@ -635,11 +487,10 @@ func TestSessionRecoveryCommand(t *testing.T) {
635487 fresh.IncarnationID = domain.IncarnationID{9}
636488 fresh.RecoveryState = domain.RecoveryFresh
637489 fresh.Committed = nil
638- fresh.Fallbacks = [2]*domain.CheckpointRef{}
639490 fresh.DegradedReason = ""
640491 d.degradedRecovery = explicitRecoveryStub{record: fresh}
641492
642- _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard", "")
493+ _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
643494 require.NoError(t, err)
644495 require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state)
645496 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.CatalogueRecord355 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, nil359 return s.record, nil
427 }360 }
@@ -434,9 +367,9 @@ type contextRecoveryStub struct {
434 err error367 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.want371 s.seen = ctx.Value(s.wantKey) == s.want
439- return s.err372+ 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 = stub382 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 string420 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.RecoveryDegraded483 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.RecoveryFresh488 fresh.RecoveryState = domain.RecoveryFresh
637 fresh.Committed = nil489 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
5353 if record.Committed != nil {
5454 plan.Keep = append(plan.Keep, *record.Committed)
5555 }
56- for _, fallback := range record.Fallbacks {
57- if fallback != nil {
58- plan.Keep = append(plan.Keep, *fallback)
59- }
60- }
6156 return plan
6257 }
6358
@@ -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 plan56 return plan
62 }57 }
63 58
M internal/usecase/daemon/maintenance_test.go +6 -8
@@ -83,7 +83,7 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) {
8383 fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
8484 record := domain.CatalogueRecord{
8585 Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy,
86- Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2},
86+ Committed: &committed,
8787 }
8888 wrongParent := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{9}}
8989
@@ -98,14 +98,14 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) {
9898 finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{
9999 Name: record.Name, IncarnationID: record.IncarnationID, Ref: committed, Parent: &fallback1,
100100 }},
101- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}},
101+ wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}},
102102 },
103103 {
104104 name: "known predecessor is conclusively not forward",
105105 finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{
106106 Name: record.Name, IncarnationID: record.IncarnationID, Ref: fallback1, Parent: &fallback2,
107107 }},
108- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}},
108+ wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}},
109109 },
110110 {
111111 name: "non-forward candidate kind remains pinned",
@@ -171,11 +171,9 @@ func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) {
171171
172172 func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) {
173173 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}}
176174 healthy := domain.CatalogueRecord{
177175 Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy,
178- Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2},
176+ Committed: &committed,
179177 }
180178
181179 for _, tc := range []struct {
@@ -196,8 +194,8 @@ func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) {
196194 if plan.PinAll != tc.wantPin {
197195 t.Fatalf("PinAll = %v, want %v", plan.PinAll, tc.wantPin)
198196 }
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)
201199 }
202200 })
203201 }
@@ -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
138138 d.finishRecordRestoreAttempt(record, restoreErr, done, false)
139139 }
140140
141-func (d *Daemon) finishExplicitRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) {
142- d.finishRecordRestoreAttempt(record, restoreErr, done, true)
143-}
144-
145141 func (d *Daemon) finishRecordRestoreAttempt(record domain.CatalogueRecord, restoreErr error, done chan struct{}, resetRetryable bool) {
146142 retryable := errors.Is(restoreErr, errRetryableRestoreLoad)
147143 degraded := false
@@ -215,75 +211,39 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
215211 return errors.New("snapshot: repository unavailable")
216212 }
217213
218- candidates := checkpointCandidates(record)
219- if len(candidates) == 0 {
214+ if record.Committed == nil {
220215 return errors.New("snapshot: healthy record has no checkpoint")
221216 }
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
253222 }
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)
257225 }
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")
260228 }
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")
266233 }
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 {
287247 d.log.Warn("snapshot_head_repair_pending", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation, "reason_code", "repair-failed")
288248 } else {
289249 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
293253 return err
294254 }
295255 d.setStoppedRecovery(record, runtimeHealthy)
296- d.logSessionRestoreComplete(record, selected.Generation, selectedFallback)
256+ d.logSessionRestoreComplete(record, selected.Generation, false)
297257 return nil
298258 }
299259
@@ -334,19 +294,6 @@ func (d *Daemon) logRejectedRestoreCandidate(record domain.CatalogueRecord, cand
334294 )
335295 }
336296
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-
350297 func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {
351298 manifest, err := snapcodec.UnmarshalManifest(generation.Manifest)
352299 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 := false143 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.CheckpointRef217+ selected := *record.Committed
223- var selectedGeneration ports.SnapshotGeneration218+ selectedGeneration, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, selected)
224- var selectedSnapshot snapcodec.Session219+ if err != nil {
225- selectedIndex := -1220+ if ctxErr := ctx.Err(); ctxErr != nil {
226- selectedFallback := false221+ 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, fallback226+ d.logRejectedRestoreCandidate(record, selected, false, "load-failed", err)
259- break227+ 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.Record246+ 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 err253 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 nil257 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) {
262262 generation := acceptanceGeneration(t, snapshot, 9)
263263 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
264264 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}}
267265 record := domain.CatalogueRecord{
268266 Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd",
269267 CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17,
270268 TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy,
271- Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2},
269+ Committed: &committed,
272270 }
273271 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
274272
@@ -296,7 +294,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
296294 require.Equal(t, record.IncarnationID, updated.IncarnationID)
297295 require.Equal(t, record.RecoveryState, updated.RecoveryState)
298296 require.Equal(t, record.Committed, updated.Committed)
299- require.Equal(t, record.Fallbacks, updated.Fallbacks)
300297 require.Equal(t, record.DegradedReason, updated.DegradedReason)
301298 require.Equal(t, []string{"after"}, updated.TabNames)
302299
@@ -314,8 +311,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
314311 require.NoError(t, err)
315312 require.True(t, ok)
316313 require.Equal(t, uint64(10), published.Committed.Generation)
317- require.Equal(t, committed, *published.Fallbacks[0])
318- require.Equal(t, fallback1, *published.Fallbacks[1])
319314 }
320315
321316 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 (
1313 var (
1414 ErrCheckpointRecordNotFound = errors.New("recovery: checkpoint catalogue record not found")
1515 ErrCheckpointConflict = errors.New("recovery: checkpoint publication conflicts with catalogue")
16- ErrFallbackNotFound = errors.New("recovery: checkpoint is not a catalogue fallback")
1716 )
1817
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-
3018 // PublishCheckpoint publishes repository data before atomically making its
3119 // reference authoritative in the catalogue. A catalogue failure deliberately
3220 // leaves the durable publication as a same-incarnation forward orphan.
@@ -54,91 +42,17 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public
5442 }
5543
5644 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-
11745 next := record
11846 next.RecoveryState = domain.RecoveryHealthy
11947 next.Committed = copyCheckpointRef(&ref)
120- next.Fallbacks = [2]*domain.CheckpointRef{}
12148 next.DegradedReason = ""
122- for i := range alternatives {
123- next.Fallbacks[i] = copyCheckpointRef(&alternatives[i])
124- }
12549 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)
12751 }
12852 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
14054 }
141- return validateLoadedGeneration(record, ref, generation)
55+ return next, nil
14256 }
14357
14458 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 its18 // PublishCheckpoint publishes repository data before atomically making its
31 // reference authoritative in the catalogue. A catalogue failure deliberately19 // 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 := record45 next := record
118 next.RecoveryState = domain.RecoveryHealthy46 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{}, err53+ 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
22
33 import (
44 "context"
5- "crypto/sha256"
65 "errors"
76 "sync"
87 "testing"
@@ -108,16 +107,6 @@ func checkpointPublication(record domain.CatalogueRecord, generation uint64) por
108107 }
109108 }
110109
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-
121110 func runCheckpointCommit(t *testing.T, count int) (domain.CatalogueRecord, *checkpointCatalogue, *checkpointRepository) {
122111 t.Helper()
123112 events := make([]string, 0, count*2)
@@ -140,17 +129,15 @@ func TestCheckpointCommits(t *testing.T) {
140129 name string
141130 count int
142131 wantCommitted uint64
143- wantFallbacks []uint64
144132 }{
145133 {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},
149137 } {
150138 t.Run(tc.name, func(t *testing.T) {
151139 record, catalogue, _ := runCheckpointCommit(t, tc.count)
152140 require.Equal(t, tc.wantCommitted, record.Committed.Generation)
153- require.Equal(t, tc.wantFallbacks, populatedGenerations(record.Fallbacks))
154141 require.Equal(t, domain.RecoveryHealthy, record.RecoveryState)
155142 require.Empty(t, record.DegradedReason)
156143 require.Equal(t, tc.count, catalogue.replaces)
@@ -158,59 +145,6 @@ func TestCheckpointCommits(t *testing.T) {
158145 }
159146 }
160147
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-
214148 func TestCheckpointCommitPublishOrphan(t *testing.T) {
215149 prior := checkpointRecord()
216150 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 string129 name string
141 count int130 count int
142 wantCommitted uint64131 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)
160160 record.IncarnationID = id
161161 record.RecoveryState = domain.RecoveryFresh
162162 record.Committed = nil
163- record.Fallbacks = [2]*domain.CheckpointRef{}
164163 record.DegradedReason = ""
165164 if err := record.Validate(); err != nil {
166165 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 = id160 record.IncarnationID = id
161 record.RecoveryState = domain.RecoveryFresh161 record.RecoveryState = domain.RecoveryFresh
162 record.Committed = nil162 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 @@
11 package recovery
22
33 import (
4- "bytes"
54 "context"
6- "crypto/sha256"
7- "encoding/binary"
85 "errors"
96 "fmt"
10- "io"
11- "slices"
127
138 "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
259 )
2610
2711 var (
@@ -31,143 +15,6 @@ var (
3115 ErrDiscardIntentInvalid = errors.New("recovery: discard intent is unusable")
3216 )
3317
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-
17118 // Discard commits to a new incarnation by saving the complete intent before
17219 // any quarantine or catalogue mutation. There is no rollback after that point.
17320 func (c *Coordinator) Discard(ctx context.Context, name, reason string) (domain.CatalogueRecord, error) {
@@ -289,7 +136,6 @@ func freshReplacement(intent domain.DiscardIntent) domain.CatalogueRecord {
289136 next.IncarnationID = intent.NewIncarnation
290137 next.RecoveryState = domain.RecoveryFresh
291138 next.Committed = nil
292- next.Fallbacks = [2]*domain.CheckpointRef{}
293139 next.DegradedReason = ""
294140 return next
295141 }
@@ -1,27 +1,11 @@
1 package recovery1 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 before18 // 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.NewIncarnation136 next.IncarnationID = intent.NewIncarnation
290 next.RecoveryState = domain.RecoveryFresh137 next.RecoveryState = domain.RecoveryFresh
291 next.Committed = nil138 next.Committed = nil
292- next.Fallbacks = [2]*domain.CheckpointRef{}
293 next.DegradedReason = ""139 next.DegradedReason = ""
294 return next140 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
160160 if record.RecoveryState != domain.RecoveryHealthy || candidate.Name != name || candidate.IncarnationID != record.IncarnationID || record.Committed == nil || !validForwardChain(*record.Committed, candidate, ancestors) {
161161 return domain.CatalogueRecord{}, ErrCheckpointConflict
162162 }
163- next := shiftedCheckpoint(record, candidate.Ref)
163+ next := record
164+ next.Committed = copyCheckpointRef(&candidate.Ref)
165+ next.DegradedReason = ""
164166 if err := next.Validate(); err != nil {
165167 return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid reconciled checkpoint transition: %w", err)
166168 }
@@ -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{}, ErrCheckpointConflict161 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) {
124124 first.Name = "first"
125125 staleRecord := first
126126 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}}
128128 second := healthyReconcileRecord()
129129 second.Name = "second"
130130 adoptable := forwardCandidate(second, 2, second.Committed)
@@ -213,7 +213,7 @@ func TestForwardOrphanAdoption(t *testing.T) {
213213 record := healthyReconcileRecord()
214214 candidate := forwardCandidate(record, 2, record.Committed)
215215 concurrent := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{7}}
216- record = shiftedCheckpoint(record, concurrent)
216+ record.Committed = &concurrent
217217 catalogue := &checkpointCatalogue{record: record}
218218 repository := &reconcileRepository{}
219219 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 := first125 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 @@
11 package recovery
22
33 import (
4- "bytes"
54 "context"
6- "crypto/sha256"
7- "encoding/hex"
85 "errors"
96 "sync"
107 "testing"
@@ -212,77 +209,10 @@ func degradedTransactionRecord() domain.CatalogueRecord {
212209 return domain.CatalogueRecord{
213210 Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded,
214211 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}}},
216212 DegradedReason: "checkpoint unreadable",
217213 }
218214 }
219215
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.
286216 func TestDiscardRecoveryRemovesIntentWhenCatalogueRecordIsAbsent(t *testing.T) {
287217 old := degradedTransactionRecord()
288218 intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
@@ -1,10 +1,7 @@
1 package recovery1 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"}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close