Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/datasystem/fdv1_datasystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
44 changes: 44 additions & 0 deletions internal/datasystem/fdv2_datasystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
}
Expand All @@ -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() {
Expand Down
147 changes: 147 additions & 0 deletions internal/datasystem/fdv2_overrides_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
2 changes: 2 additions & 0 deletions internal/overrides/sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions internal/sharedtest/test_override_source.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions ldclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
Loading
Loading