-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Implement caching Toggle.Store and Toggle@enabled() flow API #7019
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
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,12 @@ import com.duckduckgo.feature.toggles.api.Toggle.FeatureName | |
| import com.duckduckgo.feature.toggles.api.Toggle.State | ||
| import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort | ||
| import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName | ||
| import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore | ||
| import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore.Listener | ||
| import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback | ||
| import kotlinx.coroutines.channels.awaitClose | ||
| import kotlinx.coroutines.flow.Flow | ||
| import kotlinx.coroutines.flow.callbackFlow | ||
| import org.apache.commons.math3.distribution.EnumeratedIntegerDistribution | ||
| import java.lang.reflect.Method | ||
| import java.lang.reflect.Proxy | ||
|
|
@@ -119,7 +124,7 @@ class FeatureToggles private constructor( | |
| }.getOrNull() != null | ||
|
|
||
| return ToggleImpl( | ||
| store = store, | ||
| store = if (store is CachedToggleStore) store else CachedToggleStore(store), | ||
| key = getToggleNameForMethod(method), | ||
| defaultValue = resolvedDefaultValue, | ||
| isInternalAlwaysEnabled = isInternalAlwaysEnabledAnnotated, | ||
|
|
@@ -172,6 +177,39 @@ interface Toggle { | |
| */ | ||
| suspend fun enroll(): Boolean | ||
|
|
||
| /** | ||
| * Returns a cold [Flow] of [Boolean] values representing whether this toggle is enabled. | ||
| * | ||
| * ### Behavior | ||
| * - When a collector starts, the current toggle value is emitted immediately. | ||
| * - Subsequent emissions occur whenever the underlying [store] writes a new [State]. | ||
| * - The flow is cold: a listener is only registered while it is being collected. | ||
| * - When collection is cancelled or completed, the registered listener is automatically unregistered. | ||
| * | ||
| * ### Thread-safety | ||
| * Emissions are delivered on the coroutine context where the flow is collected. | ||
| * Multiple collectors will each register their own listener instance. | ||
| * | ||
| * ### Example | ||
| * ``` | ||
| * viewModelScope.launch { | ||
| * toggle.enabled() | ||
| * .distinctUntilChanged() | ||
| * .collect { enabled -> | ||
| * if (enabled) { | ||
| * showOnboarding() | ||
| * } else { | ||
| * showLoading() | ||
| * } | ||
| * } | ||
| * } | ||
| * ``` | ||
| * | ||
| * @return a cold [Flow] that emits the current enabled state and any subsequent changes | ||
| * until the collector is cancelled. | ||
| */ | ||
| fun enabled(): Flow<Boolean> | ||
|
|
||
| /** | ||
| * This method | ||
| * - Returns whether the feature flag state is enabled or disabled. | ||
|
|
@@ -386,6 +424,28 @@ internal class ToggleImpl constructor( | |
| return enrollInternal() | ||
| } | ||
|
|
||
| override fun enabled(): Flow<Boolean> = callbackFlow { | ||
| // emit current value when someone starts collecting | ||
| trySend(isEnabled()) | ||
|
|
||
| val unsubscribe = when (val s = store) { | ||
| is CachedToggleStore -> { | ||
| s.setListener( | ||
| object : Listener { | ||
| override fun onToggleStored(newValue: State) { | ||
| // emit value just stored | ||
| trySend(isEnabled()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not just
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because |
||
| } | ||
| }, | ||
| ) | ||
| } | ||
| else -> { -> Unit } | ||
| } | ||
|
|
||
| // when flow collection is cancelled/closed, run the unsubscribe to avoid leaking the listener | ||
| awaitClose { unsubscribe() } | ||
| } | ||
|
|
||
| private fun enrollInternal(force: Boolean = false): Boolean { | ||
| // if the Toggle is not enabled, then we don't enroll | ||
| if (isEnabled() == false) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| /* | ||
| * Copyright (c) 2025 DuckDuckGo | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.duckduckgo.feature.toggles.api.internal | ||
|
|
||
| import com.duckduckgo.feature.toggles.api.Toggle | ||
| import com.duckduckgo.feature.toggles.api.Toggle.State | ||
| import com.google.common.cache.CacheBuilder | ||
| import com.google.common.cache.CacheLoader | ||
| import com.google.common.cache.LoadingCache | ||
| import org.jetbrains.annotations.TestOnly | ||
| import java.util.Optional | ||
| import kotlin.jvm.optionals.getOrNull | ||
|
|
||
| class CachedToggleStore constructor( | ||
| private val store: Toggle.Store, | ||
| ) : Toggle.Store { | ||
| @Volatile | ||
| private var listener: Listener? = null | ||
|
|
||
| private val cache: LoadingCache<String, Optional<State>> = | ||
| CacheBuilder | ||
| .newBuilder() | ||
| .maximumSize(50) | ||
| .build( | ||
| object : CacheLoader<String, Optional<State>>() { | ||
| override fun load(key: String): Optional<State> = Optional.ofNullable(store.get(key)) | ||
| }, | ||
| ) | ||
|
|
||
| override fun set( | ||
| key: String, | ||
| state: State, | ||
| ) { | ||
| cache.asMap().compute(key) { k, _ -> | ||
| store.set(k, state) | ||
| Optional.of(state) | ||
| } | ||
| // Notify AFTER compute() to avoid deadlocks or re-entrancy into the cache/store. | ||
| // If the store.set() above throws, this never runs (which is what we want). | ||
| // Swallow listener exceptions so they don't break writes. | ||
| listener?.runCatching { onToggleStored(state) } | ||
| } | ||
|
|
||
| /** | ||
| * Registers a [Listener] to observe changes in toggle states stored by this [CachedToggleStore]. | ||
| * | ||
| * Only a single listener is supported at a time. When a new listener is set, it replaces any | ||
| * previously registered listener. To avoid memory leaks, callers should always invoke the returned | ||
| * unsubscribe function when the listener is no longer needed (for example, when the collector | ||
| * of a [kotlinx.coroutines.flow.callbackFlow] is closed). | ||
| * | ||
| * Example usage: | ||
| * ``` | ||
| * val unsubscribe = cachedToggleStore.setListener(object : CachedToggleStore.Listener { | ||
| * override fun onToggleStored(newValue: Toggle.State, oldValue: Toggle.State?) { | ||
| * // React to state change | ||
| * } | ||
| * }) | ||
| * | ||
| * // Later, when no longer interested: | ||
| * unsubscribe() | ||
| * ``` | ||
| * | ||
| * @param listener the [Listener] instance that will receive callbacks for each `set` operation. | ||
| * @return a function that removes the listener when invoked. The returned function is safe to call | ||
| * multiple times and will only clear the listener if it is the same instance that was | ||
| * originally registered. | ||
| */ | ||
| fun setListener(listener: Listener?): () -> Unit { | ||
| this.listener = listener | ||
|
|
||
| return { if (this.listener === listener) this.listener = null } | ||
| } | ||
|
|
||
| /** | ||
| * DO NOT USE outside tests | ||
| */ | ||
| @TestOnly | ||
| fun invalidateAll() { | ||
| cache.invalidateAll() | ||
| } | ||
|
|
||
| override fun get(key: String): State? { | ||
| val value = cache.get(key).getOrNull() | ||
| if (value == null) { | ||
| // avoid negative caching | ||
| cache.invalidate(key) | ||
| } | ||
|
|
||
| return value | ||
| } | ||
|
|
||
| interface Listener { | ||
| fun onToggleStored(newValue: State) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a coroutine context isn't specified, we'll use the same one that the consumer has specified (which in practice, might be
Mainmost of he time. Shoud we docallbackFlow {...}.flowOn(Dispatchers.IO)? In practice, if the value is cached (and it should) it might not make much of a difference, but I think it still doesn't hurt to take some load off the main thread regardlessThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No strong opinions, that also has the issue that -- other than API documentation -- it's hard to make the caller aware of it, it can happen the caller is not and then it tries to refresh the UI in IO, which will crash.
That said, I can add it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the caller doesn't specify its own context when calling the coroutine and they use
appCoroutineScopeinstead oflifecycleScopethen yes, it will crash. That being said, I'd rather have the app crashing in that case (which will be very hard to miss during implementation and/or automated testing) than have the app silently run non-UI work in the main threadThe context we set in
flowOnwill only apply to that firsttrySendblock, though, as the second one is within a callback, so if we want to also set the context there, we'd need to launch a coroutine