1package snapshot
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/usecase"
14 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/logging"
16)
17
18const (
19 defaultSnapshotIntervalMs = 5000
20 maxFKRetries = 3
21 fkRetryDelay = 100 * time.Millisecond
22)
23
24// Compile-time interface check.
25var _ port.SnapshotService = (*Service)(nil)
26
27// Compile-time drain boundary check.
28var _ port.PersistenceDrain = (*Service)(nil)
29
30// Service handles debounced session state snapshots.
31type Service struct {
32 snapshotUC *usecase.SnapshotSessionUseCase
33 provider port.WindowStateProvider
34 interval time.Duration
35 retries int
36 retryDelay time.Duration
37
38 mu sync.Mutex
39 timer *time.Timer
40 timerGen uint64
41 // stopped latches Stop: no new debounce work schedules afterwards.
42 stopped bool
43 dirty bool
44 ready bool // true when session is persisted to DB and snapshots can be saved
45 ctx context.Context
46 cancel context.CancelFunc
47
48 // Drain boundary for the residency quiescence contract. inflight
49 // counts an executing capture/database save; lastErr records the
50 // terminal failure of the most recent save while dirty is preserved
51 // for reporting and later retry. Failed writes are settled-but-failed:
52 // the drain terminates instead of looping forever on dirty.
53 inflight atomic.Int32
54 lastErr error
55 onSettled []func()
56}
57
58// NewService creates a new snapshot service.
59func NewService(
60 snapshotUC *usecase.SnapshotSessionUseCase,
61 provider port.WindowStateProvider,
62 intervalMs int,
63) *Service {
64 if intervalMs <= 0 {
65 intervalMs = defaultSnapshotIntervalMs
66 }
67 return &Service{
68 snapshotUC: snapshotUC,
69 provider: provider,
70 interval: time.Duration(intervalMs) * time.Millisecond,
71 retries: maxFKRetries,
72 retryDelay: fkRetryDelay,
73 }
74}
75
76// Start begins watching for dirty state.
77func (s *Service) Start(ctx context.Context) {
78 s.mu.Lock()
79 defer s.mu.Unlock()
80
81 s.ctx, s.cancel = context.WithCancel(ctx)
82 logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
83}
84
85// SetReady marks the service as ready to save snapshots.
86// Call this after the session has been persisted to the database
87// to avoid FK constraint violations.
88func (s *Service) SetReady() {
89 s.mu.Lock()
90 if s.timer != nil {
91 s.timer.Stop()
92 s.timer = nil
93 }
94 s.ready = true
95 dirty := s.dirty
96 ctx := s.ctx
97 s.mu.Unlock()
98
99 if !dirty || ctx == nil {
100 return
101 }
102
103 go func() {
104 if err := s.saveSnapshot(ctx); err != nil {
105 logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready")
106 }
107 }()
108}
109
110// Stop stops the service and saves final state, then joins an
111// already-running save within a bounded wait so shutdown observes the
112// drain barrier instead of racing it. The wait never blocks forever.
113// Latching stopped also prevents any later debounce from scheduling work
114// on the dead service.
115func (s *Service) Stop(ctx context.Context) error {
116 s.mu.Lock()
117 if s.cancel != nil {
118 s.cancel()
119 }
120 if s.timer != nil {
121 s.timer.Stop()
122 s.timer = nil
123 }
124 s.stopped = true
125 s.mu.Unlock()
126
127 // Final save on shutdown
128 err := s.SaveNow(ctx)
129 drainCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
130 defer cancel()
131 if waitErr := s.WaitSettled(drainCtx); waitErr != nil {
132 logging.FromContext(ctx).Warn().Err(waitErr).Msg("snapshot drain did not settle before shutdown")
133 }
134 return err
135}
136
137// MarkDirty signals that state has changed.
138// Debounces saves to avoid excessive DB writes. It is a no-op after Stop
139// so shutdown never resurrects debounce work on the dead service.
140func (s *Service) MarkDirty() {
141 s.mu.Lock()
142 defer s.mu.Unlock()
143 if s.stopped {
144 return
145 }
146 s.dirty = true
147
148 // Reset or create timer
149 if s.timer != nil {
150 s.timer.Stop()
151 }
152 s.timerGen++
153 gen := s.timerGen
154 s.timer = time.AfterFunc(s.interval, func() {
155 s.mu.Lock()
156 // Retire the owning timer as it fires: a non-nil timer means
157 // debounce work is outstanding, so leaving it set would strand
158 // the drain after the save settles. The generation check keeps
159 // a superseded timer from clearing its replacement, and a
160 // superseded or stopped timer saves nothing: the replacement
161 // timer or Stop's own final save owns that work.
162 if s.timerGen != gen || s.stopped {
163 if s.timerGen == gen {
164 s.timer = nil
165 }
166 s.mu.Unlock()
167 return
168 }
169 s.timer = nil
170 ctx := s.ctx
171 s.mu.Unlock()
172
173 if ctx == nil {
174 return
175 }
176
177 if err := s.saveSnapshot(ctx); err != nil {
178 logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot")
179 }
180 })
181}
182
183// SaveNow forces immediate save (for shutdown).
184func (s *Service) SaveNow(ctx context.Context) error {
185 s.mu.Lock()
186 if s.timer != nil {
187 s.timer.Stop()
188 s.timer = nil
189 }
190 dirty := s.dirty
191 s.mu.Unlock()
192
193 if !dirty {
194 return nil
195 }
196
197 return s.saveSnapshot(ctx)
198}
199
200func (s *Service) saveSnapshot(ctx context.Context) error {
201 s.mu.Lock()
202 ready := s.ready
203 if !ready {
204 // Don't clear dirty if not ready - keep pending snapshot for later
205 s.mu.Unlock()
206 return nil
207 }
208 // Claim the dirty work atomically: concurrent timer and SaveNow paths
209 // serialize here, so exactly one save runs and shutdown joins rather
210 // than duplicates the active save.
211 if !s.dirty {
212 s.mu.Unlock()
213 return nil
214 }
215 // Only clear dirty when we're actually going to save
216 s.dirty = false
217 s.lastErr = nil
218 s.inflight.Add(1)
219 s.mu.Unlock()
220
221 defer func() {
222 s.inflight.Add(-1)
223 s.notifyIfSettled()
224 }()
225
226 sessionID := s.provider.GetSessionID()
227
228 if sessionID == "" {
229 s.MarkDirty()
230 return nil
231 }
232
233 windows, activeWindowIndex := s.provider.GetWindowSnapshotState()
234 // A nil window list with no active index means the snapshot is truly unavailable,
235 // not merely empty. Keep the session dirty so a later capture can retry.
236 if windows == nil && activeWindowIndex < 0 {
237 s.MarkDirty()
238 logging.FromContext(ctx).Warn().Msg("window snapshot unavailable; keeping session snapshot dirty")
239 return nil
240 }
241 if windows == nil {
242 windows = make([]entity.WindowTabListState, 0)
243 }
244 input := usecase.SnapshotInput{
245 SessionID: sessionID,
246 Windows: windows,
247 ActiveWindowIndex: activeWindowIndex,
248 }
249 // single window with WindowID == "" is legacy v1 sentinel; populate TabList and clear Windows/ActiveWindowIndex.
250 if len(windows) == 1 && windows[0].WindowID == "" {
251 input.Windows = nil
252 input.ActiveWindowIndex = 0
253 input.TabList = windows[0].Tabs
254 }
255
256 if err := s.executeWithRetry(ctx, input); err != nil {
257 s.markDirty()
258 s.setTerminalError(err)
259 return err
260 }
261
262 return nil
263}
264
265// Active reports whether snapshot work is outstanding: pending debounce,
266// an armed timer, or an in-flight capture/database save. A terminally
267// failed save is settled-but-failed: dirty is preserved for reporting and
268// later retry while lastErr records it, so the drain always terminates and
269// never loops forever merely because dirty remains set.
270func (s *Service) Active() bool {
271 s.mu.Lock()
272 defer s.mu.Unlock()
273 if s.inflight.Load() > 0 {
274 return true
275 }
276 if s.timer != nil {
277 return true
278 }
279 if !s.dirty {
280 return false
281 }
282 return s.lastErr == nil
283}
284
285// LastError returns the terminal error of the most recent save, or nil.
286// Dirty is preserved alongside it for reporting and later retry.
287func (s *Service) LastError() error {
288 s.mu.Lock()
289 defer s.mu.Unlock()
290 return s.lastErr
291}
292
293// OnSettled registers a callback invoked when the service reaches a
294// settled state after a save completes. Callbacks run on the completing
295// goroutine and must be non-blocking; the UI wraps them with GTK dispatch.
296// There is no unsubscribe; services are process-lifetime owners.
297func (s *Service) OnSettled(fn func()) {
298 if fn == nil {
299 return
300 }
301 s.mu.Lock()
302 defer s.mu.Unlock()
303 s.onSettled = append(s.onSettled, fn)
304}
305
306// WaitSettled blocks until no snapshot work is outstanding or ctx ends.
307// It reports ctx.Err() on expiry so shutdown never waits forever. It must
308// never be called on the GTK thread when capture dispatches there.
309func (s *Service) WaitSettled(ctx context.Context) error {
310 ticker := time.NewTicker(10 * time.Millisecond)
311 defer ticker.Stop()
312 for {
313 if !s.Active() {
314 return nil
315 }
316 select {
317 case <-ctx.Done():
318 return ctx.Err()
319 case <-ticker.C:
320 }
321 }
322}
323
324func (s *Service) setTerminalError(err error) {
325 s.mu.Lock()
326 defer s.mu.Unlock()
327 s.lastErr = err
328}
329
330func (s *Service) notifyIfSettled() {
331 if s.Active() {
332 return
333 }
334 s.mu.Lock()
335 callbacks := append([]func(){}, s.onSettled...)
336 s.mu.Unlock()
337 for _, fn := range callbacks {
338 fn()
339 }
340}
341
342func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
343 log := logging.FromContext(ctx)
344 maxAttempts := s.retries + 1
345
346 var lastErr error
347 for attempt := 1; attempt <= maxAttempts; attempt++ {
348 if err := s.snapshotUC.Execute(ctx, input); err != nil {
349 lastErr = err
350 if !isTransientFKError(err) || attempt == maxAttempts {
351 if attempt == maxAttempts && isTransientFKError(err) {
352 log.Error().
353 Err(err).
354 Str("session_id", string(input.SessionID)).
355 Int("attempts", maxAttempts).
356 Msg("snapshot save retries exhausted after transient fk violation")
357 }
358 return err
359 }
360
361 log.Warn().
362 Err(err).
363 Str("session_id", string(input.SessionID)).
364 Int("attempt", attempt).
365 Int("max_attempts", maxAttempts).
366 Dur("retry_delay", s.retryDelay).
367 Msg("transient fk violation while saving snapshot; retrying")
368
369 if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil {
370 return fmt.Errorf("waiting to retry snapshot save: %w", waitErr)
371 }
372 continue
373 }
374
375 return nil
376 }
377
378 return lastErr
379}
380
381func (s *Service) markDirty() {
382 s.mu.Lock()
383 s.dirty = true
384 s.mu.Unlock()
385}
386
387func waitForRetry(ctx context.Context, delay time.Duration) error {
388 if delay <= 0 {
389 return nil
390 }
391 timer := time.NewTimer(delay)
392 defer timer.Stop()
393
394 select {
395 case <-ctx.Done():
396 return ctx.Err()
397 case <-timer.C:
398 return nil
399 }
400}
401
402func isTransientFKError(err error) bool {
403 // SQLite-specific transient FK detection:
404 // we match case-insensitive "foreign key" text from wrapped driver errors.
405 // Message wording may vary across SQLite versions/wrappers; adjust this check
406 // if transient FK retries stop matching expected failures in production logs.
407 if err == nil {
408 return false
409 }
410 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
411 return false
412 }
413 return strings.Contains(strings.ToLower(err.Error()), "foreign key")
414}