diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java index 99112355..cd7593e7 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java @@ -255,6 +255,7 @@ public static class EvaluationSeriesContextParam { LDContext context; LDValue defaultValue; String method; + String environmentId; } public static class IdentifyEventParams { diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestHook.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestHook.java index ac979d07..bb16db72 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestHook.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestHook.java @@ -44,6 +44,7 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex seriesContextParam.context = seriesContext.context; seriesContextParam.defaultValue = seriesContext.defaultValue; seriesContextParam.method = seriesContext.method; + seriesContextParam.environmentId = seriesContext.environmentId; params.evaluationSeriesContext = seriesContextParam; params.evaluationSeriesData = data; @@ -72,6 +73,7 @@ public Map afterEvaluation(EvaluationSeriesContext seriesContext seriesContextParam.context = seriesContext.context; seriesContextParam.defaultValue = seriesContext.defaultValue; seriesContextParam.method = seriesContext.method; + seriesContextParam.environmentId = seriesContext.environmentId; params.evaluationSeriesContext = seriesContextParam; params.evaluationSeriesData = data; diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java index faf35246..7db5cc3e 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java @@ -34,6 +34,7 @@ public class TestService { "event-gzip", "event-sampling", "filtering", + "hook-environment-id", "inline-context-all", "migrations", "optional-event-gzip", 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 57a4a16c..17ca660e 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(), allData.shouldPersist()); + return new FullDataSet<>(builder.build().entrySet(), allData.shouldPersist(), allData.getEnvironmentId()); } /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java index 3281088e..ab98fde5 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java @@ -131,6 +131,8 @@ public void close() { */ private static class ConvertingUpdateSink implements DataSourceUpdateSink { private final IterableAsyncQueue resultQueue; + // Only a full data set carries an environment ID; it is retained for subsequent partial updates. + private volatile String environmentId = null; public ConvertingUpdateSink(IterableAsyncQueue resultQueue) { this.resultQueue = resultQueue; @@ -138,13 +140,16 @@ public ConvertingUpdateSink(IterableAsyncQueue resultQueue) { @Override public boolean init(DataStoreTypes.FullDataSet allData) { + if (allData.getEnvironmentId() != null && !allData.getEnvironmentId().isEmpty()) { + environmentId = allData.getEnvironmentId(); + } // Convert the full data set into a ChangeSet and emit it ChangeSet>>> changeSet = new ChangeSet<>( ChangeSetType.Full, Selector.EMPTY, allData.getData(), - null, + environmentId, allData.shouldPersist() ); resultQueue.put(FDv2SourceResult.changeSet(changeSet, false)); @@ -166,7 +171,7 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { ChangeSetType.Partial, Selector.EMPTY, data, - null, + environmentId, true // default to true as this adapter is used for adapting FDv1 data sources which are always persistent ); resultQueue.put(FDv2SourceResult.changeSet(changeSet, false)); 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 5b8e5095..2f945f64 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 @@ -429,7 +429,8 @@ private boolean applyToLegacyStore(ChangeSet>>> unsortedChangeset) { // Convert ChangeSet to FullDataSet for legacy init path, preserving shouldPersist flag - return init(new FullDataSet<>(unsortedChangeset.getData(), unsortedChangeset.shouldPersist())); + return init(new FullDataSet<>(unsortedChangeset.getData(), unsortedChangeset.shouldPersist(), + unsortedChangeset.getEnvironmentId())); } private boolean applyPartialChangeSetToLegacyStore(ChangeSet>>> changeSet) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java index 7b5df387..6558cc45 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java @@ -55,6 +55,13 @@ interface DataSystem { * @return the data store status provider */ DataStoreStatusProvider getDataStoreStatusProvider(); + + /** + * Returns the ID of the LaunchDarkly environment the data came from, or null if it is not known. + * + * @return the environment ID, or null + */ + String getEnvironmentId(); } /** 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 6f01f596..abe46048 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 @@ -128,7 +128,8 @@ public FullDataSet getAllData(boolean returnDataEvenIfCached) JsonReader jr = new JsonReader(response.body().charStream()); // Polling data from LaunchDarkly should be persisted - return new FullDataSet<>(parseFullDataSet(jr), true); + return new FullDataSet<>(parseFullDataSet(jr), true, + response.header(HeaderConstants.ENVIRONMENT_ID.getHeaderName())); } } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluatorWithHooks.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluatorWithHooks.java index aada825e..8e366aae 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluatorWithHooks.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluatorWithHooks.java @@ -11,6 +11,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Supplier; /** * An {@link EvaluatorInterface} that will invoke the evaluation series methods of the provided {@link Hook} when @@ -21,13 +22,17 @@ class EvaluatorWithHooks implements EvaluatorInterface { private final EvaluatorInterface underlyingEvaluator; private final List hooks; private final LDLogger logger; + private final Supplier environmentIdSupplier; /** - * @param underlyingEvaluator that will do the actual flag evaluation - * @param hooks that will be invoked at various stages of the evaluation series - * @param hooksLogger that will be used to log + * @param underlyingEvaluator that will do the actual flag evaluation + * @param hooks that will be invoked at various stages of the evaluation series + * @param hooksLogger that will be used to log + * @param environmentIdSupplier provides the environment ID reported by LaunchDarkly, if known */ - EvaluatorWithHooks(EvaluatorInterface underlyingEvaluator, List hooks, LDLogger hooksLogger) { + EvaluatorWithHooks(EvaluatorInterface underlyingEvaluator, List hooks, LDLogger hooksLogger, + Supplier environmentIdSupplier) { + this.environmentIdSupplier = environmentIdSupplier; this.underlyingEvaluator = underlyingEvaluator; this.hooks = hooks; this.logger = hooksLogger; @@ -40,7 +45,8 @@ public EvalResultAndFlag evalAndFlag(String method, String featureKey, LDContext int size = hooks.size(); List seriesDataList = new ArrayList<>(size); - EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, featureKey, context, defaultValue); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, featureKey, context, defaultValue, + environmentIdSupplier.get()); Map emptyMap = Collections.emptyMap(); for (int i = 0; i < size; i++) { Hook currentHook = hooks.get(i); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java index ef1d340c..a03da864 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java @@ -155,6 +155,11 @@ public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; } + @Override + public String getEnvironmentId() { + return dataStore.getEnvironmentId(); + } + @Override public void close() throws IOException { if (disposed) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index 1fd870ba..5053625b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -226,6 +226,11 @@ public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; } + @Override + public String getEnvironmentId() { + return store.getEnvironmentId(); + } + @Override public void close() throws IOException { if (disposed) { 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 2e394990..97d52c95 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 @@ -33,10 +33,11 @@ class InMemoryDataStore implements DataStore, TransactionalDataStore, CacheExpor private final Object selectorLock = new Object(); private volatile Selector selector = Selector.EMPTY; private volatile boolean shouldPersist = false; + private volatile String environmentId = null; @Override public void init(FullDataSet allData) { - applyFullPayload(allData.getData(), null, Selector.EMPTY, allData.shouldPersist()); + applyFullPayload(allData.getData(), allData.getEnvironmentId(), Selector.EMPTY, allData.shouldPersist()); } @Override @@ -128,7 +129,8 @@ public void apply(ChangeSet>> data, - Selector selector, boolean shouldPersist) { + String environmentId, Selector selector, boolean shouldPersist) { synchronized (this.writeLock) { // Build the complete updated dictionary before assigning to Items for transactional update ImmutableMap.Builder> itemsBuilder = ImmutableMap.builder(); @@ -192,6 +205,7 @@ private void applyPartialData(Iterable exportAll() { } // Preserve the shouldPersist value that was set when data was provided to this store - return new FullDataSet<>(builder.build(), this.shouldPersist); + return new FullDataSet<>(builder.build(), this.shouldPersist, this.environmentId); } } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java index ba0e4c93..54bbd6df 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -233,8 +233,10 @@ public LDClient(String sdkKey, LDConfig config) { this.evaluator = evaluator; this.migrationEvaluator = new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger); } else { - this.evaluator = new EvaluatorWithHooks(evaluator, allHooks, this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); - this.migrationEvaluator = new EvaluatorWithHooks(new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger), allHooks, this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); + this.evaluator = new EvaluatorWithHooks(evaluator, allHooks, this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME), + this.dataSystem::getEnvironmentId); + this.migrationEvaluator = new EvaluatorWithHooks(new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger), allHooks, + this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME), this.dataSystem::getEnvironmentId); } // Create FlagTracker using the dataSystem's flag change notifier 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 a47bfba3..54d47d62 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 @@ -40,7 +40,7 @@ static FullDataSet toSerializedFormat( } // Preserve shouldPersist flag when converting formats - return new FullDataSet<>(builder.build(), inMemoryData.shouldPersist()); + return new FullDataSet<>(builder.build(), inMemoryData.shouldPersist(), inMemoryData.getEnvironmentId()); } /** 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 ef0e5e8f..0851cf6e 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 @@ -63,6 +63,7 @@ final class PersistentDataStoreWrapper implements DataStore, SettableCache, Disa // themselves remain alive until GC reclaims them; the LoadingCache loaders // are short-circuited because every touch site checks this flag first. private volatile boolean cacheDisabled; + private volatile String environmentId = null; PersistentDataStoreWrapper( final PersistentDataStore core, @@ -204,7 +205,8 @@ 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(), allData.shouldPersist())); + RuntimeException failure = initCore(new FullDataSet<>(allBuilder.build(), allData.shouldPersist(), + allData.getEnvironmentId())); if (itemCache != null && allCache != null && !cacheDisabled) { itemCache.invalidateAll(); allCache.invalidateAll(); @@ -226,6 +228,10 @@ public void init(FullDataSet allData) { } if (failure == null || cacheIndefinitely) { inited.set(true); + String newEnvironmentId = allData.getEnvironmentId(); + if (newEnvironmentId != null && !newEnvironmentId.isEmpty()) { + environmentId = newEnvironmentId; + } } if (failure != null) { throw failure; @@ -372,6 +378,11 @@ public CacheStats getCacheStats() { itemStats.evictionCount() + allStats.evictionCount()); } + @Override + public String getEnvironmentId() { + return environmentId; + } + private ItemDescriptor getAndDeserializeItem(DataKind kind, String key) { SerializedItemDescriptor maybeSerializedItem = core.get(kind, key); return maybeSerializedItem == null ? null : PersistentDataStoreConverter.deserialize(kind, maybeSerializedItem); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 773ae7e8..f4158d43 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -31,6 +31,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.SerializationException; @@ -269,7 +270,7 @@ private void handleMessage(MessageEvent event, CompletableFuture initFutur try { switch (event.getEventName()) { case PUT: - handlePut(event.getDataReader(), initFuture); + handlePut(event.getDataReader(), environmentIdOf(event), initFuture); break; case PATCH: @@ -331,12 +332,19 @@ private static boolean exceptionHasCause(Throwable e, Class c) { return e.getCause() != null && exceptionHasCause(e.getCause(), c); } - private void handlePut(Reader eventData, CompletableFuture initFuture) + private static String environmentIdOf(MessageEvent event) { + return event.getHeaders() == null ? null + : event.getHeaders().value(HeaderConstants.ENVIRONMENT_ID.getHeaderName()); + } + + private void handlePut(Reader eventData, String environmentId, CompletableFuture initFuture) throws StreamInputException, StreamStoreException { recordStreamInit(false); esStarted = 0; PutData putData = parseStreamJson(StreamProcessorEvents::parsePutData, eventData); - if (!dataSourceUpdates.init(putData.data)) { + FullDataSet data = new FullDataSet<>(putData.data.getData(), putData.data.shouldPersist(), + environmentId); + if (!dataSourceUpdates.init(data)) { throw new StreamStoreException(); } if (!initialized.getAndSet(true)) { 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 aa895228..4ef80ef3 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 @@ -131,6 +131,11 @@ public Selector getSelector() { return txMemoryStore.getSelector(); } + @Override + public String getEnvironmentId() { + return memoryStore.getEnvironmentId(); + } + @Override public void close() throws IOException { memoryStore.close(); @@ -182,7 +187,8 @@ private boolean applyToLegacyPersistence(ChangeSet>>> sortedChangeSet) { // Preserve shouldPersist flag when converting ChangeSet to FullDataSet - persistentStore.init(new FullDataSet<>(sortedChangeSet.getData(), sortedChangeSet.shouldPersist())); + persistentStore.init(new FullDataSet<>(sortedChangeSet.getData(), sortedChangeSet.shouldPersist(), + sortedChangeSet.getEnvironmentId())); } /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/EvaluationSeriesContext.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/EvaluationSeriesContext.java index 7961f28a..2e90d503 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/EvaluationSeriesContext.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/EvaluationSeriesContext.java @@ -32,6 +32,13 @@ public class EvaluationSeriesContext { */ public final LDValue defaultValue; + /** + * The ID of the LaunchDarkly environment the evaluated data came from, or null if it is not + * known. It is not known before the SDK has received a successful response from LaunchDarkly, + * or when the data came from a source other than LaunchDarkly. + */ + public final String environmentId; + /** * @param method the variation method that was used to invoke the evaluation. * @param key the key of the feature flag being evaluated. @@ -39,9 +46,22 @@ public class EvaluationSeriesContext { * @param defaultValue the user-provided default value for the evaluation. */ public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue) { + this(method, key, context, defaultValue, null); + } + + /** + * @param method the variation method that was used to invoke the evaluation. + * @param key the key of the feature flag being evaluated. + * @param context the context the evaluation was for. + * @param defaultValue the user-provided default value for the evaluation. + * @param environmentId the ID of the LaunchDarkly environment, or null if it is not known. + */ + public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue, + String environmentId) { this.flagKey = key; this.context = context; this.defaultValue = defaultValue; this.method = method; + this.environmentId = environmentId; } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStore.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStore.java index e2cd3632..83a707e6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStore.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataStore.java @@ -104,4 +104,16 @@ public interface DataStore extends Closeable { * @return a cache statistics object, or null if not applicable */ CacheStats getCacheStats(); + + /** + * Returns the ID of the LaunchDarkly environment that the currently stored data came from. + *

+ * This is the environment ID that was provided with the data, if any. The SDK makes it available + * to hook implementations. + * + * @return the environment ID, or null if it is unknown + */ + default String getEnvironmentId() { + return null; + } } 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 0667bf4e..0978a883 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 @@ -263,6 +263,7 @@ public String toString() { public static final class FullDataSet { private final Iterable>> data; private final boolean shouldPersist; + private final String environmentId; /** * Returns the wrapped data set. @@ -286,14 +287,22 @@ public boolean shouldPersist() { return shouldPersist; } + /** + * Returns the ID of the LaunchDarkly environment that this data came from, if known. + * + * @return the environment ID, or null if it was not reported with the data + */ + public String getEnvironmentId() { + return environmentId; + } + /** * Constructs a new instance. Will default to shouldPersist = true and can be stored in a persistent store. * * @param data the data set */ public FullDataSet(Iterable>> data) { - this.data = data == null ? ImmutableList.of(): data; - this.shouldPersist = true; // default to true if not specified for backwards compatibility + this(data, true); // default to true if not specified for backwards compatibility } /** @@ -303,22 +312,36 @@ public FullDataSet(Iterable>> data) * @param shouldPersist true if the data should be persisted to persistent stores, false otherwise */ public FullDataSet(Iterable>> data, boolean shouldPersist) { + this(data, shouldPersist, null); + } + + /** + * Constructs a new instance. + * + * @param data the data set + * @param shouldPersist true if the data should be persisted to persistent stores, false otherwise + * @param environmentId the ID of the LaunchDarkly environment the data came from, or null if unknown + */ + public FullDataSet(Iterable>> data, boolean shouldPersist, + String environmentId) { this.data = data == null ? ImmutableList.of(): data; this.shouldPersist = shouldPersist; + this.environmentId = environmentId; } @Override public boolean equals(Object o) { if (o instanceof FullDataSet) { FullDataSet other = (FullDataSet)o; - return data.equals(other.data) && shouldPersist == other.shouldPersist; + return data.equals(other.data) && shouldPersist == other.shouldPersist + && Objects.equals(environmentId, other.environmentId); } return false; } @Override public int hashCode() { - return Objects.hash(data, shouldPersist); + return Objects.hash(data, shouldPersist, environmentId); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapterTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapterTest.java index 7d88f621..d7e47173 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapterTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapterTest.java @@ -4,7 +4,9 @@ import com.launchdarkly.sdk.fdv2.SourceSignal; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.subsystems.DataSource; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import org.junit.After; import org.junit.Test; @@ -17,6 +19,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.*; @@ -295,4 +298,30 @@ public boolean isDone() { return delegate.isDone(); } } + + /** + * Test that an environment ID reported through the FDv1 update sink is carried on the + * change sets produced for the FDv2 data system. + */ + @Test + public void environmentIdIsForwardedToChangeSets() throws Exception { + AtomicReference capturedSink = new AtomicReference<>(); + + DataSourceSynchronizerAdapter adapter = new DataSourceSynchronizerAdapter(sink -> { + capturedSink.set(sink); + return new MockDataSource(new CountDownLatch(1), null); + }); + resourcesToClose.add(adapter); + + CompletableFuture nextFuture = adapter.next(); + + capturedSink.get().init(new FullDataSet<>(DataBuilder.forStandardTypes().build().getData(), true, + "env-from-fdv1")); + + FDv2SourceResult result = nextFuture.get(2, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertEquals("env-from-fdv1", result.getChangeSet().getEnvironmentId()); + + adapter.close(); + } } 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 5df085f7..1b448afe 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 @@ -57,6 +57,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") public class DataSourceUpdatesImplTest { @@ -163,10 +164,14 @@ private static ChangeSet private static class LegacyDataStore implements DataStore { private final Map> data = new HashMap<>(); + private String environmentId; @Override public void init(FullDataSet allData) { data.clear(); + if (allData.getEnvironmentId() != null) { + environmentId = allData.getEnvironmentId(); + } for (Map.Entry> kindEntry : allData.getData()) { DataKind kind = kindEntry.getKey(); Map items = new HashMap<>(); @@ -177,6 +182,11 @@ public void init(FullDataSet allData) { } } + @Override + public String getEnvironmentId() { + return environmentId; + } + @Override public boolean upsert(DataKind kind, String key, ItemDescriptor item) { Map items = data.get(kind); @@ -1034,9 +1044,12 @@ public void applyFullChangeSetToLegacyStoreWithEnvironmentId() throws Exception ); updates.apply(changeSet); - // Note: Java SDK doesn't have InitMetadata/EnvironmentId support in the same way as C#, - // so this test just verifies the changeset is applied without error ItemDescriptor retrievedFlag1 = legacyStore.get(FEATURES, flag1.getKey()); assertThat(retrievedFlag1, is(org.hamcrest.Matchers.notNullValue())); + assertEquals("test-env-id", legacyStore.getEnvironmentId()); + + // A later change set without an environment ID does not clear the retained value. + updates.apply(new ChangeSet<>(ChangeSetType.Full, Selector.make(2, "state2"), data, null, true)); + assertEquals("test-env-id", legacyStore.getEnvironmentId()); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorWithHookTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorWithHookTest.java index e88625f5..69d90c34 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorWithHookTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorWithHookTest.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.server.integrations.Hook; import com.launchdarkly.sdk.server.integrations.HookMetadata; import org.junit.Test; @@ -47,7 +48,7 @@ public void beforeIsExecutedBeforeAfter() { assertTrue(beforeCalled.get()); return Collections.emptyMap(); }); - EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none()); + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none(), () -> null); evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); } @@ -61,7 +62,7 @@ public void evaluationResultIsPassedToAfter() { when(mockHook.beforeEvaluation(any(), any())).thenReturn(Collections.emptyMap()); when(mockHook.afterEvaluation(any(), any(), any())).thenReturn(Collections.emptyMap()); - EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none()); + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none(), () -> null); evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); verify(mockHook).afterEvaluation(any(), any(), eq(EvaluationDetail.fromValue(LDValue.of("aValue"), 0, EvaluationReason.fallthrough()))); @@ -95,7 +96,7 @@ public void afterExecutesInReverseOrder() { return Collections.emptyMap(); }); - EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Arrays.asList(mockHookA, mockHookB), LDLogger.none()); + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Arrays.asList(mockHookA, mockHookB), LDLogger.none(), () -> null); evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); assertEquals(calls, Arrays.asList("hookABefore", "hookBBefore", "hookBAfter", "hookAAfter")); } @@ -110,7 +111,7 @@ public void beforeIsGivenEmptySeriesData() { when(mockHook.beforeEvaluation(any(), any())).thenReturn(Collections.emptyMap()); when(mockHook.afterEvaluation(any(), any(), eq(evalResult.getResult().getAnyType()))).thenReturn(Collections.emptyMap()); - EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none()); + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none(), () -> null); evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); verify(mockHook).beforeEvaluation(any(), eq(Collections.emptyMap())); @@ -128,7 +129,7 @@ public void seriesDataFromBeforeIsPassedToAfter() { when(mockHook.beforeEvaluation(any(), any())).thenReturn(mockData); when(mockHook.afterEvaluation(any(), any(), any())).thenReturn(Collections.emptyMap()); - EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none()); + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none(), () -> null); evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); verify(mockHook).afterEvaluation(any(), eq(mockData), any()); @@ -147,7 +148,7 @@ public void beforeThrowingErrorLeadsToEmptySeriesDataPassedToAfter() { when(mockHook.getMetadata()).thenReturn(new HookMetadata("mockHookName") {}); when(mockHook.afterEvaluation(any(), any(), any())).thenReturn(Collections.emptyMap()); - EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none()); + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), LDLogger.none(), () -> null); evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); verify(mockHook, times(1)).getMetadata(); @@ -186,11 +187,34 @@ public void oneHookThrowingErrorDoesNotAffectOtherHooks() { return Collections.emptyMap(); }); - EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Arrays.asList(mockHookA, mockHookB), LDLogger.none()); + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Arrays.asList(mockHookA, mockHookB), LDLogger.none(), () -> null); evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); assertEquals(calls, Arrays.asList("hookABefore", "hookBBefore", "hookBAfter", "hookAAfter")); verify(mockHookA).afterEvaluation(any(), eq(Collections.emptyMap()), any()); verify(mockHookB).afterEvaluation(any(), eq(mockData), any()); } + @Test + public void environmentIdIsProvidedToSeriesContext() { + EvalResultAndFlag evalResult = new EvalResultAndFlag(EvalResult.of(LDValue.of("aValue"), 0, EvaluationReason.fallthrough()), null); + EvaluatorInterface mockEvaluator = mock(EvaluatorInterface.class); + when(mockEvaluator.evalAndFlag(any(), any(), any(), any(), any(), any())).thenReturn(evalResult); + + Hook mockHook = mock(Hook.class); + List environmentIds = new ArrayList<>(); + when(mockHook.beforeEvaluation(any(), any())).thenAnswer((Answer>) invocation -> { + environmentIds.add(((EvaluationSeriesContext)invocation.getArgument(0)).environmentId); + return Collections.emptyMap(); + }); + when(mockHook.afterEvaluation(any(), any(), any())).thenAnswer((Answer>) invocation -> { + environmentIds.add(((EvaluationSeriesContext)invocation.getArgument(0)).environmentId); + return Collections.emptyMap(); + }); + + EvaluatorWithHooks evaluatorUnderTest = new EvaluatorWithHooks(mockEvaluator, Collections.singletonList(mockHook), + LDLogger.none(), () -> "the-environment-id"); + evaluatorUnderTest.evalAndFlag("aMethod", "aKey", LDContext.create("aKey"), LDValue.of("aDefault"), LDValueType.STRING, EvaluationOptions.NO_EVENTS); + + assertEquals(Arrays.asList("the-environment-id", "the-environment-id"), environmentIds); + } } 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 68b9dd95..17aa5ae0 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 @@ -104,6 +104,24 @@ public void applyWithFullChangeSetReplacesAllData() { assertEquals(item3, result.getItem()); } + @Test + public void environmentIdIsRetainedFromDataAndNotClearedByLaterData() { + assertNull(typedStore().getEnvironmentId()); + + typedStore().init(new FullDataSet<>(ImmutableList.of(), true, "env-id")); + assertEquals("env-id", typedStore().getEnvironmentId()); + + typedStore().init(new FullDataSet<>(ImmutableList.of(), true, null)); + assertEquals("env-id", typedStore().getEnvironmentId()); + + typedStore().apply(new ChangeSet<>(ChangeSetType.Full, Selector.make(1, "state1"), ImmutableList.of(), "", true)); + assertEquals("env-id", typedStore().getEnvironmentId()); + + typedStore().apply(new ChangeSet<>(ChangeSetType.Full, Selector.make(2, "state2"), ImmutableList.of(), + "other-env-id", true)); + assertEquals("other-env-id", typedStore().getEnvironmentId()); + } + @Test public void applyWithFullChangeSetSetsSelector() { Selector selector = Selector.make(42, "test-state"); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 5d73e2ae..2989775b 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -50,6 +50,7 @@ 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.assertSame; import static org.junit.Assert.assertTrue; @@ -60,11 +61,12 @@ public class PollingProcessorTest extends BaseTest { private static final Duration BRIEF_INTERVAL = Duration.ofMillis(20); private MockDataSourceUpdates dataSourceUpdates; + private InMemoryDataStore dataStore; @Before public void setup() { - DataStore store = new InMemoryDataStore(); - dataSourceUpdates = TestComponents.dataSourceUpdates(store, new MockDataStoreStatusProvider()); + dataStore = new InMemoryDataStore(); + dataSourceUpdates = TestComponents.dataSourceUpdates(dataStore, new MockDataStoreStatusProvider()); } private PollingProcessor makeProcessor(URI baseUri, Duration pollInterval) { @@ -410,4 +412,58 @@ private void withStatusQueue(ActionCanThrowAnyException