From e0440233f8cf50f03b72e5656afe8ec3429dc381 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 6 Apr 2026 14:27:20 -0700 Subject: [PATCH 01/12] [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/12] [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/12] [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/12] [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/12] [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/12] [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/12] [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/12] [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/12] [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/12] [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 41ac3824d4d7865c86d3d5c80e982d032ead4577 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 15 Apr 2026 13:04:38 -0700 Subject: [PATCH 11/12] [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 12/12] [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()); + } +}