diff --git a/gradle.properties b/gradle.properties index f3244d4..8ac16fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=10.1.2 +version=10.2.0 org.gradle.jvmargs=-Xmx2g \ No newline at end of file diff --git a/src/main/java/com/configcat/ClientCacheState.java b/src/main/java/com/configcat/ClientCacheState.java new file mode 100644 index 0000000..a480908 --- /dev/null +++ b/src/main/java/com/configcat/ClientCacheState.java @@ -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, +} diff --git a/src/main/java/com/configcat/ConfigCatClient.java b/src/main/java/com/configcat/ConfigCatClient.java index 406808b..659dc71 100644 --- a/src/main/java/com/configcat/ConfigCatClient.java +++ b/src/main/java/com/configcat/ConfigCatClient.java @@ -55,6 +55,8 @@ private ConfigCatClient(String sdkKey, Options options) throws IllegalArgumentEx options.pollingMode.getPollingIdentifier()); this.configService = new ConfigService(sdkKey, options.pollingMode, options.cache, logger, fetcher, options.hooks, options.offline); + } else { + this.hooks.invokeOnClientReady(ClientCacheState.HAS_LOCAL_OVERRIDE_FLAG_DATA_ONLY); } } @@ -594,8 +596,7 @@ public static ConfigCatClient get(String sdkKey) { * @return the ConfigCatClient instance. */ public static ConfigCatClient get(String sdkKey, Consumer optionsCallback) { - if (sdkKey == null || sdkKey.isEmpty()) - throw new IllegalArgumentException("SDK Key cannot be null or empty."); + Options clientOptions = new Options(); @@ -605,8 +606,17 @@ public static ConfigCatClient get(String sdkKey, Consumer optionsCallba clientOptions = options; } - if (!OverrideBehaviour.LOCAL_ONLY.equals(clientOptions.overrideBehaviour) && !isValidKey(sdkKey, clientOptions.isBaseURLCustom())) + if (sdkKey == null || sdkKey.isEmpty()) { + clientOptions.hooks.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.hooks.invokeOnClientReady(ClientCacheState.NO_FLAG_DATA); throw new IllegalArgumentException("SDK Key '" + sdkKey + "' is invalid."); + } + synchronized (INSTANCES) { ConfigCatClient client = INSTANCES.get(sdkKey); @@ -649,6 +659,13 @@ public static void closeAll() throws IOException { } } + @Override + public CompletableFuture waitForReadyAsync() { + CompletableFuture completableFuture = new CompletableFuture<>(); + getHooks().addOnClientReady((completableFuture::complete)); + return completableFuture; + } + /** * Configuration options for a {@link ConfigCatClient} instance. */ diff --git a/src/main/java/com/configcat/ConfigCatHooks.java b/src/main/java/com/configcat/ConfigCatHooks.java index 3dea0e2..7c6309e 100644 --- a/src/main/java/com/configcat/ConfigCatHooks.java +++ b/src/main/java/com/configcat/ConfigCatHooks.java @@ -5,12 +5,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; public class ConfigCatHooks { - private final Object sync = new Object(); + private final AtomicReference clientCacheState = new AtomicReference<>(null); + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); private final List>> onConfigChanged = new ArrayList<>(); - private final List onClientReady = new ArrayList<>(); - private final List>> onFlagEvaluated = new ArrayList<>(); + private final List> onClientReadyWithState = new ArrayList<>(); + private final List onClientReady = new ArrayList<>(); private final List>> onFlagEvaluated = new ArrayList<>(); private final List> onError = new ArrayList<>(); /** @@ -22,9 +25,35 @@ public class ConfigCatHooks { * * @param callback the method to call when the event fires. */ + public void addOnClientReady(Consumer 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) { - synchronized (sync) { + lock.writeLock().lock(); + try { this.onClientReady.add(callback); + } finally { + lock.writeLock().unlock(); } } @@ -35,8 +64,11 @@ public void addOnClientReady(Runnable callback) { * @param callback the method to call when the event fires. */ public void addOnConfigChanged(Consumer> callback) { - synchronized (sync) { + lock.writeLock().lock(); + try { this.onConfigChanged.add(callback); + } finally { + lock.writeLock().unlock(); } } @@ -46,8 +78,11 @@ public void addOnConfigChanged(Consumer> callback) { * @param callback the method to call when the event fires. */ public void addOnError(Consumer callback) { - synchronized (sync) { + lock.writeLock().lock(); + try { this.onError.add(callback); + } finally { + lock.writeLock().unlock(); } } @@ -58,49 +93,71 @@ public void addOnError(Consumer callback) { * @param callback the method to call when the event fires. */ public void addOnFlagEvaluated(Consumer> callback) { - synchronized (sync) { + lock.writeLock().lock(); + try { this.onFlagEvaluated.add(callback); + } finally { + lock.writeLock().unlock(); } } - void invokeOnClientReady() { - synchronized (sync) { + void invokeOnClientReady(ClientCacheState clientCacheState) { + lock.readLock().lock(); + try { + this.clientCacheState.set(clientCacheState); + for (Consumer func : this.onClientReadyWithState) { + func.accept(clientCacheState); + } for (Runnable func : this.onClientReady) { func.run(); } + } finally { + lock.readLock().unlock(); } } void invokeOnError(String error) { - synchronized (sync) { + lock.readLock().lock(); + try { for (Consumer func : this.onError) { func.accept(error); } + } finally { + lock.readLock().unlock(); } } void invokeOnConfigChanged(Map settingMap) { - synchronized (sync) { + lock.readLock().lock(); + try { for (Consumer> func : this.onConfigChanged) { func.accept(settingMap); } + } finally { + lock.readLock().unlock(); } } void invokeOnFlagEvaluated(EvaluationDetails evaluationDetails) { - synchronized (sync) { + lock.readLock().lock(); + try { for (Consumer> func : this.onFlagEvaluated) { func.accept(evaluationDetails); } + } finally { + lock.readLock().unlock(); } } void clear() { - synchronized (sync) { + lock.writeLock().lock(); + try { this.onConfigChanged.clear(); this.onError.clear(); this.onFlagEvaluated.clear(); this.onClientReady.clear(); + } finally { + lock.writeLock().unlock(); } } } \ No newline at end of file diff --git a/src/main/java/com/configcat/ConfigService.java b/src/main/java/com/configcat/ConfigService.java index fbc7c6c..e2432d0 100644 --- a/src/main/java/com/configcat/ConfigService.java +++ b/src/main/java/com/configcat/ConfigService.java @@ -81,7 +81,7 @@ public ConfigService(String sdkKey, lock.lock(); try { if (initialized.compareAndSet(false, true)) { - hooks.invokeOnClientReady(); + hooks.invokeOnClientReady(determineCacheState()); String message = ConfigCatLogMessages.getAutoPollMaxInitWaitTimeReached(autoPollingMode.getMaxInitWaitTimeSeconds()); logger.warn(4200, message); completeRunningTask(Result.error(message, cachedEntry)); @@ -91,6 +91,8 @@ public ConfigService(String sdkKey, } }, autoPollingMode.getMaxInitWaitTimeSeconds(), TimeUnit.SECONDS); } else { + // Sync up with cache before reporting ready state + cachedEntry = readCache(); setInitialized(); } } @@ -165,7 +167,7 @@ private CompletableFuture> 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)); } @@ -191,7 +193,6 @@ private CompletableFuture> fetchIfOlder(long threshold, boolean pr private void processResponse(FetchResponse response) { lock.lock(); try { - setInitialized(); if (response.isFetched()) { Entry entry = response.entry(); cachedEntry = entry; @@ -207,6 +208,7 @@ private void processResponse(FetchResponse response) { ? Result.error(response.error(), cachedEntry) : Result.success(cachedEntry)); } + setInitialized(); } finally { lock.unlock(); } @@ -219,7 +221,7 @@ private void completeRunningTask(Result result) { private void setInitialized() { if (initialized.compareAndSet(false, true)) { - hooks.invokeOnClientReady(); + hooks.invokeOnClientReady(determineCacheState()); } } @@ -255,6 +257,24 @@ private Entry readCache() { } } + private ClientCacheState determineCacheState(){ + if(cachedEntry.isEmpty()) { + return ClientCacheState.NO_FLAG_DATA; + } + if(mode instanceof ManualPollingMode) { + return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY; + } else if(mode instanceof LazyLoadingMode) { + if(cachedEntry.isExpired(System.currentTimeMillis() - (((LazyLoadingMode)mode).getCacheRefreshIntervalInSeconds() * 1000L))) { + return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY; + } + } else if(mode instanceof AutoPollingMode) { + if(cachedEntry.isExpired(System.currentTimeMillis() - (((AutoPollingMode)mode).getAutoPollRateInSeconds() * 1000L))) { + return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY; + } + } + return ClientCacheState.HAS_UP_TO_DATE_FLAG_DATA; + } + @Override public void close() throws IOException { if (!this.closed.compareAndSet(false, true)) { diff --git a/src/main/java/com/configcat/ConfigurationProvider.java b/src/main/java/com/configcat/ConfigurationProvider.java index 681d8fd..5abda18 100644 --- a/src/main/java/com/configcat/ConfigurationProvider.java +++ b/src/main/java/com/configcat/ConfigurationProvider.java @@ -255,4 +255,11 @@ public interface ConfigurationProvider extends Closeable { * @return True if the client is closed. */ boolean isClosed(); + + /** + * Awaits for SDK initialization. + * + * @return the future which executes the wait for ready and return with the client state. + */ + CompletableFuture waitForReadyAsync(); } diff --git a/src/main/java/com/configcat/Entry.java b/src/main/java/com/configcat/Entry.java index 3f5b452..935eb89 100644 --- a/src/main/java/com/configcat/Entry.java +++ b/src/main/java/com/configcat/Entry.java @@ -37,6 +37,10 @@ boolean isEmpty() { return EMPTY.equals(this); } + public boolean isExpired(long threshold) { + return fetchTime <= threshold ; + } + public static final Entry EMPTY = new Entry(Config.EMPTY, "", "", Constants.DISTANT_PAST); public String serialize() { diff --git a/src/main/java/com/configcat/Utils.java b/src/main/java/com/configcat/Utils.java index 7b6c393..1bf3e11 100644 --- a/src/main/java/com/configcat/Utils.java +++ b/src/main/java/com/configcat/Utils.java @@ -30,7 +30,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 = "10.1.2"; + static final String VERSION = "10.2.0"; static final String SDK_KEY_PROXY_PREFIX = "configcat-proxy/"; static final String SDK_KEY_PREFIX = "configcat-sdk-1"; diff --git a/src/test/java/com/configcat/AutoPollingTest.java b/src/test/java/com/configcat/AutoPollingTest.java index cab5b9f..8b6ff46 100644 --- a/src/test/java/com/configcat/AutoPollingTest.java +++ b/src/test/java/com/configcat/AutoPollingTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -249,9 +250,9 @@ void testPollsWhenCacheExpired() throws Exception { void testNonExpiredCacheCallsReady() throws Exception { ConfigCache cache = new SingleValueCache(Helpers.cacheValueFromConfigJson(String.format(TEST_JSON, "test"))); - AtomicBoolean ready = new AtomicBoolean(false); + AtomicReference ready = new AtomicReference(null); ConfigCatHooks hooks = new ConfigCatHooks(); - hooks.addOnClientReady(() -> ready.set(true)); + hooks.addOnClientReady(clientReadyState -> ready.set(clientReadyState)); PollingMode pollingMode = PollingModes.autoPoll(2); ConfigFetcher fetcher = new ConfigFetcher(new OkHttpClient(), logger, @@ -263,7 +264,9 @@ void testNonExpiredCacheCallsReady() throws Exception { assertEquals(0, this.server.getRequestCount()); - Helpers.waitFor(ready::get); + Helpers.waitForClientCacheState(2000, ready::get); + + assertEquals(ClientCacheState.HAS_UP_TO_DATE_FLAG_DATA, ready.get()); policy.close(); } diff --git a/src/test/java/com/configcat/ConfigCatClientTest.java b/src/test/java/com/configcat/ConfigCatClientTest.java index 241d179..14f64cb 100644 --- a/src/test/java/com/configcat/ConfigCatClientTest.java +++ b/src/test/java/com/configcat/ConfigCatClientTest.java @@ -1,5 +1,6 @@ package com.configcat; +import java9.util.concurrent.CompletableFuture; import okhttp3.OkHttpClient; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -530,16 +531,18 @@ void testInitOfflineCallsReady() throws IOException, InterruptedException { server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON)); - AtomicBoolean ready = new AtomicBoolean(false); + AtomicReference ready = new AtomicReference(null); ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> { options.pollingMode(PollingModes.autoPoll()); options.baseUrl(server.url("/").toString()); options.offline(true); - options.hooks().addOnClientReady(() -> ready.set(true)); + options.hooks().addOnClientReady(clientReadyState -> ready.set(clientReadyState)); }); assertEquals(0, server.getRequestCount()); - Helpers.waitFor(ready::get); + Helpers.waitForClientCacheState(2000, ready::get); + + assertEquals(ClientCacheState.NO_FLAG_DATA, ready.get()); server.shutdown(); cl.close(); @@ -614,14 +617,14 @@ void testHooks() throws IOException { server.enqueue(new MockResponse().setResponseCode(500).setBody("")); AtomicBoolean changed = new AtomicBoolean(false); - AtomicBoolean ready = new AtomicBoolean(false); + AtomicReference ready = new AtomicReference(null); AtomicReference 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); }); @@ -629,7 +632,7 @@ void testHooks() throws IOException { 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(); @@ -674,7 +677,7 @@ void testHooksAutoPollSub() throws IOException { server.enqueue(new MockResponse().setResponseCode(500).setBody("")); AtomicBoolean changed = new AtomicBoolean(false); - AtomicBoolean ready = new AtomicBoolean(false); + AtomicReference ready = new AtomicReference(null); AtomicReference error = new AtomicReference<>(""); ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> { @@ -683,20 +686,52 @@ 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(); cl.close(); } + @Test + void testReadyHookManualPollWithCache() throws IOException { + + AtomicReference 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 ready = new AtomicReference(null); + + ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> { + options.pollingMode(PollingModes.manualPoll()); + options.flagOverrides(OverrideDataSource.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 testOnFlagEvaluationError() throws IOException { MockWebServer server = new MockWebServer(); @@ -965,4 +1000,25 @@ void testSpecialCharactersWorks() throws IOException { ConfigCatClient.closeAll(); scanner.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 clientReadyStateCompletableFuture = cl.waitForReadyAsync(); + if(clientReadyStateCompletableFuture.isDone()) { + assertEquals(clientReadyStateCompletableFuture.get(), ClientCacheState.HAS_UP_TO_DATE_FLAG_DATA); + } + + server.shutdown(); + cl.close(); + } } \ No newline at end of file diff --git a/src/test/java/com/configcat/Helpers.java b/src/test/java/com/configcat/Helpers.java index d64914a..fa7aec3 100644 --- a/src/test/java/com/configcat/Helpers.java +++ b/src/test/java/com/configcat/Helpers.java @@ -44,6 +44,17 @@ static void waitFor(long timeout, Supplier predicate) throws Interrupte } } + static void waitForClientCacheState(long timeout, Supplier predicate) throws InterruptedException { + long end = System.currentTimeMillis() + timeout; + while (predicate.get() == null) { + Thread.sleep(200); + if (System.currentTimeMillis() > end) { + throw new RuntimeException("Test timed out."); + } + } + } + + public static String readFile(String filePath) throws IOException { try (InputStream stream = Helpers.class.getClassLoader().getResourceAsStream(filePath)) {