From c9d0b2a226b8da691fcac5c18d1d9e9c954fe2e7 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 2 Feb 2026 09:47:25 -0500 Subject: [PATCH 1/9] chore: update TestData to implement Synchronizer --- .../server/DataSourceSynchronizerAdapter.java | 7 +- .../sdk/server/integrations/TestData.java | 323 +++++++++++++++--- .../sdk/server/integrations/TestDataTest.java | 156 +++++---- 3 files changed, 374 insertions(+), 112 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java index fdd0ccea..895b5964 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java @@ -133,7 +133,8 @@ public boolean init(DataStoreTypes.FullDataSet al DataStoreTypes.ChangeSetType.Full, Selector.EMPTY, allData.getData(), - null); + null, + allData.shouldPersist()); resultQueue.put(FDv2SourceResult.changeSet(changeSet, false)); return true; } @@ -153,7 +154,9 @@ public boolean upsert(DataStoreTypes.DataKind kind, String key, DataStoreTypes.I DataStoreTypes.ChangeSetType.Partial, Selector.EMPTY, data, - null); + null, + true // default to true as this adapter is used for adapting FDv1 data sources which are always persistent + ); resultQueue.put(FDv2SourceResult.changeSet(changeSet, false)); return true; } 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..32330192 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 @@ -9,18 +9,34 @@ import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; +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.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.subsystems.ClientContext; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DataSource; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.TransactionalDataSourceUpdateSink; import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.time.Instant; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.LinkedList; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -29,8 +45,6 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Future; -import static java.util.concurrent.CompletableFuture.completedFuture; - /** * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK * client in test scenarios. @@ -60,15 +74,25 @@ *

* If the same {@code TestData} instance is used to configure multiple {@code LDClient} instances, * any changes made to the data will propagate to all of the {@code LDClient}s. - * + *

+ * {@code TestData} implements both {@link ComponentConfigurer}{@code } and + * {@link DataSourceBuilder}{@code }, so it can be used as: + *

