Skip to content

Commit 9b5ca97

Browse files
authored
feat: polling in BaseEppoClient (#82)
* Add Fetch Configuration Task to the common sdk * feat: allow polling in base eppo client * better java. better jitter * bump to v3.7.0
1 parent 388222d commit 9b5ca97

File tree

6 files changed

+246
-23
lines changed

6 files changed

+246
-23
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.6.1-SNAPSHOT'
9+
version = '3.7.0'
1010
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")
1111

1212
java {

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package cloud.eppo;
22

3+
import static cloud.eppo.Constants.DEFAULT_JITTER_INTERVAL_RATIO;
4+
import static cloud.eppo.Constants.DEFAULT_POLLING_INTERVAL_MILLIS;
35
import static cloud.eppo.Utils.throwIfEmptyOrNull;
46

57
import cloud.eppo.api.*;
@@ -15,6 +17,7 @@
1517
import com.fasterxml.jackson.databind.ObjectMapper;
1618
import java.util.HashMap;
1719
import java.util.Map;
20+
import java.util.Timer;
1821
import java.util.concurrent.CompletableFuture;
1922
import org.jetbrains.annotations.NotNull;
2023
import org.jetbrains.annotations.Nullable;
@@ -37,6 +40,7 @@ public class BaseEppoClient {
3740
private boolean isGracefulMode;
3841
private final IAssignmentCache assignmentCache;
3942
private final IAssignmentCache banditAssignmentCache;
43+
private Timer pollTimer;
4044

4145
@Nullable protected CompletableFuture<Boolean> getInitialConfigFuture() {
4246
return initialConfigFuture;
@@ -122,6 +126,55 @@ protected void loadConfiguration() {
122126
}
123127
}
124128

129+
protected void stopPolling() {
130+
if (pollTimer != null) {
131+
pollTimer.cancel();
132+
}
133+
}
134+
135+
/** Start polling using the default interval and jitter. */
136+
protected void startPolling() {
137+
startPolling(DEFAULT_POLLING_INTERVAL_MILLIS);
138+
}
139+
140+
/**
141+
* Start polling using the provided polling interval and default jitter of 10%
142+
*
143+
* @param pollingIntervalMs The base number of milliseconds to wait between configuration fetches.
144+
*/
145+
protected void startPolling(long pollingIntervalMs) {
146+
startPolling(pollingIntervalMs, pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO);
147+
}
148+
149+
/**
150+
* Start polling using the provided interval and jitter.
151+
*
152+
* @param pollingIntervalMs The base number of milliseconds to wait between configuration fetches.
153+
* @param pollingJitterMs The max number of milliseconds to offset each polling interval. The SDK
154+
* selects a random number between 0 and pollingJitterMS to offset the polling interval by.
155+
*/
156+
protected void startPolling(long pollingIntervalMs, long pollingJitterMs) {
157+
stopPolling();
158+
log.debug("Started polling at " + pollingIntervalMs + "," + pollingJitterMs);
159+
160+
// Set up polling for UFC
161+
pollTimer = new Timer(true);
162+
FetchConfigurationTask fetchConfigurationsTask =
163+
new FetchConfigurationTask(
164+
() -> {
165+
log.debug("[Eppo SDK] Polling callback");
166+
this.loadConfiguration();
167+
},
168+
pollTimer,
169+
pollingIntervalMs,
170+
pollingJitterMs);
171+
172+
// We don't want to fetch right away, so we schedule the next fetch.
173+
// Graceful mode is implicit here because `FetchConfigurationsTask` catches and
174+
// logs errors without rethrowing.
175+
fetchConfigurationsTask.scheduleNext();
176+
}
177+
125178
protected CompletableFuture<Void> loadConfigurationAsync() {
126179
CompletableFuture<Void> future = new CompletableFuture<>();
127180

src/main/java/cloud/eppo/Constants.java

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,19 @@
22

33
/** Constants Class */
44
public class Constants {
5-
/** Base URL */
5+
/** API Endpoint Settings */
6+
public static final String BANDIT_ENDPOINT = "/flag-config/v1/bandits";
7+
8+
public static final String FLAG_CONFIG_ENDPOINT = "/flag-config/v1/config";
69
public static final String DEFAULT_BASE_URL = "https://fscdn.eppo.cloud/api";
710

811
static String appendApiPathToHost(String host) {
912
return host + "/api";
1013
}
1114

12-
public static final int REQUEST_TIMEOUT_MILLIS = 1000;
13-
1415
/** Poller Settings */
1516
private static final long MILLISECOND_IN_ONE_SECOND = 1000;
1617

17-
public static final long TIME_INTERVAL_IN_MILLIS =
18-
30 * MILLISECOND_IN_ONE_SECOND; // time interval
19-
public static final long JITTER_INTERVAL_IN_MILLIS = 5 * MILLISECOND_IN_ONE_SECOND;
20-
21-
/** Cache Settings */
22-
public static final int MAX_CACHE_ENTRIES = 1000;
23-
24-
/** RAC settings */
25-
public static final String RAC_ENDPOINT = "/randomized_assignment/v3/config";
26-
27-
public static final String BANDIT_ENDPOINT = "/flag-config/v1/bandits";
28-
public static final String FLAG_CONFIG_ENDPOINT = "/flag-config/v1/config";
29-
30-
/** Caching Settings */
31-
public static final String EXPERIMENT_CONFIGURATION_CACHE_KEY = "experiment-configuration";
32-
33-
public static final String BANDIT_PARAMETER_CACHE_KEY = "bandit-parameter";
18+
public static final long DEFAULT_POLLING_INTERVAL_MILLIS = 30 * MILLISECOND_IN_ONE_SECOND;
19+
public static final long DEFAULT_JITTER_INTERVAL_RATIO = 10;
3420
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package cloud.eppo;
2+
3+
import java.util.Timer;
4+
import java.util.TimerTask;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
8+
public class FetchConfigurationTask extends TimerTask {
9+
private static final Logger log = LoggerFactory.getLogger(FetchConfigurationTask.class);
10+
private final Runnable runnable;
11+
private final Timer timer;
12+
private final long intervalInMillis;
13+
private final long jitterInMillis;
14+
private final long maxJitter;
15+
16+
public FetchConfigurationTask(
17+
Runnable runnable, Timer timer, long intervalInMillis, long jitterInMillis) {
18+
assert (jitterInMillis > 0);
19+
20+
this.runnable = runnable;
21+
this.timer = timer;
22+
this.intervalInMillis = intervalInMillis;
23+
this.maxJitter = intervalInMillis / 2;
24+
this.jitterInMillis = jitterInMillis;
25+
}
26+
27+
public void scheduleNext() {
28+
// Limit jitter to half the interval. Also, prevents user-provided jitter from under-running the
29+
// delay below 0.
30+
long jitter =
31+
Math.min(maxJitter, Math.round(Math.floor((Math.random() * this.jitterInMillis))));
32+
long delay = this.intervalInMillis - jitter;
33+
FetchConfigurationTask nextTask =
34+
new FetchConfigurationTask(runnable, timer, intervalInMillis, jitterInMillis);
35+
timer.schedule(nextTask, delay);
36+
}
37+
38+
@Override
39+
public void run() {
40+
// TODO: retry on failed fetches
41+
try {
42+
runnable.run();
43+
} catch (Exception e) {
44+
log.error("[Eppo SDK] Error fetching experiment configuration", e);
45+
}
46+
scheduleNext();
47+
}
48+
}

src/test/java/cloud/eppo/BaseEppoClientTest.java

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ public void testErrorGracefulModeOff() {
334334
@Test
335335
public void testInvalidConfigJSON() {
336336

337-
mockHttpResponse(TEST_BASE_URL, "{}");
337+
mockHttpResponse("{}");
338338

339339
initClient(false, false);
340340

@@ -569,4 +569,139 @@ public void testAssignmentLogErrorNonFatal() {
569569
ArgumentCaptor<Assignment> assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class);
570570
verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture());
571571
}
572+
573+
@Test
574+
public void testPolling() {
575+
EppoHttpClient httpClient = mockHttpResponse(BOOL_FLAG_CONFIG);
576+
577+
BaseEppoClient client =
578+
eppoClient =
579+
new BaseEppoClient(
580+
DUMMY_FLAG_API_KEY,
581+
"java",
582+
"100.1.0",
583+
null,
584+
TEST_BASE_URL,
585+
mockAssignmentLogger,
586+
null,
587+
null,
588+
false,
589+
false,
590+
true,
591+
null,
592+
null,
593+
null);
594+
595+
client.loadConfiguration();
596+
client.startPolling(20);
597+
598+
// Method will be called immediately on init
599+
verify(httpClient, times(1)).get(anyString());
600+
assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false));
601+
602+
// Sleep for 25 ms to allow another polling cycle to complete
603+
sleepUninterruptedly(25);
604+
605+
// Now, the method should have been called twice
606+
verify(httpClient, times(2)).get(anyString());
607+
608+
eppoClient.stopPolling();
609+
assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false));
610+
611+
sleepUninterruptedly(25);
612+
613+
// No more calls since stopped
614+
verify(httpClient, times(2)).get(anyString());
615+
616+
// Set up a different config to be served
617+
when(httpClient.get(anyString())).thenReturn(DISABLED_BOOL_FLAG_CONFIG.getBytes());
618+
client.startPolling(20);
619+
620+
// True until the next config is fetched.
621+
assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false));
622+
623+
sleepUninterruptedly(25);
624+
625+
assertFalse(eppoClient.getBooleanAssignment("bool_flag", "subject1", false));
626+
627+
eppoClient.stopPolling();
628+
}
629+
630+
@SuppressWarnings("SameParameterValue")
631+
private void sleepUninterruptedly(long sleepMs) {
632+
try {
633+
Thread.sleep(sleepMs);
634+
} catch (InterruptedException e) {
635+
throw new RuntimeException(e);
636+
}
637+
}
638+
639+
private static final String BOOL_FLAG_CONFIG =
640+
("{\n"
641+
+ " \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n"
642+
+ " \"format\": \"SERVER\",\n"
643+
+ " \"environment\": {\n"
644+
+ " \"name\": \"Test\"\n"
645+
+ " },\n"
646+
+ " \"flags\": {\n"
647+
+ " \"bool_flag\": {\n"
648+
+ " \"key\": \"bool_flag\",\n"
649+
+ " \"enabled\": true,\n"
650+
+ " \"variationType\": \"BOOLEAN\",\n"
651+
+ " \"variations\": {\n"
652+
+ " \"on\": {\n"
653+
+ " \"key\": \"on\",\n"
654+
+ " \"value\": true\n"
655+
+ " }\n"
656+
+ " },\n"
657+
+ " \"allocations\": [\n"
658+
+ " {\n"
659+
+ " \"key\": \"on\",\n"
660+
+ " \"doLog\": true,\n"
661+
+ " \"splits\": [\n"
662+
+ " {\n"
663+
+ " \"variationKey\": \"on\",\n"
664+
+ " \"shards\": []\n"
665+
+ " }\n"
666+
+ " ]\n"
667+
+ " }\n"
668+
+ " ],\n"
669+
+ " \"totalShards\": 10000\n"
670+
+ " }\n"
671+
+ " }\n"
672+
+ "}");
673+
private static final String DISABLED_BOOL_FLAG_CONFIG =
674+
("{\n"
675+
+ " \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n"
676+
+ " \"format\": \"SERVER\",\n"
677+
+ " \"environment\": {\n"
678+
+ " \"name\": \"Test\"\n"
679+
+ " },\n"
680+
+ " \"flags\": {\n"
681+
+ " \"bool_flag\": {\n"
682+
+ " \"key\": \"bool_flag\",\n"
683+
+ " \"enabled\": false,\n"
684+
+ " \"variationType\": \"BOOLEAN\",\n"
685+
+ " \"variations\": {\n"
686+
+ " \"on\": {\n"
687+
+ " \"key\": \"on\",\n"
688+
+ " \"value\": true\n"
689+
+ " }\n"
690+
+ " },\n"
691+
+ " \"allocations\": [\n"
692+
+ " {\n"
693+
+ " \"key\": \"on\",\n"
694+
+ " \"doLog\": true,\n"
695+
+ " \"splits\": [\n"
696+
+ " {\n"
697+
+ " \"variationKey\": \"on\",\n"
698+
+ " \"shards\": []\n"
699+
+ " }\n"
700+
+ " ]\n"
701+
+ " }\n"
702+
+ " ],\n"
703+
+ " \"totalShards\": 10000\n"
704+
+ " }\n"
705+
+ " }\n"
706+
+ "}");
572707
}

src/test/java/cloud/eppo/helpers/TestUtils.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
public class TestUtils {
1313

1414
@SuppressWarnings("SameParameterValue")
15-
public static void mockHttpResponse(String host, String responseBody) {
15+
public static EppoHttpClient mockHttpResponse(String responseBody) {
1616
// Create a mock instance of EppoHttpClient
1717
EppoHttpClient mockHttpClient = mock(EppoHttpClient.class);
1818

@@ -25,6 +25,7 @@ public static void mockHttpResponse(String host, String responseBody) {
2525
mockAsyncResponse.complete(responseBody.getBytes());
2626

2727
setBaseClientHttpClientOverrideField(mockHttpClient);
28+
return mockHttpClient;
2829
}
2930

3031
public static void mockHttpError() {

0 commit comments

Comments
 (0)