v0.31.0 bf0756b

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/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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close