diff --git a/server/internal/shared/config/store.go b/server/internal/shared/config/store.go index d4e61fb..edae8f6 100644 --- a/server/internal/shared/config/store.go +++ b/server/internal/shared/config/store.go @@ -103,11 +103,40 @@ func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(Rel case <-s.ctx.Done(): return nil case <-s.updates: + if !s.coalesceUpdates(ctx, reloadDebounce) { + return nil + } s.reload(logger, onReload) } } } +// reloadDebounce coalesces fsnotify events from a single write. os.WriteFile +// opens with O_CREATE|O_TRUNC then writes, so inotify can deliver back-to-back +// MODIFY events with the file momentarily empty. Reading mid-sequence yields a +// blank config; waiting for a brief quiet period lets the write settle. +const reloadDebounce = 50 * time.Millisecond + +func (s *Store) coalesceUpdates(ctx context.Context, window time.Duration) bool { + timer := time.NewTimer(window) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return false + case <-s.ctx.Done(): + return false + case <-s.updates: + if !timer.Stop() { + <-timer.C + } + timer.Reset(window) + case <-timer.C: + return true + } + } +} + func (s *Store) Close() { if s == nil { return @@ -138,6 +167,9 @@ func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) { s.mu.Unlock() changes := Diff(previous, next) + if len(changes) == 0 { + return + } if logger != nil { logger.Info("config reloaded", zap.Strings("files", files),