Skip to content

Commit e3fc4df

Browse files
authored
feat: Async configuration handling using CompletableFutures and other config refactoring (#41)
* move Configuration to api package * IConfigurationStore interface * Refactor http client and requestor to use completablefuture * refactor http client, requestor, base client to use futures * pass config store to client * version bump * Configuration API change * InitialConfig Future, no clobber
1 parent 575d8c7 commit e3fc4df

13 files changed

+458
-154
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group = 'cloud.eppo'
9-
version = '3.1.0-SNAPSHOT'
9+
version = '3.2.0-SNAPSHOT'
1010
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")
1111

1212
java {

src/main/java/cloud/eppo/BaseEppoClient.java

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
import com.fasterxml.jackson.databind.ObjectMapper;
1515
import java.util.HashMap;
1616
import java.util.Map;
17+
import java.util.concurrent.CompletableFuture;
18+
import org.jetbrains.annotations.NotNull;
19+
import org.jetbrains.annotations.Nullable;
1720
import org.slf4j.Logger;
1821
import org.slf4j.LoggerFactory;
1922

@@ -26,7 +29,7 @@ public class BaseEppoClient {
2629
protected static final String DEFAULT_HOST = "https://fscdn.eppo.cloud";
2730
protected final ConfigurationRequestor requestor;
2831

29-
private final ConfigurationStore configurationStore;
32+
private final IConfigurationStore configurationStore;
3033
private final AssignmentLogger assignmentLogger;
3134
private final BanditLogger banditLogger;
3235
private final String sdkName;
@@ -39,36 +42,17 @@ public class BaseEppoClient {
3942
private static EppoHttpClient httpClientOverride = null;
4043

4144
protected BaseEppoClient(
42-
String apiKey,
43-
String sdkName,
44-
String sdkVersion,
45-
String host,
46-
AssignmentLogger assignmentLogger,
47-
BanditLogger banditLogger,
48-
boolean isGracefulMode,
49-
boolean expectObfuscatedConfig) {
50-
this(
51-
apiKey,
52-
sdkName,
53-
sdkVersion,
54-
host,
55-
assignmentLogger,
56-
banditLogger,
57-
isGracefulMode,
58-
expectObfuscatedConfig,
59-
null);
60-
}
61-
62-
protected BaseEppoClient(
63-
String apiKey,
64-
String sdkName,
65-
String sdkVersion,
66-
String host,
67-
AssignmentLogger assignmentLogger,
68-
BanditLogger banditLogger,
45+
@NotNull String apiKey,
46+
@NotNull String sdkName,
47+
@NotNull String sdkVersion,
48+
@Nullable String host,
49+
@Nullable AssignmentLogger assignmentLogger,
50+
@Nullable BanditLogger banditLogger,
51+
@Nullable IConfigurationStore configurationStore,
6952
boolean isGracefulMode,
7053
boolean expectObfuscatedConfig,
71-
Configuration initialConfiguration) {
54+
boolean supportBandits,
55+
@Nullable CompletableFuture<Configuration> initialConfiguration) {
7256

7357
if (apiKey == null) {
7458
throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key");
@@ -82,39 +66,39 @@ protected BaseEppoClient(
8266
}
8367

8468
EppoHttpClient httpClient = buildHttpClient(host, apiKey, sdkName, sdkVersion);
85-
this.configurationStore = new ConfigurationStore(initialConfiguration);
69+
this.configurationStore =
70+
configurationStore != null ? configurationStore : new ConfigurationStore();
8671

8772
// For now, the configuration is only obfuscated for Android clients
88-
requestor = new ConfigurationRequestor(configurationStore, httpClient, expectObfuscatedConfig);
73+
requestor =
74+
new ConfigurationRequestor(
75+
this.configurationStore, httpClient, expectObfuscatedConfig, supportBandits);
76+
if (initialConfiguration != null) {
77+
requestor.setInitialConfiguration(initialConfiguration);
78+
}
79+
8980
this.assignmentLogger = assignmentLogger;
9081
this.banditLogger = banditLogger;
9182
this.isGracefulMode = isGracefulMode;
9283
// Save SDK name and version to include in logger metadata
9384
this.sdkName = sdkName;
9485
this.sdkVersion = sdkVersion;
95-
96-
// TODO: caching initialization (such as setting an API-key-specific prefix
97-
// will probably involve passing in configurationStore to the constructor
9886
}
9987

10088
private EppoHttpClient buildHttpClient(
10189
String host, String apiKey, String sdkName, String sdkVersion) {
102-
EppoHttpClient httpClient;
103-
if (httpClientOverride != null) {
104-
// Test/Debug - Client is mocked entirely
105-
httpClient = httpClientOverride;
106-
} else {
107-
// Normal operation
108-
httpClient = new EppoHttpClient(host, apiKey, sdkName, sdkVersion);
109-
}
110-
return httpClient;
90+
return httpClientOverride != null
91+
? httpClientOverride
92+
: new EppoHttpClient(host, apiKey, sdkName, sdkVersion);
11193
}
11294

11395
protected void loadConfiguration() {
114-
requestor.load();
96+
requestor.fetchAndSaveFromRemote();
11597
}
11698

117-
// TODO: async way to refresh for android
99+
protected CompletableFuture<Void> loadConfigurationAsync() {
100+
return requestor.fetchAndSaveFromRemoteAsync();
101+
}
118102

119103
protected EppoValue getTypedAssignment(
120104
String flagKey,
Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,143 @@
11
package cloud.eppo;
22

3-
import java.io.IOException;
4-
import okhttp3.Response;
3+
import cloud.eppo.api.Configuration;
4+
import java.util.concurrent.CompletableFuture;
5+
import java.util.concurrent.CompletionException;
6+
import java.util.concurrent.ExecutionException;
7+
import org.jetbrains.annotations.NotNull;
58
import org.slf4j.Logger;
69
import org.slf4j.LoggerFactory;
710

8-
// TODO: handle bandit stuff
911
public class ConfigurationRequestor {
1012
private static final Logger log = LoggerFactory.getLogger(ConfigurationRequestor.class);
13+
private static final String FLAG_CONFIG_PATH = "/api/flag-config/v1/config";
14+
private static final String BANDIT_PARAMETER_PATH = "/api/flag-config/v1/bandits";
1115

1216
private final EppoHttpClient client;
13-
private final ConfigurationStore configurationStore;
17+
private final IConfigurationStore configurationStore;
1418
private final boolean expectObfuscatedConfig;
19+
private final boolean supportBandits;
20+
21+
private CompletableFuture<Void> remoteFetchFuture = null;
22+
private CompletableFuture<Void> configurationFuture = null;
23+
private boolean initialConfigSet = false;
1524

1625
public ConfigurationRequestor(
17-
ConfigurationStore configurationStore,
18-
EppoHttpClient client,
19-
boolean expectObfuscatedConfig) {
26+
@NotNull IConfigurationStore configurationStore,
27+
@NotNull EppoHttpClient client,
28+
boolean expectObfuscatedConfig,
29+
boolean supportBandits) {
2030
this.configurationStore = configurationStore;
2131
this.client = client;
2232
this.expectObfuscatedConfig = expectObfuscatedConfig;
33+
this.supportBandits = supportBandits;
2334
}
2435

25-
// TODO: async loading for android
26-
public void load() {
27-
// Grab hold of the last configuration in case its bandit models are useful
28-
Configuration lastConfig = configurationStore.getConfiguration();
36+
// Synchronously set the initial configuration.
37+
public void setInitialConfiguration(@NotNull Configuration configuration) {
38+
if (initialConfigSet || this.configurationFuture != null) {
39+
throw new IllegalStateException("Initial configuration has already been set");
40+
}
41+
42+
try {
43+
configurationStore.saveConfiguration(configuration).join();
44+
initialConfigSet = true;
45+
} catch (CompletionException e) {
46+
log.error("Error setting initial configuration", e);
47+
}
48+
}
49+
50+
// Asynchronously sets the initial configuration.
51+
public CompletableFuture<Void> setInitialConfiguration(
52+
@NotNull CompletableFuture<Configuration> configurationFuture) {
53+
if (initialConfigSet || this.configurationFuture != null) {
54+
throw new IllegalStateException("Configuration future has already been set");
55+
}
56+
this.configurationFuture =
57+
configurationFuture.thenAccept(
58+
(config) -> {
59+
synchronized (configurationStore) {
60+
if (config == null) {
61+
log.debug("Initial configuration future returned null");
62+
} else if (remoteFetchFuture != null
63+
&& remoteFetchFuture.isDone()
64+
&& !remoteFetchFuture.isCompletedExceptionally()) {
65+
// Don't clobber a successful fetch.
66+
log.debug("Fetch successfully beat the initial config; not clobbering");
67+
} else {
68+
log.debug("saving initial configuration");
69+
configurationStore.saveConfiguration(config);
70+
initialConfigSet = true;
71+
}
72+
}
73+
});
74+
return this.configurationFuture;
75+
}
2976

77+
/** Loads configuration synchronously from the API server. */
78+
void fetchAndSaveFromRemote() {
3079
log.debug("Fetching configuration");
31-
byte[] flagConfigurationJsonBytes = requestBody("/api/flag-config/v1/config");
80+
81+
// Reuse the `lastConfig` as its bandits may be useful
82+
Configuration lastConfig = configurationStore.getConfiguration();
83+
84+
byte[] flagConfigurationJsonBytes = client.get(FLAG_CONFIG_PATH);
3285
Configuration.Builder configBuilder =
33-
new Configuration.Builder(flagConfigurationJsonBytes, expectObfuscatedConfig)
86+
Configuration.builder(flagConfigurationJsonBytes, expectObfuscatedConfig)
3487
.banditParametersFromConfig(lastConfig);
3588

36-
if (configBuilder.requiresBanditModels()) {
37-
byte[] banditParametersJsonBytes = requestBody("/api/flag-config/v1/bandits");
89+
if (supportBandits && configBuilder.requiresUpdatedBanditModels()) {
90+
byte[] banditParametersJsonBytes = client.get(BANDIT_PARAMETER_PATH);
3891
configBuilder.banditParameters(banditParametersJsonBytes);
3992
}
4093

41-
configurationStore.setConfiguration(configBuilder.build());
94+
configurationStore.saveConfiguration(configBuilder.build());
4295
}
4396

44-
private byte[] requestBody(String route) {
45-
Response response = client.get(route);
46-
if (!response.isSuccessful() || response.body() == null) {
47-
throw new RuntimeException("Failed to fetch from " + route);
48-
}
49-
try {
50-
return response.body().bytes();
51-
} catch (IOException e) {
52-
throw new RuntimeException(e);
97+
/** Loads configuration asynchronously from the API server, off-thread. */
98+
CompletableFuture<Void> fetchAndSaveFromRemoteAsync() {
99+
log.debug("Fetching configuration from API server");
100+
final Configuration lastConfig = configurationStore.getConfiguration();
101+
102+
if (remoteFetchFuture != null && !remoteFetchFuture.isDone()) {
103+
log.debug("Remote fetch is active. Cancelling and restarting");
104+
remoteFetchFuture.cancel(true);
105+
remoteFetchFuture = null;
53106
}
107+
108+
remoteFetchFuture =
109+
client
110+
.getAsync(FLAG_CONFIG_PATH)
111+
.thenApply(
112+
flagConfigJsonBytes -> {
113+
synchronized (this) {
114+
Configuration.Builder configBuilder =
115+
Configuration.builder(flagConfigJsonBytes, expectObfuscatedConfig)
116+
.banditParametersFromConfig(
117+
lastConfig); // possibly reuse last bandit models loaded.
118+
119+
if (supportBandits && configBuilder.requiresUpdatedBanditModels()) {
120+
byte[] banditParametersJsonBytes;
121+
try {
122+
banditParametersJsonBytes = client.getAsync(BANDIT_PARAMETER_PATH).get();
123+
} catch (InterruptedException | ExecutionException e) {
124+
log.error("Error fetching from remote: " + e.getMessage());
125+
throw new RuntimeException(e);
126+
}
127+
if (banditParametersJsonBytes != null) {
128+
configBuilder.banditParameters(banditParametersJsonBytes);
129+
}
130+
}
131+
return configBuilder.build();
132+
}
133+
})
134+
.thenApply(
135+
configuration -> {
136+
synchronized (configurationStore) {
137+
configurationStore.saveConfiguration(configuration);
138+
}
139+
return null;
140+
});
141+
return remoteFetchFuture;
54142
}
55143
}

src/main/java/cloud/eppo/ConfigurationStore.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
package cloud.eppo;
22

3+
import cloud.eppo.api.Configuration;
4+
import java.util.concurrent.CompletableFuture;
35
import org.jetbrains.annotations.NotNull;
46

5-
public class ConfigurationStore {
7+
/** Memory-only configuration store. */
8+
public class ConfigurationStore implements IConfigurationStore {
69

710
private volatile Configuration configuration;
811

9-
public ConfigurationStore(final Configuration initialConfiguration) {
10-
if (initialConfiguration != null) {
11-
configuration = initialConfiguration;
12-
} else {
13-
configuration = Configuration.emptyConfig();
14-
}
12+
public ConfigurationStore() {
13+
configuration = null;
1514
}
1615

17-
public void setConfiguration(@NotNull final Configuration configuration) {
16+
public CompletableFuture<Void> saveConfiguration(@NotNull final Configuration configuration) {
1817
this.configuration = configuration;
18+
return CompletableFuture.completedFuture(null);
1919
}
2020

2121
public Configuration getConfiguration() {

0 commit comments

Comments
 (0)