detached dfc95be

feat(recovery): define durable session identities

commit
dfc95bea73495fad68b9284c0d5dc93c68c0dc2e
author
brice · · 2026-07-25 19:23 UTC
parent
623e7c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +264 -0
A internal/domain/recovery.go +183 -0
@@ -0,0 +1,183 @@
1+package domain
2+
3+import (
4+ "encoding/hex"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "strings"
9+)
10+
11+// IncarnationID permanently distinguishes uses of the same session name.
12+type IncarnationID [16]byte
13+
14+// RecoveryState describes whether and how a named session can be restored.
15+type RecoveryState uint8
16+
17+const (
18+ RecoveryFresh RecoveryState = iota + 1
19+ RecoveryHealthy
20+ RecoveryDegraded
21+ RecoveryDeleting
22+)
23+
24+// CheckpointRef identifies one immutable snapshot manifest.
25+type CheckpointRef struct {
26+ Generation uint64
27+ ManifestDigest [32]byte
28+}
29+
30+// CatalogueRecord is the durable source of truth for one named session.
31+type CatalogueRecord struct {
32+ Name string
33+ IncarnationID IncarnationID
34+ Cwd string
35+ CreatedAt int64
36+ UpdatedAt int64
37+ LastUsedSeq uint64
38+ TabNames []string
39+ RecoveryState RecoveryState
40+ Committed *CheckpointRef
41+ Fallbacks [2]*CheckpointRef
42+ DegradedReason string
43+}
44+
45+// DiscardIntent records a requested replacement of uncertain persisted state.
46+type DiscardIntent struct {
47+ OldRecord CatalogueRecord
48+ OldIncarnation IncarnationID
49+ NewIncarnation IncarnationID
50+ SessionName string
51+ Reason string
52+}
53+
54+// QuarantineDescriptor records persisted state retained during replacement.
55+type QuarantineDescriptor struct {
56+ OldRecord CatalogueRecord
57+ OldIncarnation IncarnationID
58+ ReplacementIncarnation IncarnationID
59+ SessionName string
60+ Reason string
61+}
62+
63+// DeletionTombstone identifies exactly one deleted session incarnation.
64+type DeletionTombstone struct {
65+ Name string
66+ IncarnationID IncarnationID
67+}
68+
69+// Validate rejects incomplete deletion identities.
70+func (t DeletionTombstone) Validate() error {
71+ if err := ValidateSessionName(t.Name); err != nil {
72+ return err
73+ }
74+ if t.IncarnationID == (IncarnationID{}) {
75+ return errors.New("zero deletion incarnation ID")
76+ }
77+ return nil
78+}
79+
80+// NewIncarnationID reads a new non-zero identity from r.
81+func NewIncarnationID(r io.Reader) (IncarnationID, error) {
82+ var id IncarnationID
83+ if _, err := io.ReadFull(r, id[:]); err != nil {
84+ return id, err
85+ }
86+ if id == (IncarnationID{}) {
87+ return id, errors.New("zero incarnation ID")
88+ }
89+ return id, nil
90+}
91+
92+func (id IncarnationID) String() string {
93+ return hex.EncodeToString(id[:])
94+}
95+
96+// MarshalText emits the canonical lowercase hexadecimal identity.
97+func (id IncarnationID) MarshalText() ([]byte, error) {
98+ if id == (IncarnationID{}) {
99+ return nil, errors.New("zero incarnation ID")
100+ }
101+ return []byte(id.String()), nil
102+}
103+
104+// UnmarshalText accepts only a canonical lowercase non-zero identity.
105+func (id *IncarnationID) UnmarshalText(text []byte) error {
106+ if len(text) != 32 || strings.ToLower(string(text)) != string(text) {
107+ return errors.New("invalid incarnation ID")
108+ }
109+ var decoded IncarnationID
110+ if _, err := hex.Decode(decoded[:], text); err != nil {
111+ return fmt.Errorf("invalid incarnation ID: %w", err)
112+ }
113+ if decoded == (IncarnationID{}) {
114+ return errors.New("zero incarnation ID")
115+ }
116+ *id = decoded
117+ return nil
118+}
119+
120+// Validate rejects catalogue states that cannot be restored deterministically.
121+func (r CatalogueRecord) Validate() error {
122+ if err := ValidateSessionName(r.Name); err != nil {
123+ return err
124+ }
125+ if r.IncarnationID == (IncarnationID{}) {
126+ return errors.New("zero catalogue incarnation ID")
127+ }
128+
129+ switch r.RecoveryState {
130+ case RecoveryFresh:
131+ if r.Committed != nil || r.Fallbacks[0] != nil || r.Fallbacks[1] != nil {
132+ return errors.New("fresh session has checkpoint references")
133+ }
134+ case RecoveryHealthy:
135+ if r.Committed == nil {
136+ return errors.New("healthy session has no committed checkpoint")
137+ }
138+ case RecoveryDegraded:
139+ if r.Committed == nil {
140+ return errors.New("degraded session has no committed checkpoint")
141+ }
142+ if r.DegradedReason == "" {
143+ return errors.New("degraded session has no reason")
144+ }
145+ case RecoveryDeleting:
146+ // A fresh or checkpointed session may be deleted.
147+ default:
148+ return errors.New("invalid recovery state")
149+ }
150+
151+ if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" {
152+ return errors.New("non-degraded session has degraded reason")
153+ }
154+ if r.Fallbacks[1] != nil && r.Fallbacks[0] == nil {
155+ return errors.New("second fallback populated without first fallback")
156+ }
157+ if r.Committed == nil && (r.Fallbacks[0] != nil || r.Fallbacks[1] != nil) {
158+ return errors.New("fallback populated without committed checkpoint")
159+ }
160+
161+ seen := make(map[uint64]struct{}, 3)
162+ var previous uint64
163+ for i, ref := range []*CheckpointRef{r.Committed, r.Fallbacks[0], r.Fallbacks[1]} {
164+ if ref == nil {
165+ continue
166+ }
167+ if ref.Generation == 0 {
168+ return errors.New("zero checkpoint generation")
169+ }
170+ if ref.ManifestDigest == ([32]byte{}) {
171+ return errors.New("zero checkpoint manifest digest")
172+ }
173+ if _, ok := seen[ref.Generation]; ok {
174+ return errors.New("duplicate checkpoint generation")
175+ }
176+ seen[ref.Generation] = struct{}{}
177+ if i > 0 && previous <= ref.Generation {
178+ return errors.New("checkpoint generations are not newest to oldest")
179+ }
180+ previous = ref.Generation
181+ }
182+ return nil
183+}
@@ -0,0 +1,183 @@
1+package domain
2+
3+import (
4+ "encoding/hex"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "strings"
9+)
10+
11+// IncarnationID permanently distinguishes uses of the same session name.
12+type IncarnationID [16]byte
13+
14+// RecoveryState describes whether and how a named session can be restored.
15+type RecoveryState uint8
16+
17+const (
18+ RecoveryFresh RecoveryState = iota + 1
19+ RecoveryHealthy
20+ RecoveryDegraded
21+ RecoveryDeleting
22+)
23+
24+// CheckpointRef identifies one immutable snapshot manifest.
25+type CheckpointRef struct {
26+ Generation uint64
27+ ManifestDigest [32]byte
28+}
29+
30+// CatalogueRecord is the durable source of truth for one named session.
31+type CatalogueRecord struct {
32+ Name string
33+ IncarnationID IncarnationID
34+ Cwd string
35+ CreatedAt int64
36+ UpdatedAt int64
37+ LastUsedSeq uint64
38+ TabNames []string
39+ RecoveryState RecoveryState
40+ Committed *CheckpointRef
41+ Fallbacks [2]*CheckpointRef
42+ DegradedReason string
43+}
44+
45+// DiscardIntent records a requested replacement of uncertain persisted state.
46+type DiscardIntent struct {
47+ OldRecord CatalogueRecord
48+ OldIncarnation IncarnationID
49+ NewIncarnation IncarnationID
50+ SessionName string
51+ Reason string
52+}
53+
54+// QuarantineDescriptor records persisted state retained during replacement.
55+type QuarantineDescriptor struct {
56+ OldRecord CatalogueRecord
57+ OldIncarnation IncarnationID
58+ ReplacementIncarnation IncarnationID
59+ SessionName string
60+ Reason string
61+}
62+
63+// DeletionTombstone identifies exactly one deleted session incarnation.
64+type DeletionTombstone struct {
65+ Name string
66+ IncarnationID IncarnationID
67+}
68+
69+// Validate rejects incomplete deletion identities.
70+func (t DeletionTombstone) Validate() error {
71+ if err := ValidateSessionName(t.Name); err != nil {
72+ return err
73+ }
74+ if t.IncarnationID == (IncarnationID{}) {
75+ return errors.New("zero deletion incarnation ID")
76+ }
77+ return nil
78+}
79+
80+// NewIncarnationID reads a new non-zero identity from r.
81+func NewIncarnationID(r io.Reader) (IncarnationID, error) {
82+ var id IncarnationID
83+ if _, err := io.ReadFull(r, id[:]); err != nil {
84+ return id, err
85+ }
86+ if id == (IncarnationID{}) {
87+ return id, errors.New("zero incarnation ID")
88+ }
89+ return id, nil
90+}
91+
92+func (id IncarnationID) String() string {
93+ return hex.EncodeToString(id[:])
94+}
95+
96+// MarshalText emits the canonical lowercase hexadecimal identity.
97+func (id IncarnationID) MarshalText() ([]byte, error) {
98+ if id == (IncarnationID{}) {
99+ return nil, errors.New("zero incarnation ID")
100+ }
101+ return []byte(id.String()), nil
102+}
103+
104+// UnmarshalText accepts only a canonical lowercase non-zero identity.
105+func (id *IncarnationID) UnmarshalText(text []byte) error {
106+ if len(text) != 32 || strings.ToLower(string(text)) != string(text) {
107+ return errors.New("invalid incarnation ID")
108+ }
109+ var decoded IncarnationID
110+ if _, err := hex.Decode(decoded[:], text); err != nil {
111+ return fmt.Errorf("invalid incarnation ID: %w", err)
112+ }
113+ if decoded == (IncarnationID{}) {
114+ return errors.New("zero incarnation ID")
115+ }
116+ *id = decoded
117+ return nil
118+}
119+
120+// Validate rejects catalogue states that cannot be restored deterministically.
121+func (r CatalogueRecord) Validate() error {
122+ if err := ValidateSessionName(r.Name); err != nil {
123+ return err
124+ }
125+ if r.IncarnationID == (IncarnationID{}) {
126+ return errors.New("zero catalogue incarnation ID")
127+ }
128+
129+ switch r.RecoveryState {
130+ case RecoveryFresh:
131+ if r.Committed != nil || r.Fallbacks[0] != nil || r.Fallbacks[1] != nil {
132+ return errors.New("fresh session has checkpoint references")
133+ }
134+ case RecoveryHealthy:
135+ if r.Committed == nil {
136+ return errors.New("healthy session has no committed checkpoint")
137+ }
138+ case RecoveryDegraded:
139+ if r.Committed == nil {
140+ return errors.New("degraded session has no committed checkpoint")
141+ }
142+ if r.DegradedReason == "" {
143+ return errors.New("degraded session has no reason")
144+ }
145+ case RecoveryDeleting:
146+ // A fresh or checkpointed session may be deleted.
147+ default:
148+ return errors.New("invalid recovery state")
149+ }
150+
151+ if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" {
152+ return errors.New("non-degraded session has degraded reason")
153+ }
154+ if r.Fallbacks[1] != nil && r.Fallbacks[0] == nil {
155+ return errors.New("second fallback populated without first fallback")
156+ }
157+ if r.Committed == nil && (r.Fallbacks[0] != nil || r.Fallbacks[1] != nil) {
158+ return errors.New("fallback populated without committed checkpoint")
159+ }
160+
161+ seen := make(map[uint64]struct{}, 3)
162+ var previous uint64
163+ for i, ref := range []*CheckpointRef{r.Committed, r.Fallbacks[0], r.Fallbacks[1]} {
164+ if ref == nil {
165+ continue
166+ }
167+ if ref.Generation == 0 {
168+ return errors.New("zero checkpoint generation")
169+ }
170+ if ref.ManifestDigest == ([32]byte{}) {
171+ return errors.New("zero checkpoint manifest digest")
172+ }
173+ if _, ok := seen[ref.Generation]; ok {
174+ return errors.New("duplicate checkpoint generation")
175+ }
176+ seen[ref.Generation] = struct{}{}
177+ if i > 0 && previous <= ref.Generation {
178+ return errors.New("checkpoint generations are not newest to oldest")
179+ }
180+ previous = ref.Generation
181+ }
182+ return nil
183+}
A internal/domain/recovery_test.go +81 -0
@@ -0,0 +1,81 @@
1+package domain
2+
3+import (
4+ "crypto/rand"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNewIncarnationID(t *testing.T) {
11+ id, err := NewIncarnationID(rand.Reader)
12+ require.NoError(t, err)
13+ require.NotEqual(t, IncarnationID{}, id)
14+ require.Len(t, id.String(), 32)
15+}
16+
17+func TestIncarnationIDText(t *testing.T) {
18+ id := IncarnationID{0: 0xab, 15: 0xcd}
19+ text, err := id.MarshalText()
20+ require.NoError(t, err)
21+ require.Equal(t, "ab0000000000000000000000000000cd", string(text))
22+ var decoded IncarnationID
23+ require.NoError(t, decoded.UnmarshalText(text))
24+ require.Equal(t, id, decoded)
25+ require.Error(t, decoded.UnmarshalText([]byte("AB")))
26+}
27+
28+func TestCatalogueRecordValidate(t *testing.T) {
29+ valid := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}
30+ require.NoError(t, valid.Validate())
31+ valid.RecoveryState = RecoveryHealthy
32+ valid.Committed = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}
33+ require.NoError(t, valid.Validate())
34+ valid.Fallbacks[0] = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{2}}
35+ require.Error(t, valid.Validate(), "duplicate generations are malformed")
36+
37+ validRef := &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}
38+ tests := []struct {
39+ name string
40+ record CatalogueRecord
41+ }{
42+ {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}},
43+ {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}},
44+ {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}},
45+ {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}},
46+ {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}},
47+ {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}},
48+ {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}},
49+ {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}},
50+ {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
51+ {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}},
52+ {name: "second fallback without first", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, Fallbacks: [2]*CheckpointRef{nil, {Generation: 6, ManifestDigest: [32]byte{2}}}}},
53+ }
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ require.Error(t, tt.record.Validate())
57+ })
58+ }
59+}
60+
61+func TestDeletionTombstoneValidate(t *testing.T) {
62+ tests := []struct {
63+ name string
64+ tombstone DeletionTombstone
65+ wantErr bool
66+ }{
67+ {name: "valid", tombstone: DeletionTombstone{Name: "work", IncarnationID: IncarnationID{1}}},
68+ {name: "invalid name", tombstone: DeletionTombstone{Name: "bad/name", IncarnationID: IncarnationID{1}}, wantErr: true},
69+ {name: "zero incarnation", tombstone: DeletionTombstone{Name: "work"}, wantErr: true},
70+ }
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ err := tt.tombstone.Validate()
74+ if tt.wantErr {
75+ require.Error(t, err)
76+ return
77+ }
78+ require.NoError(t, err)
79+ })
80+ }
81+}
@@ -0,0 +1,81 @@
1+package domain
2+
3+import (
4+ "crypto/rand"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNewIncarnationID(t *testing.T) {
11+ id, err := NewIncarnationID(rand.Reader)
12+ require.NoError(t, err)
13+ require.NotEqual(t, IncarnationID{}, id)
14+ require.Len(t, id.String(), 32)
15+}
16+
17+func TestIncarnationIDText(t *testing.T) {
18+ id := IncarnationID{0: 0xab, 15: 0xcd}
19+ text, err := id.MarshalText()
20+ require.NoError(t, err)
21+ require.Equal(t, "ab0000000000000000000000000000cd", string(text))
22+ var decoded IncarnationID
23+ require.NoError(t, decoded.UnmarshalText(text))
24+ require.Equal(t, id, decoded)
25+ require.Error(t, decoded.UnmarshalText([]byte("AB")))
26+}
27+
28+func TestCatalogueRecordValidate(t *testing.T) {
29+ valid := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}
30+ require.NoError(t, valid.Validate())
31+ valid.RecoveryState = RecoveryHealthy
32+ valid.Committed = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}
33+ require.NoError(t, valid.Validate())
34+ valid.Fallbacks[0] = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{2}}
35+ require.Error(t, valid.Validate(), "duplicate generations are malformed")
36+
37+ validRef := &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}
38+ tests := []struct {
39+ name string
40+ record CatalogueRecord
41+ }{
42+ {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}},
43+ {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}},
44+ {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}},
45+ {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}},
46+ {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}},
47+ {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}},
48+ {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}},
49+ {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}},
50+ {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
51+ {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}},
52+ {name: "second fallback without first", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, Fallbacks: [2]*CheckpointRef{nil, {Generation: 6, ManifestDigest: [32]byte{2}}}}},
53+ }
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ require.Error(t, tt.record.Validate())
57+ })
58+ }
59+}
60+
61+func TestDeletionTombstoneValidate(t *testing.T) {
62+ tests := []struct {
63+ name string
64+ tombstone DeletionTombstone
65+ wantErr bool
66+ }{
67+ {name: "valid", tombstone: DeletionTombstone{Name: "work", IncarnationID: IncarnationID{1}}},
68+ {name: "invalid name", tombstone: DeletionTombstone{Name: "bad/name", IncarnationID: IncarnationID{1}}, wantErr: true},
69+ {name: "zero incarnation", tombstone: DeletionTombstone{Name: "work"}, wantErr: true},
70+ }
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ err := tt.tombstone.Validate()
74+ if tt.wantErr {
75+ require.Error(t, err)
76+ return
77+ }
78+ require.NoError(t, err)
79+ })
80+ }
81+}

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