Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e044023
[SDK-2070] feat: add CachedFlagStore to DataSourceBuildInputs for cac…
aaron-zeisler Apr 6, 2026
596bc97
[SDK-2070] feat: implement FDv2CacheInitializer and CacheInitializerB…
aaron-zeisler Apr 6, 2026
563ec22
[SDK-2070] feat: wire cache initializer into the default mode table
aaron-zeisler Apr 6, 2026
dbe5c65
[SDK-2070] Added comments where cache initialization is redundant
aaron-zeisler Apr 8, 2026
70bd195
[SDK-2070] refactor: skip redundant cache load in FDv2 path
aaron-zeisler Apr 8, 2026
f856058
[SDK-2070] refactor: replace CachedFlagStore with ReadOnlyPerEnvironm…
aaron-zeisler Apr 10, 2026
24c962f
[SDK-2070] fix: return ChangeSetType.None on cache miss instead of in…
aaron-zeisler Apr 10, 2026
e1617fe
Merge branch 'main' into aaronz/SDK-2070/cache-initializer
tanderson-ld Apr 10, 2026
7b5ed27
[SDK-2070] fix: defer init completion to synchronizers and treat cach…
aaron-zeisler Apr 13, 2026
6aeea41
[SDK-2070] Addressing code review comments
aaron-zeisler Apr 13, 2026
b8a50de
[SDK-2070] Merge branch 'aaronz/SDK-2070/cache-initializer' of github…
aaron-zeisler Apr 13, 2026
3f0066b
[SDK-2070] Run FDv2CacheInitializer synchronously to eliminate execut…
aaron-zeisler Apr 13, 2026
8f5353a
[SDK-2070] Add isRequiredBeforeStartup() marker to Initializer interface
aaron-zeisler Apr 14, 2026
4e9746d
[SDK-2070] Build initializers eagerly in FDv2DataSourceBuilder
aaron-zeisler Apr 14, 2026
b9b1851
[SDK-2070] Update SourceManager to accept pre-built initializers with…
aaron-zeisler Apr 14, 2026
4a82402
[SDK-2070] Add two-pass initializer execution in FDv2DataSource
aaron-zeisler Apr 14, 2026
e8caa9f
[SDK-2070] Add tests for pre-startup initializer execution
aaron-zeisler Apr 14, 2026
41ac382
[SDK-2070] Addressing code review: remove synchronizer awareness from…
aaron-zeisler Apr 15, 2026
9e51057
[SDK-2070] Addressing code review: use DataSourceBuildInputsInternal …
aaron-zeisler Apr 15, 2026
3050faf
Merge branch 'main' into aaronz/SDK-2070/cache-initializer
aaron-zeisler Apr 17, 2026
2bdaf5a
[SDK-2070] Merge branch 'aaronz/SDK-2070/cache-initializer-guarantee-…
aaron-zeisler Apr 17, 2026
5e9fa17
[SDK-2070] Split cache initializers from general initializers in FDv2…
aaron-zeisler Apr 17, 2026
9fbd135
[SDK-2070] chore: remove unused imports from branch-edited files
aaron-zeisler Apr 17, 2026
fc931a8
[SDK-2070] Addressing code review comments
aaron-zeisler Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() {
return throwExceptionIfNull(perEnvironmentData);
}

@Nullable
public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentDataIfAvailable() {
return perEnvironmentData;
}

@Nullable
public TransactionalDataStore getTransactionalDataStore() {
return transactionalDataStore;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,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;
Expand Down Expand Up @@ -138,6 +139,31 @@ public Synchronizer build(DataSourceBuildInputs inputs) {
}
}

static final class CacheInitializerBuilderImpl implements DataSourceBuilder<Initializer> {
@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,
inputs.getEvaluationContext(),
inputs.getSharedExecutor(),
inputs.getBaseLogger()
);
}
}

/**
* Returns a builder for a polling initializer.
* <p>
Expand Down Expand Up @@ -188,6 +214,7 @@ public static StreamingSynchronizerBuilder streamingSynchronizer() {
*/
@NonNull
public static Map<ConnectionMode, ModeDefinition> makeDefaultModeTable() {
DataSourceBuilder<Initializer> cacheInitializer = new CacheInitializerBuilderImpl();
DataSourceBuilder<Initializer> pollingInitializer = pollingInitializer();
DataSourceBuilder<Synchronizer> pollingSynchronizer = pollingSynchronizer();
DataSourceBuilder<Synchronizer> streamingSynchronizer = streamingSynchronizer();
Expand All @@ -202,32 +229,28 @@ public static Map<ConnectionMode, ModeDefinition> makeDefaultModeTable() {

Map<ConnectionMode, ModeDefinition> 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.<DataSourceBuilder<Initializer>>emptyList(),
Collections.singletonList(cacheInitializer),
Collections.singletonList(pollingSynchronizer),
fdv1FallbackPollingSynchronizerForeground
));
table.put(ConnectionMode.OFFLINE, new ModeDefinition(
// TODO: Arrays.asList(cacheInitializer) — add once implemented
Collections.<DataSourceBuilder<Initializer>>emptyList(),
Collections.singletonList(cacheInitializer),
Collections.<DataSourceBuilder<Synchronizer>>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.<DataSourceBuilder<Synchronizer>>emptyList(),
null
));
table.put(ConnectionMode.BACKGROUND, new ModeDefinition(
// TODO: Arrays.asList(cacheInitializer) — add once implemented
Collections.<DataSourceBuilder<Initializer>>emptyList(),
Comment thread
cursor[bot] marked this conversation as resolved.
Collections.singletonList(cacheInitializer),
Collections.singletonList(backgroundPollingSynchronizer),
fdv1FallbackPollingSynchronizerBackground
));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.launchdarkly.sdk.android;

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

