diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index a7d1c874..70475e23 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -159,38 +159,39 @@ private void runInitializers() { Initializer initializer = sourceManager.getNextInitializerAndSetActive(); while(initializer != null) { try { - FDv2SourceResult result = initializer.run().get(); - switch (result.getResultType()) { - case CHANGE_SET: - dataSourceUpdates.apply(result.getChangeSet()); - anyDataReceived = true; - if (!result.getChangeSet().getSelector().isEmpty()) { - // We received data with a selector, so we end the initialization process. - dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - startFuture.complete(true); - return; - } - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - switch(status.getState()) { - case INTERRUPTED: - case TERMINAL_ERROR: - // The data source updates handler will filter the state during initializing, but this - // will make the error information available. - dataSourceUpdates.updateStatus( - // While the error was terminal to the individual initializer, it isn't terminal - // to the data source as a whole. - DataSourceStatusProvider.State.INTERRUPTED, - status.getErrorInfo()); - break; - case SHUTDOWN: - case GOODBYE: - // We don't need to inform anyone of these statuses. - logger.debug("Ignoring status {} from initializer", result.getStatus().getState()); - break; - } - break; + try(FDv2SourceResult result = initializer.run().get()) { + switch (result.getResultType()) { + case CHANGE_SET: + dataSourceUpdates.apply(result.getChangeSet()); + anyDataReceived = true; + if (!result.getChangeSet().getSelector().isEmpty()) { + // We received data with a selector, so we end the initialization process. + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + startFuture.complete(true); + return; + } + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + switch (status.getState()) { + case INTERRUPTED: + case TERMINAL_ERROR: + // The data source updates handler will filter the state during initializing, but this + // will make the error information available. + dataSourceUpdates.updateStatus( + // While the error was terminal to the individual initializer, it isn't terminal + // to the data source as a whole. + DataSourceStatusProvider.State.INTERRUPTED, + status.getErrorInfo()); + break; + case SHUTDOWN: + case GOODBYE: + // We don't need to inform anyone of these statuses. + logger.debug("Ignoring status {} from initializer", result.getStatus().getState()); + break; + } + break; + } } } catch (ExecutionException | InterruptedException | CancellationException e) { // We don't expect these conditions to happen in practice. @@ -205,7 +206,7 @@ private void runInitializers() { new Date().toInstant())); logger.warn("Error running initializer: {}", e.toString()); } - initializer = sourceManager.getNextInitializerAndSetActive(); + initializer = sourceManager.getNextInitializerAndSetActive(); } // We received data without a selector, and we have exhausted initializers, so we are going to // consider ourselves initialized. @@ -286,55 +287,56 @@ private void runSynchronizers() { continue; } - FDv2SourceResult result = (FDv2SourceResult) res; - conditions.inform(result); + try (FDv2SourceResult result = (FDv2SourceResult) res) { + conditions.inform(result); - switch (result.getResultType()) { - case CHANGE_SET: - dataSourceUpdates.apply(result.getChangeSet()); - dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - // This could have been completed by any data source. But if it has not been completed before - // now, then we complete it. - startFuture.complete(true); - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - switch (status.getState()) { - case INTERRUPTED: - // Handled by conditions. - dataSourceUpdates.updateStatus( - DataSourceStatusProvider.State.INTERRUPTED, - status.getErrorInfo()); - break; - case SHUTDOWN: - // We should be overall shutting down. - logger.debug("Synchronizer shutdown."); - return; - case TERMINAL_ERROR: - sourceManager.blockCurrentSynchronizer(); - running = false; - dataSourceUpdates.updateStatus( - DataSourceStatusProvider.State.INTERRUPTED, - status.getErrorInfo()); - break; - case GOODBYE: - // We let the synchronizer handle this internally. - break; - } - break; - } - // We have been requested to fall back to FDv1. We handle whatever message was associated, - // close the synchronizer, and then fallback. - // Only trigger fallback if we're not already running the FDv1 fallback synchronizer. - if ( - result.isFdv1Fallback() && - sourceManager.hasFDv1Fallback() && - // This shouldn't happen in practice, an FDv1 source shouldn't request fallback - // to FDv1. But if it does, then we will discard its request. - !sourceManager.isCurrentSynchronizerFDv1Fallback() - ) { - sourceManager.fdv1Fallback(); - running = false; + switch (result.getResultType()) { + case CHANGE_SET: + dataSourceUpdates.apply(result.getChangeSet()); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + // This could have been completed by any data source. But if it has not been completed before + // now, then we complete it. + startFuture.complete(true); + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + switch (status.getState()) { + case INTERRUPTED: + // Handled by conditions. + dataSourceUpdates.updateStatus( + DataSourceStatusProvider.State.INTERRUPTED, + status.getErrorInfo()); + break; + case SHUTDOWN: + // We should be overall shutting down. + logger.debug("Synchronizer shutdown."); + return; + case TERMINAL_ERROR: + sourceManager.blockCurrentSynchronizer(); + running = false; + dataSourceUpdates.updateStatus( + DataSourceStatusProvider.State.INTERRUPTED, + status.getErrorInfo()); + break; + case GOODBYE: + // We let the synchronizer handle this internally. + break; + } + break; + } + // We have been requested to fall back to FDv1. We handle whatever message was associated, + // close the synchronizer, and then fallback. + // Only trigger fallback if we're not already running the FDv1 fallback synchronizer. + if ( + result.isFdv1Fallback() && + sourceManager.hasFDv1Fallback() && + // This shouldn't happen in practice, an FDv1 source shouldn't request fallback + // to FDv1. But if it does, then we will discard its request. + !sourceManager.isCurrentSynchronizerFDv1Fallback() + ) { + sourceManager.fdv1Fallback(); + running = false; + } } } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java index 6f440c63..acd5e121 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java @@ -1,14 +1,19 @@ package com.launchdarkly.sdk.server.datasources; + import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes; +import java.io.Closeable; +import java.util.function.Function; + /** * This type is currently experimental and not subject to semantic versioning. *

* The result type for FDv2 initializers and synchronizers. An FDv2 initializer produces a single result, while * an FDv2 synchronizer produces a stream of results. */ -public class FDv2SourceResult { +public class FDv2SourceResult implements Closeable { + public enum State { /** * The data source has encountered an interruption and will attempt to reconnect. This isn't intended to be used @@ -67,49 +72,86 @@ public Status(State state, DataSourceStatusProvider.ErrorInfo errorInfo) { private final Status status; private final ResultType resultType; - + private final boolean fdv1Fallback; - private FDv2SourceResult(DataStoreTypes.ChangeSet changeSet, Status status, ResultType resultType, boolean fdv1Fallback) { + private final Function completionCallback; + + private FDv2SourceResult( + DataStoreTypes.ChangeSet changeSet, + Status status, ResultType resultType, + boolean fdv1Fallback, + Function completionCallback + ) { this.changeSet = changeSet; this.status = status; this.resultType = resultType; this.fdv1Fallback = fdv1Fallback; + this.completionCallback = completionCallback; } public static FDv2SourceResult interrupted(DataSourceStatusProvider.ErrorInfo errorInfo, boolean fdv1Fallback) { + return interrupted(errorInfo, fdv1Fallback, null); + } + + public static FDv2SourceResult interrupted(DataSourceStatusProvider.ErrorInfo errorInfo, boolean fdv1Fallback, Function completionCallback) { return new FDv2SourceResult( - null, - new Status(State.INTERRUPTED, errorInfo), - ResultType.STATUS, - fdv1Fallback); + null, + new Status(State.INTERRUPTED, errorInfo), + ResultType.STATUS, + fdv1Fallback, + completionCallback); } public static FDv2SourceResult shutdown() { + return shutdown(null); + } + + public static FDv2SourceResult shutdown(Function completionCallback) { return new FDv2SourceResult(null, - new Status(State.SHUTDOWN, null), - ResultType.STATUS, - false); + new Status(State.SHUTDOWN, null), + ResultType.STATUS, + false, + completionCallback); } public static FDv2SourceResult terminalError(DataSourceStatusProvider.ErrorInfo errorInfo, boolean fdv1Fallback) { + return terminalError(errorInfo, fdv1Fallback, null); + } + + public static FDv2SourceResult terminalError(DataSourceStatusProvider.ErrorInfo errorInfo, boolean fdv1Fallback, Function completionCallback) { return new FDv2SourceResult(null, - new Status(State.TERMINAL_ERROR, errorInfo), - ResultType.STATUS, - fdv1Fallback); + new Status(State.TERMINAL_ERROR, errorInfo), + ResultType.STATUS, + fdv1Fallback, + completionCallback); } public static FDv2SourceResult changeSet(DataStoreTypes.ChangeSet changeSet, boolean fdv1Fallback) { - return new FDv2SourceResult(changeSet, null, ResultType.CHANGE_SET, fdv1Fallback); + return changeSet(changeSet, fdv1Fallback, null); + } + + public static FDv2SourceResult changeSet(DataStoreTypes.ChangeSet changeSet, boolean fdv1Fallback, Function completionCallback) { + return new FDv2SourceResult( + changeSet, + null, + ResultType.CHANGE_SET, + fdv1Fallback, + completionCallback); } public static FDv2SourceResult goodbye(String reason, boolean fdv1Fallback) { + return goodbye(reason, fdv1Fallback, null); + } + + public static FDv2SourceResult goodbye(String reason, boolean fdv1Fallback, Function completionCallback) { // TODO: Goodbye reason. return new FDv2SourceResult( - null, - new Status(State.GOODBYE, null), - ResultType.STATUS, - fdv1Fallback); + null, + new Status(State.GOODBYE, null), + ResultType.STATUS, + fdv1Fallback, + completionCallback); } public ResultType getResultType() { @@ -127,4 +169,31 @@ public DataStoreTypes.ChangeSet getChangeSet() { public boolean isFdv1Fallback() { return fdv1Fallback; } + + /** + * Creates a new result wrapping this one with an additional completion callback. + *

+ * The new completion callback will be invoked when the result is closed, followed by + * the original completion callback (if any). + * + * @param newCallback the completion callback to add + * @return a new FDv2SourceResult with the added completion callback + */ + public FDv2SourceResult withCompletion(Function newCallback) { + Function combinedCallback = v -> { + newCallback.apply(null); + if (completionCallback != null) { + completionCallback.apply(null); + } + return null; + }; + return new FDv2SourceResult(changeSet, status, resultType, fdv1Fallback, combinedCallback); + } + + @Override + public void close() { + if(completionCallback != null) { + completionCallback.apply(null); + } + } } 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 cc24d1ae..834e2926 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 @@ -249,8 +249,8 @@ private void closedInstance(DataSourceImpl instance) { } /** - * A builder for feature flag configurations to be used with {@link TestData}. - * + * A builder for feature flag configurations to be used with {@link TestData} and {@link TestDataV2}. + * * @see TestData#flag(String) * @see TestData#update(FlagBuilder) */ @@ -269,18 +269,20 @@ public static final class FlagBuilder { final Map>> targets = new TreeMap<>(); // TreeMap enforces ordering for test determinacy final List rules = new ArrayList<>(); - private FlagBuilder(String key) { + FlagBuilder(String key) { this.key = key; this.on = true; this.variations = new CopyOnWriteArrayList<>(); } - private FlagBuilder(FlagBuilder from) { + FlagBuilder(FlagBuilder from) { this.key = from.key; this.offVariation = from.offVariation; this.on = from.on; this.fallthroughVariation = from.fallthroughVariation; this.variations = new CopyOnWriteArrayList<>(from.variations); + this.samplingRatio = from.samplingRatio; + this.migrationCheckRatio = from.migrationCheckRatio; for (ContextKind contextKind: from.targets.keySet()) { this.targets.put(contextKind, new TreeMap<>(from.targets.get(contextKind))); } @@ -811,6 +813,8 @@ private static int variationForBoolean(boolean value) { * {@link #thenReturn(int)} to finish defining the rule. */ public final class FlagRuleBuilder { + // TODO: Move FlagRuleBuilder to TestDataV2 when TestData is deprecated + final List clauses = new ArrayList<>(); int variation; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java new file mode 100644 index 00000000..21af15fc --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java @@ -0,0 +1,323 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import java.time.Instant; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A mechanism for providing dynamically updatable feature flag state as a {@link Synchronizer} + * for use with the FDv2 data system in test scenarios. + *

+ * Unlike {@link FileData}, this mechanism does not use any external resources. It provides only + * the data that the application has put into it using the {@link #update(TestData.FlagBuilder)} method. + * Use {@link TestData} when you need a legacy {@link com.launchdarkly.sdk.server.subsystems.DataSource}; + * use {@code TestDataV2} when configuring the client with {@link DataSystemBuilder#synchronizers(DataSourceBuilder[])}. + *

+ *


+ *     TestDataV2 td = TestDataV2.synchronizer();
+ *     td.update(td.flag("flag-key-1").booleanFlag().variationForAllUsers(true));
+ *
+ *     LDConfig config = new LDConfig.Builder()
+ *         .dataSystem(new DataSystemBuilder().synchronizers(td))
+ *         .build();
+ *     LDClient client = new LDClient(sdkKey, config);
+ *
+ *     td.update(td.flag("flag-key-2")
+ *         .variationForUser("some-user-key", true)
+ *         .fallthroughVariation(false));
+ * 
+ *

+ * The above example uses a simple boolean flag. More complex configurations are possible using + * the methods of the {@link TestData.FlagBuilder} returned by {@link #flag(String)}. {@link TestData.FlagBuilder} + * supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not + * currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts. + *

+ * If the same {@code TestDataV2} instance is used to configure multiple clients, any changes + * made via {@link #update(TestData.FlagBuilder)}, {@link #delete(String)}, and + * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} + * propagate to all configured synchronizers. + * + * @since 7.11.0 + */ +public final class TestDataV2 implements DataSourceBuilder { + private final Object lock = new Object(); + private final Map currentFlags = new HashMap<>(); + private final Map currentBuilders = new HashMap<>(); + private final List synchronizerInstances = new CopyOnWriteArrayList<>(); + private volatile boolean shouldPersist = true; // defaulting to true since this is more likely to be used for testing + + /** + * Creates a new instance of the test synchronizer. + *

+ * See {@link TestDataV2} for details. + * + * @return a new configurable test synchronizer + */ + public static TestDataV2 synchronizer() { + return new TestDataV2(); + } + + private TestDataV2() {} + + /** + * Creates or copies a {@link TestData.FlagBuilder} for building a test flag configuration. + *

+ * If this flag key has already been defined in this {@code TestDataV2} instance, then the builder + * starts with the same configuration that was last provided for this flag. + *

+ * Otherwise, it starts with a new default configuration in which the flag has {@code true} and + * {@code false} variations, is {@code true} for all users when targeting is turned on and + * {@code false} otherwise, and currently has targeting turned on. You can change any of those + * properties, and provide more complex behavior, using the {@link TestData.FlagBuilder} methods. + *

+ * Once you have set the desired configuration, pass the builder to {@link #update(TestData.FlagBuilder)}. + * + * @param key the flag key + * @return a flag configuration builder + * @see #update(TestData.FlagBuilder) + */ + public TestData.FlagBuilder flag(String key) { + TestData.FlagBuilder existingBuilder; + synchronized (lock) { + existingBuilder = currentBuilders.get(key); + } + if (existingBuilder != null) { + return new TestData.FlagBuilder(existingBuilder); + } + return new TestData.FlagBuilder(key).booleanFlag(); + } + + /** + * Deletes a specific flag from the test data by create a versioned tombstone. + *

+ * This has the same effect as if a flag were removed on the LaunchDarkly dashboard. + * It immediately propagates the flag change to any {@code LDClient} instance(s) that you have + * already configured to use this {@code TestDataV2}. If no {@code LDClient} has been started yet, + * it simply adds tombstone to the test data which will be provided to any {@code LDClient} that + * you subsequently configure. + * + * @param key the flag key + * @return a flag configuration builder + */ + public TestDataV2 delete(String key) { + final ItemDescriptor tombstoneItem; + synchronized (lock) { + final ItemDescriptor oldItem = currentFlags.get(key); + final int oldVersion = oldItem == null ? 0 : oldItem.getVersion(); + tombstoneItem = ItemDescriptor.deletedItem(oldVersion + 1); + currentFlags.put(key, tombstoneItem); + currentBuilders.remove(key); + } + + pushToSynchronizers(FDv2SourceResult.changeSet(makePartialChangeSet(key, tombstoneItem), false)); + + return this; + } + + /** + * Updates the test data with the specified flag configuration. + *

+ * This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. + * It immediately propagates the flag change to any {@code LDClient} instance(s) that you have + * already configured to use this {@code TestDataV2}. If no {@code LDClient} has been started yet, + * it simply adds this flag to the test data which will be provided to any {@code LDClient} that + * you subsequently configure. + *

+ * Any subsequent changes to this {@link TestData.FlagBuilder} instance do not affect the test data, + * unless you call {@link #update(TestData.FlagBuilder)} again. + * + * @param flagBuilder a flag configuration builder + * @return the same {@code TestDataV2} instance + * @see #flag(String) + */ + public TestDataV2 update(TestData.FlagBuilder flagBuilder) { + String key = flagBuilder.key; + TestData.FlagBuilder clonedBuilder = new TestData.FlagBuilder(flagBuilder); + ItemDescriptor newItem = null; + + synchronized (lock) { + ItemDescriptor oldItem = currentFlags.get(key); + int oldVersion = oldItem == null ? 0 : oldItem.getVersion(); + newItem = flagBuilder.createFlag(oldVersion + 1); + currentFlags.put(key, newItem); + currentBuilders.put(key, clonedBuilder); + } + + pushToSynchronizers(FDv2SourceResult.changeSet(makePartialChangeSet(key, newItem), false)); + + return this; + } + + /** + * Simulates a change in the synchronizer status. + *

+ * Use this if you want to test the behavior of application code that uses + * {@link com.launchdarkly.sdk.server.LDClient#getDataSourceStatusProvider()} to track whether the + * synchronizer is having problems (for example, a network failure interrupting the streaming connection). It + * does not actually stop the {@code TestDataV2} synchronizer from working, so even if you have simulated + * an outage, calling {@link #update(TestData.FlagBuilder)} will still send updates. + * + * @param newState one of the constants defined by {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State} + * @param newError an {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo} instance, + * or null + * @return the same {@code TestDataV2} instance + */ + public TestDataV2 updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError) { + FDv2SourceResult statusResult; + switch (newState) { + case OFF: + statusResult = newError != null + ? FDv2SourceResult.terminalError(newError, false) + : FDv2SourceResult.shutdown(); + break; + case INTERRUPTED: + statusResult = FDv2SourceResult.interrupted( + newError != null ? newError : new DataSourceStatusProvider.ErrorInfo(DataSourceStatusProvider.ErrorKind.UNKNOWN, 0, null, Instant.now()), + false); + break; + case VALID: + case INITIALIZING: + default: + // VALID and INITIALIZING do not map to FDv2 status events (same as DataSourceSynchronizerAdapter) + statusResult = null; + break; + } + if (statusResult != null) { + pushToSynchronizers(statusResult); + } + 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: + *


+   *     TestDataV2 td = TestDataV2.synchronizer()
+   *         .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 TestDataV2} instance + */ + public TestDataV2 shouldPersist(boolean shouldPersist) { + this.shouldPersist = shouldPersist; + return this; + } + + @Override + public Synchronizer build(DataSourceBuildInputs context) { + TestDataV2SynchronizerImpl synchronizer = new TestDataV2SynchronizerImpl(); + synchronized (lock) { + synchronizerInstances.add(synchronizer); + } + return synchronizer; + } + + private void pushToSynchronizers(FDv2SourceResult result) { + for (TestDataV2SynchronizerImpl sync : synchronizerInstances) { + CompletableFuture completion = new CompletableFuture<>(); + FDv2SourceResult wrappedResult = result.withCompletion(v -> { + completion.complete(null); + return null; + }); + sync.put(wrappedResult, completion); + } + } + + private ChangeSet makeFullChangeSet() { + ImmutableMap copiedData; + synchronized (lock) { + copiedData = ImmutableMap.copyOf(currentFlags); + } + Iterable> entries = copiedData.entrySet(); + return new ChangeSet<>( + ChangeSetType.Full, + Selector.EMPTY, + Collections.singletonList( + new AbstractMap.SimpleEntry<>(DataModel.FEATURES, new KeyedItems<>(entries))), + null, + shouldPersist); + } + + private ChangeSet makePartialChangeSet(String key, ItemDescriptor item) { + return new ChangeSet<>( + ChangeSetType.Partial, + Selector.EMPTY, + Collections.singletonList( + new AbstractMap.SimpleEntry<>(DataModel.FEATURES, new KeyedItems<>(Collections.singletonList(new AbstractMap.SimpleEntry<>(key, item))))), + null, + shouldPersist); + } + + private void closedSynchronizerInstance(TestDataV2SynchronizerImpl synchronizer) { + synchronized (lock) { + synchronizerInstances.remove(synchronizer); + } + } + + /** + * Synchronizer implementation that queues initial and incremental change sets from TestDataV2. + */ + private final class TestDataV2SynchronizerImpl implements Synchronizer { + private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + + private final AtomicBoolean initialSent = new AtomicBoolean(false); + + void put(FDv2SourceResult result, CompletableFuture completion) { + resultQueue.put(result); + try { + CompletableFuture.anyOf(completion, shutdownFuture).get(); + } catch (Exception e) { + // Completion interrupted or canceled + } + } + + @Override + public CompletableFuture next() { + if (!initialSent.getAndSet(true)) { + // Send full changeset first, before any partial changesets that + // accumulated from update()/delete() calls made before next() was first called. + resultQueue.put(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); + } + return CompletableFuture.anyOf(shutdownFuture, resultQueue.take()) + .thenApply(r -> (FDv2SourceResult) r); + } + + @Override + public void close() { + shutdownFuture.complete(FDv2SourceResult.shutdown()); + closedSynchronizerInstance(this); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java index e8f207de..58fbc96b 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -95,6 +95,8 @@ public void initializesWithFlags() throws Exception { ItemDescriptor flag2 = flags.get("flag2"); assertThat(flag1, not(nullValue())); assertThat(flag2, not(nullValue())); + assertThat(flag1.getVersion(), equalTo(1)); + assertThat(flag2.getVersion(), equalTo(1)); assertJsonEquals(flagJson(expectedFlag1, 1), flagJson(flag1)); assertJsonEquals(flagJson(expectedFlag2, 1), flagJson(flag2)); @@ -119,8 +121,8 @@ public void addsFlag() throws Exception { assertThat(up.kind, is(DataModel.FEATURES)); assertThat(up.key, equalTo("flag1")); ItemDescriptor flag1 = up.item; - - assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); + assertThat(flag1.getVersion(), equalTo(1)); + assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag1)); } @Test @@ -150,7 +152,7 @@ public void updatesFlag() throws Exception { assertThat(up.kind, is(DataModel.FEATURES)); assertThat(up.key, equalTo("flag1")); ItemDescriptor flag1 = up.item; - + assertThat(flag1.getVersion(), equalTo(2)); expectedFlag.on(true).version(2); assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); } @@ -442,6 +444,7 @@ private void verifyFlag( assertThat(updates.upserts.size(), equalTo(1)); UpsertParams up = updates.upserts.take(); ItemDescriptor flag = up.item; + assertThat(flag.getVersion(), equalTo(1)); assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag)); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2Test.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2Test.java new file mode 100644 index 00000000..cd1dd13c --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2Test.java @@ -0,0 +1,375 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.ContextKind; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.ModelBuilders; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; +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.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import org.junit.Test; + +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import static com.google.common.collect.Iterables.get; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.nullLogger; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.iterableWithSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +@SuppressWarnings("javadoc") +public class TestDataV2Test { + private static final LDValue[] THREE_STRING_VALUES = + new LDValue[] { LDValue.of("red"), LDValue.of("green"), LDValue.of("blue") }; + + private final CapturingDataSourceUpdates updates = new CapturingDataSourceUpdates(); + + /** + * Helper class that consumes FDv2SourceResult objects in a background thread. + * This is necessary because update() and delete() block until results are closed, + * so we can't call sync.next() on the same thread. + */ + private static class ResultConsumer { + private final BlockingQueue results = new LinkedBlockingQueue<>(); + private final Thread consumerThread; + private volatile boolean stopped = false; + + ResultConsumer(Synchronizer sync) { + consumerThread = new Thread(() -> { + while (!stopped) { + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + result.close(); // Close immediately to unblock put() + results.put(result); + } catch (Exception e) { + break; + } + } + }); + consumerThread.start(); + } + + FDv2SourceResult next() throws Exception { + return results.poll(5, TimeUnit.SECONDS); + } + + void stop() { + stopped = true; + consumerThread.interrupt(); + } + } + + private DataSourceBuildInputs dataSourceBuildInputs() { + ClientContext context = clientContext("", new LDConfig.Builder().build(), updates); + SelectorSource selectorSource = () -> Selector.EMPTY; + return new DataSourceBuildInputs( + nullLogger, + 0, + updates, + context.getServiceEndpoints(), + context.getHttp(), + sharedExecutor, + null, + selectorSource); + } + + @Test + public void initializesWithEmptyData() throws Exception { + TestDataV2 td = TestDataV2.synchronizer(); + Synchronizer sync = td.build(dataSourceBuildInputs()); + ResultConsumer consumer = new ResultConsumer(sync); + + FDv2SourceResult result = consumer.next(); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + ChangeSet changeSet = result.getChangeSet(); + assertThat(changeSet, notNullValue()); + assertThat(changeSet.getType(), equalTo(ChangeSetType.Full)); + assertThat(changeSet.getData(), iterableWithSize(1)); + assertThat(get(changeSet.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(changeSet.getData(), 0).getValue().getItems(), emptyIterable()); + + consumer.stop(); + } + + @Test + public void initializesWithFlags() throws Exception { + TestDataV2 td = TestDataV2.synchronizer(); + td.update(td.flag("flag1").on(true)) + .update(td.flag("flag2").on(false)); + + Synchronizer sync = td.build(dataSourceBuildInputs()); + ResultConsumer consumer = new ResultConsumer(sync); + + FDv2SourceResult result = consumer.next(); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + ChangeSet changeSet = result.getChangeSet(); + assertThat(changeSet.getType(), equalTo(ChangeSetType.Full)); + assertThat(changeSet.getData(), iterableWithSize(1)); + assertThat(get(changeSet.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(changeSet.getData(), 0).getValue().getItems(), iterableWithSize(2)); + + ModelBuilders.FlagBuilder expectedFlag1 = flagBuilder("flag1").version(1).salt("") + .on(true).offVariation(1).fallthroughVariation(0).variations(true, false); + ModelBuilders.FlagBuilder expectedFlag2 = flagBuilder("flag2").version(1).salt("") + .on(false).offVariation(1).fallthroughVariation(0).variations(true, false); + + Map flags = ImmutableMap.copyOf(get(changeSet.getData(), 0).getValue().getItems()); + ItemDescriptor flag1 = flags.get("flag1"); + ItemDescriptor flag2 = flags.get("flag2"); + assertThat(flag1, not(nullValue())); + assertThat(flag2, not(nullValue())); + assertThat(flag1.getVersion(), equalTo(1)); + assertThat(flag2.getVersion(), equalTo(1)); + + assertJsonEquals(flagJson(expectedFlag1, 1), flagJson(flag1)); + assertJsonEquals(flagJson(expectedFlag2, 1), flagJson(flag2)); + + consumer.stop(); + } + + @Test + public void addsFlag() throws Exception { + TestDataV2 td = TestDataV2.synchronizer(); + Synchronizer sync = td.build(dataSourceBuildInputs()); + ResultConsumer consumer = new ResultConsumer(sync); + + FDv2SourceResult initResult = consumer.next(); + assertThat(initResult.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(initResult.getChangeSet().getType(), equalTo(ChangeSetType.Full)); + + td.update(td.flag("flag1").on(true)); + + FDv2SourceResult updateResult = consumer.next(); + assertThat(updateResult.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + ChangeSet changeSet = updateResult.getChangeSet(); + assertThat(changeSet.getType(), equalTo(ChangeSetType.Partial)); + assertThat(changeSet.getData(), iterableWithSize(1)); + KeyedItems keyedItems = get(changeSet.getData(), 0).getValue(); + Map items = ImmutableMap.copyOf(keyedItems.getItems()); + assertThat(items.size(), equalTo(1)); + ItemDescriptor flag1 = items.get("flag1"); + assertThat(flag1, not(nullValue())); + assertThat(flag1.getVersion(), equalTo(1)); + + ModelBuilders.FlagBuilder expectedFlag = flagBuilder("flag1").version(1).salt("") + .on(true).offVariation(1).fallthroughVariation(0).variations(true, false); + assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag1)); + + consumer.stop(); + } + + @Test + public void updatesFlag() throws Exception { + TestDataV2 td = TestDataV2.synchronizer(); + td.update(td.flag("flag1") + .on(false) + .variationForUser("a", true) + .ifMatch("name", LDValue.of("Lucy")).thenReturn(true)); + + Synchronizer sync = td.build(dataSourceBuildInputs()); + ResultConsumer consumer = new ResultConsumer(sync); + + FDv2SourceResult initResult = consumer.next(); + assertThat(initResult.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + + td.update(td.flag("flag1").on(true)); + + FDv2SourceResult updateResult = consumer.next(); + ChangeSet changeSet = updateResult.getChangeSet(); + Map items = ImmutableMap.copyOf(get(changeSet.getData(), 0).getValue().getItems()); + ItemDescriptor flag1 = items.get("flag1"); + assertThat(flag1.getVersion(), equalTo(2)); + + ModelBuilders.FlagBuilder expectedFlag = flagBuilder("flag1").version(2).salt("") + .on(true).offVariation(1).fallthroughVariation(0).variations(true, false) + .addTarget(0, "a").addContextTarget(ContextKind.DEFAULT, 0) + .addRule("rule0", 0, "{\"contextKind\":\"user\",\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"]}"); + assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); + + consumer.stop(); + } + + @Test + public void deletesFlag() throws Exception { + TestDataV2 td = TestDataV2.synchronizer(); + Synchronizer sync = td.build(dataSourceBuildInputs()); + ResultConsumer consumer = new ResultConsumer(sync); + + consumer.next(); // Consume initial result + + td.update(td.flag("foo").on(false).valueForAll(LDValue.of("bar"))); + FDv2SourceResult addResult = consumer.next(); + assertThat(addResult.getChangeSet().getType(), equalTo(ChangeSetType.Partial)); + Map addItems = ImmutableMap.copyOf(get(addResult.getChangeSet().getData(), 0).getValue().getItems()); + assertThat(addItems.get("foo").getVersion(), equalTo(1)); + assertThat(addItems.get("foo").getItem(), notNullValue()); + + td.delete("foo"); + FDv2SourceResult deleteResult = consumer.next(); + assertThat(deleteResult.getChangeSet().getType(), equalTo(ChangeSetType.Partial)); + Map deleteItems = ImmutableMap.copyOf(get(deleteResult.getChangeSet().getData(), 0).getValue().getItems()); + assertThat(deleteItems.get("foo").getVersion(), equalTo(2)); + assertThat(deleteItems.get("foo").getItem(), nullValue()); + + consumer.stop(); + sync.close(); + } + + @Test + public void flagConfigSimpleBoolean() throws Exception { + Function expectedBooleanFlag = fb -> + fb.on(true).variations(true, false).offVariation(1).fallthroughVariation(0); + + verifyFlag(f -> f, expectedBooleanFlag); + verifyFlag(f -> f.booleanFlag(), expectedBooleanFlag); + verifyFlag(f -> f.on(true), expectedBooleanFlag); + verifyFlag(f -> f.on(false), fb -> expectedBooleanFlag.apply(fb).on(false)); + verifyFlag(f -> f.variationForAll(false), fb -> expectedBooleanFlag.apply(fb).fallthroughVariation(1)); + verifyFlag(f -> f.variationForAll(true), expectedBooleanFlag); + } + + @Test + public void flagConfigStringVariations() throws Exception { + verifyFlag( + f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2), + fb -> fb.variations("red", "green", "blue").on(true).offVariation(0).fallthroughVariation(2) + ); + } + + @Test + public void userTargets() throws Exception { + Function expectedBooleanFlag = fb -> + fb.variations(true, false).on(true).offVariation(1).fallthroughVariation(0); + + verifyFlag( + f -> f.variationForUser("a", true).variationForUser("b", true), + fb -> expectedBooleanFlag.apply(fb).addTarget(0, "a", "b") + .addContextTarget(ContextKind.DEFAULT, 0) + ); + } + + @Test + public void flagRules() throws Exception { + Function expectedBooleanFlag = fb -> + fb.variations(true, false).on(true).offVariation(1).fallthroughVariation(0); + + verifyFlag( + f -> f.ifMatch("name", LDValue.of("Lucy")).thenReturn(true), + fb -> expectedBooleanFlag.apply(fb).addRule("rule0", 0, + "{\"contextKind\":\"user\",\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"]}") + ); + } + + private void verifyFlag( + Function configureFlag, + Function configureExpectedFlag + ) throws Exception { + ModelBuilders.FlagBuilder expectedFlag = flagBuilder("flagkey").version(1).salt(""); + expectedFlag = configureExpectedFlag.apply(expectedFlag); + + TestDataV2 td = TestDataV2.synchronizer(); + Synchronizer sync = td.build(dataSourceBuildInputs()); + ResultConsumer consumer = new ResultConsumer(sync); + + consumer.next(); // Consume initial result + + td.update(configureFlag.apply(td.flag("flagkey"))); + + FDv2SourceResult result = consumer.next(); + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + ChangeSet changeSet = result.getChangeSet(); + Map items = ImmutableMap.copyOf(get(changeSet.getData(), 0).getValue().getItems()); + ItemDescriptor flag = items.get("flagkey"); + assertThat(flag.getVersion(), equalTo(1)); + assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag)); + + consumer.stop(); + } + + private static String flagJson(ModelBuilders.FlagBuilder flagBuilder, int version) { + return DataModel.FEATURES.serialize(new ItemDescriptor(version, flagBuilder.build())); + } + + private static String flagJson(ItemDescriptor flag) { + return DataModel.FEATURES.serialize(flag); + } + + private static class CapturingDataSourceUpdates implements com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink, + com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSinkV2 { + BlockingQueue> inits = + new LinkedBlockingQueue<>(); + BlockingQueue upserts = new LinkedBlockingQueue<>(); + BlockingQueue> applies = new LinkedBlockingQueue<>(); + boolean valid; + + @Override + public boolean init(com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet allData) { + inits.add(allData); + return true; + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + upserts.add(new UpsertParams(kind, key, item)); + return true; + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return null; + } + + @Override + public void updateStatus(State newState, ErrorInfo newError) { + valid = newState == State.VALID; + } + + @Override + public boolean apply(ChangeSet changeSet) { + applies.add(changeSet); + return true; + } + } + + private static class UpsertParams { + final DataKind kind; + final String key; + final ItemDescriptor item; + + UpsertParams(DataKind kind, String key, ItemDescriptor item) { + this.kind = kind; + this.key = key; + this.item = item; + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2WithClientTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2WithClientTest.java new file mode 100644 index 00000000..ac236afb --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2WithClientTest.java @@ -0,0 +1,144 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +@SuppressWarnings("javadoc") +public class TestDataV2WithClientTest { + private static final String SDK_KEY = "sdk-key"; + + private TestDataV2 td = TestDataV2.synchronizer(); + private LDConfig config = new LDConfig.Builder() + .dataSystem(new DataSystemBuilder().synchronizers(td)) + .events(Components.noEvents()) + .build(); + + @Test + public void initializesWithEmptyData() throws Exception { + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.isInitialized(), is(true)); + } + } + + @Test + public void initializesWithFlag() throws Exception { + td.update(td.flag("flag").on(true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(true)); + } + } + + @Test + public void updatesFlag() throws Exception { + td.update(td.flag("flag").on(false)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(false)); + + td.update(td.flag("flag").on(true)); + + assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(true)); + } + } + + @Test + public void deletesFlag() throws Exception { + td.update(td.flag("flag").on(true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(true)); + + td.delete("flag"); + + final EvaluationDetail detail = client.boolVariationDetail("flag", LDContext.create("user"), false); + assertThat(detail.getValue(), is(false)); + assertThat(detail.isDefaultValue(), is(true)); + assertThat(detail.getReason().getErrorKind(), is(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); + } + } + + @Test + public void usesTargets() throws Exception { + td.update(td.flag("flag").fallthroughVariation(false).variationForUser("user1", true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", LDContext.create("user1"), false), is(true)); + assertThat(client.boolVariation("flag", LDContext.create("user2"), false), is(false)); + } + } + + @Test + public void usesRules() throws Exception { + td.update(td.flag("flag").fallthroughVariation(false) + .ifMatch("name", LDValue.of("Lucy")).thenReturn(true) + .ifMatch("name", LDValue.of("Mina")).thenReturn(true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", LDContext.builder("user1").name("Lucy").build(), false), is(true)); + assertThat(client.boolVariation("flag", LDContext.builder("user2").name("Mina").build(), false), is(true)); + assertThat(client.boolVariation("flag", LDContext.builder("user3").name("Quincy").build(), false), is(false)); + } + } + + @Test + public void nonBooleanFlags() throws Exception { + td.update(td.flag("flag").variations(LDValue.of("red"), LDValue.of("green"), LDValue.of("blue")) + .offVariation(0).fallthroughVariation(2) + .variationForUser("user1", 1) + .ifMatch("name", LDValue.of("Mina")).thenReturn(1)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.stringVariation("flag", LDContext.builder("user1").name("Lucy").build(), ""), equalTo("green")); + assertThat(client.stringVariation("flag", LDContext.builder("user2").name("Mina").build(), ""), equalTo("green")); + assertThat(client.stringVariation("flag", LDContext.builder("user3").name("Quincy").build(), ""), equalTo("blue")); + + td.update(td.flag("flag").on(false)); + + assertThat(client.stringVariation("flag", LDContext.builder("user1").name("Lucy").build(), ""), equalTo("red")); + } + } + + @Test + public void canUpdateStatus() throws Exception { + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.VALID)); + + ErrorInfo ei = ErrorInfo.fromHttpError(500); + td.updateStatus(State.INTERRUPTED, ei); + + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.INTERRUPTED)); + assertThat(client.getDataSourceStatusProvider().getStatus().getLastError(), equalTo(ei)); + } + } + + @Test + public void dataSourcePropagatesToMultipleClients() throws Exception { + td.update(td.flag("flag").on(true)); + + try (LDClient client1 = new LDClient(SDK_KEY, config)) { + try (LDClient client2 = new LDClient(SDK_KEY, config)) { + assertThat(client1.boolVariation("flag", LDContext.create("user"), false), is(true)); + assertThat(client2.boolVariation("flag", LDContext.create("user"), false), is(true)); + + td.update(td.flag("flag").on(false)); + + assertThat(client1.boolVariation("flag", LDContext.create("user"), false), is(false)); + assertThat(client2.boolVariation("flag", LDContext.create("user"), false), is(false)); + } + } + } +}