From 6a5d08f67d352e341aebca2d1bf5418fb2e5c8c0 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 3 Dec 2025 14:24:34 -0500 Subject: [PATCH 1/2] fix: Fix data and target availability for daemon mode --- internal/datasystem/data_availability_test.go | 397 ++++++++++++++++++ internal/datasystem/fdv1_datasystem.go | 19 +- internal/datasystem/fdv2_datasystem.go | 16 +- ldclient_events_test.go | 6 + ldclient_external_updates_only_test.go | 2 + 5 files changed, 436 insertions(+), 4 deletions(-) diff --git a/internal/datasystem/data_availability_test.go b/internal/datasystem/data_availability_test.go index bb35e14c..ba6722b9 100644 --- a/internal/datasystem/data_availability_test.go +++ b/internal/datasystem/data_availability_test.go @@ -1,9 +1,19 @@ package datasystem import ( + "context" "testing" + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-server-sdk/v7/internal" + "github.com/launchdarkly/go-server-sdk/v7/internal/datastore" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest/mocks" + "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" + "github.com/launchdarkly/go-server-sdk/v7/subsystems" + "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDataAvailibilityAtLeast(t *testing.T) { @@ -19,3 +29,390 @@ func TestDataAvailibilityAtLeast(t *testing.T) { assert.False(t, Defaults.AtLeast(Cached)) assert.True(t, Defaults.AtLeast(Defaults)) } + +func makeTestClientContext() *internal.ClientContextImpl { + basicContext := sharedtest.NewSimpleTestContext(sharedtest.TestSDKKey) + return &internal.ClientContextImpl{ + BasicClientContext: basicContext.(subsystems.BasicClientContext), + } +} + +func makeTestStore(initialized bool) subsystems.DataStore { + store := datastore.NewInMemoryDataStore(ldlog.NewDisabledLoggers()) + if initialized { + _ = store.Init([]ldstoretypes.Collection{}) + } + return store +} + +func TestFDv1DataAvailability(t *testing.T) { + t.Run("offline mode", func(t *testing.T) { + clientContext := makeTestClientContext() + clientContext.Offline = true + + fdv1, err := NewFDv1(true, nil, nil, clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Defaults, fdv1.DataAvailability()) + }) + + t.Run("LDD mode with initialized store", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(true) + + fdv1, err := NewFDv1(false, + mocks.SingleComponentConfigurer[subsystems.DataStore]{Instance: store}, + ldcomponents.ExternalUpdatesOnly(), + clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Cached, fdv1.DataAvailability()) + }) + + t.Run("LDD mode with uninitialized store", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(false) + + fdv1, err := NewFDv1(false, + mocks.SingleComponentConfigurer[subsystems.DataStore]{Instance: store}, + ldcomponents.ExternalUpdatesOnly(), + clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Defaults, fdv1.DataAvailability()) + }) + + t.Run("normal mode with no store and data source not initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + + fdv1, err := NewFDv1(false, nil, mocks.DataSourceThatNeverInitializes(), clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Defaults, fdv1.DataAvailability()) + }) + + t.Run("normal mode with no store and data source initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + + fdv1, err := NewFDv1(false, nil, mocks.DataSourceThatIsAlwaysInitialized(), clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Refreshed, fdv1.DataAvailability()) + }) + + t.Run("normal mode with initialized store and data source not initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(true) + + fdv1, err := NewFDv1(false, + mocks.SingleComponentConfigurer[subsystems.DataStore]{Instance: store}, + mocks.DataSourceThatNeverInitializes(), + clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Cached, fdv1.DataAvailability()) + }) + + t.Run("normal mode with initialized store and data source initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(true) + + fdv1, err := NewFDv1(false, + mocks.SingleComponentConfigurer[subsystems.DataStore]{Instance: store}, + mocks.DataSourceThatIsAlwaysInitialized(), + clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Refreshed, fdv1.DataAvailability()) + }) + + t.Run("normal mode with uninitialized store and data source not initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(false) + + fdv1, err := NewFDv1(false, + mocks.SingleComponentConfigurer[subsystems.DataStore]{Instance: store}, + mocks.DataSourceThatNeverInitializes(), + clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Defaults, fdv1.DataAvailability()) + }) + + t.Run("normal mode with uninitialized store and data source initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(false) + + fdv1, err := NewFDv1(false, + mocks.SingleComponentConfigurer[subsystems.DataStore]{Instance: store}, + mocks.DataSourceThatIsAlwaysInitialized(), + clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Refreshed, fdv1.DataAvailability()) + }) +} + +func TestFDv1TargetAvailability(t *testing.T) { + t.Run("offline mode", func(t *testing.T) { + clientContext := makeTestClientContext() + clientContext.Offline = true + + fdv1, err := NewFDv1(true, nil, nil, clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Defaults, fdv1.TargetAvailability()) + }) + + t.Run("LDD mode (daemon mode)", func(t *testing.T) { + clientContext := makeTestClientContext() + + fdv1, err := NewFDv1(false, nil, ldcomponents.ExternalUpdatesOnly(), clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Cached, fdv1.TargetAvailability()) + }) + + t.Run("normal mode", func(t *testing.T) { + clientContext := makeTestClientContext() + + fdv1, err := NewFDv1(false, nil, mocks.DataSourceThatIsAlwaysInitialized(), clientContext) + require.NoError(t, err) + defer fdv1.Stop() + + assert.Equal(t, Refreshed, fdv1.TargetAvailability()) + }) +} + +// mockDataSystemConfigBuilder creates a mock DataSystemConfiguration builder for testing +type mockDataSystemConfigBuilder struct { + store subsystems.DataStore + storeMode subsystems.DataStoreMode + initializers []subsystems.DataInitializer + hasSyncBuilder bool +} + +func (m mockDataSystemConfigBuilder) Build(clientContext subsystems.ClientContext) (subsystems.DataSystemConfiguration, error) { + config := subsystems.DataSystemConfiguration{ + Store: m.store, + StoreMode: m.storeMode, + Initializers: m.initializers, + } + + if m.hasSyncBuilder { + config.Synchronizers.PrimaryBuilder = func() (subsystems.DataSynchronizer, error) { + return &mockSynchronizer{}, nil + } + } + + return config, nil +} + +// mockSynchronizer is a minimal synchronizer implementation for testing +type mockSynchronizer struct{} + +func (m *mockSynchronizer) Name() string { return "mock" } +func (m *mockSynchronizer) Fetch(ds subsystems.DataSelector, ctx context.Context) (*subsystems.Basis, error) { + return nil, nil +} +func (m *mockSynchronizer) Sync(store subsystems.DataSelector) <-chan subsystems.DataSynchronizerResult { + ch := make(chan subsystems.DataSynchronizerResult) + close(ch) + return ch +} +func (m *mockSynchronizer) Close() error { return nil } + +func TestFDv2DataAvailability(t *testing.T) { + t.Run("no data sources and no store provided in data system config", func(t *testing.T) { + clientContext := makeTestClientContext() + + configBuilder := mockDataSystemConfigBuilder{ + store: nil, + storeMode: subsystems.DataStoreModeReadWrite, + initializers: nil, + hasSyncBuilder: false, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + assert.Equal(t, Defaults, fdv2.DataAvailability()) + }) + + t.Run("no data sources but store provided in read-only mode that is initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(true) + + configBuilder := mockDataSystemConfigBuilder{ + store: store, + storeMode: subsystems.DataStoreModeRead, + initializers: nil, + hasSyncBuilder: false, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + assert.Equal(t, Cached, fdv2.DataAvailability()) + }) + + t.Run("no data sources but store provided in read-only mode that is not initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(false) + + configBuilder := mockDataSystemConfigBuilder{ + store: store, + storeMode: subsystems.DataStoreModeRead, + initializers: nil, + hasSyncBuilder: false, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + assert.Equal(t, Defaults, fdv2.DataAvailability()) + }) + + t.Run("data sources configured without a store", func(t *testing.T) { + clientContext := makeTestClientContext() + + configBuilder := mockDataSystemConfigBuilder{ + store: nil, + storeMode: subsystems.DataStoreModeReadWrite, + initializers: nil, + hasSyncBuilder: true, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + + // Since we have a synchronizer but no data yet, we should have Defaults + // (store.Selector() is not defined yet) + assert.Equal(t, Defaults, fdv2.DataAvailability()) + fdv2.Stop() + }) + + t.Run("data sources configured with store that is not initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(false) + + configBuilder := mockDataSystemConfigBuilder{ + store: store, + storeMode: subsystems.DataStoreModeReadWrite, + initializers: nil, + hasSyncBuilder: true, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + assert.Equal(t, Defaults, fdv2.DataAvailability()) + }) + + t.Run("data sources configured with store that is initialized", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(true) + + configBuilder := mockDataSystemConfigBuilder{ + store: store, + storeMode: subsystems.DataStoreModeReadWrite, + initializers: nil, + hasSyncBuilder: true, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + // Store is initialized but no data received from synchronizer yet + // So we have cached data + assert.Equal(t, Cached, fdv2.DataAvailability()) + }) +} + +func TestFDv2TargetAvailability(t *testing.T) { + t.Run("disabled mode", func(t *testing.T) { + clientContext := makeTestClientContext() + + configBuilder := mockDataSystemConfigBuilder{ + store: nil, + storeMode: subsystems.DataStoreModeReadWrite, + initializers: nil, + hasSyncBuilder: false, + } + + fdv2, err := NewFDv2(true, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + assert.Equal(t, Defaults, fdv2.TargetAvailability()) + }) + + t.Run("data sources configured", func(t *testing.T) { + clientContext := makeTestClientContext() + + configBuilder := mockDataSystemConfigBuilder{ + store: nil, + storeMode: subsystems.DataStoreModeReadWrite, + initializers: nil, + hasSyncBuilder: true, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + assert.Equal(t, Refreshed, fdv2.TargetAvailability()) + }) + + t.Run("daemon mode (no data sources and no store)", func(t *testing.T) { + clientContext := makeTestClientContext() + + configBuilder := mockDataSystemConfigBuilder{ + store: nil, + storeMode: subsystems.DataStoreModeReadWrite, + initializers: nil, + hasSyncBuilder: false, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + assert.Equal(t, Cached, fdv2.TargetAvailability()) + }) + + t.Run("store provided without data sources (not daemon mode)", func(t *testing.T) { + clientContext := makeTestClientContext() + store := makeTestStore(true) + + configBuilder := mockDataSystemConfigBuilder{ + store: store, + storeMode: subsystems.DataStoreModeRead, + initializers: nil, + hasSyncBuilder: false, + } + + fdv2, err := NewFDv2(false, configBuilder, clientContext, nil) + require.NoError(t, err) + defer fdv2.Stop() + + // Has a store but no data sources, so not daemon mode + assert.Equal(t, Defaults, fdv2.TargetAvailability()) + }) +} diff --git a/internal/datasystem/fdv1_datasystem.go b/internal/datasystem/fdv1_datasystem.go index 4864db45..ec490f73 100644 --- a/internal/datasystem/fdv1_datasystem.go +++ b/internal/datasystem/fdv1_datasystem.go @@ -21,6 +21,7 @@ type FDv1 struct { dataStore subsystems.DataStore dataSource subsystems.DataSource offline bool + daemonMode bool } // NewFDv1 creates a new FDv1 instance from data store and data source configurers. Offline determines if the @@ -34,6 +35,7 @@ func NewFDv1(offline bool, dataStoreFactory subsystems.ComponentConfigurer[subsy dataStoreStatusBroadcaster: internal.NewBroadcaster[interfaces.DataStoreStatus](), flagChangeEventBroadcaster: internal.NewBroadcaster[interfaces.FlagChangeEvent](), offline: offline, + daemonMode: dataSourceFactory == ldcomponents.ExternalUpdatesOnly(), } dataStoreUpdateSink := datastore.NewDataStoreUpdateSinkImpl(system.dataStoreStatusBroadcaster) @@ -144,17 +146,30 @@ func (f *FDv1) DataAvailability() DataAvailability { if f.offline { return Defaults } - if f.dataSource.IsInitialized() { - return Refreshed + + if !f.daemonMode { + if f.dataSource.IsInitialized() { + return Refreshed + } } + if f.dataStore.IsInitialized() { return Cached } + return Defaults } //nolint:revive // Data system implementation. func (f *FDv1) TargetAvailability() DataAvailability { + if f.offline { + return Defaults + } + + if f.daemonMode { + return Cached + } + return Refreshed } diff --git a/internal/datasystem/fdv2_datasystem.go b/internal/datasystem/fdv2_datasystem.go index 9d1e20f7..0b24081a 100644 --- a/internal/datasystem/fdv2_datasystem.go +++ b/internal/datasystem/fdv2_datasystem.go @@ -62,6 +62,9 @@ type FDv2 struct { // Whether the SDK should make use of persistent store/initializers/synchronizers or not. disabled bool + // Whether the SDK is running in daemon mode (i.e., using Relay Proxy for data updates). + daemonMode bool + loggers ldlog.Loggers // Cancel and wg are used to track and stop the goroutines used by the system. @@ -163,6 +166,7 @@ func NewFDv2(disabled bool, cfgBuilder subsystems.ComponentConfigurer[subsystems } fdv2.configuredWithDataSources = len(fdv2.initializers) > 0 || fdv2.primarySyncBuilder != nil + fdv2.daemonMode = !fdv2.configuredWithDataSources && cfg.Store == nil if cfg.Store != nil && !disabled { // If there's a persistent Store, we should provide a status monitor and inform Store that it's present. @@ -437,7 +441,7 @@ func (f *FDv2) DataAvailability() DataAvailability { return Refreshed } - if !f.configuredWithDataSources || f.store.IsInitialized() { + if f.store.IsInitialized() { return Cached } @@ -446,11 +450,19 @@ func (f *FDv2) DataAvailability() DataAvailability { //nolint:revive // DataSystem method. func (f *FDv2) TargetAvailability() DataAvailability { + if f.disabled { + return Defaults + } + if f.configuredWithDataSources { return Refreshed } - return Cached + if f.daemonMode { + return Cached + } + + return Defaults } //nolint:revive // DataSystem method. diff --git a/ldclient_events_test.go b/ldclient_events_test.go index d4988f3c..43faf23d 100644 --- a/ldclient_events_test.go +++ b/ldclient_events_test.go @@ -5,7 +5,10 @@ import ( "testing" "time" + "github.com/launchdarkly/go-server-sdk/v7/internal/datastore" "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest/mocks" + "github.com/launchdarkly/go-server-sdk/v7/subsystems" + "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" "github.com/launchdarkly/go-sdk-common/v3/ldlog" "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" @@ -293,8 +296,11 @@ func TestWithEventsDisabledDecorator(t *testing.T) { doTest := func(name string, fn func(*LDClient) interfaces.LDClientInterface, shouldBeSent bool) { t.Run(name, func(t *testing.T) { events := &mocks.CapturingEventProcessor{} + store := datastore.NewInMemoryDataStore(ldlog.NewDisabledLoggers()) + store.Init([]ldstoretypes.Collection{}) config := Config{ DataSource: ldcomponents.ExternalUpdatesOnly(), + DataStore: mocks.SingleComponentConfigurer[subsystems.DataStore]{Instance: store}, Events: mocks.SingleComponentConfigurer[ldevents.EventProcessor]{Instance: events}, } client, err := MakeCustomClient("", config, 0) diff --git a/ldclient_external_updates_only_test.go b/ldclient_external_updates_only_test.go index 88b8dafb..b12e1f3a 100644 --- a/ldclient_external_updates_only_test.go +++ b/ldclient_external_updates_only_test.go @@ -15,6 +15,7 @@ import ( "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" "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" "github.com/stretchr/testify/assert" ) @@ -28,6 +29,7 @@ type clientExternalUpdatesTestParams struct { func withClientExternalUpdatesTestParams(callback func(clientExternalUpdatesTestParams)) { p := clientExternalUpdatesTestParams{} p.store = datastore.NewInMemoryDataStore(ldlog.NewDisabledLoggers()) + p.store.Init([]ldstoretypes.Collection{}) p.mockLog = ldlogtest.NewMockLog() config := Config{ DataSource: ldcomponents.ExternalUpdatesOnly(), From 1784d36f0ea23fd2b145caaf1885278bd9e0bac3 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 3 Dec 2025 15:09:36 -0500 Subject: [PATCH 2/2] cursor feedback --- internal/datasystem/data_availability_test.go | 11 ++++++----- internal/datasystem/fdv2_datasystem.go | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/datasystem/data_availability_test.go b/internal/datasystem/data_availability_test.go index ba6722b9..9b3b5293 100644 --- a/internal/datasystem/data_availability_test.go +++ b/internal/datasystem/data_availability_test.go @@ -197,9 +197,9 @@ func TestFDv1TargetAvailability(t *testing.T) { // mockDataSystemConfigBuilder creates a mock DataSystemConfiguration builder for testing type mockDataSystemConfigBuilder struct { - store subsystems.DataStore - storeMode subsystems.DataStoreMode - initializers []subsystems.DataInitializer + store subsystems.DataStore + storeMode subsystems.DataStoreMode + initializers []subsystems.DataInitializer hasSyncBuilder bool } @@ -226,6 +226,7 @@ func (m *mockSynchronizer) Name() string { return "mock" } func (m *mockSynchronizer) Fetch(ds subsystems.DataSelector, ctx context.Context) (*subsystems.Basis, error) { return nil, nil } + func (m *mockSynchronizer) Sync(store subsystems.DataSelector) <-chan subsystems.DataSynchronizerResult { ch := make(chan subsystems.DataSynchronizerResult) close(ch) @@ -394,7 +395,7 @@ func TestFDv2TargetAvailability(t *testing.T) { require.NoError(t, err) defer fdv2.Stop() - assert.Equal(t, Cached, fdv2.TargetAvailability()) + assert.Equal(t, Defaults, fdv2.TargetAvailability()) }) t.Run("store provided without data sources (not daemon mode)", func(t *testing.T) { @@ -413,6 +414,6 @@ func TestFDv2TargetAvailability(t *testing.T) { defer fdv2.Stop() // Has a store but no data sources, so not daemon mode - assert.Equal(t, Defaults, fdv2.TargetAvailability()) + assert.Equal(t, Cached, fdv2.TargetAvailability()) }) } diff --git a/internal/datasystem/fdv2_datasystem.go b/internal/datasystem/fdv2_datasystem.go index 0b24081a..a315aaab 100644 --- a/internal/datasystem/fdv2_datasystem.go +++ b/internal/datasystem/fdv2_datasystem.go @@ -166,7 +166,7 @@ func NewFDv2(disabled bool, cfgBuilder subsystems.ComponentConfigurer[subsystems } fdv2.configuredWithDataSources = len(fdv2.initializers) > 0 || fdv2.primarySyncBuilder != nil - fdv2.daemonMode = !fdv2.configuredWithDataSources && cfg.Store == nil + fdv2.daemonMode = !fdv2.configuredWithDataSources && cfg.Store != nil if cfg.Store != nil && !disabled { // If there's a persistent Store, we should provide a status monitor and inform Store that it's present.