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(""))