detached d759bef

fix(snapshot): prevent FK constraint violation on session snapshots

Add ready flag to snapshot service that blocks saves until the session
is persisted to DB. OnSessionPersisted callback notifies when safe.
commit
d759bef05667a7e4b013dca6b94e6273401c3d14
author
brice · · 2026-01-14 08:44 UTC
parent
2e6c485
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +37 -1
M cmd/dumber/main.go +15 -1
@@ -217,6 +217,11 @@ func configureDeferredInit(
217217 if uiDeps == nil {
218218 return
219219 }
220+
221+ // If session was already persisted (not deferred), mark snapshot service ready immediately
222+ // via callback that will be set by the app after initialization
223+ sessionAlreadyPersisted := session == nil || session.Persist == nil
224+
220225 uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
221226 logger := logging.FromContext(cbCtx)
222227 bgCtx := logging.WithContext(context.Background(), *logger)
@@ -227,10 +232,19 @@ func configureDeferredInit(
227232 })
228233 logDeferredInitResults(bgCtx, result)
229234 }()
230- if session != nil && session.Persist != nil {
235+
236+ if sessionAlreadyPersisted {
237+ // Session was persisted eagerly, notify immediately
238+ if uiDeps.OnSessionPersisted != nil {
239+ uiDeps.OnSessionPersisted()
240+ }
241+ } else if session.Persist != nil {
242+ // Session persist is deferred, notify after it completes
231243 go func() {
232244 if persistErr := session.Persist(bgCtx); persistErr != nil {
233245 logger.Error().Err(persistErr).Msg("deferred session persistence failed")
246+ } else if uiDeps.OnSessionPersisted != nil {
247+ uiDeps.OnSessionPersisted()
234248 }
235249 }()
236250 }
@@ -217,6 +217,11 @@ func configureDeferredInit(
217 if uiDeps == nil {217 if uiDeps == nil {
218 return218 return
219 }219 }
220+
221+ // If session was already persisted (not deferred), mark snapshot service ready immediately
222+ // via callback that will be set by the app after initialization
223+ sessionAlreadyPersisted := session == nil || session.Persist == nil
224+
220 uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {225 uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
221 logger := logging.FromContext(cbCtx)226 logger := logging.FromContext(cbCtx)
222 bgCtx := logging.WithContext(context.Background(), *logger)227 bgCtx := logging.WithContext(context.Background(), *logger)
@@ -227,10 +232,19 @@ func configureDeferredInit(
227 })232 })
228 logDeferredInitResults(bgCtx, result)233 logDeferredInitResults(bgCtx, result)
229 }()234 }()
230- if session != nil && session.Persist != nil {235+
236+ if sessionAlreadyPersisted {
237+ // Session was persisted eagerly, notify immediately
238+ if uiDeps.OnSessionPersisted != nil {
239+ uiDeps.OnSessionPersisted()
240+ }
241+ } else if session.Persist != nil {
242+ // Session persist is deferred, notify after it completes
231 go func() {243 go func() {
232 if persistErr := session.Persist(bgCtx); persistErr != nil {244 if persistErr := session.Persist(bgCtx); persistErr != nil {
233 logger.Error().Err(persistErr).Msg("deferred session persistence failed")245 logger.Error().Err(persistErr).Msg("deferred session persistence failed")
246+ } else if uiDeps.OnSessionPersisted != nil {
247+ uiDeps.OnSessionPersisted()
234 }248 }
235 }()249 }()
236 }250 }
M internal/infrastructure/snapshot/service.go +16 -0
@@ -19,6 +19,7 @@ type Service struct {
1919 mu sync.Mutex
2020 timer *time.Timer
2121 dirty bool
22+ ready bool // true when session is persisted to DB and snapshots can be saved
2223 ctx context.Context
2324 cancel context.CancelFunc
2425 }
@@ -48,6 +49,15 @@ func (s *Service) Start(ctx context.Context) {
4849 logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
4950 }
5051
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.
55+func (s *Service) SetReady() {
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ s.ready = true
59+}
60+
5161 // Stop stops the service and saves final state.
5262 func (s *Service) Stop(ctx context.Context) error {
5363 s.mu.Lock()
@@ -112,8 +122,14 @@ func (s *Service) SaveNow(ctx context.Context) error {
112122 func (s *Service) saveSnapshot(ctx context.Context) error {
113123 s.mu.Lock()
114124 s.dirty = false
125+ ready := s.ready
115126 s.mu.Unlock()
116127
128+ // Don't save until session is persisted to avoid FK constraint violations
129+ if !ready {
130+ return nil
131+ }
132+
117133 tabList := s.provider.GetTabList()
118134 sessionID := s.provider.GetSessionID()
119135
@@ -19,6 +19,7 @@ type Service struct {
19 mu sync.Mutex19 mu sync.Mutex
20 timer *time.Timer20 timer *time.Timer
21 dirty bool21 dirty bool
22+ ready bool // true when session is persisted to DB and snapshots can be saved
22 ctx context.Context23 ctx context.Context
23 cancel context.CancelFunc24 cancel context.CancelFunc
24 }25 }
@@ -48,6 +49,15 @@ func (s *Service) Start(ctx context.Context) {
48 logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")49 logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
49 }50 }
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.
55+func (s *Service) SetReady() {
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ s.ready = true
59+}
60+
51 // Stop stops the service and saves final state.61 // Stop stops the service and saves final state.
52 func (s *Service) Stop(ctx context.Context) error {62 func (s *Service) Stop(ctx context.Context) error {
53 s.mu.Lock()63 s.mu.Lock()
@@ -112,8 +122,14 @@ func (s *Service) SaveNow(ctx context.Context) error {
112 func (s *Service) saveSnapshot(ctx context.Context) error {122 func (s *Service) saveSnapshot(ctx context.Context) error {
113 s.mu.Lock()123 s.mu.Lock()
114 s.dirty = false124 s.dirty = false
125+ ready := s.ready
115 s.mu.Unlock()126 s.mu.Unlock()
116 127
128+ // Don't save until session is persisted to avoid FK constraint violations
129+ if !ready {
130+ return nil
131+ }
132+
117 tabList := s.provider.GetTabList()133 tabList := s.provider.GetTabList()
118 sessionID := s.provider.GetSessionID()134 sessionID := s.provider.GetSessionID()
119 135
M internal/ui/app.go +5 -0
@@ -1016,6 +1016,11 @@ func (a *App) initSnapshotService(ctx context.Context) {
10161016 a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
10171017 a.snapshotService.Start(ctx)
10181018
1019+ // Set up callback for main.go to notify when session is persisted
1020+ a.deps.OnSessionPersisted = func() {
1021+ a.snapshotService.SetReady()
1022+ }
1023+
10191024 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
10201025 }
10211026
@@ -1016,6 +1016,11 @@ func (a *App) initSnapshotService(ctx context.Context) {
1016 a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)1016 a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
1017 a.snapshotService.Start(ctx)1017 a.snapshotService.Start(ctx)
1018 1018
1019+ // Set up callback for main.go to notify when session is persisted
1020+ a.deps.OnSessionPersisted = func() {
1021+ a.snapshotService.SetReady()
1022+ }
1023+
1019 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")1024 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1020 }1025 }
1021 1026
M internal/ui/deps.go +1 -0
@@ -25,6 +25,7 @@ type Dependencies struct {
2525 InitialURL string // URL to open on startup (optional)
2626 RestoreSessionID string // Session ID to restore on startup (optional)
2727 OnFirstWebViewShown func(context.Context)
28+ OnSessionPersisted func() // Called by main after session is persisted to DB
2829
2930 // Theme and color scheme management
3031 Theme *theme.Manager
@@ -25,6 +25,7 @@ type Dependencies struct {
25 InitialURL string // URL to open on startup (optional)25 InitialURL string // URL to open on startup (optional)
26 RestoreSessionID string // Session ID to restore on startup (optional)26 RestoreSessionID string // Session ID to restore on startup (optional)
27 OnFirstWebViewShown func(context.Context)27 OnFirstWebViewShown func(context.Context)
28+ OnSessionPersisted func() // Called by main after session is persisted to DB
28 29
29 // Theme and color scheme management30 // Theme and color scheme management
30 Theme *theme.Manager31 Theme *theme.Manager

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