Skip to content

Commit

Permalink
Better cache initialization (#48)
Browse files Browse the repository at this point in the history
* empty flag test case created and handled

* cache per API key

* additional logging

* expose featureFlag and allocation in Assignment

* remove use of shared preferences (for now)

* more descriptive name for cache file name suffix

* bump version
  • Loading branch information
aarsilv committed May 16, 2024
1 parent edc28d8 commit d5b768c
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 132 deletions.
2 changes: 1 addition & 1 deletion eppo/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins {
}

group = "cloud.eppo"
version = "1.0.8"
version = "1.0.9"

android {
compileSdk 33
Expand Down
143 changes: 98 additions & 45 deletions eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package cloud.eppo.android;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;

import static cloud.eppo.android.ConfigCacheFile.CACHE_FILE_NAME;
import static cloud.eppo.android.ConfigCacheFile.cacheFileName;
import static cloud.eppo.android.util.Utils.logTag;
import static cloud.eppo.android.util.Utils.safeCacheKey;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.AssetManager;
import android.util.Log;

Expand Down Expand Up @@ -52,6 +52,8 @@

public class EppoClientTest {
private static final String TAG = logTag(EppoClient.class);
private static final String DUMMY_API_KEY = "mock-api-key";
private static final String DUMMY_OTHER_API_KEY = "another-mock-api-key";
private static final String TEST_HOST = "https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile";
private static final String INVALID_HOST = "https://thisisabaddomainforthistest.com";
private Gson gson = new GsonBuilder()
Expand Down Expand Up @@ -124,29 +126,16 @@ static class AssignmentTestCase {
List<String> expectedAssignments;
}

private void deleteFileIfExists(String fileName) {
File file = new File(ApplicationProvider.getApplicationContext().getFilesDir(), fileName);
if (file.exists()) {
file.delete();
}
}

private void deleteCacheFiles() {
deleteFileIfExists(CACHE_FILE_NAME);
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) {
if (shouldDeleteCacheFiles) {
deleteCacheFiles();
clearCacheFile(apiKey);
}

CountDownLatch lock = new CountDownLatch(1);

new EppoClient.Builder()
.application(ApplicationProvider.getApplicationContext())
.apiKey("mock-api-key")
.apiKey(apiKey)
.isGracefulMode(isGracefulMode)
.host(host)
.callback(new InitializationCallback() {
Expand Down Expand Up @@ -178,19 +167,27 @@ public void onError(String errorMessage) {
}

@After
public void teardown() {
deleteCacheFiles();
public void clearCaches() {
String[] apiKeys = { DUMMY_API_KEY, DUMMY_OTHER_API_KEY };
for (String apiKey : apiKeys) {
clearCacheFile(apiKey);
}
}

private void clearCacheFile(String apiKey) {
ConfigCacheFile cacheFile = new ConfigCacheFile(ApplicationProvider.getApplicationContext(), apiKey);
cacheFile.delete();
}

@Test
public void testAssignments() {
initClient(TEST_HOST, true, true, false);
initClient(TEST_HOST, true, true, false, DUMMY_API_KEY);
runTestCases();
}

@Test
public void testErrorGracefulModeOn() {
initClient(TEST_HOST, false, true, true);
initClient(TEST_HOST, false, true, true, DUMMY_API_KEY);

EppoClient realClient = EppoClient.getInstance();
EppoClient spyClient = spy(realClient);
Expand Down Expand Up @@ -219,7 +216,7 @@ public void testErrorGracefulModeOn() {

@Test
public void testErrorGracefulModeOff() {
initClient(TEST_HOST, false, true, false);
initClient(TEST_HOST, false, true, false, DUMMY_API_KEY);

EppoClient realClient = EppoClient.getInstance();
EppoClient spyClient = spy(realClient);
Expand Down Expand Up @@ -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

// wait for a bit since cache file is loaded asynchronously
waitForPopulatedCache();

// Then reinitialize with a bad host so we know it's using the cached RAC built from the first initialization
initClient(INVALID_HOST, false, false, false); // invalid port to force to use cache
initClient(INVALID_HOST, true, false, false, DUMMY_API_KEY); // invalid port to force to use cache

runTestCases();
}
Expand Down Expand Up @@ -382,7 +379,7 @@ public void testInvalidConfigJSON() {
httpClientOverrideField.set(null, mockHttpClient);


initClient(TEST_HOST, true, true, false);
initClient(TEST_HOST, true, true, false, DUMMY_API_KEY);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
} finally {
Expand All @@ -401,34 +398,90 @@ public void testInvalidConfigJSON() {
}

@Test
public void testCachedBadResponseAllowsLaterFetching() {
public void testCachedBadResponseRequiresFetch() {
// Populate the cache with a bad response
ConfigCacheFile cacheFile = new ConfigCacheFile(ApplicationProvider.getApplicationContext());
cacheFile.delete();
try {
cacheFile.getOutputWriter().write("{}");
cacheFile.getOutputWriter().close();
} catch (IOException e) {
throw new RuntimeException(e);
}
ConfigCacheFile cacheFile = new ConfigCacheFile(ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_API_KEY));
cacheFile.setContents("{ invalid }");

initClient(TEST_HOST, false, false, false);
initClient(TEST_HOST, true, false, false, DUMMY_API_KEY);

String result = EppoClient.getInstance().getStringAssignment("dummy subject", "dummy flag");
assertNull(result);
// Failure callback will have fired from cache read error, but configuration request will still be fired off on init
// Wait for the configuration request to load the configuration
waitForNonNullAssignment();
String assignment = EppoClient.getInstance().getStringAssignment("6255e1a7fc33a9c050ce9508", "randomization_algo");
assertEquals("control", assignment);
String assignment = EppoClient.getInstance().getStringAssignment("6255e1a7d1a3025a26078b95", "randomization_algo");
assertEquals("green", assignment);
}

@Test
public void testEmptyFlagsResponseRequiresFetch() {
// Populate the cache with a bad response
ConfigCacheFile cacheFile = new ConfigCacheFile(ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_API_KEY));
cacheFile.setContents("{\"flags\": {}}");

initClient(TEST_HOST, true, false, false, DUMMY_API_KEY);
String assignment = EppoClient.getInstance().getStringAssignment("6255e1a7d1a3025a26078b95", "randomization_algo");
assertEquals("green", assignment);
}

@Test
public void testDifferentCacheFilesPerKey() {
initClient(TEST_HOST, true, true, false, DUMMY_API_KEY);
// API Key 1 will fetch and then populate its cache with the usual test data
Boolean apiKey1Assignment = EppoClient.getInstance().getBooleanAssignment("subject-2", "experiment_with_boolean_variations");
assertFalse(apiKey1Assignment);

// Pre-seed a different flag configuration for the other API Key
ConfigCacheFile cacheFile2 = new ConfigCacheFile(ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_OTHER_API_KEY));
cacheFile2.setContents("{\n" +
" \"flags\": {\n" +
" \"8fc1fb33379d78c8a9edbf43afd6703a\": {\n" +
" \"subjectShards\": 10000,\n" +
" \"enabled\": true,\n" +
" \"rules\": [\n" +
" {\n" +
" \"allocationKey\": \"mock-allocation\",\n" +
" \"conditions\": []\n" +
" }\n" +
" ],\n" +
" \"allocations\": {\n" +
" \"mock-allocation\": {\n" +
" \"percentExposure\": 1,\n" +
" \"statusQuoVariationKey\": null,\n" +
" \"shippedVariationKey\": null,\n" +
" \"holdouts\": [],\n" +
" \"variations\": [\n" +
" {\n" +
" \"name\": \"on\",\n" +
" \"value\": \"true\",\n" +
" \"typedValue\": true,\n" +
" \"shardRange\": {\n" +
" \"start\": 0,\n" +
" \"end\": 10000\n" +
" }\n" +
" }" +
" ]\n" +
" }\n" +
" }\n" +
" }\n" +
" }\n" +
"}");

initClient(TEST_HOST, true, false, false, DUMMY_OTHER_API_KEY);

// Ensure API key 2 uses its cache
Boolean apiKey2Assignment = EppoClient.getInstance().getBooleanAssignment("subject-2", "experiment_with_boolean_variations");
assertTrue(apiKey2Assignment);

// Reinitialize API key 1 to be sure it used its cache
initClient(TEST_HOST, true, false, false, DUMMY_API_KEY);
// API Key 1 will fetch and then populate its cache with the usual test data
apiKey1Assignment = EppoClient.getInstance().getBooleanAssignment("subject-2", "experiment_with_boolean_variations");
assertFalse(apiKey1Assignment);
}

private void waitForPopulatedCache() {
long waitStart = System.currentTimeMillis();
long waitEnd = waitStart + 10 * 1000; // allow up to 10 seconds
boolean cachePopulated = false;
try {
File file = new File(ApplicationProvider.getApplicationContext().getFilesDir(), CACHE_FILE_NAME);
File file = new File(ApplicationProvider.getApplicationContext().getFilesDir(), cacheFileName(safeCacheKey(DUMMY_API_KEY)));
while (!cachePopulated) {
if (System.currentTimeMillis() > waitEnd) {
throw new InterruptedException("Cache file never populated; assuming configuration error");
Expand Down
50 changes: 35 additions & 15 deletions eppo/src/main/java/cloud/eppo/android/ConfigCacheFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@

import android.app.Application;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

public class ConfigCacheFile {
static final String CACHE_FILE_NAME = "eppo-sdk-config-v2.json";
private final File filesDir;
private final File cacheFile;

public ConfigCacheFile(Application application) {
filesDir = application.getFilesDir();
cacheFile = new File(filesDir, CACHE_FILE_NAME);
public ConfigCacheFile(Application application, String fileNameSuffix) {
File filesDir = application.getFilesDir();
cacheFile = new File(filesDir, cacheFileName(fileNameSuffix));
}

public static String cacheFileName(String suffix) {
return "eppo-sdk-config-v3-" + suffix + ".json";
}

public boolean exists() {
Expand All @@ -29,13 +31,31 @@ public void delete() {
}
}

public OutputStreamWriter getOutputWriter() throws IOException {
FileOutputStream fos = new FileOutputStream(cacheFile);
return new OutputStreamWriter(fos);
/**
* Useful for passing in as a writer for gson serialization
*/
public BufferedWriter getWriter() throws IOException {
return new BufferedWriter(new FileWriter(cacheFile));
}

public InputStreamReader getInputReader() throws IOException {
FileInputStream fis = new FileInputStream(cacheFile);
return new InputStreamReader(fis);
/**
* Useful for passing in as a reader for gson deserialization
*/
public BufferedReader getReader() throws IOException {
return new BufferedReader(new FileReader(cacheFile));
}

/**
* Useful for mocking caches in automated tests
*/
public void setContents(String contents) {
delete();
try {
BufferedWriter writer = getWriter();
writer.write(contents);
writer.close();
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ public void onCacheLoadFail() {
@Override
public void onSuccess(Reader response) {
try {
configurationStore.setFlags(response);
configurationStore.setFlagsFromResponse(response);
Log.d(TAG, "Configuration fetch successful");
} catch (JsonSyntaxException | JsonIOException e) {
Log.e(TAG, "Error loading configuration response", e);
if (callback != null && !cachedUsed.get()) {
Expand All @@ -60,7 +61,7 @@ public void onSuccess(Reader response) {

@Override
public void onFailure(String errorMessage) {
Log.e(TAG, errorMessage);
Log.e(TAG, "Error fetching configuration: " + errorMessage);
if (callback != null && !cachedUsed.get()) {
callback.onError(errorMessage);
}
Expand Down

0 comments on commit d5b768c

Please sign in to comment.