From e6c135eb7fd27282addec012ffb16eda2bc8fc99 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 13 Jan 2026 16:37:26 -0500 Subject: [PATCH 1/2] fix: making Selector make public --- .../launchdarkly/sdk/internal/fdv2/sources/Selector.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java index 79f83a34..5d13844c 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java @@ -45,7 +45,14 @@ static Selector empty() { return new Selector(0, null, true); } - static Selector make(int version, String state) { + /** + * Creates a new Selector with the given version and state. + * + * @param version the version number + * @param state the state identifier + * @return a new Selector instance + */ + public static Selector make(int version, String state) { return new Selector(version, state, false); } From 00975941055bfc9faf385a5c6bf57a35e00b06b3 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 13 Jan 2026 16:43:56 -0500 Subject: [PATCH 2/2] chore: adds transactional memory store --- lib/sdk/server/build.gradle | 2 +- .../sdk/server/CacheExporter.java | 31 + .../sdk/server/InMemoryDataStore.java | 130 +++- .../sdk/server/subsystems/DataStoreTypes.java | 114 +++ .../subsystems/TransactionalDataStore.java | 37 + .../sdk/server/InMemoryDataStoreTest.java | 699 +++++++++++++++++- 6 files changed, 1002 insertions(+), 11 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/CacheExporter.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/TransactionalDataStore.java diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index a6ce7237..a27e9c71 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -71,7 +71,7 @@ ext.versions = [ "guava": "32.0.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "2.1.2", - "launchdarklyJavaSdkInternal": "1.5.1", + "launchdarklyJavaSdkInternal": "1.6.1", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "4.1.0", diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/CacheExporter.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/CacheExporter.java new file mode 100644 index 00000000..fb2de087 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/CacheExporter.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; + +/** + * Optional interface for data stores that can export their entire contents. + *

+ * This interface is used to enable recovery scenarios where a persistent store + * needs to be re-synchronized from an in-memory cache. Not all data stores need + * to implement this interface. + *

+ * This is currently only for internal implementations. + */ +interface CacheExporter { + /** + * Exports all data from the cache across all known DataKinds. + * + * @return A FullDataSet containing all items in the cache. The data is a snapshot + * taken at the time of the call and may be stale immediately after return. + */ + FullDataSet exportAll(); + + /** + * Indicates if the cache has been populated with a full data set. + * + * @return true when the cache has been populated + */ + boolean isInitialized(); +} + diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java index 47530843..5c4c0de1 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -2,16 +2,22 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.subsystems.DataStore; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.TransactionalDataStore; import java.io.IOException; +import java.util.AbstractMap; import java.util.HashMap; import java.util.Map; +import java.util.Set; /** * A thread-safe, versioned store for feature flags and related data based on a @@ -20,21 +26,16 @@ * As of version 5.0.0, this is package-private; applications must use the factory method * {@link Components#inMemoryDataStore()}. */ -class InMemoryDataStore implements DataStore { +class InMemoryDataStore implements DataStore, TransactionalDataStore, CacheExporter { private volatile ImmutableMap> allData = ImmutableMap.of(); private volatile boolean initialized = false; private Object writeLock = new Object(); + private final Object selectorLock = new Object(); + private volatile Selector selector = Selector.EMPTY; @Override public void init(FullDataSet allData) { - synchronized (writeLock) { - ImmutableMap.Builder> newData = ImmutableMap.builder(); - for (Map.Entry> entry: allData.getData()) { - newData.put(entry.getKey(), ImmutableMap.copyOf(entry.getValue().getItems())); - } - this.allData = newData.build(); // replaces the entire map atomically - this.initialized = true; - } + applyFullPayload(allData.getData(), null, Selector.EMPTY); } @Override @@ -118,4 +119,115 @@ public CacheStats getCacheStats() { public void close() throws IOException { return; } + + @Override + public void apply(ChangeSet changeSet) { + switch (changeSet.getType()) { + case Full: + applyFullPayload(changeSet.getData(), changeSet.getEnvironmentId(), changeSet.getSelector()); + break; + case Partial: + applyPartialData(changeSet.getData(), changeSet.getSelector()); + break; + case None: + break; + default: + // This represents an implementation error. The ChangeSetType was extended, but handling was not + // added. + throw new IllegalArgumentException("Unknown ChangeSetType: " + changeSet.getType()); + } + } + + @Override + public Selector getSelector() { + synchronized (selectorLock) { + return selector; + } + } + + private void setSelector(Selector newSelector) { + synchronized (selectorLock) { + selector = newSelector; + } + } + + private void applyPartialData(Iterable>> data, + Selector selector) { + synchronized (writeLock) { + // Build the complete updated dictionary before assigning to Items for transactional update + ImmutableMap.Builder> itemsBuilder = ImmutableMap.builder(); + + // First, collect all kinds that will be updated + java.util.Set updatedKinds = new java.util.HashSet<>(); + for (Map.Entry> kindItemsPair : data) { + updatedKinds.add(kindItemsPair.getKey()); + } + + // Add all existing kinds that are NOT being updated + for (Map.Entry> existingEntry : allData.entrySet()) { + if (!updatedKinds.contains(existingEntry.getKey())) { + itemsBuilder.put(existingEntry.getKey(), existingEntry.getValue()); + } + } + + // Now process the updated kinds + for (Map.Entry> kindItemsPair : data) { + DataKind kind = kindItemsPair.getKey(); + // Use HashMap to allow overwriting, then convert to ImmutableMap + Map kindMap = new HashMap<>(); + + Map itemsOfKind = allData.get(kind); + if (itemsOfKind != null) { + kindMap.putAll(itemsOfKind); + } + + // Overwrite/add items from the change set (HashMap.put overwrites existing keys) + for (Map.Entry keyValuePair : kindItemsPair.getValue().getItems()) { + kindMap.put(keyValuePair.getKey(), keyValuePair.getValue()); + } + + itemsBuilder.put(kind, ImmutableMap.copyOf(kindMap)); + } + + allData = itemsBuilder.build(); + setSelector(selector); + } + } + + private void applyFullPayload(Iterable>> data, + String environmentId, Selector selector) { + ImmutableMap.Builder> itemsBuilder = ImmutableMap.builder(); + + for (Map.Entry> kindEntry : data) { + ImmutableMap.Builder kindItemsBuilder = ImmutableMap.builder(); + for (Map.Entry e1 : kindEntry.getValue().getItems()) { + kindItemsBuilder.put(e1.getKey(), e1.getValue()); + } + itemsBuilder.put(kindEntry.getKey(), kindItemsBuilder.build()); + } + + ImmutableMap> newItems = itemsBuilder.build(); + + synchronized (writeLock) { + allData = newItems; + initialized = true; + setSelector(selector); + } + } + + @Override + public FullDataSet exportAll() { + synchronized (writeLock) { + ImmutableList.Builder>> builder = ImmutableList.builder(); + + for (Map.Entry> kindEntry : allData.entrySet()) { + builder.add(new AbstractMap.SimpleEntry<>( + kindEntry.getKey(), + new KeyedItems<>(ImmutableList.copyOf(kindEntry.getValue().entrySet())) + )); + } + + return new FullDataSet<>(builder.build()); + } + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStoreTypes.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStoreTypes.java index 64677b50..561f0e3e 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStoreTypes.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStoreTypes.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.subsystems; import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import java.util.Map; import java.util.Objects; @@ -328,4 +329,117 @@ public int hashCode() { return items.hashCode(); } } + + /** + * Enumeration that indicates if this change is a full or partial change. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode + *

+ */ + public enum ChangeSetType { + /** + * Represents a full store configuration which replaces all data currently in the store. + */ + Full, + + /** + * Represents an incremental set of changes to be applied to the existing data in the store. + */ + Partial, + + /** + * Indicates that there are no store changes. + */ + None + } + + /** + * Represents a set of changes to apply to a store. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode + *

+ * + * @param will be {@link ItemDescriptor} or {@link SerializedItemDescriptor} + */ + public static final class ChangeSet { + private final ChangeSetType type; + private final Selector selector; + private final String environmentId; + private final Iterable>> data; + + /** + * Returns the type of the changeset. + * + * @return the changeset type + */ + public ChangeSetType getType() { + return type; + } + + /** + * Returns the selector for this change. This selector will not be null, but it can be an empty selector. + * + * @return the selector + */ + public Selector getSelector() { + return selector; + } + + /** + * Returns the environment ID associated with the change. This may not always be available, and when it is not, + * the value will be null. + * + * @return the environment ID, or null if not available + */ + public String getEnvironmentId() { + return environmentId; + } + + /** + * Returns a list of changes. + * + * @return an enumeration of key-value pairs; may be empty, but will not be null + */ + public Iterable>> getData() { + return data; + } + + /** + * Constructs a new ChangeSet instance. + * + * @param type the type of the changeset + * @param selector the selector for this change + * @param data the list of changes + * @param environmentId the environment ID, or null if not available + */ + public ChangeSet(ChangeSetType type, Selector selector, + Iterable>> data, String environmentId) { + this.type = type; + this.selector = selector; + this.data = data == null ? ImmutableList.of() : data; + this.environmentId = environmentId; + } + + @Override + public boolean equals(Object o) { + if (o instanceof ChangeSet) { + ChangeSet other = (ChangeSet)o; + return type == other.type && Objects.equals(selector, other.selector) && + Objects.equals(environmentId, other.environmentId) && Objects.equals(data, other.data); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(type, selector, environmentId, data); + } + + @Override + public String toString() { + return "ChangeSet(" + type + "," + selector + "," + environmentId + "," + data + ")"; + } + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/TransactionalDataStore.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/TransactionalDataStore.java new file mode 100644 index 00000000..5b84c3ea --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/TransactionalDataStore.java @@ -0,0 +1,37 @@ +package com.launchdarkly.sdk.server.subsystems; + +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; + +/** + * Interface for a data store that holds feature flags and related data received by the SDK. + * This interface supports updating the store transactionally using ChangeSets. + *

+ * Ordinarily, the only implementation of this interface is the default in-memory + * implementation, which holds references to actual SDK data model objects. Any data store + * implementation that uses an external store, such as a database, should instead use + * {@link PersistentDataStore}. + *

+ * Implementations must be thread-safe. + *

+ * This interface is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode + * + * @see PersistentDataStore + */ +public interface TransactionalDataStore { + /** + * Apply the given change set to the store. This should be done atomically if possible. + * + * @param changeSet the changeset to apply + */ + void apply(ChangeSet changeSet); + + /** + * Returns the selector for the currently stored data. The selector will be non-null but may be empty. + * + * @return the selector for the currently stored data + */ + Selector getSelector(); +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java index e507dfb6..19892954 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java @@ -1,21 +1,718 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.server.subsystems.DataStore; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import org.junit.Test; +import java.util.AbstractMap; +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class InMemoryDataStoreTest extends DataStoreTestBase { + private static final TestItem item1 = new TestItem("key1", "item1", 10); + private static final TestItem item2 = new TestItem("key2", "item2", 11); + private static final String item1Key = "key1"; + private static final int item1Version = 10; + private static final String item2Key = "key2"; + private static final int item2Version = 11; + @Override protected DataStore makeStore() { return new InMemoryDataStore(); } - + + private InMemoryDataStore typedStore() { + return (InMemoryDataStore)store; + } + + private void initStore() { + com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet allData = + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1, item2) + .build(); + store.init(allData); + } + @Test public void cacheStatsAreNull() { assertNull(makeStore().getCacheStats()); } + + // Apply method tests + + @Test + public void applyWithFullChangeSetReplacesAllData() { + // Initialize store with some data + initStore(); + + // Create a full changeset with different data + TestItem item3 = new TestItem("key3", "item3", 20); + String item3Key = "key3"; + int item3Version = 20; + + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>(item3Key, new ItemDescriptor(item3Version, item3)) + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.make(1, "state1"), + changeSetData, + "test-env" + ); + + typedStore().apply(changeSet); + + // Old items should be gone + assertNull(store.get(TEST_ITEMS, item1Key)); + assertNull(store.get(TEST_ITEMS, item2Key)); + + // New item should exist + ItemDescriptor result = store.get(TEST_ITEMS, item3Key); + assertNotNull(result); + assertEquals(item3Version, result.getVersion()); + assertEquals(item3, result.getItem()); + } + + @Test + public void applyWithFullChangeSetSetsSelector() { + Selector selector = Selector.make(42, "test-state"); + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + selector, + ImmutableList.of(), + null + ); + + typedStore().apply(changeSet); + + assertEquals(selector.getVersion(), typedStore().getSelector().getVersion()); + assertEquals(selector.getState(), typedStore().getSelector().getState()); + } + + @Test + public void applyWithFullChangeSetMarksStoreAsInitialized() { + assertFalse(store.isInitialized()); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.make(1, "state1"), + ImmutableList.of(), + null + ); + + typedStore().apply(changeSet); + + assertTrue(store.isInitialized()); + } + + @Test + public void applyWithPartialChangeSetAddsNewItems() { + initStore(); + + TestItem item3 = new TestItem("key3", "item3", 20); + String item3Key = "key3"; + int item3Version = 20; + + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>(item3Key, new ItemDescriptor(item3Version, item3)) + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + // Old items should still exist + assertNotNull(store.get(TEST_ITEMS, item1Key)); + assertNotNull(store.get(TEST_ITEMS, item2Key)); + + // New item should exist + ItemDescriptor result = store.get(TEST_ITEMS, item3Key); + assertNotNull(result); + assertEquals(item3Version, result.getVersion()); + assertEquals(item3, result.getItem()); + } + + @Test + public void applyWithPartialChangeSetCanReplaceItems() { + initStore(); + + // Partial updates replace the entire data kind with the provided items + TestItem item1Updated = new TestItem(item1Key, "item1-updated", item1Version + 10); + int item1NewVersion = item1Version + 10; + + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>(item1Key, new ItemDescriptor(item1NewVersion, item1Updated)), + new AbstractMap.SimpleEntry<>(item2Key, new ItemDescriptor(item2Version, item2)) // Must include all items in kind + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + // Item should be updated + ItemDescriptor result = store.get(TEST_ITEMS, item1Key); + assertNotNull(result); + assertEquals(item1NewVersion, result.getVersion()); + assertEquals(item1Updated, result.getItem()); + + // The other item should still exist + ItemDescriptor result2 = store.get(TEST_ITEMS, item2Key); + assertNotNull(result2); + assertEquals(item2Version, result2.getVersion()); + assertEquals(item2, result2.getItem()); + } + + @Test + public void applyWithPartialChangeSetCanDeleteItems() { + initStore(); + + // When applying partial changeset, include deleted item and keep other items + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>(item1Key, ItemDescriptor.deletedItem(item1Version + 10)), + new AbstractMap.SimpleEntry<>(item2Key, new ItemDescriptor(item2Version, item2)) // Must include all items in kind + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + // Item should be marked as deleted + ItemDescriptor result = store.get(TEST_ITEMS, item1Key); + assertNotNull(result); + assertNull(result.getItem()); + assertEquals(item1Version + 10, result.getVersion()); + + // The other item should still exist + ItemDescriptor result2 = store.get(TEST_ITEMS, item2Key); + assertNotNull(result2); + assertEquals(item2Version, result2.getVersion()); + assertEquals(item2, result2.getItem()); + } + + @Test + public void applyWithPartialChangeSetUpdatesSelector() { + initStore(); + Selector initialSelector = typedStore().getSelector(); + + Selector newSelector = Selector.make(99, "new-state"); + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + newSelector, + ImmutableList.of(), + null + ); + + typedStore().apply(changeSet); + + assertTrue(initialSelector.getVersion() != typedStore().getSelector().getVersion()); + assertEquals(newSelector.getVersion(), typedStore().getSelector().getVersion()); + assertEquals(newSelector.getState(), typedStore().getSelector().getState()); + } + + @Test + public void applyWithNoneChangeSetDoesNotModifyData() { + initStore(); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.None, + Selector.make(5, "state5"), + ImmutableList.of(), + null + ); + + typedStore().apply(changeSet); + + // Data should remain unchanged + ItemDescriptor result1 = store.get(TEST_ITEMS, item1Key); + assertNotNull(result1); + assertEquals(item1Version, result1.getVersion()); + assertEquals(item1, result1.getItem()); + + ItemDescriptor result2 = store.get(TEST_ITEMS, item2Key); + assertNotNull(result2); + assertEquals(item2Version, result2.getVersion()); + assertEquals(item2, result2.getItem()); + } + + @Test + public void applyWithFullChangeSetHandlesMultipleDataKinds() { + TestItem updatedItem1 = new TestItem("key1", "item1", 1); + TestItem updatedItem2 = new TestItem("key2", "item2", 2); + + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>("key1", new ItemDescriptor(1, updatedItem1)) + ) + ) + ), + new AbstractMap.SimpleEntry<>( + OTHER_TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>("key2", new ItemDescriptor(2, updatedItem2)) + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.make(1, "state1"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + // Both kinds should have data + ItemDescriptor result1 = store.get(TEST_ITEMS, "key1"); + assertNotNull(result1); + assertEquals(updatedItem1, result1.getItem()); + + ItemDescriptor result2 = store.get(OTHER_TEST_ITEMS, "key2"); + assertNotNull(result2); + assertEquals(updatedItem2, result2.getItem()); + } + + @Test + public void applyWithPartialChangeSetHandlesMultipleDataKinds() { + initStore(); + + TestItem item3 = new TestItem("key3", "item3", 30); + TestItem item4 = new TestItem("key4", "item4", 40); + + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>("key3", new ItemDescriptor(30, item3)) + ) + ) + ), + new AbstractMap.SimpleEntry<>( + OTHER_TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>("key4", new ItemDescriptor(40, item4)) + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + // Original TestDataKind items should still exist + assertNotNull(store.get(TEST_ITEMS, item1Key)); + assertNotNull(store.get(TEST_ITEMS, item2Key)); + + // New items should exist + ItemDescriptor result3 = store.get(TEST_ITEMS, "key3"); + assertNotNull(result3); + assertEquals(item3, result3.getItem()); + + ItemDescriptor result4 = store.get(OTHER_TEST_ITEMS, "key4"); + assertNotNull(result4); + assertEquals(item4, result4.getItem()); + } + + @Test + public void applyWithPartialChangeSetPreservesUnaffectedDataKinds() { + // Initialize with both TEST_ITEMS and OTHER_TEST_ITEMS + com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet allData = + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1, item2) + .add(OTHER_TEST_ITEMS, new TestItem("other1", "other1", 100)) + .build(); + store.init(allData); + + // Apply partial changeset that only updates TEST_ITEMS + TestItem item3 = new TestItem("key3", "item3", 30); + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>("key3", new ItemDescriptor(30, item3)) + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + // TEST_ITEMS should have original items plus new item + assertNotNull(store.get(TEST_ITEMS, item1Key)); + assertNotNull(store.get(TEST_ITEMS, item2Key)); + assertNotNull(store.get(TEST_ITEMS, "key3")); + + // OTHER_TEST_ITEMS should still exist and be unchanged + ItemDescriptor otherResult = store.get(OTHER_TEST_ITEMS, "other1"); + assertNotNull(otherResult); + assertEquals(new TestItem("other1", "other1", 100), otherResult.getItem()); + assertEquals(100, otherResult.getVersion()); + } + + @Test + public void applyWithFullChangeSetEmptyDataClearsStore() { + initStore(); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.make(1, "state1"), + ImmutableList.of(), + null + ); + + typedStore().apply(changeSet); + + // All items should be gone + assertNull(store.get(TEST_ITEMS, item1Key)); + assertNull(store.get(TEST_ITEMS, item2Key)); + + // But store should be initialized + assertTrue(store.isInitialized()); + } + + @Test + public void applyWithPartialChangeSetOnUninitializedStore() { + assertFalse(store.isInitialized()); + + TestItem item3 = new TestItem("key3", "item3", 30); + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>("key3", new ItemDescriptor(30, item3)) + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(1, "state1"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + // Item should be added + ItemDescriptor result = store.get(TEST_ITEMS, "key3"); + assertNotNull(result); + assertEquals(item3, result.getItem()); + + // Store should still not be marked as initialized (partial updates don't initialize) + assertFalse(store.isInitialized()); + } + + @Test + public void applyWithMultipleItemsInSameKind() { + TestItem localItem1 = new TestItem("key1", "item1", 10); + TestItem localItem2 = new TestItem("key2", "item2", 20); + TestItem item3 = new TestItem("key3", "item3", 30); + + ImmutableList>> changeSetData = + ImmutableList.of( + new AbstractMap.SimpleEntry<>( + TEST_ITEMS, + new com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems<>( + ImmutableList.of( + new AbstractMap.SimpleEntry<>("key1", new ItemDescriptor(10, localItem1)), + new AbstractMap.SimpleEntry<>("key2", new ItemDescriptor(20, localItem2)), + new AbstractMap.SimpleEntry<>("key3", new ItemDescriptor(30, item3)) + ) + ) + ) + ); + + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.make(1, "state1"), + changeSetData, + null + ); + + typedStore().apply(changeSet); + + Map allItems = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(3, allItems.size()); + } + + // ExportAllData tests + + @Test + public void exportAllDataReturnsCompleteSnapshot() { + TestItem item1 = new TestItem("key1", "item1", 1); + TestItem item2 = new TestItem("key2", "item2", 2); + TestItem item3 = new TestItem("key3", "item3", 3); + + store.init(new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1, item2) + .add(OTHER_TEST_ITEMS, item3) + .build()); + + FullDataSet exported = typedStore().exportAll(); + + // Should have both data kinds + int count = 0; + for (@SuppressWarnings("unused") Map.Entry> entry : exported.getData()) { + count++; + } + assertEquals(2, count); + + // Verify TEST_ITEMS data + Map testKindData = null; + Map otherKindData = null; + for (Map.Entry> entry : exported.getData()) { + if (entry.getKey() == TEST_ITEMS) { + testKindData = toItemsMap(entry.getValue()); + } else if (entry.getKey() == OTHER_TEST_ITEMS) { + otherKindData = toItemsMap(entry.getValue()); + } + } + assertNotNull(testKindData); + assertNotNull(otherKindData); + assertEquals(2, testKindData.size()); + assertEquals(item1, testKindData.get("key1").getItem()); + assertEquals(1, testKindData.get("key1").getVersion()); + assertEquals(item2, testKindData.get("key2").getItem()); + assertEquals(2, testKindData.get("key2").getVersion()); + + // Verify OTHER_TEST_ITEMS data + assertEquals(1, otherKindData.size()); + assertEquals(item3, otherKindData.get("key3").getItem()); + assertEquals(3, otherKindData.get("key3").getVersion()); + } + + @Test + public void exportAllDataWithEmptyStoreReturnsEmptyDataSet() { + FullDataSet exported = typedStore().exportAll(); + + int count = 0; + for (@SuppressWarnings("unused") Map.Entry> entry : exported.getData()) { + count++; + } + assertEquals(0, count); + } + + @Test + public void exportAllDataWithDeletedItemsIncludesDeletedItems() { + TestItem item1 = new TestItem("key1", "item1", 1); + + store.init(new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build()); + + // Delete an item + store.upsert(TEST_ITEMS, "key2", ItemDescriptor.deletedItem(2)); + + FullDataSet exported = typedStore().exportAll(); + + Map testKindData = null; + for (Map.Entry> entry : exported.getData()) { + if (entry.getKey() == TEST_ITEMS) { + testKindData = toItemsMap(entry.getValue()); + } + } + assertNotNull(testKindData); + assertEquals(2, testKindData.size()); + + // Regular item + ItemDescriptor regularItem = testKindData.get("key1"); + assertEquals(item1, regularItem.getItem()); + assertEquals(1, regularItem.getVersion()); + + // Deleted item + ItemDescriptor deletedItem = testKindData.get("key2"); + assertNull(deletedItem.getItem()); + assertEquals(2, deletedItem.getVersion()); + } + + @Test + public void exportAllDataIsThreadSafe() throws Exception { + // Initialize with some data + TestItem item1 = new TestItem("key1", "item1", 1); + store.init(new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build()); + + // Start export in background thread + java.util.concurrent.CountDownLatch exportStarted = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch continueExport = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.Future> exportTask = + java.util.concurrent.Executors.newSingleThreadExecutor().submit(() -> { + exportStarted.countDown(); + try { + continueExport.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return typedStore().exportAll(); + }); + + // Wait for export to start + exportStarted.await(); + + // Try to perform concurrent upsert (should block until export completes) + TestItem item2 = new TestItem("key2", "item2", 2); + java.util.concurrent.Future upsertTask = + java.util.concurrent.Executors.newSingleThreadExecutor().submit(() -> { + store.upsert(TEST_ITEMS, "key2", new ItemDescriptor(2, item2)); + }); + + // Let export continue + continueExport.countDown(); + + // Wait for both operations to complete + FullDataSet exported = exportTask.get(); + upsertTask.get(); + + // Export should have completed successfully + // Either key2 is in the export (upsert happened before export) or not (upsert happened after) + // Both are valid outcomes as long as no exception was thrown + Map testKindData = null; + for (Map.Entry> entry : exported.getData()) { + if (entry.getKey() == TEST_ITEMS) { + testKindData = toItemsMap(entry.getValue()); + } + } + if (testKindData != null && !testKindData.isEmpty()) { + assertTrue(testKindData.containsKey("key1")); + // key2 may or may not be present depending on timing + } + + // Verify store now has both items + assertEquals(item1, store.get(TEST_ITEMS, "key1").getItem()); + assertEquals(item2, store.get(TEST_ITEMS, "key2").getItem()); + } + + @Test + public void exportAllDataReturnsImmutableSnapshot() { + TestItem item1 = new TestItem("key1", "item1", 1); + store.init(new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build()); + + FullDataSet exported = typedStore().exportAll(); + + // Modify store after export + TestItem item2 = new TestItem("key2", "item2", 2); + store.upsert(TEST_ITEMS, "key2", new ItemDescriptor(2, item2)); + + // Exported data should not be affected + Map testKindData = null; + for (Map.Entry> entry : exported.getData()) { + if (entry.getKey() == TEST_ITEMS) { + testKindData = toItemsMap(entry.getValue()); + } + } + assertNotNull(testKindData); + assertEquals(1, testKindData.size()); + assertEquals("key1", testKindData.keySet().iterator().next()); + } }