Skip to content

feat: add android-sdk-framework module#251

Open
typotter wants to merge 5 commits intomainfrom
typo/v4/add-framework-module
Open

feat: add android-sdk-framework module#251
typotter wants to merge 5 commits intomainfrom
typo/v4/add-framework-module

Conversation

@typotter
Copy link
Copy Markdown
Collaborator

Summary

Add new android-sdk-framework module that provides base client functionality and configuration storage abstractions for Android SDK implementations.

Key Components

  • AndroidBaseClient: Base class for Android SDK clients that extends the v4 Java SDK framework
  • CachingConfigurationStore: Abstract store for cached configurations with lifecycle management
  • FileBackedConfigStore: File-based implementation for configuration persistence
  • ConfigurationCodec: Pluggable serialization/deserialization interface with default Java serialization
  • ByteStore: Low-level byte storage abstraction
  • ConfigCacheFile: Disk cache file management with content-type based extensions

Dependencies

This module depends on:

  • v4 Java SDK framework (cloud.eppo:eppo-sdk-framework)
  • slf4j-android for logging

Testing

  • Framework module builds independently: ✅
  • Framework unit tests pass: ✅
  • Existing eppo module unchanged: ✅
  • Existing eppo tests pass: ✅

Notes

  • This PR adds the framework module as a standalone component
  • The eppo module is NOT modified and remains on v3 dependencies
  • Integration with the eppo module will happen in a follow-up PR
  • Supersedes feat: add android-sdk-framework module #248

Add new android-sdk-framework module that provides base client functionality
and configuration storage abstractions for Android SDK implementations.

Key components:
- AndroidBaseClient: Base class for Android SDK clients
- CachingConfigurationStore: Abstract store for cached configurations
- FileBackedConfigStore: File-based configuration persistence
- ConfigurationCodec: Pluggable serialization/deserialization
- ByteStore: Low-level byte storage abstraction

This module uses the v4 Java SDK framework and is added as a standalone
module without integration into the existing eppo module yet.
- resumePolling(): store pollingIntervalMs/pollingJitterMs on instance
  so pause/resume works after initial polling is started via Builder
- safeCacheKey(): guard against keys shorter than 8 characters with
  Math.min to avoid StringIndexOutOfBoundsException
- BaseCacheFile.setContents(): use try-with-resources to close writer
  even when write() throws
- FileBackedByteStore: use a dedicated single-thread executor for I/O
  instead of ForkJoinPool.commonPool() to avoid pool saturation on
  low-core-count devices
- ConfigCacheFile: document why deprecated package-private constructors
  exist (v3→v4 migration support)
- build.gradle: clarify intent of EPPO_VERSION build config field
- safeCacheKey(): add null/empty guard returning "" to match Copilot
  suggestion; also prevents NPE on null key during initialization
- GsonConfigurationCodec: delete from framework module; uses reflection
  on private fields of Configuration which R8 will rename in release
  builds (no proguard keep rules), causing NoSuchFieldException at
  runtime — flagged by both Copilot and code reviewer
@typotter
Copy link
Copy Markdown
Collaborator Author

@copilot review

@typotter typotter requested a review from aarsilv March 25, 2026 22:11
@aarsilv aarsilv requested a review from Copilot March 27, 2026 14:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new android-sdk-framework Android library module that provides a shared base client and configuration persistence abstractions for Android SDK implementations, while keeping the existing :eppo module unchanged.

Changes:

  • Introduces AndroidBaseClient (builder-based singleton) extending the v4 Java SDK framework client.
  • Adds storage abstractions (CachingConfigurationStore, ByteStore, ConfigurationCodec) and a file-backed persistence implementation.
  • Adds Robolectric unit tests, an instrumentation test for polling pause/resume, and a large JSON fixture used by tests.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
settings.gradle Adds the new module include and adjusts the snapshots Maven repo URL.
android-sdk-framework/build.gradle Declares the new Android library module, dependencies, publishing/signing, and Spotless config.
android-sdk-framework/src/main/AndroidManifest.xml Minimal manifest for the library module.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java Base Android client with builder/init flow and polling pause/resume.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java Exception thrown when accessing the singleton before initialization.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java Initialization error wrapper exception.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java Shared helpers for log tags and cache key derivation.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java Async byte I/O abstraction.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java File wrapper for cache read/write helpers.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java Cache filename/extension mapping based on content type.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java Pluggable config serialization API with Java-serialization default.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java In-memory config cache with async persistence support.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java File-backed ByteStore implementation.
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java Wiring of CachingConfigurationStore + file-backed persistence.
android-sdk-framework/src/test/resources/flags-v1.json Large JSON fixture used to create a non-empty configuration in tests.
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java Tests for caching, persistence behavior, and concurrency scenarios.
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java Tests for default codec behavior and error cases.
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java Tests for file-backed byte store read/write behavior.
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java Tests for file-backed config store behavior.
android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java Instrumentation tests for polling pause/resume call safety.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +107
@Nullable private static AndroidBaseClient<?> instance;