+ * In both cases the same {@link #update(FlagBuilder)}, {@link #delete(String)}, and + * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} API + * drives updates to all configured clients. + * * @since 5.1.0 * @see FileData */ -public final class TestData implements ComponentConfigurer { +public final class TestData implements ComponentConfigurer, DataSourceBuilder { private final Object lock = new Object(); private final Map currentFlags = new HashMap<>(); private final Map currentBuilders = new HashMap<>(); - private final List instances = new CopyOnWriteArrayList<>(); + private final List synchronizerInstances = new CopyOnWriteArrayList<>(); private volatile boolean shouldPersist = true; /** @@ -134,9 +158,7 @@ public TestData delete(String key) { currentBuilders.remove(key); } - for (DataSourceImpl instance: instances) { - instance.updates.upsert(DataModel.FEATURES, key, tombstoneItem); - } + pushToSynchronizers(FDv2SourceResult.changeSet(makePartialChangeSet(key, tombstoneItem), false)); return this; } @@ -170,10 +192,8 @@ public TestData update(FlagBuilder flagBuilder) { currentBuilders.put(key, clonedBuilder); } - for (DataSourceImpl instance: instances) { - instance.updates.upsert(DataModel.FEATURES, key, newItem); - } - + pushToSynchronizers(FDv2SourceResult.changeSet(makePartialChangeSet(key, newItem), false)); + return this; } @@ -182,9 +202,13 @@ public TestData update(FlagBuilder flagBuilder) { *

* 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 data - * source is having problems (for example, a network failure interruptsingthe streaming connection). It + * source is having problems (for example, a network failure interrupting the streaming connection). It * does not actually stop the {@code TestData} data source from working, so even if you have simulated * an outage, calling {@link #update(FlagBuilder)} will still send updates. + *

+ * The mapping from legacy {@link DataSourceStatusProvider.State} to FDv2 status results matches + * {@link com.launchdarkly.sdk.server.DataSourceSynchronizerAdapter}: OFF with error → terminalError, + * OFF with null → shutdown, INTERRUPTED → interrupted, VALID/INITIALIZING → no event. * * @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, @@ -192,8 +216,27 @@ public TestData update(FlagBuilder flagBuilder) { * @return the same {@code TestData} instance */ public TestData updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError) { - for (DataSourceImpl instance: instances) { - instance.updates.updateStatus(newState, 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; } @@ -227,27 +270,237 @@ public TestData shouldPersist(boolean shouldPersist) { @Override public DataSource build(ClientContext context) { - DataSourceImpl instance = new DataSourceImpl(context.getDataSourceUpdateSink()); + TestDataSynchronizerImpl synchronizer = new TestDataSynchronizerImpl(); + synchronized (lock) { + synchronizerInstances.add(synchronizer); + } + return new SynchronizerToDataSourceAdapter(synchronizer, context.getDataSourceUpdateSink()); + } + + @Override + public Synchronizer build(DataSourceBuildInputs context) { + TestDataSynchronizerImpl synchronizer = new TestDataSynchronizerImpl(); synchronized (lock) { - instances.add(instance); + synchronizerInstances.add(synchronizer); + } + return synchronizer; + } + + private void pushToSynchronizers(FDv2SourceResult result) { + for (TestDataSynchronizerImpl sync : synchronizerInstances) { + sync.put(result); } - return instance; } - private FullDataSet makeInitData() { + private ChangeSet makeFullChangeSet() { ImmutableMap copiedData; synchronized (lock) { copiedData = ImmutableMap.copyOf(currentFlags); } - return new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, new KeyedItems<>(copiedData.entrySet())).entrySet(), shouldPersist); + 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 closedInstance(DataSourceImpl instance) { + private void closedSynchronizerInstance(TestDataSynchronizerImpl synchronizer) { synchronized (lock) { - instances.remove(instance); + synchronizerInstances.remove(synchronizer); } } - + + /** + * Adapts a {@link Synchronizer} to the legacy {@link DataSource} interface by driving + * {@link Synchronizer#next()} in a loop and applying results to the update sink. + * Used when TestData is configured as a legacy DataSource so that a single synchronizer + * implementation can serve both the FDv2 data system and legacy configuration. + */ + private static final class SynchronizerToDataSourceAdapter implements DataSource { + private final Synchronizer synchronizer; + private final DataSourceUpdateSink updateSink; + private final CompletableFuture startFuture = new CompletableFuture<>(); + private volatile Thread runThread; + private volatile boolean closed; + + SynchronizerToDataSourceAdapter(Synchronizer synchronizer, DataSourceUpdateSink updateSink) { + this.synchronizer = synchronizer; + this.updateSink = updateSink; + } + + @Override + public Future start() { + runThread = new Thread(this::run); + runThread.setName("LaunchDarkly-TestData-synchronizer-adapter"); + runThread.setDaemon(true); + runThread.start(); + return startFuture.thenApply(x -> null); + } + + @Override + public boolean isInitialized() { + try { + return startFuture.isDone() && startFuture.get(); + } catch (Exception e) { + return false; + } + } + + @Override + public void close() throws IOException { + closed = true; + synchronizer.close(); + startFuture.complete(false); + } + + private static DataSourceStatusProvider.State toDataSourceStatusState(FDv2SourceResult.State fdState) { + switch (fdState) { + case SHUTDOWN: + case TERMINAL_ERROR: + return DataSourceStatusProvider.State.OFF; + case INTERRUPTED: + return DataSourceStatusProvider.State.INTERRUPTED; + case GOODBYE: + default: + return DataSourceStatusProvider.State.VALID; + } + } + + /** + * Applies a change set using the legacy DataSourceUpdateSink API (init/upsert) + * when the sink does not implement TransactionalDataSourceUpdateSink. + */ + private void applyChangeSetToLegacySink(ChangeSet changeSet) { + switch (changeSet.getType()) { + case Full: + updateSink.init(new FullDataSet<>(changeSet.getData(), changeSet.shouldPersist())); + break; + case Partial: + for (Map.Entry> kindItems : changeSet.getData()) { + for (Map.Entry item : kindItems.getValue().getItems()) { + updateSink.upsert(kindItems.getKey(), item.getKey(), item.getValue()); + } + } + break; + case None: + default: + break; + } + } + + private void run() { + try { + while (!closed) { + CompletableFuture nextFuture = synchronizer.next(); + FDv2SourceResult result; + try { + result = nextFuture.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (ExecutionException e) { + break; + } + + switch (result.getResultType()) { + case CHANGE_SET: + if (result.getChangeSet() != null) { + if (updateSink instanceof TransactionalDataSourceUpdateSink) { + ((TransactionalDataSourceUpdateSink) updateSink).apply(result.getChangeSet()); + } else { + applyChangeSetToLegacySink(result.getChangeSet()); + } + updateSink.updateStatus(DataSourceStatusProvider.State.VALID, null); + } + startFuture.complete(true); + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + if (status != null) { + DataSourceStatusProvider.State state = toDataSourceStatusState(status.getState()); + updateSink.updateStatus(state, status.getErrorInfo()); + if (state == DataSourceStatusProvider.State.OFF) { + return; + } + } + break; + } + } + } finally { + startFuture.complete(false); + } + } + } + + /** + * Synchronizer implementation that queues initial and incremental change sets from TestData. + * Used both when TestData is configured as a Synchronizer (FDv2) and when wrapped as a DataSource. + */ + private final class TestDataSynchronizerImpl implements Synchronizer { + private final Object queueLock = new Object(); + private final LinkedList queue = new LinkedList<>(); + private final LinkedList> pendingFutures = new LinkedList<>(); + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + private volatile boolean closed; + private volatile boolean initialSent; + + void put(FDv2SourceResult result) { + synchronized (queueLock) { + if (closed) return; + CompletableFuture waiter = pendingFutures.pollFirst(); + if (waiter != null) { + waiter.complete(result); + } else { + queue.addLast(result); + } + } + } + + @Override + public CompletableFuture next() { + synchronized (queueLock) { + if (!initialSent) { + initialSent = true; + put(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); + } + } + synchronized (queueLock) { + if (!queue.isEmpty()) { + return CompletableFuture.completedFuture(queue.removeFirst()); + } + CompletableFuture future = new CompletableFuture<>(); + pendingFutures.addLast(future); + if (closed) { + future.complete(FDv2SourceResult.shutdown()); + } + return CompletableFuture.anyOf(shutdownFuture, future).thenApply(r -> (FDv2SourceResult) r); + } + } + + @Override + public void close() { + synchronized (queueLock) { + if (closed) return; + closed = true; + } + shutdownFuture.complete(FDv2SourceResult.shutdown()); + closedSynchronizerInstance(this); + } + } + /** * A builder for feature flag configurations to be used with {@link TestData}. * @@ -961,28 +1214,4 @@ private static final class Clause { } } - private final class DataSourceImpl implements DataSource { - final DataSourceUpdateSink updates; - - DataSourceImpl(DataSourceUpdateSink updates) { - this.updates = updates; - } - - @Override - public Future start() { - updates.init(makeInitData()); - updates.updateStatus(State.VALID, null); - return completedFuture(null); - } - - @Override - public boolean isInitialized() { - return true; - } - - @Override - public void close() throws IOException { - closedInstance(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..3e4d7551 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 @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel; @@ -13,24 +14,26 @@ import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; import org.junit.Test; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; 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.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; @@ -43,8 +46,10 @@ public class TestDataTest { private static final LDValue[] THREE_STRING_VALUES = new LDValue[] { LDValue.of("red"), LDValue.of("green"), LDValue.of("blue") }; + private static final int START_TIMEOUT_SECONDS = 5; // necessary due to synchronizer to data source adapter using thread + private CapturingDataSourceUpdates updates = new CapturingDataSourceUpdates(); - + // Test implementation note: We're using the ModelBuilders test helpers to build the expected // flag JSON. However, we have to use them in a slightly different way than we do in other tests // (for instance, writing out an expected clause as a JSON literal), because specific data model @@ -55,15 +60,16 @@ public void initializesWithEmptyData() throws Exception { TestData td = TestData.dataSource(); DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - - assertThat(started.isDone(), is(true)); - assertThat(updates.valid, is(true)); + started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertThat(updates.inits.size(), equalTo(1)); - FullDataSet data = updates.inits.take(); - assertThat(data.getData(), iterableWithSize(1)); - assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); - assertThat(get(data.getData(), 0).getValue().getItems(), emptyIterable()); + assertThat(updates.valid, is(true)); + assertThat(updates.applies.size(), equalTo(1)); + ChangeSet changeSet = updates.applies.take(); + assertThat(changeSet.getType(), equalTo(ChangeSetType.Full)); + assertThat(changeSet.getData(), iterableWithSize(1)); + assertThat(Iterables.get(changeSet.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + Map flags = getFlagsFromChangeSet(changeSet); + assertThat(flags.isEmpty(), is(true)); } @Test @@ -75,23 +81,23 @@ public void initializesWithFlags() throws Exception { DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - - assertThat(started.isDone(), is(true)); - assertThat(updates.valid, is(true)); + started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertThat(updates.inits.size(), equalTo(1)); - FullDataSet data = updates.inits.take(); - assertThat(data.getData(), iterableWithSize(1)); - assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); - assertThat(get(data.getData(), 0).getValue().getItems(), iterableWithSize(2)); + assertThat(updates.valid, is(true)); + assertThat(updates.applies.size(), equalTo(1)); + ChangeSet changeSet = updates.applies.take(); + assertThat(changeSet.getType(), equalTo(ChangeSetType.Full)); + assertThat(changeSet.getData(), iterableWithSize(1)); + assertThat(Iterables.get(changeSet.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + Map flags = getFlagsFromChangeSet(changeSet); + assertThat(flags.entrySet(), 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(data.getData(), 0).getValue().getItems()); - ItemDescriptor flag1 = flags.get("flag1"); + ItemDescriptor flag1 = flags.get("flag1"); ItemDescriptor flag2 = flags.get("flag2"); assertThat(flag1, not(nullValue())); assertThat(flag2, not(nullValue())); @@ -105,22 +111,22 @@ public void addsFlag() throws Exception { TestData td = TestData.dataSource(); DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - - assertThat(started.isDone(), is(true)); - assertThat(updates.valid, is(true)); + started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(updates.valid, is(true)); td.update(td.flag("flag1").on(true)); - + ModelBuilders.FlagBuilder expectedFlag = flagBuilder("flag1").version(1).salt("") .on(true).offVariation(1).fallthroughVariation(0).variations(true, false); - assertThat(updates.upserts.size(), equalTo(1)); - UpsertParams up = updates.upserts.take(); - assertThat(up.kind, is(DataModel.FEATURES)); - assertThat(up.key, equalTo("flag1")); - ItemDescriptor flag1 = up.item; - - assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); + // First apply is initial full (empty); second is the update with flag1 + updates.applies.take(); + ChangeSet changeSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(changeSet, notNullValue()); + Map flags = getFlagsFromChangeSet(changeSet); + ItemDescriptor flag1 = flags.get("flag1"); + assertThat(flag1, not(nullValue())); + assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag1)); } @Test @@ -139,18 +145,18 @@ public void updatesFlag() throws Exception { DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - - assertThat(started.isDone(), is(true)); - assertThat(updates.valid, is(true)); + started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(updates.valid, is(true)); td.update(td.flag("flag1").on(true)); - - assertThat(updates.upserts.size(), equalTo(1)); - UpsertParams up = updates.upserts.take(); - assertThat(up.kind, is(DataModel.FEATURES)); - assertThat(up.key, equalTo("flag1")); - ItemDescriptor flag1 = up.item; - + + // First apply is initial full (flag1 v1); second is the update to flag1 v2 + updates.applies.take(); + ChangeSet changeSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(changeSet, notNullValue()); + Map flags = getFlagsFromChangeSet(changeSet); + ItemDescriptor flag1 = flags.get("flag1"); + assertThat(flag1, not(nullValue())); expectedFlag.on(true).version(2); assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); } @@ -161,24 +167,29 @@ public void deletesFlag() throws Exception { try (final DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates))) { final Future started = ds.start(); - assertThat(started.isDone(), is(true)); + started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertThat(updates.valid, is(true)); td.update(td.flag("foo").on(false).valueForAll(LDValue.of("bar"))); td.delete("foo"); - assertThat(updates.upserts.size(), equalTo(2)); - UpsertParams up = updates.upserts.take(); - assertThat(up.kind, is(DataModel.FEATURES)); - assertThat(up.key, equalTo("foo")); - assertThat(up.item.getVersion(), equalTo(1)); - assertThat(up.item.getItem(), notNullValue()); - - up = updates.upserts.take(); - assertThat(up.kind, is(DataModel.FEATURES)); - assertThat(up.key, equalTo("foo")); - assertThat(up.item.getVersion(), equalTo(2)); - assertThat(up.item.getItem(), nullValue()); + // First apply is initial full (empty); second is update with foo v1; third is delete (foo v2 tombstone) + updates.applies.take(); + ChangeSet addSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(addSet, notNullValue()); + Map addFlags = getFlagsFromChangeSet(addSet); + ItemDescriptor fooV1 = addFlags.get("foo"); + assertThat(fooV1, not(nullValue())); + assertThat(fooV1.getVersion(), equalTo(1)); + assertThat(fooV1.getItem(), notNullValue()); + + ChangeSet deleteSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(deleteSet, notNullValue()); + Map deleteFlags = getFlagsFromChangeSet(deleteSet); + ItemDescriptor fooV2 = deleteFlags.get("foo"); + assertThat(fooV2, not(nullValue())); + assertThat(fooV2.getVersion(), equalTo(2)); + assertThat(fooV2.getItem(), nullValue()); } } @@ -433,15 +444,20 @@ private void verifyFlag( expectedFlag = configureExpectedFlag.apply(expectedFlag); TestData td = TestData.dataSource(); - + DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); - ds.start(); - + Future started = ds.start(); + started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + td.update(configureFlag.apply(td.flag("flagkey"))); - - assertThat(updates.upserts.size(), equalTo(1)); - UpsertParams up = updates.upserts.take(); - ItemDescriptor flag = up.item; + + // First apply is initial full (empty); second is the update with the flag + updates.applies.take(); + ChangeSet changeSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(changeSet, notNullValue()); + Map flags = getFlagsFromChangeSet(changeSet); + ItemDescriptor flag = flags.get("flagkey"); + assertThat(flag, not(nullValue())); assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag)); } @@ -452,7 +468,21 @@ private static String flagJson(ModelBuilders.FlagBuilder flagBuilder, int versio private static String flagJson(ItemDescriptor flag) { return DataModel.FEATURES.serialize(flag); } - + + /** Extracts the feature-flag key-to-descriptor map from a change set (Full or Partial). */ + private static Map getFlagsFromChangeSet(ChangeSet changeSet) { + Map flags = new HashMap<>(); + for (Map.Entry> entry : changeSet.getData()) { + if (entry.getKey().equals(DataModel.FEATURES)) { + for (Map.Entry item : entry.getValue().getItems()) { + flags.put(item.getKey(), item.getValue()); + } + break; + } + } + return flags; + } + private static class UpsertParams { final DataKind kind; final String key; From ee33e4b407c19811a8dcb9f2231567a1a714ffb2 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 2 Feb 2026 11:04:06 -0500 Subject: [PATCH 2/9] fixing more tests --- .../sdk/server/integrations/TestData.java | 46 ++++++++++++++++++- .../sdk/server/LDClientListenersTest.java | 2 +- .../server/MigrationConsistencyCheckTest.java | 6 ++- .../sdk/server/MigrationVariationTests.java | 5 +- .../integrations/TestDataWithClientTest.java | 17 +++---- 5 files changed, 58 insertions(+), 18 deletions(-) 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 32330192..8961b5f7 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 @@ -37,6 +37,7 @@ import java.util.LinkedList; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -44,6 +45,7 @@ import java.util.TreeMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Future; +import java.util.function.BooleanSupplier; /** * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK @@ -106,6 +108,46 @@ public static TestData dataSource() { return new TestData(); } + /** + * Waits until the given condition returns true or the timeout elapses. + *

+ * Use this after calling {@link #update(FlagBuilder)}, {@link #delete(String)}, or + * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} + * when using TestData as a {@link DataSource} (e.g. with an {@code LDClient}), so that your + * assertions see the updated state. The DataSource adapter applies updates on a background + * thread, so propagation is asynchronous unless you wait. + * + * @param timeout maximum time to wait + * @param unit unit for the timeout + * @param condition condition to poll; when it returns true, this method returns + * @throws InterruptedException if the current thread is interrupted while waiting + * @throws AssertionError if the condition does not become true before the timeout + */ + public void awaitPropagation(long timeout, TimeUnit unit, BooleanSupplier condition) + throws InterruptedException { + long deadlineMs = System.currentTimeMillis() + unit.toMillis(timeout); + while (System.currentTimeMillis() < deadlineMs) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(20); + } + throw new AssertionError("Update did not propagate within " + timeout + " " + unit); + } + + /** + * Waits until the given condition returns true or the default timeout (5 seconds) elapses. + *

+ * Equivalent to {@link #awaitPropagation(long, TimeUnit, BooleanSupplier)} with a 5-second timeout. + * + * @param condition condition to poll; when it returns true, this method returns + * @throws InterruptedException if the current thread is interrupted while waiting + * @throws AssertionError if the condition does not become true before the timeout + */ + public void awaitPropagation(BooleanSupplier condition) throws InterruptedException { + awaitPropagation(5, TimeUnit.SECONDS, condition); + } + private TestData() {} /** @@ -417,7 +459,7 @@ private void run() { switch (result.getResultType()) { case CHANGE_SET: - if (result.getChangeSet() != null) { + if (result.getChangeSet() != null && !closed) { if (updateSink instanceof TransactionalDataSourceUpdateSink) { ((TransactionalDataSourceUpdateSink) updateSink).apply(result.getChangeSet()); } else { @@ -429,7 +471,7 @@ private void run() { break; case STATUS: FDv2SourceResult.Status status = result.getStatus(); - if (status != null) { + if (status != null && !closed) { DataSourceStatusProvider.State state = toDataSourceStatusState(status.getState()); updateSink.updateStatus(state, status.getErrorInfo()); if (state == DataSourceStatusProvider.State.OFF) { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index da871e4b..ce250aa5 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -162,9 +162,9 @@ public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + testData.awaitPropagation(() -> client.getDataSourceStatusProvider().getStatus().getState() == DataSourceStatusProvider.State.OFF); DataSourceStatusProvider.Status newStatus = client.getDataSourceStatusProvider().getStatus(); - assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); assertThat(newStatus.getStateSince(), greaterThanOrEqualTo(errorInfo.getTime())); assertThat(newStatus.getLastError(), equalTo(errorInfo)); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java index 0187324f..8051f10f 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java @@ -97,7 +97,7 @@ public void itFindsResultsInconsistentWhenTheyAre() { } @Test - public void itDoesNotRunTheCheckIfCheckRatioIsZero() { + public void itDoesNotRunTheCheckIfCheckRatioIsZero() throws Exception { readOldResult = "consistent"; readNewResult = "inconsistent"; @@ -105,11 +105,13 @@ public void itDoesNotRunTheCheckIfCheckRatioIsZero() { .on(true) .valueForAll(LDValue.of("shadow")) .migrationCheckRatio(0)); + testData.awaitPropagation(() -> "shadow".equals(client.stringVariation("test-flag", LDContext.create("user-key"), ""))); migration.read("test-flag", LDContext.create("user-key"), MigrationStage.LIVE); - Event e = eventSink.events.get(1); + // awaitPropagation polls stringVariation, which records events; MigrationOp is the last event + Event e = eventSink.events.get(eventSink.events.size() - 1); assertEquals(Event.MigrationOp.class, e.getClass()); Event.MigrationOp me = (Event.MigrationOp) e; assertNull(me.getConsistencyMeasurement()); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java index 625e7ee7..bd4e7440 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java @@ -51,13 +51,12 @@ public void itDoesEvaluateDefaultForFlagWithInvalidStage() { } @Test - public void itEvaluatesCorrectValueForExistingFlag() { + public void itEvaluatesCorrectValueForExistingFlag() throws Exception { final String flagKey = "test-flag"; final LDContext context = LDContext.create("test-key"); testData.update(testData.flag(flagKey).valueForAll(LDValue.of(stage.toString()))); // Get a stage that is not the stage we are testing. MigrationStage defaultStage = Arrays.stream(MigrationStage.values()).filter(item -> item != stage).findFirst().get(); - MigrationVariation resStage = client.migrationVariation(flagKey, context, defaultStage); - Assert.assertEquals(stage, resStage.getStage()); + testData.awaitPropagation(() -> client.migrationVariation(flagKey, context, defaultStage).getStage() == stage); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java index 1e63bcb3..719f0e95 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java @@ -50,8 +50,7 @@ public void updatesFlag() throws Exception { 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)); + td.awaitPropagation(() -> client.boolVariation("flag", LDContext.create("user"), false)); } } @@ -63,10 +62,10 @@ public void deletesFlag() throws Exception { assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(true)); td.delete("flag"); + td.awaitPropagation(() -> client.boolVariationDetail("flag", LDContext.create("user"), false).isDefaultValue()); 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)); } } @@ -107,8 +106,7 @@ public void nonBooleanFlags() throws Exception { 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")); + td.awaitPropagation(() -> "red".equals(client.stringVariation("flag", LDContext.builder("user1").name("Lucy").build(), ""))); } } @@ -119,8 +117,8 @@ public void canUpdateStatus() throws Exception { ErrorInfo ei = ErrorInfo.fromHttpError(500); td.updateStatus(State.INTERRUPTED, ei); - - assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.INTERRUPTED)); + td.awaitPropagation(() -> client.getDataSourceStatusProvider().getStatus().getState() == State.INTERRUPTED); + assertThat(client.getDataSourceStatusProvider().getStatus().getLastError(), equalTo(ei)); } } @@ -135,9 +133,8 @@ public void dataSourcePropagatesToMultipleClients() throws Exception { 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)); + td.awaitPropagation(() -> !client1.boolVariation("flag", LDContext.create("user"), false) + && !client2.boolVariation("flag", LDContext.create("user"), false)); } } } From eed1dd53d1dd34a9216b59d5c475ebc64bfe817f Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 2 Feb 2026 11:09:46 -0500 Subject: [PATCH 3/9] self review --- .../sdk/server/integrations/TestData.java | 509 +++++++++--------- 1 file changed, 255 insertions(+), 254 deletions(-) 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 8961b5f7..f7b6b3fa 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 @@ -108,46 +108,6 @@ public static TestData dataSource() { return new TestData(); } - /** - * Waits until the given condition returns true or the timeout elapses. - *

- * Use this after calling {@link #update(FlagBuilder)}, {@link #delete(String)}, or - * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} - * when using TestData as a {@link DataSource} (e.g. with an {@code LDClient}), so that your - * assertions see the updated state. The DataSource adapter applies updates on a background - * thread, so propagation is asynchronous unless you wait. - * - * @param timeout maximum time to wait - * @param unit unit for the timeout - * @param condition condition to poll; when it returns true, this method returns - * @throws InterruptedException if the current thread is interrupted while waiting - * @throws AssertionError if the condition does not become true before the timeout - */ - public void awaitPropagation(long timeout, TimeUnit unit, BooleanSupplier condition) - throws InterruptedException { - long deadlineMs = System.currentTimeMillis() + unit.toMillis(timeout); - while (System.currentTimeMillis() < deadlineMs) { - if (condition.getAsBoolean()) { - return; - } - Thread.sleep(20); - } - throw new AssertionError("Update did not propagate within " + timeout + " " + unit); - } - - /** - * Waits until the given condition returns true or the default timeout (5 seconds) elapses. - *

- * Equivalent to {@link #awaitPropagation(long, TimeUnit, BooleanSupplier)} with a 5-second timeout. - * - * @param condition condition to poll; when it returns true, this method returns - * @throws InterruptedException if the current thread is interrupted while waiting - * @throws AssertionError if the condition does not become true before the timeout - */ - public void awaitPropagation(BooleanSupplier condition) throws InterruptedException { - awaitPropagation(5, TimeUnit.SECONDS, condition); - } - private TestData() {} /** @@ -282,6 +242,46 @@ public TestData updateStatus(DataSourceStatusProvider.State newState, DataSource } return this; } + + /** + * Waits until the given condition returns true or the timeout elapses. + *

+ * Use this after calling {@link #update(FlagBuilder)}, {@link #delete(String)}, or + * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} + * when using TestData as a {@link DataSource} (e.g. with an {@code LDClient}), so that your + * assertions see the updated state. The DataSource adapter applies updates on a background + * thread, so propagation is asynchronous unless you wait. + * + * @param timeout maximum time to wait + * @param unit unit for the timeout + * @param condition condition to poll; when it returns true, this method returns + * @throws InterruptedException if the current thread is interrupted while waiting + * @throws AssertionError if the condition does not become true before the timeout + */ + public void awaitPropagation(long timeout, TimeUnit unit, BooleanSupplier condition) + throws InterruptedException { + long deadlineMs = System.currentTimeMillis() + unit.toMillis(timeout); + while (System.currentTimeMillis() < deadlineMs) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(20); + } + throw new AssertionError("Update did not propagate within " + timeout + " " + unit); + } + + /** + * Waits until the given condition returns true or the default timeout (5 seconds) elapses. + *

+ * Equivalent to {@link #awaitPropagation(long, TimeUnit, BooleanSupplier)} with a 5-second timeout. + * + * @param condition condition to poll; when it returns true, this method returns + * @throws InterruptedException if the current thread is interrupted while waiting + * @throws AssertionError if the condition does not become true before the timeout + */ + public void awaitPropagation(BooleanSupplier condition) throws InterruptedException { + awaitPropagation(5, TimeUnit.SECONDS, condition); + } /** * Configures whether test data should be persisted to persistent stores. @@ -328,220 +328,6 @@ public Synchronizer build(DataSourceBuildInputs context) { return synchronizer; } - private void pushToSynchronizers(FDv2SourceResult result) { - for (TestDataSynchronizerImpl sync : synchronizerInstances) { - sync.put(result); - } - } - - 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(TestDataSynchronizerImpl synchronizer) { - synchronized (lock) { - synchronizerInstances.remove(synchronizer); - } - } - - /** - * Adapts a {@link Synchronizer} to the legacy {@link DataSource} interface by driving - * {@link Synchronizer#next()} in a loop and applying results to the update sink. - * Used when TestData is configured as a legacy DataSource so that a single synchronizer - * implementation can serve both the FDv2 data system and legacy configuration. - */ - private static final class SynchronizerToDataSourceAdapter implements DataSource { - private final Synchronizer synchronizer; - private final DataSourceUpdateSink updateSink; - private final CompletableFuture startFuture = new CompletableFuture<>(); - private volatile Thread runThread; - private volatile boolean closed; - - SynchronizerToDataSourceAdapter(Synchronizer synchronizer, DataSourceUpdateSink updateSink) { - this.synchronizer = synchronizer; - this.updateSink = updateSink; - } - - @Override - public Future start() { - runThread = new Thread(this::run); - runThread.setName("LaunchDarkly-TestData-synchronizer-adapter"); - runThread.setDaemon(true); - runThread.start(); - return startFuture.thenApply(x -> null); - } - - @Override - public boolean isInitialized() { - try { - return startFuture.isDone() && startFuture.get(); - } catch (Exception e) { - return false; - } - } - - @Override - public void close() throws IOException { - closed = true; - synchronizer.close(); - startFuture.complete(false); - } - - private static DataSourceStatusProvider.State toDataSourceStatusState(FDv2SourceResult.State fdState) { - switch (fdState) { - case SHUTDOWN: - case TERMINAL_ERROR: - return DataSourceStatusProvider.State.OFF; - case INTERRUPTED: - return DataSourceStatusProvider.State.INTERRUPTED; - case GOODBYE: - default: - return DataSourceStatusProvider.State.VALID; - } - } - - /** - * Applies a change set using the legacy DataSourceUpdateSink API (init/upsert) - * when the sink does not implement TransactionalDataSourceUpdateSink. - */ - private void applyChangeSetToLegacySink(ChangeSet changeSet) { - switch (changeSet.getType()) { - case Full: - updateSink.init(new FullDataSet<>(changeSet.getData(), changeSet.shouldPersist())); - break; - case Partial: - for (Map.Entry> kindItems : changeSet.getData()) { - for (Map.Entry item : kindItems.getValue().getItems()) { - updateSink.upsert(kindItems.getKey(), item.getKey(), item.getValue()); - } - } - break; - case None: - default: - break; - } - } - - private void run() { - try { - while (!closed) { - CompletableFuture nextFuture = synchronizer.next(); - FDv2SourceResult result; - try { - result = nextFuture.get(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } catch (ExecutionException e) { - break; - } - - switch (result.getResultType()) { - case CHANGE_SET: - if (result.getChangeSet() != null && !closed) { - if (updateSink instanceof TransactionalDataSourceUpdateSink) { - ((TransactionalDataSourceUpdateSink) updateSink).apply(result.getChangeSet()); - } else { - applyChangeSetToLegacySink(result.getChangeSet()); - } - updateSink.updateStatus(DataSourceStatusProvider.State.VALID, null); - } - startFuture.complete(true); - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - if (status != null && !closed) { - DataSourceStatusProvider.State state = toDataSourceStatusState(status.getState()); - updateSink.updateStatus(state, status.getErrorInfo()); - if (state == DataSourceStatusProvider.State.OFF) { - return; - } - } - break; - } - } - } finally { - startFuture.complete(false); - } - } - } - - /** - * Synchronizer implementation that queues initial and incremental change sets from TestData. - * Used both when TestData is configured as a Synchronizer (FDv2) and when wrapped as a DataSource. - */ - private final class TestDataSynchronizerImpl implements Synchronizer { - private final Object queueLock = new Object(); - private final LinkedList queue = new LinkedList<>(); - private final LinkedList> pendingFutures = new LinkedList<>(); - private final CompletableFuture shutdownFuture = new CompletableFuture<>(); - private volatile boolean closed; - private volatile boolean initialSent; - - void put(FDv2SourceResult result) { - synchronized (queueLock) { - if (closed) return; - CompletableFuture waiter = pendingFutures.pollFirst(); - if (waiter != null) { - waiter.complete(result); - } else { - queue.addLast(result); - } - } - } - - @Override - public CompletableFuture next() { - synchronized (queueLock) { - if (!initialSent) { - initialSent = true; - put(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); - } - } - synchronized (queueLock) { - if (!queue.isEmpty()) { - return CompletableFuture.completedFuture(queue.removeFirst()); - } - CompletableFuture future = new CompletableFuture<>(); - pendingFutures.addLast(future); - if (closed) { - future.complete(FDv2SourceResult.shutdown()); - } - return CompletableFuture.anyOf(shutdownFuture, future).thenApply(r -> (FDv2SourceResult) r); - } - } - - @Override - public void close() { - synchronized (queueLock) { - if (closed) return; - closed = true; - } - shutdownFuture.complete(FDv2SourceResult.shutdown()); - closedSynchronizerInstance(this); - } - } /** * A builder for feature flag configurations to be used with {@link TestData}. @@ -1256,4 +1042,219 @@ private static final class Clause { } } + + private void pushToSynchronizers(FDv2SourceResult result) { + for (TestDataSynchronizerImpl sync : synchronizerInstances) { + sync.put(result); + } + } + + 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(TestDataSynchronizerImpl synchronizer) { + synchronized (lock) { + synchronizerInstances.remove(synchronizer); + } + } + + /** + * Adapts a {@link Synchronizer} to the legacy {@link DataSource} interface by driving + * {@link Synchronizer#next()} in a loop and applying results to the update sink. + * Used when TestData is configured as a legacy DataSource so that a single synchronizer + * implementation can serve both the FDv2 data system and legacy configuration. + */ + private static final class SynchronizerToDataSourceAdapter implements DataSource { + private final Synchronizer synchronizer; + private final DataSourceUpdateSink updateSink; + private final CompletableFuture startFuture = new CompletableFuture<>(); + private volatile Thread runThread; + private volatile boolean closed; + + SynchronizerToDataSourceAdapter(Synchronizer synchronizer, DataSourceUpdateSink updateSink) { + this.synchronizer = synchronizer; + this.updateSink = updateSink; + } + + @Override + public Future start() { + runThread = new Thread(this::run); + runThread.setName("LaunchDarkly-TestData-synchronizer-adapter"); + runThread.setDaemon(true); + runThread.start(); + return startFuture.thenApply(x -> null); + } + + @Override + public boolean isInitialized() { + try { + return startFuture.isDone() && startFuture.get(); + } catch (Exception e) { + return false; + } + } + + @Override + public void close() throws IOException { + closed = true; + synchronizer.close(); + startFuture.complete(false); + } + + private static DataSourceStatusProvider.State toDataSourceStatusState(FDv2SourceResult.State fdState) { + switch (fdState) { + case SHUTDOWN: + case TERMINAL_ERROR: + return DataSourceStatusProvider.State.OFF; + case INTERRUPTED: + return DataSourceStatusProvider.State.INTERRUPTED; + case GOODBYE: + default: + return DataSourceStatusProvider.State.VALID; + } + } + + /** + * Applies a change set using the legacy DataSourceUpdateSink API (init/upsert) + * when the sink does not implement TransactionalDataSourceUpdateSink. + */ + private void applyChangeSetToLegacySink(ChangeSet changeSet) { + switch (changeSet.getType()) { + case Full: + updateSink.init(new FullDataSet<>(changeSet.getData(), changeSet.shouldPersist())); + break; + case Partial: + for (Map.Entry> kindItems : changeSet.getData()) { + for (Map.Entry item : kindItems.getValue().getItems()) { + updateSink.upsert(kindItems.getKey(), item.getKey(), item.getValue()); + } + } + break; + case None: + default: + break; + } + } + + private void run() { + try { + while (!closed) { + CompletableFuture nextFuture = synchronizer.next(); + FDv2SourceResult result; + try { + result = nextFuture.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (ExecutionException e) { + break; + } + + switch (result.getResultType()) { + case CHANGE_SET: + if (result.getChangeSet() != null && !closed) { + if (updateSink instanceof TransactionalDataSourceUpdateSink) { + ((TransactionalDataSourceUpdateSink) updateSink).apply(result.getChangeSet()); + } else { + applyChangeSetToLegacySink(result.getChangeSet()); + } + updateSink.updateStatus(DataSourceStatusProvider.State.VALID, null); + } + startFuture.complete(true); + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + if (status != null && !closed) { + DataSourceStatusProvider.State state = toDataSourceStatusState(status.getState()); + updateSink.updateStatus(state, status.getErrorInfo()); + if (state == DataSourceStatusProvider.State.OFF) { + return; + } + } + break; + } + } + } finally { + startFuture.complete(false); + } + } + } + + /** + * Synchronizer implementation that queues initial and incremental change sets from TestData. + * Used both when TestData is configured as a Synchronizer (FDv2) and when wrapped as a DataSource. + */ + private final class TestDataSynchronizerImpl implements Synchronizer { + private final Object queueLock = new Object(); + private final LinkedList queue = new LinkedList<>(); + private final LinkedList> pendingFutures = new LinkedList<>(); + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + private volatile boolean closed; + private volatile boolean initialSent; + + void put(FDv2SourceResult result) { + synchronized (queueLock) { + if (closed) return; + CompletableFuture waiter = pendingFutures.pollFirst(); + if (waiter != null) { + waiter.complete(result); + } else { + queue.addLast(result); + } + } + } + + @Override + public CompletableFuture next() { + synchronized (queueLock) { + if (!initialSent) { + initialSent = true; + put(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); + } + } + synchronized (queueLock) { + if (!queue.isEmpty()) { + return CompletableFuture.completedFuture(queue.removeFirst()); + } + CompletableFuture future = new CompletableFuture<>(); + pendingFutures.addLast(future); + if (closed) { + future.complete(FDv2SourceResult.shutdown()); + } + return CompletableFuture.anyOf(shutdownFuture, future).thenApply(r -> (FDv2SourceResult) r); + } + } + + @Override + public void close() { + synchronized (queueLock) { + if (closed) return; + closed = true; + } + shutdownFuture.complete(FDv2SourceResult.shutdown()); + closedSynchronizerInstance(this); + } + } } From 9ec551c3c801d8ffaf41d60ed9dc63af27043911 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 2 Feb 2026 14:23:59 -0500 Subject: [PATCH 4/9] refactoring to TestDataV2 --- .../sdk/server/integrations/TestData.java | 364 +++-------------- .../sdk/server/integrations/TestDataV2.java | 385 ++++++++++++++++++ .../sdk/server/LDClientListenersTest.java | 2 +- .../server/MigrationConsistencyCheckTest.java | 6 +- .../sdk/server/MigrationVariationTests.java | 5 +- .../sdk/server/integrations/TestDataTest.java | 156 +++---- .../integrations/TestDataWithClientTest.java | 17 +- 7 files changed, 511 insertions(+), 424 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java 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 f7b6b3fa..477b5ba9 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 @@ -9,35 +9,18 @@ import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; -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.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.subsystems.ClientContext; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DataSource; -import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; -import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.subsystems.TransactionalDataSourceUpdateSink; import java.io.IOException; -import java.util.concurrent.ExecutionException; -import java.time.Instant; -import java.util.AbstractMap; -import java.util.Collections; -import java.util.LinkedList; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -45,7 +28,8 @@ import java.util.TreeMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Future; -import java.util.function.BooleanSupplier; + +import static java.util.concurrent.CompletableFuture.completedFuture; /** * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK @@ -76,25 +60,15 @@ *

* If the same {@code TestData} instance is used to configure multiple {@code LDClient} instances, * any changes made to the data will propagate to all of the {@code LDClient}s. - *

- * {@code TestData} implements both {@link ComponentConfigurer}{@code } and - * {@link DataSourceBuilder}{@code }, so it can be used as: - *

    - *
  • A legacy {@link DataSource} via {@link LDConfig.Builder#dataSource(ComponentConfigurer)}
  • - *
  • A {@link Synchronizer} via {@link DataSystemBuilder#synchronizers(DataSourceBuilder[])}
  • - *
- * In both cases the same {@link #update(FlagBuilder)}, {@link #delete(String)}, and - * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} API - * drives updates to all configured clients. - * + * * @since 5.1.0 * @see FileData */ -public final class TestData implements ComponentConfigurer, DataSourceBuilder { +public final class TestData implements ComponentConfigurer { 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 final List instances = new CopyOnWriteArrayList<>(); private volatile boolean shouldPersist = true; /** @@ -160,7 +134,9 @@ public TestData delete(String key) { currentBuilders.remove(key); } - pushToSynchronizers(FDv2SourceResult.changeSet(makePartialChangeSet(key, tombstoneItem), false)); + for (DataSourceImpl instance: instances) { + instance.updates.upsert(DataModel.FEATURES, key, tombstoneItem); + } return this; } @@ -194,8 +170,10 @@ public TestData update(FlagBuilder flagBuilder) { currentBuilders.put(key, clonedBuilder); } - pushToSynchronizers(FDv2SourceResult.changeSet(makePartialChangeSet(key, newItem), false)); - + for (DataSourceImpl instance: instances) { + instance.updates.upsert(DataModel.FEATURES, key, newItem); + } + return this; } @@ -204,13 +182,9 @@ public TestData update(FlagBuilder flagBuilder) { *

* 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 data - * source is having problems (for example, a network failure interrupting the streaming connection). It + * source is having problems (for example, a network failure interruptsingthe streaming connection). It * does not actually stop the {@code TestData} data source from working, so even if you have simulated * an outage, calling {@link #update(FlagBuilder)} will still send updates. - *

- * The mapping from legacy {@link DataSourceStatusProvider.State} to FDv2 status results matches - * {@link com.launchdarkly.sdk.server.DataSourceSynchronizerAdapter}: OFF with error → terminalError, - * OFF with null → shutdown, INTERRUPTED → interrupted, VALID/INITIALIZING → no event. * * @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, @@ -218,70 +192,11 @@ public TestData update(FlagBuilder flagBuilder) { * @return the same {@code TestData} instance */ public TestData 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); + for (DataSourceImpl instance: instances) { + instance.updates.updateStatus(newState, newError); } return this; } - - /** - * Waits until the given condition returns true or the timeout elapses. - *

- * Use this after calling {@link #update(FlagBuilder)}, {@link #delete(String)}, or - * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} - * when using TestData as a {@link DataSource} (e.g. with an {@code LDClient}), so that your - * assertions see the updated state. The DataSource adapter applies updates on a background - * thread, so propagation is asynchronous unless you wait. - * - * @param timeout maximum time to wait - * @param unit unit for the timeout - * @param condition condition to poll; when it returns true, this method returns - * @throws InterruptedException if the current thread is interrupted while waiting - * @throws AssertionError if the condition does not become true before the timeout - */ - public void awaitPropagation(long timeout, TimeUnit unit, BooleanSupplier condition) - throws InterruptedException { - long deadlineMs = System.currentTimeMillis() + unit.toMillis(timeout); - while (System.currentTimeMillis() < deadlineMs) { - if (condition.getAsBoolean()) { - return; - } - Thread.sleep(20); - } - throw new AssertionError("Update did not propagate within " + timeout + " " + unit); - } - - /** - * Waits until the given condition returns true or the default timeout (5 seconds) elapses. - *

- * Equivalent to {@link #awaitPropagation(long, TimeUnit, BooleanSupplier)} with a 5-second timeout. - * - * @param condition condition to poll; when it returns true, this method returns - * @throws InterruptedException if the current thread is interrupted while waiting - * @throws AssertionError if the condition does not become true before the timeout - */ - public void awaitPropagation(BooleanSupplier condition) throws InterruptedException { - awaitPropagation(5, TimeUnit.SECONDS, condition); - } /** * Configures whether test data should be persisted to persistent stores. @@ -312,26 +227,30 @@ public TestData shouldPersist(boolean shouldPersist) { @Override public DataSource build(ClientContext context) { - TestDataSynchronizerImpl synchronizer = new TestDataSynchronizerImpl(); + DataSourceImpl instance = new DataSourceImpl(context.getDataSourceUpdateSink()); synchronized (lock) { - synchronizerInstances.add(synchronizer); + instances.add(instance); } - return new SynchronizerToDataSourceAdapter(synchronizer, context.getDataSourceUpdateSink()); + return instance; } - - @Override - public Synchronizer build(DataSourceBuildInputs context) { - TestDataSynchronizerImpl synchronizer = new TestDataSynchronizerImpl(); + + private FullDataSet makeInitData() { + ImmutableMap copiedData; synchronized (lock) { - synchronizerInstances.add(synchronizer); + copiedData = ImmutableMap.copyOf(currentFlags); } - return synchronizer; + return new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, new KeyedItems<>(copiedData.entrySet())).entrySet(), shouldPersist); } - - + + private void closedInstance(DataSourceImpl instance) { + synchronized (lock) { + instances.remove(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) */ @@ -350,13 +269,13 @@ 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; @@ -892,6 +811,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; @@ -1042,219 +963,28 @@ private static final class Clause { } } - - private void pushToSynchronizers(FDv2SourceResult result) { - for (TestDataSynchronizerImpl sync : synchronizerInstances) { - sync.put(result); - } - } - - 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(TestDataSynchronizerImpl synchronizer) { - synchronized (lock) { - synchronizerInstances.remove(synchronizer); - } - } - - /** - * Adapts a {@link Synchronizer} to the legacy {@link DataSource} interface by driving - * {@link Synchronizer#next()} in a loop and applying results to the update sink. - * Used when TestData is configured as a legacy DataSource so that a single synchronizer - * implementation can serve both the FDv2 data system and legacy configuration. - */ - private static final class SynchronizerToDataSourceAdapter implements DataSource { - private final Synchronizer synchronizer; - private final DataSourceUpdateSink updateSink; - private final CompletableFuture startFuture = new CompletableFuture<>(); - private volatile Thread runThread; - private volatile boolean closed; - - SynchronizerToDataSourceAdapter(Synchronizer synchronizer, DataSourceUpdateSink updateSink) { - this.synchronizer = synchronizer; - this.updateSink = updateSink; + private final class DataSourceImpl implements DataSource { + final DataSourceUpdateSink updates; + + DataSourceImpl(DataSourceUpdateSink updates) { + this.updates = updates; } - + @Override public Future start() { - runThread = new Thread(this::run); - runThread.setName("LaunchDarkly-TestData-synchronizer-adapter"); - runThread.setDaemon(true); - runThread.start(); - return startFuture.thenApply(x -> null); + updates.init(makeInitData()); + updates.updateStatus(State.VALID, null); + return completedFuture(null); } @Override public boolean isInitialized() { - try { - return startFuture.isDone() && startFuture.get(); - } catch (Exception e) { - return false; - } + return true; } @Override public void close() throws IOException { - closed = true; - synchronizer.close(); - startFuture.complete(false); - } - - private static DataSourceStatusProvider.State toDataSourceStatusState(FDv2SourceResult.State fdState) { - switch (fdState) { - case SHUTDOWN: - case TERMINAL_ERROR: - return DataSourceStatusProvider.State.OFF; - case INTERRUPTED: - return DataSourceStatusProvider.State.INTERRUPTED; - case GOODBYE: - default: - return DataSourceStatusProvider.State.VALID; - } - } - - /** - * Applies a change set using the legacy DataSourceUpdateSink API (init/upsert) - * when the sink does not implement TransactionalDataSourceUpdateSink. - */ - private void applyChangeSetToLegacySink(ChangeSet changeSet) { - switch (changeSet.getType()) { - case Full: - updateSink.init(new FullDataSet<>(changeSet.getData(), changeSet.shouldPersist())); - break; - case Partial: - for (Map.Entry> kindItems : changeSet.getData()) { - for (Map.Entry item : kindItems.getValue().getItems()) { - updateSink.upsert(kindItems.getKey(), item.getKey(), item.getValue()); - } - } - break; - case None: - default: - break; - } - } - - private void run() { - try { - while (!closed) { - CompletableFuture nextFuture = synchronizer.next(); - FDv2SourceResult result; - try { - result = nextFuture.get(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } catch (ExecutionException e) { - break; - } - - switch (result.getResultType()) { - case CHANGE_SET: - if (result.getChangeSet() != null && !closed) { - if (updateSink instanceof TransactionalDataSourceUpdateSink) { - ((TransactionalDataSourceUpdateSink) updateSink).apply(result.getChangeSet()); - } else { - applyChangeSetToLegacySink(result.getChangeSet()); - } - updateSink.updateStatus(DataSourceStatusProvider.State.VALID, null); - } - startFuture.complete(true); - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - if (status != null && !closed) { - DataSourceStatusProvider.State state = toDataSourceStatusState(status.getState()); - updateSink.updateStatus(state, status.getErrorInfo()); - if (state == DataSourceStatusProvider.State.OFF) { - return; - } - } - break; - } - } - } finally { - startFuture.complete(false); - } - } - } - - /** - * Synchronizer implementation that queues initial and incremental change sets from TestData. - * Used both when TestData is configured as a Synchronizer (FDv2) and when wrapped as a DataSource. - */ - private final class TestDataSynchronizerImpl implements Synchronizer { - private final Object queueLock = new Object(); - private final LinkedList queue = new LinkedList<>(); - private final LinkedList> pendingFutures = new LinkedList<>(); - private final CompletableFuture shutdownFuture = new CompletableFuture<>(); - private volatile boolean closed; - private volatile boolean initialSent; - - void put(FDv2SourceResult result) { - synchronized (queueLock) { - if (closed) return; - CompletableFuture waiter = pendingFutures.pollFirst(); - if (waiter != null) { - waiter.complete(result); - } else { - queue.addLast(result); - } - } - } - - @Override - public CompletableFuture next() { - synchronized (queueLock) { - if (!initialSent) { - initialSent = true; - put(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); - } - } - synchronized (queueLock) { - if (!queue.isEmpty()) { - return CompletableFuture.completedFuture(queue.removeFirst()); - } - CompletableFuture future = new CompletableFuture<>(); - pendingFutures.addLast(future); - if (closed) { - future.complete(FDv2SourceResult.shutdown()); - } - return CompletableFuture.anyOf(shutdownFuture, future).thenApply(r -> (FDv2SourceResult) r); - } - } - - @Override - public void close() { - synchronized (queueLock) { - if (closed) return; - closed = true; - } - shutdownFuture.complete(FDv2SourceResult.shutdown()); - closedSynchronizerInstance(this); + closedInstance(this); } } } 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..de5af19a --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java @@ -0,0 +1,385 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableMap; +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.interfaces.DataSourceStatusProvider.State; +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.DataKind; +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.LinkedList; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.TreeMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BooleanSupplier; + +/** + * 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. + *

+ * The mapping from legacy {@link DataSourceStatusProvider.State} to FDv2 status results matches + * {@link com.launchdarkly.sdk.server.DataSourceSynchronizerAdapter}: OFF with error → terminalError, + * OFF with null → shutdown, INTERRUPTED → interrupted, VALID/INITIALIZING → no event. + * + * @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; + } + + /** + * Waits until the given condition returns true or the timeout elapses. + *

+ * Use this after calling {@link #update(TestData.FlagBuilder)}, {@link #delete(String)}, or + * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} + * when using TestDataV2 with an {@code LDClient}, so that your assertions see the updated state. + * The synchronizer may apply updates asynchronously. + * + * @param timeout maximum time to wait + * @param unit unit for the timeout + * @param condition condition to poll; when it returns true, this method returns + * @throws InterruptedException if the current thread is interrupted while waiting + * @throws AssertionError if the condition does not become true before the timeout + */ + public void awaitPropagation(long timeout, TimeUnit unit, BooleanSupplier condition) + throws InterruptedException { + long deadlineMs = System.currentTimeMillis() + unit.toMillis(timeout); + while (System.currentTimeMillis() < deadlineMs) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(20); + } + throw new AssertionError("Update did not propagate within " + timeout + " " + unit); + } + + /** + * Waits until the given condition returns true or the default timeout (5 seconds) elapses. + *

+ * Equivalent to {@link #awaitPropagation(long, TimeUnit, BooleanSupplier)} with a 5-second timeout. + * + * @param condition condition to poll; when it returns true, this method returns + * @throws InterruptedException if the current thread is interrupted while waiting + * @throws AssertionError if the condition does not become true before the timeout + */ + public void awaitPropagation(BooleanSupplier condition) throws InterruptedException { + awaitPropagation(5, TimeUnit.SECONDS, condition); + } + + /** + * 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) { + sync.put(result); + } + } + + 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 Object queueLock = new Object(); + private final LinkedList queue = new LinkedList<>(); + private final LinkedList> pendingFutures = new LinkedList<>(); + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + private volatile boolean closed; + private volatile boolean initialSent; + + void put(FDv2SourceResult result) { + synchronized (queueLock) { + if (closed) return; + CompletableFuture waiter = pendingFutures.pollFirst(); + if (waiter != null) { + waiter.complete(result); + } else { + queue.addLast(result); + } + } + } + + @Override + public CompletableFuture next() { + synchronized (queueLock) { + if (!initialSent) { + initialSent = true; + put(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); + } + } + synchronized (queueLock) { + if (!queue.isEmpty()) { + return CompletableFuture.completedFuture(queue.removeFirst()); + } + CompletableFuture future = new CompletableFuture<>(); + pendingFutures.addLast(future); + if (closed) { + future.complete(FDv2SourceResult.shutdown()); + } + return CompletableFuture.anyOf(shutdownFuture, future).thenApply(r -> (FDv2SourceResult) r); + } + } + + @Override + public void close() { + synchronized (queueLock) { + if (closed) return; + closed = true; + } + shutdownFuture.complete(FDv2SourceResult.shutdown()); + closedSynchronizerInstance(this); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index ce250aa5..da871e4b 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -162,9 +162,9 @@ public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); - testData.awaitPropagation(() -> client.getDataSourceStatusProvider().getStatus().getState() == DataSourceStatusProvider.State.OFF); DataSourceStatusProvider.Status newStatus = client.getDataSourceStatusProvider().getStatus(); + assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); assertThat(newStatus.getStateSince(), greaterThanOrEqualTo(errorInfo.getTime())); assertThat(newStatus.getLastError(), equalTo(errorInfo)); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java index 8051f10f..0187324f 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationConsistencyCheckTest.java @@ -97,7 +97,7 @@ public void itFindsResultsInconsistentWhenTheyAre() { } @Test - public void itDoesNotRunTheCheckIfCheckRatioIsZero() throws Exception { + public void itDoesNotRunTheCheckIfCheckRatioIsZero() { readOldResult = "consistent"; readNewResult = "inconsistent"; @@ -105,13 +105,11 @@ public void itDoesNotRunTheCheckIfCheckRatioIsZero() throws Exception { .on(true) .valueForAll(LDValue.of("shadow")) .migrationCheckRatio(0)); - testData.awaitPropagation(() -> "shadow".equals(client.stringVariation("test-flag", LDContext.create("user-key"), ""))); migration.read("test-flag", LDContext.create("user-key"), MigrationStage.LIVE); - // awaitPropagation polls stringVariation, which records events; MigrationOp is the last event - Event e = eventSink.events.get(eventSink.events.size() - 1); + Event e = eventSink.events.get(1); assertEquals(Event.MigrationOp.class, e.getClass()); Event.MigrationOp me = (Event.MigrationOp) e; assertNull(me.getConsistencyMeasurement()); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java index bd4e7440..625e7ee7 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/MigrationVariationTests.java @@ -51,12 +51,13 @@ public void itDoesEvaluateDefaultForFlagWithInvalidStage() { } @Test - public void itEvaluatesCorrectValueForExistingFlag() throws Exception { + public void itEvaluatesCorrectValueForExistingFlag() { final String flagKey = "test-flag"; final LDContext context = LDContext.create("test-key"); testData.update(testData.flag(flagKey).valueForAll(LDValue.of(stage.toString()))); // Get a stage that is not the stage we are testing. MigrationStage defaultStage = Arrays.stream(MigrationStage.values()).filter(item -> item != stage).findFirst().get(); - testData.awaitPropagation(() -> client.migrationVariation(flagKey, context, defaultStage).getStage() == stage); + MigrationVariation resStage = client.migrationVariation(flagKey, context, defaultStage); + Assert.assertEquals(stage, resStage.getStage()); } } 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 3e4d7551..e8f207de 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 @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Iterables; import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel; @@ -14,26 +13,24 @@ import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; import org.junit.Test; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; 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.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; @@ -46,10 +43,8 @@ public class TestDataTest { private static final LDValue[] THREE_STRING_VALUES = new LDValue[] { LDValue.of("red"), LDValue.of("green"), LDValue.of("blue") }; - private static final int START_TIMEOUT_SECONDS = 5; // necessary due to synchronizer to data source adapter using thread - private CapturingDataSourceUpdates updates = new CapturingDataSourceUpdates(); - + // Test implementation note: We're using the ModelBuilders test helpers to build the expected // flag JSON. However, we have to use them in a slightly different way than we do in other tests // (for instance, writing out an expected clause as a JSON literal), because specific data model @@ -60,16 +55,15 @@ public void initializesWithEmptyData() throws Exception { TestData td = TestData.dataSource(); DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - + + assertThat(started.isDone(), is(true)); assertThat(updates.valid, is(true)); - assertThat(updates.applies.size(), equalTo(1)); - ChangeSet changeSet = updates.applies.take(); - assertThat(changeSet.getType(), equalTo(ChangeSetType.Full)); - assertThat(changeSet.getData(), iterableWithSize(1)); - assertThat(Iterables.get(changeSet.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); - Map flags = getFlagsFromChangeSet(changeSet); - assertThat(flags.isEmpty(), is(true)); + + assertThat(updates.inits.size(), equalTo(1)); + FullDataSet data = updates.inits.take(); + assertThat(data.getData(), iterableWithSize(1)); + assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(data.getData(), 0).getValue().getItems(), emptyIterable()); } @Test @@ -81,23 +75,23 @@ public void initializesWithFlags() throws Exception { DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - + + assertThat(started.isDone(), is(true)); assertThat(updates.valid, is(true)); - assertThat(updates.applies.size(), equalTo(1)); - ChangeSet changeSet = updates.applies.take(); - assertThat(changeSet.getType(), equalTo(ChangeSetType.Full)); - assertThat(changeSet.getData(), iterableWithSize(1)); - assertThat(Iterables.get(changeSet.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); - Map flags = getFlagsFromChangeSet(changeSet); - assertThat(flags.entrySet(), iterableWithSize(2)); + + assertThat(updates.inits.size(), equalTo(1)); + FullDataSet data = updates.inits.take(); + assertThat(data.getData(), iterableWithSize(1)); + assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(data.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); - ItemDescriptor flag1 = flags.get("flag1"); + Map flags = ImmutableMap.copyOf(get(data.getData(), 0).getValue().getItems()); + ItemDescriptor flag1 = flags.get("flag1"); ItemDescriptor flag2 = flags.get("flag2"); assertThat(flag1, not(nullValue())); assertThat(flag2, not(nullValue())); @@ -111,22 +105,22 @@ public void addsFlag() throws Exception { TestData td = TestData.dataSource(); DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - + + assertThat(started.isDone(), is(true)); assertThat(updates.valid, is(true)); - td.update(td.flag("flag1").on(true)); + td.update(td.flag("flag1").on(true)); + ModelBuilders.FlagBuilder expectedFlag = flagBuilder("flag1").version(1).salt("") .on(true).offVariation(1).fallthroughVariation(0).variations(true, false); - // First apply is initial full (empty); second is the update with flag1 - updates.applies.take(); - ChangeSet changeSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertThat(changeSet, notNullValue()); - Map flags = getFlagsFromChangeSet(changeSet); - ItemDescriptor flag1 = flags.get("flag1"); - assertThat(flag1, not(nullValue())); - assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag1)); + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("flag1")); + ItemDescriptor flag1 = up.item; + + assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); } @Test @@ -145,18 +139,18 @@ public void updatesFlag() throws Exception { DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); Future started = ds.start(); - started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - + + assertThat(started.isDone(), is(true)); assertThat(updates.valid, is(true)); - td.update(td.flag("flag1").on(true)); - // First apply is initial full (flag1 v1); second is the update to flag1 v2 - updates.applies.take(); - ChangeSet changeSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertThat(changeSet, notNullValue()); - Map flags = getFlagsFromChangeSet(changeSet); - ItemDescriptor flag1 = flags.get("flag1"); - assertThat(flag1, not(nullValue())); + td.update(td.flag("flag1").on(true)); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("flag1")); + ItemDescriptor flag1 = up.item; + expectedFlag.on(true).version(2); assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); } @@ -167,29 +161,24 @@ public void deletesFlag() throws Exception { try (final DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates))) { final Future started = ds.start(); - started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(started.isDone(), is(true)); assertThat(updates.valid, is(true)); td.update(td.flag("foo").on(false).valueForAll(LDValue.of("bar"))); td.delete("foo"); - // First apply is initial full (empty); second is update with foo v1; third is delete (foo v2 tombstone) - updates.applies.take(); - ChangeSet addSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertThat(addSet, notNullValue()); - Map addFlags = getFlagsFromChangeSet(addSet); - ItemDescriptor fooV1 = addFlags.get("foo"); - assertThat(fooV1, not(nullValue())); - assertThat(fooV1.getVersion(), equalTo(1)); - assertThat(fooV1.getItem(), notNullValue()); - - ChangeSet deleteSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertThat(deleteSet, notNullValue()); - Map deleteFlags = getFlagsFromChangeSet(deleteSet); - ItemDescriptor fooV2 = deleteFlags.get("foo"); - assertThat(fooV2, not(nullValue())); - assertThat(fooV2.getVersion(), equalTo(2)); - assertThat(fooV2.getItem(), nullValue()); + assertThat(updates.upserts.size(), equalTo(2)); + UpsertParams up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("foo")); + assertThat(up.item.getVersion(), equalTo(1)); + assertThat(up.item.getItem(), notNullValue()); + + up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("foo")); + assertThat(up.item.getVersion(), equalTo(2)); + assertThat(up.item.getItem(), nullValue()); } } @@ -444,20 +433,15 @@ private void verifyFlag( expectedFlag = configureExpectedFlag.apply(expectedFlag); TestData td = TestData.dataSource(); - + DataSource ds = td.build(clientContext("", new LDConfig.Builder().build(), updates)); - Future started = ds.start(); - started.get(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - + ds.start(); + td.update(configureFlag.apply(td.flag("flagkey"))); - - // First apply is initial full (empty); second is the update with the flag - updates.applies.take(); - ChangeSet changeSet = updates.applies.poll(START_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertThat(changeSet, notNullValue()); - Map flags = getFlagsFromChangeSet(changeSet); - ItemDescriptor flag = flags.get("flagkey"); - assertThat(flag, not(nullValue())); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + ItemDescriptor flag = up.item; assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag)); } @@ -468,21 +452,7 @@ private static String flagJson(ModelBuilders.FlagBuilder flagBuilder, int versio private static String flagJson(ItemDescriptor flag) { return DataModel.FEATURES.serialize(flag); } - - /** Extracts the feature-flag key-to-descriptor map from a change set (Full or Partial). */ - private static Map getFlagsFromChangeSet(ChangeSet changeSet) { - Map flags = new HashMap<>(); - for (Map.Entry> entry : changeSet.getData()) { - if (entry.getKey().equals(DataModel.FEATURES)) { - for (Map.Entry item : entry.getValue().getItems()) { - flags.put(item.getKey(), item.getValue()); - } - break; - } - } - return flags; - } - + private static class UpsertParams { final DataKind kind; final String key; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java index 719f0e95..1e63bcb3 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java @@ -50,7 +50,8 @@ public void updatesFlag() throws Exception { assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(false)); td.update(td.flag("flag").on(true)); - td.awaitPropagation(() -> client.boolVariation("flag", LDContext.create("user"), false)); + + assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(true)); } } @@ -62,10 +63,10 @@ public void deletesFlag() throws Exception { assertThat(client.boolVariation("flag", LDContext.create("user"), false), is(true)); td.delete("flag"); - td.awaitPropagation(() -> client.boolVariationDetail("flag", LDContext.create("user"), false).isDefaultValue()); 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)); } } @@ -106,7 +107,8 @@ public void nonBooleanFlags() throws Exception { assertThat(client.stringVariation("flag", LDContext.builder("user3").name("Quincy").build(), ""), equalTo("blue")); td.update(td.flag("flag").on(false)); - td.awaitPropagation(() -> "red".equals(client.stringVariation("flag", LDContext.builder("user1").name("Lucy").build(), ""))); + + assertThat(client.stringVariation("flag", LDContext.builder("user1").name("Lucy").build(), ""), equalTo("red")); } } @@ -117,8 +119,8 @@ public void canUpdateStatus() throws Exception { ErrorInfo ei = ErrorInfo.fromHttpError(500); td.updateStatus(State.INTERRUPTED, ei); - td.awaitPropagation(() -> client.getDataSourceStatusProvider().getStatus().getState() == State.INTERRUPTED); - + + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.INTERRUPTED)); assertThat(client.getDataSourceStatusProvider().getStatus().getLastError(), equalTo(ei)); } } @@ -133,8 +135,9 @@ public void dataSourcePropagatesToMultipleClients() throws Exception { assertThat(client2.boolVariation("flag", LDContext.create("user"), false), is(true)); td.update(td.flag("flag").on(false)); - td.awaitPropagation(() -> !client1.boolVariation("flag", LDContext.create("user"), false) - && !client2.boolVariation("flag", LDContext.create("user"), false)); + + assertThat(client1.boolVariation("flag", LDContext.create("user"), false), is(false)); + assertThat(client2.boolVariation("flag", LDContext.create("user"), false), is(false)); } } } From 2e437dfacdf73335fdc9b2a7b37d3c979e12312c Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 2 Feb 2026 15:01:56 -0500 Subject: [PATCH 5/9] adds TestDataV2Test --- .../server/integrations/TestDataV2Test.java | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2Test.java 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..bb8a9020 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2Test.java @@ -0,0 +1,315 @@ +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(); + + 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()); + + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + 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()); + } + + @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()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + 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())); + + assertJsonEquals(flagJson(expectedFlag1, 1), flagJson(flag1)); + assertJsonEquals(flagJson(expectedFlag2, 1), flagJson(flag2)); + } + + @Test + public void addsFlag() throws Exception { + TestDataV2 td = TestDataV2.synchronizer(); + Synchronizer sync = td.build(dataSourceBuildInputs()); + + FDv2SourceResult initResult = sync.next().get(5, TimeUnit.SECONDS); + assertThat(initResult.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(initResult.getChangeSet().getType(), equalTo(ChangeSetType.Full)); + + td.update(td.flag("flag1").on(true)); + + FDv2SourceResult updateResult = sync.next().get(5, TimeUnit.SECONDS); + 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())); + + ModelBuilders.FlagBuilder expectedFlag = flagBuilder("flag1").version(1).salt("") + .on(true).offVariation(1).fallthroughVariation(0).variations(true, false); + assertJsonEquals(flagJson(expectedFlag, 2), flagJson(flag1)); + } + + @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()); + FDv2SourceResult initResult = sync.next().get(5, TimeUnit.SECONDS); + assertThat(initResult.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + + td.update(td.flag("flag1").on(true)); + + FDv2SourceResult updateResult = sync.next().get(5, TimeUnit.SECONDS); + ChangeSet changeSet = updateResult.getChangeSet(); + Map items = ImmutableMap.copyOf(get(changeSet.getData(), 0).getValue().getItems()); + ItemDescriptor flag1 = items.get("flag1"); + + 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)); + } + + @Test + public void deletesFlag() throws Exception { + TestDataV2 td = TestDataV2.synchronizer(); + Synchronizer sync = td.build(dataSourceBuildInputs()); + + sync.next().get(5, TimeUnit.SECONDS); + + td.update(td.flag("foo").on(false).valueForAll(LDValue.of("bar"))); + FDv2SourceResult addResult = sync.next().get(5, TimeUnit.SECONDS); + 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 = sync.next().get(5, TimeUnit.SECONDS); + 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()); + + 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()); + sync.next().get(5, TimeUnit.SECONDS); + + td.update(configureFlag.apply(td.flag("flagkey"))); + + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + 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"); + assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag)); + } + + 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; + } + } +} From c0e3ce64507f68868ae7fc28a43d60cbc7a52142 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 2 Feb 2026 15:08:53 -0500 Subject: [PATCH 6/9] fixing bot comment --- .../launchdarkly/sdk/server/integrations/TestDataV2.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index de5af19a..8cb371a1 100644 --- 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 @@ -356,7 +356,11 @@ public CompletableFuture next() { synchronized (queueLock) { if (!initialSent) { initialSent = true; - put(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); + // Prepend full changeset so it is delivered before any partial changesets that + // accumulated from update()/delete() calls made before next() was first called. + if (!closed) { + queue.addFirst(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); + } } } synchronized (queueLock) { From 7dc1168c8f957501a2a84bb3bd9a075a9586ea39 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 3 Feb 2026 09:39:09 -0500 Subject: [PATCH 7/9] bot comments --- .../launchdarkly/sdk/server/integrations/TestDataV2.java | 2 -- .../sdk/server/integrations/TestDataTest.java | 9 ++++++--- .../sdk/server/integrations/TestDataV2Test.java | 7 ++++++- 3 files changed, 12 insertions(+), 6 deletions(-) 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 index 8cb371a1..924cc7b6 100644 --- 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 @@ -362,8 +362,6 @@ public CompletableFuture next() { queue.addFirst(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); } } - } - synchronized (queueLock) { if (!queue.isEmpty()) { return CompletableFuture.completedFuture(queue.removeFirst()); } 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 index bb8a9020..ce0b3a6d 100644 --- 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 @@ -108,6 +108,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)); @@ -134,10 +136,11 @@ public void addsFlag() throws Exception { 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, 2), flagJson(flag1)); + assertJsonEquals(flagJson(expectedFlag, 1), flagJson(flag1)); } @Test @@ -158,6 +161,7 @@ public void updatesFlag() throws Exception { 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) @@ -253,6 +257,7 @@ private void verifyFlag( 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)); } From eff65fdce8dff90af07bb59d530770b990c98ce9 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 3 Feb 2026 10:02:37 -0500 Subject: [PATCH 8/9] bot comments --- .../com/launchdarkly/sdk/server/integrations/TestData.java | 2 ++ .../com/launchdarkly/sdk/server/integrations/TestDataV2.java | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) 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 477b5ba9..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 @@ -281,6 +281,8 @@ public static final class FlagBuilder { 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))); } 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 index 924cc7b6..1e17e8e0 100644 --- 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 @@ -180,10 +180,6 @@ public TestDataV2 update(TestData.FlagBuilder flagBuilder) { * 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. - *

