From a470087b4fb90fab8113afdb360bb2f4dddcec9e Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Wed, 21 Jun 2023 16:45:47 +0200 Subject: [PATCH] feat: TogglesCheckedListener and ReadyListener (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add bin folder to ignore * feat: Add two new listeners. The TogglesCheckedListener which will be notified each time the poller checks for updates, regardless if there are updates or not or if the check failed. The ReadyListener which will be notified once the RefreshPolicy completes its initialisation process. Adds support for pollInterval = 0 to disable polling, it will only fetch once. Co-authored-by: Gastón Fournier fixes: #47, #49 --- README.md | 3 +- .../io/getunleash/polling/AutoPollingMode.kt | 15 +++- .../getunleash/polling/AutoPollingPolicy.kt | 90 ++++++++++--------- .../io/getunleash/polling/FilePollingMode.kt | 3 +- .../getunleash/polling/FilePollingPolicy.kt | 5 ++ .../io/getunleash/polling/PollingModes.kt | 13 ++- .../io/getunleash/polling/ReadyListener.kt | 5 ++ .../io/getunleash/polling/RefreshPolicy.kt | 58 +++++++++++- .../polling/TogglesCheckedListener.kt | 5 ++ .../io/getunleash/metrics/MetricsTest.kt | 2 +- .../polling/AutoPollingPolicyTest.kt | 64 +++++++++++++ .../polling/FilePollingPolicyTest.kt | 21 +++++ 12 files changed, 235 insertions(+), 49 deletions(-) create mode 100644 src/main/kotlin/io/getunleash/polling/ReadyListener.kt create mode 100644 src/main/kotlin/io/getunleash/polling/TogglesCheckedListener.kt diff --git a/README.md b/README.md index 7558645..180e656 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ val unleashClient = UnleashClient(config = unleashConfig, context = myAppContext #### PollingModes ##### Autopolling If you'd like for changes in toggles to take effect for you; use AutoPolling. -You can configure the pollInterval and a listener that gets notified when toggles are updated in the background thread +You can configure the pollInterval and a listener that gets notified when toggles are updated in the background thread. +If you set the poll interval to 0, the SDK will fetch once, but not set up polling. The listener is a no-argument lambda that gets called by the RefreshPolicy for every poll that 1. Does not return `304 - Not Modified` diff --git a/src/main/kotlin/io/getunleash/polling/AutoPollingMode.kt b/src/main/kotlin/io/getunleash/polling/AutoPollingMode.kt index 9cc5f02..49918c9 100644 --- a/src/main/kotlin/io/getunleash/polling/AutoPollingMode.kt +++ b/src/main/kotlin/io/getunleash/polling/AutoPollingMode.kt @@ -1,6 +1,19 @@ package io.getunleash.polling -class AutoPollingMode(val pollRateDuration: Long, val togglesUpdatedListener: TogglesUpdatedListener = TogglesUpdatedListener { }, val erroredListener: TogglesErroredListener = TogglesErroredListener { }, val pollImmediate: Boolean = true) : PollingMode { +/** + * @param pollRateDuration - How long (in seconds) between each poll + * @param togglesUpdatedListener - A listener that will be notified each time a poll actually updates the evaluation result + * @param erroredListener - A listener that will be notified each time a poll fails. The notification will include the Exception + * @param togglesCheckedListener - A listener that will be notified each time a poll completed. Will be called regardless of the check succeeded or failed. + * @param readyListener - A listener that will be notified after the poller is done instantiating, i.e. has an evaluation result in its cache. Each ready listener will receive only one notification + * @param pollImmediate - Set to true, the poller will immediately poll for configuration and then call the ready listener. Set to false, you will need to call [startPolling()) to actually talk to proxy/Edge + */ +class AutoPollingMode(val pollRateDuration: Long, + val togglesUpdatedListener: TogglesUpdatedListener? = null, + val erroredListener: TogglesErroredListener? = null, + val togglesCheckedListener: TogglesCheckedListener? = null, + val readyListener: ReadyListener? = null, + val pollImmediate: Boolean = true) : PollingMode { override fun pollingIdentifier(): String = "auto" } diff --git a/src/main/kotlin/io/getunleash/polling/AutoPollingPolicy.kt b/src/main/kotlin/io/getunleash/polling/AutoPollingPolicy.kt index fbc4ccf..ee41778 100644 --- a/src/main/kotlin/io/getunleash/polling/AutoPollingPolicy.kt +++ b/src/main/kotlin/io/getunleash/polling/AutoPollingPolicy.kt @@ -28,24 +28,37 @@ class AutoPollingPolicy( private val initFuture = CompletableFuture() private var timer: Timer? = null init { - autoPollingConfig.togglesUpdatedListener.let { listeners.add(it) } - autoPollingConfig.erroredListener.let { errorListeners.add(it) } + autoPollingConfig.togglesUpdatedListener?.let { listeners.add(it) } + autoPollingConfig.togglesCheckedListener?.let { checkListeners.add(it) } + autoPollingConfig.erroredListener?.let { errorListeners.add(it) } + autoPollingConfig.readyListener?.let { readyListeners.add(it) } if (autoPollingConfig.pollImmediate) { - timer = - timer( - name = "unleash_toggles_fetcher", - initialDelay = 0L, - daemon = true, - period = autoPollingConfig.pollRateDuration - ) { - updateToggles() - if (!initialized.getAndSet(true)) { - initFuture.complete(null) - } + if (autoPollingConfig.pollRateDuration > 0) { + timer = + timer( + name = "unleash_toggles_fetcher", + initialDelay = 0L, + daemon = true, + period = autoPollingConfig.pollRateDuration + ) { + updateToggles() + if (!initialized.getAndSet(true)) { + super.broadcastReady() + initFuture.complete(null) + } + } + } else { + updateToggles() + if (!initialized.getAndSet(true)) { + super.broadcastReady() + initFuture.complete(null) } + } } } + override val isReady: AtomicBoolean + get() = initialized override fun getConfigurationAsync(): CompletableFuture> { return if (this.initFuture.isDone) { @@ -56,13 +69,20 @@ class AutoPollingPolicy( } override fun startPolling() { - this.timer?.cancel() - this.timer = timer( - name = "unleash_toggles_fetcher", - initialDelay = 0L, - daemon = true, - period = autoPollingConfig.pollRateDuration - ) { + if (autoPollingConfig.pollRateDuration > 0) { + this.timer?.cancel() + this.timer = timer( + name = "unleash_toggles_fetcher", + initialDelay = 0L, + daemon = true, + period = autoPollingConfig.pollRateDuration + ) { + updateToggles() + if (!initialized.getAndSet(true)) { + initFuture.complete(null) + } + } + } else { updateToggles() if (!initialized.getAndSet(true)) { initFuture.complete(null) @@ -79,37 +99,25 @@ class AutoPollingPolicy( val response = super.fetcher().getTogglesAsync(context).get() val cached = super.readToggleCache() if (response.isFetched() && cached != response.toggles) { - super.writeToggleCache(response.toggles) - this.broadcastTogglesUpdated() + logger.trace("Content was not equal") + super.writeToggleCache(response.toggles) // This will also broadcast updates } else if (response.isFailed()) { - response?.error?.let(::broadcastTogglesErrored) + response?.error?.let { e -> super.broadcastTogglesErrored(e) } } } catch (e: Exception) { - this.broadcastTogglesErrored(e) + super.broadcastTogglesErrored(e) logger.warn("Exception in AutoPollingCachePolicy", e) } + logger.info("Done checking. Broadcasting check result") + super.broadcastTogglesChecked() } - - private fun broadcastTogglesErrored(e: Exception) { - synchronized(errorListeners) { - errorListeners.forEach { - it.onError(e) - } - } - } - - private fun broadcastTogglesUpdated() { - synchronized(listeners) { - listeners.forEach { - it.onTogglesUpdated() - } - } - } - override fun close() { super.close() this.timer?.cancel() this.listeners.clear() + this.errorListeners.clear() + this.checkListeners.clear() + this.readyListeners.clear() this.timer = null } } diff --git a/src/main/kotlin/io/getunleash/polling/FilePollingMode.kt b/src/main/kotlin/io/getunleash/polling/FilePollingMode.kt index 38ab1ea..9236ffb 100644 --- a/src/main/kotlin/io/getunleash/polling/FilePollingMode.kt +++ b/src/main/kotlin/io/getunleash/polling/FilePollingMode.kt @@ -6,8 +6,9 @@ import java.io.File /** * Configuration for FilePollingPolicy. Sets up where the policy loads the toggles from * @param fileToLoadFrom + * @param readyListener - Will broadcast a ready event (Once the File is loaded and the toggle cache is populated) * @since 0.2 */ -class FilePollingMode(val fileToLoadFrom: File) : PollingMode { +class FilePollingMode(val fileToLoadFrom: File, val readyListener: ReadyListener? = null) : PollingMode { override fun pollingIdentifier(): String = "file" } \ No newline at end of file diff --git a/src/main/kotlin/io/getunleash/polling/FilePollingPolicy.kt b/src/main/kotlin/io/getunleash/polling/FilePollingPolicy.kt index f43d819..b57fe62 100644 --- a/src/main/kotlin/io/getunleash/polling/FilePollingPolicy.kt +++ b/src/main/kotlin/io/getunleash/polling/FilePollingPolicy.kt @@ -9,6 +9,7 @@ import io.getunleash.data.ProxyResponse import io.getunleash.data.Toggle import org.slf4j.LoggerFactory import java9.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean /** * Allows loading a proxy response from file. @@ -36,9 +37,13 @@ class FilePollingPolicy( config = config, context = context ) { + override val isReady: AtomicBoolean = AtomicBoolean(false) init { val togglesInFile: ProxyResponse = Parser.jackson.readValue(filePollingConfig.fileToLoadFrom) + filePollingConfig.readyListener?.let { r -> addReadyListener(r) } super.writeToggleCache(togglesInFile.toggles.groupBy { it.name }.mapValues { (_, v) -> v.first() }) + super.broadcastReady() + isReady.getAndSet(true) } override fun startPolling() { diff --git a/src/main/kotlin/io/getunleash/polling/PollingModes.kt b/src/main/kotlin/io/getunleash/polling/PollingModes.kt index 9d0b28d..5f22ddd 100644 --- a/src/main/kotlin/io/getunleash/polling/PollingModes.kt +++ b/src/main/kotlin/io/getunleash/polling/PollingModes.kt @@ -39,6 +39,15 @@ object PollingModes { return AutoPollingMode(pollRateDuration = autoPollIntervalSeconds * 1000, togglesUpdatedListener = listener, pollImmediate = false) } + /** + * Creates a configured poller that fetches once at initialisation and then never polls + * @param listener - What should the poller call when toggles are updated? + * @param readyListener - What should the poller call when it has initialised its toggles cache + */ + fun fetchOnce(listener: TogglesUpdatedListener? = null, readyListener: ReadyListener? = null): PollingMode { + return AutoPollingMode(pollRateDuration = 0, togglesUpdatedListener = listener, readyListener = readyListener) + } + /** * Creates a configured auto polling config with a listener which receives updates when/if toggles get updated * @param intervalInMs - Sets intervalInMs for how often this policy should refresh the cache @@ -60,8 +69,8 @@ object PollingModes { return AutoPollingMode(pollRateDuration = intervalInMs, togglesUpdatedListener = listener, pollImmediate = false) } - fun fileMode(toggleFile: File): PollingMode { - return FilePollingMode(toggleFile) + fun fileMode(toggleFile: File, readyListener: ReadyListener? = null): PollingMode { + return FilePollingMode(toggleFile, readyListener) } diff --git a/src/main/kotlin/io/getunleash/polling/ReadyListener.kt b/src/main/kotlin/io/getunleash/polling/ReadyListener.kt new file mode 100644 index 0000000..53c2a08 --- /dev/null +++ b/src/main/kotlin/io/getunleash/polling/ReadyListener.kt @@ -0,0 +1,5 @@ +package io.getunleash.polling + +fun interface ReadyListener { + fun onReady(): Unit +} \ No newline at end of file diff --git a/src/main/kotlin/io/getunleash/polling/RefreshPolicy.kt b/src/main/kotlin/io/getunleash/polling/RefreshPolicy.kt index e580419..74e9d42 100644 --- a/src/main/kotlin/io/getunleash/polling/RefreshPolicy.kt +++ b/src/main/kotlin/io/getunleash/polling/RefreshPolicy.kt @@ -9,6 +9,7 @@ import java.io.Closeable import java.math.BigInteger import java.security.MessageDigest import java9.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean /** * Used to define how to Refresh and serve toggles @@ -27,6 +28,8 @@ abstract class RefreshPolicy( ) : Closeable { internal val listeners: MutableList = mutableListOf() internal val errorListeners: MutableList = mutableListOf() + internal val checkListeners: MutableList = mutableListOf() + internal val readyListeners: MutableList = mutableListOf() private var inMemoryConfig: Map = emptyMap() private val cacheKey: String by lazy { sha256(cacheBase.format(this.config.clientKey)) } @@ -40,6 +43,8 @@ abstract class RefreshPolicy( } } + abstract val isReady: AtomicBoolean + fun readToggleCache(): Map { return try { this.cache.read(cacheKey) @@ -52,6 +57,7 @@ abstract class RefreshPolicy( try { this.inMemoryConfig = value this.cache.write(cacheKey, value) + broadcastTogglesUpdated() } catch (e: Exception) { } } @@ -78,6 +84,38 @@ abstract class RefreshPolicy( } } + fun broadcastTogglesUpdated(): Unit { + synchronized(listeners) { + listeners.forEach { + it.onTogglesUpdated() + } + } + } + + fun broadcastTogglesChecked() { + synchronized(checkListeners) { + checkListeners.forEach { + it.onTogglesChecked() + } + } + } + + fun broadcastTogglesErrored(e: Exception) { + synchronized(errorListeners) { + errorListeners.forEach { + it.onError(e) + } + } + } + + fun broadcastReady() { + synchronized(readyListeners) { + readyListeners.forEach { + it.onReady() + } + } + } + /** * Subclasses should override this to implement their way of manually starting polling after context is updated. * Typical usage would be to use [PollingModes.manuallyStartPolling] or [PollingModes.manuallyStartedPollMs] to create/configure your polling mode, @@ -97,10 +135,26 @@ abstract class RefreshPolicy( } fun addTogglesUpdatedListener(listener: TogglesUpdatedListener): Unit { - listeners.add(listener) + synchronized(listener) { + listeners.add(listener) + } } fun addTogglesErroredListener(errorListener: TogglesErroredListener): Unit { - errorListeners.add(errorListener) + synchronized(errorListeners) { + errorListeners.add(errorListener) + } + } + + fun addTogglesCheckedListener(checkListener: TogglesCheckedListener) { + synchronized(checkListeners) { + checkListeners.add(checkListener) + } + } + + fun addReadyListener(readyListener: ReadyListener) { + synchronized(readyListeners) { + readyListeners.add(readyListener) + } } } diff --git a/src/main/kotlin/io/getunleash/polling/TogglesCheckedListener.kt b/src/main/kotlin/io/getunleash/polling/TogglesCheckedListener.kt new file mode 100644 index 0000000..d562565 --- /dev/null +++ b/src/main/kotlin/io/getunleash/polling/TogglesCheckedListener.kt @@ -0,0 +1,5 @@ +package io.getunleash.polling + +fun interface TogglesCheckedListener { + fun onTogglesChecked(): Unit +} \ No newline at end of file diff --git a/src/test/kotlin/io/getunleash/metrics/MetricsTest.kt b/src/test/kotlin/io/getunleash/metrics/MetricsTest.kt index 19027c5..e9329a8 100644 --- a/src/test/kotlin/io/getunleash/metrics/MetricsTest.kt +++ b/src/test/kotlin/io/getunleash/metrics/MetricsTest.kt @@ -105,7 +105,7 @@ class MetricsTest { @Test - fun `getVariant calls also records "yes" and "no"`() { + fun `getVariant calls also records yes and no`() { val reporter = TestReporter() val client = UnleashClient(config, context, metricsReporter = reporter) repeat(100) { diff --git a/src/test/kotlin/io/getunleash/polling/AutoPollingPolicyTest.kt b/src/test/kotlin/io/getunleash/polling/AutoPollingPolicyTest.kt index ae6bf81..60a69b6 100644 --- a/src/test/kotlin/io/getunleash/polling/AutoPollingPolicyTest.kt +++ b/src/test/kotlin/io/getunleash/polling/AutoPollingPolicyTest.kt @@ -23,6 +23,8 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import java.time.Duration import java9.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.LongAdder class AutoPollingPolicyTest { @@ -79,6 +81,68 @@ class AutoPollingPolicyTest { verify(toggleCache, never()).write(anyString(), eq(result)) } + @Test + fun `same response still sends a toggles checked update`() { + val result = mapOf("variantToggle" to Toggle("variantToggle", enabled = false)) + + val unleashFetcher = mock { + on { getTogglesAsync(any()) } doReturn CompletableFuture.completedFuture( + ToggleResponse( + Status.FETCHED, + result + ) + ) + } + val toggleCache = mock { + on { read(anyString()) } doReturn result + } + val checks = LongAdder() + val checkListener = TogglesCheckedListener { checks.add(1) } + val policy = AutoPollingPolicy( + unleashFetcher = unleashFetcher, + cache = toggleCache, + config = UnleashConfig(proxyUrl = "https://localhost:4242/proxy", clientKey = "some-key"), + context = UnleashContext(), + autoPollingConfig = PollingModes.autoPoll(2) as AutoPollingMode + ) + policy.addTogglesCheckedListener(checkListener) + assertThat(policy.getConfigurationAsync().get()).isEqualTo(result) + verify(toggleCache, never()).write(anyString(), eq(result)) + assertThat(checks.sum()).isEqualTo(1) + + } + + @Test + fun `Can fetch once when asked to check`() { + val result = mapOf("variantToggle" to Toggle("variantToggle", enabled = false)) + + val unleashFetcher = mock { + on { getTogglesAsync(any()) } doReturn CompletableFuture.completedFuture( + ToggleResponse( + Status.FETCHED, + result + ) + ) + } + val ready = AtomicBoolean(false) + val readyListener = ReadyListener { + ready.set(true) + } + val toggleCache = mock { + on { read(anyString()) } doReturn result + } + val policy = AutoPollingPolicy( + unleashFetcher = unleashFetcher, + cache = toggleCache, + config = UnleashConfig(proxyUrl = "https://localhost:4242/proxy", clientKey = "some-key"), + context = UnleashContext(), + autoPollingConfig = PollingModes.fetchOnce(listener = { }, readyListener = readyListener) as AutoPollingMode + ) + assertThat(policy.getConfigurationAsync().get()).isEqualTo(result) + verify(toggleCache, never()).write(anyString(), eq(result)) + assertThat(policy.isReady).isTrue + assertThat(ready.get()).isTrue + } @Test fun `yields correct identifier`() { val f = PollingModes.autoPoll(5) diff --git a/src/test/kotlin/io/getunleash/polling/FilePollingPolicyTest.kt b/src/test/kotlin/io/getunleash/polling/FilePollingPolicyTest.kt index 1c50f13..92a17b2 100644 --- a/src/test/kotlin/io/getunleash/polling/FilePollingPolicyTest.kt +++ b/src/test/kotlin/io/getunleash/polling/FilePollingPolicyTest.kt @@ -7,6 +7,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.kotlin.mock import java.io.File +import java.util.concurrent.atomic.AtomicBoolean class FilePollingPolicyTest { @@ -27,6 +28,26 @@ class FilePollingPolicyTest { assertThat(toggles).containsKey("unleash_android_sdk_demo") } + @Test + fun `broadcasts the ready event once it has read from file`() { + + val uri = FilePollingPolicy::class.java.classLoader.getResource("proxyresponse.json")!!.toURI() + val file = File(uri) + val ready = AtomicBoolean(false) + val pollingMode = PollingModes.fileMode(file) { ready.set(true) } + val filePollingPolicy = FilePollingPolicy( + unleashFetcher = mock(), + cache = InMemoryToggleCache(), + config = UnleashConfig("doesn't matter", clientKey = ""), + context = UnleashContext(), + filePollingConfig = pollingMode as FilePollingMode + ) + val toggles = filePollingPolicy.getConfigurationAsync().get() + assertThat(toggles).isNotEmpty() + assertThat(toggles).containsKey("unleash_android_sdk_demo") + assertThat(ready.get()).isTrue + } + @Test fun `yields correct identifier`() { val pollMode = PollingModes.fileMode(File(""))