feat(recovery): reset incompatible session snapshots
- commit
- 65de4733494831174b1e62365a470a03764df26b
- author
- brice · · 2026-07-27 13:19 UTC
- parent
- e362c80
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +265 -1
M internal/usecase/daemon/coderabbit_regression_test.go +70 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "log/slog" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "syscall" | |
| 9 | 10 | "testing" | |
| @@ -13,6 +14,7 @@ import ( | |||
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 17 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 18 | 20 | func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | |
| @@ -96,6 +98,74 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) { | |||
| 96 | 98 | require.Equal(t, ports.SessionStopped, entry.state) | |
| 97 | 99 | } | |
| 98 | 100 | ||
| 101 | + | func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) { | |
| 102 | + | tests := []struct { | |
| 103 | + | name string | |
| 104 | + | replacement func(domain.CatalogueRecord) domain.CatalogueRecord | |
| 105 | + | state ports.SessionState | |
| 106 | + | }{ | |
| 107 | + | { | |
| 108 | + | name: "newer checkpoint", | |
| 109 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |
| 110 | + | ref := *record.Committed | |
| 111 | + | ref.Generation++ | |
| 112 | + | ref.ManifestDigest[0]++ | |
| 113 | + | record.Committed = &ref | |
| 114 | + | return record | |
| 115 | + | }, | |
| 116 | + | state: ports.SessionStopped, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "newer incarnation", | |
| 120 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |
| 121 | + | record.IncarnationID = domain.IncarnationID{9} | |
| 122 | + | return record | |
| 123 | + | }, | |
| 124 | + | state: ports.SessionStopped, | |
| 125 | + | }, | |
| 126 | + | { | |
| 127 | + | name: "degraded authority", | |
| 128 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |
| 129 | + | record.DegradedReason = "newer restore decision" | |
| 130 | + | return record | |
| 131 | + | }, | |
| 132 | + | state: ports.SessionBroken, | |
| 133 | + | }, | |
| 134 | + | } | |
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | record := durableRecoveryRecord(0) | |
| 138 | + | replacement := tt.replacement(record) | |
| 139 | + | repository := &durableRecoveryRepository{ | |
| 140 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |
| 141 | + | loads: make(map[string]int), | |
| 142 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 143 | + | } | |
| 144 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 145 | + | var logs bytes.Buffer | |
| 146 | + | d.log = slog.New(slog.NewJSONHandler(&logs, nil)) | |
| 147 | + | repository.onLoad = func() { | |
| 148 | + | require.NoError(t, catalogue.Replace(record.Name, replacement)) | |
| 149 | + | d.setStoppedRecovery(replacement, tt.state) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 153 | + | ||
| 154 | + | persisted, ok, err := catalogue.Record(record.Name) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | require.True(t, ok) | |
| 157 | + | require.Equal(t, replacement, persisted) | |
| 158 | + | d.mu.Lock() | |
| 159 | + | entry := d.stopped[record.Name] | |
| 160 | + | d.mu.Unlock() | |
| 161 | + | require.Equal(t, tt.state, entry.state) | |
| 162 | + | require.Equal(t, replacement, entry.record) | |
| 163 | + | require.Empty(t, repository.deleted) | |
| 164 | + | daemonRequireEvent(t, daemonJSONLogs(t, logs.Bytes()), "snapshot_incompatible_reset_superseded") | |
| 165 | + | }) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 99 | 169 | func TestFinishRecordRestoreAlwaysClosesProvidedBarrier(t *testing.T) { | |
| 100 | 170 | d := newTestDaemon(t, nil, stubClock{}) | |
| 101 | 171 | done := make(chan struct{}) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "log/slog" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "syscall" | 9 | "syscall" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -13,6 +14,7 @@ import ( | |||||
| 13 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 17 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 18 | func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | 20 | func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | ||
| @@ -96,6 +98,74 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) { | |||||
| 96 | require.Equal(t, ports.SessionStopped, entry.state) | 98 | require.Equal(t, ports.SessionStopped, entry.state) | ||
| 97 | } | 99 | } | ||
| 98 | 100 | ||||
| 101 | + | func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) { | |||
| 102 | + | tests := []struct { | |||
| 103 | + | name string | |||
| 104 | + | replacement func(domain.CatalogueRecord) domain.CatalogueRecord | |||
| 105 | + | state ports.SessionState | |||
| 106 | + | }{ | |||
| 107 | + | { | |||
| 108 | + | name: "newer checkpoint", | |||
| 109 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |||
| 110 | + | ref := *record.Committed | |||
| 111 | + | ref.Generation++ | |||
| 112 | + | ref.ManifestDigest[0]++ | |||
| 113 | + | record.Committed = &ref | |||
| 114 | + | return record | |||
| 115 | + | }, | |||
| 116 | + | state: ports.SessionStopped, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "newer incarnation", | |||
| 120 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |||
| 121 | + | record.IncarnationID = domain.IncarnationID{9} | |||
| 122 | + | return record | |||
| 123 | + | }, | |||
| 124 | + | state: ports.SessionStopped, | |||
| 125 | + | }, | |||
| 126 | + | { | |||
| 127 | + | name: "degraded authority", | |||
| 128 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |||
| 129 | + | record.DegradedReason = "newer restore decision" | |||
| 130 | + | return record | |||
| 131 | + | }, | |||
| 132 | + | state: ports.SessionBroken, | |||
| 133 | + | }, | |||
| 134 | + | } | |||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | record := durableRecoveryRecord(0) | |||
| 138 | + | replacement := tt.replacement(record) | |||
| 139 | + | repository := &durableRecoveryRepository{ | |||
| 140 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |||
| 141 | + | loads: make(map[string]int), | |||
| 142 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 143 | + | } | |||
| 144 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 145 | + | var logs bytes.Buffer | |||
| 146 | + | d.log = slog.New(slog.NewJSONHandler(&logs, nil)) | |||
| 147 | + | repository.onLoad = func() { | |||
| 148 | + | require.NoError(t, catalogue.Replace(record.Name, replacement)) | |||
| 149 | + | d.setStoppedRecovery(replacement, tt.state) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 153 | + | ||||
| 154 | + | persisted, ok, err := catalogue.Record(record.Name) | |||
| 155 | + | require.NoError(t, err) | |||
| 156 | + | require.True(t, ok) | |||
| 157 | + | require.Equal(t, replacement, persisted) | |||
| 158 | + | d.mu.Lock() | |||
| 159 | + | entry := d.stopped[record.Name] | |||
| 160 | + | d.mu.Unlock() | |||
| 161 | + | require.Equal(t, tt.state, entry.state) | |||
| 162 | + | require.Equal(t, replacement, entry.record) | |||
| 163 | + | require.Empty(t, repository.deleted) | |||
| 164 | + | daemonRequireEvent(t, daemonJSONLogs(t, logs.Bytes()), "snapshot_incompatible_reset_superseded") | |||
| 165 | + | }) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 99 | func TestFinishRecordRestoreAlwaysClosesProvidedBarrier(t *testing.T) { | 169 | func TestFinishRecordRestoreAlwaysClosesProvidedBarrier(t *testing.T) { | ||
| 100 | d := newTestDaemon(t, nil, stubClock{}) | 170 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 101 | done := make(chan struct{}) | 171 | done := make(chan struct{}) | ||
M internal/usecase/daemon/durable_recovery_test.go +153 -1
| @@ -29,6 +29,7 @@ type durableRecoveryCatalogue struct { | |||
| 29 | 29 | mu sync.Mutex | |
| 30 | 30 | records map[string]domain.CatalogueRecord | |
| 31 | 31 | metadataUpdates []domain.CatalogueMetadataUpdate | |
| 32 | + | replaceErr error | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | type recordErrorCatalogue struct { | |
| @@ -75,6 +76,9 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata | |||
| 75 | 76 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | |
| 76 | 77 | c.mu.Lock() | |
| 77 | 78 | defer c.mu.Unlock() | |
| 79 | + | if c.replaceErr != nil { | |
| 80 | + | return c.replaceErr | |
| 81 | + | } | |
| 78 | 82 | if name != record.Name { | |
| 79 | 83 | return errors.New("catalogue key mismatch") | |
| 80 | 84 | } | |
| @@ -125,7 +129,10 @@ type durableRecoveryRepository struct { | |||
| 125 | 129 | errors map[string]error | |
| 126 | 130 | loads map[string]int | |
| 127 | 131 | repairs map[string]domain.CheckpointRef | |
| 132 | + | deleted []domain.IncarnationID | |
| 133 | + | onLoad func() | |
| 128 | 134 | repairErr error | |
| 135 | + | deleteErr error | |
| 129 | 136 | repairCalls int | |
| 130 | 137 | } | |
| 131 | 138 | ||
| @@ -136,6 +143,9 @@ func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain. | |||
| 136 | 143 | r.loads = make(map[string]int) | |
| 137 | 144 | } | |
| 138 | 145 | r.loads[name]++ | |
| 146 | + | if r.onLoad != nil { | |
| 147 | + | r.onLoad() | |
| 148 | + | } | |
| 139 | 149 | if err := r.errors[name]; err != nil { | |
| 140 | 150 | return ports.SnapshotGeneration{}, err | |
| 141 | 151 | } | |
| @@ -160,6 +170,13 @@ func (r *durableRecoveryRepository) ReconcileCheckpoint(_ context.Context, id do | |||
| 160 | 170 | return nil | |
| 161 | 171 | } | |
| 162 | 172 | ||
| 173 | + | func (r *durableRecoveryRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error { | |
| 174 | + | r.mu.Lock() | |
| 175 | + | defer r.mu.Unlock() | |
| 176 | + | r.deleted = append(r.deleted, id) | |
| 177 | + | return r.deleteErr | |
| 178 | + | } | |
| 179 | + | ||
| 163 | 180 | func durableRecoveryRecord(index int) domain.CatalogueRecord { | |
| 164 | 181 | id := domain.IncarnationID{0: byte(index>>8 + 1), 1: byte(index + 1)} | |
| 165 | 182 | ref := domain.CheckpointRef{Generation: uint64(index + 1), ManifestDigest: [32]byte{byte(index%255 + 1)}} | |
| @@ -194,7 +211,7 @@ func validGeneration(t testing.TB, record domain.CatalogueRecord) ports.Snapshot | |||
| 194 | 211 | func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) { | |
| 195 | 212 | t.Helper() | |
| 196 | 213 | catalogue := newDurableRecoveryCatalogue(records) | |
| 197 | - | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil) | |
| 214 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{0xa5}, 16))) | |
| 198 | 215 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator)) | |
| 199 | 216 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | |
| 200 | 217 | t.Cleanup(d.serveCancel) | |
| @@ -289,6 +306,141 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||
| 289 | 306 | ||
| 290 | 307 | } | |
| 291 | 308 | ||
| 309 | + | func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) { | |
| 310 | + | incompatible := durableRecoveryRecord(0) | |
| 311 | + | healthy := durableRecoveryRecord(1) | |
| 312 | + | repository := &durableRecoveryRepository{ | |
| 313 | + | generations: map[string]ports.SnapshotGeneration{healthy.Name: validGeneration(t, healthy)}, | |
| 314 | + | errors: map[string]error{incompatible.Name: fmt.Errorf("load envelope: %w", snapcodec.ErrBadVersion)}, | |
| 315 | + | loads: make(map[string]int), | |
| 316 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 317 | + | } | |
| 318 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{incompatible, healthy}, repository) | |
| 319 | + | ||
| 320 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 321 | + | ||
| 322 | + | fresh, ok, err := catalogue.Record(incompatible.Name) | |
| 323 | + | require.NoError(t, err) | |
| 324 | + | require.True(t, ok) | |
| 325 | + | require.Equal(t, domain.IncarnationID(bytes.Repeat([]byte{0xa5}, 16)), fresh.IncarnationID) | |
| 326 | + | require.Nil(t, fresh.Committed) | |
| 327 | + | require.Empty(t, fresh.DegradedReason) | |
| 328 | + | require.Empty(t, fresh.TabNames) | |
| 329 | + | require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted) | |
| 330 | + | require.Equal(t, ports.SessionStopped, d.stopped[incompatible.Name].state) | |
| 331 | + | require.Equal(t, fresh, d.stopped[incompatible.Name].record) | |
| 332 | + | ||
| 333 | + | restoredSibling, ok, err := catalogue.Record(healthy.Name) | |
| 334 | + | require.NoError(t, err) | |
| 335 | + | require.True(t, ok) | |
| 336 | + | require.Equal(t, healthy, restoredSibling) | |
| 337 | + | require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state) | |
| 338 | + | require.Equal(t, 1, repository.loads[healthy.Name]) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *testing.T) { | |
| 342 | + | record := durableRecoveryRecord(0) | |
| 343 | + | deleteErr := errors.New("delete old incarnation") | |
| 344 | + | repository := &durableRecoveryRepository{ | |
| 345 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |
| 346 | + | loads: make(map[string]int), | |
| 347 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 348 | + | deleteErr: deleteErr, | |
| 349 | + | } | |
| 350 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 351 | + | d.mu.Lock() | |
| 352 | + | restoreDone := d.stopped[record.Name].restoreDone | |
| 353 | + | d.mu.Unlock() | |
| 354 | + | ||
| 355 | + | restoreErr := d.restoreRecord(context.Background(), record) | |
| 356 | + | require.NoError(t, restoreErr, "post-commit cleanup failure must not fail the authority transition") | |
| 357 | + | ||
| 358 | + | fresh, ok, err := catalogue.Record(record.Name) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | require.True(t, ok) | |
| 361 | + | require.NotEqual(t, record.IncarnationID, fresh.IncarnationID) | |
| 362 | + | require.Nil(t, fresh.Committed) | |
| 363 | + | require.Empty(t, fresh.DegradedReason) | |
| 364 | + | d.mu.Lock() | |
| 365 | + | entry := d.stopped[record.Name] | |
| 366 | + | d.mu.Unlock() | |
| 367 | + | require.Equal(t, ports.SessionStopped, entry.state) | |
| 368 | + | require.Equal(t, fresh, entry.record) | |
| 369 | + | require.Equal(t, restoreDone, entry.restoreDone) | |
| 370 | + | select { | |
| 371 | + | case <-entry.restoreDone: | |
| 372 | + | t.Fatal("direct restore closed its coordinator-owned completion barrier") | |
| 373 | + | default: | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | d.finishRecordRestore(record, restoreErr, restoreDone) | |
| 377 | + | d.mu.Lock() | |
| 378 | + | entry = d.stopped[record.Name] | |
| 379 | + | d.mu.Unlock() | |
| 380 | + | require.Equal(t, ports.SessionStopped, entry.state) | |
| 381 | + | require.Equal(t, fresh, entry.record) | |
| 382 | + | select { | |
| 383 | + | case <-entry.restoreDone: | |
| 384 | + | default: | |
| 385 | + | t.Fatal("restore completion was not signaled") | |
| 386 | + | } | |
| 387 | + | require.Equal(t, []domain.IncarnationID{record.IncarnationID}, repository.deleted) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestIncompatibleCheckpointPreCommitFailureReturnsError(t *testing.T) { | |
| 391 | + | record := durableRecoveryRecord(0) | |
| 392 | + | cause := errors.New("catalogue replace failed") | |
| 393 | + | repository := &durableRecoveryRepository{ | |
| 394 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |
| 395 | + | loads: make(map[string]int), | |
| 396 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 397 | + | } | |
| 398 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 399 | + | catalogue.replaceErr = cause | |
| 400 | + | ||
| 401 | + | err := d.restoreRecord(context.Background(), record) | |
| 402 | + | ||
| 403 | + | require.ErrorIs(t, err, cause) | |
| 404 | + | persisted, ok, recordErr := catalogue.Record(record.Name) | |
| 405 | + | require.NoError(t, recordErr) | |
| 406 | + | require.True(t, ok) | |
| 407 | + | require.Equal(t, record, persisted) | |
| 408 | + | require.Empty(t, repository.deleted) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func TestRestoreLoadFailuresRetainCheckpointWithoutDeletion(t *testing.T) { | |
| 412 | + | tests := []struct { | |
| 413 | + | name string | |
| 414 | + | err error | |
| 415 | + | }{ | |
| 416 | + | {name: "corruption", err: errors.New("corrupt checkpoint")}, | |
| 417 | + | {name: "generic", err: errors.New("load failed")}, | |
| 418 | + | {name: "io", err: &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EACCES}}, | |
| 419 | + | } | |
| 420 | + | for _, tt := range tests { | |
| 421 | + | t.Run(tt.name, func(t *testing.T) { | |
| 422 | + | record := durableRecoveryRecord(0) | |
| 423 | + | repository := &durableRecoveryRepository{ | |
| 424 | + | errors: map[string]error{record.Name: tt.err}, | |
| 425 | + | loads: make(map[string]int), | |
| 426 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 427 | + | } | |
| 428 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 429 | + | ||
| 430 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 431 | + | ||
| 432 | + | persisted, ok, err := catalogue.Record(record.Name) | |
| 433 | + | require.NoError(t, err) | |
| 434 | + | require.True(t, ok) | |
| 435 | + | require.Equal(t, record.IncarnationID, persisted.IncarnationID) | |
| 436 | + | require.Equal(t, record.Committed, persisted.Committed) | |
| 437 | + | require.Equal(t, "checkpoint validation failed", persisted.DegradedReason) | |
| 438 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |
| 439 | + | require.Empty(t, repository.deleted) | |
| 440 | + | }) | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 292 | 444 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | |
| 293 | 445 | const count = 4200 | |
| 294 | 446 | records := make([]domain.CatalogueRecord, count) | |
| @@ -29,6 +29,7 @@ type durableRecoveryCatalogue struct { | |||||
| 29 | mu sync.Mutex | 29 | mu sync.Mutex | ||
| 30 | records map[string]domain.CatalogueRecord | 30 | records map[string]domain.CatalogueRecord | ||
| 31 | metadataUpdates []domain.CatalogueMetadataUpdate | 31 | metadataUpdates []domain.CatalogueMetadataUpdate | ||
| 32 | + | replaceErr error | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | type recordErrorCatalogue struct { | 35 | type recordErrorCatalogue struct { | ||
| @@ -75,6 +76,9 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata | |||||
| 75 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | 76 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | ||
| 76 | c.mu.Lock() | 77 | c.mu.Lock() | ||
| 77 | defer c.mu.Unlock() | 78 | defer c.mu.Unlock() | ||
| 79 | + | if c.replaceErr != nil { | |||
| 80 | + | return c.replaceErr | |||
| 81 | + | } | |||
| 78 | if name != record.Name { | 82 | if name != record.Name { | ||
| 79 | return errors.New("catalogue key mismatch") | 83 | return errors.New("catalogue key mismatch") | ||
| 80 | } | 84 | } | ||
| @@ -125,7 +129,10 @@ type durableRecoveryRepository struct { | |||||
| 125 | errors map[string]error | 129 | errors map[string]error | ||
| 126 | loads map[string]int | 130 | loads map[string]int | ||
| 127 | repairs map[string]domain.CheckpointRef | 131 | repairs map[string]domain.CheckpointRef | ||
| 132 | + | deleted []domain.IncarnationID | |||
| 133 | + | onLoad func() | |||
| 128 | repairErr error | 134 | repairErr error | ||
| 135 | + | deleteErr error | |||
| 129 | repairCalls int | 136 | repairCalls int | ||
| 130 | } | 137 | } | ||
| 131 | 138 | ||||
| @@ -136,6 +143,9 @@ func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain. | |||||
| 136 | r.loads = make(map[string]int) | 143 | r.loads = make(map[string]int) | ||
| 137 | } | 144 | } | ||
| 138 | r.loads[name]++ | 145 | r.loads[name]++ | ||
| 146 | + | if r.onLoad != nil { | |||
| 147 | + | r.onLoad() | |||
| 148 | + | } | |||
| 139 | if err := r.errors[name]; err != nil { | 149 | if err := r.errors[name]; err != nil { | ||
| 140 | return ports.SnapshotGeneration{}, err | 150 | return ports.SnapshotGeneration{}, err | ||
| 141 | } | 151 | } | ||
| @@ -160,6 +170,13 @@ func (r *durableRecoveryRepository) ReconcileCheckpoint(_ context.Context, id do | |||||
| 160 | return nil | 170 | return nil | ||
| 161 | } | 171 | } | ||
| 162 | 172 | ||||
| 173 | + | func (r *durableRecoveryRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error { | |||
| 174 | + | r.mu.Lock() | |||
| 175 | + | defer r.mu.Unlock() | |||
| 176 | + | r.deleted = append(r.deleted, id) | |||
| 177 | + | return r.deleteErr | |||
| 178 | + | } | |||
| 179 | + | ||||
| 163 | func durableRecoveryRecord(index int) domain.CatalogueRecord { | 180 | func durableRecoveryRecord(index int) domain.CatalogueRecord { | ||
| 164 | id := domain.IncarnationID{0: byte(index>>8 + 1), 1: byte(index + 1)} | 181 | id := domain.IncarnationID{0: byte(index>>8 + 1), 1: byte(index + 1)} | ||
| 165 | ref := domain.CheckpointRef{Generation: uint64(index + 1), ManifestDigest: [32]byte{byte(index%255 + 1)}} | 182 | ref := domain.CheckpointRef{Generation: uint64(index + 1), ManifestDigest: [32]byte{byte(index%255 + 1)}} | ||
| @@ -194,7 +211,7 @@ func validGeneration(t testing.TB, record domain.CatalogueRecord) ports.Snapshot | |||||
| 194 | func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) { | 211 | func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) { | ||
| 195 | t.Helper() | 212 | t.Helper() | ||
| 196 | catalogue := newDurableRecoveryCatalogue(records) | 213 | catalogue := newDurableRecoveryCatalogue(records) | ||
| 197 | - | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil) | 214 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{0xa5}, 16))) |
| 198 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator)) | 215 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator)) | ||
| 199 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | 216 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | ||
| 200 | t.Cleanup(d.serveCancel) | 217 | t.Cleanup(d.serveCancel) | ||
| @@ -289,6 +306,141 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||||
| 289 | 306 | ||||
| 290 | } | 307 | } | ||
| 291 | 308 | ||||
| 309 | + | func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) { | |||
| 310 | + | incompatible := durableRecoveryRecord(0) | |||
| 311 | + | healthy := durableRecoveryRecord(1) | |||
| 312 | + | repository := &durableRecoveryRepository{ | |||
| 313 | + | generations: map[string]ports.SnapshotGeneration{healthy.Name: validGeneration(t, healthy)}, | |||
| 314 | + | errors: map[string]error{incompatible.Name: fmt.Errorf("load envelope: %w", snapcodec.ErrBadVersion)}, | |||
| 315 | + | loads: make(map[string]int), | |||
| 316 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 317 | + | } | |||
| 318 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{incompatible, healthy}, repository) | |||
| 319 | + | ||||
| 320 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 321 | + | ||||
| 322 | + | fresh, ok, err := catalogue.Record(incompatible.Name) | |||
| 323 | + | require.NoError(t, err) | |||
| 324 | + | require.True(t, ok) | |||
| 325 | + | require.Equal(t, domain.IncarnationID(bytes.Repeat([]byte{0xa5}, 16)), fresh.IncarnationID) | |||
| 326 | + | require.Nil(t, fresh.Committed) | |||
| 327 | + | require.Empty(t, fresh.DegradedReason) | |||
| 328 | + | require.Empty(t, fresh.TabNames) | |||
| 329 | + | require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted) | |||
| 330 | + | require.Equal(t, ports.SessionStopped, d.stopped[incompatible.Name].state) | |||
| 331 | + | require.Equal(t, fresh, d.stopped[incompatible.Name].record) | |||
| 332 | + | ||||
| 333 | + | restoredSibling, ok, err := catalogue.Record(healthy.Name) | |||
| 334 | + | require.NoError(t, err) | |||
| 335 | + | require.True(t, ok) | |||
| 336 | + | require.Equal(t, healthy, restoredSibling) | |||
| 337 | + | require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state) | |||
| 338 | + | require.Equal(t, 1, repository.loads[healthy.Name]) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *testing.T) { | |||
| 342 | + | record := durableRecoveryRecord(0) | |||
| 343 | + | deleteErr := errors.New("delete old incarnation") | |||
| 344 | + | repository := &durableRecoveryRepository{ | |||
| 345 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |||
| 346 | + | loads: make(map[string]int), | |||
| 347 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 348 | + | deleteErr: deleteErr, | |||
| 349 | + | } | |||
| 350 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 351 | + | d.mu.Lock() | |||
| 352 | + | restoreDone := d.stopped[record.Name].restoreDone | |||
| 353 | + | d.mu.Unlock() | |||
| 354 | + | ||||
| 355 | + | restoreErr := d.restoreRecord(context.Background(), record) | |||
| 356 | + | require.NoError(t, restoreErr, "post-commit cleanup failure must not fail the authority transition") | |||
| 357 | + | ||||
| 358 | + | fresh, ok, err := catalogue.Record(record.Name) | |||
| 359 | + | require.NoError(t, err) | |||
| 360 | + | require.True(t, ok) | |||
| 361 | + | require.NotEqual(t, record.IncarnationID, fresh.IncarnationID) | |||
| 362 | + | require.Nil(t, fresh.Committed) | |||
| 363 | + | require.Empty(t, fresh.DegradedReason) | |||
| 364 | + | d.mu.Lock() | |||
| 365 | + | entry := d.stopped[record.Name] | |||
| 366 | + | d.mu.Unlock() | |||
| 367 | + | require.Equal(t, ports.SessionStopped, entry.state) | |||
| 368 | + | require.Equal(t, fresh, entry.record) | |||
| 369 | + | require.Equal(t, restoreDone, entry.restoreDone) | |||
| 370 | + | select { | |||
| 371 | + | case <-entry.restoreDone: | |||
| 372 | + | t.Fatal("direct restore closed its coordinator-owned completion barrier") | |||
| 373 | + | default: | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | d.finishRecordRestore(record, restoreErr, restoreDone) | |||
| 377 | + | d.mu.Lock() | |||
| 378 | + | entry = d.stopped[record.Name] | |||
| 379 | + | d.mu.Unlock() | |||
| 380 | + | require.Equal(t, ports.SessionStopped, entry.state) | |||
| 381 | + | require.Equal(t, fresh, entry.record) | |||
| 382 | + | select { | |||
| 383 | + | case <-entry.restoreDone: | |||
| 384 | + | default: | |||
| 385 | + | t.Fatal("restore completion was not signaled") | |||
| 386 | + | } | |||
| 387 | + | require.Equal(t, []domain.IncarnationID{record.IncarnationID}, repository.deleted) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func TestIncompatibleCheckpointPreCommitFailureReturnsError(t *testing.T) { | |||
| 391 | + | record := durableRecoveryRecord(0) | |||
| 392 | + | cause := errors.New("catalogue replace failed") | |||
| 393 | + | repository := &durableRecoveryRepository{ | |||
| 394 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |||
| 395 | + | loads: make(map[string]int), | |||
| 396 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 397 | + | } | |||
| 398 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 399 | + | catalogue.replaceErr = cause | |||
| 400 | + | ||||
| 401 | + | err := d.restoreRecord(context.Background(), record) | |||
| 402 | + | ||||
| 403 | + | require.ErrorIs(t, err, cause) | |||
| 404 | + | persisted, ok, recordErr := catalogue.Record(record.Name) | |||
| 405 | + | require.NoError(t, recordErr) | |||
| 406 | + | require.True(t, ok) | |||
| 407 | + | require.Equal(t, record, persisted) | |||
| 408 | + | require.Empty(t, repository.deleted) | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func TestRestoreLoadFailuresRetainCheckpointWithoutDeletion(t *testing.T) { | |||
| 412 | + | tests := []struct { | |||
| 413 | + | name string | |||
| 414 | + | err error | |||
| 415 | + | }{ | |||
| 416 | + | {name: "corruption", err: errors.New("corrupt checkpoint")}, | |||
| 417 | + | {name: "generic", err: errors.New("load failed")}, | |||
| 418 | + | {name: "io", err: &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EACCES}}, | |||
| 419 | + | } | |||
| 420 | + | for _, tt := range tests { | |||
| 421 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 422 | + | record := durableRecoveryRecord(0) | |||
| 423 | + | repository := &durableRecoveryRepository{ | |||
| 424 | + | errors: map[string]error{record.Name: tt.err}, | |||
| 425 | + | loads: make(map[string]int), | |||
| 426 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 427 | + | } | |||
| 428 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 429 | + | ||||
| 430 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 431 | + | ||||
| 432 | + | persisted, ok, err := catalogue.Record(record.Name) | |||
| 433 | + | require.NoError(t, err) | |||
| 434 | + | require.True(t, ok) | |||
| 435 | + | require.Equal(t, record.IncarnationID, persisted.IncarnationID) | |||
| 436 | + | require.Equal(t, record.Committed, persisted.Committed) | |||
| 437 | + | require.Equal(t, "checkpoint validation failed", persisted.DegradedReason) | |||
| 438 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |||
| 439 | + | require.Empty(t, repository.deleted) | |||
| 440 | + | }) | |||
| 441 | + | } | |||
| 442 | + | } | |||
| 443 | + | ||||
| 292 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | 444 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | ||
| 293 | const count = 4200 | 445 | const count = 4200 | ||
| 294 | records := make([]domain.CatalogueRecord, count) | 446 | records := make([]domain.CatalogueRecord, count) | ||
M internal/usecase/daemon/snapshot_restore_incremental.go +42 -0
| @@ -178,6 +178,9 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||
| 178 | 178 | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 179 | 179 | return ctxErr | |
| 180 | 180 | } | |
| 181 | + | if errors.Is(err, snapcodec.ErrBadVersion) { | |
| 182 | + | return d.resetIncompatibleCheckpoint(ctx, record, selected) | |
| 183 | + | } | |
| 181 | 184 | if isRetryableRestoreLoadError(err) { | |
| 182 | 185 | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) | |
| 183 | 186 | } | |
| @@ -215,6 +218,45 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||
| 215 | 218 | return nil | |
| 216 | 219 | } | |
| 217 | 220 | ||
| 221 | + | func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.CatalogueRecord, selected domain.CheckpointRef) error { | |
| 222 | + | fresh, committed, err := d.recovery.ResetIncompatible(ctx, record.Name, record.IncarnationID, selected) | |
| 223 | + | if !committed { | |
| 224 | + | if err != nil { | |
| 225 | + | return fmt.Errorf("snapshot: reset incompatible checkpoint: %w", err) | |
| 226 | + | } | |
| 227 | + | d.log.Info("snapshot_incompatible_reset_superseded", | |
| 228 | + | "session", record.Name, | |
| 229 | + | "incarnation", record.IncarnationID.String(), | |
| 230 | + | "generation", selected.Generation, | |
| 231 | + | ) | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | d.mu.Lock() | |
| 236 | + | if entry, ok := d.stopped[record.Name]; ok { | |
| 237 | + | d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionStopped, entry.restoreDone) | |
| 238 | + | } | |
| 239 | + | d.mu.Unlock() | |
| 240 | + | ||
| 241 | + | if err != nil { | |
| 242 | + | d.log.Warn("snapshot_incompatible_reset_cleanup_pending", | |
| 243 | + | "session", fresh.Name, | |
| 244 | + | "incarnation", fresh.IncarnationID.String(), | |
| 245 | + | "replaced_incarnation", record.IncarnationID.String(), | |
| 246 | + | "generation", selected.Generation, | |
| 247 | + | "err", err, | |
| 248 | + | ) | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | d.log.Info("snapshot_incompatible_reset_complete", | |
| 252 | + | "session", fresh.Name, | |
| 253 | + | "incarnation", fresh.IncarnationID.String(), | |
| 254 | + | "replaced_incarnation", record.IncarnationID.String(), | |
| 255 | + | "generation", selected.Generation, | |
| 256 | + | ) | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | ||
| 218 | 260 | func isRetryableRestoreLoadError(err error) bool { | |
| 219 | 261 | if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 220 | 262 | return false | |
| @@ -178,6 +178,9 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||||
| 178 | if ctxErr := ctx.Err(); ctxErr != nil { | 178 | if ctxErr := ctx.Err(); ctxErr != nil { | ||
| 179 | return ctxErr | 179 | return ctxErr | ||
| 180 | } | 180 | } | ||
| 181 | + | if errors.Is(err, snapcodec.ErrBadVersion) { | |||
| 182 | + | return d.resetIncompatibleCheckpoint(ctx, record, selected) | |||
| 183 | + | } | |||
| 181 | if isRetryableRestoreLoadError(err) { | 184 | if isRetryableRestoreLoadError(err) { | ||
| 182 | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) | 185 | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) | ||
| 183 | } | 186 | } | ||
| @@ -215,6 +218,45 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||||
| 215 | return nil | 218 | return nil | ||
| 216 | } | 219 | } | ||
| 217 | 220 | ||||
| 221 | + | func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.CatalogueRecord, selected domain.CheckpointRef) error { | |||
| 222 | + | fresh, committed, err := d.recovery.ResetIncompatible(ctx, record.Name, record.IncarnationID, selected) | |||
| 223 | + | if !committed { | |||
| 224 | + | if err != nil { | |||
| 225 | + | return fmt.Errorf("snapshot: reset incompatible checkpoint: %w", err) | |||
| 226 | + | } | |||
| 227 | + | d.log.Info("snapshot_incompatible_reset_superseded", | |||
| 228 | + | "session", record.Name, | |||
| 229 | + | "incarnation", record.IncarnationID.String(), | |||
| 230 | + | "generation", selected.Generation, | |||
| 231 | + | ) | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | d.mu.Lock() | |||
| 236 | + | if entry, ok := d.stopped[record.Name]; ok { | |||
| 237 | + | d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionStopped, entry.restoreDone) | |||
| 238 | + | } | |||
| 239 | + | d.mu.Unlock() | |||
| 240 | + | ||||
| 241 | + | if err != nil { | |||
| 242 | + | d.log.Warn("snapshot_incompatible_reset_cleanup_pending", | |||
| 243 | + | "session", fresh.Name, | |||
| 244 | + | "incarnation", fresh.IncarnationID.String(), | |||
| 245 | + | "replaced_incarnation", record.IncarnationID.String(), | |||
| 246 | + | "generation", selected.Generation, | |||
| 247 | + | "err", err, | |||
| 248 | + | ) | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | d.log.Info("snapshot_incompatible_reset_complete", | |||
| 252 | + | "session", fresh.Name, | |||
| 253 | + | "incarnation", fresh.IncarnationID.String(), | |||
| 254 | + | "replaced_incarnation", record.IncarnationID.String(), | |||
| 255 | + | "generation", selected.Generation, | |||
| 256 | + | ) | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | ||||
| 218 | func isRetryableRestoreLoadError(err error) bool { | 260 | func isRetryableRestoreLoadError(err error) bool { | ||
| 219 | if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | 261 | if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | ||
| 220 | return false | 262 | return false | ||