From ea035081bfb36189329cf17423b576149da1dced Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:54:34 +0000 Subject: [PATCH] fix: Poll the override files when they cannot be watched When watching was selected and the file watcher could not be created, for example because the limit on inotify instances was reached, the source logged the error and ran with no change detection, so a later edit never took effect. It now polls the files instead, at no less than the minimum poll interval, and the log says so. --- ldoverrides/file_source_builder.go | 2 ++ ldoverrides/file_source_impl.go | 16 ++++++++++++---- ldoverrides/file_source_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/ldoverrides/file_source_builder.go b/ldoverrides/file_source_builder.go index faacad73..7f7d3c02 100644 --- a/ldoverrides/file_source_builder.go +++ b/ldoverrides/file_source_builder.go @@ -5,6 +5,7 @@ import ( "time" "github.com/launchdarkly/go-server-sdk/v7/internal/filedata" + "github.com/launchdarkly/go-server-sdk/v7/ldfilewatch" "github.com/launchdarkly/go-server-sdk/v7/subsystems" ) @@ -174,5 +175,6 @@ func (b *FileSourceBuilder) Build(context subsystems.ClientContext) (subsystems. changeDetection: b.changeDetection, pollInterval: pollInterval, loggers: loggers, + watchFiles: ldfilewatch.WatchOptionalFiles, }, nil } diff --git a/ldoverrides/file_source_impl.go b/ldoverrides/file_source_impl.go index bc75e5f7..112f090e 100644 --- a/ldoverrides/file_source_impl.go +++ b/ldoverrides/file_source_impl.go @@ -8,7 +8,6 @@ import ( "github.com/launchdarkly/go-sdk-common/v3/ldlog" "github.com/launchdarkly/go-server-sdk/v7/internal/filedata" - "github.com/launchdarkly/go-server-sdk/v7/ldfilewatch" "github.com/launchdarkly/go-server-sdk/v7/subsystems" "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoreimpl" "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" @@ -20,6 +19,8 @@ type fileOverrideSource struct { changeDetection ChangeDetection pollInterval time.Duration loggers ldlog.Loggers + // watchFiles sets up watching mode. Tests replace it with one that fails. + watchFiles func(paths []string, loggers ldlog.Loggers, reload func(), closeCh <-chan struct{}) error reloader *filedata.Reloader poller *filedata.Poller @@ -61,9 +62,16 @@ func (f *fileOverrideSource) Start(sink subsystems.OverrideSink) { switch f.changeDetection { case Watching: f.closeWatchCh = make(chan struct{}) - if err := ldfilewatch.WatchOptionalFiles(f.paths, f.loggers, f.reloader.Trigger, f.closeWatchCh); err != nil { - // COVERAGE: constructing a watcher only fails under unusual OS conditions - f.loggers.Errorf("Unable to watch override files: %s", err) + if err := f.watchFiles(f.paths, f.loggers, f.reloader.Trigger, f.closeWatchCh); err != nil { + // The watcher itself cannot be created, for example because the limit on inotify + // instances is reached. Without change detection a later edit would never take effect, + // so the source polls the files instead, no more often than polling mode allows. + interval := f.pollInterval + if interval < MinimumPollInterval { + interval = MinimumPollInterval + } + f.loggers.Errorf("Unable to watch override files; polling them instead every %s: %s", interval, err) + f.poller = filedata.NewPoller(f.paths, interval, f.reloader.Trigger) } case Polling: f.poller = filedata.NewPoller(f.paths, f.pollInterval, f.reloader.Trigger) diff --git a/ldoverrides/file_source_test.go b/ldoverrides/file_source_test.go index 0b725b8b..c16fb65e 100644 --- a/ldoverrides/file_source_test.go +++ b/ldoverrides/file_source_test.go @@ -1,6 +1,7 @@ package ldoverrides import ( + "errors" "github.com/launchdarkly/go-sdk-common/v3/ldlog" "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" "os" @@ -323,6 +324,33 @@ func TestFileSourceWatchingModeReloadsOnChange(t *testing.T) { require.Len(t, flags, 0) } +func TestFileSourceWatchingModeFallsBackToPollingWhenTheWatcherCannotBeCreated(t *testing.T) { + // Without change detection a later edit would never take effect. The source polls instead, at + // no less than the minimum poll interval, which watching mode otherwise ignores. + path := filepath.Join(t.TempDir(), "overrides.json") + writeFile(t, path, `{"flagValues": {"flag1": true}}`) + + mockLog := ldlogtest.NewMockLog() + context := sharedtest.NewTestContext("", nil, &subsystems.LoggingConfiguration{Loggers: mockLog.Loggers}) + source, err := FileSource().FilePaths(path).ChangeDetection(Watching).PollInterval(time.Millisecond).Build(context) + require.NoError(t, err) + source.(*fileOverrideSource).watchFiles = func([]string, ldlog.Loggers, func(), <-chan struct{}) error { + return errors.New("watcher unavailable") + } + sink := newCapturingSink() + source.Start(sink) + t.Cleanup(func() { _ = source.Close() }) + sink.requireSnapshot(t) + + writeFile(t, path, `{"flagValues": {"flag1": true, "flag2": false}}`) + newTime := time.Now().Add(2 * time.Second) + require.NoError(t, os.Chtimes(path, newTime, newTime)) + flags := flagsByKey(t, sink.requireSnapshot(t)) + require.Len(t, flags, 2) + mockLog.AssertMessageMatch(t, true, ldlog.Error, + "Unable to watch override files; polling them instead every 1s: watcher unavailable") +} + func TestFileSourcePollingModeReloadsOnChange(t *testing.T) { path := filepath.Join(t.TempDir(), "overrides.json") writeFile(t, path, `{"flagValues": {"flag1": true}}`)