diff --git a/go.mod b/go.mod index b6a28ad2..ea0d3817 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/launchdarkly/go-ntlm-proxy-auth v1.0.3 github.com/launchdarkly/go-sdk-common/v3 v3.5.0 github.com/launchdarkly/go-sdk-events/v3 v3.6.2 - github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 + github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d github.com/launchdarkly/go-test-helpers/v3 v3.1.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index ee0be090..a97f5509 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/launchdarkly/go-semver v1.0.3 h1:agIy/RN3SqeQDIfKkl+oFslEdeIs7pgsJBs3 github.com/launchdarkly/go-semver v1.0.3/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 h1:rTgcYAFraGFj7sBMB2b7JCYCm0b9kph4FaMX02t4osQ= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d h1:ZtI+ZGVhnUaLOLaG20tt7hN3G+i5PiqE2lFZNMzvUQM= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= diff --git a/internal/overrides/diff.go b/internal/overrides/diff.go new file mode 100644 index 00000000..5514c7b6 --- /dev/null +++ b/internal/overrides/diff.go @@ -0,0 +1,114 @@ +package overrides + +import ( + "bytes" + + "github.com/launchdarkly/go-server-sdk/v7/internal/datakinds" + "github.com/launchdarkly/go-server-sdk/v7/internal/toposort" + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +var diffKinds = []st.DataKind{datakinds.Features, datakinds.Segments} //nolint:gochecknoglobals + +// computeAffectedFlags returns the keys of all flags whose merged-view evaluation may have +// changed when the override layer was replaced: the flags whose override entries were +// added, removed, or changed, plus — through dependency fan-out — every flag that depends, +// directly or transitively, on any added, removed, or changed entry of either kind. +func computeAffectedFlags( + oldOverrides, newOverrides layerContents, + oldMerged, newMerged mergedView, +) []string { + seeds := diffOverrides(oldOverrides, newOverrides) + if len(seeds) == 0 { + return nil + } + + // Dependency edges are computed over both the old and the new merged views, because a + // replacement can rewire dependencies: removing a flag override, for example, restores + // the LaunchDarkly definition's prerequisite edges, and flags that depended on the + // override's references only exist as dependents in the old view. + oldTracker := newTrackerFromView(oldMerged) + newTracker := newTrackerFromView(newMerged) + affected := make(toposort.Neighbors) + for _, seed := range seeds { + oldTracker.AddAffectedItems(affected, seed) + newTracker.AddAffectedItems(affected, seed) + } + + var flagKeys []string + for vertex := range affected { + if vertex.Kind() == datakinds.Features { + flagKeys = append(flagKeys, vertex.Key()) + } + } + return flagKeys +} + +// diffOverrides returns a vertex for each key whose override entry differs between the two +// layer snapshots. An added or removed entry is always a change even when its content is +// identical to the underlying LaunchDarkly data, because the override marker alone changes +// the served entry. Entries present in both snapshots are compared by their serialized +// form: the layer is rebuilt wholesale on every update, so pointer or version comparison +// would report every retained entry as changed. +func diffOverrides(oldOverrides, newOverrides layerContents) []toposort.Vertex { + var seeds []toposort.Vertex + for _, kind := range diffKinds { + oldItems := oldOverrides[kind] + newItems := newOverrides[kind] + for key, oldItem := range oldItems { + newItem, inNew := newItems[key] + if !inNew || !itemsEqual(kind, oldItem, newItem) { + seeds = append(seeds, toposort.NewVertex(kind, key)) + } + } + for key := range newItems { + if _, inOld := oldItems[key]; !inOld { + seeds = append(seeds, toposort.NewVertex(kind, key)) + } + } + } + return seeds +} + +func itemsEqual(kind st.DataKind, a, b st.ItemDescriptor) bool { + if a.Version != b.Version { + return false + } + return bytes.Equal(kind.Serialize(a), kind.Serialize(b)) +} + +// mergedView is a snapshot of the data visible at the store read boundary: base data with +// override entries overlaid. +type mergedView map[st.DataKind]map[string]st.ItemDescriptor + +// snapshotMergedView captures the merged view of a base store and a layer snapshot. A base +// read failure for a kind yields just the overrides for that kind, which degrades the +// dependency fan-out but never loses the directly changed keys. +func snapshotMergedView(base interface { + GetAll(st.DataKind) ([]st.KeyedItemDescriptor, error) +}, overrides layerContents) mergedView { + view := mergedView{} + for _, kind := range diffKinds { + items := map[string]st.ItemDescriptor{} + if baseItems, err := base.GetAll(kind); err == nil { + for _, item := range baseItems { + items[item.Key] = item.Item + } + } + for key, item := range overrides[kind] { + items[key] = item + } + view[kind] = items + } + return view +} + +func newTrackerFromView(view mergedView) *toposort.DependencyTracker { + tracker := toposort.NewDependencyTracker() + for _, kind := range diffKinds { + for key, item := range view[kind] { + tracker.UpdateDependenciesFrom(kind, key, item) + } + } + return tracker +} diff --git a/internal/overrides/layer.go b/internal/overrides/layer.go new file mode 100644 index 00000000..33bc5149 --- /dev/null +++ b/internal/overrides/layer.go @@ -0,0 +1,108 @@ +// Package overrides implements the flag/segment override layer: a runtime-mutable +// collection of flag and segment definitions, supplied by an override source, that takes +// precedence over LaunchDarkly data at evaluation time. +package overrides + +import ( + "sync" + "sync/atomic" + + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel" + "github.com/launchdarkly/go-server-sdk/v7/internal/datakinds" + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +type layerContents map[st.DataKind]map[string]st.ItemDescriptor + +// Layer is a thread-safe store of override entries, replaced wholesale on each update from +// an override source. +type Layer struct { + mu sync.RWMutex + contents layerContents + nonEmpty atomic.Bool +} + +// NewLayer creates an empty Layer. +func NewLayer() *Layer { + return &Layer{contents: layerContents{}} +} + +// SetAll atomically replaces the entire layer contents; an empty or nil slice clears it. +// Every reader treats the stored entities as immutable, and sources may retain the entities +// they supplied, so each flag or segment is stored as a marked copy rather than marking the +// caller's value. Returns the previous and new contents (the returned maps must not be +// modified). +func (l *Layer) SetAll(data []st.Collection) (previous, current layerContents) { + replacement := layerContents{} + count := 0 + for _, coll := range data { + items := make(map[string]st.ItemDescriptor, len(coll.Items)) + for _, item := range coll.Items { + items[item.Key] = markedCopy(item.Item) + count++ + } + replacement[coll.Kind] = items + } + + l.mu.Lock() + defer l.mu.Unlock() + previous = l.contents + l.contents = replacement + l.nonEmpty.Store(count != 0) + return previous, replacement +} + +// Get returns the override entry for a key, if any. +func (l *Layer) Get(kind st.DataKind, key string) (st.ItemDescriptor, bool) { + if l.IsEmpty() { + return st.ItemDescriptor{}, false + } + l.mu.RLock() + defer l.mu.RUnlock() + item, ok := l.contents[kind][key] + return item, ok +} + +// All returns the entries of the given kind. The returned map must not be modified. +func (l *Layer) All(kind st.DataKind) map[string]st.ItemDescriptor { + l.mu.RLock() + defer l.mu.RUnlock() + return l.contents[kind] +} + +// HasFlag reports whether the layer contains a flag entry for the given key. +func (l *Layer) HasFlag(key string) bool { + if l.IsEmpty() { + return false + } + l.mu.RLock() + defer l.mu.RUnlock() + _, ok := l.contents[datakinds.Features][key] + return ok +} + +// IsEmpty reports whether the layer contains no entries. It is a single atomic read, so the +// per-evaluation cost of a configured-but-unpopulated override layer is negligible. +func (l *Layer) IsEmpty() bool { + return !l.nonEmpty.Load() +} + +// markedCopy returns the item with its entity replaced by a copy carrying the override +// marker. The copies are also re-preprocessed defensively: entities that came from the +// standard deserialization or builders already are, but the sink cannot know how an +// override source constructed them, and preprocessing is idempotent. +func markedCopy(item st.ItemDescriptor) st.ItemDescriptor { + switch entity := item.Item.(type) { + case *ldmodel.FeatureFlag: + flag := *entity + flag.IsOverride = true + ldmodel.PreprocessFlag(&flag) + item.Item = &flag + case *ldmodel.Segment: + segment := *entity + segment.IsOverride = true + ldmodel.PreprocessSegment(&segment) + item.Item = &segment + } + return item +} diff --git a/internal/overrides/overlay.go b/internal/overrides/overlay.go new file mode 100644 index 00000000..a6d0de75 --- /dev/null +++ b/internal/overrides/overlay.go @@ -0,0 +1,69 @@ +package overrides + +import ( + "github.com/launchdarkly/go-server-sdk/v7/subsystems" + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +// Overlay merges an override Layer over a base store: a read for a key returns the override +// entry when one exists and the base entry otherwise. Placing the overlay at the store read +// boundary is what makes targeting rules, prerequisites, and segment matches behave +// identically for overridden and ordinary data — they are the same reads through the same +// boundary. +type Overlay struct { + base subsystems.ReadOnlyStore + layer *Layer +} + +var _ subsystems.ReadOnlyStore = (*Overlay)(nil) + +// NewOverlay creates an Overlay over the given base store and layer. +func NewOverlay(base subsystems.ReadOnlyStore, layer *Layer) *Overlay { + return &Overlay{base: base, layer: layer} +} + +// Get returns the override entry for the key if one exists, and otherwise delegates to the +// base store. This works even when the base store is uninitialized, because an uninitialized +// base reports not-found rather than failing. +func (o *Overlay) Get(kind st.DataKind, key string) (st.ItemDescriptor, error) { + if item, ok := o.layer.Get(kind, key); ok { + return item, nil + } + return o.base.Get(kind, key) +} + +// GetAll returns the union of the base store's items and the layer's items, with the +// override entry winning for any key present in both (including keys the base holds as +// deleted-item tombstones). +func (o *Overlay) GetAll(kind st.DataKind) ([]st.KeyedItemDescriptor, error) { + baseItems, err := o.base.GetAll(kind) + if err != nil { + return nil, err + } + overrideItems := o.layer.All(kind) + if len(overrideItems) == 0 { + return baseItems, nil + } + + result := make([]st.KeyedItemDescriptor, 0, len(baseItems)+len(overrideItems)) + seen := make(map[string]bool, len(baseItems)) + for _, item := range baseItems { + if overrideItem, ok := overrideItems[item.Key]; ok { + item.Item = overrideItem + } + seen[item.Key] = true + result = append(result, item) + } + for key, item := range overrideItems { + if !seen[key] { + result = append(result, st.KeyedItemDescriptor{Key: key, Item: item}) + } + } + return result, nil +} + +// IsInitialized delegates to the base store: the override layer never affects +// initialization status or data availability. +func (o *Overlay) IsInitialized() bool { + return o.base.IsInitialized() +} diff --git a/internal/overrides/overrides_test.go b/internal/overrides/overrides_test.go new file mode 100644 index 00000000..ef953607 --- /dev/null +++ b/internal/overrides/overrides_test.go @@ -0,0 +1,349 @@ +package overrides + +import ( + "errors" + "sort" + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "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/internal/datakinds" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeBaseStore is a minimal ReadOnlyStore for testing the overlay and sink against +// arbitrary base data and initialization states. +type fakeBaseStore struct { + flags map[string]st.ItemDescriptor + segments map[string]st.ItemDescriptor + initialized bool + getAllErr error +} + +func (f *fakeBaseStore) items(kind st.DataKind) map[string]st.ItemDescriptor { + switch kind { + case datakinds.Features: + return f.flags + case datakinds.Segments: + return f.segments + } + return nil +} + +func (f *fakeBaseStore) Get(kind st.DataKind, key string) (st.ItemDescriptor, error) { + if item, ok := f.items(kind)[key]; ok { + return item, nil + } + return st.ItemDescriptor{}.NotFound(), nil +} + +func (f *fakeBaseStore) GetAll(kind st.DataKind) ([]st.KeyedItemDescriptor, error) { + if f.getAllErr != nil { + return nil, f.getAllErr + } + var result []st.KeyedItemDescriptor + for key, item := range f.items(kind) { + result = append(result, st.KeyedItemDescriptor{Key: key, Item: item}) + } + return result, nil +} + +func (f *fakeBaseStore) IsInitialized() bool { return f.initialized } + +func flagCollection(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 coll +} + +func segmentCollection(segments ...ldmodel.Segment) st.Collection { + coll := st.Collection{Kind: datakinds.Segments} + for _, segment := range segments { + coll.Items = append(coll.Items, + st.KeyedItemDescriptor{Key: segment.Key, Item: sharedtest.SegmentDescriptor(segment)}) + } + return coll +} + +func requireFlag(t *testing.T, item st.ItemDescriptor) *ldmodel.FeatureFlag { + t.Helper() + flag, ok := item.Item.(*ldmodel.FeatureFlag) + require.True(t, ok, "expected a flag item") + return flag +} + +func TestLayerMarksCopiesWithoutMutatingSource(t *testing.T) { + layer := NewLayer() + flag := ldbuilders.NewFlagBuilder("flag1").Version(2).Build() + segment := ldbuilders.NewSegmentBuilder("segment1").Version(3).Build() + + layer.SetAll([]st.Collection{flagCollection(flag), segmentCollection(segment)}) + + assert.False(t, flag.IsOverride, "source flag must not be mutated") + assert.False(t, segment.IsOverride, "source segment must not be mutated") + + storedFlag, ok := layer.Get(datakinds.Features, "flag1") + require.True(t, ok) + assert.True(t, requireFlag(t, storedFlag).IsOverride) + assert.Equal(t, 2, storedFlag.Version) + + storedSegment, ok := layer.Get(datakinds.Segments, "segment1") + require.True(t, ok) + assert.True(t, storedSegment.Item.(*ldmodel.Segment).IsOverride) +} + +func TestLayerReplacementSemantics(t *testing.T) { + layer := NewLayer() + assert.True(t, layer.IsEmpty()) + assert.False(t, layer.HasFlag("flag1")) + + layer.SetAll([]st.Collection{flagCollection(ldbuilders.NewFlagBuilder("flag1").Build())}) + assert.False(t, layer.IsEmpty()) + assert.True(t, layer.HasFlag("flag1")) + + // A replacement is a full snapshot: entries absent from it are removed. + layer.SetAll([]st.Collection{flagCollection(ldbuilders.NewFlagBuilder("flag2").Build())}) + assert.False(t, layer.HasFlag("flag1")) + assert.True(t, layer.HasFlag("flag2")) + + layer.SetAll(nil) + assert.True(t, layer.IsEmpty()) + assert.False(t, layer.HasFlag("flag2")) +} + +func TestOverlayGetPrecedence(t *testing.T) { + base := &fakeBaseStore{ + flags: map[string]st.ItemDescriptor{ + "both": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("both").Version(1).Build()), + "base-only": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("base-only").Version(1).Build()), + }, + initialized: true, + } + layer := NewLayer() + layer.SetAll([]st.Collection{flagCollection( + ldbuilders.NewFlagBuilder("both").Version(99).Build(), + ldbuilders.NewFlagBuilder("override-only").Version(1).Build(), + )}) + overlay := NewOverlay(base, layer) + + item, err := overlay.Get(datakinds.Features, "both") + require.NoError(t, err) + assert.Equal(t, 99, item.Version) + assert.True(t, requireFlag(t, item).IsOverride) + + item, err = overlay.Get(datakinds.Features, "base-only") + require.NoError(t, err) + assert.False(t, requireFlag(t, item).IsOverride) + + item, err = overlay.Get(datakinds.Features, "override-only") + require.NoError(t, err) + assert.True(t, requireFlag(t, item).IsOverride) + + item, err = overlay.Get(datakinds.Features, "nowhere") + require.NoError(t, err) + assert.Nil(t, item.Item) +} + +func TestOverlayGetServesOverridesFromUninitializedBase(t *testing.T) { + base := &fakeBaseStore{initialized: false} + layer := NewLayer() + layer.SetAll([]st.Collection{flagCollection(ldbuilders.NewFlagBuilder("flag1").Build())}) + overlay := NewOverlay(base, layer) + + item, err := overlay.Get(datakinds.Features, "flag1") + require.NoError(t, err) + assert.True(t, requireFlag(t, item).IsOverride) + assert.False(t, overlay.IsInitialized()) +} + +func TestOverlayGetAllUnion(t *testing.T) { + base := &fakeBaseStore{ + flags: map[string]st.ItemDescriptor{ + "both": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("both").Version(1).Build()), + "base-only": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("base-only").Version(1).Build()), + "tombstone": {Version: 5, Item: nil}, + }, + initialized: true, + } + layer := NewLayer() + layer.SetAll([]st.Collection{flagCollection( + ldbuilders.NewFlagBuilder("both").Version(99).Build(), + ldbuilders.NewFlagBuilder("tombstone").Version(1).Build(), + ldbuilders.NewFlagBuilder("override-only").Version(1).Build(), + )}) + overlay := NewOverlay(base, layer) + + items, err := overlay.GetAll(datakinds.Features) + require.NoError(t, err) + byKey := map[string]st.ItemDescriptor{} + for _, item := range items { + byKey[item.Key] = item.Item + } + require.Len(t, byKey, 4) + assert.Equal(t, 99, byKey["both"].Version) + assert.True(t, requireFlag(t, byKey["both"]).IsOverride) + assert.False(t, requireFlag(t, byKey["base-only"]).IsOverride) + assert.NotNil(t, byKey["tombstone"].Item, "override must win over a deleted-item tombstone") + assert.True(t, requireFlag(t, byKey["override-only"]).IsOverride) +} + +func TestOverlayGetAllWithEmptyLayerIsPassthrough(t *testing.T) { + base := &fakeBaseStore{ + flags: map[string]st.ItemDescriptor{"flag1": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("flag1").Build())}, + initialized: true, + } + overlay := NewOverlay(base, NewLayer()) + items, err := overlay.GetAll(datakinds.Features) + require.NoError(t, err) + require.Len(t, items, 1) + + base.getAllErr = errors.New("sinkhole") + _, err = overlay.GetAll(datakinds.Features) + assert.Error(t, err) +} + +type sinkFixture struct { + base *fakeBaseStore + layer *Layer + sink *Sink + notified []string + listen bool +} + +func newSinkFixture(base *fakeBaseStore) *sinkFixture { + f := &sinkFixture{base: base, layer: NewLayer(), listen: true} + f.sink = NewSink(f.layer, base, + func(key string) { f.notified = append(f.notified, key) }, + func() bool { return f.listen }, + ldlog.NewDisabledLoggers()) + return f +} + +func (f *sinkFixture) takeNotified() []string { + result := f.notified + f.notified = nil + sort.Strings(result) + return result +} + +func TestSinkNotifiesOnAddChangeRemove(t *testing.T) { + base := &fakeBaseStore{ + flags: map[string]st.ItemDescriptor{ + "flag1": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("flag1").Version(1).Build()), + }, + initialized: true, + } + f := newSinkFixture(base) + + // Adding an override is a change even though flag1 also exists in base data. + f.sink.SetOverrides([]st.Collection{flagCollection( + ldbuilders.NewFlagBuilder("flag1").Version(1).Build(), + ldbuilders.NewFlagBuilder("flag2").Version(1).Build(), + )}) + assert.Equal(t, []string{"flag1", "flag2"}, f.takeNotified()) + + // An identical replacement (rebuilt from scratch, new pointers) changes nothing. + f.sink.SetOverrides([]st.Collection{flagCollection( + ldbuilders.NewFlagBuilder("flag1").Version(1).Build(), + ldbuilders.NewFlagBuilder("flag2").Version(1).Build(), + )}) + assert.Empty(t, f.takeNotified()) + + // Changing one entry notifies only that entry. + f.sink.SetOverrides([]st.Collection{flagCollection( + ldbuilders.NewFlagBuilder("flag1").Version(1).Build(), + ldbuilders.NewFlagBuilder("flag2").Version(2).Build(), + )}) + assert.Equal(t, []string{"flag2"}, f.takeNotified()) + + // Removing overrides notifies them: flag1 reverts to base data, flag2 to not-found. + f.sink.SetOverrides(nil) + assert.Equal(t, []string{"flag1", "flag2"}, f.takeNotified()) +} + +func TestSinkSegmentOverrideFansOutToDependentFlags(t *testing.T) { + flagWithSegment := ldbuilders.NewFlagBuilder("dependent").Version(1). + AddRule(ldbuilders.NewRuleBuilder().ID("r").Variation(0). + Clauses(ldbuilders.SegmentMatchClause("segment1"))). + Variations(ldvalue.Bool(true), ldvalue.Bool(false)). + Build() + base := &fakeBaseStore{ + flags: map[string]st.ItemDescriptor{ + "dependent": sharedtest.FlagDescriptor(flagWithSegment), + "unrelated": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("unrelated").Version(1).Build()), + }, + segments: map[string]st.ItemDescriptor{ + "segment1": sharedtest.SegmentDescriptor(ldbuilders.NewSegmentBuilder("segment1").Version(1).Build()), + }, + initialized: true, + } + f := newSinkFixture(base) + + f.sink.SetOverrides([]st.Collection{segmentCollection( + ldbuilders.NewSegmentBuilder("segment1").Version(99).Build(), + )}) + // The segment itself is not a flag, so only the dependent flag is notified. + assert.Equal(t, []string{"dependent"}, f.takeNotified()) +} + +func TestSinkPrerequisiteFanOutUsesOldAndNewViews(t *testing.T) { + // The override for "parent" declares a prerequisite on "prereq"; the base definition of + // "parent" has no prerequisites. When the override is removed, the dependency edge only + // exists in the old merged view, and "parent" must still be notified when "prereq" + // changes in the same replacement. + parentOverride := ldbuilders.NewFlagBuilder("parent").Version(1). + AddPrerequisite("prereq", 0).Build() + base := &fakeBaseStore{ + flags: map[string]st.ItemDescriptor{ + "parent": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("parent").Version(1).Build()), + "prereq": sharedtest.FlagDescriptor(ldbuilders.NewFlagBuilder("prereq").Version(1).Build()), + }, + initialized: true, + } + f := newSinkFixture(base) + + f.sink.SetOverrides([]st.Collection{flagCollection(parentOverride)}) + assert.Equal(t, []string{"parent"}, f.takeNotified()) + + // Replace the layer with an override of the prerequisite only: "parent"'s override is + // removed (a change) and "prereq" is added (a change); fan-out through the old view's + // edge also reaches "parent". + f.sink.SetOverrides([]st.Collection{flagCollection( + ldbuilders.NewFlagBuilder("prereq").Version(99).Build(), + )}) + assert.Equal(t, []string{"parent", "prereq"}, f.takeNotified()) + + // Now only "prereq" is overridden and nothing depends on it in the new view either. + f.sink.SetOverrides([]st.Collection{flagCollection( + ldbuilders.NewFlagBuilder("prereq").Version(100).Build(), + )}) + assert.Equal(t, []string{"prereq"}, f.takeNotified()) +} + +func TestSinkSkipsDiffWorkWithoutListeners(t *testing.T) { + base := &fakeBaseStore{getAllErr: errors.New("GetAll should not be called"), initialized: true} + f := newSinkFixture(base) + f.listen = false + + f.sink.SetOverrides([]st.Collection{flagCollection(ldbuilders.NewFlagBuilder("flag1").Build())}) + assert.True(t, f.layer.HasFlag("flag1")) + assert.Empty(t, f.notified) +} + +func TestSinkToleratesBaseReadFailure(t *testing.T) { + base := &fakeBaseStore{getAllErr: errors.New("sinkhole"), initialized: true} + f := newSinkFixture(base) + + // Fan-out degrades, but the directly changed flags are still notified. + f.sink.SetOverrides([]st.Collection{flagCollection(ldbuilders.NewFlagBuilder("flag1").Build())}) + assert.Equal(t, []string{"flag1"}, f.takeNotified()) +} diff --git a/internal/overrides/sink.go b/internal/overrides/sink.go new file mode 100644 index 00000000..93cb8a08 --- /dev/null +++ b/internal/overrides/sink.go @@ -0,0 +1,67 @@ +package overrides + +import ( + "sync" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-server-sdk/v7/subsystems" + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +// 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 { + mu sync.Mutex + layer *Layer + base subsystems.ReadOnlyStore + notify func(flagKey string) + hasListeners func() bool + loggers ldlog.Loggers +} + +// NewSink creates a Sink that writes to the given layer. base is the raw store holding +// LaunchDarkly data, without the overlay: merged-view snapshots for change computation are +// built from it plus the layer. The notify and hasListeners callbacks connect the sink to +// the owner's flag-change broadcaster without this package depending on it. +func NewSink( + layer *Layer, + base subsystems.ReadOnlyStore, + notify func(flagKey string), + hasListeners func() bool, + loggers ldlog.Loggers, +) *Sink { + return &Sink{ + layer: layer, + base: base, + notify: notify, + hasListeners: hasListeners, + loggers: loggers, + } +} + +// SetOverrides atomically replaces the entire override layer, then notifies listeners of +// every flag whose merged-view evaluation may have changed. Calls are serialized, so +// overlapping updates from a source cannot interleave. +func (s *Sink) SetOverrides(data []st.Collection) { + s.mu.Lock() + defer s.mu.Unlock() + + // Computing affected flags requires snapshots of the merged view before and after the + // replacement; skip all of that work when nothing is listening. + if !s.hasListeners() { + s.layer.SetAll(data) + return + } + + previous, current := s.layer.SetAll(data) + oldMerged := snapshotMergedView(s.base, previous) + newMerged := snapshotMergedView(s.base, current) + + affected := computeAffectedFlags(previous, current, oldMerged, newMerged) + if len(affected) > 0 { + s.loggers.Debugf("Override update affected %d flag(s)", len(affected)) + } + for _, key := range affected { + s.notify(key) + } +}