diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java index 1f8c644a..e2c7e75b 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -40,7 +40,8 @@ public class TestService extends NanoHTTPD { "client-prereq-events", "evaluation-hooks", "track-hooks", - "client-per-context-summaries" + "client-per-context-summaries", + "client-event-source-http-errors" }; private static final String MIME_JSON = "application/json"; static final Gson gson = new GsonBuilder() diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java index 25a3cf84..0d51e68c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java @@ -8,11 +8,18 @@ import com.launchdarkly.sdk.android.integrations.StreamingSynchronizerBuilder; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import com.launchdarkly.sdk.internal.http.HttpProperties; +import androidx.annotation.NonNull; + import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; /** * Factory methods for FDv2 data source components used with the @@ -102,6 +109,35 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } } + static final class FDv1PollingSynchronizerBuilderImpl implements DataSourceBuilder { + + protected int pollIntervalMillis = LDConfig.DEFAULT_POLL_INTERVAL_MILLIS; + + public FDv1PollingSynchronizerBuilderImpl pollIntervalMillis(int pollIntervalMillis) { + this.pollIntervalMillis = pollIntervalMillis <= LDConfig.DEFAULT_POLL_INTERVAL_MILLIS ? + LDConfig.DEFAULT_POLL_INTERVAL_MILLIS : pollIntervalMillis; + return this; + } + + @Override + public Synchronizer build(DataSourceBuildInputs inputs) { + FeatureFetcher fetcher = new HttpFeatureFlagFetcher( + inputs.getServiceEndpoints().getPollingBaseUri(), + inputs.isEvaluationReasons(), + inputs.getHttp().isUseReport(), + LDUtil.makeHttpProperties(inputs.getHttp()), + inputs.getCacheDir(), + inputs.getBaseLogger() + ); + return new FDv1PollingSynchronizer( + inputs.getEvaluationContext(), fetcher, + inputs.getSharedExecutor(), 0, + pollIntervalMillis, + inputs.getBaseLogger() + ); + } + } + /** * Returns a builder for a polling initializer. *

@@ -141,6 +177,63 @@ public static StreamingSynchronizerBuilder streamingSynchronizer() { return new StreamingSynchronizerBuilderImpl(); } + /** + * Produces the default mode table used by {@link DataSystemBuilder#buildModeTable}. + * Defined here (rather than in {@code DataSystemBuilder}) because the FDv1 fallback + * synchronizer references package-private types that are not visible from the + * {@code integrations} package. + *

+ * This method is public only for cross-package access within the SDK; it is not + * intended for use by application code. + */ + @NonNull + public static Map makeDefaultModeTable() { + DataSourceBuilder pollingInitializer = pollingInitializer(); + DataSourceBuilder pollingSynchronizer = pollingSynchronizer(); + DataSourceBuilder streamingSynchronizer = streamingSynchronizer(); + DataSourceBuilder backgroundPollingSynchronizer = + pollingSynchronizer() + .pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); + DataSourceBuilder fdv1FallbackPollingSynchronizerForeground = + new FDv1PollingSynchronizerBuilderImpl(); + + DataSourceBuilder fdv1FallbackPollingSynchronizerBackground = + new FDv1PollingSynchronizerBuilderImpl().pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); + + Map table = new LinkedHashMap<>(); + table.put(ConnectionMode.STREAMING, new ModeDefinition( + // TODO: cacheInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer), + Arrays.asList(streamingSynchronizer, pollingSynchronizer), + fdv1FallbackPollingSynchronizerForeground + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(pollingSynchronizer), + fdv1FallbackPollingSynchronizerForeground + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.>emptyList(), + null + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + // TODO: cacheInitializer and streamingInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), + Collections.>emptyList(), + null + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(backgroundPollingSynchronizer), + fdv1FallbackPollingSynchronizerBackground + )); + return table; + } + /** * Returns a builder for configuring automatic connection mode switching in response to * platform events (foreground/background and network availability). diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java index fc306bb4..2ffa79e8 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java @@ -177,15 +177,21 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { return future; } + private static boolean isFdv1Fallback(@NonNull Response response) { + String value = response.header(HeaderConstants.FDV1_FALLBACK_HEADER); + return value != null && value.equalsIgnoreCase("true"); + } + private void handleResponse( @NonNull Response response, @NonNull LDAwaitFuture future) { try { int code = response.code(); + boolean fdv1Fallback = isFdv1Fallback(response); if (code == 304) { logger.debug("FDv2 polling: 304 Not Modified"); - future.set(FDv2PayloadResponse.notModified()); + future.set(FDv2PayloadResponse.notModified(fdv1Fallback)); return; } @@ -197,7 +203,7 @@ private void handleResponse( } else { logger.warn("Polling request failed with HTTP {}", code); } - future.set(FDv2PayloadResponse.failure(code)); + future.set(FDv2PayloadResponse.failure(code, fdv1Fallback)); return; } @@ -216,7 +222,7 @@ private void handleResponse( String bodyStr = body.string(); logger.debug("Polling response received"); List events = FDv2Event.parseEventsArray(bodyStr); - future.set(FDv2PayloadResponse.success(events, code)); + future.set(FDv2PayloadResponse.success(events, code, fdv1Fallback)); } catch (Exception e) { future.setException(e); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java new file mode 100644 index 00000000..42fa15c7 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizer.java @@ -0,0 +1,191 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.subsystems.Callback; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.sdk.fdv2.SourceResultType; +import com.launchdarkly.sdk.fdv2.SourceSignal; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * FDv1 polling synchronizer used as a fallback when the server signals that FDv2 endpoints + * are unavailable via the {@code x-ld-fd-fallback} response header. + *

+ * Delegates the actual HTTP fetch to a {@link FeatureFetcher} (the same transport used by the + * production FDv1 polling data source) and converts the response into {@link FDv2SourceResult} + * objects so it can be used as a drop-in synchronizer within the FDv2 data source pipeline. + */ +final class FDv1PollingSynchronizer implements Synchronizer { + + private final LDContext evaluationContext; + private final FeatureFetcher fetcher; + private final LDLogger logger; + + private final LDAsyncQueue resultQueue = new LDAsyncQueue<>(); + private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); + + private volatile ScheduledFuture scheduledTask; + private final Object taskLock = new Object(); + + /** + * @param evaluationContext the context to evaluate flags for + * @param fetcher the HTTP transport for FDv1 polling requests + * @param executor scheduler for recurring poll tasks + * @param initialDelayMillis delay before the first poll in milliseconds + * @param pollIntervalMillis delay between the end of one poll and the start of the next + * @param logger logger + */ + FDv1PollingSynchronizer( + @NonNull LDContext evaluationContext, + @NonNull FeatureFetcher fetcher, + @NonNull ScheduledExecutorService executor, + long initialDelayMillis, + long pollIntervalMillis, + @NonNull LDLogger logger) { + this.evaluationContext = evaluationContext; + this.fetcher = fetcher; + this.logger = logger; + + synchronized (taskLock) { + scheduledTask = executor.scheduleWithFixedDelay( + this::pollAndEnqueue, + initialDelayMillis, + pollIntervalMillis, + TimeUnit.MILLISECONDS); + } + } + + private void pollAndEnqueue() { + try { + FDv2SourceResult result = doPoll(); + + if (result.getResultType() == SourceResultType.STATUS) { + FDv2SourceResult.Status status = result.getStatus(); + if (status != null && status.getState() == SourceSignal.TERMINAL_ERROR) { + synchronized (taskLock) { + if (scheduledTask != null) { + scheduledTask.cancel(false); + scheduledTask = null; + } + } + shutdownFuture.set(result); + return; + } + } + + resultQueue.put(result); + } catch (RuntimeException e) { + LDUtil.logExceptionAtErrorLevel(logger, e, "Unexpected exception in FDv1 polling synchronizer task"); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); + } + } + + /** + * Fetches flags via FDv1 polling and converts the result to an {@link FDv2SourceResult}. + *

+ * All result/error processing happens inside the {@link Callback} so the future carries a + * fully-formed {@link FDv2SourceResult}. This keeps application-level error classification + * (e.g. {@link LDInvalidResponseCodeFailure}) at the callback layer rather than unwrapping + * it from a {@link java.util.concurrent.Future#get()} exception. + */ + private FDv2SourceResult doPoll() { + LDAwaitFuture resultFuture = new LDAwaitFuture<>(); + + fetcher.fetch(evaluationContext, new Callback() { + @Override + public void onSuccess(String json) { + try { + logger.debug("FDv1 fallback polling response received"); + EnvironmentData envData = EnvironmentData.fromJson(json); + Map flags = envData.getAll(); + + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.EMPTY, + flags, + null, + true); + resultFuture.set(FDv2SourceResult.changeSet(changeSet, false)); + } catch (SerializationException e) { + LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed to parse response"); + LDFailure failure = new LDFailure( + "FDv1 fallback: invalid JSON response", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false)); + } + } + + @Override + public void onError(Throwable e) { + if (e instanceof LDInvalidResponseCodeFailure) { + int code = ((LDInvalidResponseCodeFailure) e).getResponseCode(); + if (code == 400) { + logger.error("Received 400 response when fetching flag values. " + + "Please check recommended R8 and/or ProGuard settings"); + } + boolean recoverable = LDUtil.isHttpErrorRecoverable(code); + logger.warn("FDv1 fallback polling failed with HTTP {}", code); + LDFailure failure = new LDInvalidResponseCodeFailure( + "FDv1 fallback polling request failed", e, code, recoverable); + if (!recoverable) { + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), false)); + } else { + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), false)); + } + } else if (e instanceof IOException) { + LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed with network error"); + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); + } else { + LDUtil.logExceptionAtErrorLevel(logger, e, "FDv1 fallback polling failed"); + resultFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); + } + } + }); + + try { + return resultFuture.get(); + } catch (InterruptedException e) { + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false); + } catch (Exception e) { + // Should not happen — all callback paths call set(), never setException() + Throwable cause = e.getCause() != null ? e.getCause() : e; + LDUtil.logExceptionAtErrorLevel(logger, cause, "FDv1 fallback polling failed unexpectedly"); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); + } + } + + @Override + @NonNull + public Future next() { + return LDFutures.anyOf(shutdownFuture, resultQueue.take()); + } + + @Override + public void close() { + synchronized (taskLock) { + if (scheduledTask != null) { + scheduledTask.cancel(false); + scheduledTask = null; + } + } + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); + try { + fetcher.close(); + } catch (IOException ignored) { + } + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index d46be63d..b7eb2dd2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -1,12 +1,14 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.SourceResultType; import com.launchdarkly.sdk.android.subsystems.DataSourceState; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; @@ -42,6 +44,9 @@ public interface DataSourceFactory { private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; + private static final String FDV1_FALLBACK_MESSAGE = + "Server signaled FDv1 fallback; switching to FDv1 polling synchronizer."; + private final SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; @@ -58,39 +63,46 @@ public interface DataSourceFactory { /** * Convenience constructor using default fallback and recovery timeouts. - * See {@link #FDv2DataSource(LDContext, List, List, DataSourceUpdateSinkV2, + * See {@link #FDv2DataSource(LDContext, List, List, DataSourceFactory, DataSourceUpdateSinkV2, * ScheduledExecutorService, LDLogger, long, long)} for parameter documentation. */ FDv2DataSource( @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, + @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @NonNull ScheduledExecutorService sharedExecutor, @NonNull LDLogger logger ) { - this(evaluationContext, initializers, synchronizers, dataSourceUpdateSink, sharedExecutor, logger, + this(evaluationContext, initializers, synchronizers, fdv1FallbackSynchronizer, + dataSourceUpdateSink, sharedExecutor, logger, FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); } /** - * @param evaluationContext the context to evaluate flags for - * @param initializers factories for one-shot initializers, tried in order - * @param synchronizers factories for recurring synchronizers, tried in order - * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks; must have at least - * 2 threads available for this data source to run properly. - * @param logger logger - * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back to the - * next synchronizer - * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary - * synchronizer + * @param evaluationContext the context to evaluate flags for + * @param initializers factories for one-shot initializers, tried in order + * @param synchronizers factories for recurring synchronizers, tried in order + * @param fdv1FallbackSynchronizer factory for the FDv1 fallback synchronizer, or null if none; + * appended after the regular synchronizers in a blocked state + * and only activated when the server sends the + * {@code x-ld-fd-fallback} header + * @param dataSourceUpdateSink sink to apply changesets and status updates to + * @param sharedExecutor executor used for internal background tasks; must have at least + * 2 threads available for this data source to run properly. + * @param logger logger + * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back to the + * next synchronizer + * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary + * synchronizer */ FDv2DataSource( @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, + @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @NonNull ScheduledExecutorService sharedExecutor, @NonNull LDLogger logger, @@ -100,11 +112,18 @@ public interface DataSourceFactory { this.evaluationContext = evaluationContext; this.dataSourceUpdateSink = dataSourceUpdateSink; this.logger = logger; - List synchronizerFactoriesWithState = new ArrayList<>(); + + List allSynchronizers = new ArrayList<>(); for (DataSourceFactory factory : synchronizers) { - synchronizerFactoriesWithState.add(new SynchronizerFactoryWithState(factory)); + allSynchronizers.add(new SynchronizerFactoryWithState(factory)); + } + if (fdv1FallbackSynchronizer != null) { + SynchronizerFactoryWithState fdv1 = new SynchronizerFactoryWithState(fdv1FallbackSynchronizer, true); + fdv1.block(); + allSynchronizers.add(fdv1); } - this.sourceManager = new SourceManager(synchronizerFactoriesWithState, new ArrayList<>(initializers)); + + this.sourceManager = new SourceManager(allSynchronizers, new ArrayList<>(initializers)); this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; this.sharedExecutor = sharedExecutor; @@ -233,6 +252,26 @@ private void runInitializers( try { FDv2SourceResult result = initializer.run().get(); + // FDv1 fallback takes priority over all other result processing. + // The spec requires honoring the fallback signal from any response, + // regardless of whether data was included or what the selector state is. + if (result.isFdv1Fallback() && sourceManager.hasFDv1Fallback()) { + if (result.getResultType() == SourceResultType.CHANGE_SET) { + ChangeSet> changeSet = result.getChangeSet(); + if (changeSet != null) { + sink.apply(context, changeSet); + anyDataReceived = true; + } + } + logger.info(FDV1_FALLBACK_MESSAGE); + sourceManager.fdv1Fallback(); + if (anyDataReceived) { + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); + } + return; + } + switch (result.getResultType()) { case CHANGE_SET: ChangeSet> changeSet = result.getChangeSet(); @@ -383,6 +422,20 @@ private void runSynchronizers( } break; } + + // After processing the result, check whether the server signaled + // that this environment should fall back to FDv1 (via the + // x-ld-fd-fallback response header). We check regardless of + // whether the synchronizer is still running — a terminal error + // response can still carry the fallback header, and the server's + // instruction to use FDv1 should take precedence. + if (result.isFdv1Fallback() + && sourceManager.hasFDv1Fallback() + && !sourceManager.isCurrentSynchronizerFDv1Fallback()) { + logger.info(FDV1_FALLBACK_MESSAGE); + sourceManager.fdv1Fallback(); + running = false; + } } } } catch (ExecutionException e) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index fb9d2f58..ac792f7a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -14,10 +14,8 @@ import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import java.io.Closeable; -import java.net.URI; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; @@ -42,7 +40,7 @@ class FDv2DataSourceBuilder implements ComponentConfigurer, Closeabl private ScheduledExecutorService sharedExecutor; FDv2DataSourceBuilder() { - this(makeDefaultModeTable(), ConnectionMode.STREAMING, ModeResolutionTable.MOBILE); + this(DataSystemComponents.makeDefaultModeTable(), ConnectionMode.STREAMING, ModeResolutionTable.MOBILE); } FDv2DataSourceBuilder( @@ -135,6 +133,7 @@ public DataSource build(ClientContext clientContext) { clientContext.getEvaluationContext(), initFactories, resolved.getSynchronizerFactories(), + resolved.getFdv1FallbackSynchronizerFactory(), (DataSourceUpdateSinkV2) baseSink, sharedExecutor, clientContext.getBaseLogger() @@ -161,6 +160,7 @@ private DataSourceBuildInputs makeInputs(ClientContext clientContext) { clientContext.isEvaluationReasons(), selectorSource, sharedExecutor, + ClientContextImpl.get(clientContext).getPlatformState().getCacheDir(), clientContext.getBaseLogger() ); } @@ -176,11 +176,9 @@ private static ResolvedModeDefinition resolve( for (DataSourceBuilder builder : def.getSynchronizers()) { syncFactories.add(() -> builder.build(inputs)); } - return new ResolvedModeDefinition(initFactories, syncFactories); - } - - private static Map makeDefaultModeTable() { - return new com.launchdarkly.sdk.android.integrations.DataSystemBuilder() - .buildModeTable(false); + DataSourceBuilder fdv1FallbackSynchronizer = def.getFdv1FallbackSynchronizer(); + FDv2DataSource.DataSourceFactory fdv1Factory = + fdv1FallbackSynchronizer != null ? () -> fdv1FallbackSynchronizer.build(inputs) : null; + return new ResolvedModeDefinition(initFactories, syncFactories, fdv1Factory); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java index 1f8781e4..92c3c334 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingBase.java @@ -89,7 +89,7 @@ static FDv2SourceResult doPoll( Future future = requestor.poll(selector); response = future.get(); } catch (InterruptedException e) { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e)); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false); } catch (ExecutionException e) { Throwable cause = e.getCause() != null ? e.getCause() : e; if (cause instanceof IOException) { @@ -98,10 +98,12 @@ static FDv2SourceResult doPoll( LDUtil.logExceptionAtErrorLevel(logger, cause, "Polling failed"); } return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(cause)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(cause), false) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false); } + boolean fdv1Fallback = response.isFdv1Fallback(); + // 304 Not Modified: nothing changed if (response.getStatusCode() == 304) { logger.debug("Polling got 304 Not Modified"); @@ -110,7 +112,7 @@ static FDv2SourceResult doPoll( selector, Collections.emptyMap(), null, - true)); + true), fdv1Fallback); } if (!response.isSuccess()) { @@ -120,9 +122,9 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDInvalidResponseCodeFailure( "Polling request failed", null, code, recoverable); if (oneShot || !recoverable) { - return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)); + return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback); } else { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } @@ -132,8 +134,8 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDFailure("FDv2 polling response contained no events", LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); @@ -146,8 +148,8 @@ static FDv2SourceResult doPoll( LDFailure failure = new LDFailure( "FDv2 protocol handler error", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } switch (action.getAction()) { @@ -157,15 +159,15 @@ static FDv2SourceResult doPoll( try { ChangeSet> changeSet = FDv2ChangeSetTranslator.toChangeSet(raw, logger); - return FDv2SourceResult.changeSet(changeSet); + return FDv2SourceResult.changeSet(changeSet, fdv1Fallback); } catch (SerializationException e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Polling failed to translate changeset"); LDFailure failure = new LDFailure( "Failed to translate FDv2 polling changeset", e, LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } case ERROR: { @@ -177,13 +179,13 @@ static FDv2SourceResult doPoll( "Polling error: " + error.getReason(), LDFailure.FailureType.UNKNOWN_ERROR); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Polling received GOODBYE with reason: '{}'", reason); - return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason)); + return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback); } case INTERNAL_ERROR: { FDv2ProtocolHandler.FDv2ActionInternalError internalError = @@ -194,8 +196,8 @@ static FDv2SourceResult doPoll( "Polling internal error: " + internalError.getMessage(), LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } case NONE: break; @@ -208,7 +210,7 @@ static FDv2SourceResult doPoll( "FDv2 polling response events produced no changeset", LDFailure.FailureType.INVALID_RESPONSE_BODY); return oneShot - ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure)) - : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure)); + ? FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(failure), fdv1Fallback) + : FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java index 550af863..c71267d7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java @@ -54,7 +54,7 @@ public Future run() { pollFuture.set(result); } catch (RuntimeException e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Unexpected exception in polling initializer"); - pollFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(e))); + pollFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(e), false)); } }); @@ -63,7 +63,7 @@ public Future run() { @Override public void close() { - shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); closeRequestor(); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java index c35f20e0..4e0b9781 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java @@ -93,7 +93,7 @@ private void pollAndEnqueue() { // cancels the task on any unchecked exception, ending all future polls with no // error signal. Log and enqueue an INTERRUPTED result so the consumer is notified LDUtil.logExceptionAtErrorLevel(logger, e, "Unexpected exception in polling synchronizer task"); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e))); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(e), false)); } } @@ -111,7 +111,7 @@ public void close() { scheduledTask = null; } } - shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); closeRequestor(); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java index 302a5db5..b4b351b2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2Requestor.java @@ -27,32 +27,35 @@ final class FDv2PayloadResponse { private final List events; private final boolean successful; private final int statusCode; + private final boolean fdv1Fallback; private FDv2PayloadResponse( @Nullable List events, boolean successful, - int statusCode) { + int statusCode, + boolean fdv1Fallback) { this.events = events; this.successful = successful; this.statusCode = statusCode; + this.fdv1Fallback = fdv1Fallback; } /** Creates a successful response with parsed events. */ - static FDv2PayloadResponse success(@NonNull List events, int statusCode) { - return new FDv2PayloadResponse(events, true, statusCode); + static FDv2PayloadResponse success(@NonNull List events, int statusCode, boolean fdv1Fallback) { + return new FDv2PayloadResponse(events, true, statusCode, fdv1Fallback); } /** * Creates a successful 304 Not Modified response indicating no change since the * last request. */ - static FDv2PayloadResponse notModified() { - return new FDv2PayloadResponse(null, true, 304); + static FDv2PayloadResponse notModified(boolean fdv1Fallback) { + return new FDv2PayloadResponse(null, true, 304, fdv1Fallback); } /** Creates an unsuccessful response with the HTTP status code. */ - static FDv2PayloadResponse failure(int statusCode) { - return new FDv2PayloadResponse(null, false, statusCode); + static FDv2PayloadResponse failure(int statusCode, boolean fdv1Fallback) { + return new FDv2PayloadResponse(null, false, statusCode, fdv1Fallback); } /** The parsed FDv2 events; null for 304 or unsuccessful responses. */ @@ -70,6 +73,14 @@ public boolean isSuccess() { public int getStatusCode() { return statusCode; } + + /** + * True if the server sent the {@code x-ld-fd-fallback: true} header, indicating + * that the SDK should fall back to FDv1 data sources. + */ + public boolean isFdv1Fallback() { + return fdv1Fallback; + } } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java index 71d20339..692c79ee 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java @@ -13,6 +13,7 @@ import com.launchdarkly.eventsource.RetryDelayStrategy; import com.launchdarkly.eventsource.StreamClosedByCallerException; import com.launchdarkly.eventsource.StreamEvent; +import com.launchdarkly.eventsource.ResponseHeaders; import com.launchdarkly.eventsource.StreamException; import com.launchdarkly.eventsource.StreamHttpErrorException; import com.launchdarkly.logging.LDLogger; @@ -158,7 +159,7 @@ public void close() { } catch (IOException ignored) { } } - shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); } private void startStream() { @@ -233,7 +234,8 @@ private void startStream() { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Stream thread ended unexpectedly", e, - LDFailure.FailureType.UNKNOWN_ERROR)))); + LDFailure.FailureType.UNKNOWN_ERROR)), + false)); } finally { es.close(); } @@ -258,6 +260,14 @@ private void recordStreamInit(boolean failed) { } } + private static boolean isFdv1Fallback(@Nullable ResponseHeaders headers) { + if (headers == null) { + return false; + } + String value = headers.value(HeaderConstants.FDV1_FALLBACK_HEADER); + return value != null && value.equalsIgnoreCase("true"); + } + @VisibleForTesting void handleMessage(MessageEvent event) { String eventName = event.getEventName(); @@ -269,6 +279,8 @@ void handleMessage(MessageEvent event) { return; } + boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + FDv2Event fdv2Event; try { fdv2Event = new FDv2Event(eventName, @@ -278,7 +290,8 @@ void handleMessage(MessageEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Failed to parse SSE event", e, - LDFailure.FailureType.INVALID_RESPONSE_BODY)))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + fdv1Fallback)); restartStream(true); return; } @@ -291,7 +304,8 @@ void handleMessage(MessageEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Protocol handler error", e, - LDFailure.FailureType.INVALID_RESPONSE_BODY)))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + fdv1Fallback)); restartStream(true); return; } @@ -304,12 +318,13 @@ void handleMessage(MessageEvent event) { FDv2ChangeSetTranslator.toChangeSet(raw, logger); recordStreamInit(false); streamStarted = 0; - resultQueue.put(FDv2SourceResult.changeSet(changeSet)); + resultQueue.put(FDv2SourceResult.changeSet(changeSet, fdv1Fallback)); } catch (Exception e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Failed to translate changeset"); FDv2SourceResult result = FDv2SourceResult.status(FDv2SourceResult.Status.interrupted( new LDFailure("Failed to translate changeset", e, - LDFailure.FailureType.INVALID_RESPONSE_BODY))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + fdv1Fallback); resultQueue.put(result); restartStream(true); } @@ -327,7 +342,7 @@ void handleMessage(MessageEvent event) { case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); logger.info("Stream received GOODBYE with reason: '{}'", reason); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason))); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback)); restartStream(false); break; } @@ -339,7 +354,8 @@ void handleMessage(MessageEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("FDv2 protocol internal error: " + internalError.getMessage(), - LDFailure.FailureType.INVALID_RESPONSE_BODY)))); + LDFailure.FailureType.INVALID_RESPONSE_BODY)), + fdv1Fallback)); // Only restart for invalid-data errors (bad payload or JSON); for unknown events // or protocol sequence violations the stream may still be healthy. FDv2ProtocolHandler.FDv2ProtocolErrorType errorType = internalError.getErrorType(); @@ -380,8 +396,12 @@ private void handleError(FaultEvent event) { recordStreamInit(true); protocolHandler.reset(); + boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); + if (t instanceof StreamHttpErrorException) { - int code = ((StreamHttpErrorException) t).getCode(); + StreamHttpErrorException httpError = (StreamHttpErrorException) t; + fdv1Fallback = fdv1Fallback || isFdv1Fallback(httpError.getHeaders()); + int code = httpError.getCode(); boolean recoverable = LDUtil.isHttpErrorRecoverable(code); LDFailure failure = new LDInvalidResponseCodeFailure( "Unexpected response code from stream", t, code, recoverable); @@ -389,7 +409,7 @@ private void handleError(FaultEvent event) { if (!recoverable) { logger.error("Encountered non-retriable error: {}. Aborting connection to stream. Verify correct Mobile Key and Stream URI", code); shutdownFuture.set(FDv2SourceResult.status( - FDv2SourceResult.Status.terminalError(failure))); + FDv2SourceResult.Status.terminalError(failure), fdv1Fallback)); EventSource es; synchronized (closeLock) { closed = true; @@ -408,7 +428,7 @@ private void handleError(FaultEvent event) { } else { logger.warn("Stream received HTTP error {}; will retry", code); streamStarted = System.currentTimeMillis(); - resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure))); + resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(failure), fdv1Fallback)); } } else { LDUtil.logExceptionAtWarnLevel(logger, t, "Stream network error"); @@ -416,7 +436,8 @@ private void handleError(FaultEvent event) { resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("Stream network error", t, - LDFailure.FailureType.NETWORK_FAILURE)))); + LDFailure.FailureType.NETWORK_FAILURE)), + fdv1Fallback)); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java new file mode 100644 index 00000000..35d24fc2 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HeaderConstants.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk.android; + +/** + * HTTP header constants used by FDv2 data sources. + */ +abstract class HeaderConstants { + static final String FDV1_FALLBACK_HEADER = "x-ld-fd-fallback"; +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java index c6129e48..782807dc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java @@ -40,17 +40,34 @@ class HttpFeatureFlagFetcher implements FeatureFetcher { HttpFeatureFlagFetcher( @NonNull ClientContext clientContext ) { - this.pollUri = clientContext.getServiceEndpoints().getPollingBaseUri(); - this.evaluationReasons = clientContext.isEvaluationReasons(); - this.useReport = clientContext.getHttp().isUseReport(); - this.httpProperties = LDUtil.makeHttpProperties(clientContext); - this.logger = clientContext.getBaseLogger(); - - File cacheDir = new File(ClientContextImpl.get(clientContext).getPlatformState().getCacheDir(), - "com.launchdarkly.http-cache"); + this( + clientContext.getServiceEndpoints().getPollingBaseUri(), + clientContext.isEvaluationReasons(), + clientContext.getHttp().isUseReport(), + LDUtil.makeHttpProperties(clientContext), + ClientContextImpl.get(clientContext).getPlatformState().getCacheDir(), + clientContext.getBaseLogger() + ); + } + + HttpFeatureFlagFetcher( + @NonNull URI pollUri, + boolean evaluationReasons, + boolean useReport, + @NonNull HttpProperties httpProperties, + @NonNull File platformCacheDir, + @NonNull LDLogger logger + ) { + this.pollUri = pollUri; + this.evaluationReasons = evaluationReasons; + this.useReport = useReport; + this.httpProperties = httpProperties; + this.logger = logger; + + File cacheDir = new File(platformCacheDir, "com.launchdarkly.http-cache"); logger.debug("Using cache at: {}", cacheDir.getAbsolutePath()); - client = httpProperties.toHttpClientBuilder() + this.client = httpProperties.toHttpClientBuilder() // The following client options are currently only used for polling requests; caching is // not relevant for streaming or events, and we don't use OkHttp's auto-retry logic for // streaming or events because we have our own different retry logic. However, in the diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 2e0ebb49..12d4a875 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -36,6 +36,12 @@ * must be constructed with {@link LDConfig.Builder}. */ public class LDConfig { + + /** + * The default value for {@link com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder#pollIntervalMillis(int)}: 5 minutes (300,000 ms). + */ + public static final int DEFAULT_POLL_INTERVAL_MILLIS = 300_000; + /** * The default value for {@link com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder#backgroundPollIntervalMillis(int)} * and {@link com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder#backgroundPollIntervalMillis(int)}: diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index bfbe6cb4..c052a36e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Initializer; @@ -29,19 +30,25 @@ public final class ModeDefinition { private final List> initializers; private final List> synchronizers; + private final DataSourceBuilder fdv1FallbackSynchronizer; /** - * Constructs a mode definition with the given initializers and synchronizers. + * Constructs a mode definition with the given initializers, synchronizers, + * and an optional FDv1 fallback synchronizer. * * @param initializers the initializer builders, in priority order * @param synchronizers the synchronizer builders, in priority order + * @param fdv1FallbackSynchronizer the FDv1 fallback synchronizer builder, or null if + * this mode should not support FDv1 fallback */ public ModeDefinition( @NonNull List> initializers, - @NonNull List> synchronizers + @NonNull List> synchronizers, + @Nullable DataSourceBuilder fdv1FallbackSynchronizer ) { this.initializers = Collections.unmodifiableList(new ArrayList<>(initializers)); this.synchronizers = Collections.unmodifiableList(new ArrayList<>(synchronizers)); + this.fdv1FallbackSynchronizer = fdv1FallbackSynchronizer; } /** @@ -63,4 +70,15 @@ public List> getInitializers() { public List> getSynchronizers() { return synchronizers; } + + /** + * Returns the FDv1 fallback synchronizer builder for this mode, or null if this + * mode does not support FDv1 fallback. + * + * @return the FDv1 fallback synchronizer builder, or null + */ + @Nullable + public DataSourceBuilder getFdv1FallbackSynchronizer() { + return fdv1FallbackSynchronizer; + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java index d7fb04e9..0aeed42d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -24,13 +25,16 @@ final class ResolvedModeDefinition { private final List> initializerFactories; private final List> synchronizerFactories; + private final FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory; ResolvedModeDefinition( @NonNull List> initializerFactories, - @NonNull List> synchronizerFactories + @NonNull List> synchronizerFactories, + @Nullable FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory ) { this.initializerFactories = Collections.unmodifiableList(initializerFactories); this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); + this.fdv1FallbackSynchronizerFactory = fdv1FallbackSynchronizerFactory; } @NonNull @@ -42,4 +46,9 @@ List> getInitializerFactories() { List> getSynchronizerFactories() { return synchronizerFactories; } + + @Nullable + FDv2DataSource.DataSourceFactory getFdv1FallbackSynchronizerFactory() { + return fdv1FallbackSynchronizerFactory; + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java index 4d945eef..bbbd8adb 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java @@ -59,14 +59,21 @@ boolean hasFDv1Fallback() { return false; } - /** Block all non-FDv1 synchronizers and unblock the FDv1 fallback. Android: no-op for now. */ + /** + * Block all non-FDv1 synchronizers, unblock the FDv1 fallback, and reset the + * synchronizer index so the next {@link #getNextAvailableSynchronizerAndSetActive()} + * picks the now-unblocked FDv1 slot. + */ void fdv1Fallback() { - for (SynchronizerFactoryWithState s : synchronizerFactories) { - if (s.isFDv1Fallback()) { - s.unblock(); - } else { - s.block(); + synchronized (activeSourceLock) { + for (SynchronizerFactoryWithState s : synchronizerFactories) { + if (s.isFDv1Fallback()) { + s.unblock(); + } else { + s.block(); + } } + synchronizerIndex = -1; } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java index 3aba7662..53a951d2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -5,13 +5,11 @@ import com.launchdarkly.sdk.android.ConnectionMode; import com.launchdarkly.sdk.android.DataSystemComponents; -import com.launchdarkly.sdk.android.LDConfig; import com.launchdarkly.sdk.android.ModeDefinition; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; -import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -243,67 +241,40 @@ public Map getConnectionModeOverrides() { */ @NonNull public Map buildModeTable(boolean disableBackgroundUpdating) { - Map table = makeDefaultModeTable(); + Map table = DataSystemComponents.makeDefaultModeTable(); for (Map.Entry entry : connectionModeOverrides.entrySet()) { ConnectionModeBuilder cmb = entry.getValue(); + + // Carry forward the default FDv1 fallback synchronizer only when the + // override supplies at least one initializer or synchronizer. An entirely + // empty override means "do nothing in this mode" (like OFFLINE), so there is + // no synchronizer that could receive the x-ld-fd-fallback header and the + // fallback slot would never be reached by FDv2DataSource.runSynchronizers(). + DataSourceBuilder fdv1FallbackSynchronizer = null; + if (!cmb.getInitializers().isEmpty() || !cmb.getSynchronizers().isEmpty()) { + ModeDefinition defaultForMode = table.get(entry.getKey()); + fdv1FallbackSynchronizer = defaultForMode != null + ? defaultForMode.getFdv1FallbackSynchronizer() + : null; + } + table.put(entry.getKey(), new ModeDefinition( cmb.getInitializers(), - cmb.getSynchronizers() + cmb.getSynchronizers(), + fdv1FallbackSynchronizer )); } if (disableBackgroundUpdating) { table.put(ConnectionMode.BACKGROUND, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList() + Collections.>emptyList(), + null )); } return table; } - /** - * Produces the default mode table. - */ - @NonNull - private static Map makeDefaultModeTable() { - DataSourceBuilder pollingInitializer = DataSystemComponents.pollingInitializer(); - - DataSourceBuilder pollingSynchronizer = DataSystemComponents.pollingSynchronizer(); - - DataSourceBuilder streamingSynchronizer = DataSystemComponents.streamingSynchronizer(); - - DataSourceBuilder backgroundPollingSynchronizer = - DataSystemComponents.pollingSynchronizer() - .pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); - - Map table = new LinkedHashMap<>(); - table.put(ConnectionMode.STREAMING, new ModeDefinition( - // TODO: cacheInitializer — add once implemented - Arrays.asList(/* cacheInitializer, */ pollingInitializer), - Arrays.asList(streamingSynchronizer, pollingSynchronizer) - )); - table.put(ConnectionMode.POLLING, new ModeDefinition( - // TODO: Arrays.asList(cacheInitializer) — add once implemented - Collections.>emptyList(), - Collections.singletonList(pollingSynchronizer) - )); - table.put(ConnectionMode.OFFLINE, new ModeDefinition( - // TODO: Arrays.asList(cacheInitializer) — add once implemented - Collections.>emptyList(), - Collections.>emptyList() - )); - table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( - // TODO: cacheInitializer and streamingInitializer — add once implemented - Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), - Collections.>emptyList() - )); - table.put(ConnectionMode.BACKGROUND, new ModeDefinition( - // TODO: Arrays.asList(cacheInitializer) — add once implemented - Collections.>emptyList(), - Collections.singletonList(backgroundPollingSynchronizer) - )); - return table; - } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java index 7e4ed4d9..8d152ccb 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingDataSourceBuilder.java @@ -31,10 +31,6 @@ * @since 3.3.0 */ public abstract class PollingDataSourceBuilder implements ComponentConfigurer { - /** - * The default value for {@link #pollIntervalMillis(int)}: 5 minutes. - */ - public static final int DEFAULT_POLL_INTERVAL_MILLIS = 300_000; /** * The background polling interval in millis @@ -44,7 +40,7 @@ public abstract class PollingDataSourceBuilder implements ComponentConfigurer - * The default value is {@link #DEFAULT_POLL_INTERVAL_MILLIS}. That is also the minimum value. + * The default value is {@link LDConfig#DEFAULT_POLL_INTERVAL_MILLIS}. That is also the minimum value. * * @param pollIntervalMillis the reconnect time base value in milliseconds * @return the builder * @see #backgroundPollIntervalMillis(int) */ public PollingDataSourceBuilder pollIntervalMillis(int pollIntervalMillis) { - this.pollIntervalMillis = pollIntervalMillis <= DEFAULT_POLL_INTERVAL_MILLIS ? - DEFAULT_POLL_INTERVAL_MILLIS : pollIntervalMillis; + this.pollIntervalMillis = pollIntervalMillis <= LDConfig.DEFAULT_POLL_INTERVAL_MILLIS ? + LDConfig.DEFAULT_POLL_INTERVAL_MILLIS : pollIntervalMillis; return this; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java index 07793c4d..c839b664 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android.integrations; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.LDConfig; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -32,15 +33,10 @@ */ public abstract class PollingSynchronizerBuilder implements DataSourceBuilder { - /** - * The default value for {@link #pollIntervalMillis(int)}: 5 minutes (300,000 ms). - */ - public static final int DEFAULT_POLL_INTERVAL_MILLIS = 300_000; - /** * The polling interval in milliseconds. */ - protected int pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS; + protected int pollIntervalMillis = LDConfig.DEFAULT_POLL_INTERVAL_MILLIS; /** * Per-source service endpoint override, or null to use the SDK-level endpoints. @@ -50,14 +46,14 @@ public abstract class PollingSynchronizerBuilder implements DataSourceBuilder - * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL_MILLIS}. Values + * The default and minimum value is {@link LDConfig#DEFAULT_POLL_INTERVAL_MILLIS}. Values * less than this will be set to the default. * * @param pollIntervalMillis the polling interval in milliseconds * @return this builder */ public PollingSynchronizerBuilder pollIntervalMillis(int pollIntervalMillis) { - this.pollIntervalMillis = Math.max(pollIntervalMillis, DEFAULT_POLL_INTERVAL_MILLIS); + this.pollIntervalMillis = Math.max(pollIntervalMillis, LDConfig.DEFAULT_POLL_INTERVAL_MILLIS); return this; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java index ded35e85..463e1891 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java @@ -1,10 +1,13 @@ package com.launchdarkly.sdk.android.subsystems; +import androidx.annotation.NonNull; + import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.SelectorSource; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import java.io.File; import java.util.concurrent.ScheduledExecutorService; /** @@ -27,6 +30,7 @@ public final class DataSourceBuildInputs { private final boolean evaluationReasons; private final SelectorSource selectorSource; private final ScheduledExecutorService sharedExecutor; + private final File cacheDir; private final LDLogger baseLogger; /** @@ -39,6 +43,7 @@ public final class DataSourceBuildInputs { * @param selectorSource the source for obtaining the current selector * @param sharedExecutor shared executor for scheduling tasks; owned and shut down by * the calling data source, so components must not shut it down + * @param cacheDir the platform's cache directory for HTTP-level caching * @param baseLogger the base logger instance */ public DataSourceBuildInputs( @@ -48,6 +53,7 @@ public DataSourceBuildInputs( boolean evaluationReasons, SelectorSource selectorSource, ScheduledExecutorService sharedExecutor, + @NonNull File cacheDir, LDLogger baseLogger ) { this.evaluationContext = evaluationContext; @@ -56,6 +62,7 @@ public DataSourceBuildInputs( this.evaluationReasons = evaluationReasons; this.selectorSource = selectorSource; this.sharedExecutor = sharedExecutor; + this.cacheDir = cacheDir; this.baseLogger = baseLogger; } @@ -116,6 +123,16 @@ public ScheduledExecutorService getSharedExecutor() { return sharedExecutor; } + /** + * Returns the platform's cache directory for HTTP-level caching. + * + * @return the cache directory + */ + @NonNull + public File getCacheDir() { + return cacheDir; + } + /** * Returns the base logger instance. * diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java index 17225222..0403c173 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/FDv2SourceResult.java @@ -74,21 +74,26 @@ public String getReason() { private final ChangeSet> changeSet; @Nullable private final Status status; + private final boolean fdv1Fallback; - private FDv2SourceResult(@NonNull SourceResultType resultType, @Nullable ChangeSet> changeSet, @Nullable Status status) { + private FDv2SourceResult(@NonNull SourceResultType resultType, + @Nullable ChangeSet> changeSet, + @Nullable Status status, + boolean fdv1Fallback) { this.resultType = resultType; this.changeSet = changeSet; this.status = status; + this.fdv1Fallback = fdv1Fallback; } @NonNull - public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet) { - return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null); + public static FDv2SourceResult changeSet(@NonNull ChangeSet> changeSet, boolean fdv1Fallback) { + return new FDv2SourceResult(SourceResultType.CHANGE_SET, changeSet, null, fdv1Fallback); } @NonNull - public static FDv2SourceResult status(@NonNull Status status) { - return new FDv2SourceResult(SourceResultType.STATUS, null, status); + public static FDv2SourceResult status(@NonNull Status status, boolean fdv1Fallback) { + return new FDv2SourceResult(SourceResultType.STATUS, null, status, fdv1Fallback); } @NonNull @@ -105,4 +110,12 @@ public ChangeSet> getChangeSet() { public Status getStatus() { return status; } + + /** + * True if the server indicated that the SDK should fall back to FDv1 data sources + * via the {@code x-ld-fd-fallback} response header. + */ + public boolean isFdv1Fallback() { + return fdv1Fallback; + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 00af320f..7843785f 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -858,15 +858,18 @@ private FDv2DataSourceBuilder makeFDv2DataSourceFactory() { Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); table.put(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList() + Collections.>emptyList(), + null )); return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { @Override @@ -1026,14 +1029,16 @@ public void fdv2_customResolutionTable_determinesActiveModeOnStartup() throws Ex ModeDefinition def = new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null)); + Collections.>singletonList(inputs -> null), + null); Map modeTable = new LinkedHashMap<>(); modeTable.put(com.launchdarkly.sdk.android.ConnectionMode.POLLING, def); modeTable.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, def); modeTable.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, def); modeTable.put(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList())); + Collections.>emptyList(), + null)); final com.launchdarkly.sdk.android.ConnectionMode[] activeModeCapture = new com.launchdarkly.sdk.android.ConnectionMode[1]; @@ -1070,7 +1075,8 @@ public DataSource build(ClientContext clientContext) { public void fdv2_equivalentConfigDoesNotRebuild() throws Exception { ModeDefinition sharedDef = new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null ); Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, sharedDef); @@ -1134,11 +1140,13 @@ public void fdv2_modeSwitchDoesNotIncludeInitializers() throws Exception { Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(inputs -> null), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( Collections.>singletonList(inputs -> null), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java index 59a83788..a724ea87 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java @@ -429,6 +429,69 @@ public void payloadFilterNotAddedWhenEmpty() throws Exception { } } + // ---- x-ld-fd-fallback header detection ---- + + @Test + public void fdv1FallbackHeaderOnSuccess() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isSuccess()); + assertTrue(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderAbsent() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_EVENTS_JSON))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isSuccess()); + assertFalse(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderCaseInsensitive() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "True"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertTrue(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderOnFailure() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.status(500)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertFalse(response.isSuccess()); + assertTrue(response.isFdv1Fallback()); + } + } + } + + @Test + public void fdv1FallbackHeaderFalseValue() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "false"), + Handlers.bodyJson(VALID_EVENTS_JSON)))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + FDv2Requestor.FDv2PayloadResponse response = requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + assertFalse(response.isFdv1Fallback()); + } + } + } + @Test public void evaluationReasonsAddedToRequest() throws Exception { try (HttpServer server = HttpServer.start(Handlers.bodyJson(EMPTY_EVENTS_JSON))) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java new file mode 100644 index 00000000..4c08f769 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv1PollingSynchronizerTest.java @@ -0,0 +1,264 @@ +package com.launchdarkly.sdk.android; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.subsystems.Callback; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.SourceResultType; +import com.launchdarkly.sdk.fdv2.SourceSignal; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.io.IOException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link FDv1PollingSynchronizer}. + */ +public class FDv1PollingSynchronizerTest { + + @Rule + public Timeout globalTimeout = Timeout.seconds(10); + + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + + private static final LDContext CONTEXT = LDContext.create("test-context-key"); + private static final LDLogger LOGGER = LDLogger.none(); + + private static final String VALID_FDV1_JSON = + "{\"flag1\":{\"key\":\"flag1\",\"version\":1,\"value\":true}," + + "\"flag2\":{\"key\":\"flag2\",\"version\":2,\"value\":false}}"; + + private static final String SINGLE_FLAG_JSON = + "{\"flag1\":{\"key\":\"flag1\",\"version\":1,\"value\":true}}"; + + @After + public void tearDown() { + executor.shutdownNow(); + } + + /** + * Test double that queues responses for the synchronizer to consume. + * Mirrors the {@code MockFetcher} pattern used in {@link PollingDataSourceTest}. + */ + private static class MockFetcher implements FeatureFetcher { + final BlockingQueue responses = new LinkedBlockingQueue<>(); + + void queueSuccess(String json) { + responses.add(json); + } + + void queueError(Throwable error) { + responses.add(error); + } + + @Override + public void fetch(LDContext context, Callback callback) { + Object response = responses.poll(); + if (response == null) { + callback.onError(new RuntimeException("MockFetcher: no responses queued")); + return; + } + if (response instanceof Throwable) { + callback.onError((Throwable) response); + } else { + callback.onSuccess((String) response); + } + } + + @Override + public void close() {} + } + + private FDv1PollingSynchronizer makeSynchronizer(MockFetcher fetcher) { + return makeSynchronizer(fetcher, 0, 60_000); + } + + private FDv1PollingSynchronizer makeSynchronizer(MockFetcher fetcher, long initialDelay, long pollInterval) { + return new FDv1PollingSynchronizer( + CONTEXT, fetcher, executor, + initialDelay, pollInterval, LOGGER); + } + + @Test + public void successfulPollReturnsChangeSet() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess(VALID_FDV1_JSON); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + Future future = sync.next(); + FDv2SourceResult result = future.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + assertNotNull(result.getChangeSet().getData()); + assertEquals(2, result.getChangeSet().getData().size()); + assertTrue(result.getChangeSet().getData().containsKey("flag1")); + assertTrue(result.getChangeSet().getData().containsKey("flag2")); + assertFalse(result.isFdv1Fallback()); + } finally { + sync.close(); + } + } + + @Test + public void nonRecoverableHttpErrorReturnsTerminalError() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDInvalidResponseCodeFailure( + "Unexpected response", 401, true)); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + } finally { + sync.close(); + } + } + + @Test + public void recoverableHttpErrorReturnsInterrupted() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDInvalidResponseCodeFailure( + "Unexpected response", 500, true)); + fetcher.queueSuccess(SINGLE_FLAG_JSON); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 0, 100); + try { + FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result1.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); + + FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); + assertNotNull(result2.getChangeSet()); + assertEquals(1, result2.getChangeSet().getData().size()); + } finally { + sync.close(); + } + } + + @Test + public void closeReturnsShutdown() throws Exception { + MockFetcher fetcher = new MockFetcher(); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 60_000, 60_000); + sync.close(); + + FDv2SourceResult result = sync.next().get(1, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); + } + + @Test + public void invalidJsonReturnsInterrupted() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess("not valid json"); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + } finally { + sync.close(); + } + } + + @Test + public void emptyResponseReturnsChangeSetWithNoFlags() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess("{}"); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + assertTrue(result.getChangeSet().getData().isEmpty()); + } finally { + sync.close(); + } + } + + @Test + public void networkErrorReturnsInterrupted() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDFailure("Exception while fetching flags", + new IOException("connection refused"), + LDFailure.FailureType.NETWORK_FAILURE)); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + } finally { + sync.close(); + } + } + + @Test + public void terminalErrorStopsPolling() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueError(new LDInvalidResponseCodeFailure( + "Unexpected response", 401, true)); + fetcher.queueSuccess(SINGLE_FLAG_JSON); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 0, 100); + try { + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + + Thread.sleep(500); + + assertEquals("second response should still be in the queue (never fetched)", + 1, fetcher.responses.size()); + } finally { + sync.close(); + } + } + + @Test + public void pollsRepeatAtConfiguredInterval() throws Exception { + MockFetcher fetcher = new MockFetcher(); + fetcher.queueSuccess(SINGLE_FLAG_JSON); + fetcher.queueSuccess(VALID_FDV1_JSON); + + FDv1PollingSynchronizer sync = makeSynchronizer(fetcher, 0, 200); + try { + FDv2SourceResult result1 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result1.getResultType()); + assertEquals(1, result1.getChangeSet().getData().size()); + + FDv2SourceResult result2 = sync.next().get(5, TimeUnit.SECONDS); + assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); + assertEquals(2, result2.getChangeSet().getData().size()); + } finally { + sync.close(); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index fae850e5..08680858 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -37,7 +37,7 @@ public class FDv2DataSourceBuilderTest { private ClientContext makeClientContext() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - return new ClientContext( + ClientContext base = new ClientContext( "mobile-key", ENV_REPORTER, logging.logger, @@ -52,6 +52,7 @@ private ClientContext makeClientContext() { config.serviceEndpoints, false ); + return new ClientContextImpl(base, null, null, new MockPlatformState(), null, null); } @Test @@ -67,7 +68,8 @@ public void customModeTable_buildsCorrectly() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); @@ -80,7 +82,8 @@ public void startingMode_notInTable_throws() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -97,11 +100,13 @@ public void setActiveMode_buildUsesSpecifiedMode() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(inputs -> null), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -115,7 +120,8 @@ public void setActiveMode_withoutInitializers_buildsWithEmptyInitializers() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(inputs -> null), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -129,7 +135,8 @@ public void defaultBehavior_usesStartingMode() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(inputs -> null), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); @@ -141,11 +148,13 @@ public void defaultBehavior_usesStartingMode() { public void getModeDefinition_returnsCorrectDefinition() { ModeDefinition streamingDef = new ModeDefinition( Collections.>singletonList(inputs -> null), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null ); ModeDefinition pollingDef = new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null ); Map customTable = new LinkedHashMap<>(); @@ -162,7 +171,8 @@ public void getModeDefinition_returnsCorrectDefinition() { public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { ModeDefinition sharedDef = new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null ); Map customTable = new LinkedHashMap<>(); @@ -224,6 +234,68 @@ private static ScheduledExecutorService getSharedExecutor(FDv2DataSourceBuilder } } + // ---- Default mode table FDv1 fallback slot configuration ---- + + @Test + public void defaultModeTable_streamingHasFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition streaming = builder.getModeDefinition(ConnectionMode.STREAMING); + assertNotNull(streaming); + assertEquals(1, streaming.getInitializers().size()); + assertEquals(2, streaming.getSynchronizers().size()); + assertNotNull(streaming.getFdv1FallbackSynchronizer()); + } + + @Test + public void defaultModeTable_pollingHasFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition polling = builder.getModeDefinition(ConnectionMode.POLLING); + assertNotNull(polling); + assertEquals(0, polling.getInitializers().size()); + assertEquals(1, polling.getSynchronizers().size()); + assertNotNull(polling.getFdv1FallbackSynchronizer()); + } + + @Test + public void defaultModeTable_backgroundHasFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition background = builder.getModeDefinition(ConnectionMode.BACKGROUND); + assertNotNull(background); + assertEquals(0, background.getInitializers().size()); + assertEquals(1, background.getSynchronizers().size()); + assertNotNull(background.getFdv1FallbackSynchronizer()); + } + + @Test + public void defaultModeTable_offlineHasNoFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition offline = builder.getModeDefinition(ConnectionMode.OFFLINE); + assertNotNull(offline); + assertEquals(0, offline.getInitializers().size()); + assertEquals(0, offline.getSynchronizers().size()); + assertNull(offline.getFdv1FallbackSynchronizer()); + } + + @Test + public void defaultModeTable_oneShotHasNoFdv1Fallback() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + ModeDefinition oneShot = builder.getModeDefinition(ConnectionMode.ONE_SHOT); + assertNotNull(oneShot); + assertEquals(1, oneShot.getInitializers().size()); + assertEquals(0, oneShot.getSynchronizers().size()); + assertNull(oneShot.getFdv1FallbackSynchronizer()); + } + @Test public void threeArgConstructor_retainsCustomResolutionTable() { ModeResolutionTable custom = new ModeResolutionTable( @@ -233,11 +305,13 @@ public void threeArgConstructor_retainsCustomResolutionTable() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); customTable.put(ConnectionMode.OFFLINE, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList() + Collections.>emptyList(), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder( @@ -253,7 +327,8 @@ public void setActiveMode_notInTable_throws() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), - Collections.>singletonList(inputs -> null) + Collections.>singletonList(inputs -> null), + null )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java index 77126e47..7acade43 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java @@ -46,16 +46,16 @@ public void tearDown() { // ---- helpers ---- private static FDv2SourceResult interrupted() { - return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(new RuntimeException("interrupted"))); + return FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(new RuntimeException("interrupted")), false); } private static FDv2SourceResult terminalError() { - return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("terminal"))); + return FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("terminal")), false); } private static FDv2SourceResult changeSet() { return FDv2SourceResult.changeSet( - new ChangeSet<>(ChangeSetType.None, Selector.EMPTY, new HashMap<>(), null, true)); + new ChangeSet<>(ChangeSetType.None, Selector.EMPTY, new HashMap<>(), null, true), false); } // ==== FallbackCondition ==== @@ -145,7 +145,7 @@ public void fallback_informWithNonInterruptedStatus_doesNotStartTimer() throws I FallbackCondition condition = new FallbackCondition(executor, 0); LDAwaitFuture future = condition.getFuture(); - condition.inform(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("bye"))); + condition.inform(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("bye"), false)); try { future.get(100, TimeUnit.MILLISECONDS); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index e11c6afc..282fa559 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -82,6 +82,7 @@ private FDv2DataSource buildDataSource( CONTEXT, initializers, synchronizers, + null, sink, executor, logging.logger); @@ -97,6 +98,7 @@ private FDv2DataSource buildDataSource( CONTEXT, initializers, synchronizers, + null, sink, executor, logging.logger, @@ -145,11 +147,11 @@ private static ChangeSet> makeFullChangeSet(Map run() { @Override public void close() { - future.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + future.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); } } @@ -209,7 +211,7 @@ private static class MockSynchronizer implements Synchronizer { public LDAwaitFuture next() { if (closed) { LDAwaitFuture f = new LDAwaitFuture<>(); - f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); return f; } if (!resultReturned) { @@ -274,7 +276,7 @@ public void close() { if (pendingFuture != null) { LDAwaitFuture f = pendingFuture; pendingFuture = null; - f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())); + f.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); } } } @@ -288,7 +290,7 @@ public void firstInitializerProvidesData_startSucceedsAndSinkReceivesApply() thr items.put(flag.getKey(), flag); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -310,7 +312,7 @@ public void firstInitializerFailsSecondInitializerSucceeds() throws Exception { () -> new MockInitializer(new RuntimeException("first fails")), () -> { secondCalled.set(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -329,10 +331,10 @@ public void firstInitializerSucceedsWithSelectorSecondInitializerNotInvoked() th FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), () -> { secondCalled.set(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -355,7 +357,7 @@ public void allInitializersFailSwitchesToSynchronizers() throws Exception { () -> new MockInitializer(new RuntimeException("second fails"))), Collections.singletonList(() -> { syncCalled.set(true); - return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))); + return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); @@ -372,7 +374,7 @@ public void allInitializersFailWithNoSynchronizers_startReportsNotInitialized() FDv2DataSource dataSource = buildDataSource(sink, Collections.singletonList( - () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail"))))), + () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -388,8 +390,8 @@ public void secondInitializerSucceeds_afterFirstReturnsTerminalError() throws Ex FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("first fails")))), - () -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items)))), + () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("first fails")), false)), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -405,7 +407,7 @@ public void oneInitializerNoSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -425,8 +427,8 @@ public void noInitializers_synchronizerProvidesData_startSucceedsAndSinkReceives FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeFullChangeSet(items)), - FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())))); + FDv2SourceResult.changeSet(makeFullChangeSet(items), false), + FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -456,9 +458,9 @@ public void oneInitializerOneSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -476,7 +478,7 @@ public void emptyInitializerListSkipsToSynchronizers() throws Exception { Collections.emptyList(), Collections.singletonList(() -> { syncCalled.set(true); - return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))); + return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); @@ -489,10 +491,10 @@ public void emptyInitializerListSkipsToSynchronizers() throws Exception { public void fallbackAndRecoveryTasksWellBehaved() throws Exception { // First sync: changeset then INTERRUPTED; second sync: changeset; recovery brings back first MockQueuedSynchronizer firstSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted()); MockQueuedSynchronizer secondSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); AtomicInteger firstCallCount = new AtomicInteger(0); AtomicInteger secondCallCount = new AtomicInteger(0); @@ -530,7 +532,7 @@ public void canDisposeWhenSynchronizersFallingBack() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted())), 1, 2); @@ -549,10 +551,10 @@ public void terminalErrorBlocksSynchronizer() throws Exception { Collections.emptyList(), Arrays.asList( () -> { callOrder.offer(1); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), terminalError()); }, () -> { callOrder.offer(2); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); })); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -603,7 +605,7 @@ public void blockedSynchronizerSkippedInRotation() throws Exception { Arrays.asList( () -> { firstCallCount.incrementAndGet(); return new MockQueuedSynchronizer(terminalError()); }, () -> { secondCallCount.incrementAndGet(); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); })); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); })); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -634,10 +636,10 @@ public void recoveryResetsToFirstAvailableSynchronizer() throws Exception { AtomicInteger secondCallCount = new AtomicInteger(0); MockQueuedSynchronizer firstSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted()); MockQueuedSynchronizer secondSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), @@ -666,10 +668,10 @@ public void fallbackMovesToNextSynchronizer() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted()), () -> { secondCalledQueue.offer(true); return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); }), + FDv2SourceResult.changeSet(makeChangeSet(false), false)); }), 1, 300); AwaitableCallback startCallback = startDataSource(dataSource); @@ -692,7 +694,7 @@ public void stop_closesSynchronizerAndCallsShutDown() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -714,7 +716,7 @@ public void stopAfterInitializersCompletesImmediately() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -730,7 +732,7 @@ public void closeWhileSynchronizerRunningShutdownsSource() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))) { + Collections.singletonList(() -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { @Override public void close() { syncClosed.set(true); @@ -751,7 +753,7 @@ public void multipleStopCallsAreIdempotent() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -789,7 +791,7 @@ public void dataSourceClosedDuringSynchronizationReportsOffWithoutError() throws FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -811,7 +813,7 @@ public void stopInterruptsConditionWaiting() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted())), 120, 300); @@ -833,7 +835,7 @@ public void startedFlagPreventsMultipleRuns() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.singletonList(() -> { runCount.incrementAndGet(); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -858,7 +860,7 @@ public void startBeforeRunCompletesAllComplete() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback cb1 = new AwaitableCallback<>(); AwaitableCallback cb2 = new AwaitableCallback<>(); @@ -877,7 +879,7 @@ public void multipleStartCallsEventuallyComplete() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); List> callbacks = new ArrayList<>(); for (int i = 0; i < 5; i++) { @@ -899,7 +901,7 @@ public void concurrentStopAndStartHandledSafely() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); stopDataSource(dataSource); // stop immediately after starting @@ -916,7 +918,7 @@ public void dataSourceUpdatesApplyThreadSafe() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); List results = new ArrayList<>(); for (int i = 0; i < 10; i++) { - results.add(FDv2SourceResult.changeSet(makeChangeSet(false))); + results.add(FDv2SourceResult.changeSet(makeChangeSet(false), false)); } MockQueuedSynchronizer sync = new MockQueuedSynchronizer(); for (FDv2SourceResult r : results) { @@ -947,7 +949,7 @@ public void initializerThrowsExecutionException_secondInitializerSucceeds() thro FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( () -> { firstCalled.set(true); return new MockInitializer(new RuntimeException("execution exception")); }, - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -965,7 +967,7 @@ public void initializerThrowsInterruptedException_secondInitializerSucceeds() th FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( () -> { firstCalled.set(true); return new MockInitializer(new InterruptedException("interrupted")); }, - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true)))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -983,7 +985,7 @@ public void synchronizerThrowsExecutionException_nextSynchronizerSucceeds() thro Collections.emptyList(), Arrays.asList( () -> new MockSynchronizer(new RuntimeException("execution exception")), - () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1001,7 +1003,7 @@ public void synchronizerThrowsInterruptedException_nextSynchronizerSucceeds() th Collections.emptyList(), Arrays.asList( () -> { firstCalled.set(true); return new MockSynchronizer(new InterruptedException("interrupted")); }, - () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1024,7 +1026,7 @@ public void activeSourceClosedWhenSwitchingSynchronizers() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), terminalError()) { @Override public void close() { @@ -1032,7 +1034,7 @@ public void close() { super.close(); } }, - () -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + () -> new MockQueuedSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1050,7 +1052,7 @@ public void activeSourceClosedOnShutdown() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))) { + FDv2SourceResult.changeSet(makeChangeSet(false), false)) { @Override public void close() { syncClosed.set(true); @@ -1104,7 +1106,7 @@ public void conditionsClosedAfterSynchronizerLoop() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), terminalError())), 1, 2); @@ -1121,9 +1123,9 @@ public void conditionsInformedOfAllResults() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), + FDv2SourceResult.changeSet(makeChangeSet(false), false), interrupted(), - FDv2SourceResult.changeSet(makeChangeSet(false)))), + FDv2SourceResult.changeSet(makeChangeSet(false), false))), 10, 20); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1142,7 +1144,7 @@ public void conditionsClosedOnException() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockSynchronizer(new RuntimeException("error")), - () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), 1, 2); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1159,7 +1161,7 @@ public void primeSynchronizerHasNoRecoveryCondition() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); MockQueuedSynchronizer firstSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)); FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), @@ -1187,8 +1189,8 @@ public void singleSynchronizerHasNoConditions() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)))), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false))), 1, 2); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1206,8 +1208,8 @@ public void conditionFutureNeverCompletesWhenNoConditions() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)))), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false))), 1, 2); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1229,10 +1231,10 @@ public void selectorNonEmptyCompletesInitialization() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))), + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), () -> { secondCalledQueue.offer(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false))); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); }), Collections.emptyList()); @@ -1249,7 +1251,7 @@ public void initializerChangeSetWithoutSelectorCompletesIfLastInitializer() thro MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1265,7 +1267,7 @@ public void synchronizerChangeSetAlwaysCompletesStartFuture() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false))))); + Collections.singletonList(() -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1279,9 +1281,9 @@ public void multipleChangeSetsAppliedInOrder() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1298,9 +1300,9 @@ public void goodbyeStatusHandledGracefully() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("server-requested")), - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.status(FDv2SourceResult.Status.goodbye("server-requested"), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1319,8 +1321,8 @@ public void shutdownStatusFallsBackToNextSynchronizer() throws Exception { Collections.emptyList(), Arrays.asList( () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.status(FDv2SourceResult.Status.shutdown())), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)), () -> { secondSyncCalled.set(true); return new MockQueuedSynchronizer(); })); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1340,7 +1342,7 @@ public void statusTransitionsToValidAfterInitialization() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false)))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1361,7 +1363,7 @@ public void statusIncludesErrorInfoOnFailure() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(terminalErr))))); + FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(terminalErr), false)))); AwaitableCallback startCallback = startDataSource(dataSource); awaitExpectingError(startCallback); @@ -1383,9 +1385,9 @@ public void statusRemainsValidDuringSynchronizerOperation() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1404,8 +1406,8 @@ public void statusTransitionsFromValidToOffWhenAllSynchronizersFail() throws Exc FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)), - FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(err))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false), + FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(err), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); // changeset arrives first, so start succeeds @@ -1425,7 +1427,7 @@ public void stopReportsOffStatus() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false), false)))); AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); @@ -1439,6 +1441,226 @@ public void stopReportsOffStatus() throws Exception { assertEquals(DataSourceState.OFF, offStatus); } + // ---- FDv1 fallback ---- + + @Test + public void fdv1FallbackDuringInitializationSkipsRemainingInitializers() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicBoolean secondInitCalled = new AtomicBoolean(false); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Arrays.>asList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), true)), + () -> { + secondInitCalled.set(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + }), + Collections.>singletonList( + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertFalse(secondInitCalled.get()); + + sink.awaitApplyCount(2, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackOnTerminalErrorDuringInitializationSwitchesToFdv1() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicBoolean secondInitCalled = new AtomicBoolean(false); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + // First initializer returns TERMINAL_ERROR with fdv1Fallback=true. + // The fallback should be honored and remaining initializers skipped. + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Arrays.>asList( + () -> new MockInitializer(FDv2SourceResult.status( + FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), true)), + () -> { + secondInitCalled.set(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + }), + Collections.>singletonList( + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertFalse(secondInitCalled.get()); + + // The FDv1 synchronizer should receive data, proving it was activated. + sink.awaitApplyCount(1, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackDuringInitializationWithNonEmptySelectorSwitchesToFdv1() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + // Initializer returns a fully current payload (non-empty selector) AND fdv1Fallback=true. + // The fallback should still be detected even though the non-empty selector would normally + // complete initialization immediately. + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>singletonList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), true))), + Collections.>singletonList( + () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + // The FDv1 synchronizer should receive data, proving it was activated. + sink.awaitApplyCount(2, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), true)); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + DataSourceState secondValid = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(DataSourceState.VALID, secondValid); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackOnTerminalErrorSwitchesToFdv1Synchronizer() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + // FDv2 synchronizer returns a terminal error (e.g., HTTP 401) with fdv1Fallback=true. + // The fallback should still be honored even though the error is non-recoverable. + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.status( + FDv2SourceResult.Status.terminalError(new RuntimeException("401")), + true)); + + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync), + () -> fdv1Sync, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + + // The terminal error from the FDv2 synchronizer produces INTERRUPTED first. + assertEquals(DataSourceState.INTERRUPTED, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + // Then the FDv1 synchronizer takes over and produces VALID. + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackNotTriggeredWhenAlreadyOnFdv1() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), true)); + + AtomicInteger fdv1BuildCount = new AtomicInteger(0); + MockQueuedSynchronizer fdv1Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)); + + FDv2DataSource dataSource = new FDv2DataSource( + CONTEXT, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync), + () -> { + fdv1BuildCount.incrementAndGet(); + return fdv1Sync; + }, + sink, + executor, + logging.logger); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + assertEquals(1, fdv1BuildCount.get()); + + stopDataSource(dataSource); + } + + @Test + public void fdv1FallbackNotTriggeredWhenNoFdv1SlotExists() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + MockQueuedSynchronizer fdv2Sync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), true)); + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.>emptyList(), + Collections.>singletonList(() -> fdv2Sync)); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals(DataSourceState.VALID, sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + stopDataSource(dataSource); + } + @Test public void needsRefresh_sameContext_returnsFalse() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java index eb24c43f..86852f97 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingBaseTest.java @@ -18,7 +18,9 @@ import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; /** * Unit tests for {@link FDv2PollingBase#doPoll(FDv2Requestor, LDLogger, Selector, boolean)}. @@ -88,7 +90,7 @@ private static FDv2SourceResult doPoll(MockFDv2Requestor requestor, boolean oneS @Test public void http304_returnsNoneChangeSet() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified()); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); FDv2SourceResult result = doPoll(requestor, true); @@ -101,7 +103,7 @@ public void http304_returnsNoneChangeSet() { public void http304_preservesCurrentSelector() { Selector selector = Selector.make(5, "cached-state"); MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified()); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); FDv2SourceResult result = FDv2PollingBase.doPoll(requestor, LOGGER, selector, false); @@ -116,7 +118,7 @@ public void http304_preservesCurrentSelector() { @Test public void recoverableHttpError_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -128,7 +130,7 @@ public void recoverableHttpError_oneShot_returnsTerminalError() { @Test public void recoverableHttpError_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -140,7 +142,7 @@ public void recoverableHttpError_notOneShot_returnsInterrupted() { @Test public void nonRecoverableHttpError_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -153,7 +155,7 @@ public void nonRecoverableHttpError_oneShot_returnsTerminalError() { public void nonRecoverableHttpError_notOneShot_alsoReturnsTerminalError() { // Non-recoverable (e.g. 401) always maps to TERMINAL_ERROR regardless of oneShot. MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401)); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -194,7 +196,7 @@ public void networkError_notOneShot_returnsInterrupted() { public void emptyEventsArray_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - Collections.emptyList(), 200)); + Collections.emptyList(), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -207,7 +209,7 @@ public void emptyEventsArray_oneShot_returnsTerminalError() { public void emptyEventsArray_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - Collections.emptyList(), 200)); + Collections.emptyList(), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -223,7 +225,7 @@ public void eventsWithNoChangesetAfterLoop_oneShot_returnsTerminalError() { // xfer-full server-intent without payload-transferred: loop ends with no CHANGESET. MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200)); + parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -236,7 +238,7 @@ public void eventsWithNoChangesetAfterLoop_oneShot_returnsTerminalError() { public void eventsWithNoChangesetAfterLoop_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200)); + parseEvents(XFER_FULL_NO_PAYLOAD_TRANSFERRED_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -251,7 +253,7 @@ public void eventsWithNoChangesetAfterLoop_notOneShot_returnsInterrupted() { public void goodbyeEvent_returnsGoodbye() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(GOODBYE_JSON), 200)); + parseEvents(GOODBYE_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -267,7 +269,7 @@ public void goodbyeEvent_returnsGoodbye() { public void serverErrorEvent_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(ERROR_EVENT_JSON), 200)); + parseEvents(ERROR_EVENT_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -280,7 +282,7 @@ public void serverErrorEvent_oneShot_returnsTerminalError() { public void serverErrorEvent_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(ERROR_EVENT_JSON), 200)); + parseEvents(ERROR_EVENT_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -295,7 +297,7 @@ public void serverErrorEvent_notOneShot_returnsInterrupted() { public void successfulXferFull_emptyPayload_returnsChangeSet() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_NONE_JSON), 200)); + parseEvents(XFER_NONE_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -307,7 +309,7 @@ public void successfulXferFull_emptyPayload_returnsChangeSet() { public void successfulXferFull_withData_returnsChangeSet() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(XFER_FULL_EMPTY_JSON), 200)); + parseEvents(XFER_FULL_EMPTY_JSON), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -329,7 +331,7 @@ public void internalError_malformedPayloadTransferred_oneShot_returnsTerminalErr MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(malformedPayloadTransferredJson), 200)); + parseEvents(malformedPayloadTransferredJson), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -348,7 +350,7 @@ public void internalError_malformedPayloadTransferred_notOneShot_returnsInterrup MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(malformedPayloadTransferredJson), 200)); + parseEvents(malformedPayloadTransferredJson), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -367,7 +369,7 @@ public void unrecognizedEventType_oneShot_returnsTerminalError() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(unknownEventJson), 200)); + parseEvents(unknownEventJson), 200, false)); FDv2SourceResult result = doPoll(requestor, true); @@ -382,7 +384,7 @@ public void unrecognizedEventType_notOneShot_returnsInterrupted() { MockFDv2Requestor requestor = new MockFDv2Requestor(); requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( - parseEvents(unknownEventJson), 200)); + parseEvents(unknownEventJson), 200, false)); FDv2SourceResult result = doPoll(requestor, false); @@ -397,11 +399,61 @@ public void unrecognizedEventType_notOneShot_returnsInterrupted() { public void selectorIsPassedToRequestor() { Selector selector = Selector.make(7, "my-state"); MockFDv2Requestor requestor = new MockFDv2Requestor(); - requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified()); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.notModified(false)); FDv2PollingBase.doPoll(requestor, LOGGER, selector, true); assertEquals(1, requestor.receivedSelectors.size()); assertEquals(selector, requestor.receivedSelectors.poll()); } + + // ---- fdv1Fallback propagation ---- + + @Test + public void fdv1FallbackPropagatedOnSuccess() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(XFER_FULL_EMPTY_JSON), 200, true)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + } + + @Test + public void fdv1FallbackFalseByDefault() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(XFER_FULL_EMPTY_JSON), 200, false)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertFalse(result.isFdv1Fallback()); + } + + @Test + public void fdv1FallbackPropagatedOnHttpError() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.failure(500, true)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + } + + @Test + public void fdv1FallbackPropagatedOnGoodbye() { + MockFDv2Requestor requestor = new MockFDv2Requestor(); + requestor.queueResponse(FDv2Requestor.FDv2PayloadResponse.success( + parseEvents(GOODBYE_JSON), 200, true)); + + FDv2SourceResult result = doPoll(requestor, false); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.GOODBYE, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java index 3f258a02..acab2918 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java @@ -70,7 +70,7 @@ public void successfulPoll_futureCompletesWithChangeSet() throws Exception { // Unblock the in-flight poll with a successful response. LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result = future.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); @@ -87,7 +87,7 @@ public void recoverableHttpError_futureCompletesWithTerminalError() throws Excep Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(500)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); FDv2SourceResult result = future.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); @@ -103,7 +103,7 @@ public void nonRecoverableHttpError_futureCompletesWithTerminalError() throws Ex Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); FDv2SourceResult result = future.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); @@ -148,7 +148,7 @@ public void closeBeforePollCompletes_futureCompletesWithShutdown() throws Except assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); // Allow the background thread to unblock cleanly. - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); } @Test @@ -159,7 +159,7 @@ public void closeAfterPollCompletes_doesNotThrow() throws Exception { Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); // Wait for poll to complete, then close — should not throw. future.get(1, TimeUnit.SECONDS); @@ -216,7 +216,7 @@ public void onlyOnePollIsIssued() throws Exception { Future future = initializer.run(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); future.get(1, TimeUnit.SECONDS); @@ -236,7 +236,7 @@ public void closeCallsRequestorClose() throws Exception { LDAwaitFuture pollFuture = requestor.awaitNextPoll(); initializer.close(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); assertEquals(1, requestor.closeCount); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java index ed982f11..ffa8cfd2 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java @@ -76,7 +76,7 @@ public void pollResult_deliveredViaNext() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result = nextFuture.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); @@ -94,14 +94,14 @@ public void consecutivePolls_deliveredInOrder() throws Exception { // First poll Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result1.getResultType()); // Second poll (scheduleWithFixedDelay fires after interval elapses) Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + poll2.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -141,7 +141,7 @@ public void closeWhileNextWaiting_nextReturnsShutdown() throws Exception { assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); // Unblock the background thread. - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); } @Test @@ -152,7 +152,7 @@ public void closeCallsRequestorClose() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); synchronizer.close(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); nextFuture.get(1, TimeUnit.SECONDS); assertEquals(1, requestor.closeCount); @@ -169,7 +169,7 @@ public void terminalError_stopsPollingAndDeliveredViaNext() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); FDv2SourceResult result = nextFuture.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.STATUS, result.getResultType()); @@ -188,7 +188,7 @@ public void terminalError_subsequentNextAlsoReturnsTerminalResult() throws Excep try { Future firstFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401)); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.failure(401, false)); firstFuture.get(1, TimeUnit.SECONDS); // Second next() — shutdownFuture is already completed with TERMINAL_ERROR. @@ -215,14 +215,14 @@ public void interruptedResult_doesNotStopPolling() throws Exception { // First poll → INTERRUPTED (recoverable 500). Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.failure(500)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.failure(500, false)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); // Second poll fires normally (task was not cancelled). Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -296,7 +296,7 @@ public void close() throws IOException {} Future nextFuture = synchronizer.next(); LDAwaitFuture poll2 = delegate.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result = nextFuture.get(2, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); @@ -324,14 +324,14 @@ public void internalError_malformedPayloadTransferred_continuesPolling() throws // First poll → INTERNAL_ERROR → INTERRUPTED (task must NOT be cancelled). Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(malformedJson), 200)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(malformedJson), 200, false)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); // Second poll fires normally (task was not cancelled). Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -351,13 +351,13 @@ public void unrecognizedEventType_continuesPolling() throws Exception { try { Future nextFuture1 = synchronizer.next(); LDAwaitFuture poll1 = requestor.awaitNextPoll(); - poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(unknownEventJson), 200)); + poll1.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(unknownEventJson), 200, false)); FDv2SourceResult result1 = nextFuture1.get(1, TimeUnit.SECONDS); assertEquals(SourceSignal.INTERRUPTED, result1.getStatus().getState()); Future nextFuture2 = synchronizer.next(); LDAwaitFuture poll2 = requestor.awaitNextPoll(); - poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200)); + poll2.set(FDv2Requestor.FDv2PayloadResponse.success(parseEvents(XFER_FULL_JSON), 200, false)); FDv2SourceResult result2 = nextFuture2.get(1, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result2.getResultType()); } finally { @@ -378,7 +378,7 @@ public void selectorIsPassedToRequestor() throws Exception { Future nextFuture = synchronizer.next(); LDAwaitFuture pollFuture = requestor.awaitNextPoll(); - pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified()); + pollFuture.set(FDv2Requestor.FDv2PayloadResponse.notModified(false)); nextFuture.get(1, TimeUnit.SECONDS); assertEquals(selector, requestor.receivedSelectors.poll(1, TimeUnit.SECONDS)); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java index 16ad2e9f..ddc79bbb 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java @@ -1208,6 +1208,116 @@ public void nonRecoverableHttpErrorRecordsDiagnostic() throws Exception { } } + // ---- x-ld-fd-fallback header detection ---- + + @Test + public void fdv1FallbackHeaderOnMessageEvent() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + + sync.close(); + } + } + + @Test + public void fdv1FallbackHeaderAbsentOnMessageEvent() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertFalse(result.isFdv1Fallback()); + + sync.close(); + } + } + + @Test + public void fdv1FallbackHeaderOnHttpError() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.status(401)))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.TERMINAL_ERROR, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + + sync.close(); + } + } + + @Test + public void fdv1FallbackHeaderOnRecoverableHttpError() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.sequential( + Handlers.all( + Handlers.header("x-ld-fd-fallback", "true"), + Handlers.status(503)), + Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen())))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + assertTrue(result.isFdv1Fallback()); + + sync.close(); + } + } + + @Test + public void fdv1FallbackHeaderCaseInsensitive() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("x-ld-fd-fallback", "TRUE"), + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri()); + FDv2SourceResult result = sync.next().get(5, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.isFdv1Fallback()); + + sync.close(); + } + } + private static String makeEvent(String type, String data) { return "event: " + type + "\ndata: " + data; } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java index f0def4c8..10b5dc05 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilderTest.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.android.integrations; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -62,6 +64,7 @@ public void customizeConnectionMode_replacesModeDefinition() { ModeDefinition bg = table.get(ConnectionMode.BACKGROUND); assertTrue(bg.getInitializers().isEmpty()); assertTrue(bg.getSynchronizers().isEmpty()); + assertNull(bg.getFdv1FallbackSynchronizer()); } @Test @@ -76,6 +79,48 @@ public void disableBackgroundUpdating_clearsBackgroundPipelineEvenWithOverride() ModeDefinition bg = table.get(ConnectionMode.BACKGROUND); assertTrue(bg.getInitializers().isEmpty()); assertTrue(bg.getSynchronizers().isEmpty()); + assertNull(bg.getFdv1FallbackSynchronizer()); + } + + @Test + public void customizeConnectionMode_preservesFdv1FallbackWhenModeHasSynchronizers() { + DataSystemBuilder b = Components.dataSystem() + .customizeConnectionMode( + ConnectionMode.STREAMING, + DataSystemComponents.customMode() + .synchronizers(DataSystemComponents.pollingSynchronizer())); + Map table = b.buildModeTable(false); + ModeDefinition streaming = table.get(ConnectionMode.STREAMING); + assertEquals(0, streaming.getInitializers().size()); + assertEquals(1, streaming.getSynchronizers().size()); + assertNotNull(streaming.getFdv1FallbackSynchronizer()); + } + + @Test + public void customizeConnectionMode_preservesFdv1FallbackWhenModeHasOnlyInitializers() { + DataSystemBuilder b = Components.dataSystem() + .customizeConnectionMode( + ConnectionMode.STREAMING, + DataSystemComponents.customMode() + .initializers(DataSystemComponents.pollingInitializer())); + Map table = b.buildModeTable(false); + ModeDefinition streaming = table.get(ConnectionMode.STREAMING); + assertEquals(1, streaming.getInitializers().size()); + assertEquals(0, streaming.getSynchronizers().size()); + assertNotNull(streaming.getFdv1FallbackSynchronizer()); + } + + @Test + public void customizeConnectionMode_nullsFdv1FallbackWhenModeIsEmpty() { + DataSystemBuilder b = Components.dataSystem() + .customizeConnectionMode( + ConnectionMode.STREAMING, + DataSystemComponents.customMode()); + Map table = b.buildModeTable(false); + ModeDefinition streaming = table.get(ConnectionMode.STREAMING); + assertTrue(streaming.getInitializers().isEmpty()); + assertTrue(streaming.getSynchronizers().isEmpty()); + assertNull(streaming.getFdv1FallbackSynchronizer()); } @Test diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java index 7c0d12d3..c08379a8 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java @@ -83,7 +83,7 @@ public void setAndNotifyForegroundChangeListeners(boolean foreground) { @Override public File getCacheDir() { - return null; + return new File(System.getProperty("java.io.tmpdir")); } @Override