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