From 6d6f30a609784cb23034f82cac37343c186a7f3c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:02:19 -0700 Subject: [PATCH 1/2] refactor(internal): consolidate FDv2 dependency tracker into internal/toposort --- .../datasystem/data_model_dependencies.go | 67 ------------------ internal/datasystem/store.go | 14 ++-- internal/toposort/dependency_tracker.go | 69 +++++++++++++++++++ .../dependency_tracker_test.go} | 40 +++++++---- 4 files changed, 102 insertions(+), 88 deletions(-) delete mode 100644 internal/datasystem/data_model_dependencies.go create mode 100644 internal/toposort/dependency_tracker.go rename internal/{datasystem/data_model_dependencies_test.go => toposort/dependency_tracker_test.go} (91%) diff --git a/internal/datasystem/data_model_dependencies.go b/internal/datasystem/data_model_dependencies.go deleted file mode 100644 index 4b286f1b..00000000 --- a/internal/datasystem/data_model_dependencies.go +++ /dev/null @@ -1,67 +0,0 @@ -package datasystem - -import ( - "github.com/launchdarkly/go-server-sdk/v7/internal/toposort" - st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" -) - -// Maintains a bidirectional dependency graph that can be updated whenever an item has changed. -// -// This type is duplicated from internal.datasource. This duplication will be -// removed when FDv1 is removed. -type dependencyTracker struct { - dependenciesFrom toposort.AdjacencyList - dependenciesTo toposort.AdjacencyList -} - -func newDependencyTracker() *dependencyTracker { - return &dependencyTracker{ - make(toposort.AdjacencyList), - make(toposort.AdjacencyList), - } -} - -// Updates the dependency graph when an item has changed. -func (d *dependencyTracker) updateDependenciesFrom( - kind st.DataKind, - fromKey string, - fromItem st.ItemDescriptor, -) { - fromWhat := toposort.NewVertex(kind, fromKey) - updatedDependencies := toposort.GetNeighbors(kind, fromItem) - - oldDependencySet := d.dependenciesFrom[fromWhat] - for oldDep := range oldDependencySet { - depsToThisOldDep := d.dependenciesTo[oldDep] - if depsToThisOldDep != nil { - delete(depsToThisOldDep, fromWhat) - } - } - - d.dependenciesFrom[fromWhat] = updatedDependencies - for newDep := range updatedDependencies { - depsToThisNewDep := d.dependenciesTo[newDep] - if depsToThisNewDep == nil { - depsToThisNewDep = make(toposort.Neighbors) - d.dependenciesTo[newDep] = depsToThisNewDep - } - depsToThisNewDep.Add(fromWhat) - } -} - -func (d *dependencyTracker) reset() { - d.dependenciesFrom = make(toposort.AdjacencyList) - d.dependenciesTo = make(toposort.AdjacencyList) -} - -// Populates the given set with the union of the initial item and all items that directly or indirectly -// depend on it (based on the current state of the dependency graph). -func (d *dependencyTracker) addAffectedItems(itemsOut toposort.Neighbors, initialModifiedItem toposort.Vertex) { - if !itemsOut.Contains(initialModifiedItem) { - itemsOut.Add(initialModifiedItem) - affectedItems := d.dependenciesTo[initialModifiedItem] - for affectedItem := range affectedItems { - d.addAffectedItems(itemsOut, affectedItem) - } - } -} diff --git a/internal/datasystem/store.go b/internal/datasystem/store.go index 15101f65..6d2c9a0a 100644 --- a/internal/datasystem/store.go +++ b/internal/datasystem/store.go @@ -58,7 +58,7 @@ type Store struct { // Used to track dependencies between items in the store. This helps ensure // we trigger the correct flag change notifications. - dependencyTracker *dependencyTracker + dependencyTracker *toposort.DependencyTracker // Broadcaster for flag change events. flagChangeEvent *internal.Broadcaster[interfaces.FlagChangeEvent] @@ -128,7 +128,7 @@ func NewStore( s := &Store{ persistentStore: nil, memoryStore: memorystorev2.New(loggers), - dependencyTracker: newDependencyTracker(), + dependencyTracker: toposort.NewDependencyTracker(), flagChangeEvent: flagChangeEvent, changeSetBroadcaster: changeSet, loggers: loggers, @@ -270,9 +270,9 @@ func (s *Store) applyDelta(collections []ldstoretypes.Collection, selector subsy affectedItems := make(toposort.Neighbors) for _, collection := range collections { for _, item := range collection.Items { - s.dependencyTracker.updateDependenciesFrom(collection.Kind, item.Key, item.Item) + s.dependencyTracker.UpdateDependenciesFrom(collection.Kind, item.Key, item.Item) if hasListeners { - s.dependencyTracker.addAffectedItems(affectedItems, toposort.NewVertex(collection.Kind, item.Key)) + s.dependencyTracker.AddAffectedItems(affectedItems, toposort.NewVertex(collection.Kind, item.Key)) } } } @@ -377,10 +377,10 @@ func (s *Store) IsInitialized() bool { // internal.datasource. This duplication will be removed when FDv1 support is // removed. func (s *Store) updateDependencyTrackerFromFullDataSet(allData []ldstoretypes.Collection) { - s.dependencyTracker.reset() + s.dependencyTracker.Reset() for _, coll := range allData { for _, item := range coll.Items { - s.dependencyTracker.updateDependenciesFrom(coll.Kind, item.Key, item.Item) + s.dependencyTracker.UpdateDependenciesFrom(coll.Kind, item.Key, item.Item) } } } @@ -421,7 +421,7 @@ func (s *Store) computeChangedItemsForFullDataSet( if haveOld || haveNew { if !haveOld || !haveNew || oldItem.Version < newItem.Version { - s.dependencyTracker.addAffectedItems(affectedItems, toposort.NewVertex(kind, key)) + s.dependencyTracker.AddAffectedItems(affectedItems, toposort.NewVertex(kind, key)) } } } diff --git a/internal/toposort/dependency_tracker.go b/internal/toposort/dependency_tracker.go new file mode 100644 index 00000000..0f33af68 --- /dev/null +++ b/internal/toposort/dependency_tracker.go @@ -0,0 +1,69 @@ +package toposort + +import ( + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +// DependencyTracker maintains a bidirectional dependency graph that can be updated whenever an +// item has changed. +// +// A nearly identical unexported implementation exists in internal/datasource for the FDv1 code +// path; that copy will go away when FDv1 is removed. +type DependencyTracker struct { + dependenciesFrom AdjacencyList + dependenciesTo AdjacencyList +} + +// NewDependencyTracker creates a DependencyTracker with an empty dependency graph. +func NewDependencyTracker() *DependencyTracker { + return &DependencyTracker{ + make(AdjacencyList), + make(AdjacencyList), + } +} + +// UpdateDependenciesFrom updates the dependency graph when an item has changed. +func (d *DependencyTracker) UpdateDependenciesFrom( + kind st.DataKind, + fromKey string, + fromItem st.ItemDescriptor, +) { + fromWhat := NewVertex(kind, fromKey) + updatedDependencies := GetNeighbors(kind, fromItem) + + oldDependencySet := d.dependenciesFrom[fromWhat] + for oldDep := range oldDependencySet { + depsToThisOldDep := d.dependenciesTo[oldDep] + if depsToThisOldDep != nil { + delete(depsToThisOldDep, fromWhat) + } + } + + d.dependenciesFrom[fromWhat] = updatedDependencies + for newDep := range updatedDependencies { + depsToThisNewDep := d.dependenciesTo[newDep] + if depsToThisNewDep == nil { + depsToThisNewDep = make(Neighbors) + d.dependenciesTo[newDep] = depsToThisNewDep + } + depsToThisNewDep.Add(fromWhat) + } +} + +// Reset clears the dependency graph. +func (d *DependencyTracker) Reset() { + d.dependenciesFrom = make(AdjacencyList) + d.dependenciesTo = make(AdjacencyList) +} + +// AddAffectedItems populates the given set with the union of the initial item and all items that +// directly or indirectly depend on it (based on the current state of the dependency graph). +func (d *DependencyTracker) AddAffectedItems(itemsOut Neighbors, initialModifiedItem Vertex) { + if !itemsOut.Contains(initialModifiedItem) { + itemsOut.Add(initialModifiedItem) + affectedItems := d.dependenciesTo[initialModifiedItem] + for affectedItem := range affectedItems { + d.AddAffectedItems(itemsOut, affectedItem) + } + } +} diff --git a/internal/datasystem/data_model_dependencies_test.go b/internal/toposort/dependency_tracker_test.go similarity index 91% rename from internal/datasystem/data_model_dependencies_test.go rename to internal/toposort/dependency_tracker_test.go index f3a05d8d..28192971 100644 --- a/internal/datasystem/data_model_dependencies_test.go +++ b/internal/toposort/dependency_tracker_test.go @@ -1,4 +1,4 @@ -package datasystem +package toposort_test import ( "strings" @@ -145,14 +145,14 @@ func TestSortCollectionsLeavesItemsOfUnknownDataKindUnchanged(t *testing.T) { } func TestDependencyTrackerReturnsSingleValueResultForUnknownItem(t *testing.T) { - dt := newDependencyTracker() + dt := toposort.NewDependencyTracker() // a change to any item with no known depenencies affects only itself verifyDependencyAffectedItems(t, dt, datakinds.Features, "flag1", toposort.NewVertex(datakinds.Features, "flag1")) } func TestDependencyTrackerBuildsGraph(t *testing.T) { - dt := newDependencyTracker() + dt := toposort.NewDependencyTracker() segment3 := ldbuilders.NewSegmentBuilder("segment3").Build() segment2 := ldbuilders.NewSegmentBuilder("segment2"). @@ -182,10 +182,10 @@ func TestDependencyTrackerBuildsGraph(t *testing.T) { Build() for _, s := range []ldmodel.Segment{segment1, segment2, segment3} { - dt.updateDependenciesFrom(datakinds.Segments, s.Key, sharedtest.SegmentDescriptor(s)) + dt.UpdateDependenciesFrom(datakinds.Segments, s.Key, sharedtest.SegmentDescriptor(s)) } for _, f := range []ldmodel.FeatureFlag{flag1, flag2} { - dt.updateDependenciesFrom(datakinds.Features, f.Key, sharedtest.FlagDescriptor(f)) + dt.UpdateDependenciesFrom(datakinds.Features, f.Key, sharedtest.FlagDescriptor(f)) } // a change to flag1 affects only flag1 @@ -228,17 +228,17 @@ func TestDependencyTrackerBuildsGraph(t *testing.T) { } func TestDependencyTrackerUpdatesGraph(t *testing.T) { - dt := newDependencyTracker() + dt := toposort.NewDependencyTracker() flag1 := ldbuilders.NewFlagBuilder("flag1"). AddPrerequisite("flag3", 0). Build() - dt.updateDependenciesFrom(datakinds.Features, flag1.Key, st.ItemDescriptor{Version: flag1.Version, Item: &flag1}) + dt.UpdateDependenciesFrom(datakinds.Features, flag1.Key, st.ItemDescriptor{Version: flag1.Version, Item: &flag1}) flag2 := ldbuilders.NewFlagBuilder("flag2"). AddPrerequisite("flag3", 0). Build() - dt.updateDependenciesFrom(datakinds.Features, flag2.Key, st.ItemDescriptor{Version: flag2.Version, Item: &flag2}) + dt.UpdateDependenciesFrom(datakinds.Features, flag2.Key, st.ItemDescriptor{Version: flag2.Version, Item: &flag2}) // at this point, a change to flag3 affects flag3, flag2, and flag1 verifyDependencyAffectedItems(t, dt, datakinds.Features, "flag3", @@ -251,7 +251,7 @@ func TestDependencyTrackerUpdatesGraph(t *testing.T) { flag1v2 := ldbuilders.NewFlagBuilder("flag1"). AddPrerequisite("flag4", 0). Build() - dt.updateDependenciesFrom(datakinds.Features, flag1.Key, st.ItemDescriptor{Version: flag1v2.Version, Item: &flag1v2}) + dt.UpdateDependenciesFrom(datakinds.Features, flag1.Key, st.ItemDescriptor{Version: flag1v2.Version, Item: &flag1v2}) // now, a change to flag3 affects flag3 and flag2 verifyDependencyAffectedItems(t, dt, datakinds.Features, "flag3", @@ -267,19 +267,19 @@ func TestDependencyTrackerUpdatesGraph(t *testing.T) { } func TestDependencyTrackerResetsGraph(t *testing.T) { - dt := newDependencyTracker() + dt := toposort.NewDependencyTracker() flag1 := ldbuilders.NewFlagBuilder("flag1"). AddPrerequisite("flag3", 0). Build() - dt.updateDependenciesFrom(datakinds.Features, flag1.Key, st.ItemDescriptor{Version: flag1.Version, Item: &flag1}) + dt.UpdateDependenciesFrom(datakinds.Features, flag1.Key, st.ItemDescriptor{Version: flag1.Version, Item: &flag1}) verifyDependencyAffectedItems(t, dt, datakinds.Features, "flag3", toposort.NewVertex(datakinds.Features, "flag3"), toposort.NewVertex(datakinds.Features, "flag1"), ) - dt.reset() + dt.Reset() verifyDependencyAffectedItems(t, dt, datakinds.Features, "flag3", toposort.NewVertex(datakinds.Features, "flag3"), @@ -288,7 +288,7 @@ func TestDependencyTrackerResetsGraph(t *testing.T) { func verifyDependencyAffectedItems( t *testing.T, - dt *dependencyTracker, + dt *toposort.DependencyTracker, kind st.DataKind, key string, expected ...toposort.Vertex, @@ -298,7 +298,7 @@ func verifyDependencyAffectedItems( expectedSet.Add(value) } result := make(toposort.Neighbors) - dt.addAffectedItems(result, toposort.NewVertex(kind, key)) + dt.AddAffectedItems(result, toposort.NewVertex(kind, key)) assert.Equal(t, expectedSet, result) } @@ -355,3 +355,15 @@ func verifySortedData(t *testing.T, sortedData []st.Collection, inputData []st.C } } } + +func fullDataSetToMap(allData []st.Collection) map[st.DataKind]map[string]st.ItemDescriptor { + ret := make(map[st.DataKind]map[string]st.ItemDescriptor, len(allData)) + for _, coll := range allData { + m := make(map[string]st.ItemDescriptor, len(coll.Items)) + for _, item := range coll.Items { + m[item.Key] = item.Item + } + ret[coll.Kind] = m + } + return ret +} From af30219fb47ec5def9f2f1b0d809d81c11822d2b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:33:29 -0700 Subject: [PATCH 2/2] feat(internal): add flag/segment override layer, overlay store, and change notification diff --- go.mod | 2 +- go.sum | 2 + internal/overrides/diff.go | 114 +++++++++ internal/overrides/layer.go | 108 +++++++++ internal/overrides/overlay.go | 69 ++++++ internal/overrides/overrides_test.go | 349 +++++++++++++++++++++++++++ internal/overrides/sink.go | 67 +++++ 7 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 internal/overrides/diff.go create mode 100644 internal/overrides/layer.go create mode 100644 internal/overrides/overlay.go create mode 100644 internal/overrides/overrides_test.go create mode 100644 internal/overrides/sink.go 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) + } +}