Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ gitDataDir := ${tempDir}/sdk-test-data
branchName := main
githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git
.PHONY: test-data
test-data:
test-data:
rm -rf $(testDataDir)
mkdir -p $(tempDir)
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
cp ${gitDataDir}/ufc/flags-v1.json ${testDataDir}
cp ${gitDataDir}/ufc/flags-v1-obfuscated.json ${testDataDir}
cp -r ${gitDataDir}/ufc/tests ${testDataDir}
cp ${gitDataDir}/configuration-wire/precomputed-v1.json ${testDataDir}
cp ${gitDataDir}/configuration-wire/precomputed-v1-deobfuscated.json ${testDataDir}
rm -rf ${tempDir}

## test
Expand Down
15 changes: 15 additions & 0 deletions eppo/src/main/java/cloud/eppo/android/PrecomputedCacheFile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package cloud.eppo.android;

import android.app.Application;

/** Disk cache file for precomputed configuration. */
public class PrecomputedCacheFile extends BaseCacheFile {

public PrecomputedCacheFile(Application application, String fileNameSuffix) {
super(application, cacheFileName(fileNameSuffix));
}

public static String cacheFileName(String suffix) {
return "eppo-sdk-precomputed-" + suffix + ".json";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cloud.eppo.android;

import static cloud.eppo.android.util.Utils.logTag;

import android.app.Application;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import cloud.eppo.android.dto.PrecomputedBandit;
import cloud.eppo.android.dto.PrecomputedConfigurationResponse;
import cloud.eppo.android.dto.PrecomputedFlag;
import cloud.eppo.android.util.Utils;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

/** Storage for precomputed flags/bandits with disk caching. */
public class PrecomputedConfigurationStore {

private static final String TAG = logTag(PrecomputedConfigurationStore.class);
private final PrecomputedCacheFile cacheFile;
private final Object cacheLock = new Object();

private volatile PrecomputedConfigurationResponse configuration =
PrecomputedConfigurationResponse.empty();
private final Object cacheLoadLock = new Object();
private CompletableFuture<PrecomputedConfigurationResponse> cacheLoadFuture = null;

public PrecomputedConfigurationStore(Application application, String cacheFileNameSuffix) {
cacheFile = new PrecomputedCacheFile(application, cacheFileNameSuffix);
}

/** Returns the current configuration. */
@NonNull public PrecomputedConfigurationResponse getConfiguration() {
return configuration;
}

/** Returns the salt from the current configuration, or null if not set. */
@Nullable public String getSalt() {
String salt = configuration.getSalt();
return (salt != null && !salt.isEmpty()) ? salt : null;
}

/** Returns the format from the current configuration, or null if not set. */
@Nullable public String getFormat() {
String format = configuration.getFormat();
return (format != null && !format.isEmpty()) ? format : null;
}

/** Returns a flag by its MD5-hashed key, or null if not found. */
@Nullable public PrecomputedFlag getFlag(String hashedKey) {
return configuration.getFlags().get(hashedKey);
}

/** Returns a bandit by its MD5-hashed key, or null if not found. */
@Nullable public PrecomputedBandit getBandit(String hashedKey) {
return configuration.getBandits().get(hashedKey);
}

/** Returns the flags map. */
@NonNull public Map<String, PrecomputedFlag> getFlags() {
return configuration.getFlags();
}

/** Returns the bandits map. */
@NonNull public Map<String, PrecomputedBandit> getBandits() {
return configuration.getBandits();
}

/** Updates the configuration with a new response. */
public void setConfiguration(@NonNull PrecomputedConfigurationResponse newConfiguration) {
this.configuration = newConfiguration;
}

/** Loads configuration from the cache file asynchronously. */
public CompletableFuture<PrecomputedConfigurationResponse> loadConfigFromCache() {
synchronized (cacheLoadLock) {
if (cacheLoadFuture != null) {
return cacheLoadFuture;
}
if (!cacheFile.exists()) {
Log.d(TAG, "Not loading from cache (file does not exist)");
return CompletableFuture.completedFuture(null);
}
return cacheLoadFuture =
CompletableFuture.supplyAsync(

Choose a reason for hiding this comment

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

Nothing to fix here, but just noting that there's a race condition due to a sync check and async read. You've handled it by ensuring that exceptions are caught so it's not dangerous.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yea absolutely thanks for mentioning; I'm being kind of lazy and copying the same pattern as exists in the local-eval configuration store.

() -> {
Log.d(TAG, "Loading precomputed config from cache");
return readCacheFile();
});
}
}

/** Reads the cache file and returns the configuration, or null if reading fails. */
@Nullable protected PrecomputedConfigurationResponse readCacheFile() {
synchronized (cacheLock) {
try (InputStream inputStream = cacheFile.getInputStream()) {
Log.d(TAG, "Attempting to inflate precomputed config");
byte[] bytes = Utils.toByteArray(inputStream);
PrecomputedConfigurationResponse config = PrecomputedConfigurationResponse.fromBytes(bytes);
Log.d(TAG, "Precomputed cache load complete");
return config;
} catch (IOException e) {
Log.e(TAG, "Error loading precomputed config from the cache: " + e.getMessage());
return null;
}
}
}

/** Saves the configuration to the cache file asynchronously. */
public CompletableFuture<Void> saveConfiguration(
@NonNull PrecomputedConfigurationResponse newConfiguration) {
return CompletableFuture.supplyAsync(
() -> {
synchronized (cacheLock) {
// Always update in-memory configuration, even if disk write fails
this.configuration = newConfiguration;

Log.d(TAG, "Saving precomputed configuration to cache file");
try (OutputStream outputStream = cacheFile.getOutputStream()) {
outputStream.write(newConfiguration.toBytes());
Log.d(TAG, "Updated precomputed cache file");
} catch (IOException e) {
Log.e(TAG, "Unable to write precomputed config to file (in-memory updated)", e);
// Don't throw - in-memory config is already updated
}
return null;
}
});
}

/** Deletes the cache file. */
public void deleteCache() {
cacheFile.delete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

/** Wire protocol response from the precomputed edge endpoint. */
Expand All @@ -19,6 +18,10 @@ public class PrecomputedConfigurationResponse {

private static final ObjectMapper objectMapper = new ObjectMapper();

private static final PrecomputedConfigurationResponse EMPTY =
new PrecomputedConfigurationResponse(
"PRECOMPUTED", true, "", null, "", Collections.emptyMap(), Collections.emptyMap());

private final String format;
private final boolean obfuscated;
private final String createdAt;
Expand Down Expand Up @@ -85,9 +88,7 @@ public String getCreatedAt() {
if (environmentName == null) {
return null;
}
Map<String, String> env = new HashMap<>();
env.put("name", environmentName);
return env;
return Collections.singletonMap("name", environmentName);
}

/** Returns the salt used for MD5 hashing flag keys. */
Expand All @@ -105,10 +106,9 @@ public Map<String, PrecomputedBandit> getBandits() {
return bandits;
}

/** Creates an empty configuration response. */
/** Returns a singleton empty configuration response. */
public static PrecomputedConfigurationResponse empty() {
return new PrecomputedConfigurationResponse(
"PRECOMPUTED", true, "", null, "", Collections.emptyMap(), Collections.emptyMap());
return EMPTY;
}

/**
Expand Down
Loading
Loading