From ec154cdd1e3dc2e4ca09c73063081c7a4fd13d6f Mon Sep 17 00:00:00 2001 From: Casey Waldren Date: Tue, 24 Sep 2024 16:25:27 -0700 Subject: [PATCH] chore: introduce memorystorev2 (#193) This adds a `memorystorev2` package containing an alternative Memory Store implementation designed specifically for FDv2 usage. It is similar to the existing memory store, with obsolete methods removed and new methods added - but the core `upsert/init` logic remains the same. --- internal/memorystorev2/memory_store.go | 170 ++++++ .../memory_store_benchmark_test.go | 253 ++++++++ internal/memorystorev2/memory_store_test.go | 539 ++++++++++++++++++ 3 files changed, 962 insertions(+) create mode 100644 internal/memorystorev2/memory_store.go create mode 100644 internal/memorystorev2/memory_store_benchmark_test.go create mode 100644 internal/memorystorev2/memory_store_test.go diff --git a/internal/memorystorev2/memory_store.go b/internal/memorystorev2/memory_store.go new file mode 100644 index 00000000..25b432de --- /dev/null +++ b/internal/memorystorev2/memory_store.go @@ -0,0 +1,170 @@ +// Package memorystorev2 contains an implementation for a transactional memory store suitable +// for the FDv2 architecture. +package memorystorev2 + +import ( + "sync" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +// Store provides an abstraction that makes flag and segment data available to other components. +// It accepts updates in batches - for instance, flag A was upserted while segment B was deleted - +// such that the contents of the store are consistent with a single payload version at any given time. +// +// The terminology used is "basis" and "deltas". First, the store's basis is set. This is this initial +// data, upon which subsequent deltas will be applied. Whenever the basis is set, any existing data +// is discarded. +// +// Deltas are then applied to the store. A single delta update transforms the contents of the store +// atomically. +type Store struct { + data map[ldstoretypes.DataKind]map[string]ldstoretypes.ItemDescriptor + initialized bool + sync.RWMutex + loggers ldlog.Loggers +} + +// New creates a new Store. The Store is uninitialized until SetBasis is called. +func New(loggers ldlog.Loggers) *Store { + return &Store{ + data: make(map[ldstoretypes.DataKind]map[string]ldstoretypes.ItemDescriptor), + initialized: false, + loggers: loggers, + } +} + +// SetBasis sets the basis of the Store. Any existing data is discarded. +// When the basis is set, the store becomes initialized. +func (s *Store) SetBasis(allData []ldstoretypes.Collection) { + s.Lock() + defer s.Unlock() + + s.data = make(map[ldstoretypes.DataKind]map[string]ldstoretypes.ItemDescriptor) + + for _, coll := range allData { + items := make(map[string]ldstoretypes.ItemDescriptor) + for _, item := range coll.Items { + items[item.Key] = item.Item + } + s.data[coll.Kind] = items + } + + s.initialized = true +} + +// ApplyDelta applies a delta update to the store. ApplyDelta should not be called until +// SetBasis has been called at least once. The return value indicates, for each DataKind +// present in the delta, whether the item in the delta was actually updated or not. +// +// An item is updated only if the version of the item in the delta is greater than the version +// in the store, or it wasn't already present. +func (s *Store) ApplyDelta(allData []ldstoretypes.Collection) map[ldstoretypes.DataKind]map[string]bool { + updatedMap := make(map[ldstoretypes.DataKind]map[string]bool) + + s.Lock() + defer s.Unlock() + + for _, coll := range allData { + for _, item := range coll.Items { + updated := s.upsert(coll.Kind, item.Key, item.Item) + if updatedMap[coll.Kind] == nil { + updatedMap[coll.Kind] = make(map[string]bool) + } + updatedMap[coll.Kind][item.Key] = updated + } + } + + return updatedMap +} + +// Get retrieves an item of the specified kind from the store. If the item is not found, then +// ItemDescriptor{}.NotFound() is returned with a nil error. +func (s *Store) Get(kind ldstoretypes.DataKind, key string) (ldstoretypes.ItemDescriptor, error) { + s.RLock() + + var item ldstoretypes.ItemDescriptor + coll, ok := s.data[kind] + if ok { + item, ok = coll[key] + } + + s.RUnlock() + + if ok { + return item, nil + } + if s.loggers.IsDebugEnabled() { + s.loggers.Debugf(`Key %s not found in "%s"`, key, kind) + } + return ldstoretypes.ItemDescriptor{}.NotFound(), nil +} + +// GetAll retrieves all items of the specified kind from the store. +func (s *Store) GetAll(kind ldstoretypes.DataKind) ([]ldstoretypes.KeyedItemDescriptor, error) { + s.RLock() + defer s.RUnlock() + return s.getAll(kind), nil +} + +func (s *Store) getAll(kind ldstoretypes.DataKind) []ldstoretypes.KeyedItemDescriptor { + var itemsOut []ldstoretypes.KeyedItemDescriptor + if itemsMap, ok := s.data[kind]; ok { + if len(itemsMap) > 0 { + itemsOut = make([]ldstoretypes.KeyedItemDescriptor, 0, len(itemsMap)) + for key, item := range itemsMap { + itemsOut = append(itemsOut, ldstoretypes.KeyedItemDescriptor{Key: key, Item: item}) + } + } + } + return itemsOut +} + +// GetAllKinds retrieves all items of all kinds from the store. This is different from calling +// GetAll for each kind because it provides a consistent view of the entire store at a single point in time. +func (s *Store) GetAllKinds() []ldstoretypes.Collection { + s.RLock() + defer s.RUnlock() + + allData := make([]ldstoretypes.Collection, 0, len(s.data)) + for kind := range s.data { + itemsOut := s.getAll(kind) + allData = append(allData, ldstoretypes.Collection{Kind: kind, Items: itemsOut}) + } + + return allData +} + +func (s *Store) upsert( + kind ldstoretypes.DataKind, + key string, + newItem ldstoretypes.ItemDescriptor) bool { + var coll map[string]ldstoretypes.ItemDescriptor + var ok bool + shouldUpdate := true + updated := false + if coll, ok = s.data[kind]; ok { + if item, ok := coll[key]; ok { + if item.Version >= newItem.Version { + shouldUpdate = false + } + } + } else { + s.data[kind] = map[string]ldstoretypes.ItemDescriptor{key: newItem} + shouldUpdate = false // because we already initialized the map with the new item + updated = true + } + if shouldUpdate { + coll[key] = newItem + updated = true + } + return updated +} + +// IsInitialized returns true if the store has ever been initialized with a basis. +func (s *Store) IsInitialized() bool { + s.RLock() + defer s.RUnlock() + return s.initialized +} diff --git a/internal/memorystorev2/memory_store_benchmark_test.go b/internal/memorystorev2/memory_store_benchmark_test.go new file mode 100644 index 00000000..a1644bbc --- /dev/null +++ b/internal/memorystorev2/memory_store_benchmark_test.go @@ -0,0 +1,253 @@ +package memorystorev2 + +import ( + "fmt" + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "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" + "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" +) + +// These benchmarks cover data store operations with the in-memory store. +// +// There's no reason why the performance for flags should be different from segments, but to be truly +// implementation-neutral we'll benchmark each data kind separately anyway. + +var ( // assign to package-level variables in benchmarks so function calls won't be optimized away + inMemoryStoreBenchmarkResultItem ldstoretypes.ItemDescriptor + inMemoryStoreBenchmarkResultItems []ldstoretypes.KeyedItemDescriptor + updates map[ldstoretypes.DataKind]map[string]bool +) + +type inMemoryStoreBenchmarkEnv struct { + store *Store + flags []*ldmodel.FeatureFlag + segments []*ldmodel.Segment + targetFlagKey string + targetSegmentKey string + targetFlagCopy *ldmodel.FeatureFlag + targetSegmentCopy *ldmodel.Segment + unknownKey string + initData []ldstoretypes.Collection +} + +func newInMemoryStoreBenchmarkEnv() *inMemoryStoreBenchmarkEnv { + return &inMemoryStoreBenchmarkEnv{ + store: New(ldlog.NewDisabledLoggers()), + } +} + +func (env *inMemoryStoreBenchmarkEnv) setUp(bc inMemoryStoreBenchmarkCase) { + env.flags = make([]*ldmodel.FeatureFlag, bc.numFlags) + for i := 0; i < bc.numFlags; i++ { + flag := ldbuilders.NewFlagBuilder(fmt.Sprintf("flag-%d", i)).Version(10).Build() + env.flags[i] = &flag + } + + f := env.flags[bc.numFlags/2] // arbitrarily pick a flag in the middle of the list + env.targetFlagKey = f.Key + f1 := ldbuilders.NewFlagBuilder(f.Key).Version(f.Version).Build() + env.targetFlagCopy = &f1 + + env.segments = make([]*ldmodel.Segment, bc.numFlags) + for i := 0; i < bc.numSegments; i++ { + segment := ldbuilders.NewSegmentBuilder(fmt.Sprintf("segment-%d", i)).Version(10).Build() + env.segments[i] = &segment + } + + s := env.segments[bc.numSegments/2] + env.targetSegmentKey = s.Key + s1 := ldbuilders.NewSegmentBuilder(s.Key).Version(s.Version).Build() + env.targetSegmentCopy = &s1 + + env.unknownKey = "no-match" + + basis := []ldstoretypes.Collection{ + { + Kind: datakinds.Features, + Items: make([]ldstoretypes.KeyedItemDescriptor, len(env.flags)), + }, + { + Kind: datakinds.Segments, + Items: make([]ldstoretypes.KeyedItemDescriptor, len(env.segments)), + }, + } + + for i, f := range env.flags { + basis[0].Items[i] = ldstoretypes.KeyedItemDescriptor{Key: f.Key, Item: sharedtest.FlagDescriptor(*f)} + } + + for i, s := range env.segments { + basis[1].Items[i] = ldstoretypes.KeyedItemDescriptor{Key: s.Key, Item: sharedtest.SegmentDescriptor(*s)} + } + + env.store.SetBasis(basis) +} + +func setupInitData(env *inMemoryStoreBenchmarkEnv) { + flags := make([]ldstoretypes.KeyedItemDescriptor, len(env.flags)) + for i, f := range env.flags { + flags[i] = ldstoretypes.KeyedItemDescriptor{Key: f.Key, Item: sharedtest.FlagDescriptor(*f)} + } + segments := make([]ldstoretypes.KeyedItemDescriptor, len(env.segments)) + for i, s := range env.segments { + segments[i] = ldstoretypes.KeyedItemDescriptor{Key: s.Key, Item: sharedtest.SegmentDescriptor(*s)} + } + env.initData = []ldstoretypes.Collection{ + {Kind: datakinds.Features, Items: flags}, + {Kind: datakinds.Segments, Items: segments}, + } +} + +func (env *inMemoryStoreBenchmarkEnv) tearDown() { +} + +type inMemoryStoreBenchmarkCase struct { + numFlags int + numSegments int + withInitData bool +} + +var inMemoryStoreBenchmarkCases = []inMemoryStoreBenchmarkCase{ + { + numFlags: 1, + numSegments: 1, + }, + { + numFlags: 100, + numSegments: 100, + }, + { + numFlags: 1000, + numSegments: 1000, + }, +} + +func benchmarkInMemoryStore( + b *testing.B, + cases []inMemoryStoreBenchmarkCase, + setupAction func(*inMemoryStoreBenchmarkEnv), + benchmarkAction func(*inMemoryStoreBenchmarkEnv, inMemoryStoreBenchmarkCase), +) { + env := newInMemoryStoreBenchmarkEnv() + for _, bc := range cases { + env.setUp(bc) + + if setupAction != nil { + setupAction(env) + } + + b.Run(fmt.Sprintf("%+v", bc), func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchmarkAction(env, bc) + } + }) + env.tearDown() + } +} + +func BenchmarkInMemoryStoreInit(b *testing.B) { + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, setupInitData, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + env.store.SetBasis(env.initData) + }) +} + +func BenchmarkInMemoryStoreGetFlag(b *testing.B) { + dataKind := datakinds.Features + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + inMemoryStoreBenchmarkResultItem, _ = env.store.Get(dataKind, env.targetFlagKey) + }) +} + +func BenchmarkInMemoryStoreGetSegment(b *testing.B) { + dataKind := datakinds.Segments + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + inMemoryStoreBenchmarkResultItem, _ = env.store.Get(dataKind, env.targetSegmentKey) + }) +} + +func BenchmarkInMemoryStoreGetUnknownFlag(b *testing.B) { + dataKind := datakinds.Features + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + inMemoryStoreBenchmarkResultItem, _ = env.store.Get(dataKind, env.unknownKey) + }) +} + +func BenchmarkInMemoryStoreGetUnknownSegment(b *testing.B) { + dataKind := datakinds.Segments + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + inMemoryStoreBenchmarkResultItem, _ = env.store.Get(dataKind, env.unknownKey) + }) +} + +func BenchmarkInMemoryStoreGetAllFlags(b *testing.B) { + dataKind := datakinds.Features + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + inMemoryStoreBenchmarkResultItems, _ = env.store.GetAll(dataKind) + }) +} + +func BenchmarkInMemoryStoreGetAllSegments(b *testing.B) { + dataKind := datakinds.Segments + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + inMemoryStoreBenchmarkResultItems, _ = env.store.GetAll(dataKind) + }) +} + +func BenchmarkInMemoryStoreUpsertExistingFlagSuccess(b *testing.B) { + dataKind := datakinds.Features + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + env.targetFlagCopy.Version++ + delta := makeCollections(dataKind, env.targetFlagKey, sharedtest.FlagDescriptor(*env.targetFlagCopy)) + updates = env.store.ApplyDelta(delta) + }) +} + +func BenchmarkInMemoryStoreUpsertExistingFlagFailure(b *testing.B) { + dataKind := datakinds.Features + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + env.targetFlagCopy.Version-- + delta := makeCollections(dataKind, env.targetFlagKey, sharedtest.FlagDescriptor(*env.targetFlagCopy)) + updates = env.store.ApplyDelta(delta) + }) +} + +func BenchmarkInMemoryStoreUpsertNewFlag(b *testing.B) { + dataKind := datakinds.Features + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + env.targetFlagCopy.Key = env.unknownKey + delta := makeCollections(dataKind, env.unknownKey, sharedtest.FlagDescriptor(*env.targetFlagCopy)) + updates = env.store.ApplyDelta(delta) + }) +} + +func BenchmarkInMemoryStoreUpsertExistingSegmentSuccess(b *testing.B) { + dataKind := datakinds.Segments + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + env.targetSegmentCopy.Version++ + delta := makeCollections(dataKind, env.targetSegmentKey, sharedtest.SegmentDescriptor(*env.targetSegmentCopy)) + updates = env.store.ApplyDelta(delta) + }) +} + +func BenchmarkInMemoryStoreUpsertExistingSegmentFailure(b *testing.B) { + dataKind := datakinds.Segments + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + env.targetSegmentCopy.Version-- + delta := makeCollections(dataKind, env.targetSegmentKey, sharedtest.SegmentDescriptor(*env.targetSegmentCopy)) + updates = env.store.ApplyDelta(delta) + }) +} + +func BenchmarkInMemoryStoreUpsertNewSegment(b *testing.B) { + dataKind := datakinds.Segments + benchmarkInMemoryStore(b, inMemoryStoreBenchmarkCases, nil, func(env *inMemoryStoreBenchmarkEnv, bc inMemoryStoreBenchmarkCase) { + env.targetSegmentCopy.Key = env.unknownKey + delta := makeCollections(dataKind, env.unknownKey, sharedtest.SegmentDescriptor(*env.targetSegmentCopy)) + updates = env.store.ApplyDelta(delta) + }) +} diff --git a/internal/memorystorev2/memory_store_test.go b/internal/memorystorev2/memory_store_test.go new file mode 100644 index 00000000..b47f0ffd --- /dev/null +++ b/internal/memorystorev2/memory_store_test.go @@ -0,0 +1,539 @@ +package memorystorev2 + +import ( + "errors" + "fmt" + "sort" + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders" + "github.com/launchdarkly/go-server-sdk/v7/internal/datakinds" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInMemoryDataStore(t *testing.T) { + t.Run("Get", testGet) + t.Run("GetAll", testGetAll) + t.Run("GetAllKinds", testGetAllKinds) + t.Run("SetBasis", testSetBasis) + t.Run("ApplyDelta", testApplyDelta) +} + +func makeMemoryStore() *Store { + return New(sharedtest.NewTestLoggers()) +} + +// Used to create a segment/flag. Returns the individual item, and a collection slice +// containing only that item. +type collectionItemCreator func(key string, version int, otherProperty bool) (ldstoretypes.ItemDescriptor, []ldstoretypes.Collection) + +// Used to delete a segment/flag. Returns the individual item, and a collection slice +// containing only that item. +type collectionItemDeleter func(key string, version int) (ldstoretypes.ItemDescriptor, []ldstoretypes.Collection) + +func makeCollections(kind ldstoretypes.DataKind, key string, item ldstoretypes.ItemDescriptor) []ldstoretypes.Collection { + return []ldstoretypes.Collection{ + makeCollection(kind, key, item), + } +} + +func makeCollection(kind ldstoretypes.DataKind, key string, item ldstoretypes.ItemDescriptor) ldstoretypes.Collection { + return ldstoretypes.Collection{ + Kind: kind, + Items: []ldstoretypes.KeyedItemDescriptor{ + { + Key: key, + Item: item, + }, + }, + } +} + +func forAllDataKinds(t *testing.T, test func(*testing.T, ldstoretypes.DataKind, collectionItemCreator, collectionItemDeleter)) { + test(t, datakinds.Features, func(key string, version int, otherProperty bool) (ldstoretypes.ItemDescriptor, []ldstoretypes.Collection) { + flag := ldbuilders.NewFlagBuilder(key).Version(version).On(otherProperty).Build() + descriptor := sharedtest.FlagDescriptor(flag) + + return descriptor, makeCollections(datakinds.Features, flag.Key, descriptor) + }, func(key string, version int) (ldstoretypes.ItemDescriptor, []ldstoretypes.Collection) { + descriptor := ldstoretypes.ItemDescriptor{Version: version, Item: nil} + + return descriptor, makeCollections(datakinds.Features, key, descriptor) + }) + test(t, datakinds.Segments, func(key string, version int, otherProperty bool) (ldstoretypes.ItemDescriptor, []ldstoretypes.Collection) { + segment := ldbuilders.NewSegmentBuilder(key).Version(version).Build() + if otherProperty { + segment.Included = []string{"arbitrary value"} + } + descriptor := sharedtest.SegmentDescriptor(segment) + + return descriptor, makeCollections(datakinds.Segments, segment.Key, descriptor) + }, func(key string, version int) (ldstoretypes.ItemDescriptor, []ldstoretypes.Collection) { + descriptor := ldstoretypes.ItemDescriptor{Version: version, Item: nil} + + return descriptor, makeCollections(datakinds.Segments, key, descriptor) + }) +} + +func testSetBasis(t *testing.T) { + t.Run("makes store initialized", func(t *testing.T) { + store := makeMemoryStore() + allData := sharedtest.NewDataSetBuilder().Flags(ldbuilders.NewFlagBuilder("key").Build()).Build() + + store.SetBasis(allData) + + assert.True(t, store.IsInitialized()) + }) + + t.Run("completely replaces previous data", func(t *testing.T) { + store := makeMemoryStore() + flag1 := ldbuilders.NewFlagBuilder("key1").Build() + segment1 := ldbuilders.NewSegmentBuilder("key1").Build() + allData1 := sharedtest.NewDataSetBuilder().Flags(flag1).Segments(segment1).Build() + + store.SetBasis(allData1) + + flags, err := store.GetAll(datakinds.Features) + require.NoError(t, err) + segments, err := store.GetAll(datakinds.Segments) + require.NoError(t, err) + sort.Slice(flags, func(i, j int) bool { return flags[i].Key < flags[j].Key }) + assert.Equal(t, extractCollections(allData1), [][]ldstoretypes.KeyedItemDescriptor{flags, segments}) + + flag2 := ldbuilders.NewFlagBuilder("key2").Build() + segment2 := ldbuilders.NewSegmentBuilder("key2").Build() + allData2 := sharedtest.NewDataSetBuilder().Flags(flag2).Segments(segment2).Build() + + store.SetBasis(allData2) + + flags, err = store.GetAll(datakinds.Features) + require.NoError(t, err) + segments, err = store.GetAll(datakinds.Segments) + require.NoError(t, err) + assert.Equal(t, extractCollections(allData2), [][]ldstoretypes.KeyedItemDescriptor{flags, segments}) + }) +} + +func testGet(t *testing.T) { + const unknownKey = "unknown-key" + + forAllDataKinds(t, func(t *testing.T, kind ldstoretypes.DataKind, makeItem collectionItemCreator, _ collectionItemDeleter) { + t.Run("found", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + item, collection := makeItem("key", 1, false) + store.ApplyDelta(collection) + + result, err := store.Get(kind, "key") + assert.NoError(t, err) + assert.Equal(t, item, result) + }) + + t.Run("not found", func(t *testing.T) { + mockLog := ldlogtest.NewMockLog() + mockLog.Loggers.SetMinLevel(ldlog.Info) + store := New(mockLog.Loggers) + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + result, err := store.Get(kind, unknownKey) + assert.NoError(t, err) + assert.Equal(t, ldstoretypes.ItemDescriptor{}.NotFound(), result) + + assert.Len(t, mockLog.GetAllOutput(), 0) + }) + + t.Run("not found - debug logging", func(t *testing.T) { + mockLog := ldlogtest.NewMockLog() + mockLog.Loggers.SetMinLevel(ldlog.Debug) + store := New(mockLog.Loggers) + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + result, err := store.Get(kind, unknownKey) + assert.NoError(t, err) + assert.Equal(t, ldstoretypes.ItemDescriptor{}.NotFound(), result) + + assert.Len(t, mockLog.GetAllOutput(), 1) + assert.Equal(t, + ldlogtest.MockLogItem{ + Level: ldlog.Debug, + Message: fmt.Sprintf(`Key %s not found in "%s"`, unknownKey, kind.GetName()), + }, + mockLog.GetAllOutput()[0], + ) + }) + }) +} + +func testGetAll(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + result, err := store.GetAll(datakinds.Features) + require.NoError(t, err) + assert.Len(t, result, 0) + + flag1 := ldbuilders.NewFlagBuilder("flag1").Build() + flag2 := ldbuilders.NewFlagBuilder("flag2").Build() + segment1 := ldbuilders.NewSegmentBuilder("segment1").Build() + + collection := []ldstoretypes.Collection{ + { + Kind: datakinds.Features, + Items: []ldstoretypes.KeyedItemDescriptor{ + { + Key: flag1.Key, + Item: sharedtest.FlagDescriptor(flag1), + }, + { + Key: flag2.Key, + Item: sharedtest.FlagDescriptor(flag2), + }, + }, + }, + { + Kind: datakinds.Segments, + Items: []ldstoretypes.KeyedItemDescriptor{ + { + Key: segment1.Key, + Item: sharedtest.SegmentDescriptor(segment1), + }, + }, + }, + } + + store.ApplyDelta(collection) + + flags, err := store.GetAll(datakinds.Features) + require.NoError(t, err) + segments, err := store.GetAll(datakinds.Segments) + require.NoError(t, err) + + sort.Slice(flags, func(i, j int) bool { return flags[i].Key < flags[j].Key }) + expected := extractCollections(sharedtest.NewDataSetBuilder().Flags(flag1, flag2).Segments(segment1).Build()) + assert.Equal(t, expected, [][]ldstoretypes.KeyedItemDescriptor{flags, segments}) + + result, err = store.GetAll(unknownDataKind{}) + require.NoError(t, err) + assert.Len(t, result, 0) +} + +func extractCollections(allData []ldstoretypes.Collection) [][]ldstoretypes.KeyedItemDescriptor { + var ret [][]ldstoretypes.KeyedItemDescriptor + for _, coll := range allData { + ret = append(ret, coll.Items) + } + return ret +} + +type unknownDataKind struct{} + +func (k unknownDataKind) GetName() string { + return "unknown" +} + +func (k unknownDataKind) Serialize(item ldstoretypes.ItemDescriptor) []byte { + return nil +} + +func (k unknownDataKind) Deserialize(data []byte) (ldstoretypes.ItemDescriptor, error) { + return ldstoretypes.ItemDescriptor{}, errors.New("not implemented") +} + +func testApplyDelta(t *testing.T) { + forAllDataKinds(t, func(t *testing.T, kind ldstoretypes.DataKind, makeItem collectionItemCreator, deleteItem collectionItemDeleter) { + t.Run("upserts", func(t *testing.T) { + t.Run("newer version", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + _, collection1 := makeItem("key", 10, false) + + updates := store.ApplyDelta(collection1) + assert.True(t, updates[kind]["key"]) + + item1a, collection1a := makeItem("key", 11, true) + + updates = store.ApplyDelta(collection1a) + assert.True(t, updates[kind]["key"]) + + result, err := store.Get(kind, "key") + require.NoError(t, err) + assert.Equal(t, item1a, result) + + }) + + t.Run("older version", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + item1Version := 10 + item1, collection1 := makeItem("key", item1Version, false) + + updates := store.ApplyDelta(collection1) + assert.True(t, updates[kind]["key"]) + + _, collection1a := makeItem("key", item1Version-1, true) + + updates = store.ApplyDelta(collection1a) + assert.False(t, updates[kind]["key"]) + + result, err := store.Get(kind, "key") + require.NoError(t, err) + assert.Equal(t, item1, result) + }) + + t.Run("same version", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + item1Version := 10 + item1, collection1 := makeItem("key", item1Version, false) + updated := store.ApplyDelta(collection1) + assert.True(t, updated[kind]["key"]) + + _, collection1a := makeItem("key", item1Version, true) + updated = store.ApplyDelta(collection1a) + assert.False(t, updated[kind]["key"]) + + result, err := store.Get(kind, "key") + require.NoError(t, err) + assert.Equal(t, item1, result) + }) + }) + + t.Run("deletes", func(t *testing.T) { + t.Run("newer version", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + item1, collection1 := makeItem("key", 10, false) + updated := store.ApplyDelta(collection1) + assert.True(t, updated[kind]["key"]) + + item1a, collection1a := deleteItem("key", item1.Version+1) + updated = store.ApplyDelta(collection1a) + assert.True(t, updated[kind]["key"]) + + result, err := store.Get(kind, "key") + require.NoError(t, err) + assert.Equal(t, item1a, result) + }) + + t.Run("older version", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + item1, collection1 := makeItem("key", 10, false) + updated := store.ApplyDelta(collection1) + assert.True(t, updated[kind]["key"]) + + _, collection1a := deleteItem("key", item1.Version-1) + updated = store.ApplyDelta(collection1a) + assert.False(t, updated[kind]["key"]) + + result, err := store.Get(kind, "key") + require.NoError(t, err) + assert.Equal(t, item1, result) + }) + + t.Run("same version", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + item1, collection1 := makeItem("key", 10, false) + updated := store.ApplyDelta(collection1) + assert.True(t, updated[kind]["key"]) + + _, collection1a := deleteItem("key", item1.Version) + updated = store.ApplyDelta(collection1a) + assert.False(t, updated[kind]["key"]) + + result, err := store.Get(kind, "key") + require.NoError(t, err) + assert.Equal(t, item1, result) + }) + }) + }) +} + +func testGetAllKinds(t *testing.T) { + t.Run("uninitialized store", func(t *testing.T) { + store := makeMemoryStore() + collections := store.GetAllKinds() + assert.Empty(t, collections) + }) + + t.Run("initialized but empty store", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + collections := store.GetAllKinds() + assert.Len(t, collections, 2) + assert.Empty(t, collections[0].Items) + assert.Empty(t, collections[1].Items) + }) + + t.Run("initialized store with data of a single kind", func(t *testing.T) { + forAllDataKinds(t, func(t *testing.T, kind ldstoretypes.DataKind, makeItem collectionItemCreator, _ collectionItemDeleter) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + item1, collection1 := makeItem("key1", 1, false) + + store.ApplyDelta(collection1) + + collections := store.GetAllKinds() + + assert.Len(t, collections, 2) + + for _, coll := range collections { + if coll.Kind == kind { + assert.Len(t, coll.Items, 1) + assert.Equal(t, item1, coll.Items[0].Item) + } else { + assert.Empty(t, coll.Items) + } + } + }) + }) + + t.Run("initialized store with data of multiple kinds", func(t *testing.T) { + store := makeMemoryStore() + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + flag1 := ldbuilders.NewFlagBuilder("flag1").Build() + segment1 := ldbuilders.NewSegmentBuilder("segment1").Build() + + expectedCollection := []ldstoretypes.Collection{ + makeCollection(datakinds.Features, flag1.Key, sharedtest.FlagDescriptor(flag1)), + makeCollection(datakinds.Segments, segment1.Key, sharedtest.SegmentDescriptor(segment1)), + } + + store.ApplyDelta(expectedCollection) + + gotCollections := store.GetAllKinds() + + requireCollectionsMatch(t, expectedCollection, gotCollections) + }) + + t.Run("multiple deltas applies", func(t *testing.T) { + forAllDataKinds(t, func(t *testing.T, kind ldstoretypes.DataKind, makeItem collectionItemCreator, deleteItem collectionItemDeleter) { + store := makeMemoryStore() + + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + _, collection1 := makeItem("key1", 1, false) + store.ApplyDelta(collection1) + + // The collection slice we get from GetAllKinds is going to contain the specific segment or flag + // collection we're creating here in the test, but also an empty collection for the other kind. + expected := []ldstoretypes.Collection{collection1[0]} + if kind == datakinds.Features { + expected = append(expected, ldstoretypes.Collection{Kind: datakinds.Segments, Items: nil}) + } else { + expected = append(expected, ldstoretypes.Collection{Kind: datakinds.Features, Items: nil}) + } + + requireCollectionsMatch(t, expected, store.GetAllKinds()) + + _, collection1a := makeItem("key1", 2, false) + store.ApplyDelta(collection1a) + expected[0] = collection1a[0] + requireCollectionsMatch(t, expected, store.GetAllKinds()) + + _, collection1b := deleteItem("key1", 3) + store.ApplyDelta(collection1b) + expected[0] = collection1b[0] + requireCollectionsMatch(t, expected, store.GetAllKinds()) + }) + }) + + t.Run("deltas containing multiple item kinds", func(t *testing.T) { + + store := makeMemoryStore() + + store.SetBasis(sharedtest.NewDataSetBuilder().Build()) + + // Flag1 will be deleted. + flag1 := ldbuilders.NewFlagBuilder("flag1").Build() + + // Flag2 is a control and won't be changed. + flag2 := ldbuilders.NewFlagBuilder("flag2").Build() + + // Segment1 will be upserted. + segment1 := ldbuilders.NewSegmentBuilder("segment1").Build() + + collection1 := []ldstoretypes.Collection{ + { + Kind: datakinds.Features, + Items: []ldstoretypes.KeyedItemDescriptor{ + { + Key: flag1.Key, + Item: sharedtest.FlagDescriptor(flag1), + }, + { + Key: flag2.Key, + Item: sharedtest.FlagDescriptor(flag2), + }, + }, + }, + makeCollection(datakinds.Segments, segment1.Key, sharedtest.SegmentDescriptor(segment1)), + } + + store.ApplyDelta(collection1) + + requireCollectionsMatch(t, collection1, store.GetAllKinds()) + + // Bumping the segment version is sufficient for an upsert. + // To indicate that there's no change to flag2, we simply don't pass it in the collection. + segment1.Version += 1 + collection2 := []ldstoretypes.Collection{ + // Delete flag1 + makeCollection(datakinds.Features, flag1.Key, ldstoretypes.ItemDescriptor{Version: flag1.Version + 1, Item: nil}), + // Upsert segment1 + makeCollection(datakinds.Segments, segment1.Key, sharedtest.SegmentDescriptor(segment1)), + } + + store.ApplyDelta(collection2) + + expected := []ldstoretypes.Collection{ + { + Kind: datakinds.Features, + Items: []ldstoretypes.KeyedItemDescriptor{ + { + Key: flag1.Key, + Item: ldstoretypes.ItemDescriptor{Version: flag1.Version + 1, Item: nil}, + }, + { + Key: flag2.Key, + Item: sharedtest.FlagDescriptor(flag2), + }, + }, + }, + makeCollection(datakinds.Segments, segment1.Key, sharedtest.SegmentDescriptor(segment1)), + } + + requireCollectionsMatch(t, expected, store.GetAllKinds()) + }) +} + +// This matcher is required instead of calling ElementsMatch directly on two slices of collections because +// the order of the collections, or the order within each collection, is not defined. +func requireCollectionsMatch(t *testing.T, expected []ldstoretypes.Collection, actual []ldstoretypes.Collection) { + require.Equal(t, len(expected), len(actual)) + for _, expectedCollection := range expected { + for _, actualCollection := range actual { + if expectedCollection.Kind == actualCollection.Kind { + require.ElementsMatch(t, expectedCollection.Items, actualCollection.Items) + break + } + } + } +}