From e0440233f8cf50f03b72e5656afe8ec3429dc381 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 6 Apr 2026 14:27:20 -0700 Subject: [PATCH 01/20] [SDK-2070] feat: add CachedFlagStore to DataSourceBuildInputs for cache initializer Introduce a CachedFlagStore interface in the subsystems package that provides read access to cached flag data by evaluation context. Add this as a nullable field to DataSourceBuildInputs and wire it through from FDv2DataSourceBuilder using PerEnvironmentData. This plumbing enables the upcoming FDv2 cache initializer to load persisted flags without depending on package-private types. Made-with: Cursor --- .../sdk/android/ClientContextImpl.java | 5 ++++ .../sdk/android/FDv2DataSourceBuilder.java | 17 ++++++++++-- .../android/subsystems/CachedFlagStore.java | 27 +++++++++++++++++++ .../subsystems/DataSourceBuildInputs.java | 18 +++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/CachedFlagStore.java 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/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index ac792f7a..ac0291be 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 @@ -2,6 +2,7 @@ import androidx.annotation.NonNull; +import com.launchdarkly.sdk.android.subsystems.CachedFlagStore; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; @@ -149,10 +150,21 @@ public void close() { } private DataSourceBuildInputs makeInputs(ClientContext clientContext) { - TransactionalDataStore store = ClientContextImpl.get(clientContext).getTransactionalDataStore(); + ClientContextImpl impl = ClientContextImpl.get(clientContext); + TransactionalDataStore store = impl.getTransactionalDataStore(); SelectorSource selectorSource = store != null ? new SelectorSourceFacade(store) : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + + PersistentDataStoreWrapper.PerEnvironmentData envData = impl.getPerEnvironmentDataIfAvailable(); + CachedFlagStore cachedFlagStore = envData != null + ? context -> { + String hashedId = LDUtil.urlSafeBase64HashedContextId(context); + EnvironmentData stored = envData.getContextData(hashedId); + return stored != null ? stored.getAll() : null; + } + : null; + return new DataSourceBuildInputs( clientContext.getEvaluationContext(), clientContext.getServiceEndpoints(), @@ -160,7 +172,8 @@ private DataSourceBuildInputs makeInputs(ClientContext clientContext) { clientContext.isEvaluationReasons(), selectorSource, sharedExecutor, - ClientContextImpl.get(clientContext).getPlatformState().getCacheDir(), + impl.getPlatformState().getCacheDir(), + cachedFlagStore, clientContext.getBaseLogger() ); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/CachedFlagStore.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/CachedFlagStore.java new file mode 100644 index 00000000..cf83d503 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/CachedFlagStore.java @@ -0,0 +1,27 @@ +package com.launchdarkly.sdk.android.subsystems; + +import androidx.annotation.Nullable; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.DataModel; + +import java.util.Map; + +/** + * Provides read access to cached flag data for a specific evaluation context. + *

+ * This interface bridges the persistence layer with FDv2 data source builders, + * allowing the cache initializer to load stored flags without depending on + * package-private types. + */ +public interface CachedFlagStore { + /** + * Returns the cached flag data for the given context, or null if no + * cached data exists. + * + * @param context the evaluation context to look up + * @return the cached flags, or null on cache miss + */ + @Nullable + Map getCachedFlags(LDContext context); +} 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..8a91c7b0 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,6 +1,7 @@ package com.launchdarkly.sdk.android.subsystems; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; @@ -31,6 +32,8 @@ public final class DataSourceBuildInputs { private final SelectorSource selectorSource; private final ScheduledExecutorService sharedExecutor; private final File cacheDir; + @Nullable + private final CachedFlagStore cachedFlagStore; private final LDLogger baseLogger; /** @@ -44,6 +47,8 @@ public final class DataSourceBuildInputs { * @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 cachedFlagStore read access to cached flag data, or null if no persistent + * store is configured * @param baseLogger the base logger instance */ public DataSourceBuildInputs( @@ -54,6 +59,7 @@ public DataSourceBuildInputs( SelectorSource selectorSource, ScheduledExecutorService sharedExecutor, @NonNull File cacheDir, + @Nullable CachedFlagStore cachedFlagStore, LDLogger baseLogger ) { this.evaluationContext = evaluationContext; @@ -63,6 +69,7 @@ public DataSourceBuildInputs( this.selectorSource = selectorSource; this.sharedExecutor = sharedExecutor; this.cacheDir = cacheDir; + this.cachedFlagStore = cachedFlagStore; this.baseLogger = baseLogger; } @@ -133,6 +140,17 @@ public File getCacheDir() { return cacheDir; } + /** + * Returns read access to cached flag data, or null if no persistent store + * is configured. Used by the cache initializer to load stored flags. + * + * @return the cached flag store, or null + */ + @Nullable + public CachedFlagStore getCachedFlagStore() { + return cachedFlagStore; + } + /** * Returns the base logger instance. * From 596bc97f707b0aee66f39fee50d46f33a9b92b5f Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 6 Apr 2026 14:30:27 -0700 Subject: [PATCH 02/20] [SDK-2070] feat: implement FDv2CacheInitializer and CacheInitializerBuilderImpl Add FDv2CacheInitializer that loads persisted flag data from the local cache as the first step in the initializer chain. Per CONNMODE 4.1.2, the result uses Selector.EMPTY and persist=false so the orchestrator continues to the polling initializer for a verified selector. Cache miss and no-store cases return interrupted status to move on without delay. Add CacheInitializerBuilderImpl in DataSystemComponents and comprehensive tests covering cache hit, miss, no store, exceptions, and shutdown behavior. Made-with: Cursor --- .../sdk/android/DataSystemComponents.java | 13 ++ .../sdk/android/FDv2CacheInitializer.java | 99 +++++++++ .../sdk/android/FDv2CacheInitializerTest.java | 209 ++++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2CacheInitializerTest.java 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..2779d5c8 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 @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.android.integrations.PollingSynchronizerBuilder; import com.launchdarkly.sdk.android.integrations.StreamingSynchronizerBuilder; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.subsystems.CachedFlagStore; import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Initializer; @@ -138,6 +139,18 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } } + static final class CacheInitializerBuilderImpl implements DataSourceBuilder { + @Override + public Initializer build(DataSourceBuildInputs inputs) { + return new FDv2CacheInitializer( + inputs.getCachedFlagStore(), + inputs.getEvaluationContext(), + inputs.getSharedExecutor(), + inputs.getBaseLogger() + ); + } + } + /** * Returns a builder for a polling initializer. *

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..4c2837dc --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java @@ -0,0 +1,99 @@ +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.CachedFlagStore; +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.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** + * FDv2 cache initializer: loads persisted flag data from the local cache as the first + * step in the initializer chain. + *

+ * Per CONNMODE 4.1.2, the cache initializer returns data with {@code persist=false} + * and {@link Selector#EMPTY} (no selector), so the orchestrator continues to the next + * initializer (polling) to obtain a verified selector from the server. This provides + * immediate flag values from cache while the network initializer fetches fresh data. + *

+ * A cache miss is reported as an {@link FDv2SourceResult.Status#interrupted} status, + * causing the orchestrator to move to the next initializer without delay. + */ +final class FDv2CacheInitializer implements Initializer { + + @Nullable + private final CachedFlagStore cachedFlagStore; + private final LDContext context; + private final Executor executor; + private final LDLogger logger; + private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); + + FDv2CacheInitializer( + @Nullable CachedFlagStore cachedFlagStore, + @NonNull LDContext context, + @NonNull Executor executor, + @NonNull LDLogger logger + ) { + this.cachedFlagStore = cachedFlagStore; + this.context = context; + this.executor = executor; + this.logger = logger; + } + + @Override + @NonNull + public Future run() { + LDAwaitFuture resultFuture = new LDAwaitFuture<>(); + + executor.execute(() -> { + try { + if (cachedFlagStore == null) { + logger.debug("No persistent store configured; skipping cache"); + resultFuture.set(FDv2SourceResult.status( + FDv2SourceResult.Status.interrupted( + new LDFailure("No persistent store", LDFailure.FailureType.UNKNOWN_ERROR)), + false)); + return; + } + Map flags = cachedFlagStore.getCachedFlags(context); + if (flags == null) { + logger.debug("Cache miss for context"); + resultFuture.set(FDv2SourceResult.status( + FDv2SourceResult.Status.interrupted( + new LDFailure("No cached data", LDFailure.FailureType.UNKNOWN_ERROR)), + false)); + return; + } + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, + Selector.EMPTY, + flags, + null, + false); + logger.debug("Cache hit: loaded {} flags for context", flags.size()); + resultFuture.set(FDv2SourceResult.changeSet(changeSet, false)); + } catch (Exception e) { + logger.warn("Cache initializer failed: {}", e.toString()); + resultFuture.set(FDv2SourceResult.status( + FDv2SourceResult.Status.interrupted(e), false)); + } + }); + + return LDFutures.anyOf(shutdownFuture, resultFuture); + } + + @Override + public void close() { + shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); + } +} 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..cf88f90e --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2CacheInitializerTest.java @@ -0,0 +1,209 @@ +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.CachedFlagStore; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; +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 org.junit.After; +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.ExecutorService; +import java.util.concurrent.Executors; +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 final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @After + public void tearDown() { + executor.shutdownNow(); + } + + // ---- 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()); + + CachedFlagStore store = context -> new HashMap<>(flags); + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, 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()); + + CachedFlagStore store = context -> flags; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, 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 { + CachedFlagStore store = context -> new HashMap<>(); + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(ChangeSetType.Full, result.getChangeSet().getType()); + } + + @Test + public void cacheHit_changeSetHasPersistFalse() throws Exception { + CachedFlagStore store = context -> new HashMap<>(); + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertFalse(result.getChangeSet().shouldPersist()); + } + + @Test + public void cacheHit_fdv1FallbackIsFalse() throws Exception { + CachedFlagStore store = context -> new HashMap<>(); + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertFalse(result.isFdv1Fallback()); + } + + // ---- cache miss ---- + + @Test + public void cacheMiss_returnsInterruptedStatus() throws Exception { + CachedFlagStore store = context -> null; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertNotNull(result.getStatus()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + assertFalse(result.isFdv1Fallback()); + } + + // ---- no persistent store ---- + + @Test + public void noPersistentStore_returnsInterruptedStatus() throws Exception { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + null, CONTEXT, executor, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertNotNull(result.getStatus()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + } + + // ---- exception during cache read ---- + + @Test + public void exceptionDuringCacheRead_returnsInterruptedStatus() throws Exception { + CachedFlagStore store = context -> { + throw new RuntimeException("corrupt data"); + }; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertNotNull(result.getStatus()); + assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + } + + // ---- close() behavior ---- + + @Test + public void closeBeforeRun_returnsShutdown() throws Exception { + CachedFlagStore store = context -> { + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return null; + }; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, LDLogger.none()); + + initializer.close(); + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.STATUS, result.getResultType()); + assertNotNull(result.getStatus()); + assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); + } + + @Test + public void closeAfterCompletion_doesNotThrow() throws Exception { + CachedFlagStore store = context -> new HashMap<>(); + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, 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 { + CachedFlagStore store = context -> new HashMap<>(); + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, LDLogger.none()); + + FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); + + assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); + assertTrue(result.getChangeSet().getData().isEmpty()); + } +} From 563ec2281fa30acbfa252da478e464d25175df7a Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 6 Apr 2026 14:32:51 -0700 Subject: [PATCH 03/20] [SDK-2070] feat: wire cache initializer into the default mode table Prepend the cache initializer to all connection modes per CONNMODE 4.1.1. Every mode now starts with a cache read before any network initializer, providing immediate flag values from local storage while the polling initializer fetches fresh data with a verified selector. Update initializer count assertions in DataSystemBuilderTest and FDv2DataSourceBuilderTest to reflect the new cache initializer. Made-with: Cursor --- .../sdk/android/DataSystemComponents.java | 17 +++++++---------- .../sdk/android/FDv2DataSourceBuilderTest.java | 10 +++++----- .../integrations/DataSystemBuilderTest.java | 10 +++++----- 3 files changed, 17 insertions(+), 20 deletions(-) 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 2779d5c8..2c9dd5a6 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 @@ -201,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(); @@ -215,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/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 08680858..bde06587 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 @@ -243,7 +243,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 +255,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 +267,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 +279,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 +291,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/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()); } From dbe5c65b0a37d0b0d8e01910421166fc46e3f257 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 8 Apr 2026 12:07:04 -0700 Subject: [PATCH 04/20] [SDK-2070] Added comments where cache initialization is redundant --- .../com/launchdarkly/sdk/android/ContextDataManager.java | 5 +++++ .../src/main/java/com/launchdarkly/sdk/android/LDClient.java | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) 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..d89b21af 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 @@ -82,6 +82,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. + *

+ * Note: In the FDv2 path, this cache load is redundant with {@code FDv2CacheInitializer}, + * which performs the same read as the first step in the initializer chain. The duplicate + * apply is harmless (same data, persist=false) but could be removed once FDv2 is the + * default and FDv1 code paths are retired. * * @param context the to switch to */ 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..60d81b27 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 @@ -497,10 +497,12 @@ private void identifyInternal(@NonNull LDContext context, clientContextImpl = clientContextImpl.setEvaluationContext(context); - // Calling initFromStoredData updates the current flag state *if* stored flags exist for + // Calling switchToContext 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. + // Note: In the FDv2 path, this cache load is redundant with FDv2CacheInitializer + // (which runs as the first initializer). It can be removed once FDv1 is retired. contextDataManager.switchToContext(context); connectivityManager.switchToContext(context, onCompleteListener); eventProcessor.recordIdentifyEvent(context); From 70bd195ef813ccf04977d7349a7ebd5fc0c12c00 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 8 Apr 2026 12:23:26 -0700 Subject: [PATCH 05/20] [SDK-2070] refactor: skip redundant cache load in FDv2 path In FDv2, the FDv2CacheInitializer handles cache loading as the first step in the initializer chain, making the cache load in ContextDataManager.switchToContext() redundant. Add a skipCacheLoad parameter to ContextDataManager and a setCurrentContext() method so that the FDv2 path sets the context without reading from cache, while the FDv1 path continues to load cached flags immediately. Made-with: Cursor --- .../sdk/android/ContextDataManager.java | 31 ++++++++++++++----- .../launchdarkly/sdk/android/LDClient.java | 22 ++++++++----- .../sdk/android/ConnectivityManagerTest.java | 3 +- .../android/ContextDataManagerTestBase.java | 3 +- 4 files changed, 42 insertions(+), 17 deletions(-) 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 d89b21af..1659f3ba 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 @@ -64,17 +64,39 @@ 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()); + if (skipCacheLoad) { + setCurrentContext(clientContext.getEvaluationContext()); + } else { + switchToContext(clientContext.getEvaluationContext()); + } + } + + /** + * Sets the current context without loading cached data. Used in the FDv2 path where + * the {@code FDv2CacheInitializer} handles cache loading as part of the initializer chain. + * + * @param context the context to switch to + */ + public void setCurrentContext(@NonNull LDContext context) { + synchronized (lock) { + currentContext = context; + } } /** @@ -82,11 +104,6 @@ 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. - *

- * Note: In the FDv2 path, this cache load is redundant with {@code FDv2CacheInitializer}, - * which performs the same read as the first step in the initializer chain. The duplicate - * apply is harmless (same data, persist=false) but could be removed once FDv2 is the - * default and FDv1 code paths are retired. * * @param context the to switch to */ 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 60d81b27..2e9a01b4 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,13 +499,17 @@ private void identifyInternal(@NonNull LDContext context, clientContextImpl = clientContextImpl.setEvaluationContext(context); - // Calling switchToContext 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. - // Note: In the FDv2 path, this cache load is redundant with FDv2CacheInitializer - // (which runs as the first initializer). It can be removed once FDv1 is retired. - 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. + if (config.dataSource instanceof FDv2DataSourceBuilder) { + // FDv2: just set the context; the FDv2CacheInitializer handles cache loading + // as the first step in the initializer chain. + contextDataManager.setCurrentContext(context); + } else { + // FDv1: load cached flags immediately while the data source fetches from the network. + contextDataManager.switchToContext(context); + } connectivityManager.switchToContext(context, onCompleteListener); eventProcessor.recordIdentifyEvent(context); } 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..28cc455f 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); 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 ); } From f856058abba7ae66a8d58a3aa720c6b7ad8f0aba Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 10 Apr 2026 09:20:40 -0700 Subject: [PATCH 06/20] [SDK-2070] refactor: replace CachedFlagStore with ReadOnlyPerEnvironmentData Made-with: Cursor --- .../sdk/android/DataSystemComponents.java | 17 ++++++- .../sdk/android/FDv2CacheInitializer.java | 15 ++++--- .../sdk/android/FDv2DataSourceBuilder.java | 30 +++++++------ .../android/PersistentDataStoreWrapper.java | 17 ++++++- .../android/subsystems/CachedFlagStore.java | 27 ------------ .../subsystems/DataSourceBuildInputs.java | 18 -------- .../sdk/android/FDv2CacheInitializerTest.java | 44 ++++++++++--------- 7 files changed, 79 insertions(+), 89 deletions(-) delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/CachedFlagStore.java 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 2c9dd5a6..2e885e79 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 @@ -7,7 +7,6 @@ import com.launchdarkly.sdk.android.integrations.PollingSynchronizerBuilder; import com.launchdarkly.sdk.android.integrations.StreamingSynchronizerBuilder; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; -import com.launchdarkly.sdk.android.subsystems.CachedFlagStore; import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.Initializer; @@ -15,6 +14,7 @@ import com.launchdarkly.sdk.internal.http.HttpProperties; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.net.URI; import java.util.Arrays; @@ -140,10 +140,23 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } static final class CacheInitializerBuilderImpl implements DataSourceBuilder { + @Nullable + private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData; + + CacheInitializerBuilderImpl() { + this.envData = null; + } + + CacheInitializerBuilderImpl( + @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData + ) { + this.envData = envData; + } + @Override public Initializer build(DataSourceBuildInputs inputs) { return new FDv2CacheInitializer( - inputs.getCachedFlagStore(), + envData, inputs.getEvaluationContext(), inputs.getSharedExecutor(), inputs.getBaseLogger() 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 index 4c2837dc..80204984 100644 --- 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 @@ -6,7 +6,6 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.DataModel.Flag; -import com.launchdarkly.sdk.android.subsystems.CachedFlagStore; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.fdv2.ChangeSet; @@ -32,19 +31,19 @@ final class FDv2CacheInitializer implements Initializer { @Nullable - private final CachedFlagStore cachedFlagStore; + private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData; private final LDContext context; private final Executor executor; private final LDLogger logger; private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); FDv2CacheInitializer( - @Nullable CachedFlagStore cachedFlagStore, + @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData, @NonNull LDContext context, @NonNull Executor executor, @NonNull LDLogger logger ) { - this.cachedFlagStore = cachedFlagStore; + this.envData = envData; this.context = context; this.executor = executor; this.logger = logger; @@ -57,7 +56,7 @@ public Future run() { executor.execute(() -> { try { - if (cachedFlagStore == null) { + if (envData == null) { logger.debug("No persistent store configured; skipping cache"); resultFuture.set(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( @@ -65,8 +64,9 @@ public Future run() { false)); return; } - Map flags = cachedFlagStore.getCachedFlags(context); - if (flags == null) { + String hashedContextId = LDUtil.urlSafeBase64HashedContextId(context); + EnvironmentData stored = envData.getContextData(hashedContextId); + if (stored == null) { logger.debug("Cache miss for context"); resultFuture.set(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( @@ -74,6 +74,7 @@ public Future run() { false)); return; } + Map flags = stored.getAll(); ChangeSet> changeSet = new ChangeSet<>( ChangeSetType.Full, Selector.EMPTY, 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 ac0291be..b72078dd 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 @@ -1,8 +1,8 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; -import com.launchdarkly.sdk.android.subsystems.CachedFlagStore; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; @@ -116,7 +116,9 @@ public DataSource build(ClientContext clientContext) { } DataSourceBuildInputs inputs = makeInputs(clientContext); - ResolvedModeDefinition resolved = resolve(modeDef, inputs); + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData = + ClientContextImpl.get(clientContext).getPerEnvironmentDataIfAvailable(); + ResolvedModeDefinition resolved = resolve(modeDef, inputs, envData); DataSourceUpdateSink baseSink = clientContext.getDataSourceUpdateSink(); if (!(baseSink instanceof DataSourceUpdateSinkV2)) { @@ -156,15 +158,6 @@ private DataSourceBuildInputs makeInputs(ClientContext clientContext) { ? new SelectorSourceFacade(store) : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; - PersistentDataStoreWrapper.PerEnvironmentData envData = impl.getPerEnvironmentDataIfAvailable(); - CachedFlagStore cachedFlagStore = envData != null - ? context -> { - String hashedId = LDUtil.urlSafeBase64HashedContextId(context); - EnvironmentData stored = envData.getContextData(hashedId); - return stored != null ? stored.getAll() : null; - } - : null; - return new DataSourceBuildInputs( clientContext.getEvaluationContext(), clientContext.getServiceEndpoints(), @@ -173,17 +166,26 @@ private DataSourceBuildInputs makeInputs(ClientContext clientContext) { selectorSource, sharedExecutor, impl.getPlatformState().getCacheDir(), - cachedFlagStore, clientContext.getBaseLogger() ); } private static ResolvedModeDefinition resolve( - ModeDefinition def, DataSourceBuildInputs inputs + ModeDefinition def, DataSourceBuildInputs inputs, + @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData ) { List> initFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getInitializers()) { - initFactories.add(() -> builder.build(inputs)); + // The cache initializer's dependency (ReadOnlyPerEnvironmentData) is only + // available at build time, not when the static mode table is constructed, + // so we inject it here by replacing the placeholder with a wired copy. + final DataSourceBuilder effective; + if (builder instanceof DataSystemComponents.CacheInitializerBuilderImpl) { + effective = new DataSystemComponents.CacheInitializerBuilderImpl(envData); + } else { + effective = builder; + } + initFactories.add(() -> effective.build(inputs)); } List> syncFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getSynchronizers()) { 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/CachedFlagStore.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/CachedFlagStore.java deleted file mode 100644 index cf83d503..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/CachedFlagStore.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.launchdarkly.sdk.android.subsystems; - -import androidx.annotation.Nullable; - -import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.android.DataModel; - -import java.util.Map; - -/** - * Provides read access to cached flag data for a specific evaluation context. - *

- * This interface bridges the persistence layer with FDv2 data source builders, - * allowing the cache initializer to load stored flags without depending on - * package-private types. - */ -public interface CachedFlagStore { - /** - * Returns the cached flag data for the given context, or null if no - * cached data exists. - * - * @param context the evaluation context to look up - * @return the cached flags, or null on cache miss - */ - @Nullable - Map getCachedFlags(LDContext context); -} 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 8a91c7b0..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,7 +1,6 @@ package com.launchdarkly.sdk.android.subsystems; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; @@ -32,8 +31,6 @@ public final class DataSourceBuildInputs { private final SelectorSource selectorSource; private final ScheduledExecutorService sharedExecutor; private final File cacheDir; - @Nullable - private final CachedFlagStore cachedFlagStore; private final LDLogger baseLogger; /** @@ -47,8 +44,6 @@ public final class DataSourceBuildInputs { * @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 cachedFlagStore read access to cached flag data, or null if no persistent - * store is configured * @param baseLogger the base logger instance */ public DataSourceBuildInputs( @@ -59,7 +54,6 @@ public DataSourceBuildInputs( SelectorSource selectorSource, ScheduledExecutorService sharedExecutor, @NonNull File cacheDir, - @Nullable CachedFlagStore cachedFlagStore, LDLogger baseLogger ) { this.evaluationContext = evaluationContext; @@ -69,7 +63,6 @@ public DataSourceBuildInputs( this.selectorSource = selectorSource; this.sharedExecutor = sharedExecutor; this.cacheDir = cacheDir; - this.cachedFlagStore = cachedFlagStore; this.baseLogger = baseLogger; } @@ -140,17 +133,6 @@ public File getCacheDir() { return cacheDir; } - /** - * Returns read access to cached flag data, or null if no persistent store - * is configured. Used by the cache initializer to load stored flags. - * - * @return the cached flag store, or null - */ - @Nullable - public CachedFlagStore getCachedFlagStore() { - return cachedFlagStore; - } - /** * Returns the base logger instance. * 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 index cf88f90e..a722cb08 100644 --- 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 @@ -9,11 +9,8 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.DataModel.Flag; -import com.launchdarkly.sdk.android.subsystems.CachedFlagStore; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; -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; @@ -35,6 +32,8 @@ public class FDv2CacheInitializerTest { 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 final ExecutorService executor = Executors.newSingleThreadExecutor(); @@ -43,6 +42,11 @@ public void tearDown() { executor.shutdownNow(); } + private static PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData storeReturning( + EnvironmentData data) { + return hashedContextId -> HASHED_CONTEXT_ID.equals(hashedContextId) ? data : null; + } + // ---- cache hit ---- @Test @@ -51,9 +55,9 @@ public void cacheHit_returnsChangeSetWithFlags() throws Exception { flags.put("flag1", new FlagBuilder("flag1").version(1).value(true).build()); flags.put("flag2", new FlagBuilder("flag2").version(2).value(LDValue.of("hello")).build()); - CachedFlagStore store = context -> new HashMap<>(flags); FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + storeReturning(EnvironmentData.copyingFlagsMap(flags)), + CONTEXT, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -69,9 +73,9 @@ public void cacheHit_changeSetHasEmptySelector() throws Exception { Map flags = new HashMap<>(); flags.put("flag1", new FlagBuilder("flag1").version(1).build()); - CachedFlagStore store = context -> flags; FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + storeReturning(EnvironmentData.copyingFlagsMap(flags)), + CONTEXT, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -81,9 +85,9 @@ public void cacheHit_changeSetHasEmptySelector() throws Exception { @Test public void cacheHit_changeSetHasFullType() throws Exception { - CachedFlagStore store = context -> new HashMap<>(); FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + storeReturning(new EnvironmentData()), + CONTEXT, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -92,9 +96,9 @@ public void cacheHit_changeSetHasFullType() throws Exception { @Test public void cacheHit_changeSetHasPersistFalse() throws Exception { - CachedFlagStore store = context -> new HashMap<>(); FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + storeReturning(new EnvironmentData()), + CONTEXT, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -103,9 +107,9 @@ public void cacheHit_changeSetHasPersistFalse() throws Exception { @Test public void cacheHit_fdv1FallbackIsFalse() throws Exception { - CachedFlagStore store = context -> new HashMap<>(); FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + storeReturning(new EnvironmentData()), + CONTEXT, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -116,7 +120,7 @@ public void cacheHit_fdv1FallbackIsFalse() throws Exception { @Test public void cacheMiss_returnsInterruptedStatus() throws Exception { - CachedFlagStore store = context -> null; + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> null; FDv2CacheInitializer initializer = new FDv2CacheInitializer( store, CONTEXT, executor, LDLogger.none()); @@ -146,7 +150,7 @@ public void noPersistentStore_returnsInterruptedStatus() throws Exception { @Test public void exceptionDuringCacheRead_returnsInterruptedStatus() throws Exception { - CachedFlagStore store = context -> { + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> { throw new RuntimeException("corrupt data"); }; FDv2CacheInitializer initializer = new FDv2CacheInitializer( @@ -163,7 +167,7 @@ public void exceptionDuringCacheRead_returnsInterruptedStatus() throws Exception @Test public void closeBeforeRun_returnsShutdown() throws Exception { - CachedFlagStore store = context -> { + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> { try { Thread.sleep(5000); } catch (InterruptedException e) { @@ -184,9 +188,9 @@ public void closeBeforeRun_returnsShutdown() throws Exception { @Test public void closeAfterCompletion_doesNotThrow() throws Exception { - CachedFlagStore store = context -> new HashMap<>(); FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + storeReturning(new EnvironmentData()), + CONTEXT, executor, LDLogger.none()); Future future = initializer.run(); future.get(1, TimeUnit.SECONDS); @@ -197,9 +201,9 @@ public void closeAfterCompletion_doesNotThrow() throws Exception { @Test public void emptyCacheReturnsChangeSetWithEmptyMap() throws Exception { - CachedFlagStore store = context -> new HashMap<>(); FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + storeReturning(new EnvironmentData()), + CONTEXT, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); From 24c962fe9ffdf9b4800a2c3b73b0e84f24dceb46 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 10 Apr 2026 09:45:54 -0700 Subject: [PATCH 07/20] [SDK-2070] fix: return ChangeSetType.None on cache miss instead of interrupted Cache miss and missing persistent store now return a "transfer of none" changeset (ChangeSetType.None with Selector.EMPTY) instead of an interrupted status. This fixes an OFFLINE mode regression where a cache miss left the SDK in a failed initialization state because no synchronizers follow to recover. Made-with: Cursor --- .../sdk/android/FDv2CacheInitializer.java | 29 +++++++++------ .../sdk/android/FDv2CacheInitializerTest.java | 35 ++++++++++++++----- 2 files changed, 46 insertions(+), 18 deletions(-) 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 index 80204984..46e7e537 100644 --- 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 @@ -12,6 +12,7 @@ import com.launchdarkly.sdk.fdv2.ChangeSetType; import com.launchdarkly.sdk.fdv2.Selector; +import java.util.Collections; import java.util.Map; import java.util.concurrent.Executor; import java.util.concurrent.Future; @@ -25,8 +26,12 @@ * initializer (polling) to obtain a verified selector from the server. This provides * immediate flag values from cache while the network initializer fetches fresh data. *

- * A cache miss is reported as an {@link FDv2SourceResult.Status#interrupted} status, - * causing the orchestrator to move to the next initializer without delay. + * A cache miss (or missing persistent store) is returned as a {@link ChangeSetType#None} + * changeset — analogous to "transfer of none" / 304 Not Modified (CSFDV2 9.1.2). This + * signals "I checked the source and there is nothing new" rather than an error, so the + * orchestrator records {@code anyDataReceived = true} and continues normally. This is + * critical for OFFLINE mode where no synchronizers follow: without it, a cache miss + * would leave the SDK in a failed initialization state. */ final class FDv2CacheInitializer implements Initializer { @@ -58,20 +63,24 @@ public Future run() { try { if (envData == null) { logger.debug("No persistent store configured; skipping cache"); - resultFuture.set(FDv2SourceResult.status( - FDv2SourceResult.Status.interrupted( - new LDFailure("No persistent store", LDFailure.FailureType.UNKNOWN_ERROR)), - false)); + resultFuture.set(FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + Selector.EMPTY, + Collections.emptyMap(), + null, + false), false)); return; } String hashedContextId = LDUtil.urlSafeBase64HashedContextId(context); EnvironmentData stored = envData.getContextData(hashedContextId); if (stored == null) { logger.debug("Cache miss for context"); - resultFuture.set(FDv2SourceResult.status( - FDv2SourceResult.Status.interrupted( - new LDFailure("No cached data", LDFailure.FailureType.UNKNOWN_ERROR)), - false)); + resultFuture.set(FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + Selector.EMPTY, + Collections.emptyMap(), + null, + false), false)); return; } Map flags = stored.getAll(); 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 index a722cb08..d99e8e96 100644 --- 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 @@ -10,6 +10,7 @@ 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 com.launchdarkly.sdk.fdv2.SourceSignal; @@ -119,31 +120,49 @@ public void cacheHit_fdv1FallbackIsFalse() throws Exception { // ---- cache miss ---- @Test - public void cacheMiss_returnsInterruptedStatus() throws Exception { + public void cacheMiss_returnsNoneChangeSet() throws Exception { + PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> null; + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + store, CONTEXT, executor, 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, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); - assertEquals(SourceResultType.STATUS, result.getResultType()); - assertNotNull(result.getStatus()); - assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); assertFalse(result.isFdv1Fallback()); } // ---- no persistent store ---- @Test - public void noPersistentStore_returnsInterruptedStatus() throws Exception { + public void noPersistentStore_returnsNoneChangeSet() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( null, CONTEXT, executor, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); - assertEquals(SourceResultType.STATUS, result.getResultType()); - assertNotNull(result.getStatus()); - assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + 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 ---- From 7b5ed27ad46a54c00f5888dc09500ceef6809d22 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 13 Apr 2026 10:27:53 -0700 Subject: [PATCH 08/20] [SDK-2070] fix: defer init completion to synchronizers and treat cache exceptions as None Two fixes for the cache initializer: 1. Orchestrator: only complete initialization from the post-initializer loop when no synchronizers are available. When synchronizers exist, they are the authority on init completion. Fixes premature init in POLLING/STREAMING modes where a cache miss None changeset was completing start before the synchronizer fetched server data. 2. Cache initializer: return ChangeSetType.None on exceptions during cache read instead of interrupted status. A corrupt/unreadable cache is semantically equivalent to an empty cache, not a hard error. Made-with: Cursor --- .../sdk/android/FDv2CacheInitializer.java | 23 +++++++++++-------- .../sdk/android/FDv2DataSource.java | 8 ++++--- .../sdk/android/FDv2CacheInitializerTest.java | 13 +++++++---- 3 files changed, 27 insertions(+), 17 deletions(-) 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 index 46e7e537..827e36c9 100644 --- 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 @@ -21,17 +21,16 @@ * FDv2 cache initializer: loads persisted flag data from the local cache as the first * step in the initializer chain. *

- * Per CONNMODE 4.1.2, the cache initializer returns data with {@code persist=false} - * and {@link Selector#EMPTY} (no selector), so the orchestrator continues to the next + * Per CONNMODE 4.1.2, a cache hit returns data with {@code persist=false} and + * {@link Selector#EMPTY} (no selector), so the orchestrator continues to the next * initializer (polling) to obtain a verified selector from the server. This provides * immediate flag values from cache while the network initializer fetches fresh data. *

- * A cache miss (or missing persistent store) is returned as a {@link ChangeSetType#None} - * changeset — analogous to "transfer of none" / 304 Not Modified (CSFDV2 9.1.2). This - * signals "I checked the source and there is nothing new" rather than an error, so the - * orchestrator records {@code anyDataReceived = true} and continues normally. This is - * critical for OFFLINE mode where no synchronizers follow: without it, a cache miss - * would leave the SDK in a failed initialization state. + * All non-hit outcomes — cache miss, missing persistent store, and exceptions during + * cache read — are returned as a {@link ChangeSetType#None} changeset, analogous to + * "transfer of none" / 304 Not Modified (CSFDV2 9.1.2). This signals "I checked the + * source and there is nothing new" rather than an error. A corrupt or unreadable cache + * is semantically equivalent to an empty cache: neither provides usable data. */ final class FDv2CacheInitializer implements Initializer { @@ -94,8 +93,12 @@ public Future run() { resultFuture.set(FDv2SourceResult.changeSet(changeSet, false)); } catch (Exception e) { logger.warn("Cache initializer failed: {}", e.toString()); - resultFuture.set(FDv2SourceResult.status( - FDv2SourceResult.Status.interrupted(e), false)); + resultFuture.set(FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + Selector.EMPTY, + Collections.emptyMap(), + null, + false), false)); } }); 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 b7eb2dd2..ffe75250 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 @@ -318,9 +318,11 @@ private void runInitializers( } initializer = sourceManager.getNextInitializerAndSetActive(); } - // All initializers exhausted. If any gave us data (even without a final selector), - // consider initialization successful and let synchronizers keep the data current. - if (anyDataReceived) { + // All initializers exhausted. If data was received and no synchronizers will follow, + // consider initialization successful. When synchronizers are available, defer init + // completion to the synchronizer loop — the synchronizer is the authority on whether + // the SDK has a verified, up-to-date payload. + if (anyDataReceived && !sourceManager.hasAvailableSynchronizers()) { sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); } 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 index d99e8e96..e1fae159 100644 --- 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 @@ -168,7 +168,7 @@ public void noPersistentStore_returnsNoneChangeSet() throws Exception { // ---- exception during cache read ---- @Test - public void exceptionDuringCacheRead_returnsInterruptedStatus() throws Exception { + public void exceptionDuringCacheRead_returnsNoneChangeSet() throws Exception { PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> { throw new RuntimeException("corrupt data"); }; @@ -177,9 +177,14 @@ public void exceptionDuringCacheRead_returnsInterruptedStatus() throws Exception FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); - assertEquals(SourceResultType.STATUS, result.getResultType()); - assertNotNull(result.getStatus()); - assertEquals(SourceSignal.INTERRUPTED, result.getStatus().getState()); + 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 ---- From 6aeea41d81d4d5c2fa4f9946781333f93b419cdc Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 13 Apr 2026 10:41:07 -0700 Subject: [PATCH 09/20] [SDK-2070] Addressing code review comments - Trim FDv2CacheInitializer Javadoc to only describe this class's responsibility; remove references to orchestrator, other initializers, HTTP status codes, and external spec sections. - Merge setCurrentContext() into switchToContext(context, skipCacheLoad) to eliminate the separate method and simplify call sites in the constructor and LDClient.identifyInternal(). Made-with: Cursor --- .../sdk/android/ContextDataManager.java | 28 ++++++----------- .../sdk/android/FDv2CacheInitializer.java | 14 ++++----- .../launchdarkly/sdk/android/LDClient.java | 10 ++----- .../sdk/android/ConnectivityManagerTest.java | 10 +++---- .../android/ContextDataManagerApplyTest.java | 18 +++++------ .../ContextDataManagerContextCachingTest.java | 8 ++--- .../ContextDataManagerFlagDataTest.java | 30 +++++++++---------- .../ContextDataManagerListenersTest.java | 12 ++++---- 8 files changed, 55 insertions(+), 75 deletions(-) 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 1659f3ba..83341c88 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 @@ -80,23 +80,7 @@ final class ContextDataManager implements TransactionalDataStore { this.maxCachedContexts = maxCachedContexts; this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor(); this.logger = clientContext.getBaseLogger(); - if (skipCacheLoad) { - setCurrentContext(clientContext.getEvaluationContext()); - } else { - switchToContext(clientContext.getEvaluationContext()); - } - } - - /** - * Sets the current context without loading cached data. Used in the FDv2 path where - * the {@code FDv2CacheInitializer} handles cache loading as part of the initializer chain. - * - * @param context the context to switch to - */ - public void setCurrentContext(@NonNull LDContext context) { - synchronized (lock) { - currentContext = context; - } + switchToContext(clientContext.getEvaluationContext(), skipCacheLoad); } /** @@ -105,9 +89,11 @@ public void setCurrentContext(@NonNull LDContext context) { * 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; @@ -115,6 +101,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/FDv2CacheInitializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java index 827e36c9..284240b6 100644 --- 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 @@ -18,19 +18,15 @@ import java.util.concurrent.Future; /** - * FDv2 cache initializer: loads persisted flag data from the local cache as the first - * step in the initializer chain. + * 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), so the orchestrator continues to the next - * initializer (polling) to obtain a verified selector from the server. This provides - * immediate flag values from cache while the network initializer fetches fresh data. + * {@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, analogous to - * "transfer of none" / 304 Not Modified (CSFDV2 9.1.2). This signals "I checked the - * source and there is nothing new" rather than an error. A corrupt or unreadable cache - * is semantically equivalent to an empty cache: neither provides usable data. + * 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. */ final class FDv2CacheInitializer implements Initializer { 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 2e9a01b4..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 @@ -502,14 +502,8 @@ private void identifyInternal(@NonNull LDContext 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. - if (config.dataSource instanceof FDv2DataSourceBuilder) { - // FDv2: just set the context; the FDv2CacheInitializer handles cache loading - // as the first step in the initializer chain. - contextDataManager.setCurrentContext(context); - } else { - // FDv1: load cached flags immediately while the data source fetches from the network. - contextDataManager.switchToContext(context); - } + 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/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 28cc455f..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 @@ -509,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(); @@ -541,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 @@ -569,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 @@ -784,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); @@ -1123,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..6b70223b 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 @@ -33,7 +33,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 +49,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 +66,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..ed5e6648 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 @@ -24,7 +24,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 +33,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 +66,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 +107,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 +121,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 +134,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 +152,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 +168,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 +187,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 +221,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 +254,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); From 3f0066bafb28f45ff5b755360b3fadee50a7ec3e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 13 Apr 2026 13:42:47 -0700 Subject: [PATCH 10/20] [SDK-2070] Run FDv2CacheInitializer synchronously to eliminate executor overhead Cache read now runs inline on the caller's thread instead of dispatching to an executor, removing ~300us of thread scheduling overhead per Todd's benchmarking. close() becomes a no-op since there is nothing to cancel. Made-with: Cursor --- .../sdk/android/DataSystemComponents.java | 1 - .../sdk/android/FDv2CacheInitializer.java | 82 +++++++++---------- .../sdk/android/FDv2CacheInitializerTest.java | 54 +++--------- 3 files changed, 51 insertions(+), 86 deletions(-) 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 2e885e79..5c445967 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 @@ -158,7 +158,6 @@ public Initializer build(DataSourceBuildInputs inputs) { return new FDv2CacheInitializer( envData, inputs.getEvaluationContext(), - inputs.getSharedExecutor(), inputs.getBaseLogger() ); } 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 index 284240b6..e8ab3e63 100644 --- 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 @@ -14,7 +14,6 @@ import java.util.Collections; import java.util.Map; -import java.util.concurrent.Executor; import java.util.concurrent.Future; /** @@ -27,82 +26,81 @@ * 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 Executor executor; private final LDLogger logger; - private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); FDv2CacheInitializer( @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData, @NonNull LDContext context, - @NonNull Executor executor, @NonNull LDLogger logger ) { this.envData = envData; this.context = context; - this.executor = executor; this.logger = logger; } @Override @NonNull public Future run() { - LDAwaitFuture resultFuture = new LDAwaitFuture<>(); - - executor.execute(() -> { - try { - if (envData == null) { - logger.debug("No persistent store configured; skipping cache"); - resultFuture.set(FDv2SourceResult.changeSet(new ChangeSet<>( - ChangeSetType.None, - Selector.EMPTY, - Collections.emptyMap(), - null, - false), false)); - return; - } + 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"); - resultFuture.set(FDv2SourceResult.changeSet(new ChangeSet<>( + result = FDv2SourceResult.changeSet(new ChangeSet<>( ChangeSetType.None, Selector.EMPTY, Collections.emptyMap(), null, - false), false)); - return; + 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); } - 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()); - resultFuture.set(FDv2SourceResult.changeSet(changeSet, false)); - } catch (Exception e) { - logger.warn("Cache initializer failed: {}", e.toString()); - resultFuture.set(FDv2SourceResult.changeSet(new ChangeSet<>( - ChangeSetType.None, - Selector.EMPTY, - Collections.emptyMap(), - null, - false), 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); + } - return LDFutures.anyOf(shutdownFuture, resultFuture); + LDAwaitFuture future = new LDAwaitFuture<>(); + future.set(result); + return future; } @Override public void close() { - shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); + // No-op: the cache read runs synchronously in run(), so there is nothing to cancel. } } 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 index e1fae159..dcdf2af2 100644 --- 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 @@ -13,17 +13,13 @@ import com.launchdarkly.sdk.fdv2.ChangeSet; 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.util.HashMap; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -36,13 +32,6 @@ public class FDv2CacheInitializerTest { private static final String HASHED_CONTEXT_ID = LDUtil.urlSafeBase64HashedContextId(CONTEXT); - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - - @After - public void tearDown() { - executor.shutdownNow(); - } - private static PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData storeReturning( EnvironmentData data) { return hashedContextId -> HASHED_CONTEXT_ID.equals(hashedContextId) ? data : null; @@ -58,7 +47,7 @@ public void cacheHit_returnsChangeSetWithFlags() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( storeReturning(EnvironmentData.copyingFlagsMap(flags)), - CONTEXT, executor, LDLogger.none()); + CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -76,7 +65,7 @@ public void cacheHit_changeSetHasEmptySelector() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( storeReturning(EnvironmentData.copyingFlagsMap(flags)), - CONTEXT, executor, LDLogger.none()); + CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -88,7 +77,7 @@ public void cacheHit_changeSetHasEmptySelector() throws Exception { public void cacheHit_changeSetHasFullType() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( storeReturning(new EnvironmentData()), - CONTEXT, executor, LDLogger.none()); + CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -99,7 +88,7 @@ public void cacheHit_changeSetHasFullType() throws Exception { public void cacheHit_changeSetHasPersistFalse() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( storeReturning(new EnvironmentData()), - CONTEXT, executor, LDLogger.none()); + CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -110,7 +99,7 @@ public void cacheHit_changeSetHasPersistFalse() throws Exception { public void cacheHit_fdv1FallbackIsFalse() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( storeReturning(new EnvironmentData()), - CONTEXT, executor, LDLogger.none()); + CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -123,7 +112,7 @@ public void cacheHit_fdv1FallbackIsFalse() throws Exception { public void cacheMiss_returnsNoneChangeSet() throws Exception { PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> null; FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + store, CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -140,7 +129,7 @@ public void cacheMiss_returnsNoneChangeSet() throws Exception { public void cacheMiss_fdv1FallbackIsFalse() throws Exception { PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> null; FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + store, CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -152,7 +141,7 @@ public void cacheMiss_fdv1FallbackIsFalse() throws Exception { @Test public void noPersistentStore_returnsNoneChangeSet() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( - null, CONTEXT, executor, LDLogger.none()); + null, CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -173,7 +162,7 @@ public void exceptionDuringCacheRead_returnsNoneChangeSet() throws Exception { throw new RuntimeException("corrupt data"); }; FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); + store, CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); @@ -189,32 +178,11 @@ public void exceptionDuringCacheRead_returnsNoneChangeSet() throws Exception { // ---- close() behavior ---- - @Test - public void closeBeforeRun_returnsShutdown() throws Exception { - PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData store = hashedContextId -> { - try { - Thread.sleep(5000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return null; - }; - FDv2CacheInitializer initializer = new FDv2CacheInitializer( - store, CONTEXT, executor, LDLogger.none()); - - initializer.close(); - FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); - - assertEquals(SourceResultType.STATUS, result.getResultType()); - assertNotNull(result.getStatus()); - assertEquals(SourceSignal.SHUTDOWN, result.getStatus().getState()); - } - @Test public void closeAfterCompletion_doesNotThrow() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( storeReturning(new EnvironmentData()), - CONTEXT, executor, LDLogger.none()); + CONTEXT, LDLogger.none()); Future future = initializer.run(); future.get(1, TimeUnit.SECONDS); @@ -227,7 +195,7 @@ public void closeAfterCompletion_doesNotThrow() throws Exception { public void emptyCacheReturnsChangeSetWithEmptyMap() throws Exception { FDv2CacheInitializer initializer = new FDv2CacheInitializer( storeReturning(new EnvironmentData()), - CONTEXT, executor, LDLogger.none()); + CONTEXT, LDLogger.none()); FDv2SourceResult result = initializer.run().get(1, TimeUnit.SECONDS); From 8f5353a73d27fb1bd33292819bba647014111467 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 14 Apr 2026 11:54:11 -0700 Subject: [PATCH 11/20] [SDK-2070] Add isRequiredBeforeStartup() marker to Initializer interface Add a default method `isRequiredBeforeStartup()` to the Initializer interface so FDv2DataSource can distinguish initializers that must run before the startup timeout begins. FDv2CacheInitializer overrides it to return true, matching FDv1 behavior where cache was always loaded before the timeout started. Made-with: Cursor --- .../sdk/android/FDv2CacheInitializer.java | 5 +++++ .../sdk/android/subsystems/Initializer.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+) 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 index e8ab3e63..0e9ba671 100644 --- 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 @@ -99,6 +99,11 @@ public Future run() { return future; } + @Override + public boolean isRequiredBeforeStartup() { + return true; + } + @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/subsystems/Initializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java index 31e07459..f0ec7a1d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java @@ -26,4 +26,18 @@ public interface Initializer extends Closeable { * @return a Future that completes with the result */ Future run(); + + /** + * Whether this initializer must run before the startup timeout begins ticking. + *

+ * Eager initializers run synchronously on the calling thread during + * {@code FDv2DataSource.start()}, before work is dispatched to the executor. + * This guarantees their data is available even with a timeout of zero, + * matching the FDv1 behavior where cached data was loaded in the constructor. + * + * @return {@code true} if this initializer must complete before the timeout starts + */ + default boolean isRequiredBeforeStartup() { + return false; + } } From 4e9746d6d1aee26adc3c21e694549c0a3cdabcf1 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 14 Apr 2026 11:54:19 -0700 Subject: [PATCH 12/20] [SDK-2070] Build initializers eagerly in FDv2DataSourceBuilder Build Initializer instances directly in resolve() instead of wrapping them in factory lambdas. This allows FDv2DataSource to inspect isRequiredBeforeStartup() and run pre-startup initializers synchronously before dispatching to the executor. ResolvedModeDefinition now carries List instead of List>. Made-with: Cursor --- .../sdk/android/FDv2DataSourceBuilder.java | 15 +++++++++------ .../sdk/android/ResolvedModeDefinition.java | 17 ++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) 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 b72078dd..ef2c0d93 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 @@ -126,15 +126,15 @@ public DataSource build(ClientContext clientContext) { "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); } - List> initFactories = - includeInitializers ? resolved.getInitializerFactories() : Collections.>emptyList(); + List initializers = + includeInitializers ? resolved.getInitializers() : Collections.emptyList(); // Reset includeInitializers to default after each build to prevent stale state. includeInitializers = true; return new FDv2DataSource( clientContext.getEvaluationContext(), - initFactories, + initializers, resolved.getSynchronizerFactories(), resolved.getFdv1FallbackSynchronizerFactory(), (DataSourceUpdateSinkV2) baseSink, @@ -174,7 +174,7 @@ private static ResolvedModeDefinition resolve( ModeDefinition def, DataSourceBuildInputs inputs, @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData ) { - List> initFactories = new ArrayList<>(); + List initializers = new ArrayList<>(); for (DataSourceBuilder builder : def.getInitializers()) { // The cache initializer's dependency (ReadOnlyPerEnvironmentData) is only // available at build time, not when the static mode table is constructed, @@ -185,7 +185,10 @@ private static ResolvedModeDefinition resolve( } else { effective = builder; } - initFactories.add(() -> effective.build(inputs)); + Initializer init = effective.build(inputs); + if (init != null) { + initializers.add(init); + } } List> syncFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getSynchronizers()) { @@ -194,6 +197,6 @@ private static ResolvedModeDefinition resolve( DataSourceBuilder fdv1FallbackSynchronizer = def.getFdv1FallbackSynchronizer(); FDv2DataSource.DataSourceFactory fdv1Factory = fdv1FallbackSynchronizer != null ? () -> fdv1FallbackSynchronizer.build(inputs) : null; - return new ResolvedModeDefinition(initFactories, syncFactories, fdv1Factory); + return new ResolvedModeDefinition(initializers, syncFactories, fdv1Factory); } } 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 0aeed42d..ad4bdb9b 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 @@ -10,11 +10,14 @@ import java.util.List; /** - * A fully resolved mode definition containing zero-arg factories for initializers - * and synchronizers. This is the result of resolving a {@link ModeDefinition}'s + * A fully resolved mode definition containing pre-built initializers and zero-arg + * factories for synchronizers. This is the result of resolving a {@link ModeDefinition}'s * {@link com.launchdarkly.sdk.android.subsystems.DataSourceBuilder} entries against * a {@link com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs}. *

+ * Initializers are built eagerly so that {@link FDv2DataSource} can run pre-startup + * initializers synchronously before dispatching to the executor. + *

* Instances are immutable and created by {@code FDv2DataSourceBuilder} at build time. *

* Package-private — not part of the public SDK API. @@ -23,23 +26,23 @@ */ final class ResolvedModeDefinition { - private final List> initializerFactories; + private final List initializers; private final List> synchronizerFactories; private final FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory; ResolvedModeDefinition( - @NonNull List> initializerFactories, + @NonNull List initializers, @NonNull List> synchronizerFactories, @Nullable FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory ) { - this.initializerFactories = Collections.unmodifiableList(initializerFactories); + this.initializers = Collections.unmodifiableList(initializers); this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); this.fdv1FallbackSynchronizerFactory = fdv1FallbackSynchronizerFactory; } @NonNull - List> getInitializerFactories() { - return initializerFactories; + List getInitializers() { + return initializers; } @NonNull From b9b18518539bc7e8a5a03df233951ce9bdf2353d Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 14 Apr 2026 11:54:26 -0700 Subject: [PATCH 13/20] [SDK-2070] Update SourceManager to accept pre-built initializers with filtering Accept List instead of List>. Add isRequiredBeforeStartup parameter to getNextInitializerAndSetActive() to filter initializers by their pre-startup requirement. Add resetInitializerIndex() for resetting between the eager and deferred passes. Remove the now-unnecessary getNextInitializer() helper. Made-with: Cursor --- .../sdk/android/SourceManager.java | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) 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 bbbd8adb..b2c4feaf 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 @@ -19,7 +19,7 @@ final class SourceManager implements Closeable { private final List synchronizerFactories; - private final List> initializers; + private final List initializers; private final Object activeSourceLock = new Object(); private Closeable activeSource; @@ -33,7 +33,7 @@ final class SourceManager implements Closeable { SourceManager( @NonNull List synchronizerFactories, - @NonNull List> initializers + @NonNull List initializers ) { this.synchronizerFactories = synchronizerFactories; this.initializers = initializers; @@ -143,14 +143,6 @@ boolean hasAvailableSynchronizers() { return getAvailableSynchronizerCount() > 0; } - private FDv2DataSource.DataSourceFactory getNextInitializer() { - initializerIndex++; - if (initializerIndex >= initializers.size()) { - return null; - } - return initializers.get(initializerIndex); - } - /** Block the current synchronizer so it will not be returned again (e.g. after TERMINAL_ERROR). */ void blockCurrentSynchronizer() { synchronized (activeSourceLock) { @@ -167,29 +159,43 @@ boolean isCurrentSynchronizerFDv1Fallback() { } /** - * Get the next initializer, build it, set it as active (closing any previous active source), - * and return it. Returns null if shutdown or no more initializers. - * Skips initializers whose factory returns null from build(). + * Get the next pre-built initializer whose {@link Initializer#isRequiredBeforeStartup()} + * matches the given value, set it as active (closing any previous active source), + * and return it. Returns null if shutdown or no more matching initializers. + *

+ * Call with {@code true} for the eager pass (pre-startup), then + * {@link #resetInitializerIndex()}, then call with {@code false} for the deferred pass. + * + * @param isRequiredBeforeStartup filter value to match against each initializer */ - Initializer getNextInitializerAndSetActive() { + Initializer getNextInitializerAndSetActive(boolean isRequiredBeforeStartup) { synchronized (activeSourceLock) { if (isShutdown) { return null; } - while (true) { - FDv2DataSource.DataSourceFactory factory = getNextInitializer(); - if (factory == null) { - return null; - } - Initializer initializer = factory.build(); - if (initializer != null) { + while (initializerIndex + 1 < initializers.size()) { + initializerIndex++; + Initializer init = initializers.get(initializerIndex); + if (init.isRequiredBeforeStartup() == isRequiredBeforeStartup) { if (activeSource != null) { safeClose(activeSource); } - activeSource = initializer; - return initializer; + activeSource = init; + return init; } } + return null; + } + } + + /** + * Reset the initializer index to -1 so the next call to + * {@link #getNextInitializerAndSetActive(boolean)} re-scans from the beginning. + * Used between the eager and deferred initializer passes. + */ + void resetInitializerIndex() { + synchronized (activeSourceLock) { + initializerIndex = -1; } } From 4a8240218dc9ae3e2281ea91c857a39ba03ab130 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 14 Apr 2026 11:54:34 -0700 Subject: [PATCH 14/20] [SDK-2070] Add two-pass initializer execution in FDv2DataSource Run pre-startup initializers synchronously on the calling thread before dispatching to the executor, guaranteeing cached data is available even with a zero timeout. The deferred pass then runs remaining initializers on the executor thread. Both passes reuse the existing runInitializers() method with added isRequiredBeforeStartup filter and previousDataReceived seed parameters. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) 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 5ac2eae0..37b5b3b7 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 @@ -69,7 +69,7 @@ public interface DataSourceFactory { */ FDv2DataSource( @NonNull LDContext evaluationContext, - @NonNull List> initializers, + @NonNull List initializers, @NonNull List> synchronizers, @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @@ -84,7 +84,7 @@ public interface DataSourceFactory { /** * @param evaluationContext the context to evaluate flags for - * @param initializers factories for one-shot initializers, tried in order + * @param initializers pre-built 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 @@ -101,7 +101,7 @@ public interface DataSourceFactory { */ FDv2DataSource( @NonNull LDContext evaluationContext, - @NonNull List> initializers, + @NonNull List initializers, @NonNull List> synchronizers, @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @@ -155,6 +155,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; + // Eager pass: run pre-startup initializers synchronously on the calling thread. + // This ensures cached data is available before the startup timeout begins, + // matching FDv1 behavior where cache was loaded in ContextDataManager's constructor. + boolean initializerDataReceived = runInitializers(context, dataSourceUpdateSink, true, false); + sourceManager.resetInitializerIndex(); + sharedExecutor.execute(() -> { try { if (!sourceManager.hasAvailableSources()) { @@ -164,8 +170,9 @@ public void start(@NonNull Callback resultCallback) { return; // this will go to the finally block and block until stop sets shutdownCause } + // Deferred pass: run non-eager initializers on the executor thread. if (sourceManager.hasInitializers()) { - runInitializers(context, dataSourceUpdateSink); + runInitializers(context, dataSourceUpdateSink, false, initializerDataReceived); } if (!sourceManager.hasAvailableSynchronizers()) { @@ -286,12 +293,14 @@ public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvalu return !evaluationContext.equals(newEvaluationContext); } - private void runInitializers( + private boolean runInitializers( @NonNull LDContext context, - @NonNull DataSourceUpdateSinkV2 sink + @NonNull DataSourceUpdateSinkV2 sink, + boolean isRequiredBeforeStartup, + boolean previousDataReceived ) { - boolean anyDataReceived = false; - Initializer initializer = sourceManager.getNextInitializerAndSetActive(); + boolean anyDataReceived = previousDataReceived; + Initializer initializer = sourceManager.getNextInitializerAndSetActive(isRequiredBeforeStartup); while (initializer != null) { try { FDv2SourceResult result = initializer.run().get(); @@ -313,7 +322,7 @@ private void runInitializers( sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); } - return; + return anyDataReceived; } switch (result.getResultType()) { @@ -327,7 +336,7 @@ private void runInitializers( if (!changeSet.getSelector().isEmpty()) { sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); - return; + return anyDataReceived; } // Empty selector: partial data received, keep trying remaining initializers. } @@ -358,18 +367,19 @@ private void runInitializers( } catch (InterruptedException e) { logger.warn("Initializer interrupted: {}", e.toString()); sink.setStatus(DataSourceState.INTERRUPTED, e); - return; + return anyDataReceived; } - initializer = sourceManager.getNextInitializerAndSetActive(); + initializer = sourceManager.getNextInitializerAndSetActive(isRequiredBeforeStartup); } - // All initializers exhausted. If data was received and no synchronizers will follow, - // consider initialization successful. When synchronizers are available, defer init - // completion to the synchronizer loop — the synchronizer is the authority on whether - // the SDK has a verified, up-to-date payload. + // All matching initializers exhausted. If data was received and no synchronizers will + // follow, consider initialization successful. When synchronizers are available, defer + // init completion to the synchronizer loop — the synchronizer is the authority on + // whether the SDK has a verified, up-to-date payload. if (anyDataReceived && !sourceManager.hasAvailableSynchronizers()) { sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); } + return anyDataReceived; } private List getConditions(int synchronizerCount, boolean isPrime) { From e8caa9fc0851335940529eb7bcb79fca05ac798f Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 14 Apr 2026 11:54:43 -0700 Subject: [PATCH 15/20] [SDK-2070] Add tests for pre-startup initializer execution Update FDv2DataSourceTest for pre-built initializer signatures (factory lambdas replaced with direct instances). Add tests verifying eager initializers run on the calling thread, deferred initializers run on the executor, both passes execute, OFFLINE cache miss still initializes, and cached data is available immediately. Add isRequiredBeforeStartup() test to FDv2CacheInitializerTest. Made-with: Cursor --- .../sdk/android/FDv2CacheInitializerTest.java | 9 + .../sdk/android/FDv2DataSourceTest.java | 318 ++++++++++++++---- 2 files changed, 270 insertions(+), 57 deletions(-) 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 index dcdf2af2..1ede2bbd 100644 --- 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 @@ -189,6 +189,15 @@ public void closeAfterCompletion_doesNotThrow() throws Exception { initializer.close(); } + // ---- isRequiredBeforeStartup ---- + + @Test + public void isRequiredBeforeStartup_returnsTrue() { + FDv2CacheInitializer initializer = new FDv2CacheInitializer( + null, CONTEXT, LDLogger.none()); + assertTrue(initializer.isRequiredBeforeStartup()); + } + // ---- empty cache (no flags stored, but store exists) ---- @Test 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..bfb9ac93 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 @@ -76,7 +76,7 @@ public void tearDown() { private FDv2DataSource buildDataSource( MockComponents.MockDataSourceUpdateSink sink, - List> initializers, + List initializers, List> synchronizers) { return new FDv2DataSource( CONTEXT, @@ -90,7 +90,7 @@ private FDv2DataSource buildDataSource( private FDv2DataSource buildDataSource( MockComponents.MockDataSourceUpdateSink sink, - List> initializers, + List initializers, List> synchronizers, long fallbackTimeoutSeconds, long recoveryTimeoutSeconds) { @@ -301,7 +301,7 @@ public void firstInitializerProvidesData_startSucceedsAndSinkReceivesApply() thr items.put(flag.getKey(), flag); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), + Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -319,11 +319,14 @@ public void firstInitializerFailsSecondInitializerSucceeds() throws Exception { AtomicBoolean secondCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - () -> new MockInitializer(new RuntimeException("first fails")), - () -> { - secondCalled.set(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + Arrays.asList( + new MockInitializer(new RuntimeException("first fails")), + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public LDAwaitFuture run() { + secondCalled.set(true); + return super.run(); + } }), Collections.emptyList()); @@ -341,11 +344,14 @@ public void firstInitializerSucceedsWithSelectorSecondInitializerNotInvoked() th AtomicBoolean secondCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), - () -> { - secondCalled.set(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + Arrays.asList( + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public LDAwaitFuture run() { + secondCalled.set(true); + return super.run(); + } }), Collections.emptyList()); @@ -364,8 +370,8 @@ public void allInitializersFailSwitchesToSynchronizers() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(new RuntimeException("first fails")), - () -> new MockInitializer(new RuntimeException("second fails"))), + new MockInitializer(new RuntimeException("first fails")), + new MockInitializer(new RuntimeException("second fails"))), Collections.singletonList(() -> { syncCalled.set(true); return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); @@ -385,7 +391,7 @@ public void allInitializersFailWithNoSynchronizers_startReportsNotInitialized() FDv2DataSource dataSource = buildDataSource(sink, Collections.singletonList( - () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), false))), + new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -401,8 +407,8 @@ public void secondInitializerSucceeds_afterFirstReturnsTerminalError() throws Ex FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("first fails")), false)), - () -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), + 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); @@ -418,7 +424,7 @@ public void oneInitializerNoSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -469,7 +475,7 @@ public void oneInitializerOneSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.singletonList(() -> new MockQueuedSynchronizer( FDv2SourceResult.changeSet(makeChangeSet(false), false)))); @@ -727,7 +733,7 @@ public void stopAfterInitializersCompletesImmediately() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -764,7 +770,7 @@ public void multipleStopCallsAreIdempotent() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -781,7 +787,7 @@ public void closingDataSourceDuringInitializationCompletesStartCallback() throws LDAwaitFuture slowFuture = new LDAwaitFuture<>(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(slowFuture)), + Collections.singletonList(new MockInitializer(slowFuture)), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -904,10 +910,14 @@ public void startedFlagPreventsMultipleRuns() throws Exception { AtomicInteger runCount = new AtomicInteger(0); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> { - runCount.incrementAndGet(); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); - }), + Collections.singletonList( + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public LDAwaitFuture run() { + runCount.incrementAndGet(); + return super.run(); + } + }), Collections.emptyList()); AwaitableCallback cb1 = new AwaitableCallback<>(); @@ -1018,9 +1028,15 @@ public void initializerThrowsExecutionException_secondInitializerSucceeds() thro AtomicBoolean firstCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - () -> { firstCalled.set(true); return new MockInitializer(new RuntimeException("execution exception")); }, - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Arrays.asList( + new MockInitializer(new RuntimeException("execution exception")) { + @Override + public LDAwaitFuture run() { + firstCalled.set(true); + return super.run(); + } + }, + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1036,9 +1052,15 @@ public void initializerThrowsInterruptedException_secondInitializerSucceeds() th AtomicBoolean firstCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - () -> { firstCalled.set(true); return new MockInitializer(new InterruptedException("interrupted")); }, - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Arrays.asList( + new MockInitializer(new InterruptedException("interrupted")) { + @Override + public LDAwaitFuture run() { + firstCalled.set(true); + return super.run(); + } + }, + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1145,7 +1167,7 @@ public void stopWhileInitializerRunningHandlesGracefully() throws Exception { LDAwaitFuture slowFuture = new LDAwaitFuture<>(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(slowFuture) { + Collections.singletonList(new MockInitializer(slowFuture) { @Override public LDAwaitFuture run() { initializerStarted.countDown(); @@ -1301,11 +1323,14 @@ public void selectorNonEmptyCompletesInitialization() throws Exception { BlockingQueue secondCalledQueue = new LinkedBlockingQueue<>(); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), - () -> { - secondCalledQueue.offer(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); + Arrays.asList( + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { + @Override + public LDAwaitFuture run() { + secondCalledQueue.offer(true); + return super.run(); + } }), Collections.emptyList()); @@ -1322,7 +1347,7 @@ public void initializerChangeSetWithoutSelectorCompletesIfLastInitializer() thro MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1413,7 +1438,7 @@ public void statusTransitionsToValidAfterInitialization() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1524,11 +1549,14 @@ public void fdv1FallbackDuringInitializationSkipsRemainingInitializers() throws 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)); + Arrays.asList( + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), true)), + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public LDAwaitFuture run() { + secondInitCalled.set(true); + return super.run(); + } }), Collections.>singletonList( () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), @@ -1559,12 +1587,15 @@ public void fdv1FallbackOnTerminalErrorDuringInitializationSwitchesToFdv1() thro // The fallback should be honored and remaining initializers skipped. FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Arrays.>asList( - () -> new MockInitializer(FDv2SourceResult.status( + Arrays.asList( + new MockInitializer(FDv2SourceResult.status( FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), true)), - () -> { - secondInitCalled.set(true); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public LDAwaitFuture run() { + secondInitCalled.set(true); + return super.run(); + } }), Collections.>singletonList( () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), @@ -1596,8 +1627,8 @@ public void fdv1FallbackDuringInitializationWithNonEmptySelectorSwitchesToFdv1() // complete initialization immediately. FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.>singletonList( - () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), true))), + Collections.singletonList( + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), true))), Collections.>singletonList( () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), () -> fdv1Sync, @@ -1626,7 +1657,7 @@ public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.>emptyList(), + Collections.emptyList(), Collections.>singletonList(() -> fdv2Sync), () -> fdv1Sync, sink, @@ -1660,7 +1691,7 @@ public void fdv1FallbackOnTerminalErrorSwitchesToFdv1Synchronizer() throws Excep FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.>emptyList(), + Collections.emptyList(), Collections.>singletonList(() -> fdv2Sync), () -> fdv1Sync, sink, @@ -1692,7 +1723,7 @@ public void fdv1FallbackNotTriggeredWhenAlreadyOnFdv1() throws Exception { FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.>emptyList(), + Collections.emptyList(), Collections.>singletonList(() -> fdv2Sync), () -> { fdv1BuildCount.incrementAndGet(); @@ -1721,7 +1752,7 @@ public void fdv1FallbackNotTriggeredWhenNoFdv1SlotExists() throws Exception { FDv2SourceResult.changeSet(makeChangeSet(true), true)); FDv2DataSource dataSource = buildDataSource(sink, - Collections.>emptyList(), + Collections.emptyList(), Collections.>singletonList(() -> fdv2Sync)); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1750,4 +1781,177 @@ public void needsRefresh_differentContext_returnsTrue() { Collections.emptyList()); assertTrue(dataSource.needsRefresh(false, LDContext.create("other-context"))); } + + // ============================================================================ + // Eager (Pre-Startup) Initializers + // ============================================================================ + + @Test + public void eagerInitializerRunsBeforeExecutorDispatch() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicReference eagerThread = new AtomicReference<>(); + AtomicReference callingThread = new AtomicReference<>(); + + Initializer eagerInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { + @Override + public boolean isRequiredBeforeStartup() { + return true; + } + + @Override + public LDAwaitFuture run() { + eagerThread.set(Thread.currentThread().getName()); + return super.run(); + } + }; + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(eagerInit), + 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("Eager initializer must run on the calling thread", + callingThread.get(), eagerThread.get()); + stopDataSource(dataSource); + } + + @Test + public void deferredInitializerRunsOnExecutorThread() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicReference deferredThread = new AtomicReference<>(); + CountDownLatch deferredRan = new CountDownLatch(1); + + Initializer deferredInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public boolean isRequiredBeforeStartup() { + return false; + } + + @Override + public LDAwaitFuture run() { + deferredThread.set(Thread.currentThread().getName()); + deferredRan.countDown(); + return super.run(); + } + }; + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(deferredInit), + 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); + + Initializer eagerInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { + @Override + public boolean isRequiredBeforeStartup() { + return true; + } + + @Override + public LDAwaitFuture run() { + eagerRan.set(true); + return super.run(); + } + }; + + Initializer deferredInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { + @Override + public boolean isRequiredBeforeStartup() { + return false; + } + + @Override + public LDAwaitFuture run() { + deferredRan.countDown(); + return super.run(); + } + }; + + FDv2DataSource dataSource = buildDataSource(sink, + Arrays.asList(eagerInit, deferredInit), + 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(); + + Initializer cacheInitializer = new MockInitializer( + FDv2SourceResult.changeSet(new ChangeSet<>( + ChangeSetType.None, + com.launchdarkly.sdk.fdv2.Selector.EMPTY, + Collections.emptyMap(), + null, + false), false)) { + @Override + public boolean isRequiredBeforeStartup() { + return true; + } + }; + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(cacheInitializer), + 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); + + Initializer eagerInit = new MockInitializer( + FDv2SourceResult.changeSet(makeFullChangeSet(items), false)) { + @Override + public boolean isRequiredBeforeStartup() { + return true; + } + }; + + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(eagerInit), + 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); + } } From 41ac3824d4d7865c86d3d5c80e982d032ead4577 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 15 Apr 2026 13:04:38 -0700 Subject: [PATCH 16/20] [SDK-2070] Addressing code review: remove synchronizer awareness from runInitializers() runInitializers() was calling tryCompleteStart() at the end of its loop when any data had been received, including unverified cache data. This prematurely marked initialization complete before synchronizers could run, causing end-to-end test failures in POLLING mode where cached data existed but the polling server returned 401. Moved the tryCompleteStart responsibility to start(), which already has the correct orchestration logic for the no-synchronizer case (e.g. OFFLINE mode). Changed runInitializers() to return a boolean indicating whether any initializer succeeded, letting start() decide the initialization outcome based on the full picture. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) 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 5ac2eae0..d729ab91 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,6 +7,7 @@ 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; @@ -164,14 +165,22 @@ public void start(@NonNull Callback resultCallback) { return; // this will go to the finally block and block until stop sets shutdownCause } + boolean initializersSucceeded = false; if (sourceManager.hasInitializers()) { - runInitializers(context, dataSourceUpdateSink); + initializersSucceeded = runInitializers(context, dataSourceUpdateSink); } if (!sourceManager.hasAvailableSynchronizers()) { 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)); + if (initializersSucceeded) { + // At least one initializer completed with a changeset (possibly + // None for a cache miss). A mode with initializers but no + // synchronizers (e.g., OFFLINE) is a valid terminal state. + dataSourceUpdateSink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); + } else { + shutdownCause.set(new LDFailure("All initializers exhausted and there are no available synchronizers.", LDFailure.FailureType.UNKNOWN_ERROR)); + } } return; } @@ -286,10 +295,15 @@ public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvalu return !evaluationContext.equals(newEvaluationContext); } - private void runInitializers( + /** + * @return true if at least one initializer completed with a changeset (even {@link ChangeSetType#None}), + * false if all initializers returned errors/statuses or were interrupted + */ + private boolean runInitializers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink ) { + boolean anyInitializerSucceeded = false; boolean anyDataReceived = false; Initializer initializer = sourceManager.getNextInitializerAndSetActive(); while (initializer != null) { @@ -313,7 +327,7 @@ private void runInitializers( sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); } - return; + return anyInitializerSucceeded; } switch (result.getResultType()) { @@ -321,13 +335,16 @@ private void runInitializers( ChangeSet> changeSet = result.getChangeSet(); if (changeSet != null) { sink.apply(context, changeSet); - anyDataReceived = true; + anyInitializerSucceeded = 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()) { sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); - return; + return anyInitializerSucceeded; } // Empty selector: partial data received, keep trying remaining initializers. } @@ -358,18 +375,11 @@ private void runInitializers( } catch (InterruptedException e) { logger.warn("Initializer interrupted: {}", e.toString()); sink.setStatus(DataSourceState.INTERRUPTED, e); - return; + return anyInitializerSucceeded; } initializer = sourceManager.getNextInitializerAndSetActive(); } - // All initializers exhausted. If data was received and no synchronizers will follow, - // consider initialization successful. When synchronizers are available, defer init - // completion to the synchronizer loop — the synchronizer is the authority on whether - // the SDK has a verified, up-to-date payload. - if (anyDataReceived && !sourceManager.hasAvailableSynchronizers()) { - sink.setStatus(DataSourceState.VALID, null); - tryCompleteStart(true, null); - } + return anyInitializerSucceeded; } private List getConditions(int synchronizerCount, boolean isPrime) { From 9e51057df1366deeaf73225d9c2feb874b20d433 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 15 Apr 2026 13:37:19 -0700 Subject: [PATCH 17/20] [SDK-2070] Addressing code review: use DataSourceBuildInputsInternal pattern for cache initializer dependencies Follow the ClientContext/ClientContextImpl pattern to pass ReadOnlyPerEnvironmentData through DataSourceBuildInputs instead of the instanceof/replacement hack in FDv2DataSourceBuilder.resolve(). Made-with: Cursor --- .../DataSourceBuildInputsInternal.java | 75 ++++++++++ .../sdk/android/DataSystemComponents.java | 17 +-- .../sdk/android/FDv2DataSourceBuilder.java | 26 +--- .../subsystems/DataSourceBuildInputs.java | 2 +- .../DataSourceBuildInputsInternalTest.java | 132 ++++++++++++++++++ 5 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java 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 5c445967..f7755818 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 @@ -14,7 +14,7 @@ import com.launchdarkly.sdk.internal.http.HttpProperties; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; + import java.net.URI; import java.util.Arrays; @@ -140,23 +140,10 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } static final class CacheInitializerBuilderImpl implements DataSourceBuilder { - @Nullable - private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData; - - CacheInitializerBuilderImpl() { - this.envData = null; - } - - CacheInitializerBuilderImpl( - @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData - ) { - this.envData = envData; - } - @Override public Initializer build(DataSourceBuildInputs inputs) { return new FDv2CacheInitializer( - envData, + DataSourceBuildInputsInternal.get(inputs).getPerEnvironmentDataIfAvailable(), inputs.getEvaluationContext(), inputs.getBaseLogger() ); 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 b72078dd..bb53ecf0 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 @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; @@ -116,9 +115,7 @@ public DataSource build(ClientContext clientContext) { } DataSourceBuildInputs inputs = makeInputs(clientContext); - PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData = - ClientContextImpl.get(clientContext).getPerEnvironmentDataIfAvailable(); - ResolvedModeDefinition resolved = resolve(modeDef, inputs, envData); + ResolvedModeDefinition resolved = resolve(modeDef, inputs); DataSourceUpdateSink baseSink = clientContext.getDataSourceUpdateSink(); if (!(baseSink instanceof DataSourceUpdateSinkV2)) { @@ -151,14 +148,14 @@ public void close() { } } - private DataSourceBuildInputs makeInputs(ClientContext clientContext) { + 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(), @@ -166,26 +163,17 @@ private DataSourceBuildInputs makeInputs(ClientContext clientContext) { selectorSource, sharedExecutor, impl.getPlatformState().getCacheDir(), - clientContext.getBaseLogger() + clientContext.getBaseLogger(), + impl.getPerEnvironmentDataIfAvailable() ); } private static ResolvedModeDefinition resolve( - ModeDefinition def, DataSourceBuildInputs inputs, - @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData + ModeDefinition def, DataSourceBuildInputs inputs ) { List> initFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getInitializers()) { - // The cache initializer's dependency (ReadOnlyPerEnvironmentData) is only - // available at build time, not when the static mode table is constructed, - // so we inject it here by replacing the placeholder with a wired copy. - final DataSourceBuilder effective; - if (builder instanceof DataSystemComponents.CacheInitializerBuilderImpl) { - effective = new DataSystemComponents.CacheInitializerBuilderImpl(envData); - } else { - effective = builder; - } - initFactories.add(() -> effective.build(inputs)); + initFactories.add(() -> builder.build(inputs)); } List> syncFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getSynchronizers()) { 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/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()); + } +} From 5e9fa173272108912f4660adf4b72132f93d3524 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 17 Apr 2026 16:26:02 -0700 Subject: [PATCH 18/20] [SDK-2070] Split cache initializers from general initializers in FDv2DataSource Replace the Initializer.isRequiredBeforeStartup() marker with an InitializerFromCache marker applied to the builder/factory. FDv2DataSource now partitions initializers at construction and runs cache initializers synchronously before executor dispatch (ensuring cache loads within a zero-timeout init), then runs general initializers on the executor. FDv2DataSourceBuilder wraps factories built from InitializerFromCache builders so the marker is preserved through the factory indirection. Co-authored-by: Todd Anderson Made-with: Cursor --- .../sdk/android/DataSystemComponents.java | 3 +- .../sdk/android/FDv2CacheInitializer.java | 5 - .../sdk/android/FDv2DataSource.java | 128 +++++--- .../sdk/android/FDv2DataSourceBuilder.java | 36 ++- .../sdk/android/ResolvedModeDefinition.java | 17 +- .../sdk/android/SourceManager.java | 52 ++-- .../sdk/android/subsystems/Initializer.java | 14 - .../subsystems/InitializerFromCache.java | 10 + .../sdk/android/FDv2CacheInitializerTest.java | 9 - .../sdk/android/FDv2DataSourceTest.java | 291 ++++++++---------- 10 files changed, 272 insertions(+), 293 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/InitializerFromCache.java 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 f7755818..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,6 +10,7 @@ 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; @@ -139,7 +140,7 @@ public Synchronizer build(DataSourceBuildInputs inputs) { } } - static final class CacheInitializerBuilderImpl implements DataSourceBuilder { + static final class CacheInitializerBuilderImpl implements DataSourceBuilder, InitializerFromCache { @Override public Initializer build(DataSourceBuildInputs inputs) { return new FDv2CacheInitializer( 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 index 0e9ba671..e8ab3e63 100644 --- 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 @@ -99,11 +99,6 @@ public Future run() { return future; } - @Override - public boolean isRequiredBeforeStartup() { - return true; - } - @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 7c2057a1..1144356d 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 @@ -13,9 +13,11 @@ 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; @@ -46,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; @@ -70,7 +76,7 @@ public interface DataSourceFactory { */ FDv2DataSource( @NonNull LDContext evaluationContext, - @NonNull List initializers, + @NonNull List> initializers, @NonNull List> synchronizers, @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @@ -85,7 +91,7 @@ public interface DataSourceFactory { /** * @param evaluationContext the context to evaluate flags for - * @param initializers pre-built initializers, tried in order + * @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 @@ -102,7 +108,7 @@ public interface DataSourceFactory { */ FDv2DataSource( @NonNull LDContext evaluationContext, - @NonNull List initializers, + @NonNull List> initializers, @NonNull List> synchronizers, @Nullable DataSourceFactory fdv1FallbackSynchronizer, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, @@ -115,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)); @@ -125,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, new ArrayList<>(generalInitializers)); this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; this.sharedExecutor = sharedExecutor; @@ -156,11 +171,11 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; - // Eager pass: run pre-startup initializers synchronously on the calling thread. // This ensures cached data is available before the startup timeout begins, // matching FDv1 behavior where cache was loaded in ContextDataManager's constructor. - RunInitializersResult eagerResult = runInitializers(context, dataSourceUpdateSink, true); - sourceManager.resetInitializerIndex(); + // 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 { @@ -171,25 +186,13 @@ public void start(@NonNull Callback resultCallback) { return; // this will go to the finally block and block until stop sets shutdownCause } - // Deferred pass: run non-eager initializers on the executor thread. - RunInitializersResult deferredResult = RunInitializersResult.EMPTY; if (sourceManager.hasInitializers()) { - deferredResult = runInitializers(context, dataSourceUpdateSink, false); + runGeneralInitializers(context, dataSourceUpdateSink); } - boolean initializersSucceeded = eagerResult.anySucceeded || deferredResult.anySucceeded; - if (!sourceManager.hasAvailableSynchronizers()) { if (!startCompleted.get()) { - if (initializersSucceeded) { - // At least one initializer completed with a changeset (possibly - // None for a cache miss). A mode with initializers but no - // synchronizers (e.g., OFFLINE) is a valid terminal state. - dataSourceUpdateSink.setStatus(DataSourceState.VALID, null); - tryCompleteStart(true, null); - } else { - shutdownCause.set(new LDFailure("All initializers exhausted and there are no available synchronizers.", LDFailure.FailureType.UNKNOWN_ERROR)); - } + shutdownCause.set(new LDFailure("All initializers exhausted and there are no available synchronizers.", LDFailure.FailureType.UNKNOWN_ERROR)); } return; } @@ -305,35 +308,54 @@ public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvalu } /** - * Result of running a pass of initializers. Carries two distinct signals: - *

+ * 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 static class RunInitializersResult { - static final RunInitializersResult EMPTY = new RunInitializersResult(false, false); - - final boolean anySucceeded; - final boolean anyDataReceived; + 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(); - RunInitializersResult(boolean anySucceeded, boolean anyDataReceived) { - this.anySucceeded = anySucceeded; - this.anyDataReceived = anyDataReceived; + 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 RunInitializersResult runInitializers( + private void runGeneralInitializers( @NonNull LDContext context, - @NonNull DataSourceUpdateSinkV2 sink, - boolean isRequiredBeforeStartup + @NonNull DataSourceUpdateSinkV2 sink ) { - boolean anyInitializerSucceeded = false; boolean anyDataReceived = false; - Initializer initializer = sourceManager.getNextInitializerAndSetActive(isRequiredBeforeStartup); + Initializer initializer = sourceManager.getNextInitializerAndSetActive(); while (initializer != null) { try { FDv2SourceResult result = initializer.run().get(); @@ -355,7 +377,7 @@ private RunInitializersResult runInitializers( sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); } - return new RunInitializersResult(anyInitializerSucceeded, anyDataReceived); + return; } switch (result.getResultType()) { @@ -363,7 +385,6 @@ private RunInitializersResult runInitializers( ChangeSet> changeSet = result.getChangeSet(); if (changeSet != null) { sink.apply(context, changeSet); - anyInitializerSucceeded = true; if (changeSet.getType() != ChangeSetType.None) { anyDataReceived = true; } @@ -372,7 +393,7 @@ private RunInitializersResult runInitializers( if (!changeSet.getSelector().isEmpty()) { sink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); - return new RunInitializersResult(anyInitializerSucceeded, anyDataReceived); + return; } // Empty selector: partial data received, keep trying remaining initializers. } @@ -395,19 +416,24 @@ private RunInitializersResult 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 new RunInitializersResult(anyInitializerSucceeded, anyDataReceived); + return; } - initializer = sourceManager.getNextInitializerAndSetActive(isRequiredBeforeStartup); + initializer = sourceManager.getNextInitializerAndSetActive(); + } + // All initializers exhausted. If any gave us data (even without a final selector), + // consider initialization successful and let synchronizers keep the data current. + if (anyDataReceived) { + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); } - return new RunInitializersResult(anyInitializerSucceeded, anyDataReceived); } private List getConditions(int synchronizerCount, boolean isPrime) { 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 c315e47a..f9ecb088 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; @@ -123,15 +124,15 @@ public DataSource build(ClientContext clientContext) { "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); } - List initializers = - includeInitializers ? resolved.getInitializers() : Collections.emptyList(); + List> initFactories = + includeInitializers ? resolved.getInitializerFactories() : Collections.>emptyList(); // Reset includeInitializers to default after each build to prevent stale state. includeInitializers = true; return new FDv2DataSource( clientContext.getEvaluationContext(), - initializers, + initFactories, resolved.getSynchronizerFactories(), resolved.getFdv1FallbackSynchronizerFactory(), (DataSourceUpdateSinkV2) baseSink, @@ -171,11 +172,12 @@ private DataSourceBuildInputsInternal makeInputs(ClientContext clientContext) { private static ResolvedModeDefinition resolve( ModeDefinition def, DataSourceBuildInputs inputs ) { - List initializers = new ArrayList<>(); + List> initFactories = new ArrayList<>(); for (DataSourceBuilder builder : def.getInitializers()) { - Initializer init = builder.build(inputs); - if (init != null) { - initializers.add(init); + if (builder instanceof InitializerFromCache) { + initFactories.add(new CacheInitializerFactory(() -> builder.build(inputs))); + } else { + initFactories.add(() -> builder.build(inputs)); } } List> syncFactories = new ArrayList<>(); @@ -185,6 +187,24 @@ private static ResolvedModeDefinition resolve( DataSourceBuilder fdv1FallbackSynchronizer = def.getFdv1FallbackSynchronizer(); FDv2DataSource.DataSourceFactory fdv1Factory = fdv1FallbackSynchronizer != null ? () -> fdv1FallbackSynchronizer.build(inputs) : null; - return new ResolvedModeDefinition(initializers, syncFactories, fdv1Factory); + 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/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java index ad4bdb9b..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 @@ -10,14 +10,11 @@ import java.util.List; /** - * A fully resolved mode definition containing pre-built initializers and zero-arg - * factories for synchronizers. This is the result of resolving a {@link ModeDefinition}'s + * A fully resolved mode definition containing zero-arg factories for initializers + * and synchronizers. This is the result of resolving a {@link ModeDefinition}'s * {@link com.launchdarkly.sdk.android.subsystems.DataSourceBuilder} entries against * a {@link com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs}. *

- * Initializers are built eagerly so that {@link FDv2DataSource} can run pre-startup - * initializers synchronously before dispatching to the executor. - *

* Instances are immutable and created by {@code FDv2DataSourceBuilder} at build time. *

* Package-private — not part of the public SDK API. @@ -26,23 +23,23 @@ */ final class ResolvedModeDefinition { - private final List initializers; + private final List> initializerFactories; private final List> synchronizerFactories; private final FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory; ResolvedModeDefinition( - @NonNull List initializers, + @NonNull List> initializerFactories, @NonNull List> synchronizerFactories, @Nullable FDv2DataSource.DataSourceFactory fdv1FallbackSynchronizerFactory ) { - this.initializers = Collections.unmodifiableList(initializers); + this.initializerFactories = Collections.unmodifiableList(initializerFactories); this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); this.fdv1FallbackSynchronizerFactory = fdv1FallbackSynchronizerFactory; } @NonNull - List getInitializers() { - return initializers; + List> getInitializerFactories() { + return initializerFactories; } @NonNull 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 b2c4feaf..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 @@ -19,7 +19,7 @@ final class SourceManager implements Closeable { private final List synchronizerFactories; - private final List initializers; + private final List> initializers; private final Object activeSourceLock = new Object(); private Closeable activeSource; @@ -33,7 +33,7 @@ final class SourceManager implements Closeable { SourceManager( @NonNull List synchronizerFactories, - @NonNull List initializers + @NonNull List> initializers ) { this.synchronizerFactories = synchronizerFactories; this.initializers = initializers; @@ -143,6 +143,14 @@ boolean hasAvailableSynchronizers() { return getAvailableSynchronizerCount() > 0; } + private FDv2DataSource.DataSourceFactory getNextInitializer() { + initializerIndex++; + if (initializerIndex >= initializers.size()) { + return null; + } + return initializers.get(initializerIndex); + } + /** Block the current synchronizer so it will not be returned again (e.g. after TERMINAL_ERROR). */ void blockCurrentSynchronizer() { synchronized (activeSourceLock) { @@ -159,43 +167,29 @@ boolean isCurrentSynchronizerFDv1Fallback() { } /** - * Get the next pre-built initializer whose {@link Initializer#isRequiredBeforeStartup()} - * matches the given value, set it as active (closing any previous active source), - * and return it. Returns null if shutdown or no more matching initializers. - *

- * Call with {@code true} for the eager pass (pre-startup), then - * {@link #resetInitializerIndex()}, then call with {@code false} for the deferred pass. - * - * @param isRequiredBeforeStartup filter value to match against each initializer + * Get the next initializer, build it, set it as active (closing any previous active source), + * and return it. Returns null if shutdown or no more initializers. + * Skips initializers whose factory returns null from build(). */ - Initializer getNextInitializerAndSetActive(boolean isRequiredBeforeStartup) { + Initializer getNextInitializerAndSetActive() { synchronized (activeSourceLock) { if (isShutdown) { return null; } - while (initializerIndex + 1 < initializers.size()) { - initializerIndex++; - Initializer init = initializers.get(initializerIndex); - if (init.isRequiredBeforeStartup() == isRequiredBeforeStartup) { + while (true) { + FDv2DataSource.DataSourceFactory factory = getNextInitializer(); + if (factory == null) { + return null; + } + Initializer initializer = factory.build(); + if (initializer != null) { if (activeSource != null) { safeClose(activeSource); } - activeSource = init; - return init; + activeSource = initializer; + return initializer; } } - return null; - } - } - - /** - * Reset the initializer index to -1 so the next call to - * {@link #getNextInitializerAndSetActive(boolean)} re-scans from the beginning. - * Used between the eager and deferred initializer passes. - */ - void resetInitializerIndex() { - synchronized (activeSourceLock) { - initializerIndex = -1; } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java index f0ec7a1d..31e07459 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java @@ -26,18 +26,4 @@ public interface Initializer extends Closeable { * @return a Future that completes with the result */ Future run(); - - /** - * Whether this initializer must run before the startup timeout begins ticking. - *

- * Eager initializers run synchronously on the calling thread during - * {@code FDv2DataSource.start()}, before work is dispatched to the executor. - * This guarantees their data is available even with a timeout of zero, - * matching the FDv1 behavior where cached data was loaded in the constructor. - * - * @return {@code true} if this initializer must complete before the timeout starts - */ - default boolean isRequiredBeforeStartup() { - return false; - } } 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..77781808 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/InitializerFromCache.java @@ -0,0 +1,10 @@ +package com.launchdarkly.sdk.android.subsystems; + +import java.io.Closeable; +import java.util.concurrent.Future; + +/** + * 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/FDv2CacheInitializerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2CacheInitializerTest.java index 1ede2bbd..dcdf2af2 100644 --- 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 @@ -189,15 +189,6 @@ public void closeAfterCompletion_doesNotThrow() throws Exception { initializer.close(); } - // ---- isRequiredBeforeStartup ---- - - @Test - public void isRequiredBeforeStartup_returnsTrue() { - FDv2CacheInitializer initializer = new FDv2CacheInitializer( - null, CONTEXT, LDLogger.none()); - assertTrue(initializer.isRequiredBeforeStartup()); - } - // ---- empty cache (no flags stored, but store exists) ---- @Test 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 bfb9ac93..8fce0543 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 @@ -22,6 +22,7 @@ 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; @@ -76,7 +77,7 @@ public void tearDown() { private FDv2DataSource buildDataSource( MockComponents.MockDataSourceUpdateSink sink, - List initializers, + List> initializers, List> synchronizers) { return new FDv2DataSource( CONTEXT, @@ -90,7 +91,7 @@ private FDv2DataSource buildDataSource( private FDv2DataSource buildDataSource( MockComponents.MockDataSourceUpdateSink sink, - List initializers, + List> initializers, List> synchronizers, long fallbackTimeoutSeconds, long recoveryTimeoutSeconds) { @@ -187,6 +188,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 @@ -301,7 +321,7 @@ public void firstInitializerProvidesData_startSucceedsAndSinkReceivesApply() thr items.put(flag.getKey(), flag); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -319,14 +339,11 @@ public void firstInitializerFailsSecondInitializerSucceeds() throws Exception { AtomicBoolean secondCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - new MockInitializer(new RuntimeException("first fails")), - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { - @Override - public LDAwaitFuture run() { - secondCalled.set(true); - return super.run(); - } + Arrays.asList( + () -> new MockInitializer(new RuntimeException("first fails")), + () -> { + secondCalled.set(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -344,14 +361,11 @@ public void firstInitializerSucceedsWithSelectorSecondInitializerNotInvoked() th AtomicBoolean secondCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { - @Override - public LDAwaitFuture run() { - secondCalled.set(true); - return super.run(); - } + Arrays.asList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), + () -> { + secondCalled.set(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.emptyList()); @@ -370,8 +384,8 @@ public void allInitializersFailSwitchesToSynchronizers() throws Exception { FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - new MockInitializer(new RuntimeException("first fails")), - new MockInitializer(new RuntimeException("second fails"))), + () -> new MockInitializer(new RuntimeException("first fails")), + () -> new MockInitializer(new RuntimeException("second fails"))), Collections.singletonList(() -> { syncCalled.set(true); return new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); @@ -391,7 +405,7 @@ public void allInitializersFailWithNoSynchronizers_startReportsNotInitialized() FDv2DataSource dataSource = buildDataSource(sink, Collections.singletonList( - new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), false))), + () -> new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -407,8 +421,8 @@ public void secondInitializerSucceeds_afterFirstReturnsTerminalError() throws Ex FDv2DataSource dataSource = buildDataSource(sink, Arrays.asList( - new MockInitializer(FDv2SourceResult.status(FDv2SourceResult.Status.terminalError(new RuntimeException("first fails")), false)), - new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))), + () -> 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); @@ -424,7 +438,7 @@ public void oneInitializerNoSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -475,7 +489,7 @@ public void oneInitializerOneSynchronizerIsWellBehaved() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.singletonList(() -> new MockQueuedSynchronizer( FDv2SourceResult.changeSet(makeChangeSet(false), false)))); @@ -733,7 +747,7 @@ public void stopAfterInitializersCompletesImmediately() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -770,7 +784,7 @@ public void multipleStopCallsAreIdempotent() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -787,7 +801,7 @@ public void closingDataSourceDuringInitializationCompletesStartCallback() throws LDAwaitFuture slowFuture = new LDAwaitFuture<>(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(slowFuture)), + Collections.singletonList(() -> new MockInitializer(slowFuture)), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -910,14 +924,10 @@ public void startedFlagPreventsMultipleRuns() throws Exception { AtomicInteger runCount = new AtomicInteger(0); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList( - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { - @Override - public LDAwaitFuture run() { - runCount.incrementAndGet(); - return super.run(); - } - }), + Collections.singletonList(() -> { + runCount.incrementAndGet(); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); + }), Collections.emptyList()); AwaitableCallback cb1 = new AwaitableCallback<>(); @@ -1028,15 +1038,9 @@ public void initializerThrowsExecutionException_secondInitializerSucceeds() thro AtomicBoolean firstCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - new MockInitializer(new RuntimeException("execution exception")) { - @Override - public LDAwaitFuture run() { - firstCalled.set(true); - return super.run(); - } - }, - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Arrays.asList( + () -> { firstCalled.set(true); return new MockInitializer(new RuntimeException("execution exception")); }, + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1052,15 +1056,9 @@ public void initializerThrowsInterruptedException_secondInitializerSucceeds() th AtomicBoolean firstCalled = new AtomicBoolean(false); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - new MockInitializer(new InterruptedException("interrupted")) { - @Override - public LDAwaitFuture run() { - firstCalled.set(true); - return super.run(); - } - }, - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), + Arrays.asList( + () -> { firstCalled.set(true); return new MockInitializer(new InterruptedException("interrupted")); }, + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1167,7 +1165,7 @@ public void stopWhileInitializerRunningHandlesGracefully() throws Exception { LDAwaitFuture slowFuture = new LDAwaitFuture<>(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(slowFuture) { + Collections.singletonList(() -> new MockInitializer(slowFuture) { @Override public LDAwaitFuture run() { initializerStarted.countDown(); @@ -1323,14 +1321,11 @@ public void selectorNonEmptyCompletesInitialization() throws Exception { BlockingQueue secondCalledQueue = new LinkedBlockingQueue<>(); FDv2DataSource dataSource = buildDataSource(sink, - Arrays.asList( - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { - @Override - public LDAwaitFuture run() { - secondCalledQueue.offer(true); - return super.run(); - } + Arrays.asList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)), + () -> { + secondCalledQueue.offer(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)); }), Collections.emptyList()); @@ -1347,7 +1342,7 @@ public void initializerChangeSetWithoutSelectorCompletesIfLastInitializer() thro MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1438,7 +1433,7 @@ public void statusTransitionsToValidAfterInitialization() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), + Collections.singletonList(() -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1549,14 +1544,11 @@ public void fdv1FallbackDuringInitializationSkipsRemainingInitializers() throws FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Arrays.asList( - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), true)), - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { - @Override - public LDAwaitFuture run() { - secondInitCalled.set(true); - return super.run(); - } + 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))), @@ -1587,15 +1579,12 @@ public void fdv1FallbackOnTerminalErrorDuringInitializationSwitchesToFdv1() thro // The fallback should be honored and remaining initializers skipped. FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Arrays.asList( - new MockInitializer(FDv2SourceResult.status( + Arrays.>asList( + () -> new MockInitializer(FDv2SourceResult.status( FDv2SourceResult.Status.terminalError(new RuntimeException("fail")), true)), - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { - @Override - public LDAwaitFuture run() { - secondInitCalled.set(true); - return super.run(); - } + () -> { + secondInitCalled.set(true); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)); }), Collections.>singletonList( () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(false), false))), @@ -1627,8 +1616,8 @@ public void fdv1FallbackDuringInitializationWithNonEmptySelectorSwitchesToFdv1() // complete initialization immediately. FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.singletonList( - new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), true))), + Collections.>singletonList( + () -> new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), true))), Collections.>singletonList( () -> new MockSynchronizer(FDv2SourceResult.changeSet(makeChangeSet(true), false))), () -> fdv1Sync, @@ -1657,7 +1646,7 @@ public void fdv1FallbackSwitchesToFdv1Synchronizer() throws Exception { FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.emptyList(), + Collections.>emptyList(), Collections.>singletonList(() -> fdv2Sync), () -> fdv1Sync, sink, @@ -1691,7 +1680,7 @@ public void fdv1FallbackOnTerminalErrorSwitchesToFdv1Synchronizer() throws Excep FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.emptyList(), + Collections.>emptyList(), Collections.>singletonList(() -> fdv2Sync), () -> fdv1Sync, sink, @@ -1723,7 +1712,7 @@ public void fdv1FallbackNotTriggeredWhenAlreadyOnFdv1() throws Exception { FDv2DataSource dataSource = new FDv2DataSource( CONTEXT, - Collections.emptyList(), + Collections.>emptyList(), Collections.>singletonList(() -> fdv2Sync), () -> { fdv1BuildCount.incrementAndGet(); @@ -1752,7 +1741,7 @@ public void fdv1FallbackNotTriggeredWhenNoFdv1SlotExists() throws Exception { FDv2SourceResult.changeSet(makeChangeSet(true), true)); FDv2DataSource dataSource = buildDataSource(sink, - Collections.emptyList(), + Collections.>emptyList(), Collections.>singletonList(() -> fdv2Sync)); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1782,31 +1771,23 @@ public void needsRefresh_differentContext_returnsTrue() { assertTrue(dataSource.needsRefresh(false, LDContext.create("other-context"))); } - // ============================================================================ - // Eager (Pre-Startup) Initializers - // ============================================================================ - @Test - public void eagerInitializerRunsBeforeExecutorDispatch() throws Exception { + public void cacheInitializerRunsBeforeExecutorDispatch() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - AtomicReference eagerThread = new AtomicReference<>(); + AtomicReference cacheInitializerThread = new AtomicReference<>(); AtomicReference callingThread = new AtomicReference<>(); - Initializer eagerInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { - @Override - public boolean isRequiredBeforeStartup() { - return true; - } - - @Override - public LDAwaitFuture run() { - eagerThread.set(Thread.currentThread().getName()); - return super.run(); - } - }; + 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(eagerInit), + Collections.singletonList(cacheInitializerFactory), Collections.singletonList(() -> new MockQueuedSynchronizer( FDv2SourceResult.changeSet(makeChangeSet(true), false)))); @@ -1814,33 +1795,29 @@ public LDAwaitFuture run() { AwaitableCallback startCallback = startDataSource(dataSource); assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); - assertEquals("Eager initializer must run on the calling thread", - callingThread.get(), eagerThread.get()); + assertEquals("Cache initializer must run on the calling thread", + callingThread.get(), cacheInitializerThread.get()); stopDataSource(dataSource); } @Test - public void deferredInitializerRunsOnExecutorThread() throws Exception { + public void generalInitializersRunsOnExecutorThread() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); AtomicReference deferredThread = new AtomicReference<>(); CountDownLatch deferredRan = new CountDownLatch(1); - Initializer deferredInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { - @Override - public boolean isRequiredBeforeStartup() { - return false; - } - - @Override - public LDAwaitFuture run() { - deferredThread.set(Thread.currentThread().getName()); - deferredRan.countDown(); - return super.run(); - } - }; + 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(deferredInit), + Collections.singletonList(deferredFactory), Collections.emptyList()); String callingThread = Thread.currentThread().getName(); @@ -1858,34 +1835,26 @@ public void eagerAndDeferredInitializersBothRun() throws Exception { AtomicBoolean eagerRan = new AtomicBoolean(false); CountDownLatch deferredRan = new CountDownLatch(1); - Initializer eagerInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { - @Override - public boolean isRequiredBeforeStartup() { - return true; - } - - @Override - public LDAwaitFuture run() { - eagerRan.set(true); - return super.run(); - } - }; - - Initializer deferredInit = new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true), false)) { - @Override - public boolean isRequiredBeforeStartup() { - return false; - } + FDv2DataSource.DataSourceFactory eagerFactory = new CacheInitializerFactory(() -> + new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(false), false)) { + @Override + public LDAwaitFuture run() { + eagerRan.set(true); + return super.run(); + } + }); - @Override - public LDAwaitFuture run() { - deferredRan.countDown(); - 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(eagerInit, deferredInit), + Arrays.asList(eagerFactory, deferredFactory), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1901,21 +1870,16 @@ public LDAwaitFuture run() { public void offlineModeWithEagerCacheMissStillInitializes() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - Initializer cacheInitializer = new MockInitializer( - FDv2SourceResult.changeSet(new ChangeSet<>( + FDv2DataSource.DataSourceFactory cacheMissFactory = new CacheInitializerFactory(() -> + new MockInitializer(FDv2SourceResult.changeSet(new ChangeSet<>( ChangeSetType.None, com.launchdarkly.sdk.fdv2.Selector.EMPTY, Collections.emptyMap(), null, - false), false)) { - @Override - public boolean isRequiredBeforeStartup() { - return true; - } - }; + false), false))); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(cacheInitializer), + Collections.singletonList(cacheMissFactory), Collections.emptyList()); AwaitableCallback startCallback = startDataSource(dataSource); @@ -1931,16 +1895,11 @@ public void eagerInitializerDataAvailableWithZeroTimeout() throws Exception { Map items = new HashMap<>(); items.put(flag.getKey(), flag); - Initializer eagerInit = new MockInitializer( - FDv2SourceResult.changeSet(makeFullChangeSet(items), false)) { - @Override - public boolean isRequiredBeforeStartup() { - return true; - } - }; + FDv2DataSource.DataSourceFactory eagerFactory = new CacheInitializerFactory(() -> + new MockInitializer(FDv2SourceResult.changeSet(makeFullChangeSet(items), false))); FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(eagerInit), + Collections.singletonList(eagerFactory), Collections.singletonList(() -> new MockQueuedSynchronizer( FDv2SourceResult.changeSet(makeChangeSet(true), false)))); From 9fbd135fcbc78882b7c979a7c77ec17731d467b5 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 17 Apr 2026 16:42:33 -0700 Subject: [PATCH 19/20] [SDK-2070] chore: remove unused imports from branch-edited files Made-with: Cursor --- .../launchdarkly/sdk/android/ContextDataManager.java | 1 - .../sdk/android/subsystems/InitializerFromCache.java | 3 --- .../android/ContextDataManagerContextCachingTest.java | 11 ----------- .../sdk/android/ContextDataManagerFlagDataTest.java | 2 -- .../launchdarkly/sdk/android/FDv2DataSourceTest.java | 5 ----- 5 files changed, 22 deletions(-) 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 83341c88..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; 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 index 77781808..0e765871 100644 --- 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 @@ -1,8 +1,5 @@ package com.launchdarkly.sdk.android.subsystems; -import java.io.Closeable; -import java.util.concurrent.Future; - /** * 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. 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 6b70223b..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 { 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 ed5e6648..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; 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 8fce0543..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,19 +7,14 @@ 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; From fc931a870daab7352c82a24865a2fa63b7bae21a Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 20 Apr 2026 10:08:13 -0700 Subject: [PATCH 20/20] [SDK-2070] Addressing code review comments Made-with: Cursor --- .../java/com/launchdarkly/sdk/android/FDv2DataSource.java | 3 ++- .../launchdarkly/sdk/android/FDv2DataSourceBuilder.java | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 1144356d..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 @@ -140,7 +140,7 @@ public interface DataSourceFactory { } // note that the source manager only uses the initializers after the cache initializers and not the cache initializers - this.sourceManager = new SourceManager(allSynchronizers, new ArrayList<>(generalInitializers)); + this.sourceManager = new SourceManager(allSynchronizers, generalInitializers); this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; this.sharedExecutor = sharedExecutor; @@ -192,6 +192,7 @@ public void start(@NonNull Callback resultCallback) { if (!sourceManager.hasAvailableSynchronizers()) { 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)); } 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 f9ecb088..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 @@ -172,6 +172,13 @@ private DataSourceBuildInputsInternal makeInputs(ClientContext clientContext) { 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()) { if (builder instanceof InitializerFromCache) {