1package snapshot
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/logging"
15)
16
17const (
18 defaultSnapshotIntervalMs = 5000
19 maxFKRetries = 3
20 fkRetryDelay = 100 * time.Millisecond
21)
22
23// Compile-time interface check.
24var _ port.SnapshotService = (*Service)(nil)
25
26// Service handles debounced session state snapshots.
27type Service struct {
28 snapshotUC *usecase.SnapshotSessionUseCase
29 provider port.WindowStateProvider
30 interval time.Duration
31 retries int
32 retryDelay time.Duration
33
34 mu sync.Mutex
35 timer *time.Timer
36 dirty bool
37 ready bool // true when session is persisted to DB and snapshots can be saved
38 ctx context.Context
39 cancel context.CancelFunc
40}
41
42// NewService creates a new snapshot service.
43func NewService(
44 snapshotUC *usecase.SnapshotSessionUseCase,
45 provider port.WindowStateProvider,
46 intervalMs int,
47) *Service {
48 if intervalMs <= 0 {
49 intervalMs = defaultSnapshotIntervalMs
50 }
51 return &Service{
52 snapshotUC: snapshotUC,
53 provider: provider,
54 interval: time.Duration(intervalMs) * time.Millisecond,
55 retries: maxFKRetries,
56 retryDelay: fkRetryDelay,
57 }
58}
59
60// Start begins watching for dirty state.
61func (s *Service) Start(ctx context.Context) {
62 s.mu.Lock()
63 defer s.mu.Unlock()
64
65 s.ctx, s.cancel = context.WithCancel(ctx)
66 logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
67}
68
69// SetReady marks the service as ready to save snapshots.
70// Call this after the session has been persisted to the database
71// to avoid FK constraint violations.
72func (s *Service) SetReady() {
73 s.mu.Lock()
74 if s.timer != nil {
75 s.timer.Stop()
76 s.timer = nil
77 }
78 s.ready = true
79 dirty := s.dirty
80 ctx := s.ctx
81 s.mu.Unlock()
82
83 if !dirty || ctx == nil {
84 return
85 }
86
87 go func() {
88 if err := s.saveSnapshot(ctx); err != nil {
89 logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready")
90 }
91 }()
92}
93
94// Stop stops the service and saves final state.
95func (s *Service) Stop(ctx context.Context) error {
96 s.mu.Lock()
97 if s.cancel != nil {
98 s.cancel()
99 }
100 if s.timer != nil {
101 s.timer.Stop()
102 s.timer = nil
103 }
104 s.mu.Unlock()
105
106 // Final save on shutdown
107 return s.SaveNow(ctx)
108}
109
110// MarkDirty signals that state has changed.
111// Debounces saves to avoid excessive DB writes.
112func (s *Service) MarkDirty() {
113 s.mu.Lock()
114 defer s.mu.Unlock()
115
116 s.dirty = true
117
118 // Reset or create timer
119 if s.timer != nil {
120 s.timer.Stop()
121 }
122
123 s.timer = time.AfterFunc(s.interval, func() {
124 s.mu.Lock()
125 ctx := s.ctx
126 s.mu.Unlock()
127
128 if ctx == nil {
129 return
130 }
131
132 if err := s.saveSnapshot(ctx); err != nil {
133 logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot")
134 }
135 })
136}
137
138// SaveNow forces immediate save (for shutdown).
139func (s *Service) SaveNow(ctx context.Context) error {
140 s.mu.Lock()
141 if s.timer != nil {
142 s.timer.Stop()
143 s.timer = nil
144 }
145 dirty := s.dirty
146 s.mu.Unlock()
147
148 if !dirty {
149 return nil
150 }
151
152 return s.saveSnapshot(ctx)
153}
154
155func (s *Service) saveSnapshot(ctx context.Context) error {
156 s.mu.Lock()
157 ready := s.ready
158 if !ready {
159 // Don't clear dirty if not ready - keep pending snapshot for later
160 s.mu.Unlock()
161 return nil
162 }
163 // Only clear dirty when we're actually going to save
164 s.dirty = false
165 s.mu.Unlock()
166
167 sessionID := s.provider.GetSessionID()
168
169 if sessionID == "" {
170 s.markDirty()
171 return nil
172 }
173
174 windows, activeWindowIndex := s.provider.GetWindowSnapshotState()
175 // A nil window list with no active index means the snapshot is truly unavailable,
176 // not merely empty. Keep the session dirty so a later capture can retry.
177 if windows == nil && activeWindowIndex < 0 {
178 s.markDirty()
179 logging.FromContext(ctx).Warn().Msg("window snapshot unavailable; keeping session snapshot dirty")
180 return nil
181 }
182 if windows == nil {
183 windows = make([]entity.WindowTabListState, 0)
184 }
185 input := usecase.SnapshotInput{
186 SessionID: sessionID,
187 Windows: windows,
188 ActiveWindowIndex: activeWindowIndex,
189 }
190 // single window with WindowID == "" is legacy v1 sentinel; populate TabList and clear Windows/ActiveWindowIndex.
191 if len(windows) == 1 && windows[0].WindowID == "" {
192 input.Windows = nil
193 input.ActiveWindowIndex = 0
194 input.TabList = windows[0].Tabs
195 }
196
197 if err := s.executeWithRetry(ctx, input); err != nil {
198 s.markDirty()
199 return err
200 }
201
202 return nil
203}
204
205func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
206 log := logging.FromContext(ctx)
207 maxAttempts := s.retries + 1
208
209 var lastErr error
210 for attempt := 1; attempt <= maxAttempts; attempt++ {
211 if err := s.snapshotUC.Execute(ctx, input); err != nil {
212 lastErr = err
213 if !isTransientFKError(err) || attempt == maxAttempts {
214 if attempt == maxAttempts && isTransientFKError(err) {
215 log.Error().
216 Err(err).
217 Str("session_id", string(input.SessionID)).
218 Int("attempts", maxAttempts).
219 Msg("snapshot save retries exhausted after transient fk violation")
220 }
221 return err
222 }
223
224 log.Warn().
225 Err(err).
226 Str("session_id", string(input.SessionID)).
227 Int("attempt", attempt).
228 Int("max_attempts", maxAttempts).
229 Dur("retry_delay", s.retryDelay).
230 Msg("transient fk violation while saving snapshot; retrying")
231
232 if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil {
233 return fmt.Errorf("waiting to retry snapshot save: %w", waitErr)
234 }
235 continue
236 }
237
238 return nil
239 }
240
241 return lastErr
242}
243
244func (s *Service) markDirty() {
245 s.mu.Lock()
246 s.dirty = true
247 s.mu.Unlock()
248}
249
250func waitForRetry(ctx context.Context, delay time.Duration) error {
251 if delay <= 0 {
252 return nil
253 }
254 timer := time.NewTimer(delay)
255 defer timer.Stop()
256
257 select {
258 case <-ctx.Done():
259 return ctx.Err()
260 case <-timer.C:
261 return nil
262 }
263}
264
265func isTransientFKError(err error) bool {
266 // SQLite-specific transient FK detection:
267 // we match case-insensitive "foreign key" text from wrapped driver errors.
268 // Message wording may vary across SQLite versions/wrappers; adjust this check
269 // if transient FK retries stop matching expected failures in production logs.
270 if err == nil {
271 return false
272 }
273 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
274 return false
275 }
276 return strings.Contains(strings.ToLower(err.Error()), "foreign key")
277}