service.go RawHistoryFolder

1package snapshot
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"sync"
9	"sync/atomic"
10	"time"
11
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/application/usecase"
14	"github.com/bnema/dumber/internal/domain/entity"
15	"github.com/bnema/dumber/internal/logging"
16)
17
18const (
19	defaultSnapshotIntervalMs = 5000
20	maxFKRetries              = 3
21	fkRetryDelay              = 100 * time.Millisecond
22)
23
24// Compile-time interface check.
25var _ port.SnapshotService = (*Service)(nil)
26
27// Compile-time drain boundary check.
28var _ port.PersistenceDrain = (*Service)(nil)
29
30// Service handles debounced session state snapshots.
31type Service struct {
32	snapshotUC *usecase.SnapshotSessionUseCase
33	provider   port.WindowStateProvider
34	interval   time.Duration
35	retries    int
36	retryDelay time.Duration
37
38	mu       sync.Mutex
39	timer    *time.Timer
40	timerGen uint64
41	// stopped latches Stop: no new debounce work schedules afterwards.
42	stopped bool
43	dirty   bool
44	ready   bool // true when session is persisted to DB and snapshots can be saved
45	ctx     context.Context
46	cancel  context.CancelFunc
47
48	// Drain boundary for the residency quiescence contract. inflight
49	// counts an executing capture/database save; lastErr records the
50	// terminal failure of the most recent save while dirty is preserved
51	// for reporting and later retry. Failed writes are settled-but-failed:
52	// the drain terminates instead of looping forever on dirty.
53	inflight  atomic.Int32
54	lastErr   error
55	onSettled []func()
56}
57
58// NewService creates a new snapshot service.
59func NewService(
60	snapshotUC *usecase.SnapshotSessionUseCase,
61	provider port.WindowStateProvider,
62	intervalMs int,
63) *Service {
64	if intervalMs <= 0 {
65		intervalMs = defaultSnapshotIntervalMs
66	}
67	return &Service{
68		snapshotUC: snapshotUC,
69		provider:   provider,
70		interval:   time.Duration(intervalMs) * time.Millisecond,
71		retries:    maxFKRetries,
72		retryDelay: fkRetryDelay,
73	}
74}
75
76// Start begins watching for dirty state.
77func (s *Service) Start(ctx context.Context) {
78	s.mu.Lock()
79	defer s.mu.Unlock()
80
81	s.ctx, s.cancel = context.WithCancel(ctx)
82	logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
83}
84
85// SetReady marks the service as ready to save snapshots.
86// Call this after the session has been persisted to the database
87// to avoid FK constraint violations.
88func (s *Service) SetReady() {
89	s.mu.Lock()
90	if s.timer != nil {
91		s.timer.Stop()
92		s.timer = nil
93	}
94	s.ready = true
95	dirty := s.dirty
96	ctx := s.ctx
97	s.mu.Unlock()
98
99	if !dirty || ctx == nil {
100		return
101	}
102
103	go func() {
104		if err := s.saveSnapshot(ctx); err != nil {
105			logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready")
106		}
107	}()
108}
109
110// Stop stops the service and saves final state, then joins an
111// already-running save within a bounded wait so shutdown observes the
112// drain barrier instead of racing it. The wait never blocks forever.
113// Latching stopped also prevents any later debounce from scheduling work
114// on the dead service.
115func (s *Service) Stop(ctx context.Context) error {
116	s.mu.Lock()
117	if s.cancel != nil {
118		s.cancel()
119	}
120	if s.timer != nil {
121		s.timer.Stop()
122		s.timer = nil
123	}
124	s.stopped = true
125	s.mu.Unlock()
126
127	// Final save on shutdown
128	err := s.SaveNow(ctx)
129	drainCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
130	defer cancel()
131	if waitErr := s.WaitSettled(drainCtx); waitErr != nil {
132		logging.FromContext(ctx).Warn().Err(waitErr).Msg("snapshot drain did not settle before shutdown")
133	}
134	return err
135}
136
137// MarkDirty signals that state has changed.
138// Debounces saves to avoid excessive DB writes. It is a no-op after Stop
139// so shutdown never resurrects debounce work on the dead service.
140func (s *Service) MarkDirty() {
141	s.mu.Lock()
142	defer s.mu.Unlock()
143	if s.stopped {
144		return
145	}
146	s.dirty = true
147
148	// Reset or create timer
149	if s.timer != nil {
150		s.timer.Stop()
151	}
152	s.timerGen++
153	gen := s.timerGen
154	s.timer = time.AfterFunc(s.interval, func() {
155		s.mu.Lock()
156		// Retire the owning timer as it fires: a non-nil timer means
157		// debounce work is outstanding, so leaving it set would strand
158		// the drain after the save settles. The generation check keeps
159		// a superseded timer from clearing its replacement, and a
160		// superseded or stopped timer saves nothing: the replacement
161		// timer or Stop's own final save owns that work.
162		if s.timerGen != gen || s.stopped {
163			if s.timerGen == gen {
164				s.timer = nil
165			}
166			s.mu.Unlock()
167			return
168		}
169		s.timer = nil
170		ctx := s.ctx
171		s.mu.Unlock()
172
173		if ctx == nil {
174			return
175		}
176
177		if err := s.saveSnapshot(ctx); err != nil {
178			logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot")
179		}
180	})
181}
182
183// SaveNow forces immediate save (for shutdown).
184func (s *Service) SaveNow(ctx context.Context) error {
185	s.mu.Lock()
186	if s.timer != nil {
187		s.timer.Stop()
188		s.timer = nil
189	}
190	dirty := s.dirty
191	s.mu.Unlock()
192
193	if !dirty {
194		return nil
195	}
196
197	return s.saveSnapshot(ctx)
198}
199
200func (s *Service) saveSnapshot(ctx context.Context) error {
201	s.mu.Lock()
202	ready := s.ready
203	if !ready {
204		// Don't clear dirty if not ready - keep pending snapshot for later
205		s.mu.Unlock()
206		return nil
207	}
208	// Claim the dirty work atomically: concurrent timer and SaveNow paths
209	// serialize here, so exactly one save runs and shutdown joins rather
210	// than duplicates the active save.
211	if !s.dirty {
212		s.mu.Unlock()
213		return nil
214	}
215	// Only clear dirty when we're actually going to save
216	s.dirty = false
217	s.lastErr = nil
218	s.inflight.Add(1)
219	s.mu.Unlock()
220
221	defer func() {
222		s.inflight.Add(-1)
223		s.notifyIfSettled()
224	}()
225
226	sessionID := s.provider.GetSessionID()
227
228	if sessionID == "" {
229		s.MarkDirty()
230		return nil
231	}
232
233	windows, activeWindowIndex := s.provider.GetWindowSnapshotState()
234	// A nil window list with no active index means the snapshot is truly unavailable,
235	// not merely empty. Keep the session dirty so a later capture can retry.
236	if windows == nil && activeWindowIndex < 0 {
237		s.MarkDirty()
238		logging.FromContext(ctx).Warn().Msg("window snapshot unavailable; keeping session snapshot dirty")
239		return nil
240	}
241	if windows == nil {
242		windows = make([]entity.WindowTabListState, 0)
243	}
244	input := usecase.SnapshotInput{
245		SessionID:         sessionID,
246		Windows:           windows,
247		ActiveWindowIndex: activeWindowIndex,
248	}
249	// single window with WindowID == "" is legacy v1 sentinel; populate TabList and clear Windows/ActiveWindowIndex.
250	if len(windows) == 1 && windows[0].WindowID == "" {
251		input.Windows = nil
252		input.ActiveWindowIndex = 0
253		input.TabList = windows[0].Tabs
254	}
255
256	if err := s.executeWithRetry(ctx, input); err != nil {
257		s.markDirty()
258		s.setTerminalError(err)
259		return err
260	}
261
262	return nil
263}
264
265// Active reports whether snapshot work is outstanding: pending debounce,
266// an armed timer, or an in-flight capture/database save. A terminally
267// failed save is settled-but-failed: dirty is preserved for reporting and
268// later retry while lastErr records it, so the drain always terminates and
269// never loops forever merely because dirty remains set.
270func (s *Service) Active() bool {
271	s.mu.Lock()
272	defer s.mu.Unlock()
273	if s.inflight.Load() > 0 {
274		return true
275	}
276	if s.timer != nil {
277		return true
278	}
279	if !s.dirty {
280		return false
281	}
282	return s.lastErr == nil
283}
284
285// LastError returns the terminal error of the most recent save, or nil.
286// Dirty is preserved alongside it for reporting and later retry.
287func (s *Service) LastError() error {
288	s.mu.Lock()
289	defer s.mu.Unlock()
290	return s.lastErr
291}
292
293// OnSettled registers a callback invoked when the service reaches a
294// settled state after a save completes. Callbacks run on the completing
295// goroutine and must be non-blocking; the UI wraps them with GTK dispatch.
296// There is no unsubscribe; services are process-lifetime owners.
297func (s *Service) OnSettled(fn func()) {
298	if fn == nil {
299		return
300	}
301	s.mu.Lock()
302	defer s.mu.Unlock()
303	s.onSettled = append(s.onSettled, fn)
304}
305
306// WaitSettled blocks until no snapshot work is outstanding or ctx ends.
307// It reports ctx.Err() on expiry so shutdown never waits forever. It must
308// never be called on the GTK thread when capture dispatches there.
309func (s *Service) WaitSettled(ctx context.Context) error {
310	ticker := time.NewTicker(10 * time.Millisecond)
311	defer ticker.Stop()
312	for {
313		if !s.Active() {
314			return nil
315		}
316		select {
317		case <-ctx.Done():
318			return ctx.Err()
319		case <-ticker.C:
320		}
321	}
322}
323
324func (s *Service) setTerminalError(err error) {
325	s.mu.Lock()
326	defer s.mu.Unlock()
327	s.lastErr = err
328}
329
330func (s *Service) notifyIfSettled() {
331	if s.Active() {
332		return
333	}
334	s.mu.Lock()
335	callbacks := append([]func(){}, s.onSettled...)
336	s.mu.Unlock()
337	for _, fn := range callbacks {
338		fn()
339	}
340}
341
342func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
343	log := logging.FromContext(ctx)
344	maxAttempts := s.retries + 1
345
346	var lastErr error
347	for attempt := 1; attempt <= maxAttempts; attempt++ {
348		if err := s.snapshotUC.Execute(ctx, input); err != nil {
349			lastErr = err
350			if !isTransientFKError(err) || attempt == maxAttempts {
351				if attempt == maxAttempts && isTransientFKError(err) {
352					log.Error().
353						Err(err).
354						Str("session_id", string(input.SessionID)).
355						Int("attempts", maxAttempts).
356						Msg("snapshot save retries exhausted after transient fk violation")
357				}
358				return err
359			}
360
361			log.Warn().
362				Err(err).
363				Str("session_id", string(input.SessionID)).
364				Int("attempt", attempt).
365				Int("max_attempts", maxAttempts).
366				Dur("retry_delay", s.retryDelay).
367				Msg("transient fk violation while saving snapshot; retrying")
368
369			if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil {
370				return fmt.Errorf("waiting to retry snapshot save: %w", waitErr)
371			}
372			continue
373		}
374
375		return nil
376	}
377
378	return lastErr
379}
380
381func (s *Service) markDirty() {
382	s.mu.Lock()
383	s.dirty = true
384	s.mu.Unlock()
385}
386
387func waitForRetry(ctx context.Context, delay time.Duration) error {
388	if delay <= 0 {
389		return nil
390	}
391	timer := time.NewTimer(delay)
392	defer timer.Stop()
393
394	select {
395	case <-ctx.Done():
396		return ctx.Err()
397	case <-timer.C:
398		return nil
399	}
400}
401
402func isTransientFKError(err error) bool {
403	// SQLite-specific transient FK detection:
404	// we match case-insensitive "foreign key" text from wrapped driver errors.
405	// Message wording may vary across SQLite versions/wrappers; adjust this check
406	// if transient FK retries stop matching expected failures in production logs.
407	if err == nil {
408		return false
409	}
410	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
411		return false
412	}
413	return strings.Contains(strings.ToLower(err.Error()), "foreign key")
414}

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