From 4fa930a01929d9cadce1afd09a2aa91203b4d0ba Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 27 Jan 2026 10:22:58 -0500 Subject: [PATCH 1/2] chore: adds conditional persistence propagation --- .../sdk/server/DataModelDependencies.java | 4 +- .../sdk/server/DataModelSerialization.java | 7 +- .../sdk/server/DataSourceUpdatesImpl.java | 4 +- .../sdk/server/DefaultFeatureRequestor.java | 3 +- .../sdk/server/FDv2ChangeSetTranslator.java | 7 +- .../sdk/server/InMemoryDataStore.java | 50 ++-- .../server/PersistentDataStoreConverter.java | 3 +- .../server/PersistentDataStoreWrapper.java | 11 +- .../launchdarkly/sdk/server/PollingBase.java | 6 +- .../sdk/server/StreamProcessorEvents.java | 3 +- .../sdk/server/StreamingSynchronizerImpl.java | 2 +- .../sdk/server/WriteThroughStore.java | 11 +- .../integrations/FileDataSourceImpl.java | 3 +- .../sdk/server/integrations/TestData.java | 30 +- .../sdk/server/subsystems/DataStoreTypes.java | 51 +++- .../sdk/server/DataModelDependenciesTest.java | 18 +- .../server/DataModelSerializationTest.java | 8 +- .../sdk/server/DataSourceUpdatesImplTest.java | 18 +- .../sdk/server/DataStoreTestTypes.java | 7 +- .../server/FDv2ChangeSetTranslatorTest.java | 30 +- .../sdk/server/InMemoryDataStoreTest.java | 264 +++++++++++++++++- .../PersistentDataStoreConverterTest.java | 2 +- ...ersistentDataStoreWrapperRecoveryTest.java | 82 +++++- .../sdk/server/TestComponents.java | 2 +- .../sdk/server/WriteThroughStoreTest.java | 113 +++++++- .../server/interfaces/DataStoreTypesTest.java | 6 +- 26 files changed, 627 insertions(+), 118 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java index 10453d7e..a85588b4 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java @@ -131,7 +131,7 @@ public static FullDataSet sortAllCollections(FullDataSet(builder.build().entrySet()); + return new FullDataSet<>(builder.build().entrySet(), allData.shouldPersist()); } /** @@ -148,7 +148,7 @@ public static ChangeSet sortChangeset(ChangeSet DataKind kind = entry.getKey(); builder.put(kind, sortCollection(kind, entry.getValue())); } - return new ChangeSet<>(inSet.getType(), inSet.getSelector(), builder.build().entrySet(), inSet.getEnvironmentId()); + return new ChangeSet<>(inSet.getType(), inSet.getSelector(), builder.build().entrySet(), inSet.getEnvironmentId(), inSet.shouldPersist()); } private static KeyedItems sortCollection(DataKind kind, KeyedItems input) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java index 387e0ee6..b203069a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java @@ -20,7 +20,6 @@ import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; 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.SerializationException; @@ -106,7 +105,7 @@ static VersionedData deserializeFromJsonReader(DataKind kind, JsonReader jr) thr * @param jr the JSON reader * @return the deserialized data */ - static FullDataSet parseFullDataSet(JsonReader jr) throws SerializationException { + static Iterable>> parseFullDataSet(JsonReader jr) throws SerializationException { ImmutableList.Builder> flags = ImmutableList.builder(); ImmutableList.Builder> segments = ImmutableList.builder(); @@ -141,10 +140,10 @@ static FullDataSet parseFullDataSet(JsonReader jr) throws Serial } jr.endObject(); - return new FullDataSet(ImmutableMap.of( + return ImmutableMap.of( FEATURES, new KeyedItems<>(flags.build()), SEGMENTS, new KeyedItems<>(segments.build()) - ).entrySet()); + ).entrySet(); } catch (IOException e) { throw new SerializationException(e); } catch (RuntimeException e) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 17a4f98f..051d987e 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -428,8 +428,8 @@ private boolean applyToLegacyStore(ChangeSet sortedChangeSet) { } private boolean applyFullChangeSetToLegacyStore(ChangeSet unsortedChangeset) { - // Convert ChangeSet to FullDataSet for legacy init path - return init(new FullDataSet<>(unsortedChangeset.getData())); + // Convert ChangeSet to FullDataSet for legacy init path, preserving shouldPersist flag + return init(new FullDataSet<>(unsortedChangeset.getData(), unsortedChangeset.shouldPersist())); } private boolean applyPartialChangeSetToLegacyStore(ChangeSet changeSet) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index aad77b10..8ea41890 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -110,7 +110,8 @@ public FullDataSet getAllData(boolean returnDataEvenIfCached) } JsonReader jr = new JsonReader(response.body().charStream()); - return parseFullDataSet(jr); + // Polling data from LaunchDarkly should be persisted + return new FullDataSet<>(parseFullDataSet(jr), true); } } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java index e3ff5395..ba884775 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java @@ -30,13 +30,15 @@ private FDv2ChangeSetTranslator() { * @param changeset the FDv2 changeset to convert * @param logger logger for diagnostic messages * @param environmentId the environment ID to include in the changeset (may be null) + * @param shouldPersist true if the data should be persisted to persistent stores, false otherwise * @return a DataStoreTypes.ChangeSet containing the converted data * @throws IllegalArgumentException if the changeset type is unknown */ public static DataStoreTypes.ChangeSet toChangeSet( FDv2ChangeSet changeset, LDLogger logger, - String environmentId) { + String environmentId, + boolean shouldPersist) { ChangeSetType changeSetType; switch (changeset.getType()) { case FULL: @@ -103,7 +105,8 @@ public static DataStoreTypes.ChangeSet toChangeSet( changeSetType, changeset.getSelector(), dataBuilder.build(), - environmentId); + environmentId, + shouldPersist); } /** 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 5c4c0de1..4e7448f8 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 @@ -32,15 +32,16 @@ class InMemoryDataStore implements DataStore, TransactionalDataStore, CacheExpor private Object writeLock = new Object(); private final Object selectorLock = new Object(); private volatile Selector selector = Selector.EMPTY; + private volatile boolean shouldPersist = false; @Override public void init(FullDataSet allData) { - applyFullPayload(allData.getData(), null, Selector.EMPTY); + applyFullPayload(allData.getData(), null, Selector.EMPTY, allData.shouldPersist()); } @Override public ItemDescriptor get(DataKind kind, String key) { - Map items = allData.get(kind); + Map items = this.allData.get(kind); if (items == null) { return null; } @@ -49,7 +50,7 @@ public ItemDescriptor get(DataKind kind, String key) { @Override public KeyedItems getAll(DataKind kind) { - Map items = allData.get(kind); + Map items = this.allData.get(kind); if (items == null) { return new KeyedItems<>(null); } @@ -58,7 +59,7 @@ public KeyedItems getAll(DataKind kind) { @Override public boolean upsert(DataKind kind, String key, ItemDescriptor item) { - synchronized (writeLock) { + synchronized (this.writeLock) { Map existingItems = this.allData.get(kind); ItemDescriptor oldItem = null; if (existingItems != null) { @@ -97,7 +98,7 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { @Override public boolean isInitialized() { - return initialized; + return this.initialized; } @Override @@ -124,10 +125,10 @@ public void close() throws IOException { public void apply(ChangeSet changeSet) { switch (changeSet.getType()) { case Full: - applyFullPayload(changeSet.getData(), changeSet.getEnvironmentId(), changeSet.getSelector()); + applyFullPayload(changeSet.getData(), changeSet.getEnvironmentId(), changeSet.getSelector(), changeSet.shouldPersist()); break; case Partial: - applyPartialData(changeSet.getData(), changeSet.getSelector()); + applyPartialData(changeSet.getData(), changeSet.getSelector(), changeSet.shouldPersist()); break; case None: break; @@ -140,20 +141,20 @@ public void apply(ChangeSet changeSet) { @Override public Selector getSelector() { - synchronized (selectorLock) { - return selector; + synchronized (this.selectorLock) { + return this.selector; } } private void setSelector(Selector newSelector) { - synchronized (selectorLock) { - selector = newSelector; + synchronized (this.selectorLock) { + this.selector = newSelector; } } private void applyPartialData(Iterable>> data, - Selector selector) { - synchronized (writeLock) { + Selector selector, boolean shouldPersist) { + synchronized (this.writeLock) { // Build the complete updated dictionary before assigning to Items for transactional update ImmutableMap.Builder> itemsBuilder = ImmutableMap.builder(); @@ -164,7 +165,7 @@ private void applyPartialData(Iterable> existingEntry : allData.entrySet()) { + for (Map.Entry> existingEntry : this.allData.entrySet()) { if (!updatedKinds.contains(existingEntry.getKey())) { itemsBuilder.put(existingEntry.getKey(), existingEntry.getValue()); } @@ -176,7 +177,7 @@ private void applyPartialData(Iterable kindMap = new HashMap<>(); - Map itemsOfKind = allData.get(kind); + Map itemsOfKind = this.allData.get(kind); if (itemsOfKind != null) { kindMap.putAll(itemsOfKind); } @@ -189,13 +190,14 @@ private void applyPartialData(Iterable>> data, - String environmentId, Selector selector) { + String environmentId, Selector selector, boolean shouldPersist) { ImmutableMap.Builder> itemsBuilder = ImmutableMap.builder(); for (Map.Entry> kindEntry : data) { @@ -208,26 +210,28 @@ private void applyFullPayload(Iterable> newItems = itemsBuilder.build(); - synchronized (writeLock) { - allData = newItems; - initialized = true; + synchronized (this.writeLock) { + this.allData = newItems; + this.initialized = true; + this.shouldPersist = shouldPersist; setSelector(selector); } } @Override public FullDataSet exportAll() { - synchronized (writeLock) { + synchronized (this.writeLock) { ImmutableList.Builder>> builder = ImmutableList.builder(); - for (Map.Entry> kindEntry : allData.entrySet()) { + for (Map.Entry> kindEntry : this.allData.entrySet()) { builder.add(new AbstractMap.SimpleEntry<>( kindEntry.getKey(), new KeyedItems<>(ImmutableList.copyOf(kindEntry.getValue().entrySet())) )); } - return new FullDataSet<>(builder.build()); + // Preserve the shouldPersist value that was set when data was provided to this store + return new FullDataSet<>(builder.build(), this.shouldPersist); } } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreConverter.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreConverter.java index 050a9916..a47bfba3 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreConverter.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreConverter.java @@ -39,7 +39,8 @@ static FullDataSet toSerializedFormat( )); } - return new FullDataSet<>(builder.build()); + // Preserve shouldPersist flag when converting formats + return new FullDataSet<>(builder.build(), inMemoryData.shouldPersist()); } /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index 52af86a3..d1bcb985 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -186,7 +186,7 @@ public void init(FullDataSet allData) { KeyedItems items = PersistentDataStoreConverter.serializeAll(kind, e0.getValue()); allBuilder.add(new AbstractMap.SimpleEntry<>(kind, items)); } - RuntimeException failure = initCore(new FullDataSet<>(allBuilder.build())); + RuntimeException failure = initCore(new FullDataSet<>(allBuilder.build(), allData.shouldPersist())); if (itemCache != null && allCache != null) { itemCache.invalidateAll(); allCache.invalidateAll(); @@ -410,6 +410,12 @@ private boolean pollAvailabilityAfterOutage() { if (externalCacheSnapshot.isInitialized()) { try { FullDataSet externalData = externalCacheSnapshot.exportAll(); + + if (!externalData.shouldPersist()) { + logger.debug("Skipping persistence of non-authoritative data (shouldPersist=false) during recovery"); + return true; // Recovery succeeded, but we didn't persist + } + FullDataSet serializedData = PersistentDataStoreConverter.toSerializedFormat(externalData); RuntimeException e = initCore(serializedData); @@ -453,7 +459,8 @@ private boolean pollAvailabilityAfterOutage() { builder.add(new AbstractMap.SimpleEntry<>(kind, PersistentDataStoreConverter.serializeAll(kind, items))); } } - RuntimeException e = initCore(new FullDataSet<>(builder.build())); + // any data that this PersistentDataStoreWrapper contains has already passed the shouldPersist check + RuntimeException e = initCore(new FullDataSet<>(builder.build(), true)); if (e == null) { logger.warn("Successfully updated persistent store from cached data"); } else { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java index 20a5a1f1..3642cae6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java @@ -83,7 +83,8 @@ protected CompletableFuture poll(Selector selector, boolean on Selector.EMPTY, null, // TODO: Implement environment ID support. - null + null, + true // Polling data from LaunchDarkly should be persisted )); } FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); @@ -97,7 +98,8 @@ protected CompletableFuture poll(Selector selector, boolean on ((FDv2ProtocolHandler.FDv2ActionChangeset) res).getChangeset(), logger, // TODO: Implement environment ID support. - null + null, + true // Polling data from LaunchDarkly should be persisted ); return FDv2SourceResult.changeSet(converted); } catch (Exception e) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java index 06fb6b9a..e851ebc6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java @@ -130,7 +130,8 @@ static PutData parsePutData(JsonReader jr) { path = jr.nextString(); break; case "data": - data = parseFullDataSet(jr); + // Streaming data from LaunchDarkly should be persisted + data = new FullDataSet<>(parseFullDataSet(jr), true); break; default: jr.skipValue(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index c5d52f3d..0b5aac44 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -251,7 +251,7 @@ private void handleMessage(MessageEvent event) { try { // TODO: Environment ID. DataStoreTypes.ChangeSet converted = - FDv2ChangeSetTranslator.toChangeSet(changeset.getChangeset(), logger, null); + FDv2ChangeSetTranslator.toChangeSet(changeset.getChangeset(), logger, null, true); result = FDv2SourceResult.changeSet(converted); } catch (Exception e) { logger.error("Failed to convert FDv2 changeset: {}", LogValues.exceptionSummary(e)); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/WriteThroughStore.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/WriteThroughStore.java index 70dbeba8..8315965f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/WriteThroughStore.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/WriteThroughStore.java @@ -57,7 +57,8 @@ public void init(FullDataSet allData) { memoryStore.init(allData); maybeSwitchStore(); - if (persistenceMode == DataStoreMode.READ_WRITE) { + // Only write to persistent store if shouldPersist is true and store is in READ_WRITE mode + if (persistenceMode == DataStoreMode.READ_WRITE && allData.shouldPersist()) { if (persistentStore != null) { persistentStore.init(allData); } @@ -77,6 +78,8 @@ public KeyedItems getAll(DataKind kind) { @Override public boolean upsert(DataKind kind, String key, ItemDescriptor item) { boolean result = memoryStore.upsert(kind, key, item); + // Note: upsert() doesn't have persist information. For legacy paths (FDv1), we always persist. + // For FDv2 paths, this method shouldn't be called - use apply() instead which has persist info. if (hasPersistence && persistenceMode == DataStoreMode.READ_WRITE) { result = result && persistentStore.upsert(kind, key, item); } @@ -107,7 +110,8 @@ public void apply(ChangeSet changeSet) { txMemoryStore.apply(changeSet); maybeSwitchStore(); - if (!hasPersistence || persistenceMode != DataStoreMode.READ_WRITE) { + // Only write to persistent store if shouldPersist is true and store is in READ_WRITE mode + if (!hasPersistence || persistenceMode != DataStoreMode.READ_WRITE || !changeSet.shouldPersist()) { return; } @@ -175,7 +179,8 @@ private boolean applyToLegacyPersistence(ChangeSet sortedChangeS * Applies a full change set to a legacy persistent store. */ private void applyFullChangeSetToLegacyStore(ChangeSet sortedChangeSet) { - persistentStore.init(new FullDataSet<>(sortedChangeSet.getData())); + // Preserve shouldPersist flag when converting ChangeSet to FullDataSet + persistentStore.init(new FullDataSet<>(sortedChangeSet.getData(), sortedChangeSet.shouldPersist())); } /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 08f39a23..744b8505 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -281,7 +281,8 @@ public FullDataSet build() { for (Map.Entry> e0: allData.entrySet()) { allBuilder.add(new AbstractMap.SimpleEntry<>(e0.getKey(), new KeyedItems<>(e0.getValue().entrySet()))); } - return new FullDataSet<>(allBuilder.build()); + // File data source data is not authoritative and should not be persisted + return new FullDataSet<>(allBuilder.build(), false); } public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataException { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java index e1adafde..22de587a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -69,6 +69,7 @@ public final class TestData implements ComponentConfigurer { private final Map currentFlags = new HashMap<>(); private final Map currentBuilders = new HashMap<>(); private final List instances = new CopyOnWriteArrayList<>(); + private boolean shouldPersist = true; /** * Creates a new instance of the test data source. @@ -197,6 +198,33 @@ public TestData updateStatus(DataSourceStatusProvider.State newState, DataSource return this; } + /** + * Configures whether test data should be persisted to persistent stores. + *

+ * By default, test data is persisted ({@code shouldPersist = true}) to maintain consistency with + * previous versions' behavior. When {@code true}, the test data will be written to any configured persistent + * store (if the store is in READ_WRITE mode). This is useful for integration tests that verify + * your persistent store configuration. + *

+ * Set this to {@code false} if you want to prevent test data from being written to persistent stores. + * This may be appropriate for unit testing scenarios where you want to test your application logic + * without affecting a persistent store. + *

+ * Example: + *


+   *     TestData td = TestData.dataSource()
+   *         .shouldPersist(false);  // Disable persistence to avoid polluting the store
+   *     td.update(td.flag("flag-key").booleanFlag().variationForAllUsers(true));
+   * 
+ * + * @param shouldPersist true if test data should be persisted to persistent stores, false otherwise + * @return the same {@code TestData} instance + */ + public TestData shouldPersist(boolean shouldPersist) { + this.shouldPersist = shouldPersist; + return this; + } + @Override public DataSource build(ClientContext context) { DataSourceImpl instance = new DataSourceImpl(context.getDataSourceUpdateSink()); @@ -211,7 +239,7 @@ private FullDataSet makeInitData() { synchronized (lock) { copiedData = ImmutableMap.copyOf(currentFlags); } - return new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, new KeyedItems<>(copiedData.entrySet())).entrySet()); + return new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, new KeyedItems<>(copiedData.entrySet())).entrySet(), shouldPersist); } private void closedInstance(DataSourceImpl instance) { 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 561f0e3e..013ecb9b 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 @@ -262,6 +262,7 @@ public String toString() { */ public static final class FullDataSet { private final Iterable>> data; + private final boolean shouldPersist; /** * Returns the wrapped data set. @@ -272,23 +273,42 @@ public Iterable>> getData() { return data; } + /** + * Returns whether this data should be persisted to persistent stores. + *

+ * If true, indicates that the data should be propagated to any connected + * persistent stores. If false, indicates that the data should not be persisted (e.g., data from + * an untrusted source like a file cache). + * + * @return true if the data should be persisted, false otherwise + */ + public boolean shouldPersist() { + return shouldPersist; + } + /** * Constructs a new instance. * * @param data the data set + * @param shouldPersist true if the data should be persisted to persistent stores, false otherwise */ - public FullDataSet(Iterable>> data) { + public FullDataSet(Iterable>> data, boolean shouldPersist) { this.data = data == null ? ImmutableList.of(): data; + this.shouldPersist = shouldPersist; } @Override public boolean equals(Object o) { - return o instanceof FullDataSet && data.equals(((FullDataSet)o).data); + if (o instanceof FullDataSet) { + FullDataSet other = (FullDataSet)o; + return data.equals(other.data) && shouldPersist == other.shouldPersist; + } + return false; } @Override public int hashCode() { - return data.hashCode(); + return Objects.hash(data, shouldPersist); } } @@ -368,6 +388,7 @@ public static final class ChangeSet { private final Selector selector; private final String environmentId; private final Iterable>> data; + private final boolean shouldPersist; /** * Returns the type of the changeset. @@ -406,6 +427,19 @@ public Iterable>> getData() { return data; } + /** + * Returns whether this data should be persisted to persistent stores. + *

+ * If true, indicates that the data should be propagated to any connected + * persistent stores. If false, indicates that the data should not be persisted (e.g., data from + * an untrusted source like a file cache). + * + * @return true if the data should be persisted, false otherwise + */ + public boolean shouldPersist() { + return shouldPersist; + } + /** * Constructs a new ChangeSet instance. * @@ -413,13 +447,15 @@ public Iterable>> getData() { * @param selector the selector for this change * @param data the list of changes * @param environmentId the environment ID, or null if not available + * @param shouldPersist true if the data should be persisted to persistent stores, false otherwise */ public ChangeSet(ChangeSetType type, Selector selector, - Iterable>> data, String environmentId) { + Iterable>> data, String environmentId, boolean shouldPersist) { this.type = type; this.selector = selector; this.data = data == null ? ImmutableList.of() : data; this.environmentId = environmentId; + this.shouldPersist = shouldPersist; } @Override @@ -427,19 +463,20 @@ 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); + Objects.equals(environmentId, other.environmentId) && Objects.equals(data, other.data) && + shouldPersist == other.shouldPersist; } return false; } @Override public int hashCode() { - return Objects.hash(type, selector, environmentId, data); + return Objects.hash(type, selector, environmentId, data, shouldPersist); } @Override public String toString() { - return "ChangeSet(" + type + "," + selector + "," + environmentId + "," + data + ")"; + return "ChangeSet(" + type + "," + selector + "," + environmentId + "," + data + "," + shouldPersist + ")"; } } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java index 4875e531..a6f8777b 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java @@ -396,7 +396,8 @@ public void sortChangesetPreservesChangeSetMetadata() { ChangeSetType.Partial, selector, ImmutableList.of(), - environmentId + environmentId, + true ); ChangeSet result = DataModelDependencies.sortChangeset(changeSet); @@ -441,7 +442,8 @@ public void sortChangesetSortsPrerequisiteFlagsFirst() { ChangeSetType.Partial, Selector.make(1, "state1"), changeSetData, - null + null, + true ); ChangeSet result = DataModelDependencies.sortChangeset(changeSet); @@ -489,7 +491,8 @@ public void sortChangesetSortsSegmentsBeforeFlags() { ChangeSetType.Full, Selector.make(1, "state1"), changeSetData, - null + null, + true ); ChangeSet result = DataModelDependencies.sortChangeset(changeSet); @@ -506,7 +509,8 @@ public void sortChangesetHandlesEmptyChangeset() { ChangeSetType.Full, Selector.make(1, "state1"), ImmutableList.of(), - null + null, + true ); ChangeSet result = DataModelDependencies.sortChangeset(changeSet); @@ -542,7 +546,8 @@ public void sortChangesetHandlesMultipleDataKinds() { ChangeSetType.Partial, Selector.make(1, "state1"), changeSetData, - null + null, + true ); ChangeSet result = DataModelDependencies.sortChangeset(changeSet); @@ -582,7 +587,8 @@ public void sortChangesetPreservesDeletedItems() { ChangeSetType.Partial, Selector.make(1, "state1"), changeSetData, - null + null, + true ); ChangeSet result = DataModelDependencies.sortChangeset(changeSet); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index ea5203aa..c2a56ec9 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -578,7 +578,7 @@ public void explicitNullsAreToleratedForNullableValues() { @Test public void parsingFullDataSetEmptyObject() throws Exception { String json = "{}"; - FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + FullDataSet allData = new FullDataSet<>(parseFullDataSet(jsonReaderFrom(json)), true); assertDataSetEquals(DataBuilder.forStandardTypes().build(), allData); } @@ -586,7 +586,7 @@ public void parsingFullDataSetEmptyObject() throws Exception { public void parsingFullDataSetFlagsOnly() throws Exception { FeatureFlag flag = flagBuilder("flag1").version(1000).build(); String json = "{\"flags\":{\"flag1\":" + serialize(flag) + "}}"; - FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + FullDataSet allData = new FullDataSet<>(parseFullDataSet(jsonReaderFrom(json)), true); assertDataSetEquals(DataBuilder.forStandardTypes().addAny(FEATURES, flag).build(), allData); } @@ -594,7 +594,7 @@ public void parsingFullDataSetFlagsOnly() throws Exception { public void parsingFullDataSetSegmentsOnly() throws Exception { Segment segment = segmentBuilder("segment1").version(1000).build(); String json = "{\"segments\":{\"segment1\":" + serialize(segment) + "}}"; - FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + FullDataSet allData = new FullDataSet<>(parseFullDataSet(jsonReaderFrom(json)), true); assertDataSetEquals(DataBuilder.forStandardTypes().addAny(SEGMENTS, segment).build(), allData); } @@ -606,7 +606,7 @@ public void parsingFullDataSetFlagsAndSegments() throws Exception { Segment segment2 = segmentBuilder("segment2").version(1001).build(); String json = "{\"flags\":{\"flag1\":" + serialize(flag1) + ",\"flag2\":" + serialize(flag2) + "}" + ",\"segments\":{\"segment1\":" + serialize(segment1) + ",\"segment2\":" + serialize(segment2) + "}}"; - FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + FullDataSet allData = new FullDataSet<>(parseFullDataSet(jsonReaderFrom(json)), true); assertDataSetEquals(DataBuilder.forStandardTypes() .addAny(FEATURES, flag1, flag2).addAny(SEGMENTS, segment1, segment2).build(), allData); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index a6f3744c..1431970e 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -135,7 +135,8 @@ private static ChangeSet makeFullChangeSet(FeatureFlag... flags) ChangeSetType.Full, Selector.make(1, "state1"), data, - null + null, + true ); } @@ -155,7 +156,8 @@ private static ChangeSet makePartialChangeSet(FeatureFlag... fla ChangeSetType.Partial, Selector.make(1, "state1"), data, - null + null, + true ); } @@ -707,7 +709,8 @@ public void applyPartialChangeSetSendsEventForDeletedFlag() throws Exception { ChangeSetType.Partial, Selector.make(1, "state1"), data, - null + null, + true ); updates.apply(changeSet); @@ -834,7 +837,8 @@ public void applyFullChangeSetSendsEventsForFlagsWhoseSegmentsChanged() throws E ChangeSetType.Full, Selector.make(1, "state1"), changeSetData, - null + null, + true ); updates.apply(changeSet); @@ -886,7 +890,8 @@ public void applyPartialChangeSetSendsEventsForFlagsWhoseSegmentsChanged() throw ChangeSetType.Partial, Selector.make(1, "state1"), segmentData, - null + null, + true ); updates.apply(changeSet); @@ -1024,7 +1029,8 @@ public void applyFullChangeSetToLegacyStoreWithEnvironmentId() throws Exception ChangeSetType.Full, Selector.make(1, "state1"), data, - "test-env-id" + "test-env-id", + true ); updates.apply(changeSet); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index 4e5bd0f7..1261f363 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -177,7 +177,8 @@ public FullDataSet build() { ImmutableMap.copyOf( Maps.transformValues(data, itemsMap -> new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) - )).entrySet() + )).entrySet(), + true ); } @@ -191,7 +192,9 @@ public FullDataSet buildSerialized() { ).entrySet() ) ) - ).entrySet()); + ).entrySet(), + true + ); } public LDValue buildJson() { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java index 522e94f1..0f5b3bdf 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java @@ -59,7 +59,7 @@ public void toChangeSet_withFullChangeset_returnsFullChangeSetType() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(ChangeSetType.Full, result.getType()); } @@ -72,7 +72,7 @@ public void toChangeSet_withPartialChangeset_returnsPartialChangeSetType() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.PARTIAL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(ChangeSetType.Partial, result.getType()); } @@ -83,7 +83,7 @@ public void toChangeSet_withNoneChangeset_returnsNoneChangeSetType() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.NONE, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(ChangeSetType.None, result.getType()); } @@ -95,7 +95,7 @@ public void toChangeSet_includesSelector() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, selector); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(selector.getVersion(), result.getSelector().getVersion()); assertEquals(selector.getState(), result.getSelector().getState()); @@ -107,7 +107,7 @@ public void toChangeSet_includesEnvironmentId() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, "test-env-id"); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, "test-env-id", true); assertEquals("test-env-id", result.getEnvironmentId()); } @@ -118,7 +118,7 @@ public void toChangeSet_withNullEnvironmentId_returnsNullEnvironmentId() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertNull(result.getEnvironmentId()); } @@ -131,7 +131,7 @@ public void toChangeSet_withPutOperation_deserializesItem() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); Map.Entry> flagData = findDataKind(result, "features"); assertNotNull(flagData); @@ -149,7 +149,7 @@ public void toChangeSet_withDeleteOperation_createsDeletedDescriptor() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.PARTIAL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); Map.Entry> flagData = findDataKind(result, "features"); assertNotNull(flagData); @@ -168,7 +168,7 @@ public void toChangeSet_withMultipleFlags_groupsByKind() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); Map.Entry> flagData = findDataKind(result, "features"); assertNotNull(flagData); @@ -184,7 +184,7 @@ public void toChangeSet_withFlagsAndSegments_createsMultipleDataKinds() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(2, countDataKinds(result)); assertNotNull(findDataKind(result, "features")); @@ -200,7 +200,7 @@ public void toChangeSet_withUnknownKind_skipsItemAndLogsWarning() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(1, countDataKinds(result)); assertNotNull(findDataKind(result, "features")); @@ -216,7 +216,7 @@ public void toChangeSet_withPutOperationMissingObject_skipsItemAndLogsWarning() FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); Map.Entry> flagData = findDataKind(result, "features"); assertNotNull(flagData); @@ -231,7 +231,7 @@ public void toChangeSet_withEmptyChanges_returnsEmptyData() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(0, countDataKinds(result)); } @@ -246,7 +246,7 @@ public void toChangeSet_withMixedPutAndDelete_handlesAllOperations() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.PARTIAL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); assertEquals(2, countDataKinds(result)); @@ -277,7 +277,7 @@ public void toChangeSet_preservesOrderOfChangesWithinKind() { FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); DataStoreTypes.ChangeSet result = - FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null, true); Map.Entry> flagData = findDataKind(result, "features"); assertNotNull(flagData); 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 19892954..a784936c 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 @@ -6,8 +6,10 @@ 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 org.junit.Test; @@ -85,7 +87,8 @@ public void applyWithFullChangeSetReplacesAllData() { ChangeSetType.Full, Selector.make(1, "state1"), changeSetData, - "test-env" + "test-env", + true ); typedStore().apply(changeSet); @@ -108,7 +111,8 @@ public void applyWithFullChangeSetSetsSelector() { ChangeSetType.Full, selector, ImmutableList.of(), - null + null, + true ); typedStore().apply(changeSet); @@ -125,7 +129,8 @@ public void applyWithFullChangeSetMarksStoreAsInitialized() { ChangeSetType.Full, Selector.make(1, "state1"), ImmutableList.of(), - null + null, + true ); typedStore().apply(changeSet); @@ -158,7 +163,8 @@ public void applyWithPartialChangeSetAddsNewItems() { ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -200,7 +206,8 @@ public void applyWithPartialChangeSetCanReplaceItems() { ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -241,7 +248,8 @@ public void applyWithPartialChangeSetCanDeleteItems() { ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -269,7 +277,8 @@ public void applyWithPartialChangeSetUpdatesSelector() { ChangeSetType.Partial, newSelector, ImmutableList.of(), - null + null, + true ); typedStore().apply(changeSet); @@ -287,7 +296,8 @@ public void applyWithNoneChangeSetDoesNotModifyData() { ChangeSetType.None, Selector.make(5, "state5"), ImmutableList.of(), - null + null, + true ); typedStore().apply(changeSet); @@ -334,7 +344,8 @@ public void applyWithFullChangeSetHandlesMultipleDataKinds() { ChangeSetType.Full, Selector.make(1, "state1"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -381,7 +392,8 @@ public void applyWithPartialChangeSetHandlesMultipleDataKinds() { ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -429,7 +441,8 @@ public void applyWithPartialChangeSetPreservesUnaffectedDataKinds() { ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -454,7 +467,8 @@ public void applyWithFullChangeSetEmptyDataClearsStore() { ChangeSetType.Full, Selector.make(1, "state1"), ImmutableList.of(), - null + null, + true ); typedStore().apply(changeSet); @@ -489,7 +503,8 @@ public void applyWithPartialChangeSetOnUninitializedStore() { ChangeSetType.Partial, Selector.make(1, "state1"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -528,7 +543,8 @@ public void applyWithMultipleItemsInSameKind() { ChangeSetType.Full, Selector.make(1, "state1"), changeSetData, - null + null, + true ); typedStore().apply(changeSet); @@ -583,6 +599,9 @@ public void exportAllDataReturnsCompleteSnapshot() { assertEquals(1, otherKindData.size()); assertEquals(item3, otherKindData.get("key3").getItem()); assertEquals(3, otherKindData.get("key3").getVersion()); + + // Verify shouldPersist is preserved (DataBuilder.build() returns shouldPersist=true) + assertTrue(exported.shouldPersist()); } @Test @@ -595,6 +614,9 @@ public void exportAllDataWithEmptyStoreReturnsEmptyDataSet() { count++; } assertEquals(0, count); + + // Empty store should default to shouldPersist=false (initial default value) + assertFalse(exported.shouldPersist()); } @Test @@ -629,6 +651,9 @@ public void exportAllDataWithDeletedItemsIncludesDeletedItems() { ItemDescriptor deletedItem = testKindData.get("key2"); assertNull(deletedItem.getItem()); assertEquals(2, deletedItem.getVersion()); + + // Verify shouldPersist is preserved from init (DataBuilder.build() returns shouldPersist=true) + assertTrue(exported.shouldPersist()); } @Test @@ -714,5 +739,216 @@ public void exportAllDataReturnsImmutableSnapshot() { assertNotNull(testKindData); assertEquals(1, testKindData.size()); assertEquals("key1", testKindData.keySet().iterator().next()); + + // Verify shouldPersist is preserved from init (DataBuilder.build() returns shouldPersist=true) + assertTrue(exported.shouldPersist()); + } + + @Test + public void exportAllPreservesShouldPersistFromInitWithFalse() { + TestItem item1 = new TestItem("key1", "item1", 1); + + // Initialize with shouldPersist=false (e.g., from file data source) + FullDataSet initData = new FullDataSet<>( + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build().getData(), + false // shouldPersist=false + ); + store.init(initData); + + FullDataSet exported = typedStore().exportAll(); + + // Verify shouldPersist=false is preserved + assertFalse(exported.shouldPersist()); + + // Verify data is correct + 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(item1, testKindData.get("key1").getItem()); + } + + @Test + public void exportAllPreservesShouldPersistFromInitWithTrue() { + TestItem item1 = new TestItem("key1", "item1", 1); + + // Initialize with shouldPersist=true (e.g., from polling/streaming data source) + FullDataSet initData = new FullDataSet<>( + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build().getData(), + true // shouldPersist=true + ); + store.init(initData); + + FullDataSet exported = typedStore().exportAll(); + + // Verify shouldPersist=true is preserved + assertTrue(exported.shouldPersist()); + + // Verify data is correct + 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(item1, testKindData.get("key1").getItem()); + } + + @Test + public void exportAllPreservesShouldPersistWhenApplyFalseOverwritesTrue() { + TestItem item1 = new TestItem("key1", "item1", 1); + TestItem item2 = new TestItem("key2", "item2", 2); + + // Initialize with shouldPersist=true + FullDataSet initData = new FullDataSet<>( + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build().getData(), + true // shouldPersist=true + ); + store.init(initData); + + // Apply change set with shouldPersist=false (e.g., file data source overwrites) + Map> changeSetData = ImmutableMap.of( + TEST_ITEMS, + new KeyedItems<>(ImmutableList.of( + new AbstractMap.SimpleEntry<>("key2", new ItemDescriptor(2, item2)) + )) + ); + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData.entrySet(), + null, + false // shouldPersist=false + ); + typedStore().apply(changeSet); + + FullDataSet exported = typedStore().exportAll(); + + // Verify shouldPersist=false is preserved (most recent apply overwrites) + assertFalse(exported.shouldPersist()); + + // Verify data includes both items + Map testKindData = null; + for (Map.Entry> entry : exported.getData()) { + if (entry.getKey() == TEST_ITEMS) { + testKindData = toItemsMap(entry.getValue()); + } + } + assertNotNull(testKindData); + assertEquals(2, testKindData.size()); + assertEquals(item1, testKindData.get("key1").getItem()); + assertEquals(item2, testKindData.get("key2").getItem()); + } + + @Test + public void exportAllPreservesShouldPersistWhenApplyTrueOverwritesFalse() { + TestItem item1 = new TestItem("key1", "item1", 1); + TestItem item2 = new TestItem("key2", "item2", 2); + + // Initialize with shouldPersist=false + FullDataSet initData = new FullDataSet<>( + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build().getData(), + false // shouldPersist=false + ); + store.init(initData); + + // Apply change set with shouldPersist=true (e.g., polling data source overwrites file data) + Map> changeSetData = ImmutableMap.of( + TEST_ITEMS, + new KeyedItems<>(ImmutableList.of( + new AbstractMap.SimpleEntry<>("key2", new ItemDescriptor(2, item2)) + )) + ); + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData.entrySet(), + null, + true // shouldPersist=true + ); + typedStore().apply(changeSet); + + FullDataSet exported = typedStore().exportAll(); + + // Verify shouldPersist=true is preserved (most recent apply overwrites) + assertTrue(exported.shouldPersist()); + + // Verify data includes both items + Map testKindData = null; + for (Map.Entry> entry : exported.getData()) { + if (entry.getKey() == TEST_ITEMS) { + testKindData = toItemsMap(entry.getValue()); + } + } + assertNotNull(testKindData); + assertEquals(2, testKindData.size()); + assertEquals(item1, testKindData.get("key1").getItem()); + assertEquals(item2, testKindData.get("key2").getItem()); + } + + @Test + public void exportAllPreservesShouldPersistWhenFullChangeSetOverwrites() { + TestItem item1 = new TestItem("key1", "item1", 1); + TestItem item2 = new TestItem("key2", "item2", 2); + + // Initialize with shouldPersist=true + FullDataSet initData = new FullDataSet<>( + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build().getData(), + true // shouldPersist=true + ); + store.init(initData); + + // Apply full change set with shouldPersist=false + Map> changeSetData = ImmutableMap.of( + TEST_ITEMS, + new KeyedItems<>(ImmutableList.of( + new AbstractMap.SimpleEntry<>("key2", new ItemDescriptor(2, item2)) + )) + ); + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.make(2, "state2"), + changeSetData.entrySet(), + null, + false // shouldPersist=false + ); + typedStore().apply(changeSet); + + FullDataSet exported = typedStore().exportAll(); + + // Verify shouldPersist=false is preserved (most recent apply overwrites) + assertFalse(exported.shouldPersist()); + + // Verify data only includes item2 (full change set replaces all data) + 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(item2, testKindData.get("key2").getItem()); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreConverterTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreConverterTest.java index 069f9c24..f9529b41 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreConverterTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreConverterTest.java @@ -92,7 +92,7 @@ FullDataSet build() { } builder.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(itemsBuilder.build()))); } - return new FullDataSet<>(builder.build()); + return new FullDataSet<>(builder.build(), true); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperRecoveryTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperRecoveryTest.java index 4a1608f6..6c2e87b4 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperRecoveryTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperRecoveryTest.java @@ -68,7 +68,7 @@ private FullDataSet createDataSetWithDeletedItem(DataKind kind, } builder.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(itemsBuilder.build()))); } - return new FullDataSet<>(builder.build()); + return new FullDataSet<>(builder.build(), true); } // Helper method to merge two FullDataSets @@ -99,7 +99,7 @@ private FullDataSet mergeDataSets(FullDataSet se } builder.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(itemsBuilder.build()))); } - return new FullDataSet<>(builder.build()); + return new FullDataSet<>(builder.build(), true); } private PersistentDataStoreWrapper makeWrapperWithExternalSource(CacheExporter externalSource) { @@ -495,7 +495,7 @@ public void externalDataSourceSyncWithDeletedItemsSyncsCorrectly() throws Except } builder2.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(itemsBuilder.build()))); } - externalSource.setData(new FullDataSet<>(builder2.build())); + externalSource.setData(new FullDataSet<>(builder2.build(), true)); PersistentDataStoreWrapper wrapper = makeWrapperWithExternalSource(externalSource); try { @@ -601,6 +601,82 @@ public void externalDataSourceSyncWhenExternalStoreNotInitializedFallsBackToCach } } + @Test + public void externalDataSourceSyncWithShouldPersistFalseDoesNotPersist() throws Exception { + // This test verifies that when exportAll() returns data with shouldPersist=false, + // the data is NOT persisted during recovery + MockCacheExporter externalSource = new MockCacheExporter(); + DataStoreTestTypes.TestItem item1 = new DataStoreTestTypes.TestItem("key1", "item1", 1); + DataStoreTestTypes.TestItem item2 = new DataStoreTestTypes.TestItem("key2", "item2", 1); + + // Set data with shouldPersist=false (e.g., from a file data source) + FullDataSet nonAuthoritativeData = new FullDataSet<>( + new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .add(TEST_ITEMS, item2) + .build().getData(), + false + ); + externalSource.setData(nonAuthoritativeData); + + PersistentDataStoreWrapper wrapper = makeWrapperWithExternalSource(externalSource); + try { + DataStoreStatusProvider dataStoreStatusProvider = new DataStoreStatusProviderImpl(wrapper, dataStoreUpdates); + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataStoreStatusProvider.addStatusListener(statuses::add); + + // Initialize the wrapper with some initial authoritative data + wrapper.init(new DataStoreTestTypes.DataBuilder() + .add(TEST_ITEMS, item1) + .build()); + + // Verify initial data is in persistent store + SerializedItemDescriptor initialItem = core.data.get(TEST_ITEMS).get("key1"); + assertNotNull(initialItem); + assertEquals(1, initialItem.getVersion()); + + // Cause a store error + core.unavailable = true; + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(TEST_ITEMS, "key1", new ItemDescriptor(2, item1)); + fail("Expected exception"); + } catch (RuntimeException e) { + assertEquals(FAKE_ERROR.getMessage(), e.getMessage()); + } + + DataStoreStatusProvider.Status unavailableStatus = statuses.poll(TIMEOUT_FOR_RECOVERY.toMillis(), TimeUnit.MILLISECONDS); + assertNotNull("Expected unavailable status", unavailableStatus); + assertFalse(unavailableStatus.isAvailable()); + + // Make store available again + core.fakeError = null; + core.unavailable = false; + + // Wait for recovery + DataStoreStatusProvider.Status recoveryStatus = statuses.poll(TIMEOUT_FOR_RECOVERY.toMillis(), TimeUnit.MILLISECONDS); + assertNotNull("Expected recovery status update", recoveryStatus); + assertTrue(recoveryStatus.isAvailable()); + + // Verify that the non-authoritative data (shouldPersist=false) was NOT persisted + // The persistent store should still have the original data, not the new data from external source + SerializedItemDescriptor persistedItem1 = core.data.get(TEST_ITEMS).get("key1"); + assertNotNull(persistedItem1); + // Should still be version 1 (from initial init), not updated with external source data + assertEquals(1, persistedItem1.getVersion()); + + // item2 should not exist in persistent store since it was only in the non-authoritative external source + assertFalse(core.data.get(TEST_ITEMS).containsKey("key2")); + + // Check log message - should indicate skipping persistence + assertTrue(logCapture.getMessages().stream() + .anyMatch(m -> m.getLevel().name().equals("DEBUG") && + m.getText().contains("Skipping persistence of non-authoritative data (shouldPersist=false) during recovery"))); + } finally { + wrapper.close(); + } + } + /** * Mock implementation of CacheExporter for testing. */ diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 8982c365..064580ae 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -104,7 +104,7 @@ public static DataStore inMemoryDataStore() { public static DataStore initedDataStore() { DataStore store = new InMemoryDataStore(); - store.init(new FullDataSet(null)); + store.init(new FullDataSet(null, true)); return store; } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/WriteThroughStoreTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/WriteThroughStoreTest.java index 3f54a890..ea8827ff 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/WriteThroughStoreTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/WriteThroughStoreTest.java @@ -66,7 +66,8 @@ private ChangeSet createFullChangeSet() { ChangeSetType.Full, Selector.make(1, "state1"), changeSetData.entrySet(), - null + null, + true ); } @@ -146,6 +147,26 @@ public void initWithPersistenceReadOnlyInitializesMemoryStoreOnly() throws Excep assertFalse(persistentStore.wasInitCalled); } + @Test + public void initWithShouldPersistFalseDoesNotCallPersistentStore() throws Exception { + InMemoryDataStore memoryStore = new InMemoryDataStore(); + MockPersistentStore persistentStore = new MockPersistentStore(); + + store = new WriteThroughStore(memoryStore, persistentStore, DataStoreMode.READ_WRITE); + + // Create test data with shouldPersist=false (e.g., from file data source) + FullDataSet testData = new FullDataSet<>( + createTestDataSet().getData(), + false // shouldPersist=false + ); + store.init(testData); + + assertTrue(memoryStore.isInitialized()); + // Persistent store should NOT be called when shouldPersist=false + assertFalse(persistentStore.wasInitCalled); + assertFalse(persistentStore.isInitialized()); + } + @Test public void initSwitchesActiveReadStoreToMemory() throws Exception { InMemoryDataStore memoryStore = new InMemoryDataStore(); @@ -310,7 +331,8 @@ public void applyWithPartialChangeSetAppliesToBothStores() throws Exception { ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData.entrySet(), - null + null, + true ); persistentStore.resetCallTracking(); @@ -356,7 +378,8 @@ public void applyWithLegacyPersistentStorePartialChangeSetCallsUpsert() throws E ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData.entrySet(), - null + null, + true ); persistentStore.resetCallTracking(); @@ -365,6 +388,70 @@ public void applyWithLegacyPersistentStorePartialChangeSetCallsUpsert() throws E assertTrue(persistentStore.wasUpsertCalled); } + @Test + public void applyWithShouldPersistFalseDoesNotCallPersistentStore() throws Exception { + InMemoryDataStore memoryStore = new InMemoryDataStore(); + MockPersistentStore persistentStore = new MockPersistentStore(); + + store = new WriteThroughStore(memoryStore, persistentStore, DataStoreMode.READ_WRITE); + + store.init(createTestDataSet()); + + TestItem item3 = new TestItem("key3", "item3", 30); + Map> changeSetData = ImmutableMap.of( + TEST_ITEMS, + new KeyedItems<>(ImmutableList.of( + new AbstractMap.SimpleEntry<>("key3", new ItemDescriptor(30, item3)) + )) + ); + // Create change set with shouldPersist=false (e.g., from file data source) + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Partial, + Selector.make(2, "state2"), + changeSetData.entrySet(), + null, + false // shouldPersist=false + ); + + persistentStore.resetCallTracking(); + store.apply(changeSet); + + // Memory store should be updated + ItemDescriptor result = memoryStore.get(TEST_ITEMS, "key3"); + assertNotNull(result); + assertEquals(item3, result.getItem()); + + // Persistent store should NOT be called when shouldPersist=false + assertFalse(persistentStore.wasUpsertCalled); + assertFalse(persistentStore.wasInitCalled); + } + + @Test + public void applyWithFullChangeSetShouldPersistFalseDoesNotCallPersistentStore() throws Exception { + InMemoryDataStore memoryStore = new InMemoryDataStore(); + MockTransactionalPersistentStore persistentStore = new MockTransactionalPersistentStore(); + + store = new WriteThroughStore(memoryStore, persistentStore, DataStoreMode.READ_WRITE); + + // Create full change set with shouldPersist=false + ChangeSet changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.make(1, "state1"), + createFullChangeSet().getData(), + null, + false // shouldPersist=false + ); + + persistentStore.resetCallTracking(); + store.apply(changeSet); + + // Memory store should be initialized + assertTrue(memoryStore.isInitialized()); + + // Persistent store should NOT be called when shouldPersist=false + assertFalse(persistentStore.wasApplyCalled); + } + @Test public void applySwitchesActiveReadStoreToMemory() throws Exception { InMemoryDataStore memoryStore = new InMemoryDataStore(); @@ -459,7 +546,8 @@ public void selectorReturnsMemoryStoreSelector() throws Exception { ChangeSetType.Full, Selector.make(42, "test-state"), ImmutableList.>>of(), - null + null, + true ); store.apply(changeSet); @@ -539,7 +627,8 @@ public void applyWithLegacyStorePartialChangeSetThrowsWhenUpsertFails() throws E ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData.entrySet(), - null + null, + true ); try { @@ -577,7 +666,8 @@ public void applyWithLegacyStorePartialChangeSetThrowsWhenOneOfMultipleUpsertsFa ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData.entrySet(), - null + null, + true ); try { @@ -646,7 +736,8 @@ public void applyWithLegacyStorePartialChangeSetMemoryStoreStillUpdatedBeforeExc ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData.entrySet(), - null + null, + true ); try { @@ -675,7 +766,8 @@ public void applyWithLegacyStoreNoneChangeSetDoesNotThrow() throws Exception { ChangeSetType.None, Selector.make(2, "state2"), ImmutableList.>>of(), - null + null, + true ); store.apply(changeSet); @@ -711,7 +803,8 @@ public void applySwitchesToMemoryStoreEvenWhenPersistentStoreApplyFails() throws ChangeSetType.Partial, Selector.make(2, "state2"), changeSetData.entrySet(), - null + null, + true ); // Apply should throw due to persistent store failure @@ -908,7 +1001,7 @@ public void apply(ChangeSet changeSet) { switch (changeSet.getType()) { case Full: - init(new FullDataSet<>(changeSet.getData())); + init(new FullDataSet<>(changeSet.getData(), changeSet.shouldPersist())); break; case Partial: for (Map.Entry> kindData : changeSet.getData()) { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java index b51ce329..7257c68d 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java @@ -137,13 +137,13 @@ public void keyedItemsEquality() { public void fullDataSetProperties() { ItemDescriptor item1 = new ItemDescriptor(1, "a"); KeyedItems items = new KeyedItems<>(ImmutableMap.of("key1", item1).entrySet()); - FullDataSet data = new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, items).entrySet()); + FullDataSet data = new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, items).entrySet(), true); assertThat(data.getData(), contains( new AbstractMap.SimpleEntry<>(DataModel.FEATURES, items) )); - FullDataSet emptyData = new FullDataSet<>(null); + FullDataSet emptyData = new FullDataSet<>(null, true); assertThat(emptyData.getData(), emptyIterable()); } @@ -156,7 +156,7 @@ public void fullDataSetEquality() { allPermutations.add(() -> new FullDataSet<>( ImmutableMap.of(kind, new KeyedItems<>(ImmutableMap.of("key", new ItemDescriptor(version, "a")).entrySet()) - ).entrySet())); + ).entrySet(), true)); } } TypeBehavior.checkEqualsAndHashCode(allPermutations); From 6e14b7f11679ca2d5f4758c078eebfab6f1f2a84 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 27 Jan 2026 14:04:09 -0500 Subject: [PATCH 2/2] fixing volatile issue --- .../java/com/launchdarkly/sdk/server/integrations/TestData.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java index 22de587a..cc24d1ae 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -69,7 +69,7 @@ public final class TestData implements ComponentConfigurer { private final Map currentFlags = new HashMap<>(); private final Map currentBuilders = new HashMap<>(); private final List instances = new CopyOnWriteArrayList<>(); - private boolean shouldPersist = true; + private volatile boolean shouldPersist = true; /** * Creates a new instance of the test data source.