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

Readyhook rework #51

Merged
merged 7 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
version=9.1.2
version=9.2.0
23 changes: 23 additions & 0 deletions src/main/java/com/configcat/ClientCacheState.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.configcat;

/**
* Describes the Client state.
*/
public enum ClientCacheState {
/**
* The SDK has no feature flag data neither from the cache nor from the ConfigCat CDN.
*/
NO_FLAG_DATA,
/**
* The SDK runs with local only feature flag data.
*/
HAS_LOCAL_OVERRIDE_FLAG_DATA_ONLY,
/**
* The SDK has feature flag data to work with only from the cache.
*/
HAS_CACHED_FLAG_DATA_ONLY,
/**
* The SDK works with the latest feature flag data received from the ConfigCat CDN.
*/
HAS_UP_TO_DATE_FLAG_DATA,
}
20 changes: 16 additions & 4 deletions src/main/java/com/configcat/ConfigCatClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ private ConfigCatClient(String sdkKey, Options options) {
options.pollingMode.getPollingIdentifier());

this.configService = new ConfigService(sdkKey, fetcher, options.pollingMode, options.cache, logger, options.offline, options.configCatHooks);
} else {
configCatHooks.invokeOnClientReady(ClientCacheState.HAS_LOCAL_OVERRIDE_FLAG_DATA_ONLY);
}

this.defaultUser = options.defaultUser;
Expand Down Expand Up @@ -385,6 +387,14 @@ public ConfigCatHooks getHooks() {
return this.configCatHooks;
}


@Override
public CompletableFuture<ClientCacheState> waitForReadyAsync() {
CompletableFuture<ClientCacheState> completableFuture = new CompletableFuture<>();
getHooks().addOnClientReady((completableFuture::complete));
return completableFuture;
}

