Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ private void handleResponse(
@NonNull LDAwaitFuture<FDv2PayloadResponse> future) {
try {
int code = response.code();
boolean fdv1Fallback = isFdv1Fallback(response);
FDv1FallbackTTL fdv1Fallback = isFdv1Fallback(response)
? FDv1FallbackTTL.fromHeader(response.header(HeaderConstants.FDV1_FALLBACK_TTL_HEADER))
: null;

if (code == 304) {
logger.debug("FDv2 polling: 304 Not Modified");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.launchdarkly.sdk.android;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;

import java.util.Random;

/**
* The TTL of a server-directed FDv1 fallback: how long the SDK stays off FDv2 before attempting
* to recover to it. A fallback is never indefinite.
*/
public final class FDv1FallbackTTL {
/** The TTL used whenever the service supplies no usable one, before jitter. */
static final long DEFAULT_TTL_SECONDS = 60 * 60;

private static final String GOODBYE_TTL_FIELD = "protocolFallbackTTL";

private static final Random random = new Random();

private final long seconds;

private FDv1FallbackTTL(long seconds) {
this.seconds = seconds;
}

/**
* A service-supplied TTL in (0, 1 hour] is used as given, because the service has already
* jittered it. Any other value uses {@link #defaultTTL()}.
*/
@NonNull
private static FDv1FallbackTTL fromServiceTTL(long serviceTtlSeconds) {
if (serviceTtlSeconds > 0 && serviceTtlSeconds <= DEFAULT_TTL_SECONDS) {
return new FDv1FallbackTTL(serviceTtlSeconds);
}
return defaultTTL();
}

/**
* Returns the default TTL of 1 hour minus a value drawn uniformly from [0, 30 minutes], so
* that SDKs which fell back within the same window do not all re-attempt FDv2 at once.
*
* @return the TTL
*/
@NonNull
public static FDv1FallbackTTL defaultTTL() {
long jitter;
synchronized (random) {
jitter = (long) (random.nextDouble() * (DEFAULT_TTL_SECONDS / 2 + 1));
}
return new FDv1FallbackTTL(DEFAULT_TTL_SECONDS - jitter);
}

/**
* Returns the TTL for the value of an {@code x-ld-fd-fallback-ttl} header, which is a whole
* number of seconds. An absent or malformed header gives the default TTL.
*/
@NonNull
static FDv1FallbackTTL fromHeader(@Nullable String value) {
if (value == null || value.isEmpty()) {
return defaultTTL();
}
try {
return fromServiceTTL(Long.parseLong(value));
} catch (NumberFormatException e) {
return defaultTTL();
}
}

/**
* Returns the TTL in the {@code protocolFallbackTTL} field of a goodbye event's data, or null
* if the field is absent, meaning the goodbye carries no fallback directive. A field that is
* not a whole number of seconds gives the default TTL.
*/
@Nullable
static FDv1FallbackTTL fromGoodbye(@Nullable JsonElement data) {
if (data == null || !data.isJsonObject()) {
return null;
}
JsonObject obj = data.getAsJsonObject();
if (!obj.has(GOODBYE_TTL_FIELD)) {
return null;
}
JsonElement field = obj.get(GOODBYE_TTL_FIELD);
if (field == null || !field.isJsonPrimitive() || !((JsonPrimitive) field).isNumber()) {
return defaultTTL();
}
double seconds = field.getAsDouble();
if (seconds != Math.rint(seconds) || Double.isInfinite(seconds)) {
return defaultTTL();
}
return fromServiceTTL((long) seconds);
}

/**
* @return the TTL in seconds; always in (0, 1 hour]
*/
public long getSeconds() {
return seconds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

/**
Expand All @@ -49,7 +51,8 @@ public interface DataSourceFactory<T> {
private final LDContext evaluationContext;
private final DataSourceUpdateSinkV2 dataSourceUpdateSink;
private static final String FDV1_FALLBACK_MESSAGE =
"Falling back to an FDv1 fallback synchronizer.";
"Server directed fallback to FDv1; FDv2 will be retried in {} seconds.";
private static final Object FDV2_RECOVERY = new Object();
private static final String INITIALIZER_ERROR = "Initializer error: {}";
private static final String INITIALIZER_CANCELLED = "Initializer cancelled: {}";
private static final String INITIALIZER_INTERRUPTED = "Initializer interrupted: {}";
Expand Down Expand Up @@ -78,6 +81,11 @@ public interface DataSourceFactory<T> {

// This future is set by either the worker thread terminating or stop() being called.
private final LDAwaitFuture<Throwable> shutdownCause = new LDAwaitFuture<>();

// Set while a server-directed FDv1 fallback is in effect; the future completes with
// FDV2_RECOVERY when its TTL elapses. Only accessed on the worker thread.
private LDAwaitFuture<Object> fdv2RecoveryFuture;
private ScheduledFuture<?> fdv2RecoveryTask;
/**
* Convenience constructor using default fallback and recovery timeouts.
* See {@link #FDv2DataSource(LDContext, List, List, DataSourceFactory, DataSourceUpdateSinkV2,
Expand Down Expand Up @@ -201,7 +209,7 @@ public void start(@NonNull Callback<Boolean> resultCallback) {
runGeneralInitializers(context, dataSourceUpdateSink);
}

if (!sourceManager.hasAvailableSynchronizers()) {
if (!sourceManager.hasAvailableSynchronizers() && !sourceManager.isInFdv1Fallback()) {
if (!startCompleted.get()) {
// try to claim this is the cause of the shutdown, but it might have already been set by an intentional stop().
shutdownCause.set(new LDFailure("All initializers exhausted and there are no available synchronizers.", LDFailure.FailureType.UNKNOWN_ERROR));
Expand All @@ -216,6 +224,7 @@ public void start(@NonNull Callback<Boolean> resultCallback) {
logger.warn("FDv2DataSource error: {}", t.toString());
shutdownCause.set(t);
} finally {
cancelFdv2Recovery();

// Here we grab the cause of shutdown to report with the OFF status. This is done to ensure that
// all status callbacks are handled by the worker thread. This future may have been set
Expand Down Expand Up @@ -377,16 +386,15 @@ private void runGeneralInitializers(
// 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.isFdv1Fallback()) {
if (result.getResultType() == SourceResultType.CHANGE_SET) {
ChangeSet<Map<String, DataModel.Flag>> changeSet = result.getChangeSet();
if (changeSet != null) {
sink.apply(context, changeSet);
anyDataReceived = true;
}
}
logger.info(FDV1_FALLBACK_MESSAGE);
sourceManager.fdv1Fallback();
fallBackToFdv1(result);
if (anyDataReceived) {
sink.setStatus(DataSourceState.VALID, null);
tryCompleteStart(true, null);
Expand Down Expand Up @@ -468,6 +476,57 @@ private void runGeneralInitializers(
}
}

/**
* Acts on a server-directed FDv1 fallback: stops using the FDv2 synchronizers (retaining them)
* in favor of the FDv1 fallback synchronizer, if one is configured, and schedules recovery to
* FDv2 when the directive's TTL elapses. The fallback is never indefinite.
*/
private void fallBackToFdv1(@NonNull FDv2SourceResult result) {
long ttlSeconds = result.getFdv1Fallback().getSeconds();
logger.info(FDV1_FALLBACK_MESSAGE, ttlSeconds);
scheduleFdv2Recovery(ttlSeconds);
sourceManager.fdv1Fallback();
}

private void scheduleFdv2Recovery(long ttlSeconds) {
cancelFdv2Recovery();
LDAwaitFuture<Object> future = new LDAwaitFuture<>();
fdv2RecoveryFuture = future;
fdv2RecoveryTask = sharedExecutor.schedule(() -> future.set(FDV2_RECOVERY), ttlSeconds, TimeUnit.SECONDS);
}

private void cancelFdv2Recovery() {
if (fdv2RecoveryTask != null) {
fdv2RecoveryTask.cancel(false);
}
fdv2RecoveryTask = null;
fdv2RecoveryFuture = null;
}

private void recoverToFdv2() {
cancelFdv2Recovery();
sourceManager.recoverFromFdv1Fallback();
logger.info("FDv1 fallback TTL elapsed; retrying FDv2 synchronizers.");
}

/**
* Waits out the TTL of the active FDv1 fallback when no synchronizer can run in the meantime
* (e.g. no FDv1 fallback synchronizer is configured), then recovers to FDv2.
*
* @return false if the data source was stopped while waiting
*/
private boolean awaitFdv2Recovery(@NonNull DataSourceUpdateSinkV2 sink)
throws ExecutionException, InterruptedException {
logger.info("No synchronizer is available during FDv1 fallback; waiting to retry FDv2.");
sink.setStatus(DataSourceState.INTERRUPTED, null);
Object res = LDFutures.anyOf(fdv2RecoveryFuture, shutdownCause).get();
if (res != FDV2_RECOVERY) {
return false;
}
recoverToFdv2();
return true;
}

private List<FDv2DataSourceConditions.Condition> getConditions(int synchronizerCount, boolean isPrime) {
if (synchronizerCount <= 1) {
return Collections.emptyList();
Expand Down Expand Up @@ -515,8 +574,22 @@ private void runSynchronizers(
@NonNull DataSourceUpdateSinkV2 sink
) {
try {
Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive();
while (synchronizer != null) {
while (true) {
Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive();
if (synchronizer == null) {
if (stopCalled.get() || fdv2RecoveryFuture == null || !sourceManager.isInFdv1Fallback()) {
break;
}
try {
if (!awaitFdv2Recovery(sink)) {
break;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
continue;
}
String synchronizerName = synchronizer.name();
logger.info("Synchronizer '{}' is starting.", synchronizerName);
resetSynchronizerStatusDedupe();
Expand All @@ -530,7 +603,15 @@ private void runSynchronizers(
Future<FDv2SourceResult> nextFuture = synchronizer.next();
// Race the next synchronizer result against any active conditions
// (fallback/recovery timers). Whichever resolves first wins.
Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get();
Future<Object> recoveryFuture = fdv2RecoveryFuture != null
? fdv2RecoveryFuture : new LDAwaitFuture<>();
Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture, recoveryFuture).get();

if (res == FDV2_RECOVERY) {
recoverToFdv2();
running = false;
break;
}

if (res instanceof FDv2DataSourceConditions.ConditionType) {
FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res;
Expand Down Expand Up @@ -619,16 +700,19 @@ private void runSynchronizers(

// 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;
// x-ld-fd-fallback response header or a goodbye event). 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()) {
if (sourceManager.isCurrentSynchronizerFDv1Fallback()) {
// Already on FDv1: a newer directive only replaces the TTL.
scheduleFdv2Recovery(
result.getFdv1Fallback().getSeconds());
} else {
fallBackToFdv1(result);
running = false;
}
}
}
}
Expand All @@ -651,7 +735,6 @@ private void runSynchronizers(
Thread.currentThread().interrupt();
return;
}
synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive();
}
if (!stopCalled.get()) {
logger.warn("No more synchronizers available.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ static FDv2SourceResult doPoll(
: FDv2SourceResult.status(FDv2SourceResult.Status.interrupted(cause), false);
}

boolean fdv1Fallback = response.isFdv1Fallback();
FDv1FallbackTTL fdv1Fallback = response.getFdv1Fallback();

// 304 Not Modified: nothing changed
if (response.getStatusCode() == 304) {
Expand Down Expand Up @@ -185,6 +185,11 @@ static FDv2SourceResult doPoll(
case GOODBYE: {
String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason();
logger.info("Polling received GOODBYE with reason: '{}'", reason);
FDv1FallbackTTL goodbyeFallback = FDv1FallbackTTL.fromGoodbye(event.getData());
if (goodbyeFallback != null) {
return FDv2SourceResult.status(
FDv2SourceResult.Status.goodbye(reason), goodbyeFallback);
}
return FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback);
}
case INTERNAL_ERROR: {
Expand Down
Loading
Loading