/**
* Private constructor. Use Builder to construct instances.
*
* @param apiKey API key for Eppo
* @param sdkName SDK name identifier
* @param sdkVersion SDK version string
* @param apiBaseUrl Base URL for API calls
* @param assignmentLogger Logger for assignments
* @param configurationStore Store for configuration persistence
* @param isGracefulMode Whether to operate in graceful mode
* @param expectObfuscatedConfig Whether configuration is obfuscated
* @param initialConfiguration Initial configuration future
* @param assignmentCache Cache for assignments
* @param configurationParser Parser for configuration JSON
* @param configurationClient HTTP client for configuration fetching
*/
protected AndroidBaseClient(
String apiKey,
String sdkName,
String sdkVersion,
@Nullable String apiBaseUrl,
@Nullable AssignmentLogger assignmentLogger,
CachingConfigurationStore configurationStore,
boolean isGracefulMode,
boolean expectObfuscatedConfig,
@Nullable CompletableFuture<Configuration> initialConfiguration,
@Nullable IAssignmentCache assignmentCache,
ConfigurationParser<JsonFlagType> configurationParser,
EppoConfigurationClient configurationClient) {
super(
apiKey,
sdkName,
sdkVersion,
apiBaseUrl,
assignmentLogger,
null, // banditLogger is not supported in Android
configurationStore,
isGracefulMode,
expectObfuscatedConfig,
false, // no bandits.
initialConfiguration,
assignmentCache,
null,
configurationParser,
configurationClient);
}

/**
* Gets the singleton instance of EppoClient.
*
* @return The singleton instance
* @throws NotInitializedException if the client has not been initialized
* @param <T> The JSON type parameter
*/
@SuppressWarnings("unchecked")
public static <T> AndroidBaseClient<T> getInstance() throws NotInitializedException {
if (instance == null) {
throw new NotInitializedException();
}
return (AndroidBaseClient<T>) instance;
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

instance is a mutable static used for a singleton but isn't volatile and access isn't synchronized. If initialization and getInstance() can occur across threads, this risks visibility issues (reading a partially constructed instance) and racy reinitialization. Consider making instance volatile and/or synchronizing the initialization path (or using an AtomicReference).

Copilot uses AI. Check for mistakes.

// Dedicated single-thread executor avoids saturating ForkJoinPool.commonPool() with blocking I/O
// on low-core-count Android devices.
private static final Executor IO_EXECUTOR = Executors.newSingleThreadExecutor();
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

IO_EXECUTOR is a static newSingleThreadExecutor() that is never shut down and uses a non-daemon thread by default. This can leak a thread for the lifetime of the process and can also prevent JVM test runners from exiting cleanly. Consider using a ThreadFactory that creates daemon/named threads and/or an ExecutorService that can be shut down (or delegate to an app-provided executor).

Suggested change
private static final Executor IO_EXECUTOR = Executors.newSingleThreadExecutor();
private static final Executor IO_EXECUTOR =
Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r);
t.setName("FileBackedByteStore-IO");
t.setDaemon(true);
return t;
});

