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());
+ }
}