import com.launchdarkly.logging.LDLogger;
import com.launchdarkly.sdk.LDContext;
import com.launchdarkly.sdk.android.DataModel.Flag;
import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult;
import com.launchdarkly.sdk.android.subsystems.Initializer;
import com.launchdarkly.sdk.fdv2.ChangeSet;
import com.launchdarkly.sdk.fdv2.ChangeSetType;
import com.launchdarkly.sdk.fdv2.Selector;

import java.util.Collections;
import java.util.Map;
import java.util.concurrent.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.
* <p>
* 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.
Comment thread
aaron-zeisler marked this conversation as resolved.
Outdated
* <p>
* 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.
Comment thread
aaron-zeisler marked this conversation as resolved.
Outdated
*/
final class FDv2CacheInitializer implements Initializer {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a summary of how the spec requirements and the js-core implementation were used when developing this class:

Decision Source Reasoning
Selector.EMPTY on cache result CONNMODE 4.1.2 Cache is unverified; empty selector tells the orchestrator to continue to the polling initializer for a real selector
persist=false on ChangeSet CONNMODE 4.1.2 Don't re-write data we just read from cache
Cache miss = interrupted js-core pattern Fast failure so the orchestrator immediately moves on; interrupted is the correct signal (not terminalError, which would stop the chain)
fdv1Fallback=false always Logic Cache is local storage, no server headers are involved
Nullable cachedFlagStore Testing pragmatism Test contexts don't have a persistent store; graceful degradation avoids test setup burden


@Nullable
private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData;
private final LDContext context;
private final Executor executor;
private final LDLogger logger;
private final LDAwaitFuture<FDv2SourceResult> 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<FDv2SourceResult> run() {
LDAwaitFuture<FDv2SourceResult> 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;
}
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<>(
ChangeSetType.None,
Selector.EMPTY,
Collections.emptyMap(),
null,
false), false));
return;
}
Map<String, Flag> flags = stored.getAll();
ChangeSet<Map<String, Flag>> 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));
}
Comment thread
cursor[bot] marked this conversation as resolved.
});
Comment thread
aaron-zeisler marked this conversation as resolved.
Outdated

return LDFutures.anyOf(shutdownFuture, resultFuture);
}

@Override
public void close() {
shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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;
Expand Down Expand Up @@ -115,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)) {
Expand Down Expand Up @@ -149,28 +152,40 @@ 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;

return new DataSourceBuildInputs(
clientContext.getEvaluationContext(),
clientContext.getServiceEndpoints(),
clientContext.getHttp(),
clientContext.isEvaluationReasons(),
selectorSource,
sharedExecutor,
ClientContextImpl.get(clientContext).getPlatformState().getCacheDir(),
impl.getPlatformState().getCacheDir(),
clientContext.getBaseLogger()
);
}

private static ResolvedModeDefinition resolve(
ModeDefinition def, DataSourceBuildInputs inputs
ModeDefinition def, DataSourceBuildInputs inputs,
@Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData
) {
List<FDv2DataSource.DataSourceFactory<Initializer>> initFactories = new ArrayList<>();
for (DataSourceBuilder<Initializer> 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<Initializer> effective;
if (builder instanceof DataSystemComponents.CacheInitializerBuilderImpl) {
effective = new DataSystemComponents.CacheInitializerBuilderImpl(envData);
Comment thread
aaron-zeisler marked this conversation as resolved.
Outdated
} else {
effective = builder;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment to help explain what this logic is doing and that it is dealing with the DataSourceBuilder (public API) to FDv2DataSource.DataSourceFactory "handholding"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

initFactories.add(() -> effective.build(inputs));
}
List<FDv2DataSource.DataSourceFactory<Synchronizer>> syncFactories = new ArrayList<>();
for (DataSourceBuilder<Synchronizer> builder : def.getSynchronizers()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -497,11 +499,17 @@ private void identifyInternal(@NonNull LDContext context,

clientContextImpl = clientContextImpl.setEvaluationContext(context);

// Calling initFromStoredData updates the current flag state *if* stored flags exist for
// this context. If they don't, it has no effect. Currently we do *not* return early from
// initialization just because stored flags exist; we're just making them available in case
// initialization times out or otherwise fails.
contextDataManager.switchToContext(context);
// Load cached flags for the new context so they're available in case initialization
// times out or otherwise fails. This does not short-circuit initialization — the data
// source still performs its network request regardless.
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);
}
Comment thread
aaron-zeisler marked this conversation as resolved.
Outdated
connectivityManager.switchToContext(context, onCompleteListener);
eventProcessor.recordIdentifyEvent(context);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ private void createTestManager(
contextDataManager = new ContextDataManager(
clientContext,
environmentStore,
1
1,
false
);
contextDataManager.registerAllFlagsListener(flagsUpdated -> {
allFlagsReceived.add(flagsUpdated);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ protected ContextDataManager createDataManager(int maxCachedContexts) {
return new ContextDataManager(
clientContext,
environmentStore,
maxCachedContexts
maxCachedContexts,
false
);
}

Expand Down
Loading
Loading