diff --git a/internal/datasystem/fdv1_datasystem.go b/internal/datasystem/fdv1_datasystem.go index f5aabb23..59ce2406 100644 --- a/internal/datasystem/fdv1_datasystem.go +++ b/internal/datasystem/fdv1_datasystem.go @@ -162,3 +162,15 @@ func (f *FDv1) TargetAvailability() DataAvailability { func (f *FDv1) Store() subsystems.ReadOnlyStore { return f.dataStore } + +// HasFlagOverride always reports false: flag overrides are available only with the FDv2 +// data system. +func (f *FDv1) HasFlagOverride(key string) bool { + return false +} + +// HasOverrides always reports false: flag overrides are available only with the FDv2 +// data system. +func (f *FDv1) HasOverrides() bool { + return false +} diff --git a/internal/datasystem/fdv2_datasystem.go b/internal/datasystem/fdv2_datasystem.go index 68df7675..c0f23923 100644 --- a/internal/datasystem/fdv2_datasystem.go +++ b/internal/datasystem/fdv2_datasystem.go @@ -11,6 +11,7 @@ import ( "github.com/launchdarkly/go-server-sdk/v7/interfaces" "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/overrides" "github.com/launchdarkly/go-server-sdk/v7/subsystems" ) @@ -95,6 +96,15 @@ type FDv2 struct { fallbackCond func(status interfaces.DataSourceStatus) bool recoveryCond func(status interfaces.DataSourceStatus) bool + + // The following are non-nil only when an override source is configured. The layer holds + // override entries, the overlay serves them in preference to the store's data at the + // store read boundary, and the source populates the layer at runtime. The override + // system is deliberately outside the initializer/synchronizer pipeline and never + // affects data availability, persistence, or the relay data destination. + overrideLayer *overrides.Layer + overlay *overrides.Overlay + overrideSource subsystems.OverrideSource } // NewFDv2 creates a new instance of the FDv2 data system. The first argument indicates if the system is enabled or @@ -160,6 +170,12 @@ func NewFDv2(disabled bool, cfgBuilder subsystems.ComponentConfigurer[subsystems fdv2.configuredWithDataSources = len(fdv2.initializers) > 0 || len(fdv2.synchronizerBuilders) > 0 + if cfg.OverrideSource != nil && !disabled { + fdv2.overrideLayer = overrides.NewLayer() + fdv2.overlay = overrides.NewOverlay(store, fdv2.overrideLayer) + fdv2.overrideSource = cfg.OverrideSource + } + if cfg.Store != nil && !disabled { // If there's a persistent Store, we should provide a status monitor and inform Store that it's present. fdv2.dataStoreStatusProvider = datastore.NewDataStoreStatusProviderImpl(cfg.Store, dataStoreUpdateSink) @@ -187,6 +203,17 @@ func (f *FDv2) Start(closeWhenReady chan struct{}) { close(closeWhenReady) return } + if f.overrideSource != nil { + // The source is started before the run loop so that a source that loads + // synchronously has its overrides in place before the client begins evaluating. + sink := overrides.NewSink(f.overrideLayer, f.store, + func(flagKey string) { + f.broadcasters.flagChangeEvent.Broadcast(interfaces.FlagChangeEvent{Key: flagKey}) + }, + f.broadcasters.flagChangeEvent.HasListeners, + f.loggers) + f.overrideSource.Start(sink) + } ctx, cancel := context.WithCancel(context.Background()) f.cancel = cancel f.launchTask(func() { @@ -483,6 +510,9 @@ func (f *FDv2) consumeSynchronizerResults( // Stop shuts down the data system. It will close any active synchronizers. If initialization is in progress, // it will cancel the process gracefully. func (f *FDv2) Stop() error { + if f.overrideSource != nil { + _ = f.overrideSource.Close() + } if f.cancel != nil { f.cancel() } @@ -495,9 +525,23 @@ func (f *FDv2) Stop() error { //nolint:revive // DataSystem method. func (f *FDv2) Store() subsystems.ReadOnlyStore { + if f.overlay != nil { + return f.overlay + } return f.store } +// HasFlagOverride reports whether the override layer currently contains a flag entry for +// the given key. +func (f *FDv2) HasFlagOverride(key string) bool { + return f.overrideLayer != nil && f.overrideLayer.HasFlag(key) +} + +// HasOverrides reports whether the override layer currently contains any entries. +func (f *FDv2) HasOverrides() bool { + return f.overrideLayer != nil && !f.overrideLayer.IsEmpty() +} + //nolint:revive // DataSystem method. func (f *FDv2) DataAvailability() DataAvailability { if f.store.Selector().IsDefined() { diff --git a/internal/datasystem/fdv2_overrides_test.go b/internal/datasystem/fdv2_overrides_test.go new file mode 100644 index 00000000..c37d1756 --- /dev/null +++ b/internal/datasystem/fdv2_overrides_test.go @@ -0,0 +1,147 @@ +package datasystem + +import ( + "testing" + "time" + + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel" + "github.com/launchdarkly/go-server-sdk/v7/interfaces" + "github.com/launchdarkly/go-server-sdk/v7/internal" + "github.com/launchdarkly/go-server-sdk/v7/internal/datakinds" + "github.com/launchdarkly/go-server-sdk/v7/internal/overrides" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + "github.com/launchdarkly/go-server-sdk/v7/subsystems" + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fixedConfig subsystems.DataSystemConfiguration + +func (f fixedConfig) Build(subsystems.ClientContext) (subsystems.DataSystemConfiguration, error) { + return subsystems.DataSystemConfiguration(f), nil +} + +func makeOverrideTestFDv2(t *testing.T, disabled bool, source subsystems.OverrideSource) *FDv2 { + t.Helper() + clientContext := &internal.ClientContextImpl{ + BasicClientContext: subsystems.BasicClientContext{ + SDKKey: sharedtest.TestSDKKey, + Logging: sharedtest.TestLoggingConfig(), + }, + } + system, err := NewFDv2(disabled, fixedConfig{OverrideSource: source}, clientContext, nil) + require.NoError(t, err) + return system +} + +func startAndWait(t *testing.T, system *FDv2) { + t.Helper() + ready := make(chan struct{}) + system.Start(ready) + select { + case <-ready: + case <-time.After(5 * time.Second): + require.FailNow(t, "timed out waiting for data system to start") + } + t.Cleanup(func() { _ = system.Stop() }) +} + +func overrideFlagData(flags ...ldmodel.FeatureFlag) []st.Collection { + coll := st.Collection{Kind: datakinds.Features} + for _, flag := range flags { + coll.Items = append(coll.Items, + st.KeyedItemDescriptor{Key: flag.Key, Item: sharedtest.FlagDescriptor(flag)}) + } + return []st.Collection{coll} +} + +func TestFDv2WithoutOverrideSourceServesRawStore(t *testing.T) { + system := makeOverrideTestFDv2(t, false, nil) + _, isRawStore := system.Store().(*Store) + assert.True(t, isRawStore, "Store() should be the raw store when no override source is configured") + assert.False(t, system.HasOverrides()) + assert.False(t, system.HasFlagOverride("anything")) +} + +func TestFDv2WithOverrideSourceServesOverlay(t *testing.T) { + source := sharedtest.NewTestOverrideSource( + overrideFlagData(ldbuilders.NewFlagBuilder("flag1").Version(1).Build())) + system := makeOverrideTestFDv2(t, false, source) + + _, isOverlay := system.Store().(*overrides.Overlay) + assert.True(t, isOverlay, "Store() should be the overlay when an override source is configured") + + // The source is not started (and the layer is empty) until Start. + assert.False(t, system.HasOverrides()) + + startAndWait(t, system) + + assert.True(t, source.IsStarted()) + assert.True(t, system.HasOverrides()) + assert.True(t, system.HasFlagOverride("flag1")) + assert.False(t, system.HasFlagOverride("flag2")) + + item, err := system.Store().Get(datakinds.Features, "flag1") + require.NoError(t, err) + flag, ok := item.Item.(*ldmodel.FeatureFlag) + require.True(t, ok) + assert.True(t, flag.IsOverride) +} + +func TestFDv2OverridesDoNotAffectDataAvailability(t *testing.T) { + source := sharedtest.NewTestOverrideSource( + overrideFlagData(ldbuilders.NewFlagBuilder("flag1").Version(1).Build())) + system := makeOverrideTestFDv2(t, false, source) + + availabilityBefore := system.DataAvailability() + startAndWait(t, system) + assert.Equal(t, availabilityBefore, system.DataAvailability()) +} + +func TestFDv2OverrideUpdatesTriggerFlagChangeEvents(t *testing.T) { + source := sharedtest.NewTestOverrideSource(nil) + system := makeOverrideTestFDv2(t, false, source) + listener := system.FlagChangeEventBroadcaster().AddListener() + startAndWait(t, system) + + source.SetOverrides(overrideFlagData(ldbuilders.NewFlagBuilder("flag1").Version(1).Build())) + + select { + case event := <-listener: + assert.Equal(t, interfaces.FlagChangeEvent{Key: "flag1"}, event) + case <-time.After(5 * time.Second): + require.FailNow(t, "timed out waiting for flag change event") + } +} + +func TestFDv2StopClosesOverrideSource(t *testing.T) { + source := sharedtest.NewTestOverrideSource(nil) + system := makeOverrideTestFDv2(t, false, source) + ready := make(chan struct{}) + system.Start(ready) + <-ready + + require.NoError(t, system.Stop()) + assert.True(t, source.IsClosed()) +} + +func TestFDv2DisabledDoesNotStartOverrideSource(t *testing.T) { + source := sharedtest.NewTestOverrideSource( + overrideFlagData(ldbuilders.NewFlagBuilder("flag1").Version(1).Build())) + system := makeOverrideTestFDv2(t, true, source) + startAndWait(t, system) + + assert.False(t, source.IsStarted()) + assert.False(t, system.HasOverrides()) + _, isRawStore := system.Store().(*Store) + assert.True(t, isRawStore) +} + +func TestFDv1HasNoOverrides(t *testing.T) { + system := &FDv1{} + assert.False(t, system.HasFlagOverride("anything")) + assert.False(t, system.HasOverrides()) +} diff --git a/internal/overrides/sink.go b/internal/overrides/sink.go index 93cb8a08..3cb1b89d 100644 --- a/internal/overrides/sink.go +++ b/internal/overrides/sink.go @@ -8,6 +8,8 @@ import ( st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" ) +var _ subsystems.OverrideSink = (*Sink)(nil) + // Sink applies override layer replacements supplied by an override source, and notifies // flag-change listeners of the flags affected by each replacement. type Sink struct { diff --git a/internal/sharedtest/test_override_source.go b/internal/sharedtest/test_override_source.go new file mode 100644 index 00000000..b6d444c0 --- /dev/null +++ b/internal/sharedtest/test_override_source.go @@ -0,0 +1,76 @@ +package sharedtest + +import ( + "sync" + + "github.com/launchdarkly/go-server-sdk/v7/subsystems" + "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +// TestOverrideSource is a simple programmatic OverrideSource for testing the override +// system without any file machinery. It also serves as a reference for the seam an +// override source implements: Start delivers the initial data synchronously, and +// SetOverrides pushes replacement snapshots to the sink at any time afterward. +type TestOverrideSource struct { + mu sync.Mutex + sink subsystems.OverrideSink + initialData []ldstoretypes.Collection + closed bool +} + +var _ subsystems.OverrideSource = (*TestOverrideSource)(nil) + +// NewTestOverrideSource creates a TestOverrideSource that will supply the given data as +// soon as it is started. A nil initialData means the source supplies nothing until +// SetOverrides is called. +func NewTestOverrideSource(initialData []ldstoretypes.Collection) *TestOverrideSource { + return &TestOverrideSource{initialData: initialData} +} + +// Start supplies the initial data, if any, before returning. +func (t *TestOverrideSource) Start(sink subsystems.OverrideSink) { + t.mu.Lock() + defer t.mu.Unlock() + t.sink = sink + if t.initialData != nil { + sink.SetOverrides(t.initialData) + } +} + +// SetOverrides replaces the override layer contents, as if the source's backing data had +// changed. It must not be called before the source is started. +func (t *TestOverrideSource) SetOverrides(data []ldstoretypes.Collection) { + t.mu.Lock() + defer t.mu.Unlock() + if t.sink != nil && !t.closed { + t.sink.SetOverrides(data) + } +} + +// Close marks the source closed; subsequent SetOverrides calls are ignored. +func (t *TestOverrideSource) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + t.closed = true + return nil +} + +// IsClosed reports whether Close has been called. +func (t *TestOverrideSource) IsClosed() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.closed +} + +// IsStarted reports whether Start has been called. +func (t *TestOverrideSource) IsStarted() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.sink != nil +} + +// Build implements ComponentConfigurer, so the source can be used directly in the +// data system configuration builder's Overrides method. +func (t *TestOverrideSource) Build(context subsystems.ClientContext) (subsystems.OverrideSource, error) { + return t, nil +} diff --git a/ldclient.go b/ldclient.go index 2b0e11ae..b24074b9 100644 --- a/ldclient.go +++ b/ldclient.go @@ -91,6 +91,13 @@ type dataSystem interface { /// TargetAvailability indicates the ideal form of data available. TargetAvailability() datasystem.DataAvailability + + // HasFlagOverride reports whether the flag override layer currently contains an entry + // for the given flag key. Always false when no override source is configured. + HasFlagOverride(key string) bool + + // HasOverrides reports whether the flag override layer currently contains any entries. + HasOverrides() bool } var _ dataSystem = &datasystem.FDv1{} diff --git a/ldcomponents/data_system_configuration_builder.go b/ldcomponents/data_system_configuration_builder.go index d1fa9d85..5ceb34ca 100644 --- a/ldcomponents/data_system_configuration_builder.go +++ b/ldcomponents/data_system_configuration_builder.go @@ -8,12 +8,13 @@ import ( // DataSystemConfigurationBuilder is a builder for configuring the SDK's data acquisition strategy. type DataSystemConfigurationBuilder struct { - storeBuilder ss.ComponentConfigurer[ss.DataStore] - storeMode ss.DataStoreMode - initializerBuilders []ss.ComponentConfigurer[ss.DataInitializer] - synchronizerBuilders []ss.ComponentConfigurer[ss.DataSynchronizer] - fdv1FallbackBuilder ss.ComponentConfigurer[ss.DataSynchronizer] - config ss.DataSystemConfiguration + storeBuilder ss.ComponentConfigurer[ss.DataStore] + storeMode ss.DataStoreMode + initializerBuilders []ss.ComponentConfigurer[ss.DataInitializer] + synchronizerBuilders []ss.ComponentConfigurer[ss.DataSynchronizer] + fdv1FallbackBuilder ss.ComponentConfigurer[ss.DataSynchronizer] + overrideSourceBuilder ss.ComponentConfigurer[ss.OverrideSource] + config ss.DataSystemConfiguration } // Endpoints represents custom endpoints for LaunchDarkly streaming and polling services. @@ -177,6 +178,22 @@ func (d *DataSystemConfigurationBuilder) FDv1CompatibleSynchronizer( return d } +// Overrides configures the SDK with an override source, which supplies flag and segment +// definitions that take precedence over data received from LaunchDarkly on a per-key basis. +// Overrides let an operator force one or more flags to a known state on a running client, +// whether or not the client can reach LaunchDarkly; flags not present in the override data +// are unaffected. +// +// The override source is not a data source: it has no effect on the client's initialization +// status or data source status, and configuring it changes nothing until the source +// actually supplies an override. +func (d *DataSystemConfigurationBuilder) Overrides( + source ss.ComponentConfigurer[ss.OverrideSource], +) *DataSystemConfigurationBuilder { + d.overrideSourceBuilder = source + return d +} + // Build creates a DataSystemConfiguration from the configuration provided to the builder. func (d *DataSystemConfigurationBuilder) Build( context ss.ClientContext, @@ -224,6 +241,13 @@ func (d *DataSystemConfigurationBuilder) Build( return d.fdv1FallbackBuilder.Build(context) } } + if d.overrideSourceBuilder != nil { + overrideSource, err := d.overrideSourceBuilder.Build(context) + if err != nil { + return ss.DataSystemConfiguration{}, err + } + conf.OverrideSource = overrideSource + } return conf, nil } diff --git a/subsystems/datasystem_configuration.go b/subsystems/datasystem_configuration.go index b7f0e688..793e4ac5 100644 --- a/subsystems/datasystem_configuration.go +++ b/subsystems/datasystem_configuration.go @@ -24,4 +24,8 @@ type DataSystemConfiguration struct { Initializers []DataInitializer // Synchronizers keep the SDK's data up-to-date continuously. Synchronizers SynchronizersConfiguration + // OverrideSource, if non-nil, supplies flag and segment overrides that take precedence + // over LaunchDarkly data at evaluation time. It is not part of the initializer or + // synchronizer pipeline and does not affect the client's initialization status. + OverrideSource OverrideSource } diff --git a/subsystems/override_source.go b/subsystems/override_source.go new file mode 100644 index 00000000..43cbd3c7 --- /dev/null +++ b/subsystems/override_source.go @@ -0,0 +1,46 @@ +package subsystems + +import ( + "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +// OverrideSink receives the contents of the SDK's flag/segment override layer. It is +// implemented by the SDK and passed to an OverrideSource's Start method; override sources +// call it, they do not implement it. +type OverrideSink interface { + // SetOverrides atomically replaces the entire override layer with the given flag and + // segment entries; an empty or nil slice clears the layer. Each call is a full snapshot: + // entries absent from the call are removed from the layer. + // + // Collections must use the SDK's standard data kinds ([ldstoreimpl.Features] and + // [ldstoreimpl.Segments]) with item values of type *ldmodel.FeatureFlag and + // *ldmodel.Segment respectively. Sources supply ordinary, fully parsed entities, such as + // those produced by the ldmodel serialization or the ldbuilders package; the SDK itself + // marks the entries as overrides. + // + // SetOverrides is safe to call from any goroutine. Calls are serialized by the SDK, and + // the new layer contents are visible to evaluations when the call returns. + SetOverrides(data []ldstoretypes.Collection) +} + +// OverrideSource supplies flag and segment overrides that take precedence over LaunchDarkly +// data at evaluation time, on a per-key basis. Overrides exist for resilience during an +// incident: they let an operator force one or more flags to a known state on a running +// client, whether or not the client can reach LaunchDarkly. +// +// An override source is not a data source. It does not participate in the data system's +// initializer/synchronizer pipeline, and the override layer it populates has no effect on +// the client's initialization status, data availability, or data source status. +// +// To configure an override source, use the Overrides method of the data system +// configuration builder in the ldcomponents package. +type OverrideSource interface { + // Start begins supplying overrides to the sink and returns without blocking on + // long-running work. Implementations typically perform an initial load synchronously, + // then push a full replacement snapshot to the sink whenever their backing data + // changes, until Close is called. A failed load should leave the previously supplied + // layer untouched by not calling the sink. + Start(sink OverrideSink) + // Close stops the source and releases any resources it holds. + Close() error +}