Skip to content

Commit

Permalink
feat: TogglesCheckedListener and ReadyListener (#56)
Browse files Browse the repository at this point in the history
* 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 <gastonfournier@gmail.com>

fixes: #47, #49
  • Loading branch information
Christopher Kolstad committed Jun 21, 2023
1 parent 770b220 commit a470087
Show file tree
Hide file tree
Showing 12 changed files with 235 additions and 49 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
15 changes: 14 additions & 1 deletion src/main/kotlin/io/getunleash/polling/AutoPollingMode.kt
Original file line number Diff line number Diff line change
@@ -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"

}
90 changes: 49 additions & 41 deletions src/main/kotlin/io/getunleash/polling/AutoPollingPolicy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,37 @@ class AutoPollingPolicy(
private val initFuture = CompletableFuture<Unit>()
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<Map<String, Toggle>> {
return if (this.initFuture.isDone) {
Expand All @@ -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)
Expand All @@ -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
}
}
3 changes: 2 additions & 1 deletion src/main/kotlin/io/getunleash/polling/FilePollingMode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 5 additions & 0 deletions src/main/kotlin/io/getunleash/polling/FilePollingPolicy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down
13 changes: 11 additions & 2 deletions src/main/kotlin/io/getunleash/polling/PollingModes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}


Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/io/getunleash/polling/ReadyListener.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.getunleash.polling

fun interface ReadyListener {
fun onReady(): Unit
}
58 changes: 56 additions & 2 deletions src/main/kotlin/io/getunleash/polling/RefreshPolicy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +28,8 @@ abstract class RefreshPolicy(
) : Closeable {
internal val listeners: MutableList<TogglesUpdatedListener> = mutableListOf()
internal val errorListeners: MutableList<TogglesErroredListener> = mutableListOf()
internal val checkListeners: MutableList<TogglesCheckedListener> = mutableListOf()
internal val readyListeners: MutableList<ReadyListener> = mutableListOf()
private var inMemoryConfig: Map<String, Toggle> = emptyMap()
private val cacheKey: String by lazy { sha256(cacheBase.format(this.config.clientKey)) }

Expand All @@ -40,6 +43,8 @@ abstract class RefreshPolicy(
}
}

abstract val isReady: AtomicBoolean

fun readToggleCache(): Map<String, Toggle> {
return try {
this.cache.read(cacheKey)
Expand All @@ -52,6 +57,7 @@ abstract class RefreshPolicy(
try {
this.inMemoryConfig = value
this.cache.write(cacheKey, value)
broadcastTogglesUpdated()
} catch (e: Exception) {
}
}
Expand All @@ -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,
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.getunleash.polling

fun interface TogglesCheckedListener {
fun onTogglesChecked(): Unit
}
2 changes: 1 addition & 1 deletion src/test/kotlin/io/getunleash/metrics/MetricsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit a470087

Please sign in to comment.