detached b41867d

feat(config): add Save method with file watcher debounce

commit
b41867d04d795a4fabd4bb217eb6ae980f01608d
author
bnema · · 2026-01-16 20:51 UTC
parent
3c89aed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
2323 // RemoveRoute removes a route from the configuration.
2424 RemoveRoute(ctx context.Context, domain string) error
2525
26+ // Save persists the current configuration to disk.
27+ Save(ctx context.Context) error
28+
2629 // Watch starts watching for configuration changes.
2730 // The onChange callback is called when configuration changes are detected.
2831 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) error24 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()) error31 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.
610610 return _c
611611 }
612612
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+
613664 // UpdateRoute provides a mock function for the type MockConfigService
614665 func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error {
615666 ret := _mock.Called(ctx, route)
@@ -610,6 +610,57 @@ func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context.
610 return _c610 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 MockConfigService664 // 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 (
55 "context"
66 "strings"
77 "sync"
8+ "sync/atomic"
9+ "time"
810
911 "github.com/bnema/zerowrap"
1012 "github.com/fsnotify/fsnotify"
@@ -37,17 +39,20 @@ type Config struct {
3739
3840 // Service implements the ConfigService interface.
3941 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)
4448 }
4549
4650 // NewService creates a new config service.
4751 func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
4852 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
5156 }
5257 }
5358
@@ -171,7 +176,7 @@ func (s *Service) GetRoutes(_ context.Context) []domain.Route {
171176 return routes
172177 }
173178
174-// AddRoute adds a new route to the configuration.
179+// AddRoute adds a new route to the configuration and persists it.
175180 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
176181 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
177182 zerowrap.FieldLayer: "usecase",
@@ -181,13 +186,16 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
181186 log := zerowrap.FromCtx(ctx)
182187
183188 s.mu.Lock()
184- defer s.mu.Unlock()
185-
186189 if s.config.Routes == nil {
187190 s.config.Routes = make(map[string]string)
188191 }
189-
190192 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+ }
191199
192200 log.Info().Str("image", route.Image).Msg("route added to configuration")
193201 return nil
@@ -237,11 +245,44 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
237245 return nil
238246 }
239247
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+
240274 // Watch starts watching for configuration changes.
241275 func (s *Service) Watch(ctx context.Context, onChange func()) error {
242276 log := zerowrap.FromCtx(ctx)
243277
244278 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+
245286 log.Info().Str("file", e.Name).Msg("config file changed")
246287
247288 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.Viper42+ viper *viper.Viper
41- eventBus out.EventPublisher43+ eventBus out.EventPublisher
42- config Config44+ config Config
43- mu sync.RWMutex45+ 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 routes176 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.Image192 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 nil201 return nil
@@ -237,11 +245,44 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
237 return nil245 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 {

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 7

Tags 106

↑↓ moveenter openesc close