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