detached 48ce437

fix(recovery): roll discard intents forward and fence per-session conflicts

discard roll-forward required byte equality with the pristine replacement, so a failed cleanup on an already-live session bricked startup permanently. It now checks identity only. Recover fences a session whose own durable state is self-inconsistent instead of aborting startup for every session; infrastructure failures still abort.
commit
48ce43781515a0e3c09ec6de261a54698c127f43
author
brice · · 2026-07-26 04:54 UTC
parent
9f082df
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +234 -16
M internal/usecase/recovery/coordinator.go +105 -8
@@ -83,12 +83,54 @@ func compactStrings(values []string) []string {
8383 return out
8484 }
8585
86+var (
87+ // ErrDeletionTombstoneInvalid marks a tombstone whose own identity cannot be
88+ // used. ErrDeletionTombstoneConflict marks a tombstone that disagrees with a
89+ // live catalogue record. Both are session-scoped: they fence one session
90+ // instead of failing startup.
91+ ErrDeletionTombstoneInvalid = errors.New("recovery: deletion tombstone is unusable")
92+ ErrDeletionTombstoneConflict = errors.New("recovery: deletion tombstone conflicts with catalogue")
93+ // ErrSessionRecordInvalid marks a catalogue record whose own contents cannot
94+ // be advanced deterministically.
95+ ErrSessionRecordInvalid = errors.New("recovery: catalogue record is unusable")
96+)
97+
98+// Degraded reasons are content-free by construction: they never carry paths,
99+// terminal content, or operator input.
100+const (
101+ degradedReasonDiscardIntent = "discard intent conflict"
102+ degradedReasonTombstone = "deletion tombstone conflict"
103+ degradedReasonRecord = "catalogue record conflict"
104+)
105+
86106 type Coordinator struct {
87107 catalogue ports.Catalogue
88108 repository ports.SnapshotRepository
89109 journal ports.RecoveryJournal
90110 locks *KeyLocks
91111 random io.Reader
112+
113+ conflictMu sync.Mutex
114+ conflicts []RecoveryConflict
115+}
116+
117+// RecoveryConflict is one session-scoped item Recover fenced off. Recover keeps
118+// the durable source (intent or tombstone) so a later operator action can still
119+// resolve it; startup continues for every other session.
120+type RecoveryConflict struct {
121+ Session string
122+ Kind string
123+ Err error
124+}
125+
126+// Conflicts returns the session-scoped items fenced by the last Recover calls.
127+func (c *Coordinator) Conflicts() []RecoveryConflict {
128+ if c == nil {
129+ return nil
130+ }
131+ c.conflictMu.Lock()
132+ defer c.conflictMu.Unlock()
133+ return append([]RecoveryConflict(nil), c.conflicts...)
92134 }
93135
94136 func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, journal ports.RecoveryJournal, random io.Reader) *Coordinator {
@@ -180,7 +222,7 @@ func (c *Coordinator) deleteLocked(ctx context.Context, record domain.CatalogueR
180222 record.RecoveryState = domain.RecoveryDeleting
181223 record.DegradedReason = ""
182224 if err := record.Validate(); err != nil {
183- return fmt.Errorf("recovery: invalid deleting record: %w", err)
225+ return fmt.Errorf("%w: invalid deleting record: %w", ErrSessionRecordInvalid, err)
184226 }
185227 if err := c.catalogue.Replace(record.Name, record); err != nil {
186228 return err
@@ -202,6 +244,12 @@ func (c *Coordinator) deleteLocked(ctx context.Context, record domain.CatalogueR
202244 // Recover rolls deleting records and strict deletion tombstones forward. The
203245 // two sources are enumerated independently because catalogue removal precedes
204246 // tombstone removal in the commit protocol.
247+//
248+// A per-item failure that only proves one session's durable state disagrees
249+// with itself fences that session and lets startup continue: healthy sessions
250+// must still restore next to a corrupt neighbour. Infrastructure failures
251+// (listing, journal, repository, or catalogue IO, and a non-advancing cursor)
252+// still abort startup, because they say nothing about any single session.
205253 func (c *Coordinator) Recover(ctx context.Context) error {
206254 if c == nil || c.catalogue == nil || c.repository == nil || c.journal == nil || c.locks == nil {
207255 return errors.New("recovery: incomplete coordinator dependencies")
@@ -211,8 +259,12 @@ func (c *Coordinator) Recover(ctx context.Context) error {
211259 return err
212260 }
213261 for _, intent := range intents {
262+ // The intent is deliberately retained for a fenced session: only a
263+ // successful roll-forward may remove it.
214264 if err := c.recoverDiscard(ctx, intent); err != nil {
215- return err
265+ if err := c.fenceSession(intent.SessionName, "discard-intent", degradedReasonDiscardIntent, err); err != nil {
266+ return err
267+ }
216268 }
217269 }
218270 for _, candidate := range c.catalogue.Records() {
@@ -220,13 +272,16 @@ func (c *Coordinator) Recover(ctx context.Context) error {
220272 continue
221273 }
222274 unlock := c.locks.Lock([]string{candidate.Name})
275+ var deleteErr error
223276 current, ok := c.catalogue.Record(candidate.Name)
224277 if ok && current.IncarnationID == candidate.IncarnationID && current.RecoveryState == domain.RecoveryDeleting {
225- err = c.deleteLocked(ctx, current)
278+ deleteErr = c.deleteLocked(ctx, current)
226279 }
227280 unlock()
228- if err != nil {
229- return err
281+ if deleteErr != nil {
282+ if err := c.fenceSession(candidate.Name, "catalogue-record", degradedReasonRecord, deleteErr); err != nil {
283+ return err
284+ }
230285 }
231286 }
232287
@@ -237,8 +292,12 @@ func (c *Coordinator) Recover(ctx context.Context) error {
237292 return err
238293 }
239294 for _, tombstone := range page.Tombstones {
295+ // A fenced tombstone is never deleted here: the next startup or an
296+ // explicit operator action must still be able to observe it.
240297 if err := c.recoverDeletionTombstone(ctx, tombstone); err != nil {
241- return err
298+ if err := c.fenceSession(tombstone.Name, "deletion-tombstone", degradedReasonTombstone, err); err != nil {
299+ return err
300+ }
242301 }
243302 }
244303 if page.Done {
@@ -251,9 +310,47 @@ func (c *Coordinator) Recover(ctx context.Context) error {
251310 }
252311 }
253312
313+// fenceSession returns cause unchanged when it is not session-scoped, so the
314+// caller aborts startup. For a session-scoped cause it records the conflict and
315+// durably marks the session degraded when the record can represent that state.
316+func (c *Coordinator) fenceSession(name, kind, reason string, cause error) error {
317+ if !isSessionScopedConflict(cause) {
318+ return cause
319+ }
320+ c.conflictMu.Lock()
321+ c.conflicts = append(c.conflicts, RecoveryConflict{Session: name, Kind: kind, Err: cause})
322+ c.conflictMu.Unlock()
323+
324+ unlock := c.locks.Lock([]string{name})
325+ defer unlock()
326+ record, ok := c.catalogue.Record(name)
327+ if !ok || record.RecoveryState != domain.RecoveryHealthy {
328+ // Fresh records cannot represent degraded state (it requires a committed
329+ // checkpoint), and deleting or already degraded records must keep the
330+ // state their own protocol owns. Fencing is then the recorded skip.
331+ return nil
332+ }
333+ record.RecoveryState = domain.RecoveryDegraded
334+ record.DegradedReason = reason
335+ if err := record.Validate(); err != nil {
336+ return nil
337+ }
338+ // A catalogue write failure here is infrastructure, not one session's data.
339+ return c.catalogue.Replace(name, record)
340+}
341+
342+// isSessionScopedConflict reports whether err proves only that one session's
343+// durable state is self-inconsistent. Everything else - IO, decode, traversal,
344+// context cancellation - is treated as infrastructure and aborts startup.
345+func isSessionScopedConflict(err error) bool {
346+ return errors.Is(err, ErrDiscardConflict) || errors.Is(err, ErrDiscardIntentInvalid) ||
347+ errors.Is(err, ErrDeletionTombstoneConflict) || errors.Is(err, ErrDeletionTombstoneInvalid) ||
348+ errors.Is(err, ErrSessionRecordInvalid)
349+}
350+
254351 func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
255352 if err := tombstone.Validate(); err != nil {
256- return err
353+ return fmt.Errorf("%w: %w", ErrDeletionTombstoneInvalid, err)
257354 }
258355 unlock := c.locks.Lock([]string{tombstone.Name})
259356 defer unlock()
@@ -264,7 +361,7 @@ func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone do
264361 case record.IncarnationID != tombstone.IncarnationID:
265362 includeLegacyName = false
266363 case record.RecoveryState != domain.RecoveryDeleting:
267- return fmt.Errorf("recovery: deletion tombstone conflicts with non-deleting session %q", tombstone.Name)
364+ return fmt.Errorf("%w: non-deleting session %q", ErrDeletionTombstoneConflict, tombstone.Name)
268365 }
269366 }
270367 if err := c.repository.QuarantineDeletionSources(ctx, tombstone, includeLegacyName); err != nil {
@@ -83,12 +83,54 @@ func compactStrings(values []string) []string {
83 return out83 return out
84 }84 }
85 85
86+var (
87+ // ErrDeletionTombstoneInvalid marks a tombstone whose own identity cannot be
88+ // used. ErrDeletionTombstoneConflict marks a tombstone that disagrees with a
89+ // live catalogue record. Both are session-scoped: they fence one session
90+ // instead of failing startup.
91+ ErrDeletionTombstoneInvalid = errors.New("recovery: deletion tombstone is unusable")
92+ ErrDeletionTombstoneConflict = errors.New("recovery: deletion tombstone conflicts with catalogue")
93+ // ErrSessionRecordInvalid marks a catalogue record whose own contents cannot
94+ // be advanced deterministically.
95+ ErrSessionRecordInvalid = errors.New("recovery: catalogue record is unusable")
96+)
97+
98+// Degraded reasons are content-free by construction: they never carry paths,
99+// terminal content, or operator input.
100+const (
101+ degradedReasonDiscardIntent = "discard intent conflict"
102+ degradedReasonTombstone = "deletion tombstone conflict"
103+ degradedReasonRecord = "catalogue record conflict"
104+)
105+
86 type Coordinator struct {106 type Coordinator struct {
87 catalogue ports.Catalogue107 catalogue ports.Catalogue
88 repository ports.SnapshotRepository108 repository ports.SnapshotRepository
89 journal ports.RecoveryJournal109 journal ports.RecoveryJournal
90 locks *KeyLocks110 locks *KeyLocks
91 random io.Reader111 random io.Reader
112+
113+ conflictMu sync.Mutex
114+ conflicts []RecoveryConflict
115+}
116+
117+// RecoveryConflict is one session-scoped item Recover fenced off. Recover keeps
118+// the durable source (intent or tombstone) so a later operator action can still
119+// resolve it; startup continues for every other session.
120+type RecoveryConflict struct {
121+ Session string
122+ Kind string
123+ Err error
124+}
125+
126+// Conflicts returns the session-scoped items fenced by the last Recover calls.
127+func (c *Coordinator) Conflicts() []RecoveryConflict {
128+ if c == nil {
129+ return nil
130+ }
131+ c.conflictMu.Lock()
132+ defer c.conflictMu.Unlock()
133+ return append([]RecoveryConflict(nil), c.conflicts...)
92 }134 }
93 135
94 func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, journal ports.RecoveryJournal, random io.Reader) *Coordinator {136 func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, journal ports.RecoveryJournal, random io.Reader) *Coordinator {
@@ -180,7 +222,7 @@ func (c *Coordinator) deleteLocked(ctx context.Context, record domain.CatalogueR
180 record.RecoveryState = domain.RecoveryDeleting222 record.RecoveryState = domain.RecoveryDeleting
181 record.DegradedReason = ""223 record.DegradedReason = ""
182 if err := record.Validate(); err != nil {224 if err := record.Validate(); err != nil {
183- return fmt.Errorf("recovery: invalid deleting record: %w", err)225+ return fmt.Errorf("%w: invalid deleting record: %w", ErrSessionRecordInvalid, err)
184 }226 }
185 if err := c.catalogue.Replace(record.Name, record); err != nil {227 if err := c.catalogue.Replace(record.Name, record); err != nil {
186 return err228 return err
@@ -202,6 +244,12 @@ func (c *Coordinator) deleteLocked(ctx context.Context, record domain.CatalogueR
202 // Recover rolls deleting records and strict deletion tombstones forward. The244 // Recover rolls deleting records and strict deletion tombstones forward. The
203 // two sources are enumerated independently because catalogue removal precedes245 // two sources are enumerated independently because catalogue removal precedes
204 // tombstone removal in the commit protocol.246 // tombstone removal in the commit protocol.
247+//
248+// A per-item failure that only proves one session's durable state disagrees
249+// with itself fences that session and lets startup continue: healthy sessions
250+// must still restore next to a corrupt neighbour. Infrastructure failures
251+// (listing, journal, repository, or catalogue IO, and a non-advancing cursor)
252+// still abort startup, because they say nothing about any single session.
205 func (c *Coordinator) Recover(ctx context.Context) error {253 func (c *Coordinator) Recover(ctx context.Context) error {
206 if c == nil || c.catalogue == nil || c.repository == nil || c.journal == nil || c.locks == nil {254 if c == nil || c.catalogue == nil || c.repository == nil || c.journal == nil || c.locks == nil {
207 return errors.New("recovery: incomplete coordinator dependencies")255 return errors.New("recovery: incomplete coordinator dependencies")
@@ -211,8 +259,12 @@ func (c *Coordinator) Recover(ctx context.Context) error {
211 return err259 return err
212 }260 }
213 for _, intent := range intents {261 for _, intent := range intents {
262+ // The intent is deliberately retained for a fenced session: only a
263+ // successful roll-forward may remove it.
214 if err := c.recoverDiscard(ctx, intent); err != nil {264 if err := c.recoverDiscard(ctx, intent); err != nil {
215- return err265+ if err := c.fenceSession(intent.SessionName, "discard-intent", degradedReasonDiscardIntent, err); err != nil {
266+ return err
267+ }
216 }268 }
217 }269 }
218 for _, candidate := range c.catalogue.Records() {270 for _, candidate := range c.catalogue.Records() {
@@ -220,13 +272,16 @@ func (c *Coordinator) Recover(ctx context.Context) error {
220 continue272 continue
221 }273 }
222 unlock := c.locks.Lock([]string{candidate.Name})274 unlock := c.locks.Lock([]string{candidate.Name})
275+ var deleteErr error
223 current, ok := c.catalogue.Record(candidate.Name)276 current, ok := c.catalogue.Record(candidate.Name)
224 if ok && current.IncarnationID == candidate.IncarnationID && current.RecoveryState == domain.RecoveryDeleting {277 if ok && current.IncarnationID == candidate.IncarnationID && current.RecoveryState == domain.RecoveryDeleting {
225- err = c.deleteLocked(ctx, current)278+ deleteErr = c.deleteLocked(ctx, current)
226 }279 }
227 unlock()280 unlock()
228- if err != nil {281+ if deleteErr != nil {
229- return err282+ if err := c.fenceSession(candidate.Name, "catalogue-record", degradedReasonRecord, deleteErr); err != nil {
283+ return err
284+ }
230 }285 }
231 }286 }
232 287
@@ -237,8 +292,12 @@ func (c *Coordinator) Recover(ctx context.Context) error {
237 return err292 return err
238 }293 }
239 for _, tombstone := range page.Tombstones {294 for _, tombstone := range page.Tombstones {
295+ // A fenced tombstone is never deleted here: the next startup or an
296+ // explicit operator action must still be able to observe it.
240 if err := c.recoverDeletionTombstone(ctx, tombstone); err != nil {297 if err := c.recoverDeletionTombstone(ctx, tombstone); err != nil {
241- return err298+ if err := c.fenceSession(tombstone.Name, "deletion-tombstone", degradedReasonTombstone, err); err != nil {
299+ return err
300+ }
242 }301 }
243 }302 }
244 if page.Done {303 if page.Done {
@@ -251,9 +310,47 @@ func (c *Coordinator) Recover(ctx context.Context) error {
251 }310 }
252 }311 }
253 312
313+// fenceSession returns cause unchanged when it is not session-scoped, so the
314+// caller aborts startup. For a session-scoped cause it records the conflict and
315+// durably marks the session degraded when the record can represent that state.
316+func (c *Coordinator) fenceSession(name, kind, reason string, cause error) error {
317+ if !isSessionScopedConflict(cause) {
318+ return cause
319+ }
320+ c.conflictMu.Lock()
321+ c.conflicts = append(c.conflicts, RecoveryConflict{Session: name, Kind: kind, Err: cause})
322+ c.conflictMu.Unlock()
323+
324+ unlock := c.locks.Lock([]string{name})
325+ defer unlock()
326+ record, ok := c.catalogue.Record(name)
327+ if !ok || record.RecoveryState != domain.RecoveryHealthy {
328+ // Fresh records cannot represent degraded state (it requires a committed
329+ // checkpoint), and deleting or already degraded records must keep the
330+ // state their own protocol owns. Fencing is then the recorded skip.
331+ return nil
332+ }
333+ record.RecoveryState = domain.RecoveryDegraded
334+ record.DegradedReason = reason
335+ if err := record.Validate(); err != nil {
336+ return nil
337+ }
338+ // A catalogue write failure here is infrastructure, not one session's data.
339+ return c.catalogue.Replace(name, record)
340+}
341+
342+// isSessionScopedConflict reports whether err proves only that one session's
343+// durable state is self-inconsistent. Everything else - IO, decode, traversal,
344+// context cancellation - is treated as infrastructure and aborts startup.
345+func isSessionScopedConflict(err error) bool {
346+ return errors.Is(err, ErrDiscardConflict) || errors.Is(err, ErrDiscardIntentInvalid) ||
347+ errors.Is(err, ErrDeletionTombstoneConflict) || errors.Is(err, ErrDeletionTombstoneInvalid) ||
348+ errors.Is(err, ErrSessionRecordInvalid)
349+}
350+
254 func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {351 func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
255 if err := tombstone.Validate(); err != nil {352 if err := tombstone.Validate(); err != nil {
256- return err353+ return fmt.Errorf("%w: %w", ErrDeletionTombstoneInvalid, err)
257 }354 }
258 unlock := c.locks.Lock([]string{tombstone.Name})355 unlock := c.locks.Lock([]string{tombstone.Name})
259 defer unlock()356 defer unlock()
@@ -264,7 +361,7 @@ func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone do
264 case record.IncarnationID != tombstone.IncarnationID:361 case record.IncarnationID != tombstone.IncarnationID:
265 includeLegacyName = false362 includeLegacyName = false
266 case record.RecoveryState != domain.RecoveryDeleting:363 case record.RecoveryState != domain.RecoveryDeleting:
267- return fmt.Errorf("recovery: deletion tombstone conflicts with non-deleting session %q", tombstone.Name)364+ return fmt.Errorf("%w: non-deleting session %q", ErrDeletionTombstoneConflict, tombstone.Name)
268 }365 }
269 }366 }
270 if err := c.repository.QuarantineDeletionSources(ctx, tombstone, includeLegacyName); err != nil {367 if err := c.repository.QuarantineDeletionSources(ctx, tombstone, includeLegacyName); err != nil {
M internal/usecase/recovery/discard.go +12 -5
@@ -28,6 +28,7 @@ var (
2828 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")
2929 ErrSessionNotDegraded = errors.New("recovery: session is not degraded")
3030 ErrDiscardConflict = errors.New("recovery: discard intent conflicts with catalogue")
31+ ErrDiscardIntentInvalid = errors.New("recovery: discard intent is unusable")
3132 )
3233
3334 // Retry directly validates the committed checkpoint. A failed validation is
@@ -212,7 +213,7 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
212213 }
213214 next := freshReplacement(intent)
214215 if err := next.Validate(); err != nil {
215- return fmt.Errorf("recovery: invalid discard replacement: %w", err)
216+ return fmt.Errorf("%w: invalid discard replacement: %w", ErrDiscardIntentInvalid, err)
216217 }
217218 switch current.IncarnationID {
218219 case intent.OldIncarnation:
@@ -220,8 +221,14 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
220221 return fmt.Errorf("%w: old record changed", ErrDiscardConflict)
221222 }
222223 case intent.NewIncarnation:
223- if !catalogueRecordsEqual(current, next) {
224- return fmt.Errorf("%w: replacement record changed", ErrDiscardConflict)
224+ // Step 4 is already durable. The replacement is live from this point on,
225+ // so it may legitimately have advanced to a committed checkpoint before a
226+ // later step failed. Only its identity may be required here: demanding
227+ // byte equality with the pristine replacement would fail closed forever
228+ // on a session that is in fact healthy, and destroy committed state if it
229+ // were overwritten. Steps 2-3 and 5 below are idempotent.
230+ if current.Name != intent.SessionName {
231+ return fmt.Errorf("%w: replacement session identity changed", ErrDiscardConflict)
225232 }
226233 default:
227234 return fmt.Errorf("%w: unexpected incarnation", ErrDiscardConflict)
@@ -243,11 +250,11 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
243250
244251 func validateDiscardIntent(intent domain.DiscardIntent) error {
245252 if err := intent.OldRecord.Validate(); err != nil {
246- return fmt.Errorf("recovery: invalid discard old record: %w", err)
253+ return fmt.Errorf("%w: invalid old record: %w", ErrDiscardIntentInvalid, err)
247254 }
248255 if intent.SessionName != intent.OldRecord.Name || intent.OldIncarnation != intent.OldRecord.IncarnationID ||
249256 intent.NewIncarnation == (domain.IncarnationID{}) || intent.NewIncarnation == intent.OldIncarnation || intent.Reason == "" {
250- return errors.New("recovery: invalid discard intent")
257+ return fmt.Errorf("%w: inconsistent intent fields", ErrDiscardIntentInvalid)
251258 }
252259 return nil
253260 }
@@ -28,6 +28,7 @@ var (
28 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")28 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")
29 ErrSessionNotDegraded = errors.New("recovery: session is not degraded")29 ErrSessionNotDegraded = errors.New("recovery: session is not degraded")
30 ErrDiscardConflict = errors.New("recovery: discard intent conflicts with catalogue")30 ErrDiscardConflict = errors.New("recovery: discard intent conflicts with catalogue")
31+ ErrDiscardIntentInvalid = errors.New("recovery: discard intent is unusable")
31 )32 )
32 33
33 // Retry directly validates the committed checkpoint. A failed validation is34 // Retry directly validates the committed checkpoint. A failed validation is
@@ -212,7 +213,7 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
212 }213 }
213 next := freshReplacement(intent)214 next := freshReplacement(intent)
214 if err := next.Validate(); err != nil {215 if err := next.Validate(); err != nil {
215- return fmt.Errorf("recovery: invalid discard replacement: %w", err)216+ return fmt.Errorf("%w: invalid discard replacement: %w", ErrDiscardIntentInvalid, err)
216 }217 }
217 switch current.IncarnationID {218 switch current.IncarnationID {
218 case intent.OldIncarnation:219 case intent.OldIncarnation:
@@ -220,8 +221,14 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
220 return fmt.Errorf("%w: old record changed", ErrDiscardConflict)221 return fmt.Errorf("%w: old record changed", ErrDiscardConflict)
221 }222 }
222 case intent.NewIncarnation:223 case intent.NewIncarnation:
223- if !catalogueRecordsEqual(current, next) {224+ // Step 4 is already durable. The replacement is live from this point on,
224- return fmt.Errorf("%w: replacement record changed", ErrDiscardConflict)225+ // so it may legitimately have advanced to a committed checkpoint before a
226+ // later step failed. Only its identity may be required here: demanding
227+ // byte equality with the pristine replacement would fail closed forever
228+ // on a session that is in fact healthy, and destroy committed state if it
229+ // were overwritten. Steps 2-3 and 5 below are idempotent.
230+ if current.Name != intent.SessionName {
231+ return fmt.Errorf("%w: replacement session identity changed", ErrDiscardConflict)
225 }232 }
226 default:233 default:
227 return fmt.Errorf("%w: unexpected incarnation", ErrDiscardConflict)234 return fmt.Errorf("%w: unexpected incarnation", ErrDiscardConflict)
@@ -243,11 +250,11 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
243 250
244 func validateDiscardIntent(intent domain.DiscardIntent) error {251 func validateDiscardIntent(intent domain.DiscardIntent) error {
245 if err := intent.OldRecord.Validate(); err != nil {252 if err := intent.OldRecord.Validate(); err != nil {
246- return fmt.Errorf("recovery: invalid discard old record: %w", err)253+ return fmt.Errorf("%w: invalid old record: %w", ErrDiscardIntentInvalid, err)
247 }254 }
248 if intent.SessionName != intent.OldRecord.Name || intent.OldIncarnation != intent.OldRecord.IncarnationID ||255 if intent.SessionName != intent.OldRecord.Name || intent.OldIncarnation != intent.OldRecord.IncarnationID ||
249 intent.NewIncarnation == (domain.IncarnationID{}) || intent.NewIncarnation == intent.OldIncarnation || intent.Reason == "" {256 intent.NewIncarnation == (domain.IncarnationID{}) || intent.NewIncarnation == intent.OldIncarnation || intent.Reason == "" {
250- return errors.New("recovery: invalid discard intent")257+ return fmt.Errorf("%w: inconsistent intent fields", ErrDiscardIntentInvalid)
251 }258 }
252 return nil259 return nil
253 }260 }
M internal/usecase/recovery/transactions_test.go +117 -3
@@ -92,6 +92,7 @@ type transactionRepository struct {
9292 tombstones map[domain.IncarnationID]domain.DeletionTombstone
9393 generations map[domain.CheckpointRef]ports.SnapshotGeneration
9494 loadErr error
95+ quarantineErr error
9596 events []string
9697 quarantine []bool
9798 descriptors []domain.QuarantineDescriptor
@@ -163,7 +164,7 @@ func (r *transactionRepository) QuarantineDeletionSources(_ context.Context, _ d
163164 defer r.mu.Unlock()
164165 r.events = append(r.events, "quarantine")
165166 r.quarantine = append(r.quarantine, includeLegacyName)
166- return nil
167+ return r.quarantineErr
167168 }
168169 func (r *transactionRepository) DeleteDeletionTombstone(_ context.Context, id domain.IncarnationID) error {
169170 r.mu.Lock()
@@ -280,6 +281,8 @@ func TestDegradedExportIsReadOnly(t *testing.T) {
280281 require.Empty(t, catalogue.events)
281282 }
282283
284+// A third incarnation still fails closed for that session: its intent and
285+// record are retained untouched, but startup is no longer aborted.
283286 func TestDiscardIncarnationConflict(t *testing.T) {
284287 old := degradedTransactionRecord()
285288 intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
@@ -288,11 +291,122 @@ func TestDiscardIncarnationConflict(t *testing.T) {
288291 catalogue := newTransactionCatalogue(conflict)
289292 journal := newTransactionJournal()
290293 journal.intents[old.IncarnationID] = intent
291- coordinator := NewCoordinator(catalogue, newTransactionRepository(), journal, nil)
294+ repository := newTransactionRepository()
295+ coordinator := NewCoordinator(catalogue, repository, journal, nil)
292296
293- require.Error(t, coordinator.Recover(context.Background()))
297+ require.NoError(t, coordinator.Recover(context.Background()))
294298 require.Equal(t, conflict, catalogue.records[old.Name])
295299 require.Contains(t, journal.intents, old.IncarnationID)
300+ require.Empty(t, repository.events)
301+ conflicts := coordinator.Conflicts()
302+ require.Len(t, conflicts, 1)
303+ require.Equal(t, old.Name, conflicts[0].Session)
304+ require.Equal(t, "discard-intent", conflicts[0].Kind)
305+ require.ErrorIs(t, conflicts[0].Err, ErrDiscardConflict)
306+}
307+
308+// A discard whose replacement is already durable, live, and checkpointed must
309+// roll forward. Requiring byte equality with the pristine replacement would
310+// leave the intent unresolvable and abort every future startup.
311+func TestDiscardRecoveryRollsForwardEvolvedReplacement(t *testing.T) {
312+ old := degradedTransactionRecord()
313+ intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
314+ evolved := freshReplacement(intent)
315+ evolved.RecoveryState = domain.RecoveryHealthy
316+ evolved.Committed = &domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
317+ evolved.UpdatedAt = 1234
318+ evolved.LastUsedSeq = 9
319+ evolved.TabNames = []string{"one"}
320+ require.NoError(t, evolved.Validate())
321+
322+ catalogue := newTransactionCatalogue(evolved)
323+ journal := newTransactionJournal()
324+ journal.intents[old.IncarnationID] = intent
325+ repository := newTransactionRepository()
326+ coordinator := NewCoordinator(catalogue, repository, journal, nil)
327+
328+ require.NoError(t, coordinator.Recover(context.Background()))
329+ require.Empty(t, coordinator.Conflicts())
330+ require.Equal(t, evolved, catalogue.records[old.Name], "committed replacement state must survive roll-forward")
331+ require.Empty(t, catalogue.events, "the live replacement record must not be rewritten")
332+ require.Equal(t, []domain.QuarantineDescriptor{quarantineDescriptor(intent)}, repository.descriptors)
333+ require.Equal(t, []domain.IncarnationID{intent.OldIncarnation}, repository.quarantinedIDs)
334+ require.NotContains(t, journal.intents, old.IncarnationID)
335+}
336+
337+// Goal 3: one session's self-inconsistent durable state must not stop a healthy
338+// neighbour from being recovered, and must not silently resolve itself either.
339+func TestRecoverFencesOneSessionAndContinues(t *testing.T) {
340+ ctx := context.Background()
341+ broken := degradedTransactionRecord()
342+ intent := domain.DiscardIntent{OldRecord: broken, OldIncarnation: broken.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: broken.Name, Reason: "discard"}
343+ conflict := broken
344+ conflict.IncarnationID = domain.IncarnationID{3}
345+ deleting := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDeleting}
346+
347+ catalogue := newTransactionCatalogue(conflict, deleting)
348+ journal := newTransactionJournal()
349+ journal.intents[broken.IncarnationID] = intent
350+ repository := newTransactionRepository()
351+ coordinator := NewCoordinator(catalogue, repository, journal, nil)
352+
353+ require.NoError(t, coordinator.Recover(ctx))
354+ require.NotContains(t, catalogue.records, deleting.Name, "healthy deletion must complete next to a fenced session")
355+ require.Empty(t, repository.tombstones)
356+ require.Equal(t, conflict, catalogue.records[broken.Name])
357+ require.Contains(t, journal.intents, broken.IncarnationID)
358+ require.Len(t, coordinator.Conflicts(), 1)
359+}
360+
361+// A tombstone that disagrees with a live healthy record degrades exactly that
362+// record, keeps the tombstone, and leaves every sibling tombstone processed.
363+func TestRecoverDegradesSessionOnTombstoneConflict(t *testing.T) {
364+ ctx := context.Background()
365+ live := domain.CatalogueRecord{
366+ Name: "live", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryHealthy,
367+ Committed: &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}},
368+ }
369+ conflicting := domain.DeletionTombstone{Name: live.Name, IncarnationID: live.IncarnationID}
370+ orphan := domain.DeletionTombstone{Name: "gone", IncarnationID: domain.IncarnationID{6}}
371+
372+ catalogue := newTransactionCatalogue(live)
373+ repository := newTransactionRepository()
374+ repository.tombstones[conflicting.IncarnationID] = conflicting
375+ repository.tombstones[orphan.IncarnationID] = orphan
376+ coordinator := NewCoordinator(catalogue, repository, journalStub{}, nil)
377+
378+ require.NoError(t, coordinator.Recover(ctx))
379+ got := catalogue.records[live.Name]
380+ require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
381+ require.Equal(t, degradedReasonTombstone, got.DegradedReason)
382+ require.Equal(t, live.Committed, got.Committed)
383+ require.Contains(t, repository.tombstones, conflicting.IncarnationID, "a fenced tombstone is never removed")
384+ require.NotContains(t, repository.tombstones, orphan.IncarnationID)
385+ conflicts := coordinator.Conflicts()
386+ require.Len(t, conflicts, 1)
387+ require.Equal(t, "deletion-tombstone", conflicts[0].Kind)
388+ require.ErrorIs(t, conflicts[0].Err, ErrDeletionTombstoneConflict)
389+}
390+
391+// Infrastructure failures still abort startup: they say nothing about any one
392+// session, so continuing would mutate durable state on a broken substrate.
393+func TestRecoverFailsOnInfrastructureErrors(t *testing.T) {
394+ ctx := context.Background()
395+ cause := errors.New("io failure")
396+ tombstone := domain.DeletionTombstone{Name: "work", IncarnationID: domain.IncarnationID{4}}
397+
398+ t.Run("journal listing", func(t *testing.T) {
399+ coordinator := NewCoordinator(newTransactionCatalogue(), newTransactionRepository(), journalStub{err: cause}, nil)
400+ require.ErrorIs(t, coordinator.Recover(ctx), cause)
401+ })
402+ t.Run("quarantine write", func(t *testing.T) {
403+ repository := newTransactionRepository()
404+ repository.tombstones[tombstone.IncarnationID] = tombstone
405+ repository.quarantineErr = cause
406+ coordinator := NewCoordinator(newTransactionCatalogue(), repository, journalStub{}, nil)
407+ require.ErrorIs(t, coordinator.Recover(ctx), cause)
408+ require.Empty(t, coordinator.Conflicts())
409+ })
296410 }
297411
298412 func TestDeleteRecoveryAfterCatalogueRemoval(t *testing.T) {
@@ -92,6 +92,7 @@ type transactionRepository struct {
92 tombstones map[domain.IncarnationID]domain.DeletionTombstone92 tombstones map[domain.IncarnationID]domain.DeletionTombstone
93 generations map[domain.CheckpointRef]ports.SnapshotGeneration93 generations map[domain.CheckpointRef]ports.SnapshotGeneration
94 loadErr error94 loadErr error
95+ quarantineErr error
95 events []string96 events []string
96 quarantine []bool97 quarantine []bool
97 descriptors []domain.QuarantineDescriptor98 descriptors []domain.QuarantineDescriptor
@@ -163,7 +164,7 @@ func (r *transactionRepository) QuarantineDeletionSources(_ context.Context, _ d
163 defer r.mu.Unlock()164 defer r.mu.Unlock()
164 r.events = append(r.events, "quarantine")165 r.events = append(r.events, "quarantine")
165 r.quarantine = append(r.quarantine, includeLegacyName)166 r.quarantine = append(r.quarantine, includeLegacyName)
166- return nil167+ return r.quarantineErr
167 }168 }
168 func (r *transactionRepository) DeleteDeletionTombstone(_ context.Context, id domain.IncarnationID) error {169 func (r *transactionRepository) DeleteDeletionTombstone(_ context.Context, id domain.IncarnationID) error {
169 r.mu.Lock()170 r.mu.Lock()
@@ -280,6 +281,8 @@ func TestDegradedExportIsReadOnly(t *testing.T) {
280 require.Empty(t, catalogue.events)281 require.Empty(t, catalogue.events)
281 }282 }
282 283
284+// A third incarnation still fails closed for that session: its intent and
285+// record are retained untouched, but startup is no longer aborted.
283 func TestDiscardIncarnationConflict(t *testing.T) {286 func TestDiscardIncarnationConflict(t *testing.T) {
284 old := degradedTransactionRecord()287 old := degradedTransactionRecord()
285 intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}288 intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
@@ -288,11 +291,122 @@ func TestDiscardIncarnationConflict(t *testing.T) {
288 catalogue := newTransactionCatalogue(conflict)291 catalogue := newTransactionCatalogue(conflict)
289 journal := newTransactionJournal()292 journal := newTransactionJournal()
290 journal.intents[old.IncarnationID] = intent293 journal.intents[old.IncarnationID] = intent
291- coordinator := NewCoordinator(catalogue, newTransactionRepository(), journal, nil)294+ repository := newTransactionRepository()
295+ coordinator := NewCoordinator(catalogue, repository, journal, nil)
292 296
293- require.Error(t, coordinator.Recover(context.Background()))297+ require.NoError(t, coordinator.Recover(context.Background()))
294 require.Equal(t, conflict, catalogue.records[old.Name])298 require.Equal(t, conflict, catalogue.records[old.Name])
295 require.Contains(t, journal.intents, old.IncarnationID)299 require.Contains(t, journal.intents, old.IncarnationID)
300+ require.Empty(t, repository.events)
301+ conflicts := coordinator.Conflicts()
302+ require.Len(t, conflicts, 1)
303+ require.Equal(t, old.Name, conflicts[0].Session)
304+ require.Equal(t, "discard-intent", conflicts[0].Kind)
305+ require.ErrorIs(t, conflicts[0].Err, ErrDiscardConflict)
306+}
307+
308+// A discard whose replacement is already durable, live, and checkpointed must
309+// roll forward. Requiring byte equality with the pristine replacement would
310+// leave the intent unresolvable and abort every future startup.
311+func TestDiscardRecoveryRollsForwardEvolvedReplacement(t *testing.T) {
312+ old := degradedTransactionRecord()
313+ intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
314+ evolved := freshReplacement(intent)
315+ evolved.RecoveryState = domain.RecoveryHealthy
316+ evolved.Committed = &domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
317+ evolved.UpdatedAt = 1234
318+ evolved.LastUsedSeq = 9
319+ evolved.TabNames = []string{"one"}
320+ require.NoError(t, evolved.Validate())
321+
322+ catalogue := newTransactionCatalogue(evolved)
323+ journal := newTransactionJournal()
324+ journal.intents[old.IncarnationID] = intent
325+ repository := newTransactionRepository()
326+ coordinator := NewCoordinator(catalogue, repository, journal, nil)
327+
328+ require.NoError(t, coordinator.Recover(context.Background()))
329+ require.Empty(t, coordinator.Conflicts())
330+ require.Equal(t, evolved, catalogue.records[old.Name], "committed replacement state must survive roll-forward")
331+ require.Empty(t, catalogue.events, "the live replacement record must not be rewritten")
332+ require.Equal(t, []domain.QuarantineDescriptor{quarantineDescriptor(intent)}, repository.descriptors)
333+ require.Equal(t, []domain.IncarnationID{intent.OldIncarnation}, repository.quarantinedIDs)
334+ require.NotContains(t, journal.intents, old.IncarnationID)
335+}
336+
337+// Goal 3: one session's self-inconsistent durable state must not stop a healthy
338+// neighbour from being recovered, and must not silently resolve itself either.
339+func TestRecoverFencesOneSessionAndContinues(t *testing.T) {
340+ ctx := context.Background()
341+ broken := degradedTransactionRecord()
342+ intent := domain.DiscardIntent{OldRecord: broken, OldIncarnation: broken.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: broken.Name, Reason: "discard"}
343+ conflict := broken
344+ conflict.IncarnationID = domain.IncarnationID{3}
345+ deleting := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDeleting}
346+
347+ catalogue := newTransactionCatalogue(conflict, deleting)
348+ journal := newTransactionJournal()
349+ journal.intents[broken.IncarnationID] = intent
350+ repository := newTransactionRepository()
351+ coordinator := NewCoordinator(catalogue, repository, journal, nil)
352+
353+ require.NoError(t, coordinator.Recover(ctx))
354+ require.NotContains(t, catalogue.records, deleting.Name, "healthy deletion must complete next to a fenced session")
355+ require.Empty(t, repository.tombstones)
356+ require.Equal(t, conflict, catalogue.records[broken.Name])
357+ require.Contains(t, journal.intents, broken.IncarnationID)
358+ require.Len(t, coordinator.Conflicts(), 1)
359+}
360+
361+// A tombstone that disagrees with a live healthy record degrades exactly that
362+// record, keeps the tombstone, and leaves every sibling tombstone processed.
363+func TestRecoverDegradesSessionOnTombstoneConflict(t *testing.T) {
364+ ctx := context.Background()
365+ live := domain.CatalogueRecord{
366+ Name: "live", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryHealthy,
367+ Committed: &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}},
368+ }
369+ conflicting := domain.DeletionTombstone{Name: live.Name, IncarnationID: live.IncarnationID}
370+ orphan := domain.DeletionTombstone{Name: "gone", IncarnationID: domain.IncarnationID{6}}
371+
372+ catalogue := newTransactionCatalogue(live)
373+ repository := newTransactionRepository()
374+ repository.tombstones[conflicting.IncarnationID] = conflicting
375+ repository.tombstones[orphan.IncarnationID] = orphan
376+ coordinator := NewCoordinator(catalogue, repository, journalStub{}, nil)
377+
378+ require.NoError(t, coordinator.Recover(ctx))
379+ got := catalogue.records[live.Name]
380+ require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
381+ require.Equal(t, degradedReasonTombstone, got.DegradedReason)
382+ require.Equal(t, live.Committed, got.Committed)
383+ require.Contains(t, repository.tombstones, conflicting.IncarnationID, "a fenced tombstone is never removed")
384+ require.NotContains(t, repository.tombstones, orphan.IncarnationID)
385+ conflicts := coordinator.Conflicts()
386+ require.Len(t, conflicts, 1)
387+ require.Equal(t, "deletion-tombstone", conflicts[0].Kind)
388+ require.ErrorIs(t, conflicts[0].Err, ErrDeletionTombstoneConflict)
389+}
390+
391+// Infrastructure failures still abort startup: they say nothing about any one
392+// session, so continuing would mutate durable state on a broken substrate.
393+func TestRecoverFailsOnInfrastructureErrors(t *testing.T) {
394+ ctx := context.Background()
395+ cause := errors.New("io failure")
396+ tombstone := domain.DeletionTombstone{Name: "work", IncarnationID: domain.IncarnationID{4}}
397+
398+ t.Run("journal listing", func(t *testing.T) {
399+ coordinator := NewCoordinator(newTransactionCatalogue(), newTransactionRepository(), journalStub{err: cause}, nil)
400+ require.ErrorIs(t, coordinator.Recover(ctx), cause)
401+ })
402+ t.Run("quarantine write", func(t *testing.T) {
403+ repository := newTransactionRepository()
404+ repository.tombstones[tombstone.IncarnationID] = tombstone
405+ repository.quarantineErr = cause
406+ coordinator := NewCoordinator(newTransactionCatalogue(), repository, journalStub{}, nil)
407+ require.ErrorIs(t, coordinator.Recover(ctx), cause)
408+ require.Empty(t, coordinator.Conflicts())
409+ })
296 }410 }
297 411
298 func TestDeleteRecoveryAfterCatalogueRemoval(t *testing.T) {412 func TestDeleteRecoveryAfterCatalogueRemoval(t *testing.T) {

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