v0.28.0 3cb3770

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}

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