diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 948b56f6..6903168e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -207,6 +207,11 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + @Nullable + public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentDataIfAvailable() { + return perEnvironmentData; + } + @Nullable public TransactionalDataStore getTransactionalDataStore() { return transactionalDataStore; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java index 102fbc27..0245178d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java @@ -8,7 +8,6 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.fdv2.ChangeSet; -import com.launchdarkly.sdk.fdv2.ChangeSetType; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.android.DataModel.Flag; @@ -64,17 +63,23 @@ final class ContextDataManager implements TransactionalDataStore { /** Selector from the last applied changeset that carried one; in-memory only, not persisted. */ @NonNull private Selector currentSelector = Selector.EMPTY; + /** + * @param skipCacheLoad true when an FDv2 cache initializer will handle loading cached + * flags as the first step in the initializer chain, making the + * cache load in {@link #switchToContext} redundant + */ ContextDataManager( @NonNull ClientContext clientContext, @NonNull PersistentDataStoreWrapper.PerEnvironmentData environmentStore, - int maxCachedContexts + int maxCachedContexts, + boolean skipCacheLoad ) { this.environmentStore = environmentStore; this.index = environmentStore.getIndex(); this.maxCachedContexts = maxCachedContexts; this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor(); this.logger = clientContext.getBaseLogger(); - switchToContext(clientContext.getEvaluationContext()); + switchToContext(clientContext.getEvaluationContext(), skipCacheLoad); } /** @@ -83,9 +88,11 @@ final class ContextDataManager implements TransactionalDataStore { * If the context provided is different than the current state, switches to internally * stored flag data and notifies flag listeners. * - * @param context the to switch to + * @param context the context to switch to + * @param skipCacheLoad true to only set the current context without loading cached data + * (used in the FDv2 path where the cache initializer handles loading) */ - public void switchToContext(@NonNull LDContext context) { + public void switchToContext(@NonNull LDContext context, boolean skipCacheLoad) { synchronized (lock) { if (context.equals(currentContext)) { return; @@ -93,6 +100,10 @@ public void switchToContext(@NonNull LDContext context) { currentContext = context; } + if (skipCacheLoad) { + return; + } + EnvironmentData storedData = getStoredData(context); if (storedData == null) { logger.debug("No stored flag data is available for this context"); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java new file mode 100644 index 00000000..1299859b --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java @@ -0,0 +1,75 @@ +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.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; + +import java.io.File; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Package-private subclass of {@link DataSourceBuildInputs} that carries additional + * internal-only dependencies not exposed in the public API. + *

+ * This follows the same pattern as {@link ClientContextImpl} extending + * {@link com.launchdarkly.sdk.android.subsystems.ClientContext}: the public base class + * defines the stable contract for customer-implemented components, while this subclass + * adds SDK-internal properties that our built-in components can access via + * {@link #get(DataSourceBuildInputs)}. + *

+ * This class is for internal SDK use only. It is not subject to any backwards + * compatibility guarantees. + */ +final class DataSourceBuildInputsInternal extends DataSourceBuildInputs { + + @Nullable + private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData; + + DataSourceBuildInputsInternal( + LDContext evaluationContext, + ServiceEndpoints serviceEndpoints, + HttpConfiguration http, + boolean evaluationReasons, + SelectorSource selectorSource, + ScheduledExecutorService sharedExecutor, + @NonNull File cacheDir, + LDLogger baseLogger, + @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData + ) { + super(evaluationContext, serviceEndpoints, http, evaluationReasons, + selectorSource, sharedExecutor, cacheDir, baseLogger); + this.perEnvironmentData = perEnvironmentData; + } + + /** + * Unwraps a {@link DataSourceBuildInputs} to obtain the internal subclass. + * If the instance is already a {@code DataSourceBuildInputsInternal}, it is + * returned directly. Otherwise a wrapper is created with null internal fields. + */ + static DataSourceBuildInputsInternal get(DataSourceBuildInputs inputs) { + if (inputs instanceof DataSourceBuildInputsInternal) { + return (DataSourceBuildInputsInternal) inputs; + } + return new DataSourceBuildInputsInternal( + inputs.getEvaluationContext(), + inputs.getServiceEndpoints(), + inputs.getHttp(), + inputs.isEvaluationReasons(), + inputs.getSelectorSource(), + inputs.getSharedExecutor(), + inputs.getCacheDir(), + inputs.getBaseLogger(), + null + ); + } + + @Nullable + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData getPerEnvironmentDataIfAvailable() { + return perEnvironmentData; + } +} 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 0d51e68c..37428330 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 @@ -10,11 +10,13 @@ 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.InitializerFromCache; 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; @@ -138,6 +140,17 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } } + static final class CacheInitializerBuilderImpl implements DataSourceBuilder, InitializerFromCache { + @Override + public Initializer build(DataSourceBuildInputs inputs) { + return new FDv2CacheInitializer( + DataSourceBuildInputsInternal.get(inputs).getPerEnvironmentDataIfAvailable(), + inputs.getEvaluationContext(), + inputs.getBaseLogger() + ); + } + } + /** * Returns a builder for a polling initializer. *

@@ -188,6 +201,7 @@ public static StreamingSynchronizerBuilder streamingSynchronizer() { */ @NonNull public static Map makeDefaultModeTable() { + DataSourceBuilder cacheInitializer = new CacheInitializerBuilderImpl(); DataSourceBuilder pollingInitializer = pollingInitializer(); DataSourceBuilder pollingSynchronizer = pollingSynchronizer(); DataSourceBuilder streamingSynchronizer = streamingSynchronizer(); @@ -202,32 +216,28 @@ public static Map makeDefaultModeTable() { Map table = new LinkedHashMap<>(); table.put(ConnectionMode.STREAMING, new ModeDefinition( - // TODO: cacheInitializer — add once implemented - Arrays.asList(/* cacheInitializer, */ pollingInitializer), + 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(cacheInitializer), Collections.singletonList(pollingSynchronizer), fdv1FallbackPollingSynchronizerForeground )); table.put(ConnectionMode.OFFLINE, new ModeDefinition( - // TODO: Arrays.asList(cacheInitializer) — add once implemented - Collections.>emptyList(), + Collections.singletonList(cacheInitializer), Collections.>emptyList(), null )); table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( - // TODO: cacheInitializer and streamingInitializer — add once implemented - Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), + // TODO: 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(cacheInitializer), Collections.singletonList(backgroundPollingSynchronizer), fdv1FallbackPollingSynchronizerBackground )); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java new file mode 100644 index 00000000..e8ab3e63 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java @@ -0,0 +1,106 @@ +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.DataModel.Flag; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.Selector; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.Future; + +/** + * FDv2 cache initializer: loads persisted flag data from the local cache. + *

+ * Per CONNMODE 4.1.2, a cache hit returns data with {@code persist=false} and + * {@link Selector#EMPTY} (no selector). + *

+ * All non-hit outcomes — cache miss, missing persistent store, and exceptions during + * cache read — are returned as a {@link ChangeSetType#None} changeset, signaling + * "no data available" rather than an error. A corrupt or unreadable cache is + * semantically equivalent to an empty cache: neither provides usable data. + *

+ * The cache read runs synchronously on the caller's thread because the underlying + * {@code SharedPreferences} access is fast enough that executor dispatch overhead + * would dominate the total time. + */ +final class FDv2CacheInitializer implements Initializer { + + @Nullable + private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData; + private final LDContext context; + private final LDLogger logger; + + FDv2CacheInitializer( + @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData, + @NonNull LDContext context, + @NonNull LDLogger logger + ) { + this.envData = envData; + this.context = context; + this.logger = logger; + } + + @Override + @NonNull + public Future run() { + FDv2SourceResult result; + try { + if (envData == null) { + logger.debug("No persistent store configured; skipping cache"); + result = FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + Selector.EMPTY, + Collections.emptyMap(), + null, + false), false); + } else { + String hashedContextId = LDUtil.urlSafeBase64HashedContextId(context); + EnvironmentData stored = envData.getContextData(hashedContextId); + if (stored == null) { + logger.debug("Cache miss for context"); + result = FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + Selector.EMPTY, + Collections.emptyMap(), + null, + false), false); + } else { + Map flags = stored.getAll(); + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.EMPTY, + flags, + null, + false); + logger.debug("Cache hit: loaded {} flags for context", flags.size()); + result = FDv2SourceResult.changeSet(changeSet, false); + } + } + } catch (Exception e) { + logger.warn("Cache initializer failed: {}", e.toString()); + result = FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + Selector.EMPTY, + Collections.emptyMap(), + null, + false), false); + } + + LDAwaitFuture future = new LDAwaitFuture<>(); + future.set(result); + return future; + } + + @Override + public void close() { + // No-op: the cache read runs synchronously in run(), so there is nothing to cancel. + } +} 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 9d40b947..c406b434 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 @@ -7,14 +7,17 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.ChangeSetType; 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; import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.InitializerFromCache; import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -45,7 +48,11 @@ public interface DataSourceFactory { private final DataSourceUpdateSinkV2 dataSourceUpdateSink; private static final String FDV1_FALLBACK_MESSAGE = "Server signaled FDv1 fallback; switching to FDv1 polling synchronizer."; + private static final String INITIALIZER_ERROR = "Initializer error: {}"; + private static final String INITIALIZER_CANCELLED = "Initializer cancelled: {}"; + private static final String INITIALIZER_INTERRUPTED = "Initializer interrupted: {}"; + private final List> cacheInitializers; private final SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; @@ -114,6 +121,14 @@ public interface DataSourceFactory { this.dataSourceUpdateSink = dataSourceUpdateSink; this.logger = logger; + // here we find the index of the first general initializer so we can split the list into cache and general initializers + int startOfGeneralInitializers = 0; + while (startOfGeneralInitializers < initializers.size() && initializers.get(startOfGeneralInitializers) instanceof InitializerFromCache) { + startOfGeneralInitializers++; + } + this.cacheInitializers = new ArrayList<>(initializers.subList(0, startOfGeneralInitializers)); + List> generalInitializers = new ArrayList<>(initializers.subList(startOfGeneralInitializers, initializers.size())); + List allSynchronizers = new ArrayList<>(); for (DataSourceFactory factory : synchronizers) { allSynchronizers.add(new SynchronizerFactoryWithState(factory)); @@ -124,7 +139,8 @@ public interface DataSourceFactory { allSynchronizers.add(fdv1); } - this.sourceManager = new SourceManager(allSynchronizers, new ArrayList<>(initializers)); + // note that the source manager only uses the initializers after the cache initializers and not the cache initializers + this.sourceManager = new SourceManager(allSynchronizers, generalInitializers); this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; this.sharedExecutor = sharedExecutor; @@ -155,6 +171,12 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; + // This ensures cached data is available before the startup timeout begins, + // matching FDv1 behavior where cache was loaded in ContextDataManager's constructor. + // We assume cache initializers cannot return a selector. If this assumption is invalid in the future, + // the code in this class must be modified to complete start in such a case. + runCacheInitializers(context, dataSourceUpdateSink, cacheInitializers); + sharedExecutor.execute(() -> { try { if (!sourceManager.hasAvailableSources()) { @@ -165,7 +187,7 @@ public void start(@NonNull Callback resultCallback) { } if (sourceManager.hasInitializers()) { - runInitializers(context, dataSourceUpdateSink); + runGeneralInitializers(context, dataSourceUpdateSink); } if (!sourceManager.hasAvailableSynchronizers()) { @@ -286,7 +308,50 @@ public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvalu return !evaluationContext.equals(newEvaluationContext); } - private void runInitializers( + /** + * Runs cache initializers that must run before start returns. + * + * This was added to maintain parity with Android SDK versions that load cached data + * synchronously during startup. When the Android SDK is major versioned and supports + * specifying what types of data (cached, network) to wait for, this can be removed. + */ + private void runCacheInitializers( + @NonNull LDContext context, + @NonNull DataSourceUpdateSinkV2 sink, + @NonNull List> cacheInitializers + ) { + for (DataSourceFactory factory : cacheInitializers) { + Initializer initializer = factory.build(); + try { + FDv2SourceResult result = initializer.run().get(); + + switch (result.getResultType()) { + case CHANGE_SET: + ChangeSet> changeSet = result.getChangeSet(); + if (changeSet != null) { + sink.apply(context, changeSet); + } + break; + case STATUS: + // intentionally ignored from cache initializers + } + } catch (ExecutionException e) { + logger.warn(INITIALIZER_ERROR, e.getCause() != null ? e.getCause().toString() : e.toString()); + } catch (CancellationException e) { + logger.warn(INITIALIZER_CANCELLED, e.toString()); + } catch (InterruptedException e) { + logger.warn(INITIALIZER_INTERRUPTED, e.toString()); + return; + } finally { + try { + initializer.close(); + } catch (IOException ignored) { + } + } + } + } + + private void runGeneralInitializers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink ) { @@ -321,7 +386,9 @@ private void runInitializers( ChangeSet> changeSet = result.getChangeSet(); if (changeSet != null) { sink.apply(context, changeSet); - anyDataReceived = true; + if (changeSet.getType() != ChangeSetType.None) { + anyDataReceived = true; + } // A non-empty selector means the payload is fully current; the // initializer is done and synchronizers can take over from here. if (!changeSet.getSelector().isEmpty()) { @@ -350,13 +417,13 @@ private void runInitializers( break; } } catch (ExecutionException e) { - logger.warn("Initializer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); + logger.warn(INITIALIZER_ERROR, e.getCause() != null ? e.getCause().toString() : e.toString()); sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); } catch (CancellationException e) { - logger.warn("Initializer cancelled: {}", e.toString()); + logger.warn(INITIALIZER_CANCELLED, e.toString()); sink.setStatus(DataSourceState.INTERRUPTED, e); } catch (InterruptedException e) { - logger.warn("Initializer interrupted: {}", e.toString()); + logger.warn(INITIALIZER_INTERRUPTED, e.toString()); sink.setStatus(DataSourceState.INTERRUPTED, e); return; } 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 ac792f7a..be9485dc 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 @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.InitializerFromCache; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; @@ -148,29 +149,43 @@ public void close() { } } - private DataSourceBuildInputs makeInputs(ClientContext clientContext) { - TransactionalDataStore store = ClientContextImpl.get(clientContext).getTransactionalDataStore(); + private DataSourceBuildInputsInternal makeInputs(ClientContext clientContext) { + ClientContextImpl impl = ClientContextImpl.get(clientContext); + TransactionalDataStore store = impl.getTransactionalDataStore(); SelectorSource selectorSource = store != null ? new SelectorSourceFacade(store) : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; - return new DataSourceBuildInputs( + + return new DataSourceBuildInputsInternal( clientContext.getEvaluationContext(), clientContext.getServiceEndpoints(), clientContext.getHttp(), clientContext.isEvaluationReasons(), selectorSource, sharedExecutor, - ClientContextImpl.get(clientContext).getPlatformState().getCacheDir(), - clientContext.getBaseLogger() + impl.getPlatformState().getCacheDir(), + clientContext.getBaseLogger(), + impl.getPerEnvironmentDataIfAvailable() ); } private static ResolvedModeDefinition resolve( ModeDefinition def, DataSourceBuildInputs inputs ) { + // Adapt each public DataSourceBuilder into the internal + // FDv2DataSource.DataSourceFactory by capturing the inputs in a zero-arg + // factory lambda. The InitializerFromCache marker on a builder must propagate to the + // resulting factory so FDv2DataSource can identify cache initializers and run them + // synchronously before startup. A plain lambda cannot carry the marker interface, so + // those builders are wrapped in CacheInitializerFactory, which implements both the + // factory contract and InitializerFromCache. List> initFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getInitializers()) { - initFactories.add(() -> builder.build(inputs)); + if (builder instanceof InitializerFromCache) { + initFactories.add(new CacheInitializerFactory(() -> builder.build(inputs))); + } else { + initFactories.add(() -> builder.build(inputs)); + } } List> syncFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getSynchronizers()) { @@ -181,4 +196,22 @@ private static ResolvedModeDefinition resolve( fdv1FallbackSynchronizer != null ? () -> fdv1FallbackSynchronizer.build(inputs) : null; return new ResolvedModeDefinition(initFactories, syncFactories, fdv1Factory); } + + /** + * Wraps a {@link FDv2DataSource.DataSourceFactory} to carry the {@link InitializerFromCache} + * marker so that {@link FDv2DataSource} can identify cache initializer factories at runtime. + */ + private static class CacheInitializerFactory + implements FDv2DataSource.DataSourceFactory, InitializerFromCache { + private final FDv2DataSource.DataSourceFactory delegate; + + CacheInitializerFactory(FDv2DataSource.DataSourceFactory delegate) { + this.delegate = delegate; + } + + @Override + public Initializer build() { + return delegate.build(); + } + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 7e7af17b..8bd1aca4 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -421,10 +421,12 @@ protected LDClient( taskExecutor ); + boolean usingFDv2 = config.dataSource instanceof FDv2DataSourceBuilder; this.contextDataManager = new ContextDataManager( clientContextImpl, environmentStore, - config.getMaxCachedContexts() + config.getMaxCachedContexts(), + usingFDv2 ); eventProcessor = config.events.build(clientContextImpl); @@ -497,11 +499,11 @@ private void identifyInternal(@NonNull LDContext context, clientContextImpl = clientContextImpl.setEvaluationContext(context); - // Calling initFromStoredData updates the current flag state *if* stored flags exist for - // this context. If they don't, it has no effect. Currently we do *not* return early from - // initialization just because stored flags exist; we're just making them available in case - // initialization times out or otherwise fails. - contextDataManager.switchToContext(context); + // Load cached flags for the new context so they're available in case initialization + // times out or otherwise fails. This does not short-circuit initialization — the data + // source still performs its network request regardless. + boolean usingFDv2 = config.dataSource instanceof FDv2DataSourceBuilder; + contextDataManager.switchToContext(context, usingFDv2); connectivityManager.switchToContext(context, onCompleteListener); eventProcessor.recordIdentifyEvent(context); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java index 8289ab5c..cc414be4 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java @@ -144,11 +144,26 @@ public void setGeneratedContextKey(ContextKind contextKind, String key) { ANON_CONTEXT_KEY_PREFIX + contextKind.toString(), key); } + /** + * Read-only view of per-environment flag data. This is the subset of + * {@link PerEnvironmentData} needed by the FDv2 cache initializer. + */ + interface ReadOnlyPerEnvironmentData { + /** + * Returns the stored flag data, if any, for a specific context. + * + * @param hashedContextId the hashed canonical key of the context + * @return the {@link EnvironmentData}, or null if not found + */ + @Nullable + EnvironmentData getContextData(String hashedContextId); + } + /** * Provides access to stored data that is specific to a single environment. This object is * returned by {@link PersistentDataStoreWrapper#perEnvironmentData(String)}. */ - final class PerEnvironmentData { + final class PerEnvironmentData implements ReadOnlyPerEnvironmentData { private final String environmentNamespace; PerEnvironmentData(String mobileKey) { 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 463e1891..14843b40 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 @@ -23,7 +23,7 @@ * * @see DataSourceBuilder */ -public final class DataSourceBuildInputs { +public class DataSourceBuildInputs { private final LDContext evaluationContext; private final ServiceEndpoints serviceEndpoints; private final HttpConfiguration http; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/InitializerFromCache.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/InitializerFromCache.java new file mode 100644 index 00000000..0e765871 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/InitializerFromCache.java @@ -0,0 +1,7 @@ +package com.launchdarkly.sdk.android.subsystems; + +/** + * Marker interface for an initializer that is used to load data from the cache and + * will be run synchronously when the data source is started. + */ +public interface InitializerFromCache {} 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 7843785f..ac49d639 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 @@ -131,7 +131,8 @@ private void createTestManager( contextDataManager = new ContextDataManager( clientContext, environmentStore, - 1 + 1, + false ); contextDataManager.registerAllFlagsListener(flagsUpdated -> { allFlagsReceived.add(flagsUpdated); @@ -508,7 +509,7 @@ public void refreshDataSourceForNewContext() throws Exception { long connectionTimeBeforeSwitch = connectivityManager.getConnectionInformation().getLastSuccessfulConnection(); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2); + contextDataManager.switchToContext(context2, false); AwaitableCallback done = new AwaitableCallback<>(); connectivityManager.switchToContext(context2, done); done.await(); @@ -540,7 +541,7 @@ public void refreshDataSourceWhileOffline() { replayAll(); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2); + contextDataManager.switchToContext(context2, false); connectivityManager.switchToContext(context2, LDUtil.noOpCallback()); verifyAll(); // verifies eventProcessor calls @@ -568,7 +569,7 @@ public void refreshDataSourceWhileInBackgroundWithBackgroundPollingDisabled() { replayAll(); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2); + contextDataManager.switchToContext(context2, false); connectivityManager.switchToContext(context2, LDUtil.noOpCallback()); verifyAll(); // verifies eventProcessor calls @@ -783,7 +784,7 @@ public void onInternalFailure(LDFailure ldFailure) { }); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2); + contextDataManager.switchToContext(context2, false); connectivityManager.switchToContext(context2, new AwaitableCallback<>()); latch.await(500, TimeUnit.MILLISECONDS); @@ -1122,7 +1123,7 @@ public void fdv2_contextChange_rebuildsDataSource() throws Exception { verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2); + contextDataManager.switchToContext(context2, false); AwaitableCallback done = new AwaitableCallback<>(); connectivityManager.switchToContext(context2, done); done.await(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java index 54a863c5..b4e9eae1 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java @@ -34,7 +34,7 @@ public void applyFullReplacesDataAndPersists() { fullItems.put(flag2.getKey(), flag2); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); ChangeSet> changeSet = new ChangeSet<>( ChangeSetType.Full, Selector.EMPTY, @@ -56,7 +56,7 @@ public void applyFullWithShouldPersistFalseUpdatesMemoryOnly() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); Flag flag2 = new FlagBuilder("flag2").version(2).build(); @@ -82,7 +82,7 @@ public void applyPartialMergesAndPersists() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); Flag flag2 = new FlagBuilder("flag2").version(2).build(); @@ -112,7 +112,7 @@ public void applyPartialOverwritesEvenWhenIncomingVersionIsLower() { Flag flag1 = new FlagBuilder("flag1").version(2).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); Flag flag1LowerVersion = new FlagBuilder("flag1").version(1).value(false).build(); @@ -136,7 +136,7 @@ public void applyNoneDoesNotChangeFlags() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); ChangeSet> changeSet = new ChangeSet<>( @@ -156,7 +156,7 @@ public void applyNoneDoesNotChangeFlags() { @Test public void applyStoresSelectorInMemory() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); assertTrue(manager.getSelector().isEmpty()); Selector selector = Selector.make(42, "state-42"); @@ -178,7 +178,7 @@ public void applyStoresSelectorInMemory() { @Test public void applyFullWithEmptySelectorClearsStoredSelector() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); Selector first = Selector.make(1, "state1"); Flag flag = new FlagBuilder("flag1").version(1).build(); manager.apply(CONTEXT, new ChangeSet<>( @@ -193,7 +193,7 @@ public void applyFullWithEmptySelectorClearsStoredSelector() { @Test public void applyPartialWithEmptySelectorClearsStoredSelector() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); Selector first = Selector.make(1, "state1"); Flag flag = new FlagBuilder("flag1").version(1).build(); manager.apply(CONTEXT, new ChangeSet<>( @@ -212,7 +212,7 @@ public void applyDoesNothingWhenContextMismatch() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); LDContext otherContext = LDContext.create("other-context"); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java index 8693166c..eebbccb9 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java @@ -1,18 +1,7 @@ package com.launchdarkly.sdk.android; -import static com.launchdarkly.sdk.android.AssertHelpers.assertDataSetsEqual; -import static com.launchdarkly.sdk.android.AssertHelpers.assertFlagsEqual; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.fail; -import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.android.subsystems.PersistentDataStore; - -import org.junit.Rule; import org.junit.Test; public class ContextDataManagerContextCachingTest extends ContextDataManagerTestBase { @@ -33,7 +22,7 @@ public void canCacheManyContextsWithNegativeMaxCachedContexts() { int numContexts = 20; for (int i = 1; i <= numContexts; i++) { - manager.switchToContext(makeContext(i)); + manager.switchToContext(makeContext(i), false); manager.initData(makeContext(i), makeFlagData(i)); } @@ -49,7 +38,7 @@ public void deletesExcessContexts() { ContextDataManager manager = createDataManager(maxCachedContexts); for (int i = 1; i <= maxCachedContexts + excess; i++) { - manager.switchToContext(makeContext(i)); + manager.switchToContext(makeContext(i), false); manager.initData(makeContext(i), makeFlagData(i)); } @@ -66,13 +55,13 @@ public void deletesExcessContextsFromPreviousManagerInstance() { ContextDataManager manager = createDataManager(1); for (int i = 1; i <= 2; i++) { - manager.switchToContext(makeContext(i)); + manager.switchToContext(makeContext(i), false); manager.initData(makeContext(i), makeFlagData(i)); assertContextIsCached(makeContext(i), makeFlagData(i)); } ContextDataManager newManagerInstance = createDataManager(1); - newManagerInstance.switchToContext(makeContext(3)); + newManagerInstance.switchToContext(makeContext(3), false); newManagerInstance.initData(makeContext(3), makeFlagData(3)); assertContextIsNotCached(makeContext(1)); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java index 665154ba..988f615b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java @@ -5,12 +5,10 @@ import static com.launchdarkly.sdk.android.AssertHelpers.assertDataSetsEqual; import static com.launchdarkly.sdk.android.AssertHelpers.assertFlagsEqual; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -24,7 +22,7 @@ public void getStoredDataNotFound() { public void initDataUpdatesStoredData() { EnvironmentData data = new DataSetBuilder().add(new FlagBuilder("flag1").build()).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, data); assertDataSetsEqual(data, createDataManager().getStoredData(CONTEXT)); } @@ -33,18 +31,18 @@ public void initDataUpdatesStoredData() { public void initFromStoredData() { EnvironmentData data = new DataSetBuilder().add(new FlagBuilder("flag1").build()).build(); ContextDataManager manager1 = createDataManager(); - manager1.switchToContext(CONTEXT); + manager1.switchToContext(CONTEXT, false); manager1.initData(CONTEXT, data); ContextDataManager manager2 = createDataManager(); - manager2.switchToContext(CONTEXT); + manager2.switchToContext(CONTEXT, false); assertDataSetsEqual(data, manager2.getAllNonDeleted()); } @Test public void initFromStoredDataNotFound() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); } @Test @@ -66,7 +64,7 @@ public void getKnownFlag() { Flag flag = new FlagBuilder("flag1").build(); EnvironmentData data = new DataSetBuilder().add(flag).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, data); assertSame(flag, manager.getNonDeletedFlag(flag.getKey())); @@ -107,7 +105,7 @@ public void getAllReturnsFlags() { flag2 = new FlagBuilder("flag2").version(2).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).add(flag2).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); EnvironmentData actualData = manager.getAllNonDeleted(); @@ -121,7 +119,7 @@ public void getAllFiltersOutDeletedFlags() { deletedFlag = Flag.deletedItemPlaceholder("flag2", 2); EnvironmentData initialData = new DataSetBuilder().add(flag1).add(deletedFlag).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); EnvironmentData expectedData = new DataSetBuilder().add(flag1).build(); @@ -134,7 +132,7 @@ public void upsertAddsFlag() { flag2 = new FlagBuilder("flag2").version(2).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, flag2); @@ -152,7 +150,7 @@ public void upsertUpdatesFlag() { flag1b = new FlagBuilder(flag1a.getKey()).version(2).value(false).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1a).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, flag1b); @@ -168,14 +166,14 @@ public void upsertUpdatesFlag() { public void switchDoesNotUpdateIndexTimestamp() throws Exception { EnvironmentData data = new DataSetBuilder().add(new FlagBuilder("flag1").build()).build(); ContextDataManager manager1 = createDataManager(); - manager1.switchToContext(INITIAL_CONTEXT); + manager1.switchToContext(INITIAL_CONTEXT, false); manager1.initData(INITIAL_CONTEXT, data); Long firstTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(INITIAL_CONTEXT), LDUtil.urlSafeBase64Hash(INITIAL_CONTEXT)); Thread.sleep(2); // sleep for an amount that is greater than precision of System.currentTimeMillis so the change can be detected - manager1.switchToContext(CONTEXT); - manager1.switchToContext(INITIAL_CONTEXT); + manager1.switchToContext(CONTEXT, false); + manager1.switchToContext(INITIAL_CONTEXT, false); Long secondTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(INITIAL_CONTEXT), LDUtil.urlSafeBase64Hash(INITIAL_CONTEXT)); assertEquals(firstTimestamp, secondTimestamp); @@ -187,7 +185,7 @@ public void upsertUpdatesIndexTimestamp() throws Exception { flag1b = new FlagBuilder(flag1a.getKey()).version(2).value(false).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1a).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); long firstTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(CONTEXT), LDUtil.urlSafeBase64Hash(CONTEXT)); @@ -221,7 +219,7 @@ public void upsertDeletesFlag() { deletedFlag2 = Flag.deletedItemPlaceholder(flag2.getKey(), 2); EnvironmentData initialData = new DataSetBuilder().add(flag1).add(flag2).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, deletedFlag2); @@ -254,7 +252,7 @@ public void upsertDoesNotDeleteFlagWithLowerVersion() { private void upsertDoesNotUpdateFlag(Flag initialFlag, Flag updatedFlag) { EnvironmentData initialData = new DataSetBuilder().add(initialFlag).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, updatedFlag); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java index a69cb2cf..56361e0c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java @@ -48,7 +48,7 @@ public void listenerIsCalledOnUpdate() throws InterruptedException { manager.registerListener(flag.getKey(), listener); manager.registerAllFlagsListener(allFlagsListener); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.upsert(CONTEXT, flag); assertEquals(flag.getKey(), listener.expectUpdate(5, TimeUnit.SECONDS)); @@ -65,7 +65,7 @@ public void listenerIsCalledOnDelete() throws InterruptedException { manager.registerListener(flag.getKey(), listener); manager.registerAllFlagsListener(allFlagsListener); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.upsert(CONTEXT, flag); assertEquals(flag.getKey(), listener.expectUpdate(5, TimeUnit.SECONDS)); @@ -115,7 +115,7 @@ public void listenerIsCalledAfterInitData() { manager.registerAllFlagsListener(all1); // change the data - manager.switchToContext(context1); + manager.switchToContext(context1, false); manager.upsert(context1, flagState1); // verify callbacks @@ -131,7 +131,7 @@ public void listenerIsCalledAfterInitData() { // simulate switching context Flag flagState2 = new FlagBuilder(FLAG_KEY).value(LDValue.of(2)).build(); EnvironmentData envData = new EnvironmentData().withFlagUpdatedOrAdded(flagState2); - manager.switchToContext(context2); + manager.switchToContext(context2, false); manager.initData(context2, envData); // verify callbacks @@ -144,7 +144,7 @@ public void partialApplyNotifiesListenersForEachKeyEvenWhenEvaluatedValueUnchang throws InterruptedException { Flag initial = new FlagBuilder("flag").version(1).value(true).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.initData(CONTEXT, new DataSetBuilder().add(initial).build()); AwaitableFlagListener listener = new AwaitableFlagListener(); @@ -177,7 +177,7 @@ public void listenerIsCalledOnMainThread() throws InterruptedException { manager.registerListener(flag.getKey(), listener); manager.registerAllFlagsListener(allFlagsListener); - manager.switchToContext(CONTEXT); + manager.switchToContext(CONTEXT, false); manager.upsert(CONTEXT, flag); listener.expectUpdate(5, TimeUnit.SECONDS); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java index 545e97c4..b5aa3cc7 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java @@ -60,7 +60,8 @@ protected ContextDataManager createDataManager(int maxCachedContexts) { return new ContextDataManager( clientContext, environmentStore, - maxCachedContexts + maxCachedContexts, + false ); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java new file mode 100644 index 00000000..6ea14957 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java @@ -0,0 +1,132 @@ +package com.launchdarkly.sdk.android; + +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; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.sdk.fdv2.SourceResultType; + +import org.junit.Test; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class DataSourceBuildInputsInternalTest { + + private static final LDContext CONTEXT = LDContext.create("test-user"); + private static final File CACHE_DIR = new File(System.getProperty("java.io.tmpdir")); + + private static DataSourceBuildInputsInternal makeInternalInputs( + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData + ) { + return new DataSourceBuildInputsInternal( + CONTEXT, null, null, false, + () -> Selector.EMPTY, null, CACHE_DIR, + LDLogger.none(), envData + ); + } + + private static DataSourceBuildInputs makePlainInputs() { + return new DataSourceBuildInputs( + CONTEXT, null, null, false, + () -> Selector.EMPTY, null, CACHE_DIR, + LDLogger.none() + ); + } + + // ---- get() unwrap behavior ---- + + @Test + public void get_withInternalInstance_returnsSameInstance() { + DataSourceBuildInputsInternal internal = makeInternalInputs(null); + assertSame(internal, DataSourceBuildInputsInternal.get(internal)); + } + + @Test + public void get_withPlainInputs_wrapsWithNullInternalFields() { + DataSourceBuildInputs plain = makePlainInputs(); + DataSourceBuildInputsInternal result = DataSourceBuildInputsInternal.get(plain); + + assertNotNull(result); + assertNull(result.getPerEnvironmentDataIfAvailable()); + } + + @Test + public void get_withPlainInputs_preservesBaseProperties() { + DataSourceBuildInputs plain = makePlainInputs(); + DataSourceBuildInputsInternal result = DataSourceBuildInputsInternal.get(plain); + + assertEquals(plain.getEvaluationContext(), result.getEvaluationContext()); + assertEquals(plain.getServiceEndpoints(), result.getServiceEndpoints()); + assertEquals(plain.getHttp(), result.getHttp()); + assertEquals(plain.isEvaluationReasons(), result.isEvaluationReasons()); + assertEquals(plain.getSelectorSource(), result.getSelectorSource()); + assertEquals(plain.getSharedExecutor(), result.getSharedExecutor()); + assertEquals(plain.getCacheDir(), result.getCacheDir()); + assertEquals(plain.getBaseLogger(), result.getBaseLogger()); + } + + // ---- getPerEnvironmentDataIfAvailable() ---- + + @Test + public void getPerEnvironmentDataIfAvailable_returnsProvidedValue() { + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData = hashedContextId -> null; + DataSourceBuildInputsInternal internal = makeInternalInputs(envData); + + assertSame(envData, internal.getPerEnvironmentDataIfAvailable()); + } + + @Test + public void getPerEnvironmentDataIfAvailable_returnsNullWhenNotProvided() { + DataSourceBuildInputsInternal internal = makeInternalInputs(null); + + assertNull(internal.getPerEnvironmentDataIfAvailable()); + } + + // ---- CacheInitializerBuilderImpl integration ---- + + @Test + public void cacheInitializerBuilder_withInternalInputs_receivesEnvData() throws Exception { + String hashedContextId = LDUtil.urlSafeBase64HashedContextId(CONTEXT); + Map flags = new HashMap<>(); + flags.put("flag1", new FlagBuilder("flag1").version(1).value(LDValue.of("yes")).build()); + + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData = + id -> hashedContextId.equals(id) + ? EnvironmentData.copyingFlagsMap(flags) + : null; + + DataSourceBuildInputsInternal inputs = makeInternalInputs(envData); + Initializer initializer = new DataSystemComponents.CacheInitializerBuilderImpl().build(inputs); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + assertEquals(1, result.getChangeSet().getData().size()); + assertTrue(result.getChangeSet().getData().containsKey("flag1")); + } + + @Test + public void cacheInitializerBuilder_withPlainInputs_treatsAsNullEnvData() throws Exception { + DataSourceBuildInputs plain = makePlainInputs(); + Initializer initializer = new DataSystemComponents.CacheInitializerBuilderImpl().build(plain); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertEquals(ChangeSetType.None, result.getChangeSet().getType()); + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2CacheInitializerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2CacheInitializerTest.java new file mode 100644 index 00000000..dcdf2af2 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2CacheInitializerTest.java @@ -0,0 +1,205 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.SourceResultType; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +public class FDv2CacheInitializerTest { + + @Rule + public Timeout globalTimeout = Timeout.seconds(5); + + private static final LDContext CONTEXT = LDContext.create("test-user"); + private static final String HASHED_CONTEXT_ID = + LDUtil.urlSafeBase64HashedContextId(CONTEXT); + + private static PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData storeReturning( + EnvironmentData data) { + return hashedContextId -> HASHED_CONTEXT_ID.equals(hashedContextId) ? data : null; + } + + // ---- cache hit ---- + + @Test + public void cacheHit_returnsChangeSetWithFlags() throws Exception { + Map flags = new HashMap<>(); + flags.put("flag1", new FlagBuilder("flag1").version(1).value(true).build()); + flags.put("flag2", new FlagBuilder("flag2").version(2).value(LDValue.of("hello")).build()); + + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + storeReturning(EnvironmentData.copyingFlagsMap(flags)), + CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + assertEquals(2, result.getChangeSet().getData().size()); + assertTrue(result.getChangeSet().getData().containsKey("flag1")); + assertTrue(result.getChangeSet().getData().containsKey("flag2")); + } + + @Test + public void cacheHit_changeSetHasEmptySelector() throws Exception { + Map flags = new HashMap<>(); + flags.put("flag1", new FlagBuilder("flag1").version(1).build()); + + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + storeReturning(EnvironmentData.copyingFlagsMap(flags)), + CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.getChangeSet().getSelector().isEmpty()); + } + + @Test + public void cacheHit_changeSetHasFullType() throws Exception { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + storeReturning(new EnvironmentData()), + CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + } + + @Test + public void cacheHit_changeSetHasPersistFalse() throws Exception { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + storeReturning(new EnvironmentData()), + CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertFalse(result.getChangeSet().shouldPersist()); + } + + @Test + public void cacheHit_fdv1FallbackIsFalse() throws Exception { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + storeReturning(new EnvironmentData()), + CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertFalse(result.isFdv1Fallback()); + } + + // ---- cache miss ---- + + @Test + public void cacheMiss_returnsNoneChangeSet() throws Exception { + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> null; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + ChangeSet changeSet = result.getChangeSet(); + assertNotNull(changeSet); + assertEquals(ChangeSetType.None, changeSet.getType()); + assertTrue(changeSet.getSelector().isEmpty()); + assertTrue(((java.util.Map) changeSet.getData()).isEmpty()); + assertFalse(changeSet.shouldPersist()); + } + + @Test + public void cacheMiss_fdv1FallbackIsFalse() throws Exception { + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> null; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertFalse(result.isFdv1Fallback()); + } + + // ---- no persistent store ---- + + @Test + public void noPersistentStore_returnsNoneChangeSet() throws Exception { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + null, CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + ChangeSet changeSet = result.getChangeSet(); + assertNotNull(changeSet); + assertEquals(ChangeSetType.None, changeSet.getType()); + assertTrue(changeSet.getSelector().isEmpty()); + assertTrue(((java.util.Map) changeSet.getData()).isEmpty()); + assertFalse(changeSet.shouldPersist()); + } + + // ---- exception during cache read ---- + + @Test + public void exceptionDuringCacheRead_returnsNoneChangeSet() throws Exception { + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> { + throw new RuntimeException("corrupt data"); + }; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + ChangeSet changeSet = result.getChangeSet(); + assertNotNull(changeSet); + assertEquals(ChangeSetType.None, changeSet.getType()); + assertTrue(changeSet.getSelector().isEmpty()); + assertTrue(((java.util.Map) changeSet.getData()).isEmpty()); + assertFalse(changeSet.shouldPersist()); + assertFalse(result.isFdv1Fallback()); + } + + // ---- close() behavior ---- + + @Test + public void closeAfterCompletion_doesNotThrow() throws Exception { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + storeReturning(new EnvironmentData()), + CONTEXT, LDLogger.none()); + + Future future = initializer.run(); + future.get(1, TimeUnit.SECONDS); + initializer.close(); + } + + // ---- empty cache (no flags stored, but store exists) ---- + + @Test + public void emptyCacheReturnsChangeSetWithEmptyMap() throws Exception { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + storeReturning(new EnvironmentData()), + CONTEXT, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.getChangeSet().getData().isEmpty()); + } +} 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 08680858..a2044ab7 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 @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -37,6 +38,7 @@ public class FDv2DataSourceBuilderTest { private ClientContext makeClientContext() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + HttpConfiguration http = new HttpConfiguration(10_000, Collections.emptyMap(), null, false); ClientContext base = new ClientContext( "mobile-key", ENV_REPORTER, @@ -46,7 +48,7 @@ private ClientContext makeClientContext() { "default", false, CONTEXT, - null, + http, false, null, config.serviceEndpoints, @@ -243,7 +245,7 @@ public void defaultModeTable_streamingHasFdv1Fallback() { ModeDefinition streaming = builder.getModeDefinition(ConnectionMode.STREAMING); assertNotNull(streaming); - assertEquals(1, streaming.getInitializers().size()); + assertEquals(2, streaming.getInitializers().size()); assertEquals(2, streaming.getSynchronizers().size()); assertNotNull(streaming.getFdv1FallbackSynchronizer()); } @@ -255,7 +257,7 @@ public void defaultModeTable_pollingHasFdv1Fallback() { ModeDefinition polling = builder.getModeDefinition(ConnectionMode.POLLING); assertNotNull(polling); - assertEquals(0, polling.getInitializers().size()); + assertEquals(1, polling.getInitializers().size()); assertEquals(1, polling.getSynchronizers().size()); assertNotNull(polling.getFdv1FallbackSynchronizer()); } @@ -267,7 +269,7 @@ public void defaultModeTable_backgroundHasFdv1Fallback() { ModeDefinition background = builder.getModeDefinition(ConnectionMode.BACKGROUND); assertNotNull(background); - assertEquals(0, background.getInitializers().size()); + assertEquals(1, background.getInitializers().size()); assertEquals(1, background.getSynchronizers().size()); assertNotNull(background.getFdv1FallbackSynchronizer()); } @@ -279,7 +281,7 @@ public void defaultModeTable_offlineHasNoFdv1Fallback() { ModeDefinition offline = builder.getModeDefinition(ConnectionMode.OFFLINE); assertNotNull(offline); - assertEquals(0, offline.getInitializers().size()); + assertEquals(1, offline.getInitializers().size()); assertEquals(0, offline.getSynchronizers().size()); assertNull(offline.getFdv1FallbackSynchronizer()); } @@ -291,7 +293,7 @@ public void defaultModeTable_oneShotHasNoFdv1Fallback() { ModeDefinition oneShot = builder.getModeDefinition(ConnectionMode.ONE_SHOT); assertNotNull(oneShot); - assertEquals(1, oneShot.getInitializers().size()); + assertEquals(2, oneShot.getInitializers().size()); assertEquals(0, oneShot.getSynchronizers().size()); assertNull(oneShot.getFdv1FallbackSynchronizer()); } 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 9f0d8082..3b854587 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 @@ -7,21 +7,17 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import androidx.annotation.NonNull; - import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.DataModel; -import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.fdv2.ChangeSetType; -import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.DataSourceState; -import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.InitializerFromCache; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import com.launchdarkly.sdk.fdv2.Selector; @@ -187,6 +183,25 @@ public void close() { } } + /** + * Wraps a factory closure with the {@link InitializerFromCache} marker so the data source + * treats initializers it produces as cache initializers (run synchronously before executor dispatch). + */ + private static class CacheInitializerFactory + implements FDv2DataSource.DataSourceFactory, InitializerFromCache { + private final FDv2DataSource.DataSourceFactory delegate; + + CacheInitializerFactory(FDv2DataSource.DataSourceFactory delegate) { + this.delegate = delegate; + } + + @Override + public Initializer build() { + return delegate.build(); + } + } + + /** * A synchronizer that returns one pre-set result on the first next(), then returns a * never-completing future (simulating an idle-but-open connection). close() makes subsequent @@ -1750,4 +1765,147 @@ public void needsRefresh_differentContext_returnsTrue() { Collections.emptyList()); assertTrue(dataSource.needsRefresh(false, LDContext.create("other-context"))); } + + @Test + public void cacheInitializerRunsBeforeExecutorDispatch() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicReference cacheInitializerThread = new AtomicReference<>(); + AtomicReference callingThread = new AtomicReference<>(); + + FDv2DataSource.DataSourceFactory cacheInitializerFactory = new CacheInitializerFactory(() -> + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { + @Override + public LDAwaitFuture run() { + cacheInitializerThread.set(Thread.currentThread().getName()); + return super.run(); + } + }); + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(cacheInitializerFactory), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)))); + + callingThread.set(Thread.currentThread().getName()); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals("Cache initializer must run on the calling thread", + callingThread.get(), cacheInitializerThread.get()); + stopDataSource(dataSource); + } + + @Test + public void generalInitializersRunsOnExecutorThread() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicReference deferredThread = new AtomicReference<>(); + CountDownLatch deferredRan = new CountDownLatch(1); + + FDv2DataSource.DataSourceFactory deferredFactory = () -> + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public LDAwaitFuture run() { + deferredThread.set(Thread.currentThread().getName()); + deferredRan.countDown(); + return super.run(); + } + }; + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(deferredFactory), + Collections.emptyList()); + + String callingThread = Thread.currentThread().getName(); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + assertTrue(deferredRan.await(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + assertFalse("Deferred initializer must NOT run on the calling thread", + callingThread.equals(deferredThread.get())); + } + + @Test + public void eagerAndDeferredInitializersBothRun() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicBoolean eagerRan = new AtomicBoolean(false); + CountDownLatch deferredRan = new CountDownLatch(1); + + FDv2DataSource.DataSourceFactory eagerFactory = new CacheInitializerFactory(() -> + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { + @Override + public LDAwaitFuture run() { + eagerRan.set(true); + return super.run(); + } + }); + + FDv2DataSource.DataSourceFactory deferredFactory = () -> + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public LDAwaitFuture run() { + deferredRan.countDown(); + return super.run(); + } + }; + + FDv2DataSource dataSource = buildDataSource(sink, + Arrays.asList(eagerFactory, deferredFactory), + Collections.emptyList()); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + assertTrue(deferredRan.await(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + assertTrue(eagerRan.get()); + sink.awaitApplyCount(2, AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(2, sink.getApplyCount()); + } + + @Test + public void offlineModeWithEagerCacheMissStillInitializes() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + FDv2DataSource.DataSourceFactory cacheMissFactory = new CacheInitializerFactory(() -> + new MockInitializer(FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + com.launchdarkly.sdk.fdv2.Selector.EMPTY, + Collections.emptyMap(), + null, + false), false))); + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(cacheMissFactory), + Collections.emptyList()); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + + assertEquals(DataSourceState.VALID, sink.getLastState()); + } + + @Test + public void eagerInitializerDataAvailableWithZeroTimeout() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + DataModel.Flag flag = new FlagBuilder("flag1").version(1).value(LDValue.of(true)).build(); + Map items = new HashMap<>(); + items.put(flag.getKey(), flag); + + FDv2DataSource.DataSourceFactory eagerFactory = new CacheInitializerFactory(() -> + new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))); + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(eagerFactory), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(true), false)))); + + AwaitableCallback startCallback = startDataSource(dataSource); + + ChangeSet> applied = sink.expectApply(); + assertNotNull(applied); + assertEquals(1, applied.getData().size()); + assertTrue(applied.getData().containsKey("flag1")); + + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); + stopDataSource(dataSource); + } } 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 10b5dc05..c01646c9 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 @@ -42,15 +42,15 @@ public void buildModeTable_containsAllStandardModes() { @Test public void buildModeTable_defaultInitializerAndSynchronizerCounts() { Map table = Components.dataSystem().buildModeTable(false); - assertEquals(1, table.get(ConnectionMode.STREAMING).getInitializers().size()); + assertEquals(2, table.get(ConnectionMode.STREAMING).getInitializers().size()); assertEquals(2, table.get(ConnectionMode.STREAMING).getSynchronizers().size()); - assertEquals(0, table.get(ConnectionMode.POLLING).getInitializers().size()); + assertEquals(1, table.get(ConnectionMode.POLLING).getInitializers().size()); assertEquals(1, table.get(ConnectionMode.POLLING).getSynchronizers().size()); - assertEquals(0, table.get(ConnectionMode.OFFLINE).getInitializers().size()); + assertEquals(1, table.get(ConnectionMode.OFFLINE).getInitializers().size()); assertEquals(0, table.get(ConnectionMode.OFFLINE).getSynchronizers().size()); - assertEquals(1, table.get(ConnectionMode.ONE_SHOT).getInitializers().size()); + assertEquals(2, table.get(ConnectionMode.ONE_SHOT).getInitializers().size()); assertEquals(0, table.get(ConnectionMode.ONE_SHOT).getSynchronizers().size()); - assertEquals(0, table.get(ConnectionMode.BACKGROUND).getInitializers().size()); + assertEquals(1, table.get(ConnectionMode.BACKGROUND).getInitializers().size()); assertEquals(1, table.get(ConnectionMode.BACKGROUND).getSynchronizers().size()); }