- * The mapping from legacy {@link DataSourceStatusProvider.State} to FDv2 status results matches - * {@link com.launchdarkly.sdk.server.DataSourceSynchronizerAdapter}: OFF with error → terminalError, - * OFF with null → shutdown, INTERRUPTED → interrupted, VALID/INITIALIZING → no event. * * @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, From 4f26ccf8f7d9af4749ab4f8243c580320526c4a1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:23:19 -0800 Subject: [PATCH 9/9] chore: Allow for completion reporting for FDv2SourceResult consumption. (#128) > [!NOTE] > **Medium Risk** > Touches core FDv2 data-source result consumption and introduces new callback/lifecycle semantics that could affect threading or resource cleanup if misused; behavior is covered with new integration tests but remains concurrency-sensitive. > > **Overview** > Adds **consumption completion reporting** for FDv2 results by making `FDv2SourceResult` `Closeable`, adding an optional completion callback plus `withCompletion()`, and updating `FDv2DataSource` to use try-with-resources when handling initializer/synchronizer results so callbacks reliably fire. > > Refactors `TestDataV2` to use this mechanism (wrapping results with per-synchronizer completions, switching its internal queueing to `IterableAsyncQueue`, and removing the old polling-based `awaitPropagation` helper), and adds `TestDataV2WithClientTest` to assert initialization, flag updates/deletes, rule/target behavior, status updates, and multi-client propagation. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 08f24e2a7b7926059475617049d9f13feb663645. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../sdk/server/FDv2DataSource.java | 164 +++++++++--------- .../server/datasources/FDv2SourceResult.java | 105 +++++++++-- .../sdk/server/integrations/TestDataV2.java | 112 +++--------- .../server/integrations/TestDataV2Test.java | 77 ++++++-- .../TestDataV2WithClientTest.java | 144 +++++++++++++++ 5 files changed, 406 insertions(+), 196 deletions(-) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataV2WithClientTest.java 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/TestDataV2.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java index 1e17e8e0..21af15fc 100644 --- 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 @@ -1,32 +1,27 @@ 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.interfaces.DataSourceStatusProvider.State; 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.DataKind; 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.LinkedList; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; -import java.util.TreeMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.BooleanSupplier; +import java.util.concurrent.atomic.AtomicBoolean; /** * A mechanism for providing dynamically updatable feature flag state as a {@link Synchronizer} @@ -149,7 +144,7 @@ public TestDataV2 delete(String key) { *

* 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) @@ -158,7 +153,7 @@ 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(); @@ -166,7 +161,7 @@ public TestDataV2 update(TestData.FlagBuilder flagBuilder) { currentFlags.put(key, newItem); currentBuilders.put(key, clonedBuilder); } - + pushToSynchronizers(FDv2SourceResult.changeSet(makePartialChangeSet(key, newItem), false)); return this; @@ -211,45 +206,6 @@ public TestDataV2 updateStatus(DataSourceStatusProvider.State newState, DataSour } return this; } - - /** - * Waits until the given condition returns true or the timeout elapses. - *

- * Use this after calling {@link #update(TestData.FlagBuilder)}, {@link #delete(String)}, or - * {@link #updateStatus(DataSourceStatusProvider.State, DataSourceStatusProvider.ErrorInfo)} - * when using TestDataV2 with an {@code LDClient}, so that your assertions see the updated state. - * The synchronizer may apply updates asynchronously. - * - * @param timeout maximum time to wait - * @param unit unit for the timeout - * @param condition condition to poll; when it returns true, this method returns - * @throws InterruptedException if the current thread is interrupted while waiting - * @throws AssertionError if the condition does not become true before the timeout - */ - public void awaitPropagation(long timeout, TimeUnit unit, BooleanSupplier condition) - throws InterruptedException { - long deadlineMs = System.currentTimeMillis() + unit.toMillis(timeout); - while (System.currentTimeMillis() < deadlineMs) { - if (condition.getAsBoolean()) { - return; - } - Thread.sleep(20); - } - throw new AssertionError("Update did not propagate within " + timeout + " " + unit); - } - - /** - * Waits until the given condition returns true or the default timeout (5 seconds) elapses. - *

- * Equivalent to {@link #awaitPropagation(long, TimeUnit, BooleanSupplier)} with a 5-second timeout. - * - * @param condition condition to poll; when it returns true, this method returns - * @throws InterruptedException if the current thread is interrupted while waiting - * @throws AssertionError if the condition does not become true before the timeout - */ - public void awaitPropagation(BooleanSupplier condition) throws InterruptedException { - awaitPropagation(5, TimeUnit.SECONDS, condition); - } /** * Configures whether test data should be persisted to persistent stores. @@ -289,7 +245,12 @@ public Synchronizer build(DataSourceBuildInputs context) { private void pushToSynchronizers(FDv2SourceResult result) { for (TestDataV2SynchronizerImpl sync : synchronizerInstances) { - sync.put(result); + CompletableFuture completion = new CompletableFuture<>(); + FDv2SourceResult wrappedResult = result.withCompletion(v -> { + completion.complete(null); + return null; + }); + sync.put(wrappedResult, completion); } } @@ -328,54 +289,33 @@ private void closedSynchronizerInstance(TestDataV2SynchronizerImpl synchronizer) * Synchronizer implementation that queues initial and incremental change sets from TestDataV2. */ private final class TestDataV2SynchronizerImpl implements Synchronizer { - private final Object queueLock = new Object(); - private final LinkedList queue = new LinkedList<>(); - private final LinkedList> pendingFutures = new LinkedList<>(); + private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); private final CompletableFuture shutdownFuture = new CompletableFuture<>(); - private volatile boolean closed; - private volatile boolean initialSent; - void put(FDv2SourceResult result) { - synchronized (queueLock) { - if (closed) return; - CompletableFuture waiter = pendingFutures.pollFirst(); - if (waiter != null) { - waiter.complete(result); - } else { - queue.addLast(result); - } + 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() { - synchronized (queueLock) { - if (!initialSent) { - initialSent = true; - // Prepend full changeset so it is delivered before any partial changesets that - // accumulated from update()/delete() calls made before next() was first called. - if (!closed) { - queue.addFirst(FDv2SourceResult.changeSet(makeFullChangeSet(), false)); - } - } - if (!queue.isEmpty()) { - return CompletableFuture.completedFuture(queue.removeFirst()); - } - CompletableFuture future = new CompletableFuture<>(); - pendingFutures.addLast(future); - if (closed) { - future.complete(FDv2SourceResult.shutdown()); - } - return CompletableFuture.anyOf(shutdownFuture, future).thenApply(r -> (FDv2SourceResult) r); + 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() { - synchronized (queueLock) { - if (closed) return; - closed = true; - } shutdownFuture.complete(FDv2SourceResult.shutdown()); closedSynchronizerInstance(this); } 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 index ce0b3a6d..cd1dd13c 100644 --- 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 @@ -52,6 +52,41 @@ public class TestDataV2Test { 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; @@ -70,8 +105,9 @@ private DataSourceBuildInputs dataSourceBuildInputs() { public void initializesWithEmptyData() throws Exception { TestDataV2 td = TestDataV2.synchronizer(); Synchronizer sync = td.build(dataSourceBuildInputs()); + ResultConsumer consumer = new ResultConsumer(sync); - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + FDv2SourceResult result = consumer.next(); assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); ChangeSet changeSet = result.getChangeSet(); @@ -80,6 +116,8 @@ public void initializesWithEmptyData() throws Exception { 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 @@ -89,7 +127,9 @@ public void initializesWithFlags() throws Exception { .update(td.flag("flag2").on(false)); Synchronizer sync = td.build(dataSourceBuildInputs()); - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + ResultConsumer consumer = new ResultConsumer(sync); + + FDv2SourceResult result = consumer.next(); assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); ChangeSet changeSet = result.getChangeSet(); @@ -113,20 +153,23 @@ public void initializesWithFlags() throws Exception { 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 = sync.next().get(5, TimeUnit.SECONDS); + 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 = sync.next().get(5, TimeUnit.SECONDS); + FDv2SourceResult updateResult = consumer.next(); assertThat(updateResult.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); ChangeSet changeSet = updateResult.getChangeSet(); assertThat(changeSet.getType(), equalTo(ChangeSetType.Partial)); @@ -141,6 +184,8 @@ public void addsFlag() throws Exception { 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 @@ -152,12 +197,14 @@ public void updatesFlag() throws Exception { .ifMatch("name", LDValue.of("Lucy")).thenReturn(true)); Synchronizer sync = td.build(dataSourceBuildInputs()); - FDv2SourceResult initResult = sync.next().get(5, TimeUnit.SECONDS); + 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 = sync.next().get(5, TimeUnit.SECONDS); + FDv2SourceResult updateResult = consumer.next(); ChangeSet changeSet = updateResult.getChangeSet(); Map items = ImmutableMap.copyOf(get(changeSet.getData(), 0).getValue().getItems()); ItemDescriptor flag1 = items.get("flag1"); @@ -168,29 +215,33 @@ public void updatesFlag() throws Exception { .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); - sync.next().get(5, TimeUnit.SECONDS); + consumer.next(); // Consume initial result td.update(td.flag("foo").on(false).valueForAll(LDValue.of("bar"))); - FDv2SourceResult addResult = sync.next().get(5, TimeUnit.SECONDS); + 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 = sync.next().get(5, TimeUnit.SECONDS); + 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(); } @@ -248,17 +299,21 @@ private void verifyFlag( TestDataV2 td = TestDataV2.synchronizer(); Synchronizer sync = td.build(dataSourceBuildInputs()); - sync.next().get(5, TimeUnit.SECONDS); + ResultConsumer consumer = new ResultConsumer(sync); + + consumer.next(); // Consume initial result td.update(configureFlag.apply(td.flag("flagkey"))); - FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + 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) { 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)); + } + } + } +}