v0.27.2 5960dd8

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}

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