Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better cache initialization #48

Merged
merged 7 commits into from
May 16, 2024
Merged

Conversation

aarsilv
Copy link
Contributor

@aarsilv aarsilv commented May 15, 2024

Eppo Internal:
🎟️ Ticket: FF-2092 - Cache should not succeed unless loaded flags & SDK key specific
🗨️ Slack Convo: "...There should be no race between SDK initialization and assignment..."

Description

A user noticed an apparent race condition between initialization and assignment.
What is happening is that the client thinks it successfully populated from a cached configuration, but that configuration is empty. It fires off the completion callback before fetching an updated configuration. Then getAssignment() is called with the empty configuration before the correct one is fetched.

One way this could happen is if an empty configuration is fetched and cached for one environment, and then the API key is changed to hit a different environment that has flags.

Fix

To fix this, we require at least one flag to be present to consider loading from cache "successful".

Also, we make caching per-API key so that when switching between environments caches for the other environments are not used.

Additional Step: Removing use of Shared Preferences (for now)

The Android SDK uses two layers of caching: the filesystem and Shared Preferences. The former is done asynchronously, so the latter was used as a backstop for a very fast assignment request right after initialization. This is a valid but unusual use case, so we've opted to remove the use of Shared Preferences for now, and we can fold it in later if needed.

private void deleteCacheFiles() {
deleteFileIfExists(CACHE_FILE_NAME);
ConfigCacheFile cacheFile = new ConfigCacheFile(ApplicationProvider.getApplicationContext(), DUMMY_API_KEY);
cacheFile.delete();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can just leverage the delete functionality of the ConfigCacheFile class, which was doing the same thing.

SharedPreferences sharedPreferences = ApplicationProvider.getApplicationContext().getSharedPreferences("eppo", Context.MODE_PRIVATE);
sharedPreferences.edit().clear().commit();
}

private void initClient(String host, boolean throwOnCallackError, boolean shouldDeleteCacheFiles, boolean isGracefulMode) {
private void initClient(String host, boolean throwOnCallackError, boolean shouldDeleteCacheFiles, boolean isGracefulMode, String apiKey) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Our test harness now accepts an API key as well

@@ -263,13 +260,13 @@ private void runTestCases() {
@Test
public void testCachedAssignments() {
// First initialize successfully
initClient(TEST_HOST, false, true, false); // ensure cache is populated
initClient(TEST_HOST, true, true, false, DUMMY_API_KEY); // ensure cache is populated
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This second argument is whether or not to rethrow errors, which we almost always want to be true unless we are testing graceful mode.

waitForNonNullAssignment();
String assignment = EppoClient.getInstance().getStringAssignment("6255e1a7fc33a9c050ce9508", "randomization_algo");
assertEquals("control", assignment);
String assignment = EppoClient.getInstance().getStringAssignment("6255e1a7d1a3025a26078b95", "randomization_algo");
Copy link
Contributor Author

Choose a reason for hiding this comment

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

no need to wait as the completed callback should not be called until fetch succeeds

throw new RuntimeException(e);
}
ConfigCacheFile cacheFile = new ConfigCacheFile(ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_API_KEY));
cacheFile.setContents("{ invalid }");
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we want invalid JSON for this test as the empty object case is much more similar to the newly-added no flags test case

}

@Test
public void testEmptyFlagsResponseRequiresFetch() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Test that reproduced the valid-but-empty cache issue

}

@Test
public void testDifferentCacheFilesPerKey() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Test that reproduced the switching environments issue

Comment on lines +452 to +462
" \"variations\": [\n" +
" {\n" +
" \"name\": \"on\",\n" +
" \"value\": \"true\",\n" +
" \"typedValue\": true,\n" +
" \"shardRange\": {\n" +
" \"start\": 0,\n" +
" \"end\": 10000\n" +
" }\n" +
" }" +
" ]\n" +
Copy link
Contributor Author

Choose a reason for hiding this comment

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

always assign true

/**
* Useful for passing in as a writer for gson serialization
*/
public BufferedWriter getWriter() throws IOException {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Switching to buffered writers and readers, which play nicer with larger files.

}

public static SharedPreferences getSharedPrefs(Context context, String keySuffix) {
return context.getSharedPreferences("eppo-"+keySuffix, Context.MODE_PRIVATE);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

isolate shared preferences by API key

Comment on lines 20 to 21
public static String cacheFileName(String keySuffix) {
return "eppo-sdk-config-v3-" + keySuffix + ".json";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Isolate cache files by API key

public static String safeCacheKey(String key) {
// Take the first eight characters to avoid the key being sensitive information
// Remove non-alphanumeric characters so it plays nice with filesystem
return key.substring(0,8).replaceAll("\\W", "");
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: we use keySuffix when referencing the value returned from this method, but this is returning the prefix

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah yeah this is confusing keySuffix refers to us adding a suffix onto the cache key (filename) but we use the prefix of the API key. Since we're removing shared preferences I think I can just rename this to the more descriptive cacheFileNameSuffix

@aarsilv aarsilv merged commit d5b768c into main May 16, 2024
1 check passed
@aarsilv aarsilv deleted the aaron/ff-2092/better-cache-init branch May 16, 2024 19:03
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.

None yet

3 participants