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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
114 changes: 114 additions & 0 deletions internal/overrides/diff.go
Original file line number Diff line number Diff line change
@@ -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
}
108 changes: 108 additions & 0 deletions internal/overrides/layer.go
Original file line number Diff line number Diff line change
@@ -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
}
69 changes: 69 additions & 0 deletions internal/overrides/overlay.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading