feat(config): add Save method with file watcher debounce
- commit
- b41867d04d795a4fabd4bb217eb6ae980f01608d
- author
- bnema · · 2026-01-16 20:51 UTC
- parent
- 3c89aed
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +105 -10
M internal/boundaries/in/config.go +3 -0
| @@ -23,6 +23,9 @@ type ConfigService interface { | |||
| 23 | 23 | // RemoveRoute removes a route from the configuration. | |
| 24 | 24 | RemoveRoute(ctx context.Context, domain string) error | |
| 25 | 25 | ||
| 26 | + | // Save persists the current configuration to disk. | |
| 27 | + | Save(ctx context.Context) error | |
| 28 | + | ||
| 26 | 29 | // Watch starts watching for configuration changes. | |
| 27 | 30 | // The onChange callback is called when configuration changes are detected. | |
| 28 | 31 | Watch(ctx context.Context, onChange func()) error | |
| @@ -23,6 +23,9 @@ type ConfigService interface { | |||||
| 23 | // RemoveRoute removes a route from the configuration. | 23 | // RemoveRoute removes a route from the configuration. | ||
| 24 | RemoveRoute(ctx context.Context, domain string) error | 24 | RemoveRoute(ctx context.Context, domain string) error | ||
| 25 | 25 | ||||
| 26 | + | // Save persists the current configuration to disk. | |||
| 27 | + | Save(ctx context.Context) error | |||
| 28 | + | ||||
| 26 | // Watch starts watching for configuration changes. | 29 | // Watch starts watching for configuration changes. | ||
| 27 | // The onChange callback is called when configuration changes are detected. | 30 | // The onChange callback is called when configuration changes are detected. | ||
| 28 | Watch(ctx context.Context, onChange func()) error | 31 | Watch(ctx context.Context, onChange func()) error | ||
M internal/boundaries/in/mocks/mock_config_service.go +51 -0
| @@ -610,6 +610,57 @@ func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context. | |||
| 610 | 610 | return _c | |
| 611 | 611 | } | |
| 612 | 612 | ||
| 613 | + | // Save provides a mock function for the type MockConfigService | |
| 614 | + | func (_mock *MockConfigService) Save(ctx context.Context) error { | |
| 615 | + | ret := _mock.Called(ctx) | |
| 616 | + | ||
| 617 | + | if len(ret) == 0 { | |
| 618 | + | panic("no return value specified for Save") | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | var r0 error | |
| 622 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 623 | + | r0 = returnFunc(ctx) | |
| 624 | + | } else { | |
| 625 | + | r0 = ret.Error(0) | |
| 626 | + | } | |
| 627 | + | return r0 | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // MockConfigService_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save' | |
| 631 | + | type MockConfigService_Save_Call struct { | |
| 632 | + | *mock.Call | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | // Save is a helper method to define mock.On call | |
| 636 | + | // - ctx context.Context | |
| 637 | + | func (_e *MockConfigService_Expecter) Save(ctx interface{}) *MockConfigService_Save_Call { | |
| 638 | + | return &MockConfigService_Save_Call{Call: _e.mock.On("Save", ctx)} | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | func (_c *MockConfigService_Save_Call) Run(run func(ctx context.Context)) *MockConfigService_Save_Call { | |
| 642 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 643 | + | var arg0 context.Context | |
| 644 | + | if args[0] != nil { | |
| 645 | + | arg0 = args[0].(context.Context) | |
| 646 | + | } | |
| 647 | + | run( | |
| 648 | + | arg0, | |
| 649 | + | ) | |
| 650 | + | }) | |
| 651 | + | return _c | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | func (_c *MockConfigService_Save_Call) Return(err error) *MockConfigService_Save_Call { | |
| 655 | + | _c.Call.Return(err) | |
| 656 | + | return _c | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | func (_c *MockConfigService_Save_Call) RunAndReturn(run func(ctx context.Context) error) *MockConfigService_Save_Call { | |
| 660 | + | _c.Call.Return(run) | |
| 661 | + | return _c | |
| 662 | + | } | |
| 663 | + | ||
| 613 | 664 | // UpdateRoute provides a mock function for the type MockConfigService | |
| 614 | 665 | func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 615 | 666 | ret := _mock.Called(ctx, route) | |
| @@ -610,6 +610,57 @@ func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context. | |||||
| 610 | return _c | 610 | return _c | ||
| 611 | } | 611 | } | ||
| 612 | 612 | ||||
| 613 | + | // Save provides a mock function for the type MockConfigService | |||
| 614 | + | func (_mock *MockConfigService) Save(ctx context.Context) error { | |||
| 615 | + | ret := _mock.Called(ctx) | |||
| 616 | + | ||||
| 617 | + | if len(ret) == 0 { | |||
| 618 | + | panic("no return value specified for Save") | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | var r0 error | |||
| 622 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |||
| 623 | + | r0 = returnFunc(ctx) | |||
| 624 | + | } else { | |||
| 625 | + | r0 = ret.Error(0) | |||
| 626 | + | } | |||
| 627 | + | return r0 | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | // MockConfigService_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save' | |||
| 631 | + | type MockConfigService_Save_Call struct { | |||
| 632 | + | *mock.Call | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | // Save is a helper method to define mock.On call | |||
| 636 | + | // - ctx context.Context | |||
| 637 | + | func (_e *MockConfigService_Expecter) Save(ctx interface{}) *MockConfigService_Save_Call { | |||
| 638 | + | return &MockConfigService_Save_Call{Call: _e.mock.On("Save", ctx)} | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | func (_c *MockConfigService_Save_Call) Run(run func(ctx context.Context)) *MockConfigService_Save_Call { | |||
| 642 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 643 | + | var arg0 context.Context | |||
| 644 | + | if args[0] != nil { | |||
| 645 | + | arg0 = args[0].(context.Context) | |||
| 646 | + | } | |||
| 647 | + | run( | |||
| 648 | + | arg0, | |||
| 649 | + | ) | |||
| 650 | + | }) | |||
| 651 | + | return _c | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | func (_c *MockConfigService_Save_Call) Return(err error) *MockConfigService_Save_Call { | |||
| 655 | + | _c.Call.Return(err) | |||
| 656 | + | return _c | |||
| 657 | + | } | |||
| 658 | + | ||||
| 659 | + | func (_c *MockConfigService_Save_Call) RunAndReturn(run func(ctx context.Context) error) *MockConfigService_Save_Call { | |||
| 660 | + | _c.Call.Return(run) | |||
| 661 | + | return _c | |||
| 662 | + | } | |||
| 663 | + | ||||
| 613 | // UpdateRoute provides a mock function for the type MockConfigService | 664 | // UpdateRoute provides a mock function for the type MockConfigService | ||
| 614 | func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error { | 665 | func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error { | ||
| 615 | ret := _mock.Called(ctx, route) | 666 | ret := _mock.Called(ctx, route) | ||
M internal/usecase/config/service.go +51 -10
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/zerowrap" | |
| 10 | 12 | "github.com/fsnotify/fsnotify" | |
| @@ -37,17 +39,20 @@ type Config struct { | |||
| 37 | 39 | ||
| 38 | 40 | // Service implements the ConfigService interface. | |
| 39 | 41 | type Service struct { | |
| 40 | - | viper *viper.Viper | |
| 41 | - | eventBus out.EventPublisher | |
| 42 | - | config Config | |
| 43 | - | mu sync.RWMutex | |
| 42 | + | viper *viper.Viper | |
| 43 | + | eventBus out.EventPublisher | |
| 44 | + | config Config | |
| 45 | + | mu sync.RWMutex | |
| 46 | + | lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher) | |
| 47 | + | debounceDelay int64 // Debounce delay in nanoseconds (default 500ms) | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | // NewService creates a new config service. | |
| 47 | 51 | func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service { | |
| 48 | 52 | return &Service{ | |
| 49 | - | viper: v, | |
| 50 | - | eventBus: eventBus, | |
| 53 | + | viper: v, | |
| 54 | + | eventBus: eventBus, | |
| 55 | + | debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher | |
| 51 | 56 | } | |
| 52 | 57 | } | |
| 53 | 58 | ||
| @@ -171,7 +176,7 @@ func (s *Service) GetRoutes(_ context.Context) []domain.Route { | |||
| 171 | 176 | return routes | |
| 172 | 177 | } | |
| 173 | 178 | ||
| 174 | - | // AddRoute adds a new route to the configuration. | |
| 179 | + | // AddRoute adds a new route to the configuration and persists it. | |
| 175 | 180 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |
| 176 | 181 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 177 | 182 | zerowrap.FieldLayer: "usecase", | |
| @@ -181,13 +186,16 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 181 | 186 | log := zerowrap.FromCtx(ctx) | |
| 182 | 187 | ||
| 183 | 188 | s.mu.Lock() | |
| 184 | - | defer s.mu.Unlock() | |
| 185 | - | ||
| 186 | 189 | if s.config.Routes == nil { | |
| 187 | 190 | s.config.Routes = make(map[string]string) | |
| 188 | 191 | } | |
| 189 | - | ||
| 190 | 192 | s.config.Routes[route.Domain] = route.Image | |
| 193 | + | s.mu.Unlock() | |
| 194 | + | ||
| 195 | + | // Persist to disk | |
| 196 | + | if err := s.Save(ctx); err != nil { | |
| 197 | + | log.Warn().Err(err).Msg("failed to persist route to disk") | |
| 198 | + | } | |
| 191 | 199 | ||
| 192 | 200 | log.Info().Str("image", route.Image).Msg("route added to configuration") | |
| 193 | 201 | return nil | |
| @@ -237,11 +245,44 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | |||
| 237 | 245 | return nil | |
| 238 | 246 | } | |
| 239 | 247 | ||
| 248 | + | // Save persists the current configuration to disk. | |
| 249 | + | func (s *Service) Save(ctx context.Context) error { | |
| 250 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 251 | + | zerowrap.FieldLayer: "usecase", | |
| 252 | + | zerowrap.FieldUseCase: "SaveConfig", | |
| 253 | + | }) | |
| 254 | + | log := zerowrap.FromCtx(ctx) | |
| 255 | + | ||
| 256 | + | s.mu.Lock() | |
| 257 | + | defer s.mu.Unlock() | |
| 258 | + | ||
| 259 | + | // Update viper with current routes | |
| 260 | + | s.viper.Set("routes", s.config.Routes) | |
| 261 | + | ||
| 262 | + | // Record save time to debounce file watcher events | |
| 263 | + | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | |
| 264 | + | ||
| 265 | + | // Write config to disk | |
| 266 | + | if err := s.viper.WriteConfig(); err != nil { | |
| 267 | + | return log.WrapErr(err, "failed to write config") | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | log.Info().Msg("configuration saved to disk") | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | ||
| 240 | 274 | // Watch starts watching for configuration changes. | |
| 241 | 275 | func (s *Service) Watch(ctx context.Context, onChange func()) error { | |
| 242 | 276 | log := zerowrap.FromCtx(ctx) | |
| 243 | 277 | ||
| 244 | 278 | s.viper.OnConfigChange(func(e fsnotify.Event) { | |
| 279 | + | // Check if this event is within the debounce window of our own Save | |
| 280 | + | lastSave := atomic.LoadInt64(&s.lastSaveTime) | |
| 281 | + | if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay { | |
| 282 | + | log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)") | |
| 283 | + | return | |
| 284 | + | } | |
| 285 | + | ||
| 245 | 286 | log.Info().Str("file", e.Name).Msg("config file changed") | |
| 246 | 287 | ||
| 247 | 288 | if err := s.viper.ReadInConfig(); err != nil { | |
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | + | "sync/atomic" | |||
| 9 | + | "time" | |||
| 8 | 10 | ||||
| 9 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 10 | "github.com/fsnotify/fsnotify" | 12 | "github.com/fsnotify/fsnotify" | ||
| @@ -37,17 +39,20 @@ type Config struct { | |||||
| 37 | 39 | ||||
| 38 | // Service implements the ConfigService interface. | 40 | // Service implements the ConfigService interface. | ||
| 39 | type Service struct { | 41 | type Service struct { | ||
| 40 | - | viper *viper.Viper | 42 | + | viper *viper.Viper |
| 41 | - | eventBus out.EventPublisher | 43 | + | eventBus out.EventPublisher |
| 42 | - | config Config | 44 | + | config Config |
| 43 | - | mu sync.RWMutex | 45 | + | mu sync.RWMutex |
| 46 | + | lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher) | |||
| 47 | + | debounceDelay int64 // Debounce delay in nanoseconds (default 500ms) | |||
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | // NewService creates a new config service. | 50 | // NewService creates a new config service. | ||
| 47 | func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service { | 51 | func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service { | ||
| 48 | return &Service{ | 52 | return &Service{ | ||
| 49 | - | viper: v, | 53 | + | viper: v, |
| 50 | - | eventBus: eventBus, | 54 | + | eventBus: eventBus, |
| 55 | + | debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher | |||
| 51 | } | 56 | } | ||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| @@ -171,7 +176,7 @@ func (s *Service) GetRoutes(_ context.Context) []domain.Route { | |||||
| 171 | return routes | 176 | return routes | ||
| 172 | } | 177 | } | ||
| 173 | 178 | ||||
| 174 | - | // AddRoute adds a new route to the configuration. | 179 | + | // AddRoute adds a new route to the configuration and persists it. |
| 175 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | 180 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | ||
| 176 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 181 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 177 | zerowrap.FieldLayer: "usecase", | 182 | zerowrap.FieldLayer: "usecase", | ||
| @@ -181,13 +186,16 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||||
| 181 | log := zerowrap.FromCtx(ctx) | 186 | log := zerowrap.FromCtx(ctx) | ||
| 182 | 187 | ||||
| 183 | s.mu.Lock() | 188 | s.mu.Lock() | ||
| 184 | - | defer s.mu.Unlock() | |||
| 185 | - | ||||
| 186 | if s.config.Routes == nil { | 189 | if s.config.Routes == nil { | ||
| 187 | s.config.Routes = make(map[string]string) | 190 | s.config.Routes = make(map[string]string) | ||
| 188 | } | 191 | } | ||
| 189 | - | ||||
| 190 | s.config.Routes[route.Domain] = route.Image | 192 | s.config.Routes[route.Domain] = route.Image | ||
| 193 | + | s.mu.Unlock() | |||
| 194 | + | ||||
| 195 | + | // Persist to disk | |||
| 196 | + | if err := s.Save(ctx); err != nil { | |||
| 197 | + | log.Warn().Err(err).Msg("failed to persist route to disk") | |||
| 198 | + | } | |||
| 191 | 199 | ||||
| 192 | log.Info().Str("image", route.Image).Msg("route added to configuration") | 200 | log.Info().Str("image", route.Image).Msg("route added to configuration") | ||
| 193 | return nil | 201 | return nil | ||
| @@ -237,11 +245,44 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | |||||
| 237 | return nil | 245 | return nil | ||
| 238 | } | 246 | } | ||
| 239 | 247 | ||||
| 248 | + | // Save persists the current configuration to disk. | |||
| 249 | + | func (s *Service) Save(ctx context.Context) error { | |||
| 250 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 251 | + | zerowrap.FieldLayer: "usecase", | |||
| 252 | + | zerowrap.FieldUseCase: "SaveConfig", | |||
| 253 | + | }) | |||
| 254 | + | log := zerowrap.FromCtx(ctx) | |||
| 255 | + | ||||
| 256 | + | s.mu.Lock() | |||
| 257 | + | defer s.mu.Unlock() | |||
| 258 | + | ||||
| 259 | + | // Update viper with current routes | |||
| 260 | + | s.viper.Set("routes", s.config.Routes) | |||
| 261 | + | ||||
| 262 | + | // Record save time to debounce file watcher events | |||
| 263 | + | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | |||
| 264 | + | ||||
| 265 | + | // Write config to disk | |||
| 266 | + | if err := s.viper.WriteConfig(); err != nil { | |||
| 267 | + | return log.WrapErr(err, "failed to write config") | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | log.Info().Msg("configuration saved to disk") | |||
| 271 | + | return nil | |||
| 272 | + | } | |||
| 273 | + | ||||
| 240 | // Watch starts watching for configuration changes. | 274 | // Watch starts watching for configuration changes. | ||
| 241 | func (s *Service) Watch(ctx context.Context, onChange func()) error { | 275 | func (s *Service) Watch(ctx context.Context, onChange func()) error { | ||
| 242 | log := zerowrap.FromCtx(ctx) | 276 | log := zerowrap.FromCtx(ctx) | ||
| 243 | 277 | ||||
| 244 | s.viper.OnConfigChange(func(e fsnotify.Event) { | 278 | s.viper.OnConfigChange(func(e fsnotify.Event) { | ||
| 279 | + | // Check if this event is within the debounce window of our own Save | |||
| 280 | + | lastSave := atomic.LoadInt64(&s.lastSaveTime) | |||
| 281 | + | if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay { | |||
| 282 | + | log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)") | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | ||||
| 245 | log.Info().Str("file", e.Name).Msg("config file changed") | 286 | log.Info().Str("file", e.Name).Msg("config file changed") | ||
| 246 | 287 | ||||
| 247 | if err := s.viper.ReadInConfig(); err != nil { | 288 | if err := s.viper.ReadInConfig(); err != nil { | ||