Skip to content

chore: FDv2 Cache Initializer#342

Merged
aaron-zeisler merged 24 commits intomainfrom
aaronz/SDK-2070/cache-initializer
Apr 20, 2026
Merged

chore: FDv2 Cache Initializer#342
aaron-zeisler merged 24 commits intomainfrom
aaronz/SDK-2070/cache-initializer

Conversation

@aaron-zeisler
Copy link
Copy Markdown
Contributor

@aaron-zeisler aaron-zeisler commented Apr 6, 2026

Goal

Implement the FDv2 cache initializer so that cached flag data is loaded from local storage as the first step in every connection mode's initializer chain, providing immediate flag values while the network initializer fetches fresh data.

Approach

The FDv1 production code loads cached flags synchronously during ContextDataManager.switchToContext(). In FDv2, this cache loading is modeled as a dedicated Initializer — the first in every mode's chain — per CONNMODE spec 4.1.1.

Cache initializers are identified by the InitializerFromCache marker interface applied to their builder. At mode-resolution time, FDv2DataSourceBuilder wraps those builders' factories so the marker is preserved through the factory indirection, and at construction time FDv2DataSource partitions its initializers into cache initializers and general initializers. Cache initializers run synchronously on the caller's thread before executor dispatch, matching FDv1 behavior where an SDK initializing with timeout=0 still receives cached values immediately. General initializers (e.g. polling) then run on the executor as before.

The cache initializer's dependency on ReadOnlyPerEnvironmentData is wired through a new package-private DataSourceBuildInputsInternal class that extends the public DataSourceBuildInputs, following the same pattern as ClientContext/ClientContextImpl. Internal SDK components call DataSourceBuildInputsInternal.get(inputs) to unwrap internal-only properties, while the public API surface remains unchanged.

Key Behaviors

  • Cache hit returns a CHANGE_SET with ChangeSetType.Full, Selector.EMPTY, and persist=false, so the orchestrator applies the data immediately but continues to the next initializer for a verified selector from the server
  • Cache miss, missing persistent store, and exceptions during cache read all return a 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 modes with no synchronizers (e.g. OFFLINE) still complete startup successfully on cache miss
  • Pre-startup execution: cache initializers run synchronously before the executor-dispatched phase begins, guaranteeing cached flag values are applied before the SDK's startup timeout starts counting
  • Separation of concerns: runCacheInitializers() applies changesets but ignores selectors/statuses and never completes start; runGeneralInitializers() is responsible for init completion and observes synchronizer availability via the existing start() orchestration
  • Redundant cache loads removed: in the FDv2 path, ContextDataManager skips its prior cache-load-on-context-switch via a skipCacheLoad parameter on switchToContext(), keeping FDv1 behavior unchanged
  • fdv1Fallback is always false since the cache is local (no server headers involved)

Not In Scope

  • Cache freshness tracking (CSFDV2 5.2.x)
  • Data Availability = CACHED config option (CONNMODE 4.1.3) — whether cache alone completes initialization when synchronizers are available
  • Poll interval relative to cache freshness (CSFDV2 5.3.9)

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions

Related issues

Provide links to any issues in this repository or elsewhere relating to this pull request.

Describe the solution you've provided

Provide a clear and concise description of what you expect to happen.

Describe alternatives you've considered

Provide a clear and concise description of any alternative solutions or features you've considered.

Additional context

Add any other context about the pull request here.


Note

Medium Risk
Changes FDv2 startup/identify sequencing to apply cached flag data synchronously before executor-dispatched initialization, which can affect initialization timing and flag availability semantics across connection modes.

Overview
Implements an FDv2 cache initializer that reads persisted flag data and applies it as the first initializer step in every default connection mode, returning Full changesets on cache hit and None on miss/no-store/errors.

Updates the FDv2 orchestration to separate cache initializers from general initializers, running cache initializers synchronously on the caller thread before start() dispatch, and adjusts success criteria to ignore ChangeSetType.None as “data received.”

Adds internal-only plumbing (DataSourceBuildInputsInternal, ReadOnlyPerEnvironmentData, and InitializerFromCache) to pass persistent-store access into built-in FDv2 components without expanding the public API, and avoids redundant cache loads by allowing ContextDataManager.switchToContext(..., skipCacheLoad) when FDv2 is in use (wired through LDClient).

Reviewed by Cursor Bugbot for commit fc931a8. Bugbot is set up for automated code reviews on this repo. Configure here.

