detached d5c98ee

refactor(recovery): replace intent journals with idempotent operations

commit
d5c98eeb0210b71ca8f224a0968d1b2fb2774fe0
author
brice · · 2026-07-26 11:18 UTC
parent
4102d7e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
37 files · +262 -3828
M internal/adapters/recoveryfs/atomic.go +6 -2
@@ -8,13 +8,17 @@ import (
8 "github.com/bnema/vev/pkg/safedir"8 "github.com/bnema/vev/pkg/safedir"
9 )9 )
10 10
11+type atomicWriter struct {
12+ hooks journalHooks
13+}
14+
11 type journalHooks struct {15 type journalHooks struct {
12 syncFile func(string) error16 syncFile func(string) error
13 rename func(string, string) error17 rename func(string, string) error
14 syncDirectory func(string) error18 syncDirectory func(string) error
15 }19 }
16 20
17-func (j *Journal) atomicWrite(path string, data []byte) (retErr error) {21+func (j *atomicWriter) atomicWrite(path string, data []byte) (retErr error) {
18 dir := filepath.Dir(path)22 dir := filepath.Dir(path)
19 if err := safedir.EnsurePrivate(dir); err != nil {23 if err := safedir.EnsurePrivate(dir); err != nil {
20 return err24 return err
@@ -55,7 +59,7 @@ func (j *Journal) atomicWrite(path string, data []byte) (retErr error) {
55 return j.syncDir(dir)59 return j.syncDir(dir)
56 }60 }
57 61
58-func (j *Journal) syncDir(dir string) error {62+func (j *atomicWriter) syncDir(dir string) error {
59 if j.hooks.syncDirectory != nil {63 if j.hooks.syncDirectory != nil {
60 return j.hooks.syncDirectory(dir)64 return j.hooks.syncDirectory(dir)
61 }65 }
D internal/adapters/recoveryfs/journal.go +0 -198
@@ -1,198 +0,0 @@
1-package recoveryfs
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/json"
7- "errors"
8- "fmt"
9- "io"
10- "os"
11- "path/filepath"
12- "sort"
13- "strings"
14-
15- "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/pkg/safedir"
17-)
18-
19-const (
20- discardVersion = 1
21- maxDiscardIntents = 1024
22- maxDiscardIntentSize = 64 << 10
23-)
24-
25-type Journal struct {
26- dir string
27- hooks journalHooks
28-}
29-
30-type discardFile struct {
31- Version int `json:"version"`
32- Intent domain.DiscardIntent `json:"intent"`
33-}
34-
35-func New(stateDir string) *Journal { return &Journal{dir: filepath.Join(stateDir, "transactions")} }
36-
37-func validateDiscard(intent domain.DiscardIntent) error {
38- if err := intent.OldRecord.Validate(); err != nil {
39- return fmt.Errorf("recovery journal: old record: %w", err)
40- }
41- if intent.SessionName != intent.OldRecord.Name || intent.OldIncarnation != intent.OldRecord.IncarnationID {
42- return errors.New("recovery journal: inconsistent old identity")
43- }
44- if intent.NewIncarnation == (domain.IncarnationID{}) || intent.NewIncarnation == intent.OldIncarnation || intent.Reason == "" {
45- return errors.New("recovery journal: incomplete discard intent")
46- }
47- return nil
48-}
49-
50-func encodeDiscard(intent domain.DiscardIntent) ([]byte, error) {
51- if err := validateDiscard(intent); err != nil {
52- return nil, err
53- }
54- return json.Marshal(discardFile{Version: discardVersion, Intent: intent})
55-}
56-
57-func decodeDiscard(data []byte) (domain.DiscardIntent, error) {
58- decoder := json.NewDecoder(bytes.NewReader(data))
59- decoder.DisallowUnknownFields()
60- var file discardFile
61- if err := decoder.Decode(&file); err != nil {
62- return domain.DiscardIntent{}, err
63- }
64- if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
65- if err != nil {
66- return domain.DiscardIntent{}, err
67- }
68- return domain.DiscardIntent{}, errors.New("recovery journal: trailing data")
69- }
70- if file.Version != discardVersion {
71- return domain.DiscardIntent{}, errors.New("recovery journal: unsupported version")
72- }
73- return file.Intent, validateDiscard(file.Intent)
74-}
75-
76-func (j *Journal) path(id domain.IncarnationID) string {
77- return filepath.Join(j.dir, "discard-"+id.String())
78-}
79-
80-func (j *Journal) SaveDiscard(ctx context.Context, intent domain.DiscardIntent) error {
81- if err := ctx.Err(); err != nil {
82- return err
83- }
84- data, err := encodeDiscard(intent)
85- if err != nil {
86- return err
87- }
88- path := j.path(intent.OldIncarnation)
89- if existing, err := readFileBounded(path, maxDiscardIntentSize); err == nil {
90- if !bytes.Equal(existing, data) {
91- return errors.New("recovery journal: discard identity collision")
92- }
93- // Re-sync the parent on idempotent retry: the prior install may have
94- // succeeded while its durability acknowledgement failed.
95- return j.syncDir(filepath.Dir(path))
96- } else if !errors.Is(err, os.ErrNotExist) {
97- return err
98- }
99- return j.atomicWrite(path, data)
100-}
101-
102-func (j *Journal) ListDiscards(ctx context.Context) ([]domain.DiscardIntent, error) {
103- dir, err := os.Open(j.dir)
104- if errors.Is(err, os.ErrNotExist) {
105- return nil, nil
106- }
107- if err != nil {
108- return nil, err
109- }
110- entries := make([]os.DirEntry, 0, maxDiscardIntents)
111- for {
112- batch, readErr := dir.ReadDir(maxDiscardIntents + 1)
113- for _, entry := range batch {
114- if strings.HasPrefix(entry.Name(), ".intent-") && !entry.IsDir() {
115- continue
116- }
117- if entry.IsDir() || !strings.HasPrefix(entry.Name(), "discard-") {
118- _ = dir.Close()
119- return nil, errors.New("recovery journal: invalid transaction entry")
120- }
121- entries = append(entries, entry)
122- if len(entries) > maxDiscardIntents {
123- _ = dir.Close()
124- return nil, errors.New("recovery journal: transaction entry limit exceeded")
125- }
126- }
127- if errors.Is(readErr, io.EOF) {
128- break
129- }
130- if readErr != nil {
131- _ = dir.Close()
132- return nil, readErr
133- }
134- }
135- if err := dir.Close(); err != nil {
136- return nil, err
137- }
138- sort.Slice(entries, func(a, b int) bool { return entries[a].Name() < entries[b].Name() })
139- out := make([]domain.DiscardIntent, 0, len(entries))
140- seen := make(map[domain.IncarnationID]struct{}, len(entries))
141- for _, entry := range entries {
142- if err := ctx.Err(); err != nil {
143- return nil, err
144- }
145- data, err := readFileBounded(filepath.Join(j.dir, entry.Name()), maxDiscardIntentSize)
146- if err != nil {
147- return nil, err
148- }
149- intent, err := decodeDiscard(data)
150- if err != nil {
151- return nil, err
152- }
153- if entry.Name() != filepath.Base(j.path(intent.OldIncarnation)) {
154- return nil, errors.New("recovery journal: non-canonical intent filename")
155- }
156- if _, ok := seen[intent.OldIncarnation]; ok {
157- return nil, errors.New("recovery journal: duplicate discard identity")
158- }
159- seen[intent.OldIncarnation] = struct{}{}
160- out = append(out, intent)
161- }
162- return out, nil
163-}
164-
165-func (j *Journal) DeleteDiscard(ctx context.Context, id domain.IncarnationID) error {
166- if err := ctx.Err(); err != nil {
167- return err
168- }
169- if id == (domain.IncarnationID{}) {
170- return errors.New("recovery journal: zero discard identity")
171- }
172- if err := safedir.EnsurePrivate(j.dir); err != nil {
173- return err
174- }
175- if err := os.Remove(j.path(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
176- return err
177- }
178- return j.syncDir(j.dir)
179-}
180-
181-func readFileBounded(path string, limit int64) ([]byte, error) {
182- file, err := os.Open(path)
183- if err != nil {
184- return nil, err
185- }
186- data, readErr := io.ReadAll(io.LimitReader(file, limit+1))
187- closeErr := file.Close()
188- if readErr != nil {
189- return nil, readErr
190- }
191- if closeErr != nil {
192- return nil, closeErr
193- }
194- if int64(len(data)) > limit {
195- return nil, errors.New("recovery journal: intent size limit exceeded")
196- }
197- return data, nil
198-}
D internal/adapters/recoveryfs/journal_test.go +0 -145
@@ -1,145 +0,0 @@
1-package recoveryfs
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/pkg/safedir"
13- "github.com/stretchr/testify/require"
14-)
15-
16-func TestDiscardCrashMatrix(t *testing.T) {
17- ctx := context.Background()
18- state := t.TempDir()
19- j := New(state)
20- old := domain.IncarnationID{1}
21- intent := domain.DiscardIntent{
22- OldRecord: domain.CatalogueRecord{
23- Name: "work", IncarnationID: old, RecoveryState: domain.RecoveryDegraded,
24- Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid",
25- },
26- OldIncarnation: old, NewIncarnation: domain.IncarnationID{2}, SessionName: "work", Reason: "discard",
27- }
28- require.NoError(t, j.SaveDiscard(ctx, intent))
29-
30- t.Run("existing intent cannot be overwritten", func(t *testing.T) {
31- changed := intent
32- changed.Reason = "different"
33- require.Error(t, j.SaveDiscard(ctx, changed))
34- got, err := j.ListDiscards(ctx)
35- require.NoError(t, err)
36- require.Equal(t, []domain.DiscardIntent{intent}, got)
37- })
38-
39- t.Run("malformed intent remains visible", func(t *testing.T) {
40- path := j.path(old)
41- encoded, err := os.ReadFile(path)
42- require.NoError(t, err)
43- t.Cleanup(func() { require.NoError(t, os.WriteFile(path, encoded, 0o600)) })
44- require.NoError(t, os.WriteFile(path, append(encoded, 0), 0o600))
45- _, err = j.ListDiscards(ctx)
46- require.Error(t, err)
47- _, statErr := os.Stat(path)
48- require.NoError(t, statErr)
49- require.Error(t, j.SaveDiscard(ctx, intent))
50- })
51-
52- t.Run("delete sync failure rolls forward", func(t *testing.T) {
53- injected := errors.New("injected delete directory sync failure")
54- j.hooks.syncDirectory = func(string) error { return injected }
55- require.ErrorIs(t, j.DeleteDiscard(ctx, old), injected)
56- j.hooks = journalHooks{}
57- require.NoError(t, j.DeleteDiscard(ctx, old))
58- got, err := j.ListDiscards(ctx)
59- require.NoError(t, err)
60- require.Empty(t, got)
61- })
62-}
63-
64-func TestRecoveryJournalRoundTripAndIdempotentDelete(t *testing.T) {
65- j := New(t.TempDir())
66- old := domain.IncarnationID{1}
67- intent := domain.DiscardIntent{
68- OldRecord: domain.CatalogueRecord{Name: "work", IncarnationID: old, RecoveryState: domain.RecoveryDegraded, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"},
69- OldIncarnation: old, NewIncarnation: domain.IncarnationID{2}, SessionName: "work", Reason: "discard",
70- }
71- require.NoError(t, j.SaveDiscard(context.Background(), intent))
72- got, err := j.ListDiscards(context.Background())
73- require.NoError(t, err)
74- require.Equal(t, []domain.DiscardIntent{intent}, got)
75- require.NoError(t, j.DeleteDiscard(context.Background(), old))
76- require.NoError(t, j.DeleteDiscard(context.Background(), old))
77- got, err = j.ListDiscards(context.Background())
78- require.NoError(t, err)
79- require.Empty(t, got)
80-}
81-
82-func TestRecoveryJournalIgnoresAtomicLeftoversWhenCountingDiscards(t *testing.T) {
83- j := New(t.TempDir())
84- require.NoError(t, safedir.EnsurePrivate(j.dir))
85- for i := range maxDiscardIntents + 1 {
86- require.NoError(t, os.WriteFile(filepath.Join(j.dir, fmt.Sprintf(".intent-leftover-%04d", i)), nil, 0o600))
87- }
88-
89- got, err := j.ListDiscards(context.Background())
90- require.NoError(t, err)
91- require.Empty(t, got)
92-}
93-
94-func TestRecoveryJournalRejectsMalformedIntent(t *testing.T) {
95- j := New(t.TempDir())
96- require.Error(t, j.SaveDiscard(context.Background(), domain.DiscardIntent{}))
97-
98- _, err := decodeDiscard([]byte(`{"version":1,"intent":{}} !`))
99- require.ErrorContains(t, err, "invalid character")
100-}
101-
102-func TestRecoveryJournalBoundsEnumerationAndIntentReads(t *testing.T) {
103- state := t.TempDir()
104- j := New(state)
105- require.NoError(t, safedir.EnsurePrivate(j.dir))
106- for i := range maxDiscardIntents + 1 {
107- require.NoError(t, os.WriteFile(filepath.Join(j.dir, fmt.Sprintf("discard-extra-%04d", i)), nil, 0o600))
108- }
109- _, err := j.ListDiscards(context.Background())
110- require.ErrorContains(t, err, "entry limit exceeded")
111-
112- require.NoError(t, os.RemoveAll(j.dir))
113- require.NoError(t, safedir.EnsurePrivate(j.dir))
114- require.NoError(t, os.WriteFile(filepath.Join(j.dir, "discard-oversized"), make([]byte, maxDiscardIntentSize+1), 0o600))
115- _, err = j.ListDiscards(context.Background())
116- require.ErrorContains(t, err, "size limit exceeded")
117-}
118-
119-func TestRecoveryJournalFailsClosedAtDurabilityBoundaries(t *testing.T) {
120- for _, boundary := range []string{"file sync", "rename", "directory sync"} {
121- t.Run(boundary, func(t *testing.T) {
122- j := New(t.TempDir())
123- old := domain.IncarnationID{1}
124- intent := domain.DiscardIntent{
125- OldRecord: domain.CatalogueRecord{Name: "work", IncarnationID: old, RecoveryState: domain.RecoveryDegraded, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"},
126- OldIncarnation: old, NewIncarnation: domain.IncarnationID{2}, SessionName: "work", Reason: "discard",
127- }
128- injected := errors.New("injected durability failure")
129- switch boundary {
130- case "file sync":
131- j.hooks.syncFile = func(string) error { return injected }
132- case "rename":
133- j.hooks.rename = func(string, string) error { return injected }
134- case "directory sync":
135- j.hooks.syncDirectory = func(string) error { return injected }
136- }
137- require.ErrorIs(t, j.SaveDiscard(context.Background(), intent), injected)
138- j.hooks = journalHooks{}
139- require.NoError(t, j.SaveDiscard(context.Background(), intent))
140- got, err := j.ListDiscards(context.Background())
141- require.NoError(t, err)
142- require.Equal(t, []domain.DiscardIntent{intent}, got)
143- })
144- }
145-}
M internal/adapters/snapshot/maintenance_delete.go +13 -135
@@ -19,10 +19,9 @@ import (
19 )19 )
20 20
21 const (21 const (
22- deletionTombstoneVersion = uint16(1)22+ deletionTombstoneVersion = uint16(1)
23- deletionTombstoneMagic = "VEVD"23+ deletionTombstoneMagic = "VEVD"
24- deletionTombstonesDir = "deletions"24+ deletionTombstonesDir = "deletions"
25- quarantineDescriptorVersion = 1
26 )25 )
27 26
28 var ErrMaintenanceBudgetTooSmall = errors.New("snapshot: maintenance budget too small")27 var ErrMaintenanceBudgetTooSmall = errors.New("snapshot: maintenance budget too small")
@@ -550,151 +549,30 @@ func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (chang
550 return changed, false, nil549 return changed, false, nil
551 }550 }
552 551
553-func (r *Repository) QuarantineDeletionSources(ctx context.Context, tombstone domain.DeletionTombstone, includeLegacyName bool) error {552+func (r *Repository) DeleteIncarnation(ctx context.Context, id domain.IncarnationID) error {
554- if err := tombstone.Validate(); err != nil {
555- return err
556- }
557 if err := ctx.Err(); err != nil {553 if err := ctx.Err(); err != nil {
558 return err554 return err
559 }555 }
560- key, err := incarnationKey(tombstone.IncarnationID)556+ key, err := incarnationKey(id)
561 if err != nil {557 if err != nil {
562 return err558 return err
563 }559 }
564 lock := r.lockSession(key)560 lock := r.lockSession(key)
565 defer r.unlockSession(lock)561 defer r.unlockSession(lock)
566 r.invalidateStorageEpoch(key)562 r.invalidateStorageEpoch(key)
567- quarantineDir := filepath.Join(r.dir, "quarantine", tombstone.IncarnationID.String())
568- if err := r.ensurePrivateDirectory(quarantineDir); err != nil {
569- return err
570- }
571- if err := r.quarantineSource(r.sessionPath(tombstone.IncarnationID), filepath.Join(quarantineDir, "snapshot")); err != nil {
572- return err
573- }
574- if hook := r.hooks.afterDeletionIncarnationQuarantine; hook != nil {
575- if err := hook(); err != nil {
576- return err
577- }
578- }
579- if includeLegacyName {
580- if err := r.quarantineSource(filepath.Join(r.dir, filenameForName(tombstone.Name)), filepath.Join(quarantineDir, "legacy.snap")); err != nil {
581- return err
582- }
583- if hook := r.hooks.afterDeletionLegacyQuarantine; hook != nil {
584- if err := hook(); err != nil {
585- return err
586- }
587- }
588- }
589- return nil
590-}
591-
592-func (r *Repository) quarantineSource(source, target string) error {
593- if _, err := r.stat(target); err == nil {
594- if _, sourceErr := r.stat(source); errors.Is(sourceErr, os.ErrNotExist) {
595- if err := r.syncDirectory(filepath.Dir(source)); err != nil {
596- return err
597- }
598- if filepath.Clean(filepath.Dir(source)) != filepath.Clean(filepath.Dir(target)) {
599- return r.syncDirectory(filepath.Dir(target))
600- }
601- return nil
602- }
603- return fmt.Errorf("snapshot: quarantine target already exists")
604- } else if !errors.Is(err, os.ErrNotExist) {
605- return err
606- }
607- if _, err := r.stat(source); errors.Is(err, os.ErrNotExist) {
608- return nil
609- } else if err != nil {
610- return err
611- }
612- if err := r.rename(source, target); err != nil {
613- return err
614- }
615- if err := r.syncDirectory(filepath.Dir(source)); err != nil {
616- return err
617- }
618- if filepath.Clean(filepath.Dir(source)) != filepath.Clean(filepath.Dir(target)) {
619- return r.syncDirectory(filepath.Dir(target))
620- }
621- return nil
622-}
623-
624-type quarantineDescriptorFile struct {
625- Version int `json:"version"`
626- Descriptor domain.QuarantineDescriptor `json:"descriptor"`
627-}
628-
629-func validateQuarantineDescriptor(descriptor domain.QuarantineDescriptor) error {
630- if err := descriptor.OldRecord.Validate(); err != nil {
631- return fmt.Errorf("snapshot: quarantine old record: %w", err)
632- }
633- if descriptor.SessionName != descriptor.OldRecord.Name || descriptor.OldIncarnation != descriptor.OldRecord.IncarnationID {
634- return errors.New("snapshot: inconsistent quarantine identity")
635- }
636- if descriptor.ReplacementIncarnation == (domain.IncarnationID{}) || descriptor.ReplacementIncarnation == descriptor.OldIncarnation || descriptor.Reason == "" {
637- return errors.New("snapshot: incomplete quarantine descriptor")
638- }
639- return nil
640-}
641-
642-func encodeQuarantineDescriptor(descriptor domain.QuarantineDescriptor) ([]byte, error) {
643- if err := validateQuarantineDescriptor(descriptor); err != nil {
644- return nil, err
645- }
646- return json.Marshal(quarantineDescriptorFile{Version: quarantineDescriptorVersion, Descriptor: descriptor})
647-}
648 563
649-func decodeQuarantineDescriptor(encoded []byte) (domain.QuarantineDescriptor, error) {564+ path := r.sessionPath(id)
650- decoder := json.NewDecoder(bytes.NewReader(encoded))565+ if err := os.RemoveAll(path); err != nil {
651- decoder.DisallowUnknownFields()566+ return fmt.Errorf("remove snapshot incarnation %s: %w", id.String(), safeFilesystemError(err))
652- var file quarantineDescriptorFile
653- if err := decoder.Decode(&file); err != nil {
654- return domain.QuarantineDescriptor{}, err
655- }
656- if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
657- return domain.QuarantineDescriptor{}, errors.New("snapshot: trailing quarantine descriptor data")
658 }567 }
659- if file.Version != quarantineDescriptorVersion {
660- return domain.QuarantineDescriptor{}, errors.New("snapshot: unsupported quarantine descriptor version")
661- }
662- return file.Descriptor, validateQuarantineDescriptor(file.Descriptor)
663-}
664-
665-func (r *Repository) SaveQuarantineDescriptor(ctx context.Context, descriptor domain.QuarantineDescriptor) error {
666 if err := ctx.Err(); err != nil {568 if err := ctx.Err(); err != nil {
667 return err569 return err
668 }570 }
669- encoded, err := encodeQuarantineDescriptor(descriptor)571+ parent := filepath.Dir(path)
670- if err != nil {572+ if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
671- return err573+ return nil
672- }574+ } else if err != nil {
673- dir := filepath.Join(r.dir, "quarantine", descriptor.OldIncarnation.String())
674- if err := r.ensurePrivateDirectory(dir); err != nil {
675 return err575 return err
676 }576 }
677- return r.writeImmutable(filepath.Join(dir, "record"), encoded, func(existing []byte) error {577+ return r.syncDirectory(parent)
678- decoded, err := decodeQuarantineDescriptor(existing)
679- if err != nil {
680- return err
681- }
682- if !quarantineDescriptorsEqual(decoded, descriptor) || !bytes.Equal(existing, encoded) {
683- return errors.New("snapshot: quarantine descriptor identity mismatch")
684- }
685- return nil
686- })
687-}
688-
689-func quarantineDescriptorsEqual(a, b domain.QuarantineDescriptor) bool {
690- return a.OldIncarnation == b.OldIncarnation && a.ReplacementIncarnation == b.ReplacementIncarnation &&
691- a.SessionName == b.SessionName && a.Reason == b.Reason && a.OldRecord.Equal(b.OldRecord)
692-}
693-
694-func (r *Repository) QuarantineIncarnation(ctx context.Context, id domain.IncarnationID) error {
695- return r.QuarantineDeletionSources(ctx, domain.DeletionTombstone{Name: "quarantined", IncarnationID: id}, false)
696-}
697-
698-func (r *Repository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
699- return errors.New("snapshot: direct incarnation deletion not implemented")
700 }578 }
D internal/adapters/snapshot/maintenance_delete_test.go +0 -474
@@ -1,474 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "encoding/base64"
6- "encoding/hex"
7- "errors"
8- "fmt"
9- "os"
10- "path/filepath"
11- "reflect"
12- "slices"
13- "testing"
14- "time"
15-
16- "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"
18-)
19-
20-func TestDiscardCrashMatrix(t *testing.T) {
21- ctx := context.Background()
22- old := domain.IncarnationID{1}
23- replacement := domain.IncarnationID{2}
24- record := domain.CatalogueRecord{
25- Name: "broken", IncarnationID: old, RecoveryState: domain.RecoveryDegraded,
26- Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "corrupt",
27- }
28- descriptor := domain.QuarantineDescriptor{
29- OldRecord: record, OldIncarnation: old, ReplacementIncarnation: replacement,
30- SessionName: record.Name, Reason: "explicit discard",
31- }
32-
33- t.Run("descriptor is immutable and idempotent", func(t *testing.T) {
34- repo := NewRepository(privateDir(t))
35- if err := repo.SaveQuarantineDescriptor(ctx, descriptor); err != nil {
36- t.Fatal(err)
37- }
38- if err := repo.SaveQuarantineDescriptor(ctx, descriptor); err != nil {
39- t.Fatalf("idempotent retry: %v", err)
40- }
41- path := filepath.Join(repo.dir, "quarantine", old.String(), "record")
42- encoded, err := os.ReadFile(path)
43- if err != nil {
44- t.Fatal(err)
45- }
46- got, err := decodeQuarantineDescriptor(encoded)
47- if err != nil || !reflect.DeepEqual(got, descriptor) {
48- t.Fatalf("descriptor round trip = %#v, %v", got, err)
49- }
50- changed := descriptor
51- changed.Reason = "different"
52- if err := repo.SaveQuarantineDescriptor(ctx, changed); err == nil {
53- t.Fatal("descriptor overwrite accepted")
54- }
55- if err := os.WriteFile(path, append(encoded, 0), 0o600); err != nil {
56- t.Fatal(err)
57- }
58- if err := repo.SaveQuarantineDescriptor(ctx, descriptor); err == nil {
59- t.Fatal("malformed existing descriptor accepted")
60- }
61- })
62-
63- for _, boundary := range []string{"descriptor-file-sync", "descriptor-install", "descriptor-directory-sync"} {
64- t.Run(boundary, func(t *testing.T) {
65- repo := NewRepository(privateDir(t))
66- dir := filepath.Join(repo.dir, "quarantine", old.String())
67- if err := repo.ensurePrivateDirectory(dir); err != nil {
68- t.Fatal(err)
69- }
70- injected := errors.New("injected descriptor durability failure")
71- switch boundary {
72- case "descriptor-file-sync":
73- repo.hooks.syncFile = func(string) error { return injected }
74- case "descriptor-install":
75- repo.hooks.installImmutable = func(string) error { return injected }
76- case "descriptor-directory-sync":
77- repo.hooks.syncDirectory = func(path string) error {
78- if path == dir {
79- return injected
80- }
81- return nil
82- }
83- }
84- if err := repo.SaveQuarantineDescriptor(ctx, descriptor); !errors.Is(err, injected) {
85- t.Fatalf("SaveQuarantineDescriptor() error = %v", err)
86- }
87- repo.hooks = repositoryHooks{}
88- if err := repo.SaveQuarantineDescriptor(ctx, descriptor); err != nil {
89- t.Fatalf("retry: %v", err)
90- }
91- })
92- }
93-
94- for _, boundary := range []string{"rename", "source-directory-sync", "destination-directory-sync"} {
95- t.Run(boundary, func(t *testing.T) {
96- repo := NewRepository(privateDir(t))
97- source := repo.sessionPath(old)
98- destinationDir := filepath.Join(repo.dir, "quarantine", old.String())
99- if err := repo.ensurePrivateDirectory(source); err != nil {
100- t.Fatal(err)
101- }
102- if err := repo.ensurePrivateDirectory(destinationDir); err != nil {
103- t.Fatal(err)
104- }
105- if err := os.WriteFile(filepath.Join(source, "payload"), []byte("uncertain"), 0o600); err != nil {
106- t.Fatal(err)
107- }
108- injected := errors.New("injected quarantine durability failure")
109- switch boundary {
110- case "rename":
111- repo.hooks.rename = func(string) error { return injected }
112- case "source-directory-sync":
113- repo.hooks.syncDirectory = func(path string) error {
114- if path == filepath.Dir(source) {
115- return injected
116- }
117- return nil
118- }
119- case "destination-directory-sync":
120- repo.hooks.syncDirectory = func(path string) error {
121- if path == destinationDir {
122- return injected
123- }
124- return nil
125- }
126- }
127- if err := repo.QuarantineIncarnation(ctx, old); !errors.Is(err, injected) {
128- t.Fatalf("QuarantineIncarnation() error = %v", err)
129- }
130- repo.hooks = repositoryHooks{}
131- if err := repo.QuarantineIncarnation(ctx, old); err != nil {
132- t.Fatalf("retry: %v", err)
133- }
134- got, err := os.ReadFile(filepath.Join(destinationDir, "snapshot", "payload"))
135- if err != nil || string(got) != "uncertain" {
136- t.Fatalf("quarantined payload = %q, %v", got, err)
137- }
138- })
139- }
140-
141- t.Run("quarantine never removes source data", func(t *testing.T) {
142- repo := NewRepository(privateDir(t))
143- source := repo.sessionPath(old)
144- if err := repo.ensurePrivateDirectory(source); err != nil {
145- t.Fatal(err)
146- }
147- payload := filepath.Join(source, "payload")
148- if err := os.WriteFile(payload, []byte("uncertain"), 0o600); err != nil {
149- t.Fatal(err)
150- }
151- if err := repo.SaveQuarantineDescriptor(ctx, descriptor); err != nil {
152- t.Fatal(err)
153- }
154- if err := repo.QuarantineIncarnation(ctx, old); err != nil {
155- t.Fatal(err)
156- }
157- target := filepath.Join(repo.dir, "quarantine", old.String(), "snapshot", "payload")
158- got, err := os.ReadFile(target)
159- if err != nil || string(got) != "uncertain" {
160- t.Fatalf("quarantined payload = %q, %v", got, err)
161- }
162- if err := repo.QuarantineIncarnation(ctx, old); err != nil {
163- t.Fatalf("idempotent retry: %v", err)
164- }
165- })
166-}
167-
168-func TestQuarantineDeletionSourcesSerializesConcurrentPublication(t *testing.T) {
169- repo := NewRepository(privateDir(t))
170- id := domain.IncarnationID{9}
171- tombstone := domain.DeletionTombstone{Name: "work", IncarnationID: id}
172- publication := incarnationPublication(t, id, "work", 1, nil)
173- if err := repo.Publish(context.Background(), publication); err != nil {
174- t.Fatal(err)
175- }
176-
177- quarantined := make(chan struct{})
178- release := make(chan struct{})
179- repo.hooks.afterDeletionIncarnationQuarantine = func() error {
180- close(quarantined)
181- <-release
182- return nil
183- }
184- quarantineDone := make(chan error, 1)
185- go func() { quarantineDone <- repo.QuarantineDeletionSources(context.Background(), tombstone, false) }()
186- <-quarantined
187-
188- publishAttempted := make(chan struct{})
189- repo.hooks.beforeSessionLock = func(string) { close(publishAttempted) }
190- publishDone := make(chan error, 1)
191- go func() { publishDone <- repo.Publish(context.Background(), publication) }()
192- select {
193- case <-publishAttempted:
194- case <-time.After(time.Second):
195- t.Fatal("Publish did not attempt the incarnation lock")
196- }
197- select {
198- case err := <-publishDone:
199- t.Fatalf("Publish completed while quarantine held the incarnation lock: %v", err)
200- case <-time.After(25 * time.Millisecond):
201- }
202- close(release)
203- if err := <-quarantineDone; err != nil {
204- t.Fatal(err)
205- }
206- if err := <-publishDone; err != nil {
207- t.Fatal(err)
208- }
209- if _, err := os.Stat(repo.headPath(id)); err != nil {
210- t.Fatalf("republished HEAD: %v", err)
211- }
212- if _, err := os.Stat(filepath.Join(repo.dir, "quarantine", id.String(), "snapshot", repositoryHead)); err != nil {
213- t.Fatalf("quarantined HEAD: %v", err)
214- }
215-}
216-
217-func TestDeletionTombstoneCodec(t *testing.T) {
218- want := domain.DeletionTombstone{Name: "work", IncarnationID: domain.IncarnationID{1}}
219- encoded, err := encodeDeletionTombstone(want)
220- if err != nil {
221- t.Fatal(err)
222- }
223- if got := hex.EncodeToString(encoded); got != "5645564400010004776f726b01000000000000000000000000000000" {
224- t.Fatalf("encoded tombstone = %s", got)
225- }
226- got, err := decodeDeletionTombstone(encoded)
227- if err != nil {
228- t.Fatal(err)
229- }
230- if got != want {
231- t.Fatalf("round trip = %#v", got)
232- }
233- for n := range len(encoded) {
234- if _, err := decodeDeletionTombstone(encoded[:n]); err == nil {
235- t.Fatalf("prefix %d accepted", n)
236- }
237- }
238- if _, err := decodeDeletionTombstone(append(encoded, 0)); err == nil {
239- t.Fatal("trailing byte accepted")
240- }
241- for _, test := range []struct {
242- name string
243- mutate func([]byte) []byte
244- }{
245- {name: "wrong magic", mutate: func(data []byte) []byte { data[0] ^= 0xff; return data }},
246- {name: "wrong version", mutate: func(data []byte) []byte { data[5]++; return data }},
247- {name: "invalid name", mutate: func(data []byte) []byte { copy(data[8:12], "../x"); return data }},
248- {name: "zero ID", mutate: func(data []byte) []byte { clear(data[len(data)-16:]); return data }},
249- } {
250- t.Run(test.name, func(t *testing.T) {
251- malformed := test.mutate(append([]byte(nil), encoded...))
252- if _, err := decodeDeletionTombstone(malformed); err == nil {
253- t.Fatal("malformed tombstone accepted")
254- }
255- })
256- }
257-
258- for _, boundary := range []string{"file-sync", "install", "directory-sync"} {
259- t.Run(boundary, func(t *testing.T) {
260- repo := NewRepository(privateDir(t))
261- dir := filepath.Join(repo.dir, deletionTombstonesDir)
262- if err := repo.ensurePrivateDirectory(dir); err != nil {
263- t.Fatal(err)
264- }
265- injected := errors.New("injected tombstone persistence failure")
266- switch boundary {
267- case "file-sync":
268- repo.hooks.syncFile = func(string) error { return injected }
269- case "install":
270- repo.hooks.installImmutable = func(string) error { return injected }
271- case "directory-sync":
272- repo.hooks.syncDirectory = func(path string) error {
273- if path == dir {
274- return injected
275- }
276- return nil
277- }
278- }
279- if err := repo.WriteDeletionTombstone(context.Background(), want); !errors.Is(err, injected) {
280- t.Fatalf("WriteDeletionTombstone() error = %v", err)
281- }
282- repo.hooks = repositoryHooks{}
283- if err := repo.WriteDeletionTombstone(context.Background(), want); err != nil {
284- t.Fatalf("retry: %v", err)
285- }
286- })
287- }
288-}
289-
290-func TestDeletionTombstoneListingResumesAcrossRepositoryRecreation(t *testing.T) {
291- dir := privateDir(t)
292- ctx := context.Background()
293- want := []domain.DeletionTombstone{
294- {Name: "first", IncarnationID: domain.IncarnationID{15: 1}},
295- {Name: "second", IncarnationID: domain.IncarnationID{15: 2}},
296- {Name: "third", IncarnationID: domain.IncarnationID{15: 3}},
297- }
298- repo := NewRepository(dir)
299- for _, index := range []int{2, 0, 1} {
300- if err := repo.WriteDeletionTombstone(ctx, want[index]); err != nil {
301- t.Fatal(err)
302- }
303- }
304-
305- var got []domain.DeletionTombstone
306- cursor := ports.DeletionTombstoneCursor{}
307- reads := 0
308- for calls := 0; ; calls++ {
309- if calls == 32 {
310- t.Fatal("listing did not complete")
311- }
312- // A new Repository has no in-memory scan state. Every continuation must
313- // therefore carry everything needed to resume the bounded directory scan.
314- repo = NewRepository(dir)
315- repo.hooks.beforeDeletionTombstoneRead = func(string) { reads++ }
316- page, err := repo.ListDeletionTombstones(ctx, cursor, ports.MaintenanceBudget{Entries: 1, Bytes: 1024})
317- if err != nil {
318- t.Fatal(err)
319- }
320- got = append(got, page.Tombstones...)
321- if page.Done {
322- break
323- }
324- if page.Next.After == "" || page.Next.After == cursor.After {
325- t.Fatalf("continuation did not advance: before=%q after=%q", cursor.After, page.Next.After)
326- }
327- cursor = page.Next
328- }
329- if !slices.Equal(got, want) {
330- t.Fatalf("tombstones = %#v, want %#v", got, want)
331- }
332- if reads != len(want) {
333- t.Fatalf("tombstone object reads = %d, want %d", reads, len(want))
334- }
335-}
336-
337-func TestDeletionTombstoneListingBatchesSortedCandidatesWithinScanBudget(t *testing.T) {
338- dir := privateDir(t)
339- repo := NewRepository(dir)
340- ctx := context.Background()
341- want := make([]domain.DeletionTombstone, 5)
342- for i := range want {
343- want[i] = domain.DeletionTombstone{Name: fmt.Sprintf("session-%d", i), IncarnationID: domain.IncarnationID{15: byte(i + 1)}}
344- }
345- for _, i := range []int{4, 1, 3, 0, 2} {
346- if err := repo.WriteDeletionTombstone(ctx, want[i]); err != nil {
347- t.Fatal(err)
348- }
349- }
350-
351- cursor := ports.DeletionTombstoneCursor{}
352- var got []domain.DeletionTombstone
353- reads := 0
354- for calls := range 64 {
355- repo = NewRepository(dir)
356- repo.hooks.beforeDeletionTombstoneRead = func(string) { reads++ }
357- entryBudget := uint64(2)
358- if calls > 0 {
359- entryBudget = 1 // A resumed caller may reduce its per-pass budget.
360- }
361- page, err := repo.ListDeletionTombstones(ctx, cursor, ports.MaintenanceBudget{Entries: entryBudget, Bytes: 4096})
362- if err != nil {
363- t.Fatal(err)
364- }
365- if uint64(len(page.Tombstones)) > entryBudget {
366- t.Fatalf("page %d returned %d tombstones over entry budget %d", calls, len(page.Tombstones), entryBudget)
367- }
368- got = append(got, page.Tombstones...)
369- if page.Done {
370- break
371- }
372- cursor = page.Next
373- }
374- if !slices.Equal(got, want) {
375- t.Fatalf("tombstones = %#v, want %#v", got, want)
376- }
377- if reads != len(want) {
378- t.Fatalf("payload reads = %d, want %d", reads, len(want))
379- }
380-}
381-
382-func TestMalformedDeletionTombstone(t *testing.T) {
383- dir := privateDir(t)
384- repo := NewRepository(dir)
385- tombstone := domain.DeletionTombstone{Name: "work", IncarnationID: domain.IncarnationID{7}}
386- if err := repo.WriteDeletionTombstone(context.Background(), tombstone); err != nil {
387- t.Fatal(err)
388- }
389- path := repo.deletionTombstonePath(tombstone.IncarnationID)
390- if err := os.WriteFile(path, []byte("malformed"), 0o600); err != nil {
391- t.Fatal(err)
392- }
393- if _, err := repo.ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 64, Bytes: 1024}); err == nil {
394- t.Fatal("malformed tombstone was skipped")
395- }
396- if _, err := os.Stat(path); err != nil {
397- t.Fatalf("malformed tombstone was removed: %v", err)
398- }
399-}
400-
401-func TestDeletionTombstoneListingRejectsMalformedContinuation(t *testing.T) {
402- repo := NewRepository(privateDir(t))
403- ctx := context.Background()
404- after := domain.IncarnationID{15: 2}.String() + ".tombstone"
405-
406- tests := []struct {
407- name string
408- cursor ports.DeletionTombstoneCursor
409- }{
410- {name: "repository-memory token", cursor: ports.DeletionTombstoneCursor{After: "@1"}},
411- {name: "non-advancing empty state", cursor: rawDeletionListingCursor(t, `{}`)},
412- {name: "non-canonical key", cursor: rawDeletionListingCursor(t, `{"after":"ABC.tombstone"}`)},
413- {name: "candidate does not advance", cursor: rawDeletionListingCursor(t, `{"after":"`+after+`","candidate":"`+after+`","found":1}`)},
414- {name: "unknown field", cursor: rawDeletionListingCursor(t, `{"unknown":1}`)},
415- }
416- for _, test := range tests {
417- t.Run(test.name, func(t *testing.T) {
418- if _, err := repo.ListDeletionTombstones(ctx, test.cursor, ports.MaintenanceBudget{Entries: 1, Bytes: 1024}); err == nil {
419- t.Fatal("malformed continuation accepted")
420- }
421- })
422- }
423-}
424-
425-func rawDeletionListingCursor(t *testing.T, state string) ports.DeletionTombstoneCursor {
426- t.Helper()
427- return ports.DeletionTombstoneCursor{After: deletionListingCursorPrefix + base64.RawURLEncoding.EncodeToString([]byte(state))}
428-}
429-
430-func TestDeletionTombstoneListing(t *testing.T) {
431- repo := NewRepository(privateDir(t))
432- ctx := context.Background()
433- first := domain.DeletionTombstone{Name: "a", IncarnationID: domain.IncarnationID{1}}
434- second := domain.DeletionTombstone{Name: "b", IncarnationID: domain.IncarnationID{2}}
435- if err := repo.WriteDeletionTombstone(ctx, second); err != nil {
436- t.Fatal(err)
437- }
438- if err := repo.WriteDeletionTombstone(ctx, first); err != nil {
439- t.Fatal(err)
440- }
441- page, err := repo.ListDeletionTombstones(ctx, ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 3, Bytes: 1024})
442- if err != nil {
443- t.Fatal(err)
444- }
445- if !page.Done || !slices.Equal(page.Tombstones, []domain.DeletionTombstone{first, second}) || page.Next.After == "" {
446- t.Fatalf("batched page = %#v", page)
447- }
448- if _, err := repo.ListDeletionTombstones(ctx, ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 3, Bytes: 1}); err == nil {
449- t.Fatal("non-advancing budget accepted")
450- }
451-
452- reads := 0
453- repo.hooks.beforeDeletionTombstoneRead = func(string) { reads++ }
454- page, err = repo.ListDeletionTombstones(ctx, ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 1, Bytes: 1024})
455- if err != nil || reads != 0 || len(page.Tombstones) != 0 || page.Next.After == "" {
456- t.Fatalf("bounded scan read %d objects: page=%#v err=%v", reads, page, err)
457- }
458-
459- injected := errors.New("injected directory sync failure")
460- tombstoneDir := filepath.Join(repo.dir, deletionTombstonesDir)
461- repo.hooks.syncDirectory = func(path string) error {
462- if path == tombstoneDir {
463- return injected
464- }
465- return nil
466- }
467- if err := repo.DeleteDeletionTombstone(ctx, first.IncarnationID); !errors.Is(err, injected) {
468- t.Fatalf("DeleteDeletionTombstone() error = %v", err)
469- }
470- repo.hooks = repositoryHooks{}
471- if err := repo.DeleteDeletionTombstone(ctx, first.IncarnationID); err != nil {
472- t.Fatalf("DeleteDeletionTombstone() retry = %v", err)
473- }
474-}
M internal/adapters/snapshot/maintenance_gc.go +0 -188
@@ -755,194 +755,6 @@ func (r *Repository) validateMaintenanceCheckpoint(ctx context.Context, id domai
755 return validatedMaintenanceCheckpoint{refs: refs, parent: manifest.ParentCheckpoint}, consumed, true, nil755 return validatedMaintenanceCheckpoint{refs: refs, parent: manifest.ParentCheckpoint}, consumed, true, nil
756 }756 }
757 757
758-// Reconcile scans top-level incarnation namespaces from an external seek
759-// cookie. Checkpoint-chain work receives a fresh budget for every directory.
760-func (r *Repository) Reconcile(ctx context.Context, records []domain.CatalogueRecord, cursor ports.ReconcileCursor, budget ports.MaintenanceBudget) (nextCursor ports.ReconcileCursor, findings []ports.ReconcileFinding, retErr error) {
761- requested := budget
762- defer func() {
763- consumed := ports.MaintenanceBudget{}
764- for _, finding := range findings {
765- consumed.Entries += finding.Consumed.Entries
766- consumed.Bytes += finding.Consumed.Bytes
767- r.log.Info("snapshot_reconciliation_progress",
768- "incarnation", finding.Candidate.IncarnationID.String(),
769- "action", finding.Kind,
770- "status", finding.Status,
771- "consumed_entries", finding.Consumed.Entries,
772- "consumed_bytes", finding.Consumed.Bytes,
773- "cursor", finding.Cursor.DirectoryCookie,
774- )
775- }
776- r.log.Info("snapshot_reconciliation_complete",
777- "action", "scan",
778- "cursor", nextCursor.DirectoryCookie,
779- "requested_entries", requested.Entries,
780- "requested_bytes", requested.Bytes,
781- "consumed_entries", consumed.Entries,
782- "consumed_bytes", consumed.Bytes,
783- "done", nextCursor.DirectoryCookie == 0 && retErr == nil,
784- "err", retErr,
785- )
786- }()
787- if err := ctx.Err(); err != nil {
788- return cursor, nil, err
789- }
790- if budget.Entries == 0 || budget.Bytes == 0 {
791- return cursor, nil, nil
792- }
793- offset := int64(cursor.DirectoryCookie)
794- if offset < 0 {
795- return cursor, nil, errors.New("snapshot: invalid reconciliation cursor")
796- }
797- byIncarnation := make(map[domain.IncarnationID]domain.CatalogueRecord, len(records))
798- for _, record := range records {
799- byIncarnation[record.IncarnationID] = record
800- }
801-
802- root := filepath.Join(r.dir, repositorySessionsDir)
803- state := &maintenanceCursor{offset: offset}
804- findingCapacity := maintenanceBatch
805- if budget.Entries < maintenanceBatch {
806- findingCapacity = int(budget.Entries)
807- }
808- findings = make([]ports.ReconcileFinding, 0, findingCapacity)
809- stepConsumed := ports.MaintenanceBudget{}
810- resumeCursor := cursor
811- for scanned := uint64(0); scanned < budget.Entries && stepConsumed.Entries < budget.Entries; scanned++ {
812- entries, done, err := r.readMaintenanceDirent(root, 1, state)
813- if errors.Is(err, os.ErrNotExist) {
814- return ports.ReconcileCursor{}, findings, nil
815- }
816- if err != nil {
817- return cursor, nil, err
818- }
819- if len(entries) == 0 {
820- if done {
821- return ports.ReconcileCursor{}, findings, nil
822- }
823- continue
824- }
825- entry := entries[0]
826- nextCursor = ports.ReconcileCursor{DirectoryCookie: uint64(entry.cookie)}
827- if !entry.isDir || isQuarantine(entry.name) {
828- resumeCursor = nextCursor
829- if done {
830- return ports.ReconcileCursor{}, findings, nil
831- }
832- continue
833- }
834- findingIndex := len(findings)
835- consumedBeforeCandidate := stepConsumed
836- var id domain.IncarnationID
837- if err := id.UnmarshalText([]byte(entry.name)); err != nil {
838- findings = append(findings, ports.ReconcileFinding{Kind: ports.ReconcileUnknownIncarnation, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{Name: entry.name}, Cursor: nextCursor, Consumed: ports.MaintenanceBudget{Entries: 1}})
839- } else if record, ok := byIncarnation[id]; !ok {
840- findings = append(findings, ports.ReconcileFinding{Kind: ports.ReconcileUnknownIncarnation, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{Name: entry.name, IncarnationID: id}, Cursor: nextCursor, Consumed: ports.MaintenanceBudget{Entries: 1}})
841- } else {
842- remaining := ports.MaintenanceBudget{Entries: budget.Entries - stepConsumed.Entries, Bytes: budget.Bytes - stepConsumed.Bytes}
843- finding := r.reconcileIncarnation(ctx, record, remaining)
844- finding.Cursor = nextCursor
845- findings = append(findings, finding)
846- }
847- if len(findings) > findingIndex {
848- stepConsumed.Entries += findings[findingIndex].Consumed.Entries
849- stepConsumed.Bytes += findings[findingIndex].Consumed.Bytes
850- if findings[findingIndex].Status == ports.ReconcileBudgetExhausted {
851- // If a fresh per-call budget cannot admit this candidate, retrying
852- // the same fixed budget can never succeed. Report the bounded
853- // exhaustion once and deliberately advance so later entries remain
854- // reachable. A candidate reached after earlier work is retried on a
855- // fresh call because it may fit that call's full budget.
856- if consumedBeforeCandidate.Entries == 0 && consumedBeforeCandidate.Bytes == 0 {
857- return nextCursor, findings, nil
858- }
859- return resumeCursor, findings, nil
860- }
861- }
862- resumeCursor = nextCursor
863- if done {
864- return ports.ReconcileCursor{}, findings, nil
865- }
866- }
867- return nextCursor, findings, nil
868-}
869-
870-func (r *Repository) reconcileIncarnation(ctx context.Context, record domain.CatalogueRecord, budget ports.MaintenanceBudget) ports.ReconcileFinding {
871- finding := ports.ReconcileFinding{Kind: ports.ReconcileInvalidCandidate, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{Name: record.Name, IncarnationID: record.IncarnationID}, Consumed: ports.MaintenanceBudget{Entries: 1}}
872- if record.Committed == nil {
873- return finding
874- }
875- headPath := r.headPath(record.IncarnationID)
876- info, err := r.stat(headPath)
877- if err != nil || info.Size() < 0 {
878- return finding
879- }
880- headBytes := uint64(info.Size())
881- if headBytes > budget.Bytes-finding.Consumed.Bytes {
882- finding.Status = ports.ReconcileBudgetExhausted
883- return finding
884- }
885- finding.Consumed.Bytes += headBytes
886- if hook := r.hooks.beforeMaintenancePayloadRead; hook != nil {
887- hook(headPath)
888- }
889- generation, digest, err := r.readHead(record.IncarnationID)
890- if err != nil {
891- return finding
892- }
893- candidateRef := domain.CheckpointRef{Generation: generation, ManifestDigest: digest}
894- finding.Candidate.Ref = candidateRef
895- if generation <= record.Committed.Generation {
896- finding.Status = ports.ReconcileValidated
897- return finding
898- }
899- finding.Kind = ports.ReconcileForwardOrphan
900-
901- candidate, exhausted, err := r.reconcileLoad(ctx, record, candidateRef, &finding.Consumed, budget)
902- if exhausted {
903- finding.Status = ports.ReconcileBudgetExhausted
904- return finding
905- }
906- if err != nil {
907- return finding
908- }
909- finding.Candidate.Parent = candidate.parent
910- parent := candidate.parent
911- for parent != nil && *parent != *record.Committed {
912- ancestor, exhausted, err := r.reconcileLoad(ctx, record, *parent, &finding.Consumed, budget)
913- if exhausted {
914- finding.Status = ports.ReconcileBudgetExhausted
915- return finding
916- }
917- if err != nil {
918- return finding
919- }
920- finding.AncestorChain = append(finding.AncestorChain, ports.ValidatedCheckpoint{Ref: *parent, Parent: ancestor.parent})
921- parent = ancestor.parent
922- }
923- if parent == nil {
924- return finding
925- }
926- finding.Status = ports.ReconcileValidated
927- return finding
928-}
929-
930-func (r *Repository) reconcileLoad(ctx context.Context, record domain.CatalogueRecord, ref domain.CheckpointRef, consumed *ports.MaintenanceBudget, budget ports.MaintenanceBudget) (validatedMaintenanceCheckpoint, bool, error) {
931- if consumed.Entries >= budget.Entries || consumed.Bytes >= budget.Bytes {
932- return validatedMaintenanceCheckpoint{}, true, nil
933- }
934- validated, bytes, admitted, err := r.validateMaintenanceCheckpoint(ctx, record.IncarnationID, ref, budget.Bytes-consumed.Bytes)
935- consumed.Bytes += bytes
936- if err != nil {
937- return validatedMaintenanceCheckpoint{}, false, err
938- }
939- if !admitted {
940- return validatedMaintenanceCheckpoint{}, true, nil
941- }
942- consumed.Entries++
943- return validated, false, nil
944-}
945-
946 func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool) map[ports.SnapshotDigest]struct{} {758 func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool) map[ports.SnapshotDigest]struct{} {
947 if conservative {759 if conservative {
948 references := make(map[ports.SnapshotDigest]struct{})760 references := make(map[ports.SnapshotDigest]struct{})
M internal/adapters/snapshot/maintenance_test.go +0 -41
@@ -1220,47 +1220,6 @@ func TestMaintainSessionDistinguishesInitialOversizeFromResumableExhaustion(t *t
1220 }1220 }
1221 }1221 }
1222 1222
1223-func TestReconcileDoesNotHoldMaintenanceLockDuringPayloadRead(t *testing.T) {
1224- repo := NewRepository(privateDir(t))
1225- publications := publishMaintenanceGenerations(t, repo, "reconcile-lock", 2)
1226- committed := checkpointRefForPublication(publications[0])
1227- record := domain.CatalogueRecord{Name: "reconcile-lock", IncarnationID: publications[0].IncarnationID, RecoveryState: domain.RecoveryHealthy, Committed: &committed}
1228-
1229- entered := make(chan struct{})
1230- release := make(chan struct{})
1231- var releaseOnce sync.Once
1232- defer releaseOnce.Do(func() { close(release) })
1233- var enteredOnce sync.Once
1234- repo.hooks.beforeMaintenancePayloadRead = func(string) {
1235- enteredOnce.Do(func() { close(entered) })
1236- <-release
1237- }
1238- reconcileDone := make(chan error, 1)
1239- go func() {
1240- _, _, err := repo.Reconcile(context.Background(), []domain.CatalogueRecord{record}, ports.ReconcileCursor{}, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1241- reconcileDone <- err
1242- }()
1243- <-entered
1244-
1245- pinDone := make(chan error, 1)
1246- go func() {
1247- _, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, ports.MaintenanceBudget{Entries: 1, Bytes: 1})
1248- pinDone <- err
1249- }()
1250- select {
1251- case err := <-pinDone:
1252- if err != nil {
1253- t.Fatal(err)
1254- }
1255- case <-time.After(remainingTestTime(t) / 2):
1256- t.Fatal("maintenance lock remained held during reconciliation payload I/O")
1257- }
1258- releaseOnce.Do(func() { close(release) })
1259- if err := <-reconcileDone; err != nil {
1260- t.Fatal(err)
1261- }
1262-}
1263-
1264 func TestMaintainSessionYieldsOnEmptyNonDoneObjectShardBatch(t *testing.T) {1223 func TestMaintainSessionYieldsOnEmptyNonDoneObjectShardBatch(t *testing.T) {
1265 repo := NewRepository(privateDir(t))1224 repo := NewRepository(privateDir(t))
1266 publication := publishMaintenanceGenerations(t, repo, "empty-shard-batch", 1)[0]1225 publication := publishMaintenanceGenerations(t, repo, "empty-shard-batch", 1)[0]
D internal/adapters/snapshot/reconciliation_test.go +0 -268
@@ -1,268 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "io"
6- "path/filepath"
7- "syscall"
8- "testing"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"
12- codec "github.com/bnema/vev/internal/usecase/snapshot"
13- "github.com/bnema/vev/pkg/safedir"
14- "github.com/stretchr/testify/require"
15-)
16-
17-type fakeReconcileDrainDirectory struct {
18- names []string
19- position int
20-}
21-
22-func (d *fakeReconcileDrainDirectory) Seek(offset int64, whence int) (int64, error) {
23- if whence == io.SeekCurrent {
24- return int64(d.position), nil
25- }
26- d.position = int(offset)
27- return offset, nil
28-}
29-
30-func (d *fakeReconcileDrainDirectory) ReadDirent(buffer []byte) (int, error) {
31- if d.position >= len(d.names) {
32- return 0, nil
33- }
34- batch := maintenanceDirentBatch(d.names[d.position:]...)
35- d.position = len(d.names)
36- return copy(buffer, batch), nil
37-}
38-
39-func (d *fakeReconcileDrainDirectory) Close() error { return nil }
40-
41-func configureDrainingReconcileScanner(repo *Repository, names []string) {
42- repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
43- return &fakeReconcileDrainDirectory{names: names}, nil
44- }
45- repo.hooks.maintenanceDirentConfig = &maintenanceDirentConfig{
46- drainBuffer: true,
47- cookie: func(file maintenanceDirectory, _ *syscall.Dirent, remaining int) (int64, error) {
48- end, err := file.Seek(0, io.SeekCurrent)
49- return end - int64(remaining), err
50- },
51- }
52-}
53-
54-func TestReconciliationDrainedBatchHasNoSkippedOrDuplicateEntries(t *testing.T) {
55- repo := NewRepository(privateDir(t))
56- sessions := filepath.Join(repo.dir, repositorySessionsDir)
57- names := make([]string, 3)
58- for i := range names {
59- id := domain.IncarnationID{byte(i + 20)}
60- names[i] = id.String()
61- require.NoError(t, safedir.EnsurePrivate(filepath.Join(sessions, names[i])))
62- }
63- configureDrainingReconcileScanner(repo, names)
64-
65- cursor := ports.ReconcileCursor{}
66- var got []string
67- for calls := 0; ; calls++ {
68- next, findings, err := repo.Reconcile(context.Background(), nil, cursor, ports.MaintenanceBudget{Entries: 2, Bytes: 1})
69- require.NoError(t, err)
70- for _, finding := range findings {
71- got = append(got, finding.Candidate.Name)
72- }
73- if next.DirectoryCookie == 0 {
74- break
75- }
76- require.Greater(t, next.DirectoryCookie, cursor.DirectoryCookie)
77- cursor = next
78- require.Less(t, calls, 3)
79- }
80- require.Equal(t, names, got)
81-}
82-
83-func TestReconciliationReadsEachCandidatePayloadOnceWithinBudget(t *testing.T) {
84- repo := NewRepository(privateDir(t))
85- first := publishMaintenanceGenerations(t, repo, "first", 2)
86- second := publishMaintenanceGenerations(t, repo, "second", 2)
87-
88- records := make([]domain.CatalogueRecord, 0, 2)
89- expectedPaths := make(map[string]struct{})
90- for _, publications := range [][]ports.SnapshotPublication{first, second} {
91- committed := checkpointRefForPublication(publications[0])
92- records = append(records, domain.CatalogueRecord{
93- Name: publications[0].Name,
94- IncarnationID: publications[0].IncarnationID,
95- RecoveryState: domain.RecoveryHealthy,
96- Committed: &committed,
97- })
98- candidate := checkpointRefForPublication(publications[1])
99- expectedPaths[repo.headPath(publications[0].IncarnationID)] = struct{}{}
100- expectedPaths[repo.manifestPath(publications[0].IncarnationID, candidate.Generation)] = struct{}{}
101- manifest, err := codec.UnmarshalManifest(publications[1].Manifest)
102- require.NoError(t, err)
103- for digest := range manifestRefs(manifest) {
104- expectedPaths[repo.objectPath(publications[0].IncarnationID, digest)] = struct{}{}
105- }
106- }
107-
108- var budgetBytes uint64
109- for path := range expectedPaths {
110- info, err := repo.stat(path)
111- require.NoError(t, err)
112- budgetBytes += uint64(info.Size())
113- }
114- reads := make(map[string]int)
115- var actualBytes uint64
116- repo.hooks.beforePayloadRead = func(path string) {
117- info, err := repo.stat(path)
118- require.NoError(t, err)
119- reads[path]++
120- actualBytes += uint64(info.Size())
121- }
122-
123- next, findings, err := repo.Reconcile(context.Background(), records, ports.ReconcileCursor{}, ports.MaintenanceBudget{Entries: 16, Bytes: budgetBytes})
124- require.NoError(t, err)
125- require.Zero(t, next)
126- require.Len(t, findings, 2)
127- var consumedBytes uint64
128- for _, finding := range findings {
129- require.Equal(t, ports.ReconcileForwardOrphan, finding.Kind)
130- require.Equal(t, ports.ReconcileValidated, finding.Status)
131- consumedBytes += finding.Consumed.Bytes
132- }
133- require.Equal(t, consumedBytes, actualBytes, "every payload read must be charged")
134- require.LessOrEqual(t, actualBytes, budgetBytes)
135- require.Equal(t, expectedPaths, func() map[string]struct{} {
136- got := make(map[string]struct{}, len(reads))
137- for path := range reads {
138- got[path] = struct{}{}
139- }
140- return got
141- }())
142- for path, count := range reads {
143- require.Equalf(t, 1, count, "payload %q was reread", path)
144- }
145-
146- reads = make(map[string]int)
147- actualBytes = 0
148- limitedBudget := budgetBytes - 1
149- _, findings, err = repo.Reconcile(context.Background(), records, ports.ReconcileCursor{}, ports.MaintenanceBudget{Entries: 16, Bytes: limitedBudget})
150- require.NoError(t, err)
151- consumedBytes = 0
152- exhausted := false
153- for _, finding := range findings {
154- consumedBytes += finding.Consumed.Bytes
155- exhausted = exhausted || finding.Status == ports.ReconcileBudgetExhausted
156- }
157- require.True(t, exhausted, "reconciliation must yield rather than read past its budget")
158- require.Equal(t, consumedBytes, actualBytes)
159- require.LessOrEqual(t, actualBytes, limitedBudget)
160- for path, count := range reads {
161- require.Equalf(t, 1, count, "payload %q was reread in a budget-limited step", path)
162- }
163-}
164-
165-func TestReconciliationIntrinsicallyOversizedCandidateMakesFixedBudgetProgress(t *testing.T) {
166- tests := []struct {
167- name string
168- budget ports.MaintenanceBudget
169- }{
170- {name: "entry cost", budget: ports.MaintenanceBudget{Entries: 1, Bytes: 8 << 20}},
171- {name: "byte cost", budget: ports.MaintenanceBudget{Entries: 8, Bytes: 1}},
172- }
173- for _, tc := range tests {
174- t.Run(tc.name, func(t *testing.T) {
175- repo := NewRepository(privateDir(t))
176- publications := publishMaintenanceGenerations(t, repo, "work", 2)
177- committed := checkpointRefForPublication(publications[0])
178- record := domain.CatalogueRecord{Name: "work", IncarnationID: publications[0].IncarnationID, RecoveryState: domain.RecoveryHealthy, Committed: &committed}
179- unknown := domain.IncarnationID{99}
180- require.NoError(t, safedir.EnsurePrivate(filepath.Join(repo.dir, repositorySessionsDir, unknown.String())))
181- configureDrainingReconcileScanner(repo, []string{record.IncarnationID.String(), unknown.String()})
182-
183- next, findings, err := repo.Reconcile(context.Background(), []domain.CatalogueRecord{record}, ports.ReconcileCursor{}, tc.budget)
184- require.NoError(t, err)
185- require.Equal(t, uint64(1), next.DirectoryCookie)
186- require.Len(t, findings, 1)
187- require.Equal(t, ports.ReconcileBudgetExhausted, findings[0].Status)
188- require.LessOrEqual(t, findings[0].Consumed.Entries, tc.budget.Entries)
189- require.LessOrEqual(t, findings[0].Consumed.Bytes, tc.budget.Bytes)
190-
191- later, findings, err := repo.Reconcile(context.Background(), []domain.CatalogueRecord{record}, next, tc.budget)
192- require.NoError(t, err)
193- require.Len(t, findings, 1)
194- require.Equal(t, unknown, findings[0].Candidate.IncarnationID)
195- require.Equal(t, ports.ReconcileQuarantined, findings[0].Status)
196- if later.DirectoryCookie == 0 {
197- return
198- }
199- require.Greater(t, later.DirectoryCookie, next.DirectoryCookie)
200-
201- done, findings, err := repo.Reconcile(context.Background(), []domain.CatalogueRecord{record}, later, tc.budget)
202- require.NoError(t, err)
203- require.Zero(t, done)
204- require.Empty(t, findings, "oversized candidate must not be retried")
205- })
206- }
207-}
208-
209-func TestReconciliationCursor(t *testing.T) {
210- repo := NewRepository(privateDir(t))
211- publications := publishMaintenanceGenerations(t, repo, "work", 2)
212- committed := checkpointRefForPublication(publications[0])
213- record := domain.CatalogueRecord{
214- Name: "work",
215- IncarnationID: publications[0].IncarnationID,
216- RecoveryState: domain.RecoveryHealthy,
217- Committed: &committed,
218- }
219-
220- sessions := filepath.Join(repo.dir, repositorySessionsDir)
221- for i := byte(20); i < 26; i++ {
222- id := domain.IncarnationID{i}
223- require.NoError(t, safedir.EnsurePrivate(filepath.Join(sessions, id.String())))
224- }
225-
226- cursor := ports.ReconcileCursor{}
227- var findings []ports.ReconcileFinding
228- calls := 0
229- for {
230- next, page, err := repo.Reconcile(context.Background(), []domain.CatalogueRecord{record}, cursor, ports.MaintenanceBudget{Entries: 2, Bytes: 8 << 20})
231- require.NoError(t, err)
232- for _, finding := range page {
233- require.LessOrEqual(t, finding.Consumed.Entries, uint64(2))
234- require.LessOrEqual(t, finding.Consumed.Bytes, uint64(8<<20))
235- }
236- findings = append(findings, page...)
237- calls++
238- if next.DirectoryCookie == 0 {
239- break
240- }
241- require.NotEqual(t, cursor, next, "reconciliation cursor must advance")
242- cursor = next
243- require.Less(t, calls, 10)
244- }
245- require.Greater(t, calls, 1, "top-level traversal must resume across bounded calls")
246-
247- unknown := make(map[domain.IncarnationID]struct{})
248- adoptable := 0
249- for _, finding := range findings {
250- switch finding.Kind {
251- case ports.ReconcileUnknownIncarnation:
252- require.Equal(t, ports.ReconcileQuarantined, finding.Status)
253- _, duplicate := unknown[finding.Candidate.IncarnationID]
254- require.False(t, duplicate, "unknown incarnation was returned twice")
255- unknown[finding.Candidate.IncarnationID] = struct{}{}
256- case ports.ReconcileForwardOrphan:
257- if finding.Status == ports.ReconcileBudgetExhausted {
258- continue
259- }
260- require.Equal(t, ports.ReconcileValidated, finding.Status)
261- require.Equal(t, uint64(2), finding.Candidate.Ref.Generation)
262- require.Equal(t, committed, *finding.Candidate.Parent)
263- adoptable++
264- }
265- }
266- require.Len(t, unknown, 6)
267- require.Equal(t, 1, adoptable, "candidate must reach a terminal result exactly once")
268-}
D internal/adapters/snapshot/recovery_crash_test.go +0 -149
@@ -1,149 +0,0 @@
1-package snapshot
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/vev/internal/adapters/recoveryfs"
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/persist"
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/usecase/recovery"
16- "github.com/bnema/vev/pkg/safedir"
17- "github.com/stretchr/testify/require"
18-)
19-
20-var errDeletionBoundaryCrash = errors.New("simulated process interruption")
21-
22-type recoveryCrashHarness struct {
23- stateDir string
24- snapshotDir string
25-}
26-
27-func newRecoveryCrashHarness(t *testing.T) recoveryCrashHarness {
28- t.Helper()
29- stateDir := t.TempDir()
30- require.NoError(t, os.Chmod(stateDir, 0o700))
31- return recoveryCrashHarness{stateDir: stateDir, snapshotDir: filepath.Join(stateDir, "snapshots")}
32-}
33-
34-func (h recoveryCrashHarness) open(t *testing.T) (*persist.Persister, *Repository, *recoveryfs.Journal) {
35- t.Helper()
36- store, err := persist.OpenStore(persist.StorePath(h.stateDir))
37- require.NoError(t, err)
38- return persist.New(store), NewRepository(h.snapshotDir), recoveryfs.New(h.stateDir)
39-}
40-
41-func TestDeletionRestartsAtInternalQuarantineBoundaries(t *testing.T) {
42- ctx := context.Background()
43- for _, tc := range []struct {
44- name string
45- inject func(*Repository)
46- }{
47- {name: "after incarnation namespace", inject: func(repository *Repository) {
48- repository.hooks.afterDeletionIncarnationQuarantine = func() error { return errDeletionBoundaryCrash }
49- }},
50- {name: "after legacy name", inject: func(repository *Repository) {
51- repository.hooks.afterDeletionLegacyQuarantine = func() error { return errDeletionBoundaryCrash }
52- }},
53- } {
54- t.Run(tc.name, func(t *testing.T) {
55- h := newRecoveryCrashHarness(t)
56- catalogue, repository, journal := h.open(t)
57- oldID := domain.IncarnationID{1}
58- record := domain.CatalogueRecord{Name: "work", IncarnationID: oldID, RecoveryState: domain.RecoveryFresh}
59- require.NoError(t, catalogue.Create(record))
60- sessionPath := repository.sessionPath(oldID)
61- require.NoError(t, safedir.EnsurePrivate(sessionPath))
62- require.NoError(t, os.WriteFile(filepath.Join(sessionPath, "durable"), []byte("old namespace"), 0o600))
63- require.NoError(t, os.WriteFile(filepath.Join(h.snapshotDir, filenameForName(record.Name)), []byte("old legacy"), 0o600))
64- tc.inject(repository)
65-
66- coordinator := recovery.NewCoordinator(catalogue, repository, journal, nil)
67- require.ErrorIs(t, coordinator.Delete(ctx, record.Name), errDeletionBoundaryCrash)
68- require.NoError(t, catalogue.Close())
69-
70- // Simulate a new process: none of the adapter or coordinator state survives.
71- catalogue, repository, journal = h.open(t)
72- require.NoError(t, recovery.NewCoordinator(catalogue, repository, journal, nil).Recover(ctx))
73- _, exists, err := catalogue.Record(record.Name)
74- require.NoError(t, err)
75- require.False(t, exists)
76- require.NoDirExists(t, repository.sessionPath(oldID))
77- require.Equal(t, []byte("old namespace"), mustReadCrashFile(t, filepath.Join(h.snapshotDir, "quarantine", oldID.String(), "snapshot", "durable")))
78- require.Equal(t, []byte("old legacy"), mustReadCrashFile(t, filepath.Join(h.snapshotDir, "quarantine", oldID.String(), "legacy.snap")))
79- page, err := repository.ListDeletionTombstones(ctx, ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 16, Bytes: 4096})
80- require.NoError(t, err)
81- require.Empty(t, page.Tombstones)
82-
83- // Reusing the name after roll-forward must be safe on another restart.
84- newID := domain.IncarnationID{2}
85- created, err := recovery.NewCoordinator(catalogue, repository, journal, bytes.NewReader(newID[:])).Create(ctx, domain.CatalogueRecord{Name: record.Name})
86- require.NoError(t, err)
87- require.Equal(t, newID, created.IncarnationID)
88- newPath := repository.sessionPath(newID)
89- require.NoError(t, safedir.EnsurePrivate(newPath))
90- require.NoError(t, os.WriteFile(filepath.Join(newPath, "durable"), []byte("new namespace"), 0o600))
91- require.NoError(t, catalogue.Close())
92-
93- catalogue, repository, journal = h.open(t)
94- require.NoError(t, recovery.NewCoordinator(catalogue, repository, journal, nil).Recover(ctx))
95- got, exists, err := catalogue.Record(record.Name)
96- require.NoError(t, err)
97- require.True(t, exists)
98- require.Equal(t, newID, got.IncarnationID)
99- require.Equal(t, []byte("new namespace"), mustReadCrashFile(t, filepath.Join(repository.sessionPath(newID), "durable")))
100- require.NoError(t, catalogue.Close())
101- })
102- }
103-}
104-
105-type rejectCreateCatalogue struct {
106- ports.Catalogue
107-}
108-
109-func (c rejectCreateCatalogue) Create(domain.CatalogueRecord) error {
110- return errDeletionBoundaryCrash
111-}
112-
113-func TestCreatePreCommitRestartLeavesNoAdoptableNamespace(t *testing.T) {
114- ctx := context.Background()
115- h := newRecoveryCrashHarness(t)
116- catalogue, repository, journal := h.open(t)
117- interruptedID := domain.IncarnationID{3}
118- _, err := recovery.NewCoordinator(rejectCreateCatalogue{Catalogue: catalogue}, repository, journal, bytes.NewReader(interruptedID[:])).Create(ctx, domain.CatalogueRecord{Name: "work"})
119- require.ErrorIs(t, err, errDeletionBoundaryCrash)
120- records, err := catalogue.Records()
121- require.NoError(t, err)
122- require.Empty(t, records)
123- require.NoDirExists(t, filepath.Join(h.snapshotDir, repositorySessionsDir))
124- require.NoError(t, catalogue.Close())
125-
126- catalogue, repository, journal = h.open(t)
127- require.NoError(t, recovery.NewCoordinator(catalogue, repository, journal, nil).Recover(ctx))
128- records, err = catalogue.Records()
129- require.NoError(t, err)
130- require.Empty(t, records)
131- require.NoDirExists(t, repository.sessionPath(interruptedID))
132-
133- retryID := domain.IncarnationID{4}
134- created, err := recovery.NewCoordinator(catalogue, repository, journal, bytes.NewReader(retryID[:])).Create(ctx, domain.CatalogueRecord{Name: "work"})
135- require.NoError(t, err)
136- require.Equal(t, retryID, created.IncarnationID)
137- records, err = catalogue.Records()
138- require.NoError(t, err)
139- require.Len(t, records, 1)
140- require.NoDirExists(t, repository.sessionPath(interruptedID))
141- require.NoError(t, catalogue.Close())
142-}
143-
144-func mustReadCrashFile(t *testing.T, path string) []byte {
145- t.Helper()
146- data, err := os.ReadFile(path)
147- require.NoError(t, err)
148- return data
149-}
M internal/adapters/snapshot/repository.go +0 -4
@@ -78,10 +78,6 @@ type repositoryHooks struct {
78 maintenanceDirentConfig *maintenanceDirentConfig78 maintenanceDirentConfig *maintenanceDirentConfig
79 beforeDirectoryRead func(string)79 beforeDirectoryRead func(string)
80 beforeDeletionTombstoneRead func(string)80 beforeDeletionTombstoneRead func(string)
81- // afterDeletionIncarnationQuarantine and afterDeletionLegacyQuarantine expose
82- // the two completed deletion rename+sync boundaries to package tests.
83- afterDeletionIncarnationQuarantine func() error
84- afterDeletionLegacyQuarantine func() error
85 // beforeMaintenanceWork observes each budgeted quarantine filesystem step.81 // beforeMaintenanceWork observes each budgeted quarantine filesystem step.
86 // It is test-only instrumentation for hostile traversal bounds.82 // It is test-only instrumentation for hostile traversal bounds.
87 beforeMaintenanceWork func(string)83 beforeMaintenanceWork func(string)
M internal/app/daemon_integration_test.go +16 -14
@@ -23,7 +23,6 @@ import (
23 "github.com/bnema/vev/internal/adapters/ipc"23 "github.com/bnema/vev/internal/adapters/ipc"
24 "github.com/bnema/vev/internal/adapters/lifecycle"24 "github.com/bnema/vev/internal/adapters/lifecycle"
25 "github.com/bnema/vev/internal/adapters/pty"25 "github.com/bnema/vev/internal/adapters/pty"
26- "github.com/bnema/vev/internal/adapters/recoveryfs"
27 "github.com/bnema/vev/internal/adapters/snapshot"26 "github.com/bnema/vev/internal/adapters/snapshot"
28 "github.com/bnema/vev/internal/domain"27 "github.com/bnema/vev/internal/domain"
29 "github.com/bnema/vev/internal/persist"28 "github.com/bnema/vev/internal/persist"
@@ -534,17 +533,19 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
534 Catalogue: newTestPersister(t, stateDir),533 Catalogue: newTestPersister(t, stateDir),
535 closed: make(chan struct{}),534 closed: make(chan struct{}),
536 }535 }
536+ require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}))
537 repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))537 repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
538 shutdownClock := newLifecycleShutdownClock(t)538 shutdownClock := newLifecycleShutdownClock(t)
539- reconciler := &lifecycleBlockingReconciler{539+ maintenanceRepository := &lifecycleBlockingMaintenanceRepository{
540- entered: make(chan struct{}),540+ SnapshotRepository: repository,
541- release: make(chan struct{}),541+ entered: make(chan struct{}),
542- returned: make(chan struct{}),542+ release: make(chan struct{}),
543+ returned: make(chan struct{}),
543 }544 }
544 ctx, cancel := context.WithCancel(context.Background())545 ctx, cancel := context.WithCancel(context.Background())
545 t.Cleanup(cancel)546 t.Cleanup(cancel)
546 var releaseOnce sync.Once547 var releaseOnce sync.Once
547- releaseMaintenance := func() { releaseOnce.Do(func() { close(reconciler.release) }) }548+ releaseMaintenance := func() { releaseOnce.Do(func() { close(maintenanceRepository.release) }) }
548 t.Cleanup(releaseMaintenance)549 t.Cleanup(releaseMaintenance)
549 550
550 listenerReady := make(chan struct{})551 listenerReady := make(chan struct{})
@@ -563,7 +564,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
563 shutdownClock,564 shutdownClock,
564 discardLog(),565 discardLog(),
565 daemon.WithSnapshotRepository(repository),566 daemon.WithSnapshotRepository(repository),
566- daemon.WithDurableMaintenance(catalogue, repository, reconciler, nil),567+ daemon.WithDurableMaintenance(catalogue, maintenanceRepository, nil),
567 daemon.WithCatalogue(catalogue, nil),568 daemon.WithCatalogue(catalogue, nil),
568 )569 )
569 return d.Serve(ctx, listener)570 return d.Serve(ctx, listener)
@@ -571,7 +572,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
571 }()572 }()
572 573
573 awaitLifecycleStage(t, listenerReady, "daemon listener")574 awaitLifecycleStage(t, listenerReady, "daemon listener")
574- awaitLifecycleStage(t, reconciler.entered, "maintenance repository call")575+ awaitLifecycleStage(t, maintenanceRepository.entered, "maintenance repository call")
575 cancel()576 cancel()
576 shutdownClock.nextTimer(t).fire()577 shutdownClock.nextTimer(t).fire()
577 select {578 select {
@@ -585,7 +586,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
585 require.ErrorIs(t, err, lifecycle.ErrBusy)586 require.ErrorIs(t, err, lifecycle.ErrBusy)
586 587
587 releaseMaintenance()588 releaseMaintenance()
588- awaitLifecycleStage(t, reconciler.returned, "maintenance repository return")589+ awaitLifecycleStage(t, maintenanceRepository.returned, "maintenance repository return")
589 awaitLifecycleStage(t, catalogue.closed, "catalogue close after maintenance")590 awaitLifecycleStage(t, catalogue.closed, "catalogue close after maintenance")
590 awaitLifecycleStage(t, callbackReturned, "Serve callback return after catalogue close")591 awaitLifecycleStage(t, callbackReturned, "Serve callback return after catalogue close")
591 require.NoError(t, awaitLifecycleResult(t, wrapperReturned, "lifecycle wrapper return"))592 require.NoError(t, awaitLifecycleResult(t, wrapperReturned, "lifecycle wrapper return"))
@@ -594,7 +595,8 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
594 require.NoError(t, owner.Release())595 require.NoError(t, owner.Release())
595 }596 }
596 597
597-type lifecycleBlockingReconciler struct {598+type lifecycleBlockingMaintenanceRepository struct {
599+ ports.SnapshotRepository
598 entered chan struct{}600 entered chan struct{}
599 release chan struct{}601 release chan struct{}
600 returned chan struct{}602 returned chan struct{}
@@ -602,11 +604,11 @@ type lifecycleBlockingReconciler struct {
602 returnOnce sync.Once604 returnOnce sync.Once
603 }605 }
604 606
605-func (r *lifecycleBlockingReconciler) Step(context.Context, ports.ReconcileCursor) (ports.ReconcileCursor, []ports.ReconcileDecision, error) {607+func (r *lifecycleBlockingMaintenanceRepository) MaintainSession(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error) {
606 r.enterOnce.Do(func() { close(r.entered) })608 r.enterOnce.Do(func() { close(r.entered) })
607 <-r.release // Intentionally ignore cancellation: lifecycle ownership must outlive this call.609 <-r.release // Intentionally ignore cancellation: lifecycle ownership must outlive this call.
608 r.returnOnce.Do(func() { close(r.returned) })610 r.returnOnce.Do(func() { close(r.returned) })
609- return ports.ReconcileCursor{}, nil, nil611+ return true, nil
610 }612 }
611 613
612 type lifecycleObservedCatalogue struct {614 type lifecycleObservedCatalogue struct {
@@ -743,7 +745,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
743 745
744 opened, err := persist.OpenOrCreate(stateDir)746 opened, err := persist.OpenOrCreate(stateDir)
745 require.NoError(t, err)747 require.NoError(t, err)
746- coordinator := recovery.NewCoordinator(opened.Catalogue, repository, recoveryfs.New(stateDir), rand.Reader)748+ coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
747 require.NoError(t, coordinator.Recover(ctx))749 require.NoError(t, coordinator.Recover(ctx))
748 listener, err := ipc.Listen(runtimeDir)750 listener, err := ipc.Listen(runtimeDir)
749 require.NoError(t, err)751 require.NoError(t, err)
@@ -1054,7 +1056,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
1054 if err != nil {1056 if err != nil {
1055 return err1057 return err
1056 }1058 }
1057- coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, recoveryfs.New(stateDir), rand.Reader)1059+ coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader)
1058 if err := coordinator.Recover(ctx); err != nil {1060 if err := coordinator.Recover(ctx); err != nil {
1059 return errors.Join(err, opened.Catalogue.Close())1061 return errors.Join(err, opened.Catalogue.Close())
1060 }1062 }
D internal/app/durable_crash_matrix_test.go +0 -389
@@ -1,389 +0,0 @@
1-package app
2-
3-import (
4- "bytes"
5- "context"
6- "crypto/sha256"
7- "errors"
8- "os"
9- "path/filepath"
10- "testing"
11-
12- "github.com/bnema/vev/internal/adapters/recoveryfs"
13- "github.com/bnema/vev/internal/adapters/snapshot"
14- "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/persist"
16- "github.com/bnema/vev/internal/ports"
17- "github.com/bnema/vev/internal/usecase/recovery"
18- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
19- "github.com/stretchr/testify/require"
20-)
21-
22-var errInjectedCrash = errors.New("injected crash after durable boundary")
23-
24-type durableCrashHarness struct {
25- stateDir string
26- snapshotDir string
27-}
28-
29-func newDurableCrashHarness(t *testing.T) durableCrashHarness {
30- t.Helper()
31- stateDir := t.TempDir()
32- require.NoError(t, os.Chmod(stateDir, 0o700))
33- return durableCrashHarness{stateDir: stateDir, snapshotDir: filepath.Join(stateDir, "snapshots")}
34-}
35-
36-func (h durableCrashHarness) open(t *testing.T) (*persist.Persister, *snapshot.Repository, *recoveryfs.Journal) {
37- t.Helper()
38- store, err := persist.OpenStore(persist.StorePath(h.stateDir))
39- require.NoError(t, err)
40- return persist.New(store), snapshot.NewRepository(h.snapshotDir), recoveryfs.New(h.stateDir)
41-}
42-
43-func (h durableCrashHarness) recoverTwice(ctx context.Context, t *testing.T, assert func(*persist.Persister, *snapshot.Repository, *recoveryfs.Journal)) {
44- t.Helper()
45- for attempt := range 2 {
46- catalogue, repository, journal := h.open(t)
47- coordinator := recovery.NewCoordinator(catalogue, repository, journal, nil)
48- require.NoError(t, coordinator.Recover(ctx), "recovery attempt %d", attempt+1)
49- assert(catalogue, repository, journal)
50- require.NoError(t, catalogue.Close())
51- }
52-}
53-
54-type durableBoundary struct {
55- want string
56- triggered bool
57-}
58-
59-func (b *durableBoundary) after(name string, err error) error {
60- if err != nil || name != b.want {
61- return err
62- }
63- b.triggered = true
64- return errInjectedCrash
65-}
66-
67-type crashCatalogue struct {
68- ports.Catalogue
69- boundary *durableBoundary
70-}
71-
72-func (c crashCatalogue) Create(record domain.CatalogueRecord) error {
73- return c.boundary.after("catalogue-create", c.Catalogue.Create(record))
74-}
75-func (c crashCatalogue) Replace(name string, record domain.CatalogueRecord) error {
76- return c.boundary.after("catalogue-replace", c.Catalogue.Replace(name, record))
77-}
78-func (c crashCatalogue) Rename(oldName string, record domain.CatalogueRecord) error {
79- return c.boundary.after("catalogue-rename", c.Catalogue.Rename(oldName, record))
80-}
81-func (c crashCatalogue) Delete(name string) error {
82- return c.boundary.after("catalogue-delete", c.Catalogue.Delete(name))
83-}
84-
85-type crashRepository struct {
86- ports.SnapshotRepository
87- boundary *durableBoundary
88-}
89-
90-func (r crashRepository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {
91- return r.boundary.after("snapshot-publish", r.SnapshotRepository.Publish(ctx, publication))
92-}
93-func (r crashRepository) WriteDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
94- return r.boundary.after("deletion-tombstone", r.SnapshotRepository.WriteDeletionTombstone(ctx, tombstone))
95-}
96-func (r crashRepository) QuarantineDeletionSources(ctx context.Context, tombstone domain.DeletionTombstone, includeLegacy bool) error {
97- return r.boundary.after("deletion-quarantine", r.SnapshotRepository.QuarantineDeletionSources(ctx, tombstone, includeLegacy))
98-}
99-func (r crashRepository) DeleteDeletionTombstone(ctx context.Context, id domain.IncarnationID) error {
100- return r.boundary.after("deletion-tombstone-delete", r.SnapshotRepository.DeleteDeletionTombstone(ctx, id))
101-}
102-func (r crashRepository) SaveQuarantineDescriptor(ctx context.Context, descriptor domain.QuarantineDescriptor) error {
103- return r.boundary.after("discard-descriptor", r.SnapshotRepository.SaveQuarantineDescriptor(ctx, descriptor))
104-}
105-func (r crashRepository) QuarantineIncarnation(ctx context.Context, id domain.IncarnationID) error {
106- return r.boundary.after("discard-quarantine", r.SnapshotRepository.QuarantineIncarnation(ctx, id))
107-}
108-
109-type crashJournal struct {
110- ports.RecoveryJournal
111- boundary *durableBoundary
112-}
113-
114-func (j crashJournal) SaveDiscard(ctx context.Context, intent domain.DiscardIntent) error {
115- return j.boundary.after("discard-intent", j.RecoveryJournal.SaveDiscard(ctx, intent))
116-}
117-func (j crashJournal) DeleteDiscard(ctx context.Context, id domain.IncarnationID) error {
118- return j.boundary.after("discard-intent-delete", j.RecoveryJournal.DeleteDiscard(ctx, id))
119-}
120-
121-func TestDurableTransactionCrashMatrix(t *testing.T) {
122- ctx := context.Background()
123-
124- t.Run("create/catalogue-create", func(t *testing.T) {
125- h := newDurableCrashHarness(t)
126- catalogue, repository, journal := h.open(t)
127- boundary := &durableBoundary{want: "catalogue-create"}
128- coordinator := recovery.NewCoordinator(crashCatalogue{Catalogue: catalogue, boundary: boundary}, repository, journal, bytes.NewReader(bytes.Repeat([]byte{1}, 16)))
129- _, err := coordinator.Create(ctx, domain.CatalogueRecord{Name: "work", Cwd: "/tmp"})
130- require.ErrorIs(t, err, errInjectedCrash)
131- require.True(t, boundary.triggered)
132- require.NoError(t, catalogue.Close())
133-
134- h.recoverTwice(ctx, t, func(catalogue *persist.Persister, _ *snapshot.Repository, _ *recoveryfs.Journal) {
135- record, exists, err := catalogue.Record("work")
136- require.NoError(t, err)
137- require.True(t, exists)
138- require.Equal(t, domain.IncarnationID{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, record.IncarnationID)
139- require.Equal(t, domain.RecoveryFresh, record.RecoveryState)
140- })
141- })
142-
143- for _, boundaryName := range []string{"snapshot-publish", "catalogue-replace"} {
144- t.Run("checkpoint/"+boundaryName, func(t *testing.T) {
145- h := newDurableCrashHarness(t)
146- catalogue, repository, journal := h.open(t)
147- record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryFresh}
148- require.NoError(t, catalogue.Create(record))
149- publication := durablePublication(t, record.Name, record.IncarnationID, []byte("checkpoint"))
150- boundary := &durableBoundary{want: boundaryName}
151- coordinator := recovery.NewCoordinator(
152- crashCatalogue{Catalogue: catalogue, boundary: boundary},
153- crashRepository{SnapshotRepository: repository, boundary: boundary},
154- journal,
155- nil,
156- )
157- _, err := coordinator.PublishCheckpoint(ctx, record.Name, publication)
158- require.ErrorIs(t, err, errInjectedCrash)
159- require.True(t, boundary.triggered)
160- require.NoError(t, catalogue.Close())
161-
162- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(publication.Manifest)}
163- h.recoverTwice(ctx, t, func(catalogue *persist.Persister, repository *snapshot.Repository, _ *recoveryfs.Journal) {
164- stored, exists, err := catalogue.Record(record.Name)
165- require.NoError(t, err)
166- require.True(t, exists)
167- if boundaryName == "snapshot-publish" {
168- require.Equal(t, domain.RecoveryFresh, stored.RecoveryState)
169- require.Nil(t, stored.Committed)
170- } else {
171- require.Equal(t, domain.RecoveryHealthy, stored.RecoveryState)
172- require.Equal(t, ref, *stored.Committed)
173- }
174- generation, err := repository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, ref)
175- require.NoError(t, err)
176- require.Equal(t, publication.Manifest, generation.Manifest)
177- })
178- })
179- }
180-
181- t.Run("rename/catalogue-rename", func(t *testing.T) {
182- h := newDurableCrashHarness(t)
183- catalogue, repository, journal := h.open(t)
184- record := domain.CatalogueRecord{Name: "old", IncarnationID: domain.IncarnationID{3}, RecoveryState: domain.RecoveryFresh}
185- require.NoError(t, catalogue.Create(record))
186- boundary := &durableBoundary{want: "catalogue-rename"}
187- coordinator := recovery.NewCoordinator(crashCatalogue{Catalogue: catalogue, boundary: boundary}, repository, journal, nil)
188- _, err := coordinator.Rename(ctx, "old", "new")
189- require.ErrorIs(t, err, errInjectedCrash)
190- require.True(t, boundary.triggered)
191- require.NoError(t, catalogue.Close())
192-
193- h.recoverTwice(ctx, t, func(catalogue *persist.Persister, _ *snapshot.Repository, _ *recoveryfs.Journal) {
194- _, oldExists, err := catalogue.Record("old")
195- require.NoError(t, err)
196- renamed, newExists, err := catalogue.Record("new")
197- require.NoError(t, err)
198- require.False(t, oldExists)
199- require.True(t, newExists)
200- require.Equal(t, record.IncarnationID, renamed.IncarnationID)
201- })
202- })
203-
204- deleteBoundaries := []string{"catalogue-replace", "deletion-tombstone", "deletion-quarantine", "catalogue-delete", "deletion-tombstone-delete", "transaction-complete"}
205- for _, boundaryName := range deleteBoundaries {
206- t.Run("delete/"+boundaryName, func(t *testing.T) {
207- h := newDurableCrashHarness(t)
208- catalogue, repository, journal := h.open(t)
209- record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryFresh}
210- require.NoError(t, catalogue.Create(record))
211- publication := durablePublication(t, record.Name, record.IncarnationID, []byte("delete-source"))
212- require.NoError(t, repository.Publish(ctx, publication))
213- legacy := []byte{0, 1, 2, 3, 0xff}
214- require.NoError(t, os.WriteFile(filepath.Join(h.snapshotDir, "work.snap"), legacy, 0o600))
215- sourceBytes := readTree(t, filepath.Join(h.snapshotDir, "sessions", record.IncarnationID.String()))
216-
217- boundary := &durableBoundary{want: boundaryName}
218- coordinator := recovery.NewCoordinator(
219- crashCatalogue{Catalogue: catalogue, boundary: boundary},
220- crashRepository{SnapshotRepository: repository, boundary: boundary},
221- journal,
222- nil,
223- )
224- err := coordinator.Delete(ctx, record.Name)
225- if boundaryName == "transaction-complete" {
226- require.NoError(t, err)
227- boundary.triggered = true // interruption before the caller transfers lifecycle ownership
228- } else {
229- require.ErrorIs(t, err, errInjectedCrash)
230- }
231- require.True(t, boundary.triggered)
232- require.NoError(t, catalogue.Close())
233-
234- h.recoverTwice(ctx, t, func(catalogue *persist.Persister, repository *snapshot.Repository, _ *recoveryfs.Journal) {
235- _, exists, err := catalogue.Record(record.Name)
236- require.NoError(t, err)
237- require.False(t, exists)
238- assertNoTombstones(ctx, t, repository)
239- require.NoDirExists(t, filepath.Join(h.snapshotDir, "sessions", record.IncarnationID.String()))
240- require.Equal(t, sourceBytes, readTree(t, filepath.Join(h.snapshotDir, "quarantine", record.IncarnationID.String(), "snapshot")))
241- require.Equal(t, legacy, mustReadFile(t, filepath.Join(h.snapshotDir, "quarantine", record.IncarnationID.String(), "legacy.snap")))
242- })
243- })
244- }
245-}
246-
247-func TestDurableDiscardCrashMatrix(t *testing.T) {
248- ctx := context.Background()
249- oldID := domain.IncarnationID{5}
250- newID := domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}
251- boundaries := []string{"discard-intent", "discard-descriptor", "discard-quarantine", "catalogue-replace", "discard-intent-delete", "runtime-exposure"}
252-
253- for _, boundaryName := range boundaries {
254- t.Run(boundaryName, func(t *testing.T) {
255- h := newDurableCrashHarness(t)
256- catalogue, repository, journal := h.open(t)
257- publication := durablePublication(t, "broken", oldID, []byte("discard-source"))
258- require.NoError(t, repository.Publish(ctx, publication))
259- ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(publication.Manifest)}
260- old := domain.CatalogueRecord{Name: "broken", IncarnationID: oldID, RecoveryState: domain.RecoveryDegraded, Committed: ref, DegradedReason: "checkpoint unreadable"}
261- require.NoError(t, catalogue.Create(old))
262- sourceBytes := readTree(t, filepath.Join(h.snapshotDir, "sessions", oldID.String()))
263-
264- boundary := &durableBoundary{want: boundaryName}
265- coordinator := recovery.NewCoordinator(
266- crashCatalogue{Catalogue: catalogue, boundary: boundary},
267- crashRepository{SnapshotRepository: repository, boundary: boundary},
268- crashJournal{RecoveryJournal: journal, boundary: boundary},
269- bytes.NewReader(newID[:]),
270- )
271- replacement, err := coordinator.Discard(ctx, old.Name, "explicit discard")
272- if boundaryName == "runtime-exposure" {
273- require.NoError(t, err)
274- require.Equal(t, newID, replacement.IncarnationID)
275- boundary.triggered = true // crash before the returned runtime is installed
276- } else {
277- require.ErrorIs(t, err, errInjectedCrash)
278- }
279- require.True(t, boundary.triggered)
280- require.NoError(t, catalogue.Close())
281-
282- h.recoverTwice(ctx, t, func(catalogue *persist.Persister, _ *snapshot.Repository, journal *recoveryfs.Journal) {
283- got, exists, err := catalogue.Record(old.Name)
284- require.NoError(t, err)
285- require.True(t, exists)
286- require.Equal(t, newID, got.IncarnationID)
287- require.Equal(t, domain.RecoveryFresh, got.RecoveryState)
288- require.Nil(t, got.Committed)
289- require.NoDirExists(t, filepath.Join(h.snapshotDir, "sessions", oldID.String()))
290- require.Equal(t, sourceBytes, readTree(t, filepath.Join(h.snapshotDir, "quarantine", oldID.String(), "snapshot")))
291- intents, err := journal.ListDiscards(ctx)
292- require.NoError(t, err)
293- require.Empty(t, intents)
294- require.FileExists(t, filepath.Join(h.snapshotDir, "quarantine", oldID.String(), "record"))
295- })
296- })
297- }
298-}
299-
300-func TestDeletionRecoveryProtectsReusedNameFilesystemBytes(t *testing.T) {
301- ctx := context.Background()
302- h := newDurableCrashHarness(t)
303- catalogue, repository, _ := h.open(t)
304- oldID := domain.IncarnationID{6}
305- replacementID := domain.IncarnationID{7}
306- oldPublication := durablePublication(t, "work", oldID, []byte("old-incarnation"))
307- replacementPublication := durablePublication(t, "work", replacementID, []byte("replacement-incarnation"))
308- require.NoError(t, repository.Publish(ctx, oldPublication))
309- require.NoError(t, repository.Publish(ctx, replacementPublication))
310- replacement := domain.CatalogueRecord{Name: "work", IncarnationID: replacementID, RecoveryState: domain.RecoveryFresh}
311- require.NoError(t, catalogue.Create(replacement))
312- require.NoError(t, repository.WriteDeletionTombstone(ctx, domain.DeletionTombstone{Name: "work", IncarnationID: oldID}))
313- legacy := []byte{0xde, 0xad, 0x00, 0xbe, 0xef}
314- require.NoError(t, os.WriteFile(filepath.Join(h.snapshotDir, "work.snap"), legacy, 0o600))
315- oldBytes := readTree(t, filepath.Join(h.snapshotDir, "sessions", oldID.String()))
316- replacementPath := filepath.Join(h.snapshotDir, "sessions", replacementID.String())
317- replacementBytes := readTree(t, replacementPath)
318- require.NoError(t, catalogue.Close())
319-
320- h.recoverTwice(ctx, t, func(catalogue *persist.Persister, repository *snapshot.Repository, _ *recoveryfs.Journal) {
321- got, exists, err := catalogue.Record("work")
322- require.NoError(t, err)
323- require.True(t, exists)
324- require.Equal(t, replacement, got)
325- require.Equal(t, replacementBytes, readTree(t, replacementPath))
326- require.Equal(t, legacy, mustReadFile(t, filepath.Join(h.snapshotDir, "work.snap")))
327- require.Equal(t, oldBytes, readTree(t, filepath.Join(h.snapshotDir, "quarantine", oldID.String(), "snapshot")))
328- require.NoDirExists(t, filepath.Join(h.snapshotDir, "sessions", oldID.String()))
329- assertNoTombstones(ctx, t, repository)
330- })
331-}
332-
333-func durablePublication(t *testing.T, name string, id domain.IncarnationID, payload []byte) ports.SnapshotPublication {
334- t.Helper()
335- tail, err := snapcodec.MarshalObject(snapcodec.HistoryTail, payload)
336- require.NoError(t, err)
337- visible, err := snapcodec.MarshalObject(snapcodec.Visible, []byte("visible"))
338- require.NoError(t, err)
339- manifest, err := snapcodec.MarshalManifest(snapcodec.Manifest{
340- Generation: 1, IncarnationID: id, Name: name,
341- Tabs: []snapcodec.ManifestTab{{Cols: 1, Rows: 1, Panes: []snapcodec.ManifestPane{{
342- ID: "p", Tail: snapcodec.ObjectRef{Kind: snapcodec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))},
343- Visible: snapcodec.ObjectRef{Kind: snapcodec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))},
344- }}}},
345- })
346- require.NoError(t, err)
347- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: 1, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
348-}
349-
350-func assertNoTombstones(ctx context.Context, t *testing.T, repository *snapshot.Repository) {
351- t.Helper()
352- page, err := repository.ListDeletionTombstones(ctx, ports.DeletionTombstoneCursor{}, ports.MaintenanceBudget{Entries: 64, Bytes: 64 << 10})
353- require.NoError(t, err)
354- require.True(t, page.Done)
355- require.Empty(t, page.Tombstones)
356-}
357-
358-func mustReadFile(t *testing.T, path string) []byte {
359- t.Helper()
360- data, err := os.ReadFile(path)
361- require.NoError(t, err)
362- return data
363-}
364-
365-func readTree(t *testing.T, root string) map[string][]byte {
366- t.Helper()
367- files := make(map[string][]byte)
368- err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
369- if err != nil {
370- return err
371- }
372- if entry.IsDir() {
373- return nil
374- }
375- relative, err := filepath.Rel(root, path)
376- if err != nil {
377- return err
378- }
379- data, err := os.ReadFile(path)
380- if err != nil {
381- return err
382- }
383- files[relative] = data
384- return nil
385- })
386- require.NoError(t, err)
387- require.NotEmpty(t, files)
388- return files
389-}
M internal/app/offline_kill_retry_test.go +2 -2
@@ -75,8 +75,8 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
75 }75 }
76 76
77 require.NoError(t, runKill(context.Background(), "named", false, false))77 require.NoError(t, runKill(context.Background(), "named", false, false))
78- require.NoFileExists(t, filepath.Join(source, "sentinel"))78+ require.NoDirExists(t, source)
79- require.FileExists(t, filepath.Join(stateDir, "snapshots", "quarantine", id.String(), "snapshot", "sentinel"))79+ require.NoDirExists(t, filepath.Join(stateDir, "snapshots", "quarantine", id.String()))
80 })80 })
81 }81 }
82 }82 }
M internal/app/run.go +5 -19
@@ -36,7 +36,6 @@ import (
36 "github.com/bnema/vev/internal/adapters/noticefile"36 "github.com/bnema/vev/internal/adapters/noticefile"
37 "github.com/bnema/vev/internal/adapters/observability"37 "github.com/bnema/vev/internal/adapters/observability"
38 "github.com/bnema/vev/internal/adapters/pty"38 "github.com/bnema/vev/internal/adapters/pty"
39- "github.com/bnema/vev/internal/adapters/recoveryfs"
40 remoteadapter "github.com/bnema/vev/internal/adapters/remote"39 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
41 "github.com/bnema/vev/internal/adapters/shellcmd"40 "github.com/bnema/vev/internal/adapters/shellcmd"
42 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"41 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
@@ -521,20 +520,13 @@ type interruptedRecoveryIdentity struct {
521 id domain.IncarnationID520 id domain.IncarnationID
522 }521 }
523 522
524-func pendingInterruptedRecoveries(ctx context.Context, records []domain.CatalogueRecord, journal ports.RecoveryJournal, repository ports.SnapshotRepository) ([]interruptedRecoveryIdentity, error) {523+func pendingInterruptedRecoveries(ctx context.Context, records []domain.CatalogueRecord, repository ports.SnapshotRepository) ([]interruptedRecoveryIdentity, error) {
525 pending := make([]interruptedRecoveryIdentity, 0)524 pending := make([]interruptedRecoveryIdentity, 0)
526 for _, record := range records {525 for _, record := range records {
527 if record.RecoveryState == domain.RecoveryDeleting {526 if record.RecoveryState == domain.RecoveryDeleting {
528 pending = append(pending, interruptedRecoveryIdentity{name: record.Name, id: record.IncarnationID})527 pending = append(pending, interruptedRecoveryIdentity{name: record.Name, id: record.IncarnationID})
529 }528 }
530 }529 }
531- intents, err := journal.ListDiscards(ctx)
532- if err != nil {
533- return nil, err
534- }
535- for _, intent := range intents {
536- pending = append(pending, interruptedRecoveryIdentity{name: intent.SessionName, id: intent.OldIncarnation})
537- }
538 cursor := ports.DeletionTombstoneCursor{}530 cursor := ports.DeletionTombstoneCursor{}
539 for {531 for {
540 page, err := repository.ListDeletionTombstones(ctx, cursor, ports.MaintenanceBudget{Entries: 64, Bytes: 64 << 10})532 page, err := repository.ListDeletionTombstones(ctx, cursor, ports.MaintenanceBudget{Entries: 64, Bytes: 64 << 10})
@@ -602,10 +594,6 @@ func fencedRecoveries(conflicts []recovery.RecoveryConflict, catalogue ports.Cat
602 594
603 func fencedRecoveryReasonCode(err error) string {595 func fencedRecoveryReasonCode(err error) string {
604 switch {596 switch {
605- case errors.Is(err, recovery.ErrDiscardConflict):
606- return "discard-intent-conflict"
607- case errors.Is(err, recovery.ErrDiscardIntentInvalid):
608- return "discard-intent-invalid"
609 case errors.Is(err, recovery.ErrDeletionTombstoneConflict):597 case errors.Is(err, recovery.ErrDeletionTombstoneConflict):
610 return "deletion-tombstone-conflict"598 return "deletion-tombstone-conflict"
611 case errors.Is(err, recovery.ErrDeletionTombstoneInvalid):599 case errors.Is(err, recovery.ErrDeletionTombstoneInvalid):
@@ -738,12 +726,11 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
738 recoveryMode = "new-install"726 recoveryMode = "new-install"
739 }727 }
740 logCatalogueRecovery(log, opened.Records, recoveryMode)728 logCatalogueRecovery(log, opened.Records, recoveryMode)
741- journal := recoveryfs.New(platform.StateDir())729+ pendingRecoveries, err := pendingInterruptedRecoveries(ctx, opened.Records, snapshotRepository)
742- pendingRecoveries, err := pendingInterruptedRecoveries(ctx, opened.Records, journal, snapshotRepository)
743 if err != nil {730 if err != nil {
744 return errors.Join(fmt.Errorf("vev: inspect durable session transactions: %w", err), opened.Catalogue.Close())731 return errors.Join(fmt.Errorf("vev: inspect durable session transactions: %w", err), opened.Catalogue.Close())
745 }732 }
746- coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, journal, rand.Reader)733+ coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader)
747 // Recovery may roll catalogue changes forward, so reload the authoritative734 // Recovery may roll catalogue changes forward, so reload the authoritative
748 // snapshot before constructing runtime reservations. A failed reload aborts735 // snapshot before constructing runtime reservations. A failed reload aborts
749 // startup rather than treating durable sessions as absent.736 // startup rather than treating durable sessions as absent.
@@ -774,8 +761,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
774 for _, pending := range pendingRecoveries {761 for _, pending := range pendingRecoveries {
775 unresolved = append(unresolved, pending.id)762 unresolved = append(unresolved, pending.id)
776 }763 }
777- reconciler := recovery.NewReconciler(coordinator, opened.Catalogue, snapshotRepository, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})764+ daemonOpts = append(daemonOpts, daemon.WithDurableMaintenance(opened.Catalogue, snapshotRepository, unresolved))
778- daemonOpts = append(daemonOpts, daemon.WithDurableMaintenance(opened.Catalogue, snapshotRepository, reconciler, unresolved))
779 log.Info("session persistence enabled", "path", storePath)765 log.Info("session persistence enabled", "path", storePath)
780 daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records))766 daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records))
781 767
@@ -1577,7 +1563,7 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
1577 } else if !ok {1563 } else if !ok {
1578 return fmt.Errorf("vev: no such session: %s", name)1564 return fmt.Errorf("vev: no such session: %s", name)
1579 }1565 }
1580- coordinator := recovery.NewCoordinator(opened.Catalogue, repository, recoveryfs.New(platform.StateDir()), rand.Reader)1566+ coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
1581 if err := coordinator.Recover(ctx); err != nil {1567 if err := coordinator.Recover(ctx); err != nil {
1582 return fmt.Errorf("vev: recovering stored session transactions: %w", err)1568 return fmt.Errorf("vev: recovering stored session transactions: %w", err)
1583 }1569 }
M internal/app/run_test.go +1 -2
@@ -21,7 +21,6 @@ import (
21 21
22 "github.com/bnema/vev/internal/adapters/lifecycle"22 "github.com/bnema/vev/internal/adapters/lifecycle"
23 "github.com/bnema/vev/internal/adapters/noticefile"23 "github.com/bnema/vev/internal/adapters/noticefile"
24- "github.com/bnema/vev/internal/adapters/recoveryfs"
25 "github.com/bnema/vev/internal/adapters/snapshot"24 "github.com/bnema/vev/internal/adapters/snapshot"
26 "github.com/bnema/vev/internal/domain"25 "github.com/bnema/vev/internal/domain"
27 "github.com/bnema/vev/internal/persist"26 "github.com/bnema/vev/internal/persist"
@@ -132,7 +131,7 @@ func TestStartupReloadsCatalogueAfterRolledForwardDeletion(t *testing.T) {
132 CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryDeleting,131 CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryDeleting,
133 }132 }
134 require.NoError(t, catalogue.Save(record))133 require.NoError(t, catalogue.Save(record))
135- coordinator := recovery.NewCoordinator(catalogue, repository, recoveryfs.New(stateDir), rand.Reader)134+ coordinator := recovery.NewCoordinator(catalogue, repository, rand.Reader)
136 135
137 records, err := recoverAuthoritativeCatalogue(ctx, coordinator, catalogue)136 records, err := recoverAuthoritativeCatalogue(ctx, coordinator, catalogue)
138 require.NoError(t, err)137 require.NoError(t, err)
M internal/domain/recovery.go +0 -18
@@ -81,24 +81,6 @@ func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate {
81 }81 }
82 }82 }
83 83
84-// DiscardIntent records a requested replacement of uncertain persisted state.
85-type DiscardIntent struct {
86- OldRecord CatalogueRecord
87- OldIncarnation IncarnationID
88- NewIncarnation IncarnationID
89- SessionName string
90- Reason string
91-}
92-
93-// QuarantineDescriptor records persisted state retained during replacement.
94-type QuarantineDescriptor struct {
95- OldRecord CatalogueRecord
96- OldIncarnation IncarnationID
97- ReplacementIncarnation IncarnationID
98- SessionName string
99- Reason string
100-}
101-
102 // DeletionTombstone identifies exactly one deleted session incarnation.84 // DeletionTombstone identifies exactly one deleted session incarnation.
103 type DeletionTombstone struct {85 type DeletionTombstone struct {
104 Name string86 Name string
M internal/ports/mocks/recovery_mock.go +13 -494
@@ -12,209 +12,6 @@ import (
12 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
13 )13 )
14 14
15-// NewMockRecoveryJournal creates a new instance of MockRecoveryJournal. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16-// The first argument is typically a *testing.T value.
17-func NewMockRecoveryJournal(t interface {
18- mock.TestingT
19- Cleanup(func())
20-}) *MockRecoveryJournal {
21- mock := &MockRecoveryJournal{}
22- mock.Mock.Test(t)
23-
24- t.Cleanup(func() { mock.AssertExpectations(t) })
25-
26- return mock
27-}
28-
29-// MockRecoveryJournal is an autogenerated mock type for the RecoveryJournal type
30-type MockRecoveryJournal struct {
31- mock.Mock
32-}
33-
34-type MockRecoveryJournal_Expecter struct {
35- mock *mock.Mock
36-}
37-
38-func (_m *MockRecoveryJournal) EXPECT() *MockRecoveryJournal_Expecter {
39- return &MockRecoveryJournal_Expecter{mock: &_m.Mock}
40-}
41-
42-// DeleteDiscard provides a mock function for the type MockRecoveryJournal
43-func (_mock *MockRecoveryJournal) DeleteDiscard(context1 context.Context, incarnationID domain.IncarnationID) error {
44- ret := _mock.Called(context1, incarnationID)
45-
46- if len(ret) == 0 {
47- panic("no return value specified for DeleteDiscard")
48- }
49-
50- var r0 error
51- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID) error); ok {
52- r0 = returnFunc(context1, incarnationID)
53- } else {
54- r0 = ret.Error(0)
55- }
56- return r0
57-}
58-
59-// MockRecoveryJournal_DeleteDiscard_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteDiscard'
60-type MockRecoveryJournal_DeleteDiscard_Call struct {
61- *mock.Call
62-}
63-
64-// DeleteDiscard is a helper method to define mock.On call
65-// - context1 context.Context
66-// - incarnationID domain.IncarnationID
67-func (_e *MockRecoveryJournal_Expecter) DeleteDiscard(context1 any, incarnationID any) *MockRecoveryJournal_DeleteDiscard_Call {
68- return &MockRecoveryJournal_DeleteDiscard_Call{Call: _e.mock.On("DeleteDiscard", context1, incarnationID)}
69-}
70-
71-func (_c *MockRecoveryJournal_DeleteDiscard_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID)) *MockRecoveryJournal_DeleteDiscard_Call {
72- _c.Call.Run(func(args mock.Arguments) {
73- var arg0 context.Context
74- if args[0] != nil {
75- arg0 = args[0].(context.Context)
76- }
77- var arg1 domain.IncarnationID
78- if args[1] != nil {
79- arg1 = args[1].(domain.IncarnationID)
80- }
81- run(
82- arg0,
83- arg1,
84- )
85- })
86- return _c
87-}
88-
89-func (_c *MockRecoveryJournal_DeleteDiscard_Call) Return(err error) *MockRecoveryJournal_DeleteDiscard_Call {
90- _c.Call.Return(err)
91- return _c
92-}
93-
94-func (_c *MockRecoveryJournal_DeleteDiscard_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID) error) *MockRecoveryJournal_DeleteDiscard_Call {
95- _c.Call.Return(run)
96- return _c
97-}
98-
99-// ListDiscards provides a mock function for the type MockRecoveryJournal
100-func (_mock *MockRecoveryJournal) ListDiscards(context1 context.Context) ([]domain.DiscardIntent, error) {
101- ret := _mock.Called(context1)
102-
103- if len(ret) == 0 {
104- panic("no return value specified for ListDiscards")
105- }
106-
107- var r0 []domain.DiscardIntent
108- var r1 error
109- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.DiscardIntent, error)); ok {
110- return returnFunc(context1)
111- }
112- if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.DiscardIntent); ok {
113- r0 = returnFunc(context1)
114- } else {
115- if ret.Get(0) != nil {
116- r0 = ret.Get(0).([]domain.DiscardIntent)
117- }
118- }
119- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
120- r1 = returnFunc(context1)
121- } else {
122- r1 = ret.Error(1)
123- }
124- return r0, r1
125-}
126-
127-// MockRecoveryJournal_ListDiscards_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListDiscards'
128-type MockRecoveryJournal_ListDiscards_Call struct {
129- *mock.Call
130-}
131-
132-// ListDiscards is a helper method to define mock.On call
133-// - context1 context.Context
134-func (_e *MockRecoveryJournal_Expecter) ListDiscards(context1 any) *MockRecoveryJournal_ListDiscards_Call {
135- return &MockRecoveryJournal_ListDiscards_Call{Call: _e.mock.On("ListDiscards", context1)}
136-}
137-
138-func (_c *MockRecoveryJournal_ListDiscards_Call) Run(run func(context1 context.Context)) *MockRecoveryJournal_ListDiscards_Call {
139- _c.Call.Run(func(args mock.Arguments) {
140- var arg0 context.Context
141- if args[0] != nil {
142- arg0 = args[0].(context.Context)
143- }
144- run(
145- arg0,
146- )
147- })
148- return _c
149-}
150-
151-func (_c *MockRecoveryJournal_ListDiscards_Call) Return(discardIntents []domain.DiscardIntent, err error) *MockRecoveryJournal_ListDiscards_Call {
152- _c.Call.Return(discardIntents, err)
153- return _c
154-}
155-
156-func (_c *MockRecoveryJournal_ListDiscards_Call) RunAndReturn(run func(context1 context.Context) ([]domain.DiscardIntent, error)) *MockRecoveryJournal_ListDiscards_Call {
157- _c.Call.Return(run)
158- return _c
159-}
160-
161-// SaveDiscard provides a mock function for the type MockRecoveryJournal
162-func (_mock *MockRecoveryJournal) SaveDiscard(context1 context.Context, discardIntent domain.DiscardIntent) error {
163- ret := _mock.Called(context1, discardIntent)
164-
165- if len(ret) == 0 {
166- panic("no return value specified for SaveDiscard")
167- }
168-
169- var r0 error
170- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.DiscardIntent) error); ok {
171- r0 = returnFunc(context1, discardIntent)
172- } else {
173- r0 = ret.Error(0)
174- }
175- return r0
176-}
177-
178-// MockRecoveryJournal_SaveDiscard_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveDiscard'
179-type MockRecoveryJournal_SaveDiscard_Call struct {
180- *mock.Call
181-}
182-
183-// SaveDiscard is a helper method to define mock.On call
184-// - context1 context.Context
185-// - discardIntent domain.DiscardIntent
186-func (_e *MockRecoveryJournal_Expecter) SaveDiscard(context1 any, discardIntent any) *MockRecoveryJournal_SaveDiscard_Call {
187- return &MockRecoveryJournal_SaveDiscard_Call{Call: _e.mock.On("SaveDiscard", context1, discardIntent)}
188-}
189-
190-func (_c *MockRecoveryJournal_SaveDiscard_Call) Run(run func(context1 context.Context, discardIntent domain.DiscardIntent)) *MockRecoveryJournal_SaveDiscard_Call {
191- _c.Call.Run(func(args mock.Arguments) {
192- var arg0 context.Context
193- if args[0] != nil {
194- arg0 = args[0].(context.Context)
195- }
196- var arg1 domain.DiscardIntent
197- if args[1] != nil {
198- arg1 = args[1].(domain.DiscardIntent)
199- }
200- run(
201- arg0,
202- arg1,
203- )
204- })
205- return _c
206-}
207-
208-func (_c *MockRecoveryJournal_SaveDiscard_Call) Return(err error) *MockRecoveryJournal_SaveDiscard_Call {
209- _c.Call.Return(err)
210- return _c
211-}
212-
213-func (_c *MockRecoveryJournal_SaveDiscard_Call) RunAndReturn(run func(context1 context.Context, discardIntent domain.DiscardIntent) error) *MockRecoveryJournal_SaveDiscard_Call {
214- _c.Call.Return(run)
215- return _c
216-}
217-
218 // NewMockCheckpointCoordinator creates a new instance of MockCheckpointCoordinator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.15 // NewMockCheckpointCoordinator creates a new instance of MockCheckpointCoordinator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
219 // The first argument is typically a *testing.T value.16 // The first argument is typically a *testing.T value.
220 func NewMockCheckpointCoordinator(t interface {17 func NewMockCheckpointCoordinator(t interface {
@@ -636,29 +433,20 @@ func (_m *MockDegradedRecoveryCoordinator) EXPECT() *MockDegradedRecoveryCoordin
636 }433 }
637 434
638 // Discard provides a mock function for the type MockDegradedRecoveryCoordinator435 // Discard provides a mock function for the type MockDegradedRecoveryCoordinator
639-func (_mock *MockDegradedRecoveryCoordinator) Discard(context1 context.Context, s string, s1 string) (domain.CatalogueRecord, error) {436+func (_mock *MockDegradedRecoveryCoordinator) Discard(context1 context.Context, s string) error {
640- ret := _mock.Called(context1, s, s1)437+ ret := _mock.Called(context1, s)
641 438
642 if len(ret) == 0 {439 if len(ret) == 0 {
643 panic("no return value specified for Discard")440 panic("no return value specified for Discard")
644 }441 }
645 442
646- var r0 domain.CatalogueRecord443+ var r0 error
647- var r1 error444+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
648- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (domain.CatalogueRecord, error)); ok {445+ r0 = returnFunc(context1, s)
649- return returnFunc(context1, s, s1)
650- }
651- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) domain.CatalogueRecord); ok {
652- r0 = returnFunc(context1, s, s1)
653- } else {
654- r0 = ret.Get(0).(domain.CatalogueRecord)
655- }
656- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
657- r1 = returnFunc(context1, s, s1)
658 } else {446 } else {
659- r1 = ret.Error(1)447+ r0 = ret.Error(0)
660 }448 }
661- return r0, r1449+ return r0
662 }450 }
663 451
664 // MockDegradedRecoveryCoordinator_Discard_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Discard'452 // MockDegradedRecoveryCoordinator_Discard_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Discard'
@@ -669,12 +457,11 @@ type MockDegradedRecoveryCoordinator_Discard_Call struct {
669 // Discard is a helper method to define mock.On call457 // Discard is a helper method to define mock.On call
670 // - context1 context.Context458 // - context1 context.Context
671 // - s string459 // - s string
672-// - s1 string460+func (_e *MockDegradedRecoveryCoordinator_Expecter) Discard(context1 any, s any) *MockDegradedRecoveryCoordinator_Discard_Call {
673-func (_e *MockDegradedRecoveryCoordinator_Expecter) Discard(context1 any, s any, s1 any) *MockDegradedRecoveryCoordinator_Discard_Call {461+ return &MockDegradedRecoveryCoordinator_Discard_Call{Call: _e.mock.On("Discard", context1, s)}
674- return &MockDegradedRecoveryCoordinator_Discard_Call{Call: _e.mock.On("Discard", context1, s, s1)}
675 }462 }
676 463
677-func (_c *MockDegradedRecoveryCoordinator_Discard_Call) Run(run func(context1 context.Context, s string, s1 string)) *MockDegradedRecoveryCoordinator_Discard_Call {464+func (_c *MockDegradedRecoveryCoordinator_Discard_Call) Run(run func(context1 context.Context, s string)) *MockDegradedRecoveryCoordinator_Discard_Call {
678 _c.Call.Run(func(args mock.Arguments) {465 _c.Call.Run(func(args mock.Arguments) {
679 var arg0 context.Context466 var arg0 context.Context
680 if args[0] != nil {467 if args[0] != nil {
@@ -684,25 +471,20 @@ func (_c *MockDegradedRecoveryCoordinator_Discard_Call) Run(run func(context1 co
684 if args[1] != nil {471 if args[1] != nil {
685 arg1 = args[1].(string)472 arg1 = args[1].(string)
686 }473 }
687- var arg2 string
688- if args[2] != nil {
689- arg2 = args[2].(string)
690- }
691 run(474 run(
692 arg0,475 arg0,
693 arg1,476 arg1,
694- arg2,
695 )477 )
696 })478 })
697 return _c479 return _c
698 }480 }
699 481
700-func (_c *MockDegradedRecoveryCoordinator_Discard_Call) Return(catalogueRecord domain.CatalogueRecord, err error) *MockDegradedRecoveryCoordinator_Discard_Call {482+func (_c *MockDegradedRecoveryCoordinator_Discard_Call) Return(err error) *MockDegradedRecoveryCoordinator_Discard_Call {
701- _c.Call.Return(catalogueRecord, err)483+ _c.Call.Return(err)
702 return _c484 return _c
703 }485 }
704 486
705-func (_c *MockDegradedRecoveryCoordinator_Discard_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string) (domain.CatalogueRecord, error)) *MockDegradedRecoveryCoordinator_Discard_Call {487+func (_c *MockDegradedRecoveryCoordinator_Discard_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockDegradedRecoveryCoordinator_Discard_Call {
706 _c.Call.Return(run)488 _c.Call.Return(run)
707 return _c489 return _c
708 }490 }
@@ -1586,212 +1368,6 @@ func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 co
1586 return _c1368 return _c
1587 }1369 }
1588 1370
1589-// QuarantineDeletionSources provides a mock function for the type MockSnapshotRepository
1590-func (_mock *MockSnapshotRepository) QuarantineDeletionSources(context1 context.Context, deletionTombstone domain.DeletionTombstone, b bool) error {
1591- ret := _mock.Called(context1, deletionTombstone, b)
1592-
1593- if len(ret) == 0 {
1594- panic("no return value specified for QuarantineDeletionSources")
1595- }
1596-
1597- var r0 error
1598- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.DeletionTombstone, bool) error); ok {
1599- r0 = returnFunc(context1, deletionTombstone, b)
1600- } else {
1601- r0 = ret.Error(0)
1602- }
1603- return r0
1604-}
1605-
1606-// MockSnapshotRepository_QuarantineDeletionSources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QuarantineDeletionSources'
1607-type MockSnapshotRepository_QuarantineDeletionSources_Call struct {
1608- *mock.Call
1609-}
1610-
1611-// QuarantineDeletionSources is a helper method to define mock.On call
1612-// - context1 context.Context
1613-// - deletionTombstone domain.DeletionTombstone
1614-// - b bool
1615-func (_e *MockSnapshotRepository_Expecter) QuarantineDeletionSources(context1 any, deletionTombstone any, b any) *MockSnapshotRepository_QuarantineDeletionSources_Call {
1616- return &MockSnapshotRepository_QuarantineDeletionSources_Call{Call: _e.mock.On("QuarantineDeletionSources", context1, deletionTombstone, b)}
1617-}
1618-
1619-func (_c *MockSnapshotRepository_QuarantineDeletionSources_Call) Run(run func(context1 context.Context, deletionTombstone domain.DeletionTombstone, b bool)) *MockSnapshotRepository_QuarantineDeletionSources_Call {
1620- _c.Call.Run(func(args mock.Arguments) {
1621- var arg0 context.Context
1622- if args[0] != nil {
1623- arg0 = args[0].(context.Context)
1624- }
1625- var arg1 domain.DeletionTombstone
1626- if args[1] != nil {
1627- arg1 = args[1].(domain.DeletionTombstone)
1628- }
1629- var arg2 bool
1630- if args[2] != nil {
1631- arg2 = args[2].(bool)
1632- }
1633- run(
1634- arg0,
1635- arg1,
1636- arg2,
1637- )
1638- })
1639- return _c
1640-}
1641-
1642-func (_c *MockSnapshotRepository_QuarantineDeletionSources_Call) Return(err error) *MockSnapshotRepository_QuarantineDeletionSources_Call {
1643- _c.Call.Return(err)
1644- return _c
1645-}
1646-
1647-func (_c *MockSnapshotRepository_QuarantineDeletionSources_Call) RunAndReturn(run func(context1 context.Context, deletionTombstone domain.DeletionTombstone, b bool) error) *MockSnapshotRepository_QuarantineDeletionSources_Call {
1648- _c.Call.Return(run)
1649- return _c
1650-}
1651-
1652-// QuarantineIncarnation provides a mock function for the type MockSnapshotRepository
1653-func (_mock *MockSnapshotRepository) QuarantineIncarnation(context1 context.Context, incarnationID domain.IncarnationID) error {
1654- ret := _mock.Called(context1, incarnationID)
1655-
1656- if len(ret) == 0 {
1657- panic("no return value specified for QuarantineIncarnation")
1658- }
1659-
1660- var r0 error
1661- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID) error); ok {
1662- r0 = returnFunc(context1, incarnationID)
1663- } else {
1664- r0 = ret.Error(0)
1665- }
1666- return r0
1667-}
1668-
1669-// MockSnapshotRepository_QuarantineIncarnation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QuarantineIncarnation'
1670-type MockSnapshotRepository_QuarantineIncarnation_Call struct {
1671- *mock.Call
1672-}
1673-
1674-// QuarantineIncarnation is a helper method to define mock.On call
1675-// - context1 context.Context
1676-// - incarnationID domain.IncarnationID
1677-func (_e *MockSnapshotRepository_Expecter) QuarantineIncarnation(context1 any, incarnationID any) *MockSnapshotRepository_QuarantineIncarnation_Call {
1678- return &MockSnapshotRepository_QuarantineIncarnation_Call{Call: _e.mock.On("QuarantineIncarnation", context1, incarnationID)}
1679-}
1680-
1681-func (_c *MockSnapshotRepository_QuarantineIncarnation_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID)) *MockSnapshotRepository_QuarantineIncarnation_Call {
1682- _c.Call.Run(func(args mock.Arguments) {
1683- var arg0 context.Context
1684- if args[0] != nil {
1685- arg0 = args[0].(context.Context)
1686- }
1687- var arg1 domain.IncarnationID
1688- if args[1] != nil {
1689- arg1 = args[1].(domain.IncarnationID)
1690- }
1691- run(
1692- arg0,
1693- arg1,
1694- )
1695- })
1696- return _c
1697-}
1698-
1699-func (_c *MockSnapshotRepository_QuarantineIncarnation_Call) Return(err error) *MockSnapshotRepository_QuarantineIncarnation_Call {
1700- _c.Call.Return(err)
1701- return _c
1702-}
1703-
1704-func (_c *MockSnapshotRepository_QuarantineIncarnation_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID) error) *MockSnapshotRepository_QuarantineIncarnation_Call {
1705- _c.Call.Return(run)
1706- return _c
1707-}
1708-
1709-// Reconcile provides a mock function for the type MockSnapshotRepository
1710-func (_mock *MockSnapshotRepository) Reconcile(context1 context.Context, catalogueRecords []domain.CatalogueRecord, reconcileCursor ports.ReconcileCursor, maintenanceBudget ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) {
1711- ret := _mock.Called(context1, catalogueRecords, reconcileCursor, maintenanceBudget)
1712-
1713- if len(ret) == 0 {
1714- panic("no return value specified for Reconcile")
1715- }
1716-
1717- var r0 ports.ReconcileCursor
1718- var r1 []ports.ReconcileFinding
1719- var r2 error
1720- if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error)); ok {
1721- return returnFunc(context1, catalogueRecords, reconcileCursor, maintenanceBudget)
1722- }
1723- if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) ports.ReconcileCursor); ok {
1724- r0 = returnFunc(context1, catalogueRecords, reconcileCursor, maintenanceBudget)
1725- } else {
1726- r0 = ret.Get(0).(ports.ReconcileCursor)
1727- }
1728- if returnFunc, ok := ret.Get(1).(func(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) []ports.ReconcileFinding); ok {
1729- r1 = returnFunc(context1, catalogueRecords, reconcileCursor, maintenanceBudget)
1730- } else {
1731- if ret.Get(1) != nil {
1732- r1 = ret.Get(1).([]ports.ReconcileFinding)
1733- }
1734- }
1735- if returnFunc, ok := ret.Get(2).(func(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) error); ok {
1736- r2 = returnFunc(context1, catalogueRecords, reconcileCursor, maintenanceBudget)
1737- } else {
1738- r2 = ret.Error(2)
1739- }
1740- return r0, r1, r2
1741-}
1742-
1743-// MockSnapshotRepository_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile'
1744-type MockSnapshotRepository_Reconcile_Call struct {
1745- *mock.Call
1746-}
1747-
1748-// Reconcile is a helper method to define mock.On call
1749-// - context1 context.Context
1750-// - catalogueRecords []domain.CatalogueRecord
1751-// - reconcileCursor ports.ReconcileCursor
1752-// - maintenanceBudget ports.MaintenanceBudget
1753-func (_e *MockSnapshotRepository_Expecter) Reconcile(context1 any, catalogueRecords any, reconcileCursor any, maintenanceBudget any) *MockSnapshotRepository_Reconcile_Call {
1754- return &MockSnapshotRepository_Reconcile_Call{Call: _e.mock.On("Reconcile", context1, catalogueRecords, reconcileCursor, maintenanceBudget)}
1755-}
1756-
1757-func (_c *MockSnapshotRepository_Reconcile_Call) Run(run func(context1 context.Context, catalogueRecords []domain.CatalogueRecord, reconcileCursor ports.ReconcileCursor, maintenanceBudget ports.MaintenanceBudget)) *MockSnapshotRepository_Reconcile_Call {
1758- _c.Call.Run(func(args mock.Arguments) {
1759- var arg0 context.Context
1760- if args[0] != nil {
1761- arg0 = args[0].(context.Context)
1762- }
1763- var arg1 []domain.CatalogueRecord
1764- if args[1] != nil {
1765- arg1 = args[1].([]domain.CatalogueRecord)
1766- }
1767- var arg2 ports.ReconcileCursor
1768- if args[2] != nil {
1769- arg2 = args[2].(ports.ReconcileCursor)
1770- }
1771- var arg3 ports.MaintenanceBudget
1772- if args[3] != nil {
1773- arg3 = args[3].(ports.MaintenanceBudget)
1774- }
1775- run(
1776- arg0,
1777- arg1,
1778- arg2,
1779- arg3,
1780- )
1781- })
1782- return _c
1783-}
1784-
1785-func (_c *MockSnapshotRepository_Reconcile_Call) Return(reconcileCursor1 ports.ReconcileCursor, reconcileFindings []ports.ReconcileFinding, err error) *MockSnapshotRepository_Reconcile_Call {
1786- _c.Call.Return(reconcileCursor1, reconcileFindings, err)
1787- return _c
1788-}
1789-
1790-func (_c *MockSnapshotRepository_Reconcile_Call) RunAndReturn(run func(context1 context.Context, catalogueRecords []domain.CatalogueRecord, reconcileCursor ports.ReconcileCursor, maintenanceBudget ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error)) *MockSnapshotRepository_Reconcile_Call {
1791- _c.Call.Return(run)
1792- return _c
1793-}
1794-
1795 // RepairHEAD provides a mock function for the type MockSnapshotRepository1371 // RepairHEAD provides a mock function for the type MockSnapshotRepository
1796 func (_mock *MockSnapshotRepository) RepairHEAD(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error {1372 func (_mock *MockSnapshotRepository) RepairHEAD(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error {
1797 ret := _mock.Called(context1, incarnationID, v)1373 ret := _mock.Called(context1, incarnationID, v)
@@ -1855,63 +1431,6 @@ func (_c *MockSnapshotRepository_RepairHEAD_Call) RunAndReturn(run func(context1
1855 return _c1431 return _c
1856 }1432 }
1857 1433
1858-// SaveQuarantineDescriptor provides a mock function for the type MockSnapshotRepository
1859-func (_mock *MockSnapshotRepository) SaveQuarantineDescriptor(context1 context.Context, quarantineDescriptor domain.QuarantineDescriptor) error {
1860- ret := _mock.Called(context1, quarantineDescriptor)
1861-
1862- if len(ret) == 0 {
1863- panic("no return value specified for SaveQuarantineDescriptor")
1864- }
1865-
1866- var r0 error
1867- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.QuarantineDescriptor) error); ok {
1868- r0 = returnFunc(context1, quarantineDescriptor)
1869- } else {
1870- r0 = ret.Error(0)
1871- }
1872- return r0
1873-}
1874-
1875-// MockSnapshotRepository_SaveQuarantineDescriptor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveQuarantineDescriptor'
1876-type MockSnapshotRepository_SaveQuarantineDescriptor_Call struct {
1877- *mock.Call
1878-}
1879-
1880-// SaveQuarantineDescriptor is a helper method to define mock.On call
1881-// - context1 context.Context
1882-// - quarantineDescriptor domain.QuarantineDescriptor
1883-func (_e *MockSnapshotRepository_Expecter) SaveQuarantineDescriptor(context1 any, quarantineDescriptor any) *MockSnapshotRepository_SaveQuarantineDescriptor_Call {
1884- return &MockSnapshotRepository_SaveQuarantineDescriptor_Call{Call: _e.mock.On("SaveQuarantineDescriptor", context1, quarantineDescriptor)}
1885-}
1886-
1887-func (_c *MockSnapshotRepository_SaveQuarantineDescriptor_Call) Run(run func(context1 context.Context, quarantineDescriptor domain.QuarantineDescriptor)) *MockSnapshotRepository_SaveQuarantineDescriptor_Call {
1888- _c.Call.Run(func(args mock.Arguments) {
1889- var arg0 context.Context
1890- if args[0] != nil {
1891- arg0 = args[0].(context.Context)
1892- }
1893- var arg1 domain.QuarantineDescriptor
1894- if args[1] != nil {
1895- arg1 = args[1].(domain.QuarantineDescriptor)
1896- }
1897- run(
1898- arg0,
1899- arg1,
1900- )
1901- })
1902- return _c
1903-}
1904-
1905-func (_c *MockSnapshotRepository_SaveQuarantineDescriptor_Call) Return(err error) *MockSnapshotRepository_SaveQuarantineDescriptor_Call {
1906- _c.Call.Return(err)
1907- return _c
1908-}
1909-
1910-func (_c *MockSnapshotRepository_SaveQuarantineDescriptor_Call) RunAndReturn(run func(context1 context.Context, quarantineDescriptor domain.QuarantineDescriptor) error) *MockSnapshotRepository_SaveQuarantineDescriptor_Call {
1911- _c.Call.Return(run)
1912- return _c
1913-}
1914-
1915 // WriteDeletionTombstone provides a mock function for the type MockSnapshotRepository1434 // WriteDeletionTombstone provides a mock function for the type MockSnapshotRepository
1916 func (_mock *MockSnapshotRepository) WriteDeletionTombstone(context1 context.Context, deletionTombstone domain.DeletionTombstone) error {1435 func (_mock *MockSnapshotRepository) WriteDeletionTombstone(context1 context.Context, deletionTombstone domain.DeletionTombstone) error {
1917 ret := _mock.Called(context1, deletionTombstone)1436 ret := _mock.Called(context1, deletionTombstone)
M internal/ports/recovery.go +1 -68
@@ -38,8 +38,6 @@ type MaintenanceBudget struct {
38 Bytes uint6438 Bytes uint64
39 }39 }
40 40
41-type ReconcileCursor struct{ DirectoryCookie uint64 }
42-
43 type DeletionTombstoneCursor struct{ After string }41 type DeletionTombstoneCursor struct{ After string }
44 42
45 type DeletionTombstonePage struct {43 type DeletionTombstonePage struct {
@@ -48,69 +46,8 @@ type DeletionTombstonePage struct {
48 Done bool46 Done bool
49 }47 }
50 48
51-type ReconcileFindingKind uint8
52-
53-const (
54- ReconcileForwardOrphan ReconcileFindingKind = iota + 1
55- ReconcileUnknownIncarnation
56- ReconcileInvalidCandidate
57-)
58-
59-type ReconcileValidationStatus uint8
60-
61-const (
62- ReconcileValidated ReconcileValidationStatus = iota + 1
63- ReconcileQuarantined
64- ReconcileBudgetExhausted
65-)
66-
67-type ReconcileCandidate struct {
68- IncarnationID domain.IncarnationID
69- Name string
70- Ref domain.CheckpointRef
71- Parent *domain.CheckpointRef
72-}
73-
74-type ValidatedCheckpoint struct {
75- Ref domain.CheckpointRef
76- Parent *domain.CheckpointRef
77-}
78-
79-type ReconcileFinding struct {
80- Kind ReconcileFindingKind
81- Status ReconcileValidationStatus
82- Candidate ReconcileCandidate
83- AncestorChain []ValidatedCheckpoint
84- Cursor ReconcileCursor
85- Consumed MaintenanceBudget
86-}
87-
88-type ReconcileDecisionKind uint8
89-
90-const (
91- ReconcileAdopt ReconcileDecisionKind = iota + 1
92- ReconcileKeepQuarantined
93- ReconcileDefer
94-)
95-
96-type ReconcileDecision struct {
97- Kind ReconcileDecisionKind
98- Name string
99- Candidate *domain.CheckpointRef
100- ReasonCode string
101- // RetentionResolved permits collection after a conclusive adoption or
102- // not-forward decision. Its zero value conservatively pins known sessions.
103- RetentionResolved bool
104-}
105-
106 var ErrBudgetExhausted = errors.New("snapshot maintenance budget exhausted")49 var ErrBudgetExhausted = errors.New("snapshot maintenance budget exhausted")
107 50
108-type RecoveryJournal interface {
109- SaveDiscard(context.Context, domain.DiscardIntent) error
110- ListDiscards(context.Context) ([]domain.DiscardIntent, error)
111- DeleteDiscard(context.Context, domain.IncarnationID) error
112-}
113-
114 type CheckpointCoordinator interface {51 type CheckpointCoordinator interface {
115 PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error)52 PublishCheckpoint(context.Context, string, SnapshotPublication) (domain.CatalogueRecord, error)
116 }53 }
@@ -127,7 +64,7 @@ type SessionLifecycleCoordinator interface {
127 64
128 // DegradedRecoveryCoordinator exposes only the explicit operator discard action.65 // DegradedRecoveryCoordinator exposes only the explicit operator discard action.
129 type DegradedRecoveryCoordinator interface {66 type DegradedRecoveryCoordinator interface {
130- Discard(context.Context, string, string) (domain.CatalogueRecord, error)67+ Discard(context.Context, string) error
131 }68 }
132 69
133 type Catalogue interface {70 type Catalogue interface {
@@ -147,11 +84,7 @@ type SnapshotRepository interface {
147 RepairHEAD(context.Context, domain.IncarnationID, CheckpointRef) error84 RepairHEAD(context.Context, domain.IncarnationID, CheckpointRef) error
148 WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error85 WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error
149 ListDeletionTombstones(context.Context, DeletionTombstoneCursor, MaintenanceBudget) (DeletionTombstonePage, error)86 ListDeletionTombstones(context.Context, DeletionTombstoneCursor, MaintenanceBudget) (DeletionTombstonePage, error)
150- QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error
151 DeleteDeletionTombstone(context.Context, domain.IncarnationID) error87 DeleteDeletionTombstone(context.Context, domain.IncarnationID) error
152- SaveQuarantineDescriptor(context.Context, domain.QuarantineDescriptor) error
153- QuarantineIncarnation(context.Context, domain.IncarnationID) error
154 DeleteIncarnation(context.Context, domain.IncarnationID) error88 DeleteIncarnation(context.Context, domain.IncarnationID) error
155 MaintainSession(context.Context, RetentionPlan, MaintenanceBudget) (bool, error)89 MaintainSession(context.Context, RetentionPlan, MaintenanceBudget) (bool, error)
156- Reconcile(context.Context, []domain.CatalogueRecord, ReconcileCursor, MaintenanceBudget) (ReconcileCursor, []ReconcileFinding, error)
157 }90 }
M internal/usecase/daemon/control.go +9 -3
@@ -465,11 +465,17 @@ func (e controlExec) SessionRecovery(action string) (string, error) {
465 if ctx == nil {465 if ctx == nil {
466 ctx = context.Background()466 ctx = context.Background()
467 }467 }
468- record, err := e.d.degradedRecovery.Discard(ctx, e.recoveryName, "explicit operator discard")468+ if err := e.d.degradedRecovery.Discard(ctx, e.recoveryName); err != nil {
469- if err == nil {469+ return "", err
470+ }
471+ record, ok, err := e.d.catalogue.Record(e.recoveryName)
472+ if err != nil {
473+ return "", err
474+ }
475+ if ok {
470 e.d.setStoppedRecovery(record, runtimeFresh)476 e.d.setStoppedRecovery(record, runtimeFresh)
471 }477 }
472- return "", err478+ return "", nil
473 }479 }
474 480
475 func (e controlExec) ListSessions(asJSON bool) (string, error) {481 func (e controlExec) ListSessions(asJSON bool) (string, error) {
M internal/usecase/daemon/daemon.go +5 -6
@@ -325,12 +325,11 @@ func WithNoticeStore(store ports.NoticeStore) Option {
325 return func(d *Daemon) { d.noticeStore = store }325 return func(d *Daemon) { d.noticeStore = store }
326 }326 }
327 327
328-// WithDurableMaintenance schedules bounded retention and reconciliation after328+// WithDurableMaintenance schedules bounded retention after catalogue-driven
329-// catalogue-driven restoration. Each call receives an independent budget;329+// restoration. Unresolved incarnation IDs remain pinned for this daemon lifetime.
330-// unresolved incarnation IDs remain pinned for this daemon lifetime.330+func WithDurableMaintenance(catalogue ports.Catalogue, repository ports.SnapshotRepository, unresolved []domain.IncarnationID) Option {
331-func WithDurableMaintenance(catalogue ports.Catalogue, repository ports.SnapshotRepository, reconciler maintenanceReconciler, unresolved []domain.IncarnationID) Option {
332 return func(d *Daemon) {331 return func(d *Daemon) {
333- d.maintenance = newMaintenanceDependencies(catalogue, repository, reconciler, unresolved)332+ d.maintenance = newMaintenanceDependencies(catalogue, repository, unresolved)
334 }333 }
335 }334 }
336 335
@@ -548,7 +547,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
548 } else {547 } else {
549 d.closeRestoreDone()548 d.closeRestoreDone()
550 }549 }
551- if d.maintenance.reconciler != nil {550+ if d.maintenance.catalogue != nil && d.maintenance.repository != nil {
552 d.startDurableMaintenance()551 d.startDurableMaintenance()
553 }552 }
554 553
M internal/usecase/daemon/durable_authority_test.go +2 -2
@@ -18,7 +18,7 @@ type testLifecycleRepository struct{ ports.SnapshotRepository }
18 func (testLifecycleRepository) WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error {18 func (testLifecycleRepository) WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error {
19 return nil19 return nil
20 }20 }
21-func (testLifecycleRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {21+func (testLifecycleRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
22 return nil22 return nil
23 }23 }
24 func (testLifecycleRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {24 func (testLifecycleRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {
@@ -44,7 +44,7 @@ func WithStore(t testing.TB, store ports.Store) Option {
44 if repository == nil {44 if repository == nil {
45 repository = testLifecycleRepository{}45 repository = testLifecycleRepository{}
46 }46 }
47- authority := recoveryusecase.NewCoordinator(catalogue, repository, nil, rand.Reader)47+ authority := recoveryusecase.NewCoordinator(catalogue, repository, rand.Reader)
48 d.lifecycleRecovery = authority48 d.lifecycleRecovery = authority
49 d.checkpointRecovery = authority49 d.checkpointRecovery = authority
50 }50 }
M internal/usecase/daemon/durable_recovery_test.go +10 -9
@@ -192,7 +192,7 @@ func validGeneration(t testing.TB, record domain.CatalogueRecord) ports.Snapshot
192 func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) {192 func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) {
193 t.Helper()193 t.Helper()
194 catalogue := newDurableRecoveryCatalogue(records)194 catalogue := newDurableRecoveryCatalogue(records)
195- coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)195+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
196 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithCheckpointCoordinator(coordinator))196 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithCheckpointCoordinator(coordinator))
197 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())197 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
198 t.Cleanup(d.serveCancel)198 t.Cleanup(d.serveCancel)
@@ -211,7 +211,7 @@ func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) {
211 }211 }
212 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior})212 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior})
213 repository := &durableRecoveryRepository{}213 repository := &durableRecoveryRepository{}
214- coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)214+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
215 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, []domain.CatalogueRecord{prior}), WithSnapshotRepository(repository), WithCheckpointCoordinator(coordinator))215 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, []domain.CatalogueRecord{prior}), WithSnapshotRepository(repository), WithCheckpointCoordinator(coordinator))
216 startSnapshotEncodeWorker(t, d)216 startSnapshotEncodeWorker(t, d)
217 217
@@ -352,11 +352,12 @@ func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []s
352 }352 }
353 353
354 type explicitRecoveryStub struct {354 type explicitRecoveryStub struct {
355- record domain.CatalogueRecord355+ record domain.CatalogueRecord
356+ catalogue ports.Catalogue
356 }357 }
357 358
358-func (s explicitRecoveryStub) Discard(context.Context, string, string) (domain.CatalogueRecord, error) {359+func (s explicitRecoveryStub) Discard(_ context.Context, name string) error {
359- return s.record, nil360+ return s.catalogue.Replace(name, s.record)
360 }361 }
361 362
362 type contextRecoveryStub struct {363 type contextRecoveryStub struct {
@@ -366,9 +367,9 @@ type contextRecoveryStub struct {
366 err error367 err error
367 }368 }
368 369
369-func (s *contextRecoveryStub) Discard(ctx context.Context, _, _ string) (domain.CatalogueRecord, error) {370+func (s *contextRecoveryStub) Discard(ctx context.Context, _ string) error {
370 s.seen = ctx.Value(s.wantKey) == s.want371 s.seen = ctx.Value(s.wantKey) == s.want
371- return domain.CatalogueRecord{}, s.err372+ return s.err
372 }373 }
373 374
374 func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {375 func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {
@@ -481,13 +482,13 @@ func TestSessionRecoveryCommand(t *testing.T) {
481 degraded := durableRecoveryRecord(2)482 degraded := durableRecoveryRecord(2)
482 degraded.RecoveryState = domain.RecoveryDegraded483 degraded.RecoveryState = domain.RecoveryDegraded
483 degraded.DegradedReason = "checkpoint validation failed"484 degraded.DegradedReason = "checkpoint validation failed"
484- d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{degraded}, &durableRecoveryRepository{})485+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{degraded}, &durableRecoveryRepository{})
485 fresh := degraded486 fresh := degraded
486 fresh.IncarnationID = domain.IncarnationID{9}487 fresh.IncarnationID = domain.IncarnationID{9}
487 fresh.RecoveryState = domain.RecoveryFresh488 fresh.RecoveryState = domain.RecoveryFresh
488 fresh.Committed = nil489 fresh.Committed = nil
489 fresh.DegradedReason = ""490 fresh.DegradedReason = ""
490- d.degradedRecovery = explicitRecoveryStub{record: fresh}491+ d.degradedRecovery = explicitRecoveryStub{record: fresh, catalogue: catalogue}
491 492
492 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")493 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
493 require.NoError(t, err)494 require.NoError(t, err)
M internal/usecase/daemon/maintenance.go +8 -57
@@ -14,27 +14,19 @@ const (
14 maintenanceBytes = 8 << 2014 maintenanceBytes = 8 << 20
15 )15 )
16 16
17-type maintenanceReconciler interface {
18- Step(context.Context, ports.ReconcileCursor) (ports.ReconcileCursor, []ports.ReconcileDecision, error)
19-}
20-
21 // maintenanceDependencies is exclusively owned by runDurableMaintenance's17 // maintenanceDependencies is exclusively owned by runDurableMaintenance's
22 // single goroutine after startup. Shutdown joins that goroutine before these18 // single goroutine after startup. Shutdown joins that goroutine before these
23 // cursor and repair sets can be observed or reused.19 // cursor and repair sets can be observed or reused.
24 type maintenanceDependencies struct {20 type maintenanceDependencies struct {
25- catalogue ports.Catalogue21+ catalogue ports.Catalogue
26- repository ports.SnapshotRepository22+ repository ports.SnapshotRepository
27- reconciler maintenanceReconciler23+ unresolved map[domain.IncarnationID]struct{}
28- unresolved map[domain.IncarnationID]struct{}
29- repairUncertain map[domain.IncarnationID]struct{}
30- cursor ports.ReconcileCursor
31 }24 }
32 25
33-func newMaintenanceDependencies(catalogue ports.Catalogue, repository ports.SnapshotRepository, reconciler maintenanceReconciler, unresolved []domain.IncarnationID) maintenanceDependencies {26+func newMaintenanceDependencies(catalogue ports.Catalogue, repository ports.SnapshotRepository, unresolved []domain.IncarnationID) maintenanceDependencies {
34 deps := maintenanceDependencies{27 deps := maintenanceDependencies{
35- catalogue: catalogue, repository: repository, reconciler: reconciler,28+ catalogue: catalogue, repository: repository,
36- unresolved: make(map[domain.IncarnationID]struct{}, len(unresolved)),29+ unresolved: make(map[domain.IncarnationID]struct{}, len(unresolved)),
37- repairUncertain: make(map[domain.IncarnationID]struct{}),
38 }30 }
39 for _, id := range unresolved {31 for _, id := range unresolved {
40 if id != (domain.IncarnationID{}) {32 if id != (domain.IncarnationID{}) {
@@ -107,49 +99,10 @@ func (d *Daemon) runDurableMaintenance(ctx context.Context) {
107 // continued immediately rather than delayed until the periodic interval.99 // continued immediately rather than delayed until the periodic interval.
108 func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) bool {100 func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) bool {
109 deps := &d.maintenance101 deps := &d.maintenance
110- if deps.catalogue == nil || deps.repository == nil || deps.reconciler == nil {102+ if deps.catalogue == nil || deps.repository == nil {
111 return false103 return false
112 }104 }
113 105
114- // Reconcile before collection. Until a complete top-level scan reaches EOF,
115- // an unseen forward orphan may still be repairable, so retention yields
116- // without deleting any incarnation data.
117- previousCursor := deps.cursor
118- next, decisions, err := deps.reconciler.Step(ctx, previousCursor)
119- if err != nil {
120- d.log.Warn("durable_reconciliation_tick", "action", "scan", "cursor", deps.cursor.DirectoryCookie, "done", false, "err", err)
121- return false
122- }
123- deps.cursor = next
124- for _, decision := range decisions {
125- record, ok, err := deps.catalogue.Record(decision.Name)
126- if err != nil {
127- d.log.Warn("durable_reconciliation_tick", "action", "catalogue-read", "done", false, "err", err)
128- return false
129- }
130- if ok && !decision.RetentionResolved {
131- deps.repairUncertain[record.IncarnationID] = struct{}{}
132- }
133- // The catalogue lookup can miss (e.g. an unknown-incarnation finding for a
134- // name the catalogue never held); logging record.IncarnationID.String() in
135- // that case would emit a misleading all-zero incarnation, so the field is
136- // omitted rather than logged as a fabricated identity.
137- args := []any{
138- "session", decision.Name,
139- "action", decision.Kind,
140- "reason_code", decision.ReasonCode,
141- "cursor", next.DirectoryCookie,
142- }
143- if ok {
144- args = append([]any{"incarnation", record.IncarnationID.String()}, args...)
145- }
146- d.log.Info("durable_reconciliation_decision", args...)
147- }
148- d.log.Info("durable_reconciliation_tick", "action", "scan", "cursor", next.DirectoryCookie, "done", next.DirectoryCookie == 0, "err", nil)
149- if next.DirectoryCookie != 0 {
150- return next.DirectoryCookie != previousCursor.DirectoryCookie
151- }
152-
153 records, err := deps.catalogue.Records()106 records, err := deps.catalogue.Records()
154 if err != nil {107 if err != nil {
155 d.log.Warn("durable_maintenance_tick", "action", "catalogue-read", "done", false, "err", err)108 d.log.Warn("durable_maintenance_tick", "action", "catalogue-read", "done", false, "err", err)
@@ -170,9 +123,8 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) bool {
170 return false123 return false
171 }124 }
172 _, unresolved := deps.unresolved[record.IncarnationID]125 _, unresolved := deps.unresolved[record.IncarnationID]
173- _, uncertainRepair := deps.repairUncertain[record.IncarnationID]
174 _, restoring := restoringIncarnations[record.IncarnationID]126 _, restoring := restoringIncarnations[record.IncarnationID]
175- plan := retentionPlan(record, unresolved || uncertainRepair, restoring)127+ plan := retentionPlan(record, unresolved, restoring)
176 done, err := deps.repository.MaintainSession(ctx, plan, ports.MaintenanceBudget{Entries: maintenanceEntries, Bytes: maintenanceBytes})128 done, err := deps.repository.MaintainSession(ctx, plan, ports.MaintenanceBudget{Entries: maintenanceEntries, Bytes: maintenanceBytes})
177 if err == nil && !done {129 if err == nil && !done {
178 incomplete = true130 incomplete = true
@@ -187,6 +139,5 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) bool {
187 "err", err,139 "err", err,
188 )140 )
189 }141 }
190- clear(deps.repairUncertain)
191 return incomplete142 return incomplete
192 }143 }
M internal/usecase/daemon/maintenance_test.go +6 -152
@@ -2,46 +2,16 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "testing"5 "testing"
7 6
8 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
10 portsmocks "github.com/bnema/vev/internal/ports/mocks"9 portsmocks "github.com/bnema/vev/internal/ports/mocks"
11- recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
12 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
14 )12 )
15 13
16-type cursorMaintenanceReconciler struct {14+func TestProductionMaintenanceUsesCatalogue(t *testing.T) {
17- seen []ports.ReconcileCursor
18-}
19-
20-func (r *cursorMaintenanceReconciler) Step(_ context.Context, cursor ports.ReconcileCursor) (ports.ReconcileCursor, []ports.ReconcileDecision, error) {
21- r.seen = append(r.seen, cursor)
22- if cursor.DirectoryCookie == 0 {
23- return ports.ReconcileCursor{DirectoryCookie: 7}, nil, nil
24- }
25- return ports.ReconcileCursor{}, nil, nil
26-}
27-
28-type stalledMaintenanceReconciler struct{}
29-
30-func (stalledMaintenanceReconciler) Step(_ context.Context, cursor ports.ReconcileCursor) (ports.ReconcileCursor, []ports.ReconcileDecision, error) {
31- if cursor.DirectoryCookie == 0 {
32- return ports.ReconcileCursor{DirectoryCookie: 7}, nil, nil
33- }
34- return cursor, nil, nil
35-}
36-
37-func TestMaintenanceDoesNotImmediatelyRetryStalledCursor(t *testing.T) {
38- d := newTestDaemon(t, nil, stubClock{})
39- d.maintenance = newMaintenanceDependencies(portsmocks.NewMockCatalogue(t), portsmocks.NewMockSnapshotRepository(t), stalledMaintenanceReconciler{}, nil)
40- require.True(t, d.runDurableMaintenanceTick(context.Background()))
41- require.False(t, d.runDurableMaintenanceTick(context.Background()))
42-}
43-
44-func TestProductionMaintenanceUsesCatalogueAndResumesCursor(t *testing.T) {
45 record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}15 record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}
46 catalogue := portsmocks.NewMockCatalogue(t)16 catalogue := portsmocks.NewMockCatalogue(t)
47 catalogue.EXPECT().Records().Return([]domain.CatalogueRecord{record}, nil).Once()17 catalogue.EXPECT().Records().Return([]domain.CatalogueRecord{record}, nil).Once()
@@ -49,124 +19,10 @@ func TestProductionMaintenanceUsesCatalogueAndResumesCursor(t *testing.T) {
49 repository.EXPECT().MaintainSession(mock.Anything, mock.MatchedBy(func(plan ports.RetentionPlan) bool {19 repository.EXPECT().MaintainSession(mock.Anything, mock.MatchedBy(func(plan ports.RetentionPlan) bool {
50 return plan.IncarnationID == record.IncarnationID && !plan.PinAll && len(plan.Keep) == 020 return plan.IncarnationID == record.IncarnationID && !plan.PinAll && len(plan.Keep) == 0
51 }), ports.MaintenanceBudget{Entries: maintenanceEntries, Bytes: maintenanceBytes}).Return(true, nil).Once()21 }), ports.MaintenanceBudget{Entries: maintenanceEntries, Bytes: maintenanceBytes}).Return(true, nil).Once()
52- reconciler := &cursorMaintenanceReconciler{}
53 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})22 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
54- WithDurableMaintenance(catalogue, repository, reconciler, nil)(d)23+ WithDurableMaintenance(catalogue, repository, nil)(d)
55 24
56- require.True(t, d.runDurableMaintenanceTick(context.Background()), "partial reconciliation must continue immediately")
57 require.False(t, d.runDurableMaintenanceTick(context.Background()))25 require.False(t, d.runDurableMaintenanceTick(context.Background()))
58- require.True(t, d.runDurableMaintenanceTick(context.Background()), "the next partial pass must also continue immediately")
59- if len(reconciler.seen) != 3 || reconciler.seen[0].DirectoryCookie != 0 || reconciler.seen[1].DirectoryCookie != 7 || reconciler.seen[2].DirectoryCookie != 0 {
60- t.Fatalf("reconciliation cursors = %v, want [0 7 0]", reconciler.seen)
61- }
62-}
63-
64-type reconciliationMaintenanceRepository struct {
65- ports.SnapshotRepository
66- findings []ports.ReconcileFinding
67- err error
68- plans []ports.RetentionPlan
69-}
70-
71-func (r *reconciliationMaintenanceRepository) Reconcile(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) {
72- return ports.ReconcileCursor{}, r.findings, r.err
73-}
74-
75-func (r *reconciliationMaintenanceRepository) MaintainSession(_ context.Context, plan ports.RetentionPlan, _ ports.MaintenanceBudget) (bool, error) {
76- r.plans = append(r.plans, plan)
77- return true, nil
78-}
79-
80-func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) {
81- committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}
82- fallback1 := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
83- fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
84- record := domain.CatalogueRecord{
85- Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy,
86- Committed: &committed,
87- }
88- wrongParent := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{9}}
89-
90- for _, tc := range []struct {
91- name string
92- finding ports.ReconcileFinding
93- reconcileErr error
94- wantPlan *ports.RetentionPlan
95- }{
96- {
97- name: "current HEAD is conclusively not forward",
98- finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{
99- Name: record.Name, IncarnationID: record.IncarnationID, Ref: committed, Parent: &fallback1,
100- }},
101- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}},
102- },
103- {
104- name: "known predecessor is conclusively not forward",
105- finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{
106- Name: record.Name, IncarnationID: record.IncarnationID, Ref: fallback1, Parent: &fallback2,
107- }},
108- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed}},
109- },
110- {
111- name: "non-forward candidate kind remains pinned",
112- finding: ports.ReconcileFinding{Kind: ports.ReconcileInvalidCandidate, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{
113- Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}},
114- }},
115- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true},
116- },
117- {
118- name: "unreadable candidate remains pinned",
119- finding: ports.ReconcileFinding{Kind: ports.ReconcileInvalidCandidate, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{
120- Name: record.Name, IncarnationID: record.IncarnationID,
121- }},
122- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true},
123- },
124- {
125- name: "quarantined forward candidate remains pinned",
126- finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{
127- Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, Parent: &wrongParent,
128- }},
129- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true},
130- },
131- {
132- name: "incarnation mismatch remains pinned",
133- finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{
134- Name: record.Name, IncarnationID: domain.IncarnationID{9}, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, Parent: &committed,
135- }},
136- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true},
137- },
138- {
139- name: "incomplete decision remains pinned",
140- finding: ports.ReconcileFinding{Status: ports.ReconcileBudgetExhausted, Candidate: ports.ReconcileCandidate{
141- Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}},
142- }},
143- wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true},
144- },
145- {
146- name: "reconciliation error withholds maintenance",
147- reconcileErr: errors.New("scan failed"),
148- },
149- } {
150- t.Run(tc.name, func(t *testing.T) {
151- catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
152- repository := &reconciliationMaintenanceRepository{err: tc.reconcileErr}
153- if tc.finding.Candidate.Name != "" {
154- repository.findings = []ports.ReconcileFinding{tc.finding}
155- }
156- coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)
157- reconciler := recoveryusecase.NewReconciler(coordinator, catalogue, repository, ports.MaintenanceBudget{Entries: 8, Bytes: 1024})
158- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
159- WithDurableMaintenance(catalogue, repository, reconciler, nil)(d)
160-
161- d.runDurableMaintenanceTick(context.Background())
162-
163- if tc.wantPlan == nil {
164- require.Empty(t, repository.plans)
165- return
166- }
167- require.Equal(t, []ports.RetentionPlan{*tc.wantPlan}, repository.plans)
168- })
169- }
170 }26 }
171 27
172 func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) {28 func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) {
@@ -184,18 +40,16 @@ func TestRetentionPlanPinsEveryUnresolvedRuntimeState(t *testing.T) {
184 wantPin bool40 wantPin bool
185 }{41 }{
186 {name: "healthy", record: healthy},42 {name: "healthy", record: healthy},
187- {name: "pending journal tombstone or repair", record: healthy, unresolved: true, wantPin: true},43+ {name: "pending deletion", record: healthy, unresolved: true, wantPin: true},
188 {name: "restoring", record: healthy, restoring: true, wantPin: true},44 {name: "restoring", record: healthy, restoring: true, wantPin: true},
189 {name: "degraded", record: func() domain.CatalogueRecord { r := healthy; r.RecoveryState = domain.RecoveryDegraded; return r }(), wantPin: true},45 {name: "degraded", record: func() domain.CatalogueRecord { r := healthy; r.RecoveryState = domain.RecoveryDegraded; return r }(), wantPin: true},
190 {name: "deleting", record: func() domain.CatalogueRecord { r := healthy; r.RecoveryState = domain.RecoveryDeleting; return r }(), wantPin: true},46 {name: "deleting", record: func() domain.CatalogueRecord { r := healthy; r.RecoveryState = domain.RecoveryDeleting; return r }(), wantPin: true},
191 } {47 } {
192 t.Run(tc.name, func(t *testing.T) {48 t.Run(tc.name, func(t *testing.T) {
193 plan := retentionPlan(tc.record, tc.unresolved, tc.restoring)49 plan := retentionPlan(tc.record, tc.unresolved, tc.restoring)
194- if plan.PinAll != tc.wantPin {50+ require.Equal(t, tc.wantPin, plan.PinAll)
195- t.Fatalf("PinAll = %v, want %v", plan.PinAll, tc.wantPin)51+ if !tc.wantPin {
196- }52+ require.Equal(t, []ports.CheckpointRef{committed}, plan.Keep)
197- if !tc.wantPin && len(plan.Keep) != 1 {
198- t.Fatalf("healthy keep set = %v, want committed only", plan.Keep)
199 }53 }
200 })54 })
201 }55 }
M internal/usecase/daemon/picker_test.go +2 -2
@@ -33,7 +33,7 @@ type refusingSnapshotDeleteRepository struct {
33 func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {33 func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
34 return nil34 return nil
35 }35 }
36-func (s refusingSnapshotDeleteRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {36+func (s refusingSnapshotDeleteRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
37 return s.err37 return s.err
38 }38 }
39 39
@@ -272,7 +272,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
272 record.Name = "restoring"272 record.Name = "restoring"
273 repository := &cancellationRecoveryRepository{started: make(chan struct{})}273 repository := &cancellationRecoveryRepository{started: make(chan struct{})}
274 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})274 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
275- coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)275+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
276 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)276 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
277 WithSnapshotRepository(repository)(d)277 WithSnapshotRepository(repository)(d)
278 WithCheckpointCoordinator(coordinator)(d)278 WithCheckpointCoordinator(coordinator)(d)
M internal/usecase/daemon/session_purge_control_test.go +2 -2
@@ -26,11 +26,11 @@ func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) {
26 26
27 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)27 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)
28 require.True(t, repository.tombstoned["work"])28 require.True(t, repository.tombstoned["work"])
29- require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)29+ require.Equal(t, []string{"tombstone", "delete incarnation"}, repository.calls)
30 30
31 state.mu.Lock()31 state.mu.Lock()
32 state.deleteErr = nil32 state.deleteErr = nil
33 state.mu.Unlock()33 state.mu.Unlock()
34 require.NoError(t, d.retryStoppedPurge("work"))34 require.NoError(t, d.retryStoppedPurge("work"))
35- require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)35+ require.Equal(t, []string{"tombstone", "delete incarnation", "tombstone", "delete incarnation", "clear tombstone"}, repository.calls)
36 }36 }
M internal/usecase/daemon/session_purge_retry_test.go +32 -62
@@ -14,8 +14,7 @@ import (
14 14
15 type retryablePurgeRepository struct {15 type retryablePurgeRepository struct {
16 noOpSnapshotRepository16 noOpSnapshotRepository
17- incrementalErr error17+ deleteErr error
18- legacyErr error
19 tombstoned map[string]bool18 tombstoned map[string]bool
20 calls []string19 calls []string
21 deadlineScoped []bool20 deadlineScoped []bool
@@ -24,6 +23,7 @@ type retryablePurgeRepository struct {
24 func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {23 func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {
25 return nil24 return nil
26 }25 }
26+
27 func (r *retryablePurgeRepository) WriteDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {27 func (r *retryablePurgeRepository) WriteDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
28 _, hasDeadline := ctx.Deadline()28 _, hasDeadline := ctx.Deadline()
29 r.deadlineScoped = append(r.deadlineScoped, hasDeadline)29 r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
@@ -34,76 +34,46 @@ func (r *retryablePurgeRepository) WriteDeletionTombstone(ctx context.Context, t
34 r.tombstoned[tombstone.Name] = true34 r.tombstoned[tombstone.Name] = true
35 return nil35 return nil
36 }36 }
37-func (r *retryablePurgeRepository) QuarantineDeletionSources(ctx context.Context, tombstone domain.DeletionTombstone, _ bool) error {37+
38+func (r *retryablePurgeRepository) DeleteIncarnation(ctx context.Context, _ domain.IncarnationID) error {
38 _, hasDeadline := ctx.Deadline()39 _, hasDeadline := ctx.Deadline()
39 r.deadlineScoped = append(r.deadlineScoped, hasDeadline)40 r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
40- r.calls = append(r.calls, "incremental")41+ r.calls = append(r.calls, "delete incarnation")
41- if r.incrementalErr != nil {42+ return r.deleteErr
42- return r.incrementalErr
43- }
44- r.calls = append(r.calls, "legacy")
45- return r.legacyErr
46 }43 }
44+
47 func (r *retryablePurgeRepository) DeleteDeletionTombstone(ctx context.Context, _ domain.IncarnationID) error {45 func (r *retryablePurgeRepository) DeleteDeletionTombstone(ctx context.Context, _ domain.IncarnationID) error {
48 _, hasDeadline := ctx.Deadline()46 _, hasDeadline := ctx.Deadline()
49 r.deadlineScoped = append(r.deadlineScoped, hasDeadline)47 r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
50 r.calls = append(r.calls, "clear tombstone")48 r.calls = append(r.calls, "clear tombstone")
51- for name := range r.tombstoned {49+ clear(r.tombstoned)
52- delete(r.tombstoned, name)
53- }
54 return nil50 return nil
55 }51 }
56-func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
57- for _, tt := range []struct {
58- name string
59- incremental error
60- legacy error
61- }{
62- {name: "incremental failure", incremental: errors.New("incremental failed")},
63- {name: "legacy failure", legacy: errors.New("legacy failed")},
64- } {
65- t.Run(tt.name, func(t *testing.T) {
66- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
67- repository := &retryablePurgeRepository{incrementalErr: tt.incremental, legacyErr: tt.legacy}
68- WithSnapshotRepository(repository)(d)
69- store, _ := newMockStore(t)
70- WithStore(t, store)(d)
71- sess := newSnapshotTestSession(t, "work", false, "/work")
72- record := sess.persistRecordLocked(1)
73- record.RecoveryState = domain.RecoveryFresh
74- require.NoError(t, d.catalogue.Create(record))
75- pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
76- require.True(t, ok, "snapshot test pane must use MockPTY")
77- pty.EXPECT().Close().Return(nil).Once()
78- d.sessions = map[domain.SessionID]*session{sess.id: sess}
79 52
80- require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))53+func TestLivePurgeRetainsTombstoneAcrossPartialDeletion(t *testing.T) {
81- require.True(t, repository.tombstoned["work"], "partial purge must fence startup restore/import")54+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
82- wantCalls := []string{"tombstone", "incremental"}55+ repository := &retryablePurgeRepository{deleteErr: errors.New("delete failed")}
83- if tt.incremental == nil {56+ WithSnapshotRepository(repository)(d)
84- wantCalls = append(wantCalls, "legacy")57+ store, _ := newMockStore(t)
85- }58+ WithStore(t, store)(d)
86- require.Equal(t, wantCalls, repository.calls)59+ sess := newSnapshotTestSession(t, "work", false, "/work")
87- d.mu.Lock()60+ record := sess.persistRecordLocked(1)
88- stopped, ok := d.stopped["work"]61+ record.RecoveryState = domain.RecoveryFresh
89- d.mu.Unlock()62+ require.NoError(t, d.catalogue.Create(record))
90- require.True(t, ok)63+ pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
91- require.True(t, stopped.purging)64+ require.True(t, ok)
65+ pty.EXPECT().Close().Return(nil).Once()
66+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
67+
68+ require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
69+ require.True(t, repository.tombstoned["work"])
70+ require.Equal(t, []string{"tombstone", "delete incarnation"}, repository.calls)
92 71
93- repository.incrementalErr = nil72+ repository.deleteErr = nil
94- repository.legacyErr = nil73+ require.NoError(t, d.retryStoppedPurge("work"))
95- require.NoError(t, d.retryStoppedPurge("work"))74+ require.False(t, repository.tombstoned["work"])
96- require.False(t, repository.tombstoned["work"])75+ require.Equal(t, []string{"tombstone", "delete incarnation", "tombstone", "delete incarnation", "clear tombstone"}, repository.calls)
97- wantCalls = append(wantCalls, "tombstone", "incremental", "legacy", "clear tombstone")76+ for _, scoped := range repository.deadlineScoped {
98- require.Equal(t, wantCalls, repository.calls)77+ require.True(t, scoped)
99- require.NotEmpty(t, repository.deadlineScoped)
100- for _, scoped := range repository.deadlineScoped {
101- require.True(t, scoped, "every purge repository call must receive a deadline-scoped context")
102- }
103- d.mu.Lock()
104- _, ok = d.stopped["work"]
105- d.mu.Unlock()
106- require.False(t, ok, "metadata is removed only after both source deletes and tombstone clear")
107- })
108 }78 }
109 }79 }
M internal/usecase/daemon/session_rename_purge_test.go +16 -28
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "errors"
5 "testing"4 "testing"
6 5
7 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
@@ -11,32 +10,21 @@ import (
11 )10 )
12 11
13 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {12 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
14- legacyDeleteErr := errors.New("legacy delete failed")13+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
15- for _, tt := range []struct {14+ repository := &retryablePurgeRepository{}
16- name string15+ WithSnapshotRepository(repository)(d)
17- legacyErr error16+ store, state := newMockStore(t)
18- }{17+ WithStore(t, store)(d)
19- {name: "snapshot sources available"},18+ sess := newSnapshotTestSession(t, "old", false, "/work")
20- {name: "unrelated legacy source delete would fail", legacyErr: legacyDeleteErr},19+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
21- } {20+ sess.mu.Lock()
22- t.Run(tt.name, func(t *testing.T) {21+ record := sess.persistRecordLocked(1)
23- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})22+ sess.mu.Unlock()
24- repository := &retryablePurgeRepository{legacyErr: tt.legacyErr}23+ require.NoError(t, testPersister(t, d).Save(record))
25- WithSnapshotRepository(repository)(d)
26- store, state := newMockStore(t)
27- WithStore(t, store)(d)
28- sess := newSnapshotTestSession(t, "old", false, "/work")
29- d.sessions = map[domain.SessionID]*session{sess.id: sess}
30- sess.mu.Lock()
31- record := sess.persistRecordLocked(1)
32- sess.mu.Unlock()
33- require.NoError(t, testPersister(t, d).Save(record))
34 24
35- require.NoError(t, d.renameSession(sess, "new"))25+ require.NoError(t, d.renameSession(sess, "new"))
36- require.Empty(t, repository.calls, "rename must not invoke source deletion, including a failing legacy source")26+ require.Empty(t, repository.calls, "rename must not delete incarnation-keyed snapshots")
37- require.False(t, state.has("old"))27+ require.False(t, state.has("old"))
38- require.True(t, state.has("new"))28+ require.True(t, state.has("new"))
39- require.Equal(t, sess.incarnation, state.record(t, "new").IncarnationID)29+ require.Equal(t, sess.incarnation, state.record(t, "new").IncarnationID)
40- })
41- }
42 }30 }
M internal/usecase/daemon/snapshot_restore_test.go +2 -2
@@ -223,7 +223,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
223 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})223 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
224 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)224 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
225 WithSnapshotRepository(repository)(d)225 WithSnapshotRepository(repository)(d)
226- WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil, nil))(d)226+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
227 t.Cleanup(func() { release(); d.sessWg.Wait() })227 t.Cleanup(func() { release(); d.sessWg.Wait() })
228 228
229 d.restoreIncrementalSnapshots(context.Background())229 d.restoreIncrementalSnapshots(context.Background())
@@ -272,7 +272,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
272 272
273 pty, release := newBlockingPTY(t)273 pty, release := newBlockingPTY(t)
274 d := newTestDaemon(t, newFactory(t, pty), stubClock{})274 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
275- coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)275+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
276 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)276 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
277 WithSnapshotRepository(repository)(d)277 WithSnapshotRepository(repository)(d)
278 WithRecoveryCoordinator(coordinator)(d)278 WithRecoveryCoordinator(coordinator)(d)
M internal/usecase/daemon/snapshot_test.go +0 -12
@@ -107,27 +107,15 @@ func (noOpSnapshotRepository) WriteDeletionTombstone(context.Context, domain.Del
107 func (noOpSnapshotRepository) ListDeletionTombstones(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {107 func (noOpSnapshotRepository) ListDeletionTombstones(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {
108 return ports.DeletionTombstonePage{Done: true}, nil108 return ports.DeletionTombstonePage{Done: true}, nil
109 }109 }
110-func (noOpSnapshotRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {
111- return nil
112-}
113 func (noOpSnapshotRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {110 func (noOpSnapshotRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {
114 return nil111 return nil
115 }112 }
116-func (noOpSnapshotRepository) SaveQuarantineDescriptor(context.Context, domain.QuarantineDescriptor) error {
117- return nil
118-}
119-func (noOpSnapshotRepository) QuarantineIncarnation(context.Context, domain.IncarnationID) error {
120- return nil
121-}
122 func (noOpSnapshotRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {113 func (noOpSnapshotRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
123 return nil114 return nil
124 }115 }
125 func (noOpSnapshotRepository) MaintainSession(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error) {116 func (noOpSnapshotRepository) MaintainSession(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error) {
126 return true, nil117 return true, nil
127 }118 }
128-func (noOpSnapshotRepository) Reconcile(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) {
129- return ports.ReconcileCursor{}, nil, nil
130-}
131 119
132 func awaitSnapshotIdle(t testing.TB, sess *session) {120 func awaitSnapshotIdle(t testing.TB, sess *session) {
133 t.Helper()121 t.Helper()
M internal/usecase/recovery/checkpoint_test.go +2 -2
@@ -112,7 +112,7 @@ func runCheckpointCommit(t *testing.T, count int) (domain.CatalogueRecord, *chec
112 events := make([]string, 0, count*2)112 events := make([]string, 0, count*2)
113 catalogue := &checkpointCatalogue{record: checkpointRecord(), events: &events}113 catalogue := &checkpointCatalogue{record: checkpointRecord(), events: &events}
114 repository := &checkpointRepository{events: &events}114 repository := &checkpointRepository{events: &events}
115- coordinator := NewCoordinator(catalogue, repository, nil, nil)115+ coordinator := NewCoordinator(catalogue, repository, nil)
116 for generation := uint64(1); generation <= uint64(count); generation++ {116 for generation := uint64(1); generation <= uint64(count); generation++ {
117 var err error117 var err error
118 catalogue.record, err = coordinator.PublishCheckpoint(context.Background(), "work", checkpointPublication(catalogue.record, generation))118 catalogue.record, err = coordinator.PublishCheckpoint(context.Background(), "work", checkpointPublication(catalogue.record, generation))
@@ -153,7 +153,7 @@ func TestCheckpointCommitPublishOrphan(t *testing.T) {
153 cause := errors.New("catalogue sync failed")153 cause := errors.New("catalogue sync failed")
154 catalogue := &checkpointCatalogue{record: prior, replaceErr: cause}154 catalogue := &checkpointCatalogue{record: prior, replaceErr: cause}
155 repository := &checkpointRepository{}155 repository := &checkpointRepository{}
156- coordinator := NewCoordinator(catalogue, repository, nil, nil)156+ coordinator := NewCoordinator(catalogue, repository, nil)
157 publication := checkpointPublication(prior, 8)157 publication := checkpointPublication(prior, 8)
158 158
159 _, err := coordinator.PublishCheckpoint(context.Background(), prior.Name, publication)159 _, err := coordinator.PublishCheckpoint(context.Background(), prior.Name, publication)
M internal/usecase/recovery/coordinator.go +12 -34
@@ -98,15 +98,13 @@ var (
98 // Degraded reasons are content-free by construction: they never carry paths,98 // Degraded reasons are content-free by construction: they never carry paths,
99 // terminal content, or operator input.99 // terminal content, or operator input.
100 const (100 const (
101- degradedReasonDiscardIntent = "discard intent conflict"101+ degradedReasonTombstone = "deletion tombstone conflict"
102- degradedReasonTombstone = "deletion tombstone conflict"102+ degradedReasonRecord = "catalogue record conflict"
103- degradedReasonRecord = "catalogue record conflict"
104 )103 )
105 104
106 type Coordinator struct {105 type Coordinator struct {
107 catalogue ports.Catalogue106 catalogue ports.Catalogue
108 repository ports.SnapshotRepository107 repository ports.SnapshotRepository
109- journal ports.RecoveryJournal
110 locks *KeyLocks108 locks *KeyLocks
111 random io.Reader109 random io.Reader
112 110
@@ -133,8 +131,8 @@ func (c *Coordinator) Conflicts() []RecoveryConflict {
133 return append([]RecoveryConflict(nil), c.conflicts...)131 return append([]RecoveryConflict(nil), c.conflicts...)
134 }132 }
135 133
136-func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, journal ports.RecoveryJournal, random io.Reader) *Coordinator {134+func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, random io.Reader) *Coordinator {
137- return &Coordinator{catalogue: catalogue, repository: repository, journal: journal, locks: NewKeyLocks(), random: random}135+ return &Coordinator{catalogue: catalogue, repository: repository, locks: NewKeyLocks(), random: random}
138 }136 }
139 137
140 // Create commits fresh catalogue metadata before the caller creates or exposes138 // Create commits fresh catalogue metadata before the caller creates or exposes
@@ -244,7 +242,7 @@ func (c *Coordinator) deleteLocked(ctx context.Context, record domain.CatalogueR
244 if err := c.repository.WriteDeletionTombstone(ctx, tombstone); err != nil {242 if err := c.repository.WriteDeletionTombstone(ctx, tombstone); err != nil {
245 return err243 return err
246 }244 }
247- if err := c.repository.QuarantineDeletionSources(ctx, tombstone, true); err != nil {245+ if err := c.repository.DeleteIncarnation(ctx, record.IncarnationID); err != nil {
248 return err246 return err
249 }247 }
250 if err := c.catalogue.Delete(record.Name); err != nil {248 if err := c.catalogue.Delete(record.Name); err != nil {
@@ -260,29 +258,16 @@ func (c *Coordinator) deleteLocked(ctx context.Context, record domain.CatalogueR
260 // A per-item failure that only proves one session's durable state disagrees258 // A per-item failure that only proves one session's durable state disagrees
261 // with itself fences that session and lets startup continue: healthy sessions259 // with itself fences that session and lets startup continue: healthy sessions
262 // must still restore next to a corrupt neighbour. Infrastructure failures260 // must still restore next to a corrupt neighbour. Infrastructure failures
263-// (listing, journal, repository, or catalogue IO, and a non-advancing cursor)261+// (listing, repository, or catalogue IO, and a non-advancing cursor) still
264-// still abort startup, because they say nothing about any single session.262+// abort startup, because they say nothing about any single session.
265 func (c *Coordinator) Recover(ctx context.Context) error {263 func (c *Coordinator) Recover(ctx context.Context) error {
266- if c == nil || c.catalogue == nil || c.repository == nil || c.journal == nil || c.locks == nil {264+ if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
267 return errors.New("recovery: incomplete coordinator dependencies")265 return errors.New("recovery: incomplete coordinator dependencies")
268 }266 }
269 c.conflictMu.Lock()267 c.conflictMu.Lock()
270 c.conflicts = nil268 c.conflicts = nil
271 c.conflictMu.Unlock()269 c.conflictMu.Unlock()
272 270
273- intents, err := c.journal.ListDiscards(ctx)
274- if err != nil {
275- return err
276- }
277- for _, intent := range intents {
278- // The intent is deliberately retained for a fenced session: only a
279- // successful roll-forward may remove it.
280- if err := c.recoverDiscard(ctx, intent); err != nil {
281- if err := c.fenceSession(intent.SessionName, "discard-intent", degradedReasonDiscardIntent, err); err != nil {
282- return err
283- }
284- }
285- }
286 records, err := c.catalogue.Records()271 records, err := c.catalogue.Records()
287 if err != nil {272 if err != nil {
288 return err273 return err
@@ -370,8 +355,7 @@ func (c *Coordinator) fenceSession(name, kind, reason string, cause error) error
370 // durable state is self-inconsistent. Everything else - IO, decode, traversal,355 // durable state is self-inconsistent. Everything else - IO, decode, traversal,
371 // context cancellation - is treated as infrastructure and aborts startup.356 // context cancellation - is treated as infrastructure and aborts startup.
372 func isSessionScopedConflict(err error) bool {357 func isSessionScopedConflict(err error) bool {
373- return errors.Is(err, ErrDiscardConflict) || errors.Is(err, ErrDiscardIntentInvalid) ||358+ return errors.Is(err, ErrDeletionTombstoneConflict) || errors.Is(err, ErrDeletionTombstoneInvalid) ||
374- errors.Is(err, ErrDeletionTombstoneConflict) || errors.Is(err, ErrDeletionTombstoneInvalid) ||
375 errors.Is(err, ErrSessionRecordInvalid)359 errors.Is(err, ErrSessionRecordInvalid)
376 }360 }
377 361
@@ -385,16 +369,10 @@ func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone do
385 if err != nil {369 if err != nil {
386 return err370 return err
387 }371 }
388- includeLegacyName := true372+ if exists && record.IncarnationID == tombstone.IncarnationID && record.RecoveryState != domain.RecoveryDeleting {
389- if exists {373+ return fmt.Errorf("%w: non-deleting session %q", ErrDeletionTombstoneConflict, tombstone.Name)
390- switch {
391- case record.IncarnationID != tombstone.IncarnationID:
392- includeLegacyName = false
393- case record.RecoveryState != domain.RecoveryDeleting:
394- return fmt.Errorf("%w: non-deleting session %q", ErrDeletionTombstoneConflict, tombstone.Name)
395- }
396 }374 }
397- if err := c.repository.QuarantineDeletionSources(ctx, tombstone, includeLegacyName); err != nil {375+ if err := c.repository.DeleteIncarnation(ctx, tombstone.IncarnationID); err != nil {
398 return err376 return err
399 }377 }
400 if exists && record.IncarnationID == tombstone.IncarnationID {378 if exists && record.IncarnationID == tombstone.IncarnationID {
M internal/usecase/recovery/coordinator_test.go +2 -35
@@ -5,59 +5,26 @@ import (
5 "errors"5 "errors"
6 "testing"6 "testing"
7 7
8- "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
10 portsmocks "github.com/bnema/vev/internal/ports/mocks"9 portsmocks "github.com/bnema/vev/internal/ports/mocks"
11 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
12 )11 )
13 12
14-type journalStub struct {
15- intents []domain.DiscardIntent
16- err error
17-}
18-
19-func (j journalStub) SaveDiscard(context.Context, domain.DiscardIntent) error { return nil }
20-func (j journalStub) ListDiscards(context.Context) ([]domain.DiscardIntent, error) {
21- return j.intents, j.err
22-}
23-func (j journalStub) DeleteDiscard(context.Context, domain.IncarnationID) error { return nil }
24-
25 func TestDeleteChecksCancellationBeforeCatalogueAccess(t *testing.T) {13 func TestDeleteChecksCancellationBeforeCatalogueAccess(t *testing.T) {
26 ctx, cancel := context.WithCancel(t.Context())14 ctx, cancel := context.WithCancel(t.Context())
27 cancel()15 cancel()
28 catalogue := portsmocks.NewMockCatalogue(t)16 catalogue := portsmocks.NewMockCatalogue(t)
29 repository := portsmocks.NewMockSnapshotRepository(t)17 repository := portsmocks.NewMockSnapshotRepository(t)
30- coordinator := NewCoordinator(catalogue, repository, journalStub{}, nil)18+ coordinator := NewCoordinator(catalogue, repository, nil)
31 19
32 require.ErrorIs(t, coordinator.Delete(ctx, "work"), context.Canceled)20 require.ErrorIs(t, coordinator.Delete(ctx, "work"), context.Canceled)
33 }21 }
34 22
35-func TestCoordinatorRecoverRollsPendingDiscardForward(t *testing.T) {
36- ctx := context.Background()
37- old := domain.CatalogueRecord{
38- Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded,
39- Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "corrupt",
40- }
41- intent := domain.DiscardIntent{
42- OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2},
43- SessionName: old.Name, Reason: "discard",
44- }
45- repo := portsmocks.NewMockSnapshotRepository(t)
46- repo.EXPECT().SaveQuarantineDescriptor(ctx, quarantineDescriptor(intent)).Return(nil)
47- repo.EXPECT().QuarantineIncarnation(ctx, old.IncarnationID).Return(nil)
48- repo.EXPECT().ListDeletionTombstones(ctx, ports.DeletionTombstoneCursor{}, recoveryListingBudget).
49- Return(ports.DeletionTombstonePage{Done: true}, nil)
50- catalogue := newTransactionCatalogue(old)
51- c := NewCoordinator(catalogue, repo, journalStub{intents: []domain.DiscardIntent{intent}}, nil)
52- require.NoError(t, c.Recover(ctx))
53- require.Equal(t, intent.NewIncarnation, catalogue.records[old.Name].IncarnationID)
54-}
55-
56 func TestCoordinatorRecoverPagesTombstonesAndPropagatesErrors(t *testing.T) {23 func TestCoordinatorRecoverPagesTombstonesAndPropagatesErrors(t *testing.T) {
57 cause := errors.New("malformed tombstone")24 cause := errors.New("malformed tombstone")
58 repo := portsmocks.NewMockSnapshotRepository(t)25 repo := portsmocks.NewMockSnapshotRepository(t)
59 repo.EXPECT().ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, recoveryListingBudget).26 repo.EXPECT().ListDeletionTombstones(context.Background(), ports.DeletionTombstoneCursor{}, recoveryListingBudget).
60 Return(ports.DeletionTombstonePage{}, cause)27 Return(ports.DeletionTombstonePage{}, cause)
61- c := NewCoordinator(newTransactionCatalogue(), repo, journalStub{}, nil)28+ c := NewCoordinator(newTransactionCatalogue(), repo, nil)
62 require.ErrorIs(t, c.Recover(context.Background()), cause)29 require.ErrorIs(t, c.Recover(context.Background()), cause)
63 }30 }
M internal/usecase/recovery/discard.go +29 -109
@@ -11,131 +11,51 @@ import (
11 var (11 var (
12 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")12 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")
13 ErrSessionNotDegraded = errors.New("recovery: session is not degraded")13 ErrSessionNotDegraded = errors.New("recovery: session is not degraded")
14- ErrDiscardConflict = errors.New("recovery: discard intent conflicts with catalogue")
15- ErrDiscardIntentInvalid = errors.New("recovery: discard intent is unusable")
16 )14 )
17 15
18-// Discard commits to a new incarnation by saving the complete intent before16+// Discard throws away a broken session's persisted state and replaces it with a
19-// any quarantine or catalogue mutation. There is no rollback after that point.17+// fresh incarnation. It is retry-idempotent, not crash-resumable: a crash
20-func (c *Coordinator) Discard(ctx context.Context, name, reason string) (domain.CatalogueRecord, error) {18+// leaves either the old record (rerun discard) or a new record plus an orphan
21- if c == nil || c.catalogue == nil || c.repository == nil || c.journal == nil || c.locks == nil || c.random == nil {19+// directory that startup garbage collection removes.
22- return domain.CatalogueRecord{}, errors.New("recovery: incomplete discard dependencies")20+func (c *Coordinator) Discard(ctx context.Context, name string) error {
21+ if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil || c.random == nil {
22+ return errors.New("recovery: incomplete discard dependencies")
23+ }
24+ if err := ctx.Err(); err != nil {
25+ return err
23 }26 }
24 unlock := c.locks.Lock([]string{name})27 unlock := c.locks.Lock([]string{name})
25 defer unlock()28 defer unlock()
29+
26 record, ok, err := c.catalogue.Record(name)30 record, ok, err := c.catalogue.Record(name)
27 if err != nil {31 if err != nil {
28- return domain.CatalogueRecord{}, err32+ return err
29 }33 }
30 if !ok {34 if !ok {
31- return domain.CatalogueRecord{}, ErrRecoveryRecordNotFound35+ return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound)
32- }
33- if record.RecoveryState != domain.RecoveryDegraded {
34- return domain.CatalogueRecord{}, ErrSessionNotDegraded
35- }
36- if reason == "" {
37- return domain.CatalogueRecord{}, errors.New("recovery: discard reason is required")
38- }
39- newID, err := domain.NewIncarnationID(c.random)
40- if err != nil {
41- return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate replacement incarnation: %w", err)
42 }36 }
43- intent := domain.DiscardIntent{37+ if record.RecoveryState == domain.RecoveryFresh {
44- OldRecord: record, OldIncarnation: record.IncarnationID, NewIncarnation: newID,38+ return nil
45- SessionName: record.Name, Reason: reason,
46 }39 }
47- if err := c.journal.SaveDiscard(ctx, intent); err != nil {40+ if record.RecoveryState != domain.RecoveryDegraded {
48- return domain.CatalogueRecord{}, err41+ return ErrSessionNotDegraded
49- }
50- if err := c.recoverDiscardLocked(ctx, intent); err != nil {
51- return domain.CatalogueRecord{}, err
52 }42 }
53- return freshReplacement(intent), nil
54-}
55 43
56-func (c *Coordinator) recoverDiscard(ctx context.Context, intent domain.DiscardIntent) error {44+ old := record.IncarnationID
57- if c == nil || c.catalogue == nil || c.repository == nil || c.journal == nil || c.locks == nil {45+ next, err := domain.NewIncarnationID(c.random)
58- return errors.New("recovery: incomplete discard recovery dependencies")
59- }
60- unlock := c.locks.Lock([]string{intent.SessionName})
61- defer unlock()
62- return c.recoverDiscardLocked(ctx, intent)
63-}
64-
65-func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.DiscardIntent) error {
66- if err := validateDiscardIntent(intent); err != nil {
67- return err
68- }
69- current, ok, err := c.catalogue.Record(intent.SessionName)
70 if err != nil {46 if err != nil {
71- return err47+ return fmt.Errorf("recovery: generate replacement incarnation: %w", err)
72- }
73- if !ok {
74- // Catalogue deletion is terminal for this session identity. There is no
75- // live record to replace or quarantine, so retaining the intent would
76- // manufacture a permanent startup conflict.
77- return c.journal.DeleteDiscard(ctx, intent.OldIncarnation)
78 }48 }
79- next := freshReplacement(intent)49+ fresh := record
80- if err := next.Validate(); err != nil {50+ fresh.IncarnationID = next
81- return fmt.Errorf("%w: invalid discard replacement: %w", ErrDiscardIntentInvalid, err)51+ fresh.RecoveryState = domain.RecoveryFresh
52+ fresh.Committed = nil
53+ fresh.DegradedReason = ""
54+ if err := fresh.Validate(); err != nil {
55+ return fmt.Errorf("recovery: invalid discard replacement: %w", err)
82 }56 }
83- switch current.IncarnationID {57+ if err := c.catalogue.Replace(name, fresh); err != nil {
84- case intent.OldIncarnation:
85- if !current.Equal(intent.OldRecord) {
86- return fmt.Errorf("%w: old record changed", ErrDiscardConflict)
87- }
88- case intent.NewIncarnation:
89- // Step 4 is already durable. The replacement is live from this point on,
90- // so it may legitimately have advanced to a committed checkpoint before a
91- // later step failed. Only its identity may be required here: demanding
92- // byte equality with the pristine replacement would fail closed forever
93- // on a session that is in fact healthy, and destroy committed state if it
94- // were overwritten. Steps 2-3 and 5 below are idempotent.
95- if current.Name != intent.SessionName {
96- return fmt.Errorf("%w: replacement session identity changed", ErrDiscardConflict)
97- }
98- default:
99- return fmt.Errorf("%w: unexpected incarnation", ErrDiscardConflict)
100- }
101-
102- if err := c.repository.SaveQuarantineDescriptor(ctx, quarantineDescriptor(intent)); err != nil {
103 return err58 return err
104 }59 }
105- if err := c.repository.QuarantineIncarnation(ctx, intent.OldIncarnation); err != nil {60+ return c.repository.DeleteIncarnation(ctx, old)
106- return err
107- }
108- if current.IncarnationID == intent.OldIncarnation {
109- if err := c.catalogue.Replace(intent.SessionName, next); err != nil {
110- return err
111- }
112- }
113- return c.journal.DeleteDiscard(ctx, intent.OldIncarnation)
114-}
115-
116-func validateDiscardIntent(intent domain.DiscardIntent) error {
117- if err := intent.OldRecord.Validate(); err != nil {
118- return fmt.Errorf("%w: invalid old record: %w", ErrDiscardIntentInvalid, err)
119- }
120- if intent.SessionName != intent.OldRecord.Name || intent.OldIncarnation != intent.OldRecord.IncarnationID ||
121- intent.NewIncarnation == (domain.IncarnationID{}) || intent.NewIncarnation == intent.OldIncarnation || intent.Reason == "" {
122- return fmt.Errorf("%w: inconsistent intent fields", ErrDiscardIntentInvalid)
123- }
124- return nil
125-}
126-
127-func quarantineDescriptor(intent domain.DiscardIntent) domain.QuarantineDescriptor {
128- return domain.QuarantineDescriptor{
129- OldRecord: intent.OldRecord, OldIncarnation: intent.OldIncarnation,
130- ReplacementIncarnation: intent.NewIncarnation, SessionName: intent.SessionName, Reason: intent.Reason,
131- }
132-}
133-
134-func freshReplacement(intent domain.DiscardIntent) domain.CatalogueRecord {
135- next := intent.OldRecord
136- next.IncarnationID = intent.NewIncarnation
137- next.RecoveryState = domain.RecoveryFresh
138- next.Committed = nil
139- next.DegradedReason = ""
140- return next
141 }61 }
D internal/usecase/recovery/reconcile.go +0 -173
@@ -1,173 +0,0 @@
1-package recovery
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-// Reconciler runs bounded repository maintenance after catalogue-driven
13-// restoration. Its cursor is caller-owned and can be resumed by a later pass.
14-type Reconciler struct {
15- coordinator *Coordinator
16- catalogue ports.Catalogue
17- repository ports.SnapshotRepository
18- budget ports.MaintenanceBudget
19-}
20-
21-func NewReconciler(coordinator *Coordinator, catalogue ports.Catalogue, repository ports.SnapshotRepository, budget ports.MaintenanceBudget) *Reconciler {
22- return &Reconciler{coordinator: coordinator, catalogue: catalogue, repository: repository, budget: budget}
23-}
24-
25-func (r *Reconciler) Step(ctx context.Context, cursor ports.ReconcileCursor) (ports.ReconcileCursor, []ports.ReconcileDecision, error) {
26- if r == nil || r.coordinator == nil || r.catalogue == nil || r.repository == nil || r.budget.Entries == 0 || r.budget.Bytes == 0 {
27- return cursor, nil, errors.New("recovery: incomplete reconciliation dependencies")
28- }
29- if err := ctx.Err(); err != nil {
30- return cursor, nil, err
31- }
32- records, err := r.catalogue.Records()
33- if err != nil {
34- return cursor, nil, err
35- }
36- next, findings, err := r.repository.Reconcile(ctx, records, cursor, r.budget)
37- if err != nil {
38- return cursor, nil, err
39- }
40- decisions := make([]ports.ReconcileDecision, 0, len(findings))
41- consumed := ports.MaintenanceBudget{}
42- for _, finding := range findings {
43- // Compare against the remainder before adding. This both enforces the
44- // page-wide budget and avoids uint64 wraparound.
45- if finding.Consumed.Entries > r.budget.Entries-consumed.Entries ||
46- finding.Consumed.Bytes > r.budget.Bytes-consumed.Bytes {
47- return cursor, nil, errors.New("recovery: repository exceeded reconciliation budget")
48- }
49- consumed.Entries += finding.Consumed.Entries
50- consumed.Bytes += finding.Consumed.Bytes
51- record, ok, err := r.catalogue.Record(finding.Candidate.Name)
52- if err != nil {
53- return cursor, nil, err
54- }
55- if !ok {
56- record = domain.CatalogueRecord{}
57- }
58- decision := classifyFinding(record, finding)
59- if decision.Kind == ports.ReconcileAdopt {
60- if _, err := r.coordinator.PublishReconciledCheckpoint(ctx, finding.Candidate.Name, finding.Candidate, finding.AncestorChain); err != nil {
61- if !errors.Is(err, ErrCheckpointConflict) && !errors.Is(err, ErrCheckpointRecordNotFound) {
62- return cursor, nil, err
63- }
64- decision.Kind = ports.ReconcileDefer
65- decision.ReasonCode = "catalogue-conflict"
66- decision.RetentionResolved = false
67- }
68- }
69- decisions = append(decisions, decision)
70- }
71- return next, decisions, nil
72-}
73-
74-func classifyFinding(record domain.CatalogueRecord, finding ports.ReconcileFinding) ports.ReconcileDecision {
75- decision := ports.ReconcileDecision{Name: finding.Candidate.Name}
76- candidate := finding.Candidate.Ref
77- decision.Candidate = &candidate
78- if finding.Status == ports.ReconcileBudgetExhausted {
79- decision.Kind = ports.ReconcileDefer
80- decision.ReasonCode = "budget-exhausted"
81- return decision
82- }
83- if finding.Kind == ports.ReconcileUnknownIncarnation || record.IncarnationID == (domain.IncarnationID{}) {
84- decision.Kind = ports.ReconcileKeepQuarantined
85- decision.ReasonCode = "unknown-incarnation"
86- return decision
87- }
88- if finding.Candidate.IncarnationID != record.IncarnationID {
89- decision.Kind = ports.ReconcileKeepQuarantined
90- decision.ReasonCode = "incarnation-mismatch"
91- return decision
92- }
93- if finding.Kind != ports.ReconcileForwardOrphan || record.Committed == nil {
94- decision.Kind = ports.ReconcileKeepQuarantined
95- decision.ReasonCode = "invalid-candidate"
96- return decision
97- }
98- if finding.Status == ports.ReconcileValidated && finding.Candidate.Ref.Generation <= record.Committed.Generation {
99- decision.Kind = ports.ReconcileKeepQuarantined
100- decision.ReasonCode = "not-forward"
101- decision.RetentionResolved = true
102- return decision
103- }
104- if finding.Status != ports.ReconcileValidated || !validForwardChain(*record.Committed, finding.Candidate, finding.AncestorChain) {
105- decision.Kind = ports.ReconcileKeepQuarantined
106- decision.ReasonCode = "invalid-chain"
107- return decision
108- }
109- decision.Kind = ports.ReconcileAdopt
110- decision.RetentionResolved = true
111- return decision
112-}
113-
114-func validForwardChain(committed domain.CheckpointRef, candidate ports.ReconcileCandidate, ancestors []ports.ValidatedCheckpoint) bool {
115- if committed.Generation == 0 || candidate.Ref.Generation <= committed.Generation || candidate.Parent == nil {
116- return false
117- }
118- if len(ancestors) == 0 {
119- return *candidate.Parent == committed
120- }
121- if *candidate.Parent != ancestors[0].Ref || candidate.Ref.Generation <= ancestors[0].Ref.Generation {
122- return false
123- }
124- for i, ancestor := range ancestors {
125- if ancestor.Parent == nil || ancestor.Ref.Generation <= committed.Generation {
126- return false
127- }
128- if i+1 < len(ancestors) {
129- if ancestor.Ref.Generation <= ancestors[i+1].Ref.Generation || *ancestor.Parent != ancestors[i+1].Ref {
130- return false
131- }
132- continue
133- }
134- if *ancestor.Parent != committed {
135- return false
136- }
137- }
138- return true
139-}
140-
141-// PublishReconciledCheckpoint rechecks the chain while holding the session
142-// transaction lock. The candidate is already the durable HEAD and its payloads
143-// were validated by the adapter, so publication performs no repository reread.
144-func (c *Coordinator) PublishReconciledCheckpoint(ctx context.Context, name string, candidate ports.ReconcileCandidate, ancestors []ports.ValidatedCheckpoint) (domain.CatalogueRecord, error) {
145- if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
146- return domain.CatalogueRecord{}, errors.New("recovery: incomplete reconciled checkpoint dependencies")
147- }
148- unlock := c.locks.Lock([]string{name})
149- defer unlock()
150- if err := ctx.Err(); err != nil {
151- return domain.CatalogueRecord{}, err
152- }
153- record, ok, err := c.catalogue.Record(name)
154- if err != nil {
155- return domain.CatalogueRecord{}, err
156- }
157- if !ok {
158- return domain.CatalogueRecord{}, ErrCheckpointRecordNotFound
159- }
160- if record.RecoveryState != domain.RecoveryHealthy || candidate.Name != name || candidate.IncarnationID != record.IncarnationID || record.Committed == nil || !validForwardChain(*record.Committed, candidate, ancestors) {
161- return domain.CatalogueRecord{}, ErrCheckpointConflict
162- }
163- next := record
164- next.Committed = copyCheckpointRef(&candidate.Ref)
165- next.DegradedReason = ""
166- if err := next.Validate(); err != nil {
167- return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid reconciled checkpoint transition: %w", err)
168- }
169- if err := c.catalogue.Replace(name, next); err != nil {
170- return domain.CatalogueRecord{}, err
171- }
172- return next, nil
173-}
D internal/usecase/recovery/reconcile_test.go +0 -248
@@ -1,248 +0,0 @@
1-package recovery
2-
3-import (
4- "context"
5- "fmt"
6- "testing"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
10- "github.com/stretchr/testify/require"
11-)
12-
13-type reconcileRepository struct {
14- ports.SnapshotRepository
15- pages map[uint64]reconcilePage
16- seenCursors []ports.ReconcileCursor
17- seenBudgets []ports.MaintenanceBudget
18- repairs []domain.CheckpointRef
19- events *[]string
20-}
21-
22-type reconcilePage struct {
23- next ports.ReconcileCursor
24- findings []ports.ReconcileFinding
25- err error
26-}
27-
28-type reconcileConflictCatalogue struct {
29- *transactionCatalogue
30- stale domain.CatalogueRecord
31- reads int
32-}
33-
34-func (c *reconcileConflictCatalogue) Records() ([]domain.CatalogueRecord, error) {
35- records, err := c.transactionCatalogue.Records()
36- if err != nil {
37- return nil, err
38- }
39- for i := range records {
40- if records[i].Name == c.stale.Name {
41- records[i] = c.stale
42- }
43- }
44- return records, nil
45-}
46-
47-func (c *reconcileConflictCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
48- if name == c.stale.Name && c.reads == 0 {
49- c.reads++
50- return c.stale, true, nil
51- }
52- return c.transactionCatalogue.Record(name)
53-}
54-
55-func (r *reconcileRepository) Reconcile(_ context.Context, _ []domain.CatalogueRecord, cursor ports.ReconcileCursor, budget ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) {
56- r.seenCursors = append(r.seenCursors, cursor)
57- r.seenBudgets = append(r.seenBudgets, budget)
58- page := r.pages[cursor.DirectoryCookie]
59- return page.next, page.findings, page.err
60-}
61-
62-func (r *reconcileRepository) RepairHEAD(_ context.Context, _ domain.IncarnationID, ref ports.CheckpointRef) error {
63- if r.events != nil {
64- *r.events = append(*r.events, "head")
65- }
66- r.repairs = append(r.repairs, ref)
67- return nil
68-}
69-
70-func TestReconciliationCursor(t *testing.T) {
71- record := healthyReconcileRecord()
72- catalogue := &checkpointCatalogue{record: record}
73- candidate := forwardCandidate(record, 2, record.Committed)
74- repository := &reconcileRepository{pages: map[uint64]reconcilePage{
75- 0: {next: ports.ReconcileCursor{DirectoryCookie: 12}, findings: []ports.ReconcileFinding{{Status: ports.ReconcileBudgetExhausted, Cursor: ports.ReconcileCursor{DirectoryCookie: 12}, Consumed: ports.MaintenanceBudget{Entries: 1, Bytes: 16}}}},
76- 12: {next: ports.ReconcileCursor{DirectoryCookie: 24}, findings: []ports.ReconcileFinding{{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: candidate, Cursor: ports.ReconcileCursor{DirectoryCookie: 24}, Consumed: ports.MaintenanceBudget{Entries: 2, Bytes: 32}}}},
77- }}
78- coordinator := NewCoordinator(catalogue, repository, nil, nil)
79- reconciler := &Reconciler{coordinator: coordinator, catalogue: catalogue, repository: repository, budget: ports.MaintenanceBudget{Entries: 2, Bytes: 32}}
80-
81- next, decisions, err := reconciler.Step(context.Background(), ports.ReconcileCursor{})
82- require.NoError(t, err)
83- require.Equal(t, uint64(12), next.DirectoryCookie)
84- require.Equal(t, ports.ReconcileDefer, decisions[0].Kind)
85- require.Equal(t, "budget-exhausted", decisions[0].ReasonCode)
86-
87- next, decisions, err = reconciler.Step(context.Background(), next)
88- require.NoError(t, err)
89- require.Equal(t, uint64(24), next.DirectoryCookie)
90- require.Equal(t, ports.ReconcileAdopt, decisions[0].Kind)
91- require.Equal(t, []ports.ReconcileCursor{{}, {DirectoryCookie: 12}}, repository.seenCursors)
92- require.Equal(t, []ports.MaintenanceBudget{{Entries: 2, Bytes: 32}, {Entries: 2, Bytes: 32}}, repository.seenBudgets)
93-}
94-
95-func TestReconciliationRejectsCumulativeAndOverflowingBudgets(t *testing.T) {
96- record := healthyReconcileRecord()
97- finding := ports.ReconcileFinding{Status: ports.ReconcileBudgetExhausted, Candidate: ports.ReconcileCandidate{Name: record.Name}}
98- for _, tc := range []struct {
99- name string
100- budget ports.MaintenanceBudget
101- consumed []ports.MaintenanceBudget
102- }{
103- {name: "cumulative entries", budget: ports.MaintenanceBudget{Entries: 3, Bytes: 10}, consumed: []ports.MaintenanceBudget{{Entries: 2, Bytes: 1}, {Entries: 2, Bytes: 1}}},
104- {name: "cumulative bytes", budget: ports.MaintenanceBudget{Entries: 10, Bytes: 3}, consumed: []ports.MaintenanceBudget{{Entries: 1, Bytes: 2}, {Entries: 1, Bytes: 2}}},
105- {name: "overflow safe", budget: ports.MaintenanceBudget{Entries: ^uint64(0), Bytes: ^uint64(0)}, consumed: []ports.MaintenanceBudget{{Entries: ^uint64(0), Bytes: ^uint64(0)}, {Entries: 1, Bytes: 1}}},
106- } {
107- t.Run(tc.name, func(t *testing.T) {
108- findings := make([]ports.ReconcileFinding, len(tc.consumed))
109- for i, usage := range tc.consumed {
110- findings[i] = finding
111- findings[i].Consumed = usage
112- }
113- repository := &reconcileRepository{pages: map[uint64]reconcilePage{0: {findings: findings}}}
114- catalogue := &checkpointCatalogue{record: record}
115- reconciler := NewReconciler(NewCoordinator(catalogue, repository, nil, nil), catalogue, repository, tc.budget)
116- _, _, err := reconciler.Step(context.Background(), ports.ReconcileCursor{})
117- require.EqualError(t, err, "recovery: repository exceeded reconciliation budget")
118- })
119- }
120-}
121-
122-func TestReconciliationDefersConflictAndContinues(t *testing.T) {
123- first := healthyReconcileRecord()
124- first.Name = "first"
125- staleRecord := first
126- stale := forwardCandidate(staleRecord, 2, staleRecord.Committed)
127- first.Committed = &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{9}}
128- second := healthyReconcileRecord()
129- second.Name = "second"
130- adoptable := forwardCandidate(second, 2, second.Committed)
131- catalogue := &reconcileConflictCatalogue{transactionCatalogue: newTransactionCatalogue(first, second), stale: staleRecord}
132- repository := &reconcileRepository{pages: map[uint64]reconcilePage{0: {findings: []ports.ReconcileFinding{
133- {Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: stale, Consumed: ports.MaintenanceBudget{Entries: 1, Bytes: 1}},
134- {Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: adoptable, Consumed: ports.MaintenanceBudget{Entries: 1, Bytes: 1}},
135- }}}}
136- reconciler := NewReconciler(NewCoordinator(catalogue, repository, nil, nil), catalogue, repository, ports.MaintenanceBudget{Entries: 2, Bytes: 2})
137-
138- _, decisions, err := reconciler.Step(context.Background(), ports.ReconcileCursor{})
139- require.NoError(t, err)
140- require.Equal(t, []ports.ReconcileDecisionKind{ports.ReconcileDefer, ports.ReconcileAdopt}, []ports.ReconcileDecisionKind{decisions[0].Kind, decisions[1].Kind})
141- require.Equal(t, "catalogue-conflict", decisions[0].ReasonCode)
142- require.Equal(t, adoptable.Ref, *catalogue.records[second.Name].Committed)
143-}
144-
145-func TestForwardOrphanAdoption(t *testing.T) {
146- for _, tc := range []struct {
147- name string
148- mutate func(domain.CatalogueRecord, *ports.ReconcileFinding)
149- wantKind ports.ReconcileDecisionKind
150- wantCause string
151- }{
152- {name: "direct child", wantKind: ports.ReconcileAdopt},
153- {name: "two parent chain", mutate: addReconcileAncestor, wantKind: ports.ReconcileAdopt},
154- {name: "wrong incarnation", mutate: func(record domain.CatalogueRecord, finding *ports.ReconcileFinding) {
155- finding.Candidate.IncarnationID = domain.IncarnationID{9}
156- }, wantKind: ports.ReconcileKeepQuarantined, wantCause: "incarnation-mismatch"},
157- {name: "wrong digest", mutate: func(record domain.CatalogueRecord, finding *ports.ReconcileFinding) {
158- finding.Candidate.Parent.ManifestDigest[0]++
159- }, wantKind: ports.ReconcileKeepQuarantined, wantCause: "invalid-chain"},
160- {name: "missing link", mutate: func(record domain.CatalogueRecord, finding *ports.ReconcileFinding) { finding.Candidate.Parent = nil }, wantKind: ports.ReconcileKeepQuarantined, wantCause: "invalid-chain"},
161- {name: "generation reversal", mutate: func(record domain.CatalogueRecord, finding *ports.ReconcileFinding) {
162- finding.Candidate.Ref.Generation = record.Committed.Generation
163- }, wantKind: ports.ReconcileKeepQuarantined, wantCause: "not-forward"},
164- {name: "non-orphan invalid candidate", mutate: func(record domain.CatalogueRecord, finding *ports.ReconcileFinding) {
165- finding.Kind = ports.ReconcileInvalidCandidate
166- }, wantKind: ports.ReconcileKeepQuarantined, wantCause: "invalid-candidate"},
167- } {
168- t.Run(tc.name, func(t *testing.T) {
169- record := healthyReconcileRecord()
170- finding := ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: forwardCandidate(record, 2, record.Committed)}
171- if tc.mutate != nil {
172- tc.mutate(record, &finding)
173- }
174- decision := classifyFinding(record, finding)
175- require.Equal(t, tc.wantKind, decision.Kind)
176- require.Equal(t, tc.wantCause, decision.ReasonCode)
177- })
178- }
179-
180- t.Run("validated head publishes without repository reread", func(t *testing.T) {
181- record := healthyReconcileRecord()
182- events := []string{}
183- catalogue := &checkpointCatalogue{record: record, events: &events}
184- repository := &reconcileRepository{events: &events}
185- coordinator := NewCoordinator(catalogue, repository, nil, nil)
186- candidate := forwardCandidate(record, 2, record.Committed)
187-
188- next, err := coordinator.PublishReconciledCheckpoint(context.Background(), record.Name, candidate, nil)
189- require.NoError(t, err)
190- require.Equal(t, candidate.Ref, *next.Committed)
191- require.Equal(t, []string{"catalogue"}, events)
192- require.Empty(t, repository.repairs)
193- })
194-
195- for _, state := range []domain.RecoveryState{domain.RecoveryDegraded, domain.RecoveryDeleting} {
196- t.Run(fmt.Sprintf("non-healthy record %d is not reconciled", state), func(t *testing.T) {
197- record := healthyReconcileRecord()
198- record.RecoveryState = state
199- if state == domain.RecoveryDegraded {
200- record.DegradedReason = "uncertain"
201- } else {
202- record.Committed = nil
203- }
204- candidate := forwardCandidate(healthyReconcileRecord(), 2, healthyReconcileRecord().Committed)
205- coordinator := NewCoordinator(&checkpointCatalogue{record: record}, &reconcileRepository{}, nil, nil)
206-
207- _, err := coordinator.PublishReconciledCheckpoint(context.Background(), record.Name, candidate, nil)
208- require.ErrorIs(t, err, ErrCheckpointConflict)
209- })
210- }
211-
212- t.Run("stale decision after concurrent commit", func(t *testing.T) {
213- record := healthyReconcileRecord()
214- candidate := forwardCandidate(record, 2, record.Committed)
215- concurrent := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{7}}
216- record.Committed = &concurrent
217- catalogue := &checkpointCatalogue{record: record}
218- repository := &reconcileRepository{}
219- coordinator := NewCoordinator(catalogue, repository, nil, nil)
220-
221- _, err := coordinator.PublishReconciledCheckpoint(context.Background(), record.Name, candidate, nil)
222- require.ErrorIs(t, err, ErrCheckpointConflict)
223- require.Empty(t, repository.repairs)
224- })
225-
226- t.Run("unknown identity remains quarantined", func(t *testing.T) {
227- finding := ports.ReconcileFinding{Kind: ports.ReconcileUnknownIncarnation, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{Name: "unknown", IncarnationID: domain.IncarnationID{9}}}
228- decision := classifyFinding(domain.CatalogueRecord{}, finding)
229- require.Equal(t, ports.ReconcileKeepQuarantined, decision.Kind)
230- require.Equal(t, "unknown-incarnation", decision.ReasonCode)
231- })
232-}
233-
234-func healthyReconcileRecord() domain.CatalogueRecord {
235- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
236- return domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, Committed: &ref}
237-}
238-
239-func forwardCandidate(record domain.CatalogueRecord, generation uint64, parent *domain.CheckpointRef) ports.ReconcileCandidate {
240- parentCopy := *parent
241- return ports.ReconcileCandidate{IncarnationID: record.IncarnationID, Name: record.Name, Ref: domain.CheckpointRef{Generation: generation, ManifestDigest: [32]byte{byte(generation)}}, Parent: &parentCopy}
242-}
243-
244-func addReconcileAncestor(record domain.CatalogueRecord, finding *ports.ReconcileFinding) {
245- ancestor := ports.ValidatedCheckpoint{Ref: domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}, Parent: record.Committed}
246- finding.Candidate = forwardCandidate(record, 3, &ancestor.Ref)
247- finding.AncestorChain = []ports.ValidatedCheckpoint{ancestor}
248-}
M internal/usecase/recovery/transactions_test.go +66 -280
@@ -1,6 +1,7 @@
1 package recovery1 package recovery
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "errors"6 "errors"
6 "sync"7 "sync"
@@ -12,9 +13,10 @@ import (
12 )13 )
13 14
14 type transactionCatalogue struct {15 type transactionCatalogue struct {
15- mu sync.Mutex16+ mu sync.Mutex
16- records map[string]domain.CatalogueRecord17+ records map[string]domain.CatalogueRecord
17- events []string18+ events []string
19+ replaceErrOnce error
18 }20 }
19 21
20 func newTransactionCatalogue(records ...domain.CatalogueRecord) *transactionCatalogue {22 func newTransactionCatalogue(records ...domain.CatalogueRecord) *transactionCatalogue {
@@ -34,12 +36,14 @@ func (c *transactionCatalogue) Records() ([]domain.CatalogueRecord, error) {
34 }36 }
35 return out, nil37 return out, nil
36 }38 }
39+
37 func (c *transactionCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {40 func (c *transactionCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
38 c.mu.Lock()41 c.mu.Lock()
39 defer c.mu.Unlock()42 defer c.mu.Unlock()
40 record, ok := c.records[name]43 record, ok := c.records[name]
41 return record, ok, nil44 return record, ok, nil
42 }45 }
46+
43 func (c *transactionCatalogue) Create(record domain.CatalogueRecord) error {47 func (c *transactionCatalogue) Create(record domain.CatalogueRecord) error {
44 c.mu.Lock()48 c.mu.Lock()
45 defer c.mu.Unlock()49 defer c.mu.Unlock()
@@ -50,10 +54,17 @@ func (c *transactionCatalogue) Create(record domain.CatalogueRecord) error {
50 c.events = append(c.events, "create")54 c.events = append(c.events, "create")
51 return nil55 return nil
52 }56 }
57+
53 func (c *transactionCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error { return nil }58 func (c *transactionCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error { return nil }
59+
54 func (c *transactionCatalogue) Replace(name string, record domain.CatalogueRecord) error {60 func (c *transactionCatalogue) Replace(name string, record domain.CatalogueRecord) error {
55 c.mu.Lock()61 c.mu.Lock()
56 defer c.mu.Unlock()62 defer c.mu.Unlock()
63+ if c.replaceErrOnce != nil {
64+ err := c.replaceErrOnce
65+ c.replaceErrOnce = nil
66+ return err
67+ }
57 if _, exists := c.records[name]; !exists {68 if _, exists := c.records[name]; !exists {
58 return errors.New("record missing")69 return errors.New("record missing")
59 }70 }
@@ -61,6 +72,7 @@ func (c *transactionCatalogue) Replace(name string, record domain.CatalogueRecor
61 c.events = append(c.events, "replace:"+name)72 c.events = append(c.events, "replace:"+name)
62 return nil73 return nil
63 }74 }
75+
64 func (c *transactionCatalogue) Rename(oldName string, record domain.CatalogueRecord) error {76 func (c *transactionCatalogue) Rename(oldName string, record domain.CatalogueRecord) error {
65 c.mu.Lock()77 c.mu.Lock()
66 defer c.mu.Unlock()78 defer c.mu.Unlock()
@@ -75,6 +87,7 @@ func (c *transactionCatalogue) Rename(oldName string, record domain.CatalogueRec
75 c.events = append(c.events, "rename:"+oldName+":"+record.Name)87 c.events = append(c.events, "rename:"+oldName+":"+record.Name)
76 return nil88 return nil
77 }89 }
90+
78 func (c *transactionCatalogue) Delete(name string) error {91 func (c *transactionCatalogue) Delete(name string) error {
79 c.mu.Lock()92 c.mu.Lock()
80 defer c.mu.Unlock()93 defer c.mu.Unlock()
@@ -82,126 +95,25 @@ func (c *transactionCatalogue) Delete(name string) error {
82 c.events = append(c.events, "delete:"+name)95 c.events = append(c.events, "delete:"+name)
83 return nil96 return nil
84 }97 }
98+
85 func (c *transactionCatalogue) Close() error { return nil }99 func (c *transactionCatalogue) Close() error { return nil }
86 100
87 type transactionRepository struct {101 type transactionRepository struct {
88 ports.SnapshotRepository102 ports.SnapshotRepository
89- mu sync.Mutex103+ mu sync.Mutex
90- tombstones map[domain.IncarnationID]domain.DeletionTombstone104+ deleteErrOnce error
91- generations map[domain.CheckpointRef]ports.SnapshotGeneration105+ deletedIDs []domain.IncarnationID
92- loadErr error
93- quarantineErr error
94- events []string
95- quarantine []bool
96- descriptors []domain.QuarantineDescriptor
97- quarantinedIDs []domain.IncarnationID
98- repaired []domain.CheckpointRef
99 }106 }
100 107
101-func newTransactionRepository() *transactionRepository {108+func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error {
102- return &transactionRepository{
103- tombstones: make(map[domain.IncarnationID]domain.DeletionTombstone),
104- generations: make(map[domain.CheckpointRef]ports.SnapshotGeneration),
105- }
106-}
107-func (r *transactionRepository) LoadCheckpoint(_ context.Context, _ domain.IncarnationID, _ string, ref domain.CheckpointRef) (ports.SnapshotGeneration, error) {
108- r.mu.Lock()
109- defer r.mu.Unlock()
110- r.events = append(r.events, "load")
111- if r.loadErr != nil {
112- return ports.SnapshotGeneration{}, r.loadErr
113- }
114- generation, ok := r.generations[ref]
115- if !ok {
116- return ports.SnapshotGeneration{}, errors.New("checkpoint missing")
117- }
118- return generation, nil
119-}
120-func (r *transactionRepository) RepairHEAD(_ context.Context, _ domain.IncarnationID, ref domain.CheckpointRef) error {
121- r.mu.Lock()
122- defer r.mu.Unlock()
123- r.repaired = append(r.repaired, ref)
124- r.events = append(r.events, "repair-head")
125- return nil
126-}
127-func (r *transactionRepository) SaveQuarantineDescriptor(_ context.Context, descriptor domain.QuarantineDescriptor) error {
128- r.mu.Lock()
129- defer r.mu.Unlock()
130- r.descriptors = append(r.descriptors, descriptor)
131- r.events = append(r.events, "descriptor")
132- return nil
133-}
134-func (r *transactionRepository) QuarantineIncarnation(_ context.Context, id domain.IncarnationID) error {
135- r.mu.Lock()
136- defer r.mu.Unlock()
137- r.quarantinedIDs = append(r.quarantinedIDs, id)
138- r.events = append(r.events, "quarantine-incarnation")
139- return nil
140-}
141-func (r *transactionRepository) WriteDeletionTombstone(_ context.Context, tombstone domain.DeletionTombstone) error {
142- r.mu.Lock()
143- defer r.mu.Unlock()
144- r.tombstones[tombstone.IncarnationID] = tombstone
145- r.events = append(r.events, "tombstone")
146- return nil
147-}
148-func (r *transactionRepository) ListDeletionTombstones(_ context.Context, cursor ports.DeletionTombstoneCursor, _ ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {
149- r.mu.Lock()
150- defer r.mu.Unlock()
151- if cursor.After != "" || len(r.tombstones) == 0 {
152- return ports.DeletionTombstonePage{Done: true}, nil
153- }
154- out := make([]domain.DeletionTombstone, 0, len(r.tombstones))
155- for _, tombstone := range r.tombstones {
156- out = append(out, tombstone)
157- }
158- return ports.DeletionTombstonePage{Tombstones: out, Done: true}, nil
159-}
160-func (r *transactionRepository) QuarantineDeletionSources(_ context.Context, _ domain.DeletionTombstone, includeLegacyName bool) error {
161- r.mu.Lock()
162- defer r.mu.Unlock()
163- r.events = append(r.events, "quarantine")
164- r.quarantine = append(r.quarantine, includeLegacyName)
165- return r.quarantineErr
166-}
167-func (r *transactionRepository) DeleteDeletionTombstone(_ context.Context, id domain.IncarnationID) error {
168 r.mu.Lock()109 r.mu.Lock()
169 defer r.mu.Unlock()110 defer r.mu.Unlock()
170- delete(r.tombstones, id)111+ if r.deleteErrOnce != nil {
171- r.events = append(r.events, "remove-tombstone")112+ err := r.deleteErrOnce
172- return nil113+ r.deleteErrOnce = nil
173-}114+ return err
174-
175-type transactionJournal struct {
176- mu sync.Mutex
177- intents map[domain.IncarnationID]domain.DiscardIntent
178- events []string
179-}
180-
181-func newTransactionJournal() *transactionJournal {
182- return &transactionJournal{intents: make(map[domain.IncarnationID]domain.DiscardIntent)}
183-}
184-func (j *transactionJournal) SaveDiscard(_ context.Context, intent domain.DiscardIntent) error {
185- j.mu.Lock()
186- defer j.mu.Unlock()
187- j.intents[intent.OldIncarnation] = intent
188- j.events = append(j.events, "intent")
189- return nil
190-}
191-func (j *transactionJournal) ListDiscards(context.Context) ([]domain.DiscardIntent, error) {
192- j.mu.Lock()
193- defer j.mu.Unlock()
194- out := make([]domain.DiscardIntent, 0, len(j.intents))
195- for _, intent := range j.intents {
196- out = append(out, intent)
197 }115 }
198- return out, nil116+ r.deletedIDs = append(r.deletedIDs, id)
199-}
200-func (j *transactionJournal) DeleteDiscard(_ context.Context, id domain.IncarnationID) error {
201- j.mu.Lock()
202- defer j.mu.Unlock()
203- delete(j.intents, id)
204- j.events = append(j.events, "delete-intent")
205 return nil117 return nil
206 }118 }
207 119
@@ -213,171 +125,45 @@ func degradedTransactionRecord() domain.CatalogueRecord {
213 }125 }
214 }126 }
215 127
216-func TestDiscardRecoveryRemovesIntentWhenCatalogueRecordIsAbsent(t *testing.T) {128+func TestDiscardIsRetryIdempotent(t *testing.T) {
217- old := degradedTransactionRecord()129+ t.Parallel()
218- intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}130+ failure := errors.New("injected discard failure")
219- catalogue := newTransactionCatalogue()131+ tests := []struct {
220- journal := newTransactionJournal()132+ name string
221- journal.intents[old.IncarnationID] = intent133+ failBefore bool
222- coordinator := NewCoordinator(catalogue, newTransactionRepository(), journal, nil)134+ }{
223-135+ {name: "retry from old catalogue record", failBefore: true},
224- require.NoError(t, coordinator.Recover(context.Background()))136+ {name: "retry from fresh catalogue record"},
225- require.NotContains(t, journal.intents, old.IncarnationID)137+ }
226- require.Equal(t, []string{"delete-intent"}, journal.events)138+ for _, tt := range tests {
227- require.Empty(t, coordinator.Conflicts())139+ t.Run(tt.name, func(t *testing.T) {
228-}140+ t.Parallel()
229-141+ old := degradedTransactionRecord()
230-func TestDiscardIncarnationConflict(t *testing.T) {142+ catalogue := newTransactionCatalogue(old)
231- old := degradedTransactionRecord()143+ repository := &transactionRepository{}
232- intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}144+ if tt.failBefore {
233- conflict := old145+ catalogue.replaceErrOnce = failure
234- conflict.IncarnationID = domain.IncarnationID{3}146+ } else {
235- catalogue := newTransactionCatalogue(conflict)147+ repository.deleteErrOnce = failure
236- journal := newTransactionJournal()148+ }
237- journal.intents[old.IncarnationID] = intent149+ ids := append(bytes.Repeat([]byte{2}, 16), bytes.Repeat([]byte{3}, 16)...)
238- repository := newTransactionRepository()150+ coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(ids))
239- coordinator := NewCoordinator(catalogue, repository, journal, nil)151+
240-152+ err := coordinator.Discard(context.Background(), old.Name)
241- require.NoError(t, coordinator.Recover(context.Background()))153+ require.ErrorIs(t, err, failure)
242- require.Equal(t, conflict, catalogue.records[old.Name])154+ require.NoError(t, coordinator.Discard(context.Background(), old.Name))
243- require.Contains(t, journal.intents, old.IncarnationID)155+
244- require.Empty(t, repository.events)156+ got, ok, err := catalogue.Record(old.Name)
245- conflicts := coordinator.Conflicts()157+ require.NoError(t, err)
246- require.Len(t, conflicts, 1)158+ require.True(t, ok)
247- require.Equal(t, old.Name, conflicts[0].Session)159+ require.Nil(t, got.Committed)
248- require.Equal(t, "discard-intent", conflicts[0].Kind)160+ require.Equal(t, domain.RecoveryFresh, got.RecoveryState)
249- require.ErrorIs(t, conflicts[0].Err, ErrDiscardConflict)161+ require.NotEqual(t, old.IncarnationID, got.IncarnationID)
250-162+ if tt.failBefore {
251- delete(journal.intents, old.IncarnationID)163+ require.Contains(t, repository.deletedIDs, old.IncarnationID)
252- require.NoError(t, coordinator.Recover(context.Background()))164+ } else {
253- require.Empty(t, coordinator.Conflicts(), "a later recovery reports only its own conflicts")165+ require.Empty(t, repository.deletedIDs, "startup garbage collection owns the orphaned old directory")
254-}166+ }
255-167+ })
256-// A discard whose replacement is already durable, live, and checkpointed must
257-// roll forward. Requiring byte equality with the pristine replacement would
258-// leave the intent unresolvable and abort every future startup.
259-func TestDiscardRecoveryRollsForwardEvolvedReplacement(t *testing.T) {
260- old := degradedTransactionRecord()
261- intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
262- evolved := freshReplacement(intent)
263- evolved.RecoveryState = domain.RecoveryHealthy
264- evolved.Committed = &domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
265- evolved.UpdatedAt = 1234
266- evolved.LastUsedSeq = 9
267- evolved.TabNames = []string{"one"}
268- require.NoError(t, evolved.Validate())
269-
270- catalogue := newTransactionCatalogue(evolved)
271- journal := newTransactionJournal()
272- journal.intents[old.IncarnationID] = intent
273- repository := newTransactionRepository()
274- coordinator := NewCoordinator(catalogue, repository, journal, nil)
275-
276- require.NoError(t, coordinator.Recover(context.Background()))
277- require.Empty(t, coordinator.Conflicts())
278- require.Equal(t, evolved, catalogue.records[old.Name], "committed replacement state must survive roll-forward")
279- require.Empty(t, catalogue.events, "the live replacement record must not be rewritten")
280- require.Equal(t, []domain.QuarantineDescriptor{quarantineDescriptor(intent)}, repository.descriptors)
281- require.Equal(t, []domain.IncarnationID{intent.OldIncarnation}, repository.quarantinedIDs)
282- require.NotContains(t, journal.intents, old.IncarnationID)
283-}
284-
285-// Goal 3: one session's self-inconsistent durable state must not stop a healthy
286-// neighbour from being recovered, and must not silently resolve itself either.
287-func TestRecoverFencesOneSessionAndContinues(t *testing.T) {
288- ctx := context.Background()
289- broken := degradedTransactionRecord()
290- intent := domain.DiscardIntent{OldRecord: broken, OldIncarnation: broken.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: broken.Name, Reason: "discard"}
291- conflict := broken
292- conflict.IncarnationID = domain.IncarnationID{3}
293- deleting := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDeleting}
294-
295- catalogue := newTransactionCatalogue(conflict, deleting)
296- journal := newTransactionJournal()
297- journal.intents[broken.IncarnationID] = intent
298- repository := newTransactionRepository()
299- coordinator := NewCoordinator(catalogue, repository, journal, nil)
300-
301- require.NoError(t, coordinator.Recover(ctx))
302- require.NotContains(t, catalogue.records, deleting.Name, "healthy deletion must complete next to a fenced session")
303- require.Empty(t, repository.tombstones)
304- require.Equal(t, conflict, catalogue.records[broken.Name])
305- require.Contains(t, journal.intents, broken.IncarnationID)
306- require.Len(t, coordinator.Conflicts(), 1)
307-}
308-
309-// A tombstone that disagrees with a live healthy record degrades exactly that
310-// record, keeps the tombstone, and leaves every sibling tombstone processed.
311-func TestRecoverDegradesSessionOnTombstoneConflict(t *testing.T) {
312- ctx := context.Background()
313- live := domain.CatalogueRecord{
314- Name: "live", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryHealthy,
315- Committed: &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}},
316 }168 }
317- conflicting := domain.DeletionTombstone{Name: live.Name, IncarnationID: live.IncarnationID}
318- orphan := domain.DeletionTombstone{Name: "gone", IncarnationID: domain.IncarnationID{6}}
319-
320- catalogue := newTransactionCatalogue(live)
321- repository := newTransactionRepository()
322- repository.tombstones[conflicting.IncarnationID] = conflicting
323- repository.tombstones[orphan.IncarnationID] = orphan
324- coordinator := NewCoordinator(catalogue, repository, journalStub{}, nil)
325-
326- require.NoError(t, coordinator.Recover(ctx))
327- got := catalogue.records[live.Name]
328- require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
329- require.Equal(t, degradedReasonTombstone, got.DegradedReason)
330- require.Equal(t, live.Committed, got.Committed)
331- require.Contains(t, repository.tombstones, conflicting.IncarnationID, "a fenced tombstone is never removed")
332- require.NotContains(t, repository.tombstones, orphan.IncarnationID)
333- conflicts := coordinator.Conflicts()
334- require.Len(t, conflicts, 1)
335- require.Equal(t, "deletion-tombstone", conflicts[0].Kind)
336- require.ErrorIs(t, conflicts[0].Err, ErrDeletionTombstoneConflict)
337-}
338-
339-// Infrastructure failures still abort startup: they say nothing about any one
340-// session, so continuing would mutate durable state on a broken substrate.
341-func TestRecoverFailsOnInfrastructureErrors(t *testing.T) {
342- ctx := context.Background()
343- cause := errors.New("io failure")
344- tombstone := domain.DeletionTombstone{Name: "work", IncarnationID: domain.IncarnationID{4}}
345-
346- t.Run("journal listing", func(t *testing.T) {
347- coordinator := NewCoordinator(newTransactionCatalogue(), newTransactionRepository(), journalStub{err: cause}, nil)
348- require.ErrorIs(t, coordinator.Recover(ctx), cause)
349- })
350- t.Run("quarantine write", func(t *testing.T) {
351- repository := newTransactionRepository()
352- repository.tombstones[tombstone.IncarnationID] = tombstone
353- repository.quarantineErr = cause
354- coordinator := NewCoordinator(newTransactionCatalogue(), repository, journalStub{}, nil)
355- require.ErrorIs(t, coordinator.Recover(ctx), cause)
356- require.Empty(t, coordinator.Conflicts())
357- })
358-}
359-
360-func TestDeleteRecoveryAfterCatalogueRemoval(t *testing.T) {
361- ctx := context.Background()
362- old := domain.DeletionTombstone{Name: "work", IncarnationID: domain.IncarnationID{4}}
363- t.Run("absent catalogue record", func(t *testing.T) {
364- catalogue := newTransactionCatalogue()
365- repository := newTransactionRepository()
366- repository.tombstones[old.IncarnationID] = old
367- coordinator := NewCoordinator(catalogue, repository, journalStub{}, nil)
368- require.NoError(t, coordinator.Recover(ctx))
369- require.Empty(t, repository.tombstones)
370- require.Equal(t, []bool{true}, repository.quarantine)
371- })
372- t.Run("reused name preserves new incarnation", func(t *testing.T) {
373- replacement := domain.CatalogueRecord{Name: old.Name, IncarnationID: domain.IncarnationID{9}, RecoveryState: domain.RecoveryFresh}
374- catalogue := newTransactionCatalogue(replacement)
375- repository := newTransactionRepository()
376- repository.tombstones[old.IncarnationID] = old
377- coordinator := NewCoordinator(catalogue, repository, journalStub{}, nil)
378- require.NoError(t, coordinator.Recover(ctx))
379- require.Equal(t, replacement, catalogue.records[old.Name])
380- require.Equal(t, []bool{false}, repository.quarantine)
381- require.Empty(t, repository.tombstones)
382- })
383 }169 }

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