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 if windows == nil {
176 windows = make([]entity.WindowTabListState, 0)
177 }
178 input := usecase.SnapshotInput{
179 SessionID: sessionID,
180 Windows: windows,
181 ActiveWindowIndex: activeWindowIndex,
182 }
183 // single window with WindowID == "" is legacy v1 sentinel; populate TabList and clear Windows/ActiveWindowIndex.
184 if len(windows) == 1 && windows[0].WindowID == "" {
185 input.Windows = nil
186 input.ActiveWindowIndex = 0
187 input.TabList = windows[0].Tabs
188 }
189
190 if err := s.executeWithRetry(ctx, input); err != nil {
191 s.markDirty()
192 return err
193 }
194
195 return nil
196}
197
198func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
199 log := logging.FromContext(ctx)
200 maxAttempts := s.retries + 1
201
202 var lastErr error
203 for attempt := 1; attempt <= maxAttempts; attempt++ {
204 if err := s.snapshotUC.Execute(ctx, input); err != nil {
205 lastErr = err
206 if !isTransientFKError(err) || attempt == maxAttempts {
207 if attempt == maxAttempts && isTransientFKError(err) {
208 log.Error().
209 Err(err).
210 Str("session_id", string(input.SessionID)).
211 Int("attempts", maxAttempts).
212 Msg("snapshot save retries exhausted after transient fk violation")
213 }
214 return err
215 }
216
217 log.Warn().
218 Err(err).
219 Str("session_id", string(input.SessionID)).
220 Int("attempt", attempt).
221 Int("max_attempts", maxAttempts).
222 Dur("retry_delay", s.retryDelay).
223 Msg("transient fk violation while saving snapshot; retrying")
224
225 if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil {
226 return fmt.Errorf("waiting to retry snapshot save: %w", waitErr)
227 }
228 continue
229 }
230
231 return nil
232 }
233
234 return lastErr
235}
236
237func (s *Service) markDirty() {
238 s.mu.Lock()
239 s.dirty = true
240 s.mu.Unlock()
241}
242
243func waitForRetry(ctx context.Context, delay time.Duration) error {
244 if delay <= 0 {
245 return nil
246 }
247 timer := time.NewTimer(delay)
248 defer timer.Stop()
249
250 select {
251 case <-ctx.Done():
252 return ctx.Err()
253 case <-timer.C:
254 return nil
255 }
256}
257
258func isTransientFKError(err error) bool {
259 // SQLite-specific transient FK detection:
260 // we match case-insensitive "foreign key" text from wrapped driver errors.
261 // Message wording may vary across SQLite versions/wrappers; adjust this check
262 // if transient FK retries stop matching expected failures in production logs.
263 if err == nil {
264 return false
265 }
266 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
267 return false
268 }
269 return strings.Contains(strings.ToLower(err.Error()), "foreign key")
270}