@Override
public void close() throws IOException {
if (!this.isClosed.compareAndSet(false, true)) {
Expand Down Expand Up @@ -616,18 +626,20 @@ public static ConfigCatClient get(final String sdkKey) {
* @return the ConfigCatClient instance.
*/
public static ConfigCatClient get(String sdkKey, Consumer<Options> optionsCallback) {
if (sdkKey == null || sdkKey.isEmpty()) {
throw new IllegalArgumentException("SDK Key cannot be null or empty.");
}
Options clientOptions = new Options();

Options clientOptions = new Options();
if (optionsCallback != null) {
Options options = new Options();
optionsCallback.accept(options);
clientOptions = options;
}

if (sdkKey == null || sdkKey.isEmpty()) {
clientOptions.configCatHooks.invokeOnClientReady(ClientCacheState.NO_FLAG_DATA);
throw new IllegalArgumentException("SDK Key cannot be null or empty.");
}
if (!OverrideBehaviour.LOCAL_ONLY.equals(clientOptions.overrideBehaviour) && !isValidKey(sdkKey, clientOptions.isBaseURLCustom())) {
clientOptions.configCatHooks.invokeOnClientReady(ClientCacheState.NO_FLAG_DATA);
throw new IllegalArgumentException("SDK Key '" + sdkKey + "' is invalid.");
}

Expand Down
32 changes: 31 additions & 1 deletion src/main/java/com/configcat/ConfigCatHooks.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;

public class ConfigCatHooks {
private final AtomicReference<ClientCacheState> clientCacheState = new AtomicReference<>(null);
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
private final List<Consumer<Map<String, Setting>>> onConfigChanged = new ArrayList<>();
private final List<Consumer<ClientCacheState>> onClientReadyWithState = new ArrayList<>();
private final List<Runnable> onClientReady = new ArrayList<>();
private final List<Consumer<EvaluationDetails<Object>>> onFlagEvaluated = new ArrayList<>();
private final List<Consumer<String>> onError = new ArrayList<>();
Expand All @@ -22,6 +25,29 @@ public class ConfigCatHooks {
*
* @param callback the method to call when the event fires.
*/
public void addOnClientReady(Consumer<ClientCacheState> callback) {
lock.writeLock().lock();
try {
if(clientCacheState.get() != null) {
callback.accept(clientCacheState.get());
} else {
this.onClientReadyWithState.add(callback);
}
} finally {
lock.writeLock().unlock();
}
}

/**
* Subscribes to the onReady event. This event is fired when the SDK reaches the ready state.
* If the SDK is configured with lazy load or manual polling it's considered ready right after instantiation.
* In case of auto polling, the ready state is reached when the SDK has a valid config.json loaded
* into memory either from cache or from HTTP. If the config couldn't be loaded neither from cache nor from HTTP the
* onReady event fires when the auto polling's maxInitWaitTimeInSeconds is reached.
*
* @param callback the method to call when the event fires.
*/
@Deprecated
public void addOnClientReady(Runnable callback) {
lock.writeLock().lock();
try {
Expand Down Expand Up @@ -75,9 +101,13 @@ public void addOnFlagEvaluated(Consumer<EvaluationDetails<Object>> callback) {
}
}

void invokeOnClientReady() {
void invokeOnClientReady(ClientCacheState clientCacheState) {
lock.readLock().lock();
try {
this.clientCacheState.set(clientCacheState);
for (Consumer<ClientCacheState> func : this.onClientReadyWithState) {
func.accept(clientCacheState);
}
for (Runnable func : this.onClientReady) {
func.run();
}
Expand Down
28 changes: 24 additions & 4 deletions src/main/java/com/configcat/ConfigService.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public ConfigService(String sdkKey,
lock.lock();
try {
if (initialized.compareAndSet(false, true)) {
this.configCatHooks.invokeOnClientReady();
this.configCatHooks.invokeOnClientReady(determineCacheState());
String message = ConfigCatLogMessages.getAutoPollMaxInitWaitTimeReached(autoPollingMode.getMaxInitWaitTimeSeconds());
this.logger.warn(4200, message);
completeRunningTask(Result.error(message, cachedEntry));
Expand All @@ -69,13 +69,15 @@ public ConfigService(String sdkKey,
}, autoPollingMode.getMaxInitWaitTimeSeconds(), TimeUnit.SECONDS);

} else {
// Sync up with cache before reporting ready state
cachedEntry = readCache();
setInitialized();
}
}

private void setInitialized() {
if (initialized.compareAndSet(false, true)) {
configCatHooks.invokeOnClientReady();
configCatHooks.invokeOnClientReady(determineCacheState());
}
}

Expand Down Expand Up @@ -128,7 +130,7 @@ private CompletableFuture<Result<Entry>> fetchIfOlder(long threshold, boolean pr
cachedEntry = fromCache;
}
// Cache isn't expired
if (cachedEntry.getFetchTime() > threshold) {
if (!cachedEntry.isExpired(threshold)) {
setInitialized();
return CompletableFuture.completedFuture(Result.success(cachedEntry));
}
Expand Down Expand Up @@ -194,7 +196,6 @@ public boolean isOffline() {
private void processResponse(FetchResponse response) {
lock.lock();
try {
setInitialized();
if (response.isFetched()) {
Entry entry = response.entry();
cachedEntry = entry;
Expand All @@ -210,6 +211,7 @@ private void processResponse(FetchResponse response) {
? Result.error(response.error(), cachedEntry)
: Result.success(cachedEntry));
}
setInitialized();
} finally {
lock.unlock();
}
Expand Down Expand Up @@ -244,4 +246,22 @@ private void writeCache(Entry entry) {
logger.error(2201, ConfigCatLogMessages.CONFIG_SERVICE_CACHE_WRITE_ERROR, e);
}
}

private ClientCacheState determineCacheState(){
if(cachedEntry.isEmpty()) {
return ClientCacheState.NO_FLAG_DATA;
}
if(pollingMode instanceof ManualPollingMode) {
return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY;
} else if(pollingMode instanceof LazyLoadingMode) {
if(cachedEntry.isExpired(System.currentTimeMillis() - (((LazyLoadingMode)pollingMode).getCacheRefreshIntervalInSeconds() * 1000L))) {
return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY;
}
} else if(pollingMode instanceof AutoPollingMode) {
if(cachedEntry.isExpired(System.currentTimeMillis() - (((AutoPollingMode)pollingMode).getAutoPollRateInSeconds() * 1000L))) {
return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY;
}
}
return ClientCacheState.HAS_UP_TO_DATE_FLAG_DATA;
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/configcat/ConfigurationProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,12 @@ public interface ConfigurationProvider extends Closeable {
* @return the hooks object used for event subscription.
*/
ConfigCatHooks getHooks();

/**
* Awaits for SDK initialization.
*
* @return the future which executes the wait for ready and return with the client state.
*/
CompletableFuture<ClientCacheState> waitForReadyAsync();

}
2 changes: 1 addition & 1 deletion src/main/java/com/configcat/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ private Constants() { /* prevent from instantiation*/ }
static final long DISTANT_PAST = 0;
static final String CONFIG_JSON_NAME = "config_v6.json";
static final String SERIALIZATION_FORMAT_VERSION = "v2";
static final String VERSION = "9.1.2";
static final String VERSION = "9.2.0";

static final String SDK_KEY_PROXY_PREFIX = "configcat-proxy/";
static final String SDK_KEY_PREFIX = "configcat-sdk-1";
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/configcat/Entry.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public Entry withFetchTime(long fetchTime) {
return new Entry(getConfig(), getETag(), getConfigJson(), fetchTime);
}

public boolean isExpired(long threshold) {
return fetchTime <= threshold ;
}
public Entry(Config config, String eTag, String configJson, long fetchTime) {
this.config = config;
this.eTag = eTag;
Expand Down
67 changes: 61 additions & 6 deletions src/test/java/com/configcat/ConfigCatClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
Expand Down Expand Up @@ -752,22 +753,22 @@ void testHooks() throws IOException {
server.enqueue(new MockResponse().setResponseCode(500).setBody(""));

AtomicBoolean changed = new AtomicBoolean(false);
AtomicBoolean ready = new AtomicBoolean(false);
AtomicReference<ClientCacheState> ready = new AtomicReference(null);
AtomicReference<String> error = new AtomicReference<>("");

ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> {
options.pollingMode(PollingModes.manualPoll());
options.baseUrl(server.url("/").toString());
options.hooks().addOnConfigChanged(map -> changed.set(true));
options.hooks().addOnClientReady(() -> ready.set(true));
options.hooks().addOnClientReady(clientReadyState -> ready.set(clientReadyState));
options.hooks().addOnError(error::set);
});

cl.forceRefresh();
cl.forceRefresh();

assertTrue(changed.get());
assertTrue(ready.get());
assertEquals(ClientCacheState.NO_FLAG_DATA, ready.get());
assertEquals("Unexpected HTTP response was received while trying to fetch config JSON: 500 Server Error", error.get());

server.shutdown();
Expand Down Expand Up @@ -803,6 +804,38 @@ void testHooksSub() throws IOException {
cl.close();
}

@Test
void testReadyHookManualPollWithCache() throws IOException {

AtomicReference<ClientCacheState> ready = new AtomicReference(null);
ConfigCache cache = new SingleValueCache(Helpers.cacheValueFromConfigJson(String.format(TEST_JSON, "test")));

ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> {
options.pollingMode(PollingModes.manualPoll());
options.cache(cache);
options.hooks().addOnClientReady(clientReadyState -> ready.set(clientReadyState));
});

assertEquals(ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY, ready.get());

cl.close();
}

@Test
void testReadyHookLocalOnly() throws IOException {
AtomicReference<ClientCacheState> ready = new AtomicReference(null);

ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> {
options.pollingMode(PollingModes.manualPoll());
options.flagOverrides(OverrideDataSourceBuilder.map(Collections.EMPTY_MAP), OverrideBehaviour.LOCAL_ONLY);
options.hooks().addOnClientReady(clientReadyState -> ready.set(clientReadyState));
});

assertEquals(ClientCacheState.HAS_LOCAL_OVERRIDE_FLAG_DATA_ONLY, ready.get());

cl.close();
}

@Test
void testHooksAutoPollSub() throws IOException {
MockWebServer server = new MockWebServer();
Expand All @@ -812,7 +845,7 @@ void testHooksAutoPollSub() throws IOException {
server.enqueue(new MockResponse().setResponseCode(500).setBody(""));

AtomicBoolean changed = new AtomicBoolean(false);
AtomicBoolean ready = new AtomicBoolean(false);
AtomicReference<ClientCacheState> ready = new AtomicReference(null);
AtomicReference<String> error = new AtomicReference<>("");

ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> {
Expand All @@ -821,14 +854,14 @@ void testHooksAutoPollSub() throws IOException {
});

cl.getHooks().addOnConfigChanged(map -> changed.set(true));
cl.getHooks().addOnClientReady(() -> ready.set(true));
cl.getHooks().addOnClientReady(clientReadyState -> ready.set(clientReadyState));
cl.getHooks().addOnError(error::set);

cl.forceRefresh();
cl.forceRefresh();

assertTrue(changed.get());
assertTrue(ready.get());
assertEquals(ClientCacheState.HAS_UP_TO_DATE_FLAG_DATA, ready.get());
assertEquals("Unexpected HTTP response was received while trying to fetch config JSON: 500 Server Error", error.get());

server.shutdown();
Expand Down Expand Up @@ -980,4 +1013,26 @@ void testGetValueInvalidTypes(String settingKey, Class callType, Object defaultV
cl.close();
}

@Test
void testWaitForReady() throws IOException, InterruptedException, ExecutionException {
MockWebServer server = new MockWebServer();
server.start();

server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON));

ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> {
options.pollingMode(PollingModes.autoPoll(2));
options.baseUrl(server.url("/").toString());
});

CompletableFuture<ClientCacheState> clientReadyStateCompletableFuture = cl.waitForReadyAsync();
if(clientReadyStateCompletableFuture.isDone()) {
assertEquals(clientReadyStateCompletableFuture.get(), ClientCacheState.HAS_UP_TO_DATE_FLAG_DATA);
}

server.shutdown();
cl.close();
}


}