Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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,23 @@ final class ContextDataManager implements TransactionalDataStore {
/** Selector from the last applied changeset that carried one; in-memory only, not persisted. */
@NonNull private Selector currentSelector = Selector.EMPTY;

/**
* @param skipCacheLoad true when an FDv2 cache initializer will handle loading cached
* flags as the first step in the initializer chain, making the
* cache load in {@link #switchToContext} redundant
*/
ContextDataManager(
@NonNull ClientContext clientContext,
@NonNull PersistentDataStoreWrapper.PerEnvironmentData environmentStore,
int maxCachedContexts
int maxCachedContexts,
boolean skipCacheLoad
) {
this.environmentStore = environmentStore;
this.index = environmentStore.getIndex();
this.maxCachedContexts = maxCachedContexts;
this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor();
this.logger = clientContext.getBaseLogger();
switchToContext(clientContext.getEvaluationContext());
switchToContext(clientContext.getEvaluationContext(), skipCacheLoad);
}

/**
Expand All @@ -83,16 +89,22 @@ final class ContextDataManager implements TransactionalDataStore {
* If the context provided is different than the current state, switches to internally
* stored flag data and notifies flag listeners.
*
* @param context the to switch to
* @param context the context to switch to
* @param skipCacheLoad true to only set the current context without loading cached data
* (used in the FDv2 path where the cache initializer handles loading)
*/
public void switchToContext(@NonNull LDContext context) {
public void switchToContext(@NonNull LDContext context, boolean skipCacheLoad) {
synchronized (lock) {
if (context.equals(currentContext)) {
return;
}
currentContext = context;
}

if (skipCacheLoad) {
return;
}

EnvironmentData storedData = getStoredData(context);
if (storedData == null) {
logger.debug("No stored flag data is available for this context");
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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)}.
* <p>
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import androidx.annotation.NonNull;


import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
Expand Down Expand Up @@ -138,6 +139,17 @@ public Synchronizer build(DataSourceBuildInputs inputs) {
}
}

static final class CacheInitializerBuilderImpl implements DataSourceBuilder<Initializer> {
@Override
public Initializer build(DataSourceBuildInputs inputs) {
return new FDv2CacheInitializer(
DataSourceBuildInputsInternal.get(inputs).getPerEnvironmentDataIfAvailable(),
inputs.getEvaluationContext(),
inputs.getBaseLogger()
);
}
}

/**
* Returns a builder for a polling initializer.
* <p>
Expand Down Expand Up @@ -188,6 +200,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 +215,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,106 @@
package com.launchdarkly.sdk.android;

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

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

import java.util.Collections;
import java.util.Map;
import java.util.concurrent.Future;

/**
* FDv2 cache initializer: loads persisted flag data from the local cache.
* <p>
* Per CONNMODE 4.1.2, a cache hit returns data with {@code persist=false} and
* {@link Selector#EMPTY} (no selector).
* <p>
* All non-hit outcomes — cache miss, missing persistent store, and exceptions during
* cache read — are returned as a {@link ChangeSetType#None} changeset, signaling
* "no data available" rather than an error. A corrupt or unreadable cache is
* semantically equivalent to an empty cache: neither provides usable data.
* <p>
* 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 {
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 LDLogger logger;

FDv2CacheInitializer(
@Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData,
@NonNull LDContext context,
@NonNull LDLogger logger
) {
this.envData = envData;
this.context = context;
this.logger = logger;
}

@Override
@NonNull
public Future<FDv2SourceResult> run() {
FDv2SourceResult result;
try {
if (envData == null) {
logger.debug("No persistent store configured; skipping cache");
result = FDv2SourceResult.changeSet(new ChangeSet<>(
ChangeSetType.None,
Selector.EMPTY,
Collections.emptyMap(),
null,
false), false);
} else {
String hashedContextId = LDUtil.urlSafeBase64HashedContextId(context);
EnvironmentData stored = envData.getContextData(hashedContextId);
if (stored == null) {
logger.debug("Cache miss for context");
result = FDv2SourceResult.changeSet(new ChangeSet<>(
ChangeSetType.None,
Selector.EMPTY,
Collections.emptyMap(),
null,
false), false);
} else {
Map<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());
result = FDv2SourceResult.changeSet(changeSet, false);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
} catch (Exception e) {
logger.warn("Cache initializer failed: {}", e.toString());
result = FDv2SourceResult.changeSet(new ChangeSet<>(
ChangeSetType.None,
Selector.EMPTY,
Collections.emptyMap(),
null,
false), false);
}

LDAwaitFuture<FDv2SourceResult> future = new LDAwaitFuture<>();
future.set(result);
return future;
}

@Override
public void close() {
// No-op: the cache read runs synchronously in run(), so there is nothing to cancel.
}
}
Loading
Loading