service.go RawHistoryFolder
1package snapshot
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/logging"
11)
12
13// Service handles debounced session state snapshots.
14type Service struct {
15 snapshotUC *usecase.SnapshotSessionUseCase
16 provider port.TabListProvider
17 interval time.Duration
18
19 mu sync.Mutex
20 timer *time.Timer
21 dirty bool
22 ready bool // true when session is persisted to DB and snapshots can be saved
23 ctx context.Context
24 cancel context.CancelFunc
25}
26
27// NewService creates a new snapshot service.
28func NewService(
29 snapshotUC *usecase.SnapshotSessionUseCase,
30 provider port.TabListProvider,
31 intervalMs int,
32) *Service {
33 if intervalMs <= 0 {
34 intervalMs = 5000 // Default 5 seconds
35 }
36 return &Service{
37 snapshotUC: snapshotUC,
38 provider: provider,
39 interval: time.Duration(intervalMs) * time.Millisecond,
40 }
41}
42
43// Start begins watching for dirty state.
44func (s *Service) Start(ctx context.Context) {
45 s.mu.Lock()
46 defer s.mu.Unlock()
47
48 s.ctx, s.cancel = context.WithCancel(ctx)
49 logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
50}
51
52// SetReady marks the service as ready to save snapshots.
53// Call this after the session has been persisted to the database
54// to avoid FK constraint violations.
55func (s *Service) SetReady() {
56 s.mu.Lock()
57 defer s.mu.Unlock()
58 s.ready = true
59}
60
61// Stop stops the service and saves final state.
62func (s *Service) Stop(ctx context.Context) error {
63 s.mu.Lock()
64 if s.cancel != nil {
65 s.cancel()
66 }
67 if s.timer != nil {
68 s.timer.Stop()
69 s.timer = nil
70 }
71 s.mu.Unlock()
72
73 // Final save on shutdown
74 return s.SaveNow(ctx)
75}
76
77// MarkDirty signals that state has changed.
78// Debounces saves to avoid excessive DB writes.
79func (s *Service) MarkDirty() {
80 s.mu.Lock()
81 defer s.mu.Unlock()
82
83 s.dirty = true
84
85 // Reset or create timer
86 if s.timer != nil {
87 s.timer.Stop()
88 }
89
90 s.timer = time.AfterFunc(s.interval, func() {
91 s.mu.Lock()
92 ctx := s.ctx
93 s.mu.Unlock()
94
95 if ctx == nil {
96 return
97 }
98
99 if err := s.saveSnapshot(ctx); err != nil {
100 logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot")
101 }
102 })
103}
104
105// SaveNow forces immediate save (for shutdown).
106func (s *Service) SaveNow(ctx context.Context) error {
107 s.mu.Lock()
108 if s.timer != nil {
109 s.timer.Stop()
110 s.timer = nil
111 }
112 dirty := s.dirty
113 s.mu.Unlock()
114
115 if !dirty {
116 return nil
117 }
118
119 return s.saveSnapshot(ctx)
120}
121
122func (s *Service) saveSnapshot(ctx context.Context) error {
123 s.mu.Lock()
124 ready := s.ready
125 if !ready {
126 // Don't clear dirty if not ready - keep pending snapshot for later
127 s.mu.Unlock()
128 return nil
129 }
130 // Only clear dirty when we're actually going to save
131 s.dirty = false
132 s.mu.Unlock()
133
134 tabList := s.provider.GetTabList()
135 sessionID := s.provider.GetSessionID()
136
137 if sessionID == "" {
138 return nil
139 }
140
141 return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{
142 SessionID: sessionID,
143 TabList: tabList,
144 })
145}