Base automatically changed from aaronz/SDK-1829/fdv2-to-fdv1-fallback-handling to main April 8, 2026 16:19
…he 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
…uilderImpl

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
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
@aaron-zeisler aaron-zeisler force-pushed the aaronz/SDK-2070/cache-initializer branch from 363887c to 563ec22 Compare April 8, 2026 17:52
* 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 {
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

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
@aaron-zeisler
Copy link
Copy Markdown
Contributor Author

@tanderson-ld The 5th (and currently last) commit updates the code in LDClient and ContextDataManager. In FDv1, flags are loaded from the cache in ContextDataManager's constructor and also in LDClient's "identify" flow. But in FDv2, loading from cache in those places is redundant now that we have the cache initializer. I could defer this commit to a separate pull request if you feel that's cleaner.

@aaron-zeisler aaron-zeisler marked this pull request as ready for review April 8, 2026 21:03
@aaron-zeisler aaron-zeisler requested a review from a team as a code owner April 8, 2026 21:03
…terrupted

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
@tanderson-ld
Copy link
Copy Markdown
Contributor

We need to meet this requirement:

For SDKs that are inherently waiting for cache today even with a timeout of 0 seconds, we need to ensure that in FDv2 that same SDK major version continues to wait for the cache initializer.

…e 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
- 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
….com:launchdarkly/android-client-sdk into aaronz/SDK-2070/cache-initializer
…or 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
Add a default method `isRequiredBeforeStartup()` to the Initializer
interface so FDv2DataSource can distinguish initializers that must
run before the startup timeout begins. FDv2CacheInitializer overrides
it to return true, matching FDv1 behavior where cache was always
loaded before the timeout started.

Made-with: Cursor
Build Initializer instances directly in resolve() instead of wrapping
them in factory lambdas. This allows FDv2DataSource to inspect
isRequiredBeforeStartup() and run pre-startup initializers
synchronously before dispatching to the executor. ResolvedModeDefinition
now carries List<Initializer> instead of List<DataSourceFactory<Initializer>>.

Made-with: Cursor
… filtering

Accept List<Initializer> instead of List<DataSourceFactory<Initializer>>.
Add isRequiredBeforeStartup parameter to getNextInitializerAndSetActive()
to filter initializers by their pre-startup requirement. Add
resetInitializerIndex() for resetting between the eager and deferred
passes. Remove the now-unnecessary getNextInitializer() helper.

Made-with: Cursor
Run pre-startup initializers synchronously on the calling thread before
dispatching to the executor, guaranteeing cached data is available even
with a zero timeout. The deferred pass then runs remaining initializers
on the executor thread. Both passes reuse the existing runInitializers()
method with added isRequiredBeforeStartup filter and previousDataReceived
seed parameters.

Made-with: Cursor
Update FDv2DataSourceTest for pre-built initializer signatures (factory
lambdas replaced with direct instances). Add tests verifying eager
initializers run on the calling thread, deferred initializers run on the
executor, both passes execute, OFFLINE cache miss still initializes, and
cached data is available immediately. Add isRequiredBeforeStartup() test
to FDv2CacheInitializerTest.

Made-with: Cursor
… 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
…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

return LDFutures.anyOf(shutdownFuture, resultFuture);
LDAwaitFuture<FDv2SourceResult> future = new LDAwaitFuture<>();
future.set(result);
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.

There's a built-in future type other than LDAwaitFuture that's already complete. Composable futures were added in Android 24.

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.

This is still open.

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.

Question about this. Our build.gradle files define the min SDK value at 21. Here's an example:

Since the CompletableFuture class was introduced in SDK version 24, I think the SDK will throw errors when it's run on API versions 21-23. Is this a big problem? What do you think about this? THose versions are pretty old and I wonder if we can update our min SDK value.

aaron-zeisler and others added 3 commits April 17, 2026 13:33
…cache-before-timeout' into aaronz/SDK-2070/cache-initializer

Integrates pre-startup (eager) initializer execution with existing
cache initializer improvements. Key merge resolution decisions:

- Introduced RunInitializersResult to carry both anySucceeded and
  anyDataReceived from each initializer pass, replacing the single
  boolean return that differed between branches
- Combined DataSourceBuildInputsInternal pattern (no instanceof hack)
  with eager initializer building in FDv2DataSourceBuilder.resolve()
- Updated FDv2DataSourceBuilderTest to provide HttpConfiguration,
  needed now that initializers are eagerly built during resolve()

Made-with: Cursor
…DataSource

Replace the Initializer.isRequiredBeforeStartup() marker with an
InitializerFromCache marker applied to the builder/factory. FDv2DataSource
now partitions initializers at construction and runs cache initializers
synchronously before executor dispatch (ensuring cache loads within a
zero-timeout init), then runs general initializers on the executor.

FDv2DataSourceBuilder wraps factories built from InitializerFromCache
builders so the marker is preserved through the factory indirection.

Co-authored-by: Todd Anderson <tanderson@launchdarkly.com>
Made-with: Cursor
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5e9fa17. Configure here.


return LDFutures.anyOf(shutdownFuture, resultFuture);
LDAwaitFuture<FDv2SourceResult> future = new LDAwaitFuture<>();
future.set(result);
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.

This is still open.


this.sourceManager = new SourceManager(allSynchronizers, new ArrayList<>(initializers));
// note that the source manager only uses the initializers after the cache initializers and not the cache initializers
this.sourceManager = new SourceManager(allSynchronizers, new ArrayList<>(generalInitializers));
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.

new ArrayList isn't necessary (I wrote the code, sorry!)

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


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().
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.

This comment doesn't need to be removed. It is still valid.

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(new CacheInitializerFactory(() -> builder.build(inputs)));
} else {
initFactories.add(() -> builder.build(inputs));
}
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

@aaron-zeisler aaron-zeisler merged commit 8e74102 into main Apr 20, 2026
7 checks passed
@aaron-zeisler aaron-zeisler deleted the aaronz/SDK-2070/cache-initializer branch April 20, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants