fix(config): debounce fsnotify reload events to avoid mid-write reads
Backend CI / Backend (push) Successful in 13m20s

os.WriteFile truncates then writes, so inotify can deliver back-to-back
MODIFY events while the file is momentarily empty. Reading mid-sequence
made TestStoreReloadsWhenLocalOverrideIsCreated and
TestStoreReloadsConfigAndKeepsPreviousOnInvalidYAML flake on Linux CI by
firing onReload with a blank config before the write settled.

Coalesce updates over a 50ms quiet window before reloading, and skip
onReload when the diff is empty so subscribers only see real changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 21:55:49 +08:00
parent 11079ca90a
commit 7fbc2a03d3
+32
View File
@@ -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),