detached 2bc840b

fix(recovery): stabilize restore completion states

commit
2bc840bf92f1ae302150722e7f3d6f1948fa6dcd
author
brice · · 2026-07-26 00:00 UTC
parent
27ab0c6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +273 -71
M internal/persist/migration.go +0 -7
@@ -416,13 +416,6 @@ func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord)
416416 return atomicMigrationWrite(filepath.Join(stateDir, migrationDirName, catalogueFormatName), []byte("v1\n"))
417417 }
418418
419-func decodeLegacyCatalogueV0(data []byte) ([]legacyCatalogueRecordV0, error) {
420- record, err := decodeLegacyRecordV0("legacy", data)
421- if err != nil {
422- return nil, err
423- }
424- return []legacyCatalogueRecordV0{record}, nil
425-}
426419 func decodeLegacyMap(data map[string][]byte) ([]legacyCatalogueRecordV0, error) {
427420 out := make([]legacyCatalogueRecordV0, 0, len(data))
428421 for name, value := range data {
@@ -416,13 +416,6 @@ func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord)
416 return atomicMigrationWrite(filepath.Join(stateDir, migrationDirName, catalogueFormatName), []byte("v1\n"))416 return atomicMigrationWrite(filepath.Join(stateDir, migrationDirName, catalogueFormatName), []byte("v1\n"))
417 }417 }
418 418
419-func decodeLegacyCatalogueV0(data []byte) ([]legacyCatalogueRecordV0, error) {
420- record, err := decodeLegacyRecordV0("legacy", data)
421- if err != nil {
422- return nil, err
423- }
424- return []legacyCatalogueRecordV0{record}, nil
425-}
426 func decodeLegacyMap(data map[string][]byte) ([]legacyCatalogueRecordV0, error) {419 func decodeLegacyMap(data map[string][]byte) ([]legacyCatalogueRecordV0, error) {
427 out := make([]legacyCatalogueRecordV0, 0, len(data))420 out := make([]legacyCatalogueRecordV0, 0, len(data))
428 for name, value := range data {421 for name, value := range data {
M internal/ports/mocks/recovery_mock.go +8 -8
@@ -462,22 +462,22 @@ func (_m *MockCheckpointCoordinator) EXPECT() *MockCheckpointCoordinator_Expecte
462462 }
463463
464464 // PromoteFallback provides a mock function for the type MockCheckpointCoordinator
465-func (_mock *MockCheckpointCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (domain.CatalogueRecord, error) {
465+func (_mock *MockCheckpointCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) {
466466 ret := _mock.Called(context1, s, checkpointRef)
467467
468468 if len(ret) == 0 {
469469 panic("no return value specified for PromoteFallback")
470470 }
471471
472- var r0 domain.CatalogueRecord
472+ var r0 ports.FallbackPromotionOutcome
473473 var r1 error
474- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (domain.CatalogueRecord, error)); ok {
474+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)); ok {
475475 return returnFunc(context1, s, checkpointRef)
476476 }
477- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) domain.CatalogueRecord); ok {
477+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) ports.FallbackPromotionOutcome); ok {
478478 r0 = returnFunc(context1, s, checkpointRef)
479479 } else {
480- r0 = ret.Get(0).(domain.CatalogueRecord)
480+ r0 = ret.Get(0).(ports.FallbackPromotionOutcome)
481481 }
482482 if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.CheckpointRef) error); ok {
483483 r1 = returnFunc(context1, s, checkpointRef)
@@ -523,12 +523,12 @@ func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Run(run func(context1
523523 return _c
524524 }
525525
526-func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Return(catalogueRecord domain.CatalogueRecord, err error) *MockCheckpointCoordinator_PromoteFallback_Call {
527- _c.Call.Return(catalogueRecord, err)
526+func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Return(fallbackPromotionOutcome ports.FallbackPromotionOutcome, err error) *MockCheckpointCoordinator_PromoteFallback_Call {
527+ _c.Call.Return(fallbackPromotionOutcome, err)
528528 return _c
529529 }
530530
531-func (_c *MockCheckpointCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (domain.CatalogueRecord, error)) *MockCheckpointCoordinator_PromoteFallback_Call {
531+func (_c *MockCheckpointCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)) *MockCheckpointCoordinator_PromoteFallback_Call {
532532 _c.Call.Return(run)
533533 return _c
534534 }
@@ -462,22 +462,22 @@ func (_m *MockCheckpointCoordinator) EXPECT() *MockCheckpointCoordinator_Expecte
462 }462 }
463 463
464 // PromoteFallback provides a mock function for the type MockCheckpointCoordinator464 // PromoteFallback provides a mock function for the type MockCheckpointCoordinator
465-func (_mock *MockCheckpointCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (domain.CatalogueRecord, error) {465+func (_mock *MockCheckpointCoordinator) PromoteFallback(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) {
466 ret := _mock.Called(context1, s, checkpointRef)466 ret := _mock.Called(context1, s, checkpointRef)
467 467
468 if len(ret) == 0 {468 if len(ret) == 0 {
469 panic("no return value specified for PromoteFallback")469 panic("no return value specified for PromoteFallback")
470 }470 }
471 471
472- var r0 domain.CatalogueRecord472+ var r0 ports.FallbackPromotionOutcome
473 var r1 error473 var r1 error
474- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (domain.CatalogueRecord, error)); ok {474+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)); ok {
475 return returnFunc(context1, s, checkpointRef)475 return returnFunc(context1, s, checkpointRef)
476 }476 }
477- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) domain.CatalogueRecord); ok {477+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.CheckpointRef) ports.FallbackPromotionOutcome); ok {
478 r0 = returnFunc(context1, s, checkpointRef)478 r0 = returnFunc(context1, s, checkpointRef)
479 } else {479 } else {
480- r0 = ret.Get(0).(domain.CatalogueRecord)480+ r0 = ret.Get(0).(ports.FallbackPromotionOutcome)
481 }481 }
482 if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.CheckpointRef) error); ok {482 if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.CheckpointRef) error); ok {
483 r1 = returnFunc(context1, s, checkpointRef)483 r1 = returnFunc(context1, s, checkpointRef)
@@ -523,12 +523,12 @@ func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Run(run func(context1
523 return _c523 return _c
524 }524 }
525 525
526-func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Return(catalogueRecord domain.CatalogueRecord, err error) *MockCheckpointCoordinator_PromoteFallback_Call {526+func (_c *MockCheckpointCoordinator_PromoteFallback_Call) Return(fallbackPromotionOutcome ports.FallbackPromotionOutcome, err error) *MockCheckpointCoordinator_PromoteFallback_Call {
527- _c.Call.Return(catalogueRecord, err)527+ _c.Call.Return(fallbackPromotionOutcome, err)
528 return _c528 return _c
529 }529 }
530 530
531-func (_c *MockCheckpointCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (domain.CatalogueRecord, error)) *MockCheckpointCoordinator_PromoteFallback_Call {531+func (_c *MockCheckpointCoordinator_PromoteFallback_Call) RunAndReturn(run func(context1 context.Context, s string, checkpointRef domain.CheckpointRef) (ports.FallbackPromotionOutcome, error)) *MockCheckpointCoordinator_PromoteFallback_Call {
532 _c.Call.Return(run)532 _c.Call.Return(run)
533 return _c533 return _c
534 }534 }
M internal/ports/recovery.go +7 -1
@@ -123,9 +123,15 @@ type RecoveryJournal interface {
123123 DeleteDiscard(context.Context, domain.IncarnationID) error
124124 }
125125
126+type FallbackPromotionOutcome struct {
127+ Record domain.CatalogueRecord
128+ CatalogueCommitted bool
129+ HEADRepairError error
130+}
131+
126132 type CheckpointCoordinator interface {
127133 PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error)
128- PromoteFallback(context.Context, string, domain.CheckpointRef) (domain.CatalogueRecord, error)
134+ PromoteFallback(context.Context, string, domain.CheckpointRef) (FallbackPromotionOutcome, error)
129135 }
130136
131137 type Catalogue interface {
@@ -123,9 +123,15 @@ type RecoveryJournal interface {
123 DeleteDiscard(context.Context, domain.IncarnationID) error123 DeleteDiscard(context.Context, domain.IncarnationID) error
124 }124 }
125 125
126+type FallbackPromotionOutcome struct {
127+ Record domain.CatalogueRecord
128+ CatalogueCommitted bool
129+ HEADRepairError error
130+}
131+
126 type CheckpointCoordinator interface {132 type CheckpointCoordinator interface {
127 PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error)133 PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error)
128- PromoteFallback(context.Context, string, domain.CheckpointRef) (domain.CatalogueRecord, error)134+ PromoteFallback(context.Context, string, domain.CheckpointRef) (FallbackPromotionOutcome, error)
129 }135 }
130 136
131 type Catalogue interface {137 type Catalogue interface {
M internal/usecase/daemon/daemon.go +25 -24
@@ -128,12 +128,8 @@ type Daemon struct {
128128 checkpointRecovery ports.CheckpointCoordinator
129129 legacySnapshots ports.LegacySnapshotSource
130130 snapsEnabled bool
131- // legacyImportPending holds verified legacy blobs whose source deletion
132- // failed, so a later import retries only deletion rather than publication.
133- legacyImportMu sync.Mutex
134- legacyImportPending map[string][]byte
135- noticeStore ports.NoticeStore
136- snapshotJobs chan *snapshotCapture
131+ noticeStore ports.NoticeStore
132+ snapshotJobs chan *snapshotCapture
137133 // snapshotWake wakes the repository scheduler when a session becomes dirty
138134 // or an attempt completes. It is never closed and producers only send
139135 // non-blockingly.
@@ -407,24 +403,23 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
407403 shell = "/bin/sh"
408404 }
409405 d := &Daemon{
410- sessions: make(map[domain.SessionID]*session),
411- stopped: make(map[string]stoppedSession),
412- parked: make(map[uint64]*parkedAttachment),
413- ptys: ptys,
414- clock: clock,
415- log: log,
416- baseEnv: os.Environ(),
417- shell: shell,
418- persist: persist.New(nil),
419- dirOrHome: dirOrHome,
420- done: make(chan struct{}),
421- restoreDone: make(chan struct{}),
422- animWake: make(chan struct{}, 1),
423- legacyImportPending: make(map[string][]byte),
424- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
425- snapshotWake: make(chan struct{}, 1),
426- notices: newNoticeCenter(),
427- resumeParkGrace: defaultResumeParkGrace,
406+ sessions: make(map[domain.SessionID]*session),
407+ stopped: make(map[string]stoppedSession),
408+ parked: make(map[uint64]*parkedAttachment),
409+ ptys: ptys,
410+ clock: clock,
411+ log: log,
412+ baseEnv: os.Environ(),
413+ shell: shell,
414+ persist: persist.New(nil),
415+ dirOrHome: dirOrHome,
416+ done: make(chan struct{}),
417+ restoreDone: make(chan struct{}),
418+ animWake: make(chan struct{}, 1),
419+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
420+ snapshotWake: make(chan struct{}, 1),
421+ notices: newNoticeCenter(),
422+ resumeParkGrace: defaultResumeParkGrace,
428423 barScripts: &barScriptState{
429424 cfg: barConfigFromDomain(domain.Defaults().Bar),
430425 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -904,6 +899,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
904899 }
905900
906901 func (d *Daemon) waitForTargetRestore(name string) error {
902+ var observedClosed chan struct{}
907903 for {
908904 d.mu.Lock()
909905 if d.findByNameLocked(name) != nil {
@@ -925,6 +921,10 @@ func (d *Daemon) waitForTargetRestore(name string) error {
925921 return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
926922 case runtimeRestoring:
927923 done := stopped.restoreDone
924+ if done == nil || done == observedClosed {
925+ d.mu.Unlock()
926+ return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
927+ }
928928 var shutdown <-chan struct{}
929929 if d.serveCtx != nil {
930930 shutdown = d.serveCtx.Done()
@@ -932,6 +932,7 @@ func (d *Daemon) waitForTargetRestore(name string) error {
932932 d.mu.Unlock()
933933 select {
934934 case <-done:
935+ observedClosed = done
935936 continue
936937 case <-shutdown:
937938 return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
@@ -128,12 +128,8 @@ type Daemon struct {
128 checkpointRecovery ports.CheckpointCoordinator128 checkpointRecovery ports.CheckpointCoordinator
129 legacySnapshots ports.LegacySnapshotSource129 legacySnapshots ports.LegacySnapshotSource
130 snapsEnabled bool130 snapsEnabled bool
131- // legacyImportPending holds verified legacy blobs whose source deletion131+ noticeStore ports.NoticeStore
132- // failed, so a later import retries only deletion rather than publication.132+ snapshotJobs chan *snapshotCapture
133- legacyImportMu sync.Mutex
134- legacyImportPending map[string][]byte
135- noticeStore ports.NoticeStore
136- snapshotJobs chan *snapshotCapture
137 // snapshotWake wakes the repository scheduler when a session becomes dirty133 // snapshotWake wakes the repository scheduler when a session becomes dirty
138 // or an attempt completes. It is never closed and producers only send134 // or an attempt completes. It is never closed and producers only send
139 // non-blockingly.135 // non-blockingly.
@@ -407,24 +403,23 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
407 shell = "/bin/sh"403 shell = "/bin/sh"
408 }404 }
409 d := &Daemon{405 d := &Daemon{
410- sessions: make(map[domain.SessionID]*session),406+ sessions: make(map[domain.SessionID]*session),
411- stopped: make(map[string]stoppedSession),407+ stopped: make(map[string]stoppedSession),
412- parked: make(map[uint64]*parkedAttachment),408+ parked: make(map[uint64]*parkedAttachment),
413- ptys: ptys,409+ ptys: ptys,
414- clock: clock,410+ clock: clock,
415- log: log,411+ log: log,
416- baseEnv: os.Environ(),412+ baseEnv: os.Environ(),
417- shell: shell,413+ shell: shell,
418- persist: persist.New(nil),414+ persist: persist.New(nil),
419- dirOrHome: dirOrHome,415+ dirOrHome: dirOrHome,
420- done: make(chan struct{}),416+ done: make(chan struct{}),
421- restoreDone: make(chan struct{}),417+ restoreDone: make(chan struct{}),
422- animWake: make(chan struct{}, 1),418+ animWake: make(chan struct{}, 1),
423- legacyImportPending: make(map[string][]byte),419+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
424- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),420+ snapshotWake: make(chan struct{}, 1),
425- snapshotWake: make(chan struct{}, 1),421+ notices: newNoticeCenter(),
426- notices: newNoticeCenter(),422+ resumeParkGrace: defaultResumeParkGrace,
427- resumeParkGrace: defaultResumeParkGrace,
428 barScripts: &barScriptState{423 barScripts: &barScriptState{
429 cfg: barConfigFromDomain(domain.Defaults().Bar),424 cfg: barConfigFromDomain(domain.Defaults().Bar),
430 outputs: make(map[domain.SessionID]barScriptOutputs),425 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -904,6 +899,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
904 }899 }
905 900
906 func (d *Daemon) waitForTargetRestore(name string) error {901 func (d *Daemon) waitForTargetRestore(name string) error {
902+ var observedClosed chan struct{}
907 for {903 for {
908 d.mu.Lock()904 d.mu.Lock()
909 if d.findByNameLocked(name) != nil {905 if d.findByNameLocked(name) != nil {
@@ -925,6 +921,10 @@ func (d *Daemon) waitForTargetRestore(name string) error {
925 return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}921 return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
926 case runtimeRestoring:922 case runtimeRestoring:
927 done := stopped.restoreDone923 done := stopped.restoreDone
924+ if done == nil || done == observedClosed {
925+ d.mu.Unlock()
926+ return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
927+ }
928 var shutdown <-chan struct{}928 var shutdown <-chan struct{}
929 if d.serveCtx != nil {929 if d.serveCtx != nil {
930 shutdown = d.serveCtx.Done()930 shutdown = d.serveCtx.Done()
@@ -932,6 +932,7 @@ func (d *Daemon) waitForTargetRestore(name string) error {
932 d.mu.Unlock()932 d.mu.Unlock()
933 select {933 select {
934 case <-done:934 case <-done:
935+ observedClosed = done
935 continue936 continue
936 case <-shutdown:937 case <-shutdown:
937 return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}938 return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
M internal/usecase/daemon/durable_recovery_test.go +106 -0
@@ -70,6 +70,8 @@ type durableRecoveryRepository struct {
7070 errors map[string]error
7171 loads map[string]int
7272 repairs map[string]domain.CheckpointRef
73+ repairErr error
74+ repairCalls int
7375 }
7476
7577 func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
@@ -89,6 +91,10 @@ func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain.
8991 func (r *durableRecoveryRepository) RepairHEAD(_ context.Context, id domain.IncarnationID, ref ports.CheckpointRef) error {
9092 r.mu.Lock()
9193 defer r.mu.Unlock()
94+ r.repairCalls++
95+ if r.repairErr != nil {
96+ return r.repairErr
97+ }
9298 r.repairs[id.String()] = ref
9399 return nil
94100 }
@@ -234,6 +240,40 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
234240 require.Equal(t, fallbackRef, fallbackRepository.repairs[fallback.IncarnationID.String()])
235241 }
236242
243+func TestCatalogueRestoreKeepsCommittedFallbackHealthyWhenHEADRepairFails(t *testing.T) {
244+ record := durableRecoveryRecord(2)
245+ fallbackRef := domain.CheckpointRef{Generation: record.Committed.Generation - 1}
246+ fallbackRecord := record
247+ fallbackRecord.Committed = &fallbackRef
248+ generation := validGeneration(t, fallbackRecord)
249+ fallbackRef.ManifestDigest = sha256.Sum256(generation.Manifest)
250+ record.Fallbacks[0] = &fallbackRef
251+ repository := &durableRecoveryRepository{
252+ generations: map[string]ports.SnapshotGeneration{record.Name: generation},
253+ errors: make(map[string]error),
254+ loads: make(map[string]int),
255+ repairs: make(map[string]domain.CheckpointRef),
256+ repairErr: errors.New("head repair unavailable"),
257+ }
258+
259+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
260+ d.restoreCatalogue(context.Background(), catalogue.Records())
261+
262+ committed, ok := catalogue.Record(record.Name)
263+ require.True(t, ok)
264+ require.Equal(t, domain.RecoveryHealthy, committed.RecoveryState)
265+ require.Equal(t, fallbackRef, *committed.Committed)
266+ require.Equal(t, runtimeHealthy, d.stopped[record.Name].state)
267+ require.Equal(t, 1, repository.repairCalls)
268+
269+ repository.repairErr = nil
270+ next, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{committed}, repository)
271+ next.restoreCatalogue(context.Background(), catalogue.Records())
272+ require.Equal(t, runtimeHealthy, next.stopped[record.Name].state)
273+ require.Equal(t, 2, repository.repairCalls, "the next startup retries the pending HEAD repair")
274+ require.Equal(t, fallbackRef, repository.repairs[record.IncarnationID.String()])
275+}
276+
237277 func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
238278 const count = 4200
239279 records := make([]domain.CatalogueRecord, count)
@@ -280,6 +320,18 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
280320 require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
281321 }
282322
323+type cancellationRecoveryRepository struct {
324+ ports.SnapshotRepository
325+ started chan struct{}
326+ once sync.Once
327+}
328+
329+func (r *cancellationRecoveryRepository) LoadCheckpoint(ctx context.Context, _ domain.IncarnationID, _ string, _ ports.CheckpointRef) (ports.SnapshotGeneration, error) {
330+ r.once.Do(func() { close(r.started) })
331+ <-ctx.Done()
332+ return ports.SnapshotGeneration{}, ctx.Err()
333+}
334+
283335 type recoveryCountingPTYFactory struct {
284336 calls atomic.Int32
285337 }
@@ -335,6 +387,60 @@ func TestAttachWaitsForRestore(t *testing.T) {
335387 require.Zero(t, factory.calls.Load())
336388 }
337389
390+func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
391+ record := durableRecoveryRecord(0)
392+ repository := &cancellationRecoveryRepository{started: make(chan struct{})}
393+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
394+ restoreCtx, cancelRestore := context.WithCancel(context.Background())
395+ restored := make(chan struct{})
396+ go func() {
397+ d.restoreCatalogue(restoreCtx, catalogue.Records())
398+ close(restored)
399+ }()
400+ <-repository.started
401+
402+ attached := make(chan error, 1)
403+ go func() {
404+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
405+ attached <- err
406+ }()
407+ cancelRestore()
408+
409+ var protocolError *protoErr
410+ require.ErrorAs(t, <-attached, &protocolError)
411+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
412+ <-restored
413+ entry := d.stopped[record.Name]
414+ require.Equal(t, runtimeDegraded, entry.state)
415+ require.Equal(t, domain.RecoveryDegraded, entry.record.RecoveryState)
416+ require.Equal(t, "restore interrupted", entry.record.DegradedReason)
417+ select {
418+ case <-entry.restoreDone:
419+ default:
420+ t.Fatal("restore completion was not signaled")
421+ }
422+ committed, _ := catalogue.Record(record.Name)
423+ require.Equal(t, entry.record, committed)
424+}
425+
426+func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
427+ record := durableRecoveryRecord(0)
428+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
429+ closeRuntimeRestoreDone(d.stopped[record.Name].restoreDone)
430+
431+ result := make(chan error, 1)
432+ go func() { result <- d.waitForTargetRestore(record.Name) }()
433+
434+ select {
435+ case err := <-result:
436+ var protocolError *protoErr
437+ require.ErrorAs(t, err, &protocolError)
438+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
439+ case <-time.After(time.Second):
440+ t.Fatal("waiter spun on a closed restore channel")
441+ }
442+}
443+
338444 func TestAttachRejectsDegraded(t *testing.T) {
339445 record := durableRecoveryRecord(0)
340446 record.RecoveryState = domain.RecoveryDegraded
@@ -70,6 +70,8 @@ type durableRecoveryRepository struct {
70 errors map[string]error70 errors map[string]error
71 loads map[string]int71 loads map[string]int
72 repairs map[string]domain.CheckpointRef72 repairs map[string]domain.CheckpointRef
73+ repairErr error
74+ repairCalls int
73 }75 }
74 76
75 func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {77 func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
@@ -89,6 +91,10 @@ func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain.
89 func (r *durableRecoveryRepository) RepairHEAD(_ context.Context, id domain.IncarnationID, ref ports.CheckpointRef) error {91 func (r *durableRecoveryRepository) RepairHEAD(_ context.Context, id domain.IncarnationID, ref ports.CheckpointRef) error {
90 r.mu.Lock()92 r.mu.Lock()
91 defer r.mu.Unlock()93 defer r.mu.Unlock()
94+ r.repairCalls++
95+ if r.repairErr != nil {
96+ return r.repairErr
97+ }
92 r.repairs[id.String()] = ref98 r.repairs[id.String()] = ref
93 return nil99 return nil
94 }100 }
@@ -234,6 +240,40 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
234 require.Equal(t, fallbackRef, fallbackRepository.repairs[fallback.IncarnationID.String()])240 require.Equal(t, fallbackRef, fallbackRepository.repairs[fallback.IncarnationID.String()])
235 }241 }
236 242
243+func TestCatalogueRestoreKeepsCommittedFallbackHealthyWhenHEADRepairFails(t *testing.T) {
244+ record := durableRecoveryRecord(2)
245+ fallbackRef := domain.CheckpointRef{Generation: record.Committed.Generation - 1}
246+ fallbackRecord := record
247+ fallbackRecord.Committed = &fallbackRef
248+ generation := validGeneration(t, fallbackRecord)
249+ fallbackRef.ManifestDigest = sha256.Sum256(generation.Manifest)
250+ record.Fallbacks[0] = &fallbackRef
251+ repository := &durableRecoveryRepository{
252+ generations: map[string]ports.SnapshotGeneration{record.Name: generation},
253+ errors: make(map[string]error),
254+ loads: make(map[string]int),
255+ repairs: make(map[string]domain.CheckpointRef),
256+ repairErr: errors.New("head repair unavailable"),
257+ }
258+
259+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
260+ d.restoreCatalogue(context.Background(), catalogue.Records())
261+
262+ committed, ok := catalogue.Record(record.Name)
263+ require.True(t, ok)
264+ require.Equal(t, domain.RecoveryHealthy, committed.RecoveryState)
265+ require.Equal(t, fallbackRef, *committed.Committed)
266+ require.Equal(t, runtimeHealthy, d.stopped[record.Name].state)
267+ require.Equal(t, 1, repository.repairCalls)
268+
269+ repository.repairErr = nil
270+ next, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{committed}, repository)
271+ next.restoreCatalogue(context.Background(), catalogue.Records())
272+ require.Equal(t, runtimeHealthy, next.stopped[record.Name].state)
273+ require.Equal(t, 2, repository.repairCalls, "the next startup retries the pending HEAD repair")
274+ require.Equal(t, fallbackRef, repository.repairs[record.IncarnationID.String()])
275+}
276+
237 func TestCatalogueRestoreAggregateOver4096(t *testing.T) {277 func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
238 const count = 4200278 const count = 4200
239 records := make([]domain.CatalogueRecord, count)279 records := make([]domain.CatalogueRecord, count)
@@ -280,6 +320,18 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
280 require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)320 require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
281 }321 }
282 322
323+type cancellationRecoveryRepository struct {
324+ ports.SnapshotRepository
325+ started chan struct{}
326+ once sync.Once
327+}
328+
329+func (r *cancellationRecoveryRepository) LoadCheckpoint(ctx context.Context, _ domain.IncarnationID, _ string, _ ports.CheckpointRef) (ports.SnapshotGeneration, error) {
330+ r.once.Do(func() { close(r.started) })
331+ <-ctx.Done()
332+ return ports.SnapshotGeneration{}, ctx.Err()
333+}
334+
283 type recoveryCountingPTYFactory struct {335 type recoveryCountingPTYFactory struct {
284 calls atomic.Int32336 calls atomic.Int32
285 }337 }
@@ -335,6 +387,60 @@ func TestAttachWaitsForRestore(t *testing.T) {
335 require.Zero(t, factory.calls.Load())387 require.Zero(t, factory.calls.Load())
336 }388 }
337 389
390+func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
391+ record := durableRecoveryRecord(0)
392+ repository := &cancellationRecoveryRepository{started: make(chan struct{})}
393+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
394+ restoreCtx, cancelRestore := context.WithCancel(context.Background())
395+ restored := make(chan struct{})
396+ go func() {
397+ d.restoreCatalogue(restoreCtx, catalogue.Records())
398+ close(restored)
399+ }()
400+ <-repository.started
401+
402+ attached := make(chan error, 1)
403+ go func() {
404+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
405+ attached <- err
406+ }()
407+ cancelRestore()
408+
409+ var protocolError *protoErr
410+ require.ErrorAs(t, <-attached, &protocolError)
411+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
412+ <-restored
413+ entry := d.stopped[record.Name]
414+ require.Equal(t, runtimeDegraded, entry.state)
415+ require.Equal(t, domain.RecoveryDegraded, entry.record.RecoveryState)
416+ require.Equal(t, "restore interrupted", entry.record.DegradedReason)
417+ select {
418+ case <-entry.restoreDone:
419+ default:
420+ t.Fatal("restore completion was not signaled")
421+ }
422+ committed, _ := catalogue.Record(record.Name)
423+ require.Equal(t, entry.record, committed)
424+}
425+
426+func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
427+ record := durableRecoveryRecord(0)
428+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
429+ closeRuntimeRestoreDone(d.stopped[record.Name].restoreDone)
430+
431+ result := make(chan error, 1)
432+ go func() { result <- d.waitForTargetRestore(record.Name) }()
433+
434+ select {
435+ case err := <-result:
436+ var protocolError *protoErr
437+ require.ErrorAs(t, err, &protocolError)
438+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
439+ case <-time.After(time.Second):
440+ t.Fatal("waiter spun on a closed restore channel")
441+ }
442+}
443+
338 func TestAttachRejectsDegraded(t *testing.T) {444 func TestAttachRejectsDegraded(t *testing.T) {
339 record := durableRecoveryRecord(0)445 record := durableRecoveryRecord(0)
340 record.RecoveryState = domain.RecoveryDegraded446 record.RecoveryState = domain.RecoveryDegraded
M internal/usecase/daemon/picker_test.go +40 -0
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/vev/internal/usecase/keys"
1818 "github.com/bnema/vev/internal/usecase/layout"
1919 "github.com/bnema/vev/internal/usecase/picker"
20+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
2021 themeui "github.com/bnema/vev/internal/usecase/theme"
2122 "github.com/bnema/vev/pkg/renderer"
2223 "github.com/bnema/vev/pkg/vt"
@@ -287,6 +288,45 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
287288 require.Same(t, target, ac.currentSession())
288289 }
289290
291+func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
292+ p, release := newBlockingPTY(t)
293+ defer release()
294+ d, from, ac, _ := newManualSessionWithPTYs(t, p)
295+ record := durableRecoveryRecord(0)
296+ record.Name = "restoring"
297+ repository := &cancellationRecoveryRepository{started: make(chan struct{})}
298+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
299+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)
300+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
301+ WithSnapshotRepository(repository, nil)(d)
302+ WithCheckpointCoordinator(coordinator)(d)
303+ restoreCtx, cancelRestore := context.WithCancel(context.Background())
304+ restored := make(chan struct{})
305+ go func() {
306+ d.restoreCatalogue(restoreCtx, catalogue.Records())
307+ close(restored)
308+ }()
309+ <-repository.started
310+
311+ result := make(chan error, 1)
312+ go func() {
313+ result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
314+ }()
315+ cancelRestore()
316+
317+ require.Error(t, <-result)
318+ <-restored
319+ require.Same(t, from, ac.currentSession())
320+ entry := d.stopped[record.Name]
321+ require.Equal(t, runtimeDegraded, entry.state)
322+ require.Equal(t, "restore interrupted", entry.record.DegradedReason)
323+ select {
324+ case <-entry.restoreDone:
325+ default:
326+ t.Fatal("restore completion was not signaled")
327+ }
328+}
329+
290330 func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
291331 tests := []struct {
292332 name string
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/vev/internal/usecase/keys"17 "github.com/bnema/vev/internal/usecase/keys"
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19 "github.com/bnema/vev/internal/usecase/picker"19 "github.com/bnema/vev/internal/usecase/picker"
20+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
20 themeui "github.com/bnema/vev/internal/usecase/theme"21 themeui "github.com/bnema/vev/internal/usecase/theme"
21 "github.com/bnema/vev/pkg/renderer"22 "github.com/bnema/vev/pkg/renderer"
22 "github.com/bnema/vev/pkg/vt"23 "github.com/bnema/vev/pkg/vt"
@@ -287,6 +288,45 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
287 require.Same(t, target, ac.currentSession())288 require.Same(t, target, ac.currentSession())
288 }289 }
289 290
291+func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
292+ p, release := newBlockingPTY(t)
293+ defer release()
294+ d, from, ac, _ := newManualSessionWithPTYs(t, p)
295+ record := durableRecoveryRecord(0)
296+ record.Name = "restoring"
297+ repository := &cancellationRecoveryRepository{started: make(chan struct{})}
298+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
299+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)
300+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
301+ WithSnapshotRepository(repository, nil)(d)
302+ WithCheckpointCoordinator(coordinator)(d)
303+ restoreCtx, cancelRestore := context.WithCancel(context.Background())
304+ restored := make(chan struct{})
305+ go func() {
306+ d.restoreCatalogue(restoreCtx, catalogue.Records())
307+ close(restored)
308+ }()
309+ <-repository.started
310+
311+ result := make(chan error, 1)
312+ go func() {
313+ result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
314+ }()
315+ cancelRestore()
316+
317+ require.Error(t, <-result)
318+ <-restored
319+ require.Same(t, from, ac.currentSession())
320+ entry := d.stopped[record.Name]
321+ require.Equal(t, runtimeDegraded, entry.state)
322+ require.Equal(t, "restore interrupted", entry.record.DegradedReason)
323+ select {
324+ case <-entry.restoreDone:
325+ default:
326+ t.Fatal("restore completion was not signaled")
327+ }
328+}
329+
290 func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {330 func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
291 tests := []struct {331 tests := []struct {
292 name string332 name string
M internal/usecase/daemon/snapshot_restore_incremental.go +48 -17
@@ -58,10 +58,7 @@ func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.Catalogu
5858 d.ensureCatalogueRegistryEntry(record)
5959 done := d.recordRestoreDone(record.Name)
6060 err := d.restoreRecord(ctx, record)
61- if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
62- d.markRecordDegraded(record, err)
63- }
64- closeRuntimeRestoreDone(done)
61+ d.finishRecordRestore(record, err, done)
6562 }
6663 })
6764 }
@@ -126,21 +123,44 @@ func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state runtime
126123 d.stopped[record.Name] = entry
127124 }
128125
129-func (d *Daemon) markRecordDegraded(record domain.CatalogueRecord, restoreErr error) {
130- if d.catalogue != nil {
131- if current, ok := d.catalogue.Record(record.Name); ok && current.IncarnationID == record.IncarnationID {
132- record = current
126+func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) {
127+ if restoreErr != nil {
128+ if d.catalogue != nil {
129+ if current, ok := d.catalogue.Record(record.Name); ok && current.IncarnationID == record.IncarnationID {
130+ record = current
131+ }
132+ }
133+ record.RecoveryState = domain.RecoveryDegraded
134+ record.DegradedReason = "checkpoint validation failed"
135+ if errors.Is(restoreErr, context.Canceled) || errors.Is(restoreErr, context.DeadlineExceeded) {
136+ record.DegradedReason = "restore interrupted"
137+ }
138+ if d.catalogue != nil {
139+ if err := d.catalogue.Replace(record.Name, record); err != nil {
140+ d.log.Error("marking session degraded failed", "session", record.Name, "err", err)
141+ }
133142 }
134143 }
135- record.RecoveryState = domain.RecoveryDegraded
136- record.DegradedReason = "checkpoint validation failed"
137- if d.catalogue != nil {
138- if err := d.catalogue.Replace(record.Name, record); err != nil {
139- d.log.Error("marking session degraded failed", "session", record.Name, "err", err)
144+
145+ d.mu.Lock()
146+ entry, ok := d.stopped[record.Name]
147+ if ok {
148+ if restoreErr != nil {
149+ entry.record = record
150+ entry.state = runtimeDegraded
151+ entry.cwd = record.Cwd
152+ entry.createdAt = record.CreatedAt
153+ entry.incarnation = record.IncarnationID
154+ entry.lastUsedSeq = record.LastUsedSeq
155+ entry.tabNames = append([]string(nil), record.TabNames...)
156+ d.stopped[record.Name] = entry
140157 }
158+ closeRuntimeRestoreDone(done)
159+ }
160+ d.mu.Unlock()
161+ if restoreErr != nil {
162+ d.log.Warn("session checkpoint restore failed", "session", record.Name, "err", restoreErr)
141163 }
142- d.setStoppedRecovery(record, runtimeDegraded)
143- d.log.Warn("session checkpoint restore failed", "session", record.Name, "err", restoreErr)
144164 }
145165
146166 func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error {
@@ -174,6 +194,9 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
174194 for i, candidate := range candidates {
175195 generation, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, candidate)
176196 if err != nil {
197+ if ctxErr := ctx.Err(); ctxErr != nil {
198+ return ctxErr
199+ }
177200 continue
178201 }
179202 snapshot, err := sessionFromGeneration(generation)
@@ -191,12 +214,20 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
191214 if d.checkpointRecovery == nil {
192215 return errors.New("snapshot: checkpoint coordinator unavailable for fallback promotion")
193216 }
194- var err error
195- record, err = d.checkpointRecovery.PromoteFallback(ctx, record.Name, selected)
217+ outcome, err := d.checkpointRecovery.PromoteFallback(ctx, record.Name, selected)
196218 if err != nil {
197219 return fmt.Errorf("snapshot: promote fallback: %w", err)
198220 }
221+ if !outcome.CatalogueCommitted {
222+ return errors.New("snapshot: fallback promotion did not commit catalogue")
223+ }
224+ record = outcome.Record
225+ if outcome.HEADRepairError != nil {
226+ d.log.Warn("fallback committed with HEAD repair pending", "session", record.Name, "err", outcome.HEADRepairError)
227+ }
199228 d.log.Warn("restored session from fallback checkpoint", "session", record.Name, "generation", selected.Generation)
229+ } else if err := d.snapshotRepository.RepairHEAD(ctx, record.IncarnationID, selected); err != nil {
230+ d.log.Warn("checkpoint HEAD repair pending", "session", record.Name, "err", err)
200231 }
201232
202233 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation); err != nil {
@@ -58,10 +58,7 @@ func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.Catalogu
58 d.ensureCatalogueRegistryEntry(record)58 d.ensureCatalogueRegistryEntry(record)
59 done := d.recordRestoreDone(record.Name)59 done := d.recordRestoreDone(record.Name)
60 err := d.restoreRecord(ctx, record)60 err := d.restoreRecord(ctx, record)
61- if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {61+ d.finishRecordRestore(record, err, done)
62- d.markRecordDegraded(record, err)
63- }
64- closeRuntimeRestoreDone(done)
65 }62 }
66 })63 })
67 }64 }
@@ -126,21 +123,44 @@ func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state runtime
126 d.stopped[record.Name] = entry123 d.stopped[record.Name] = entry
127 }124 }
128 125
129-func (d *Daemon) markRecordDegraded(record domain.CatalogueRecord, restoreErr error) {126+func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) {
130- if d.catalogue != nil {127+ if restoreErr != nil {
131- if current, ok := d.catalogue.Record(record.Name); ok && current.IncarnationID == record.IncarnationID {128+ if d.catalogue != nil {
132- record = current129+ if current, ok := d.catalogue.Record(record.Name); ok && current.IncarnationID == record.IncarnationID {
130+ record = current
131+ }
132+ }
133+ record.RecoveryState = domain.RecoveryDegraded
134+ record.DegradedReason = "checkpoint validation failed"
135+ if errors.Is(restoreErr, context.Canceled) || errors.Is(restoreErr, context.DeadlineExceeded) {
136+ record.DegradedReason = "restore interrupted"
137+ }
138+ if d.catalogue != nil {
139+ if err := d.catalogue.Replace(record.Name, record); err != nil {
140+ d.log.Error("marking session degraded failed", "session", record.Name, "err", err)
141+ }
133 }142 }
134 }143 }
135- record.RecoveryState = domain.RecoveryDegraded144+
136- record.DegradedReason = "checkpoint validation failed"145+ d.mu.Lock()
137- if d.catalogue != nil {146+ entry, ok := d.stopped[record.Name]
138- if err := d.catalogue.Replace(record.Name, record); err != nil {147+ if ok {
139- d.log.Error("marking session degraded failed", "session", record.Name, "err", err)148+ if restoreErr != nil {
149+ entry.record = record
150+ entry.state = runtimeDegraded
151+ entry.cwd = record.Cwd
152+ entry.createdAt = record.CreatedAt
153+ entry.incarnation = record.IncarnationID
154+ entry.lastUsedSeq = record.LastUsedSeq
155+ entry.tabNames = append([]string(nil), record.TabNames...)
156+ d.stopped[record.Name] = entry
140 }157 }
158+ closeRuntimeRestoreDone(done)
159+ }
160+ d.mu.Unlock()
161+ if restoreErr != nil {
162+ d.log.Warn("session checkpoint restore failed", "session", record.Name, "err", restoreErr)
141 }163 }
142- d.setStoppedRecovery(record, runtimeDegraded)
143- d.log.Warn("session checkpoint restore failed", "session", record.Name, "err", restoreErr)
144 }164 }
145 165
146 func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error {166 func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error {
@@ -174,6 +194,9 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
174 for i, candidate := range candidates {194 for i, candidate := range candidates {
175 generation, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, candidate)195 generation, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, candidate)
176 if err != nil {196 if err != nil {
197+ if ctxErr := ctx.Err(); ctxErr != nil {
198+ return ctxErr
199+ }
177 continue200 continue
178 }201 }
179 snapshot, err := sessionFromGeneration(generation)202 snapshot, err := sessionFromGeneration(generation)
@@ -191,12 +214,20 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
191 if d.checkpointRecovery == nil {214 if d.checkpointRecovery == nil {
192 return errors.New("snapshot: checkpoint coordinator unavailable for fallback promotion")215 return errors.New("snapshot: checkpoint coordinator unavailable for fallback promotion")
193 }216 }
194- var err error217+ outcome, err := d.checkpointRecovery.PromoteFallback(ctx, record.Name, selected)
195- record, err = d.checkpointRecovery.PromoteFallback(ctx, record.Name, selected)
196 if err != nil {218 if err != nil {
197 return fmt.Errorf("snapshot: promote fallback: %w", err)219 return fmt.Errorf("snapshot: promote fallback: %w", err)
198 }220 }
221+ if !outcome.CatalogueCommitted {
222+ return errors.New("snapshot: fallback promotion did not commit catalogue")
223+ }
224+ record = outcome.Record
225+ if outcome.HEADRepairError != nil {
226+ d.log.Warn("fallback committed with HEAD repair pending", "session", record.Name, "err", outcome.HEADRepairError)
227+ }
199 d.log.Warn("restored session from fallback checkpoint", "session", record.Name, "generation", selected.Generation)228 d.log.Warn("restored session from fallback checkpoint", "session", record.Name, "generation", selected.Generation)
229+ } else if err := d.snapshotRepository.RepairHEAD(ctx, record.IncarnationID, selected); err != nil {
230+ d.log.Warn("checkpoint HEAD repair pending", "session", record.Name, "err", err)
200 }231 }
201 232
202 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation); err != nil {233 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation); err != nil {
M internal/usecase/recovery/checkpoint.go +10 -11
@@ -64,16 +64,16 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public
6464 // PromoteFallback validates a directly indexed fallback and any alternatives,
6565 // commits the bounded validated set, and repairs HEAD only after the catalogue
6666 // commit is durable.
67-func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref domain.CheckpointRef) (domain.CatalogueRecord, error) {
67+func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) {
6868 if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
69- return domain.CatalogueRecord{}, errors.New("recovery: incomplete checkpoint coordinator dependencies")
69+ return ports.FallbackPromotionOutcome{}, errors.New("recovery: incomplete checkpoint coordinator dependencies")
7070 }
7171 unlock := c.locks.Lock([]string{name})
7272 defer unlock()
7373
7474 record, ok := c.catalogue.Record(name)
7575 if !ok {
76- return domain.CatalogueRecord{}, ErrCheckpointRecordNotFound
76+ return ports.FallbackPromotionOutcome{}, ErrCheckpointRecordNotFound
7777 }
7878 found := false
7979 for _, fallback := range record.Fallbacks {
@@ -83,10 +83,10 @@ func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref doma
8383 }
8484 }
8585 if !found {
86- return domain.CatalogueRecord{}, ErrFallbackNotFound
86+ return ports.FallbackPromotionOutcome{}, ErrFallbackNotFound
8787 }
8888 if err := c.validateCheckpoint(ctx, record, ref); err != nil {
89- return domain.CatalogueRecord{}, err
89+ return ports.FallbackPromotionOutcome{}, err
9090 }
9191
9292 alternatives := make([]domain.CheckpointRef, 0, 2)
@@ -111,15 +111,14 @@ func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref doma
111111 next.Fallbacks[i] = copyCheckpointRef(&alternatives[i])
112112 }
113113 if err := next.Validate(); err != nil {
114- return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid fallback promotion: %w", err)
114+ return ports.FallbackPromotionOutcome{}, fmt.Errorf("recovery: invalid fallback promotion: %w", err)
115115 }
116116 if err := c.catalogue.Replace(name, next); err != nil {
117- return domain.CatalogueRecord{}, err
118- }
119- if err := c.repository.RepairHEAD(ctx, record.IncarnationID, ref); err != nil {
120- return next, err
117+ return ports.FallbackPromotionOutcome{}, err
121118 }
122- return next, nil
119+ outcome := ports.FallbackPromotionOutcome{Record: next, CatalogueCommitted: true}
120+ outcome.HEADRepairError = c.repository.RepairHEAD(ctx, record.IncarnationID, ref)
121+ return outcome, nil
123122 }
124123
125124 func (c *Coordinator) validateCheckpoint(ctx context.Context, record domain.CatalogueRecord, ref domain.CheckpointRef) error {
@@ -64,16 +64,16 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public
64 // PromoteFallback validates a directly indexed fallback and any alternatives,64 // PromoteFallback validates a directly indexed fallback and any alternatives,
65 // commits the bounded validated set, and repairs HEAD only after the catalogue65 // commits the bounded validated set, and repairs HEAD only after the catalogue
66 // commit is durable.66 // commit is durable.
67-func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref domain.CheckpointRef) (domain.CatalogueRecord, error) {67+func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) {
68 if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {68 if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
69- return domain.CatalogueRecord{}, errors.New("recovery: incomplete checkpoint coordinator dependencies")69+ return ports.FallbackPromotionOutcome{}, errors.New("recovery: incomplete checkpoint coordinator dependencies")
70 }70 }
71 unlock := c.locks.Lock([]string{name})71 unlock := c.locks.Lock([]string{name})
72 defer unlock()72 defer unlock()
73 73
74 record, ok := c.catalogue.Record(name)74 record, ok := c.catalogue.Record(name)
75 if !ok {75 if !ok {
76- return domain.CatalogueRecord{}, ErrCheckpointRecordNotFound76+ return ports.FallbackPromotionOutcome{}, ErrCheckpointRecordNotFound
77 }77 }
78 found := false78 found := false
79 for _, fallback := range record.Fallbacks {79 for _, fallback := range record.Fallbacks {
@@ -83,10 +83,10 @@ func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref doma
83 }83 }
84 }84 }
85 if !found {85 if !found {
86- return domain.CatalogueRecord{}, ErrFallbackNotFound86+ return ports.FallbackPromotionOutcome{}, ErrFallbackNotFound
87 }87 }
88 if err := c.validateCheckpoint(ctx, record, ref); err != nil {88 if err := c.validateCheckpoint(ctx, record, ref); err != nil {
89- return domain.CatalogueRecord{}, err89+ return ports.FallbackPromotionOutcome{}, err
90 }90 }
91 91
92 alternatives := make([]domain.CheckpointRef, 0, 2)92 alternatives := make([]domain.CheckpointRef, 0, 2)
@@ -111,15 +111,14 @@ func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref doma
111 next.Fallbacks[i] = copyCheckpointRef(&alternatives[i])111 next.Fallbacks[i] = copyCheckpointRef(&alternatives[i])
112 }112 }
113 if err := next.Validate(); err != nil {113 if err := next.Validate(); err != nil {
114- return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid fallback promotion: %w", err)114+ return ports.FallbackPromotionOutcome{}, fmt.Errorf("recovery: invalid fallback promotion: %w", err)
115 }115 }
116 if err := c.catalogue.Replace(name, next); err != nil {116 if err := c.catalogue.Replace(name, next); err != nil {
117- return domain.CatalogueRecord{}, err117+ return ports.FallbackPromotionOutcome{}, err
118- }
119- if err := c.repository.RepairHEAD(ctx, record.IncarnationID, ref); err != nil {
120- return next, err
121 }118 }
122- return next, nil119+ outcome := ports.FallbackPromotionOutcome{Record: next, CatalogueCommitted: true}
120+ outcome.HEADRepairError = c.repository.RepairHEAD(ctx, record.IncarnationID, ref)
121+ return outcome, nil
123 }122 }
124 123
125 func (c *Coordinator) validateCheckpoint(ctx context.Context, record domain.CatalogueRecord, ref domain.CheckpointRef) error {124 func (c *Coordinator) validateCheckpoint(ctx context.Context, record domain.CatalogueRecord, ref domain.CheckpointRef) error {
M internal/usecase/recovery/checkpoint_test.go +29 -3
@@ -56,6 +56,7 @@ type checkpointRepository struct {
5656 loadErrors map[uint64]error
5757 loads []uint64
5858 repairs []domain.CheckpointRef
59+ repairErr error
5960 events *[]string
6061 }
6162
@@ -81,7 +82,7 @@ func (r *checkpointRepository) LoadCheckpoint(_ context.Context, _ domain.Incarn
8182
8283 func (r *checkpointRepository) RepairHEAD(_ context.Context, _ domain.IncarnationID, ref ports.CheckpointRef) error {
8384 r.repairs = append(r.repairs, ref)
84- return nil
85+ return r.repairErr
8586 }
8687
8788 func checkpointRecord() domain.CatalogueRecord {
@@ -173,13 +174,38 @@ func TestCheckpointCommitThirdPromotesOnlyValidatedDirectFallbacks(t *testing.T)
173174
174175 promoted, err := coordinator.PromoteFallback(context.Background(), record.Name, refs[1])
175176 require.NoError(t, err)
176- require.Equal(t, refs[1], *promoted.Committed)
177- require.Equal(t, []uint64{1}, populatedGenerations(promoted.Fallbacks))
177+ require.Equal(t, refs[1], *promoted.Record.Committed)
178+ require.Equal(t, []uint64{1}, populatedGenerations(promoted.Record.Fallbacks))
178179 require.Equal(t, []uint64{2, 1}, repository.loads, "only the selected and older catalogue-indexed fallbacks are read directly")
179180 require.Equal(t, []domain.CheckpointRef{refs[1]}, repository.repairs)
180181 require.Equal(t, 1, catalogue.replaces)
181182 }
182183
184+func TestPromoteFallbackReportsPostCommitHEADRepairFailure(t *testing.T) {
185+ selected := domain.CheckpointRef{Generation: 2, ManifestDigest: sha256.Sum256([]byte("second"))}
186+ record := checkpointRecord()
187+ record.RecoveryState = domain.RecoveryHealthy
188+ record.Committed = &domain.CheckpointRef{Generation: 3, ManifestDigest: sha256.Sum256([]byte("third"))}
189+ record.Fallbacks[0] = &selected
190+ catalogue := &checkpointCatalogue{record: record}
191+ repairErr := errors.New("head repair failed")
192+ repository := &checkpointRepository{
193+ generations: map[uint64]ports.SnapshotGeneration{
194+ 2: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 2, Manifest: []byte("second")},
195+ },
196+ repairErr: repairErr,
197+ }
198+ coordinator := NewCoordinator(catalogue, repository, nil, nil)
199+
200+ outcome, err := coordinator.PromoteFallback(context.Background(), record.Name, selected)
201+ require.NoError(t, err)
202+ require.True(t, outcome.CatalogueCommitted)
203+ require.ErrorIs(t, outcome.HEADRepairError, repairErr)
204+ require.Equal(t, selected, *outcome.Record.Committed)
205+ require.Equal(t, domain.RecoveryHealthy, catalogue.record.RecoveryState)
206+ require.Equal(t, selected, *catalogue.record.Committed)
207+}
208+
183209 func TestCheckpointCommitPublishOrphan(t *testing.T) {
184210 prior := checkpointRecord()
185211 priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
@@ -56,6 +56,7 @@ type checkpointRepository struct {
56 loadErrors map[uint64]error56 loadErrors map[uint64]error
57 loads []uint6457 loads []uint64
58 repairs []domain.CheckpointRef58 repairs []domain.CheckpointRef
59+ repairErr error
59 events *[]string60 events *[]string
60 }61 }
61 62
@@ -81,7 +82,7 @@ func (r *checkpointRepository) LoadCheckpoint(_ context.Context, _ domain.Incarn
81 82
82 func (r *checkpointRepository) RepairHEAD(_ context.Context, _ domain.IncarnationID, ref ports.CheckpointRef) error {83 func (r *checkpointRepository) RepairHEAD(_ context.Context, _ domain.IncarnationID, ref ports.CheckpointRef) error {
83 r.repairs = append(r.repairs, ref)84 r.repairs = append(r.repairs, ref)
84- return nil85+ return r.repairErr
85 }86 }
86 87
87 func checkpointRecord() domain.CatalogueRecord {88 func checkpointRecord() domain.CatalogueRecord {
@@ -173,13 +174,38 @@ func TestCheckpointCommitThirdPromotesOnlyValidatedDirectFallbacks(t *testing.T)
173 174
174 promoted, err := coordinator.PromoteFallback(context.Background(), record.Name, refs[1])175 promoted, err := coordinator.PromoteFallback(context.Background(), record.Name, refs[1])
175 require.NoError(t, err)176 require.NoError(t, err)
176- require.Equal(t, refs[1], *promoted.Committed)177+ require.Equal(t, refs[1], *promoted.Record.Committed)
177- require.Equal(t, []uint64{1}, populatedGenerations(promoted.Fallbacks))178+ require.Equal(t, []uint64{1}, populatedGenerations(promoted.Record.Fallbacks))
178 require.Equal(t, []uint64{2, 1}, repository.loads, "only the selected and older catalogue-indexed fallbacks are read directly")179 require.Equal(t, []uint64{2, 1}, repository.loads, "only the selected and older catalogue-indexed fallbacks are read directly")
179 require.Equal(t, []domain.CheckpointRef{refs[1]}, repository.repairs)180 require.Equal(t, []domain.CheckpointRef{refs[1]}, repository.repairs)
180 require.Equal(t, 1, catalogue.replaces)181 require.Equal(t, 1, catalogue.replaces)
181 }182 }
182 183
184+func TestPromoteFallbackReportsPostCommitHEADRepairFailure(t *testing.T) {
185+ selected := domain.CheckpointRef{Generation: 2, ManifestDigest: sha256.Sum256([]byte("second"))}
186+ record := checkpointRecord()
187+ record.RecoveryState = domain.RecoveryHealthy
188+ record.Committed = &domain.CheckpointRef{Generation: 3, ManifestDigest: sha256.Sum256([]byte("third"))}
189+ record.Fallbacks[0] = &selected
190+ catalogue := &checkpointCatalogue{record: record}
191+ repairErr := errors.New("head repair failed")
192+ repository := &checkpointRepository{
193+ generations: map[uint64]ports.SnapshotGeneration{
194+ 2: {IncarnationID: record.IncarnationID, Name: record.Name, Generation: 2, Manifest: []byte("second")},
195+ },
196+ repairErr: repairErr,
197+ }
198+ coordinator := NewCoordinator(catalogue, repository, nil, nil)
199+
200+ outcome, err := coordinator.PromoteFallback(context.Background(), record.Name, selected)
201+ require.NoError(t, err)
202+ require.True(t, outcome.CatalogueCommitted)
203+ require.ErrorIs(t, outcome.HEADRepairError, repairErr)
204+ require.Equal(t, selected, *outcome.Record.Committed)
205+ require.Equal(t, domain.RecoveryHealthy, catalogue.record.RecoveryState)
206+ require.Equal(t, selected, *catalogue.record.Committed)
207+}
208+
183 func TestCheckpointCommitPublishOrphan(t *testing.T) {209 func TestCheckpointCommitPublishOrphan(t *testing.T) {
184 prior := checkpointRecord()210 prior := checkpointRecord()
185 priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}211 priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}

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