Copilot uses AI. Check for mistakes.
*
* @param configClass the class of the configuration type
*/
public Default(@NotNull Class<T> configClass) {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

Default accepts configClass as @NotNull but doesn't check it. If callers accidentally pass null, fromBytes() will throw a confusing NPE when calling configClass.isInstance(...). Consider validating configClass in the constructor and throwing IllegalArgumentException if it's null.

Suggested change
public Default(@NotNull Class<T> configClass) {
public Default(@NotNull Class<T> configClass) {
if (configClass == null) {
throw new IllegalArgumentException("configClass must not be null");
}

Copilot uses AI. Check for mistakes.
package cloud.eppo.android.framework.util;

public class Utils {
public static String logTag(Class loggingClass) {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

logTag uses a raw Class type and doesn't guard against null, which can lead to a runtime NPE (loggingClass.getSimpleName()). Consider changing the signature to Class<?> (or @NotNull Class<?>) and validating the argument.

Suggested change
public static String logTag(Class loggingClass) {
public static String logTag(Class<?> loggingClass) {
if (loggingClass == null) {
// Fallback tag when no class is provided
return "EppoSDK";
}

Copilot uses AI. Check for mistakes.
mavenLocal()
maven {
url "https://central.sonatype.com/repository/maven-snapshots/"
url "https://central.sonatype.com/repository/maven-snapshots"
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

Maven repository base URLs typically need a trailing slash for correct URI resolution of artifact paths. Without it, some resolvers can treat the last path segment as a file and resolve dependencies incorrectly. Consider restoring the trailing / on this snapshots repo URL.

Suggested change
url "https://central.sonatype.com/repository/maven-snapshots"
url "https://central.sonatype.com/repository/maven-snapshots/"

Copilot uses AI. Check for mistakes.
Comment on lines +374 to +395
public AndroidBaseClient<JsonFlagType> buildAndInit() {
try {
return buildAndInitAsync().get();
} catch (ExecutionException | InterruptedException | CompletionException e) {
// If the exception was an `EppoInitializationException`, we know for sure that
// `buildAndInitAsync` logged it (and wrapped it with a RuntimeException) which was then
// wrapped by `CompletableFuture` with a `CompletionException`.
if (e instanceof CompletionException) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException
&& cause.getCause() instanceof EppoInitializationException) {
@SuppressWarnings("unchecked")
AndroidBaseClient<JsonFlagType> typedInstance =
(AndroidBaseClient<JsonFlagType>) instance;
return typedInstance;
}
}
Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e);
if (!isGracefulMode) {
throw new RuntimeException(e);
}
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

buildAndInit() catches InterruptedException but does not restore the thread interrupt status (e.g., via Thread.currentThread().interrupt()). Swallowing interrupts can break caller cancellation/timeout logic. Consider re-interrupting the thread when InterruptedException is caught (and decide whether to propagate or return gracefully).

Copilot uses AI. Check for mistakes.
AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClientWithoutPolling();
assertNotNull("Client should be initialized", androidBaseClient);

// Try to resume polling (should log warning and not crash per EppoClient.java:436-441)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

These comments reference EppoClient.java:436-441, but this module uses AndroidBaseClient and line numbers will drift over time. Consider referencing the specific method (AndroidBaseClient.resumePolling) or the expected behavior instead of a hard-coded file/line reference.

Suggested change
// Try to resume polling (should log warning and not crash per EppoClient.java:436-441)
// Try to resume polling; AndroidBaseClient.resumePolling should log a warning and not crash if polling was never started

Copilot uses AI. Check for mistakes.
Comment on lines +343 to +349
} else if (offlineMode || failCount.incrementAndGet() == 2) {
ret.completeExceptionally(
new EppoInitializationException(
"Unable to initialize client; Configuration could not be loaded", ex));
} else {
Log.i(TAG, "Initial config was not used.");
failCount.incrementAndGet();
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

The failCount increment logic here double-increments on the first "initial config not used" path: failCount.incrementAndGet() == 2 is evaluated (incrementing), then the else block increments again. This can cause initialization to fail/short-circuit earlier than intended. Refactor to increment exactly once per failure (e.g., store the incremented value in a local variable and reuse it in the condition/logging).

Suggested change
} else if (offlineMode || failCount.incrementAndGet() == 2) {
ret.completeExceptionally(
new EppoInitializationException(
"Unable to initialize client; Configuration could not be loaded", ex));
} else {
Log.i(TAG, "Initial config was not used.");
failCount.incrementAndGet();
} else {
int failures = failCount.incrementAndGet();
if (offlineMode || failures == 2) {
ret.completeExceptionally(
new EppoInitializationException(
"Unable to initialize client; Configuration could not be loaded",
ex));
} else {
Log.i(TAG, "Initial config was not used.");
}

Copilot uses AI. Check for mistakes.
private volatile Configuration configuration = Configuration.emptyConfig();

protected CachingConfigurationStore(
@NotNull ConfigurationCodec<Configuration> codec, @NotNull ByteStore byteStore) {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

The constructor accepts codec/byteStore as @NotNull but doesn't validate them. Passing null will lead to later NPEs that are harder to diagnose. Consider adding explicit null checks and throwing IllegalArgumentException with a clear message.

Suggested change
@NotNull ConfigurationCodec<Configuration> codec, @NotNull ByteStore byteStore) {
@NotNull ConfigurationCodec<Configuration> codec, @NotNull ByteStore byteStore) {
if (codec == null) {
throw new IllegalArgumentException("codec must not be null");
}
if (byteStore == null) {
throw new IllegalArgumentException("byteStore must not be null");
}

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +61
ConfigCacheFile(
@NotNull Application application,
@NotNull String configType,
@NotNull String suffix,
@NotNull String contentType) {
this(application, configType + "-" + suffix, contentType);
}

/**
* Creates a cache file with the given full file name (no prefix). Used when the caller supplies
* the complete filename (e.g. baseName + "." + extension).
*
* @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead. These
* package-private constructors exist only to support migration of the eppo module from v3 to
* v4; they will be removed once that migration is complete.
*/
ConfigCacheFile(@NotNull Application application, @NotNull String fullFileName) {
super(application, fullFileName);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

These constructors are documented with @deprecated in Javadoc but aren't annotated with @Deprecated. Without the annotation, callers won't get compile-time deprecation warnings. Consider adding @Deprecated to each deprecated constructor (and optionally @SuppressWarnings("deprecation") in call sites if needed).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

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

Nice work! You've really put together a flexible, extensible, and well-tested Android SDK with many lessons we should take with us to Datadog. (On that note, please write up lessons from all this in a confluence page). I like the clean code and good test coverage including concurrency tests.

In addition to my own review, I added co-pilot and also had Claude take a pass. I'm approving as-is, although there are some things that I and the tools found that I'd love for you to engage on:

  • EppoClientPollingTest should check for actual poll calls (or lack of, when paused) using spys or some other mechanism
  • Copilot-identified leaky newSingleThreadExecutor
  • Claude-identified saveConfiguration disk/memory race condition
  • Name of AndroidBaseClient
  • Consider a default no-op instance
  • Claude-identified possibility of buildAndInit() hanging forever
  • Copilot-identified need of atomic singleton and/or thread safe access of instance in AndroidBaseClient (volatile/synchronized)
  • gson shouldn't be a dependency anymore
  • slf4j-android maybe should be runtime only

*
* @return Initialized EppoClient
*/
private AndroidBaseClient<JsonNode> buildOfflineClientWithoutPolling()
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.

pretty much same as previous method but with a different boolean passed for pollingEnabled() and the call to pollingIntervalMs() Could be consolidated to a single method (with 0 pollingMs meaning no polling) or the explicitly named methods with a shared base method.

}

@Test
public void testPauseAndResumePolling() throws ExecutionException, InterruptedException {
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 test doesn't seem to be doing much. Consider a shorter polling interval and then pausing longer than the interval to make sure no polls happens and then check polling happened again after unpausing.

}

@Test
public void testPauseResumeSequenceDoesNotCrash()
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.

👍

}

@Test
public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException {
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.

Seems like overkill

Comment on lines +25 to +27
ConfigCacheFile cacheFile =
new ConfigCacheFile(application, cacheFileSuffix, codec.getContentType());
return new FileBackedByteStore(cacheFile);
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.

Pretty small--any reason not to inline it int he constructor?

(success, ex) -> {
if (ex == null && Boolean.TRUE.equals(success)) {
ret.complete(newInstance);
} else if (offlineMode || failCount.incrementAndGet() == 2) {
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.

Had Claude give this a look and it is worried that buildAndInitAsync() can hang forever.

  buildAndInitAsync() can hang forever. If initialConfigFuture is non-null and  
  resolves with success = false/null, the else branch increments    
  failCount twice (to 2, then 3). If loadConfigurationAsync then also fails,    
  incrementAndGet() returns 4 — the == 2 check never matches, and ret is never
  completed. The caller blocks forever on .get().

Also on that note, consider providing an optional timeout (with a default to something generous like 30 seconds?)

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.

It also has a possible other recommendation regarding use of anyOf()

  ret.complete() can be called from two different callbacks on success. Both    
  loadConfigurationAsync and initialConfigFuture handlers call
  ret.complete(newInstance) on success. CompletableFuture.complete() is         
  idempotent so this isn't a bug, but it suggests the dual-callback design is
  more complex than it needs to be. A clearer approach would be
  CompletableFuture.anyOf() for success with an explicit failure combiner.

Comment on lines +58 to +60
delete();
try (BufferedWriter writer = getWriter()) {
writer.write(contents);
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.

Technically, a crash between these two will result in the loss of file. So you could do write and then rename. But if this is is only used for tests not worth worrying about this edge case.

private long pollingIntervalMs;
private long pollingJitterMs;

@Nullable private static AndroidBaseClient<?> instance;
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.

By not having a default no-op instance, buildAndInit() could return null if graceful mode is at play and an error happens before instance is set.

api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT'

api 'com.google.code.gson:gson:2.10.1'
api 'org.slf4j:slf4j-android:1.7.36'
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.

Don't see this in the code, should this be runtimeOnly?

Comment on lines +45 to +48
.thenRun(
() -> {
this.configuration = config;
});
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.

Had Claude dig into this, and it thinks there a race condition.

  The write goes to the single-thread IO_EXECUTOR, but thenRun executes on      
  whatever thread completes the future — typically the IO thread, but not
  guaranteed. Here's the race:                                                  
                  
  Thread 1: saveConfiguration(A)
    1. codec.toBytes(A)                                                         
    2. byteStore.write(A) → queued on IO_EXECUTOR
                                                                                
  Thread 2: saveConfiguration(B)
    3. codec.toBytes(B)                                                         
    4. byteStore.write(B) → queued on IO_EXECUTOR
                                                                                
  IO_EXECUTOR runs them in order:
    5. writes A to disk                                                         
    6. writes B to disk       ← disk now has B                                  
   
  thenRun callbacks fire:                                                       
    7. this.configuration = B ← from step 4's callback
    8. this.configuration = A ← from step 2's callback (ran slightly later)     
                                                                                
  Result: disk=B, memory=A
                                                                                
  Step 8 can run after step 7 because thenRun doesn't run on the IO_EXECUTOR —  
  it runs on whatever thread calls complete(), or the calling thread if the
  future is already done. The two thenRun callbacks are not ordered by the      
  single-thread executor.

  The simplest fix is to run the in-memory update inside the IO_EXECUTOR too:   
   
  return byteStore                                                              
      .write(bytes)
      .thenRunAsync(() -> { this.configuration = config; }, IO_EXECUTOR);
                                                                                
  This guarantees the disk write and memory update for A both happen before B's,
   since they're all serialized on the same single thread. Though that would    
  require exposing the executor or moving the logic into FileBackedByteStore.

@aarsilv
Copy link
Copy Markdown
Contributor

aarsilv commented Mar 27, 2026

I asked Claude to check past PRs for past issues and make sure we're not reintroducing them, it came up:

  1. "The async cache load race from PR #235 appears to be re-introduced —      
  loadFromStorage() doesn't update the in-memory cache, so getConfiguration() 
  returns empty until the first saveConfiguration()"                            
  2. "The SNAPSHOT dependency in api scope will break consumers if published 
  as-is (same issue as #78)"    

Details:

  1. Async cache load race (PR #235 redux)                                      
                                                                                
  In buildAndInitAsync() (line ~672-674):                                       
   
  if (initialConfiguration == null && !ignoreCachedConfiguration) {             
      initialConfiguration = configStore.loadFromStorage();                     
  }
                                                                                
  loadFromStorage() reads from disk and returns the config via a                
  CompletableFuture, but deliberately does not update the in-memory cache (line
  ~1022, the Javadoc even says "without updating the in-memory cache"). That    
  loaded config gets passed to the BaseEppoClient constructor as
  initialConfiguration.

  Meanwhile, configStore.getConfiguration() still returns emptyConfig() — it    
  won't change until someone calls saveConfiguration(), which only happens when
  a fresh config is fetched from the network.                                   
                  
  So in this sequence:                                                          
  1. App starts, cache file exists on disk with valid config
  2. buildAndInitAsync() loads cache into initialConfiguration future           
  3. Client is constructed, configStore.getConfiguration() returns empty
  4. User calls a flag evaluation that goes through                             
  configStore.getConfiguration() → gets empty config → returns default value    
  5. Network fetch completes, calls saveConfiguration() → now in-memory cache is
   populated                                                                    
                                                                                
  This is the exact scenario from PR #235: offline or slow-network users get
  empty config despite having a perfectly valid cache on disk. The fix from PR  
  #235 was to update this.configuration inside the async load — that same fix is
   missing here.                                                                
                  
  ---                                                                           
  2. SNAPSHOT dependency in api scope
                                                                                
  In build.gradle (line 58):

  api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT'
                                                                                
  Two problems:
                                                                                
  Scope: api means this dependency is transitive — any app that depends on      
  android-sdk-framework will need to resolve eppo-sdk-framework:0.1.0-SNAPSHOT
  at compile time. SNAPSHOT artifacts are only available from a snapshot        
  repository (typically Sonatype OSSRH snapshots), which most consumers don't
  have configured. This is what caused Issue #78 when
  sdk-common-jvm:2.0.0-SNAPSHOT leaked to consumers in v3.2.0.

  Timing: If this gets published to Maven Central as-is (even accidentally, or  
  as a pre-release), consumers' builds will fail with a dependency resolution
  error. The 0.1.0-SNAPSHOT needs to be promoted to a release version before    
  this module is published — but there's no build guard preventing a premature
  publish. The shouldPublish flag in the Gradle config is a manual check, not an
   automated one.

  Suggestion: add a build-time check that fails the publish task if any         
  api/implementation dependency contains SNAPSHOT.

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.

3 participants