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