返回 DeepSeek-Reasonix
receipt_store.go
根目录 / internal / extension / receipt_store.go
1 package extension
2
3 import (
4 "sync"
5 "time"
6 )
7
8 const (
9 // ReceiptStore deliberately retains only recent in-process recovery evidence.
10 // A generation can record many file/provider effects, so both dimensions are
11 // bounded independently. Retention is not persistence: crash recovery remains
12 // outside this PR's contract.
13 defaultReceiptGenerationLimit = 32
14 defaultReceiptPerGenerationLimit = 256
15 )
16
17 type receiptGeneration struct {
18 ids []string
19 truncated bool
20 }
21
22 // ReceiptStore is a bounded, process-local ledger of irreversible /
23 // compensatable effects used by recovery to decide what is safe to resume. It
24 // does not claim rollback success for irreversible work.
25 type ReceiptStore struct {
26 mu sync.Mutex
27 byID map[string]EffectReceipt
28 byGen map[uint64]*receiptGeneration
29 generationOrder []uint64
30 generationLimit int
31 perGenerationLimit int
32 evictedThrough uint64
33 evictedZero bool
34 sequence uint64
35 onEvict func(EffectReceipt)
36 }
37
38 // DefaultReceiptStore is the compatibility owner's ledger.
39 var DefaultReceiptStore = DefaultRuntimeOwner.Receipts
40
41 // NewReceiptStore returns an empty store.
42 func NewReceiptStore() *ReceiptStore {
43 return newReceiptStore(defaultReceiptGenerationLimit, defaultReceiptPerGenerationLimit, nil)
44 }
45
46 func newReceiptStore(generationLimit, perGenerationLimit int, onEvict func(EffectReceipt)) *ReceiptStore {
47 if generationLimit < 1 {
48 generationLimit = 1
49 }
50 if perGenerationLimit < 1 {
51 perGenerationLimit = 1
52 }
53 return &ReceiptStore{
54 byID: make(map[string]EffectReceipt),
55 byGen: make(map[uint64]*receiptGeneration),
56 generationLimit: generationLimit,
57 perGenerationLimit: perGenerationLimit,
58 onEvict: onEvict,
59 }
60 }
61
62 // Record inserts or updates a receipt. Irreversible receipts never set
63 // CompensationStatus to a successful rollback.
64 func (s *ReceiptStore) Record(r EffectReceipt) {
65 if s == nil {
66 return
67 }
68 if r.Class == Irreversible {
69 // Never claim external work was undone.
70 if r.CompensationStatus == "applied" || r.CompensationStatus == "rolled_back" {
71 r.CompensationStatus = "not_applicable"
72 }
73 if r.CompensationStatus == "" {
74 r.CompensationStatus = "not_applicable"
75 }
76 }
77 s.mu.Lock()
78 if r.ID == "" {
79 s.sequence++
80 r.ID = "receipt-" + itoaU64(s.sequence)
81 }
82 if previous, exists := s.byID[r.ID]; exists && previous.Generation != r.Generation && r.Generation != 0 {
83 // Receipt IDs are update keys only within one generation. Keep both
84 // generations when a caller accidentally reuses an ID.
85 r.ID += "#gen-" + itoaU64(r.Generation)
86 }
87 if previous, exists := s.byID[r.ID]; exists {
88 if r.Generation == 0 {
89 r.Generation = previous.Generation
90 }
91 if r.Owner == "" {
92 r.Owner = previous.Owner
93 }
94 if r.Component == "" {
95 r.Component = previous.Component
96 }
97 if r.StartedAt.IsZero() {
98 r.StartedAt = previous.StartedAt
99 }
100 if r.CompletedAt.IsZero() {
101 r.CompletedAt = previous.CompletedAt
102 }
103 }
104 if r.StartedAt.IsZero() {
105 r.StartedAt = time.Now().UTC()
106 }
107 _, exists := s.byID[r.ID]
108 s.byID[r.ID] = r
109 if !exists {
110 bucket := s.byGen[r.Generation]
111 if bucket == nil {
112 bucket = &receiptGeneration{}
113 s.byGen[r.Generation] = bucket
114 s.generationOrder = append(s.generationOrder, r.Generation)
115 }
116 bucket.ids = append(bucket.ids, r.ID)
117 }
118 evicted := s.trimLocked()
119 s.mu.Unlock()
120 for _, old := range evicted {
121 if s.onEvict != nil {
122 s.onEvict(old)
123 }
124 }
125 }
126
127 // Get returns a receipt by id.
128 func (s *ReceiptStore) Get(id string) (EffectReceipt, bool) {
129 if s == nil {
130 return EffectReceipt{}, false
131 }
132 s.mu.Lock()
133 defer s.mu.Unlock()
134 r, ok := s.byID[id]
135 return r, ok
136 }
137
138 // ForGeneration returns all receipts for a generation.
139 func (s *ReceiptStore) ForGeneration(gen uint64) []EffectReceipt {
140 if s == nil {
141 return nil
142 }
143 s.mu.Lock()
144 defer s.mu.Unlock()
145 ids := s.byGen[gen]
146 if ids == nil {
147 return nil
148 }
149 out := make([]EffectReceipt, 0, len(ids.ids))
150 for _, id := range ids.ids {
151 if r, ok := s.byID[id]; ok {
152 out = append(out, r)
153 }
154 }
155 return out
156 }
157
158 // trimLocked evicts whole old generations first, then the oldest receipts in
159 // an overfull generation. The caller must hold s.mu.
160 func (s *ReceiptStore) trimLocked() []EffectReceipt {
161 var evicted []EffectReceipt
162 for len(s.generationOrder) > s.generationLimit {
163 gen := s.generationOrder[0]
164 s.generationOrder = s.generationOrder[1:]
165 bucket := s.byGen[gen]
166 delete(s.byGen, gen)
167 if gen == 0 {
168 s.evictedZero = true
169 } else if gen > s.evictedThrough {
170 s.evictedThrough = gen
171 }
172 if bucket != nil {
173 for _, id := range bucket.ids {
174 if r, ok := s.byID[id]; ok {
175 evicted = append(evicted, r)
176 delete(s.byID, id)
177 }
178 }
179 }
180 }
181 for gen, bucket := range s.byGen {
182 if len(bucket.ids) <= s.perGenerationLimit {
183 continue
184 }
185 extra := len(bucket.ids) - s.perGenerationLimit
186 for _, id := range bucket.ids[:extra] {
187 if r, ok := s.byID[id]; ok {
188 evicted = append(evicted, r)
189 delete(s.byID, id)
190 }
191 }
192 bucket.ids = append([]string(nil), bucket.ids[extra:]...)
193 bucket.truncated = true
194 s.byGen[gen] = bucket
195 }
196 return evicted
197 }
198
199 func (s *ReceiptStore) generationSnapshot(gen uint64) ([]EffectReceipt, bool) {
200 if s == nil {
201 return nil, false
202 }
203 s.mu.Lock()
204 defer s.mu.Unlock()
205 bucket := s.byGen[gen]
206 if bucket == nil {
207 truncated := (gen == 0 && s.evictedZero) || (gen != 0 && gen <= s.evictedThrough)
208 return nil, truncated
209 }
210 ids := append([]string(nil), bucket.ids...)
211 out := make([]EffectReceipt, 0, len(ids))
212 for _, id := range ids {
213 if r, ok := s.byID[id]; ok {
214 out = append(out, r)
215 }
216 }
217 truncated := bucket.truncated || (gen == 0 && s.evictedZero) || (gen != 0 && gen <= s.evictedThrough)
218 return out, truncated
219 }
220
221 // Recoverability classifies whether a generation's external effects allow
222 // a clean resume. Irreversible completed work without compensation blocks
223 // claiming a clean rollback but still allows resume with awareness.
224 type Recoverability struct {
225 Clean bool `json:"clean"`
226 HasIrreversible bool `json:"hasIrreversible"`
227 Blocking []string `json:"blocking,omitempty"`
228 Notes []string `json:"notes,omitempty"`
229 }
230
231 // AssessRecoverability reports whether checkpoint resume can claim a clean
232 // state for generation gen.
233 func (s *ReceiptStore) AssessRecoverability(gen uint64) Recoverability {
234 out := Recoverability{Clean: true}
235 receipts, truncated := s.generationSnapshot(gen)
236 if truncated {
237 out.Clean = false
238 out.Blocking = append(out.Blocking, "receipt-history-truncated")
239 out.Notes = append(out.Notes, "receipt history was evicted; clean rollback cannot be proven")
240 }
241 for _, r := range receipts {
242 switch r.Class {
243 case Irreversible:
244 out.HasIrreversible = true
245 out.Clean = false
246 out.Notes = append(out.Notes, "irreversible effect "+r.ID+" cannot be rolled back")
247 case Compensatable:
248 if r.CompensationStatus == "failed" || r.CompensationStatus == "" || r.CompensationStatus == "prior_truncated" {
249 out.Clean = false
250 out.Blocking = append(out.Blocking, r.ID)
251 out.Notes = append(out.Notes, "compensatable effect "+r.ID+" not fully compensated")
252 }
253 }
254 }
255 return out
256 }
257
258 // IngestScope copies completed receipts from a LiveScope/EffectScope into the store.
259 func (s *ReceiptStore) IngestScope(scope EffectScope) {
260 if s == nil || scope == nil {
261 return
262 }
263 for _, r := range scope.Receipts() {
264 s.Record(r)
265 }
266 }
267
268 func itoaU64(n uint64) string {
269 if n == 0 {
270 return "0"
271 }
272 var buf [20]byte
273 i := len(buf)
274 for n > 0 {
275 i--
276 buf[i] = byte('0' + n%10)
277 n /= 10
278 }
279 return string(buf[i:])
280 }
281
281 lines GO