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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +37 -1
M cmd/dumber/main.go +15 -1
| @@ -217,6 +217,11 @@ func configureDeferredInit( | |||
| 217 | 217 | if uiDeps == nil { | |
| 218 | 218 | 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 | 225 | uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) { | |
| 221 | 226 | logger := logging.FromContext(cbCtx) | |
| 222 | 227 | bgCtx := logging.WithContext(context.Background(), *logger) | |
| @@ -227,10 +232,19 @@ func configureDeferredInit( | |||
| 227 | 232 | }) | |
| 228 | 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 | 243 | go func() { | |
| 232 | 244 | if persistErr := session.Persist(bgCtx); persistErr != nil { | |
| 233 | 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 | } | |
| @@ -217,6 +217,11 @@ func configureDeferredInit( | |||||
| 217 | if uiDeps == nil { | 217 | if uiDeps == nil { | ||
| 218 | return | 218 | 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 { | |||
| 19 | 19 | mu sync.Mutex | |
| 20 | 20 | timer *time.Timer | |
| 21 | 21 | dirty bool | |
| 22 | + | ready bool // true when session is persisted to DB and snapshots can be saved | |
| 22 | 23 | ctx context.Context | |
| 23 | 24 | cancel context.CancelFunc | |
| 24 | 25 | } | |
| @@ -48,6 +49,15 @@ func (s *Service) Start(ctx context.Context) { | |||
| 48 | 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 | 61 | // Stop stops the service and saves final state. | |
| 52 | 62 | func (s *Service) Stop(ctx context.Context) error { | |
| 53 | 63 | s.mu.Lock() | |
| @@ -112,8 +122,14 @@ func (s *Service) SaveNow(ctx context.Context) error { | |||
| 112 | 122 | func (s *Service) saveSnapshot(ctx context.Context) error { | |
| 113 | 123 | s.mu.Lock() | |
| 114 | 124 | s.dirty = false | |
| 125 | + | ready := s.ready | |
| 115 | 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 | 133 | tabList := s.provider.GetTabList() | |
| 118 | 134 | sessionID := s.provider.GetSessionID() | |
| 119 | 135 | ||
| @@ -19,6 +19,7 @@ type Service struct { | |||||
| 19 | mu sync.Mutex | 19 | mu sync.Mutex | ||
| 20 | timer *time.Timer | 20 | timer *time.Timer | ||
| 21 | dirty bool | 21 | dirty bool | ||
| 22 | + | ready bool // true when session is persisted to DB and snapshots can be saved | |||
| 22 | ctx context.Context | 23 | ctx context.Context | ||
| 23 | cancel context.CancelFunc | 24 | 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 = false | 124 | 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) { | |||
| 1016 | 1016 | a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs) | |
| 1017 | 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 | 1024 | log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started") | |
| 1020 | 1025 | } | |
| 1021 | 1026 | ||
| @@ -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 { | |||
| 25 | 25 | InitialURL string // URL to open on startup (optional) | |
| 26 | 26 | RestoreSessionID string // Session ID to restore on startup (optional) | |
| 27 | 27 | OnFirstWebViewShown func(context.Context) | |
| 28 | + | OnSessionPersisted func() // Called by main after session is persisted to DB | |
| 28 | 29 | ||
| 29 | 30 | // Theme and color scheme management | |
| 30 | 31 | 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 management | 30 | // Theme and color scheme management | ||
| 30 | Theme *theme.Manager | 31 | Theme *theme.Manager | ||