-
Notifications
You must be signed in to change notification settings - Fork 8
feat: Multi provider impl #168
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
Merged
Merged
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
af65a59
Multi provider impl draft
PenguinDan 79f59ba
Ktlint
PenguinDan 6127d68
Try emit initial state for Multi provider
PenguinDan 2142879
Shared flow should always have content
PenguinDan c6f3a8f
Add tests
PenguinDan f2f415a
Update multi provider strategy to better align with Open Feature specs
PenguinDan 199236c
Add original metadata and allow all providers to shutdown
PenguinDan 38fac8c
Add default reason to default value in First Match Strategy
PenguinDan 48033c5
Remove json dependency and update ProviderMetadata
PenguinDan 4fa33eb
Align to Event spec
PenguinDan d5f5546
Ktlint
PenguinDan 8d8cbec
Update API dumps for multiprovider and ProviderMetadata changes
PenguinDan 3818a11
Use Lazy and ktlint
PenguinDan f834a43
Add TODO once EventDetails have been added
PenguinDan c299717
PR comments; remove redundant comments, fix test definitions, move st…
PenguinDan 2305056
Return an error result for FirstSuccessfulStrategy rather than throwing
PenguinDan 70a892d
Revert sample app changes
PenguinDan 6fe18eb
Add README documentation for Multiprovider
PenguinDan 870ae07
Lets favor not throwing in the FirstMatchStrategy also
PenguinDan 5472c08
Update tests to represent to non-throwing pattern and api dump
PenguinDan 7df6772
Kotlin Format
PenguinDan 2cc75c5
Update kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/mu…
PenguinDan e71d448
Update multi provider readme
PenguinDan c4c49db
Merge branch 'main' into MultiProvider-Impl
PenguinDan f540783
API dump
PenguinDan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
57 changes: 57 additions & 0 deletions
57
...-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
package dev.openfeature.kotlin.sdk.multiprovider | ||
|
||
import dev.openfeature.kotlin.sdk.EvaluationContext | ||
import dev.openfeature.kotlin.sdk.FeatureProvider | ||
import dev.openfeature.kotlin.sdk.ProviderEvaluation | ||
import dev.openfeature.kotlin.sdk.exceptions.ErrorCode | ||
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError | ||
|
||
/** | ||
* Return the first result returned by a provider. Skip providers that indicate they had no value due to FLAG_NOT_FOUND. | ||
* In all other cases, use the value returned by the provider. If any provider returns an error result other than | ||
* FLAG_NOT_FOUND, the whole evaluation should error and "bubble up" the individual provider's error in the result. | ||
* | ||
* As soon as a value is returned by a provider, the rest of the operation should short-circuit and not call the | ||
* rest of the providers. | ||
*/ | ||
class FirstMatchStrategy : Strategy { | ||
/** | ||
* Evaluates providers in sequence until finding one that has knowledge of the flag. | ||
* | ||
* @param providers List of providers to evaluate in order | ||
* @param key The feature flag key to look up | ||
* @param defaultValue Value to return if no provider knows about the flag | ||
* @param evaluationContext Optional context for evaluation | ||
* @param flagEval The specific evaluation method to call on each provider | ||
* @return ProviderEvaluation with the first match or default value | ||
*/ | ||
bencehornak marked this conversation as resolved.
Show resolved
Hide resolved
|
||
override fun <T> evaluate( | ||
providers: List<FeatureProvider>, | ||
key: String, | ||
defaultValue: T, | ||
evaluationContext: EvaluationContext?, | ||
flagEval: FlagEval<T> | ||
): ProviderEvaluation<T> { | ||
// Iterate through each provider in the provided order | ||
for (provider in providers) { | ||
try { | ||
// Call the flag evaluation method on the current provider | ||
val eval = provider.flagEval(key, defaultValue, evaluationContext) | ||
|
||
// If the provider knows about this flag (any result except FLAG_NOT_FOUND), | ||
// return this result immediately, even if it's an error | ||
if (eval.errorCode != ErrorCode.FLAG_NOT_FOUND) { | ||
return eval | ||
} | ||
// Continue to next provider if error is FLAG_NOT_FOUND | ||
} catch (_: OpenFeatureError.FlagNotFoundError) { | ||
PenguinDan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Handle FLAG_NOT_FOUND exception - continue to next provider | ||
continue | ||
} | ||
// We don't catch any other exception, but rather, bubble up the exceptions | ||
} | ||
|
||
// No provider knew about the flag, return default value with DEFAULT reason | ||
return ProviderEvaluation(defaultValue, errorCode = ErrorCode.FLAG_NOT_FOUND) | ||
PenguinDan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} |
56 changes: 56 additions & 0 deletions
56
...src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
package dev.openfeature.kotlin.sdk.multiprovider | ||
|
||
import dev.openfeature.kotlin.sdk.EvaluationContext | ||
import dev.openfeature.kotlin.sdk.FeatureProvider | ||
import dev.openfeature.kotlin.sdk.ProviderEvaluation | ||
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError | ||
|
||
/** | ||
* Similar to "First Match", except that errors from evaluated providers do not halt execution. | ||
* Instead, it will return the first successful result from a provider. | ||
* | ||
* If no provider successfully responds, it will throw an error result. | ||
*/ | ||
bencehornak marked this conversation as resolved.
Show resolved
Hide resolved
|
||
class FirstSuccessfulStrategy : Strategy { | ||
/** | ||
* Evaluates providers in sequence until finding one that returns a successful result. | ||
* | ||
* @param providers List of providers to evaluate in order | ||
* @param key The feature flag key to evaluate | ||
* @param defaultValue Value to use in provider evaluations | ||
* @param evaluationContext Optional context for evaluation | ||
* @param flagEval The specific evaluation method to call on each provider | ||
* @return ProviderEvaluation with the first successful result | ||
* @throws OpenFeatureError.GeneralError if no provider returns a successful evaluation | ||
*/ | ||
override fun <T> evaluate( | ||
PenguinDan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
providers: List<FeatureProvider>, | ||
key: String, | ||
defaultValue: T, | ||
evaluationContext: EvaluationContext?, | ||
flagEval: FlagEval<T> | ||
): ProviderEvaluation<T> { | ||
// Iterate through each provider in the provided order | ||
for (provider in providers) { | ||
try { | ||
// Call the flag evaluation method on the current provider | ||
val eval = provider.flagEval(key, defaultValue, evaluationContext) | ||
|
||
// If the provider returned a successful result (no error), | ||
// return this result immediately | ||
if (eval.errorCode == null) { | ||
return eval | ||
} | ||
// Continue to next provider if this one had an error | ||
} catch (_: OpenFeatureError) { | ||
// Handle any OpenFeature exceptions - continue to next provider | ||
// FirstSuccessful strategy skips errors and continues | ||
continue | ||
} | ||
} | ||
|
||
// No provider returned a successful result, throw an error | ||
// This indicates that all providers either failed or had errors | ||
throw OpenFeatureError.GeneralError("No provider returned a successful evaluation for the requested flag.") | ||
} | ||
} |
225 changes: 225 additions & 0 deletions
225
kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
package dev.openfeature.kotlin.sdk.multiprovider | ||
|
||
import dev.openfeature.kotlin.sdk.EvaluationContext | ||
import dev.openfeature.kotlin.sdk.FeatureProvider | ||
import dev.openfeature.kotlin.sdk.Hook | ||
import dev.openfeature.kotlin.sdk.ProviderEvaluation | ||
import dev.openfeature.kotlin.sdk.ProviderMetadata | ||
import dev.openfeature.kotlin.sdk.Value | ||
import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents | ||
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError | ||
import kotlinx.coroutines.async | ||
import kotlinx.coroutines.awaitAll | ||
import kotlinx.coroutines.coroutineScope | ||
import kotlinx.coroutines.flow.Flow | ||
import kotlinx.coroutines.flow.MutableSharedFlow | ||
import kotlinx.coroutines.flow.asSharedFlow | ||
import kotlinx.coroutines.flow.launchIn | ||
import kotlinx.coroutines.flow.onEach | ||
|
||
/** | ||
* MultiProvider is a FeatureProvider implementation that delegates flag evaluations | ||
* to multiple underlying providers using a configurable strategy. | ||
* | ||
* This class acts as a composite provider that can: | ||
* - Combine multiple feature providers into a single interface | ||
* - Apply different evaluation strategies (FirstMatch, FirstSuccessful, etc.) | ||
* - Manage lifecycle events for all underlying providers | ||
* - Forward context changes to all providers | ||
* | ||
* @param providers List of FeatureProvider instances to delegate to | ||
* @param strategy Strategy to use for combining provider results (defaults to FirstMatchStrategy) | ||
*/ | ||
class MultiProvider( | ||
providers: List<FeatureProvider>, | ||
private val strategy: Strategy = FirstMatchStrategy(), | ||
) : FeatureProvider { | ||
// Metadata identifying this as a multiprovider | ||
override val metadata: ProviderMetadata = object : ProviderMetadata { | ||
override val name: String? = "multiprovider" | ||
PenguinDan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// TODO: Support hooks | ||
PenguinDan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
override val hooks: List<Hook<*>> = emptyList() | ||
private val uniqueProviders = getUniqueSetOfProviders(providers) | ||
|
||
// Shared flow because we don't want the distinct operator since it would break consecutive emits of | ||
// ProviderConfigurationChanged | ||
private val eventFlow = MutableSharedFlow<OpenFeatureProviderEvents>(replay = 1, extraBufferCapacity = 5).apply { | ||
OpenFeatureProviderEvents.ProviderError(OpenFeatureError.ProviderNotReadyError()) | ||
} | ||
|
||
// Track individual provider statuses | ||
private val providerStatuses = mutableMapOf<FeatureProvider, OpenFeatureProviderEvents>() | ||
PenguinDan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Event precedence (highest to lowest priority) - based on the specifications | ||
private val eventPrecedence = mapOf( | ||
OpenFeatureProviderEvents.ProviderError::class to 4, // FATAL/ERROR | ||
OpenFeatureProviderEvents.ProviderNotReady::class to 3, // NOT READY, Deprecated but still supporting | ||
PenguinDan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
OpenFeatureProviderEvents.ProviderStale::class to 2, // STALE | ||
OpenFeatureProviderEvents.ProviderReady::class to 1 // READY | ||
// ProviderConfigurationChanged doesn't affect status, so not included | ||
) | ||
|
||
private fun getUniqueSetOfProviders(providers: List<FeatureProvider>): List<FeatureProvider> { | ||
val setOfProviderNames = mutableSetOf<String>() | ||
val uniqueProviders = mutableListOf<FeatureProvider>() | ||
providers.forEach { currProvider -> | ||
val providerName = currProvider.metadata.name | ||
if (setOfProviderNames.add(providerName.orEmpty())) { | ||
uniqueProviders.add(currProvider) | ||
} else { | ||
println("Duplicate provider with name $providerName found") // Log error, no logging tool | ||
} | ||
} | ||
|
||
return uniqueProviders | ||
} | ||
|
||
/** | ||
* @return Number of unique providers | ||
*/ | ||
fun getProviderCount(): Int = uniqueProviders.size | ||
|
||
override fun observe(): Flow<OpenFeatureProviderEvents> = eventFlow.asSharedFlow() | ||
|
||
/** | ||
* Initializes all underlying providers with the given context. | ||
* This ensures all providers are ready before any evaluations occur. | ||
* | ||
* @param initialContext Optional evaluation context to initialize providers with | ||
*/ | ||
override suspend fun initialize(initialContext: EvaluationContext?) { | ||
coroutineScope { | ||
// Listen to events emitted by providers to emit our own set of events | ||
// according to https://openfeature.dev/specification/appendix-a/#status-and-event-handling | ||
uniqueProviders.forEach { provider -> | ||
provider.observe() | ||
.onEach { event -> | ||
handleProviderEvent(provider, event) | ||
} | ||
.launchIn(this) | ||
} | ||
|
||
// State updates captured by observing individual Feature Flag providers | ||
uniqueProviders | ||
.map { async { it.initialize(initialContext) } } | ||
.awaitAll() | ||
} | ||
} | ||
|
||
private suspend fun handleProviderEvent(provider: FeatureProvider, event: OpenFeatureProviderEvents) { | ||
val hasStatusUpdated = updateProviderStatus(provider, event) | ||
|
||
// This event should be re-emitted any time it occurs from any provider. | ||
if (event is OpenFeatureProviderEvents.ProviderConfigurationChanged) { | ||
eventFlow.emit(event) | ||
return | ||
} | ||
|
||
// If the status has been updated, calculate what our new event should be | ||
if (hasStatusUpdated) { | ||
val currPrecedenceVal = eventFlow.replayCache.firstOrNull()?.run { eventPrecedence[this::class] } ?: 0 | ||
val updatedPrecedenceVal = eventPrecedence[event::class] ?: 0 | ||
|
||
if (updatedPrecedenceVal > currPrecedenceVal) { | ||
eventFlow.emit(event) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* @return true if the status has been updated to a different value, false otherwise | ||
*/ | ||
private fun updateProviderStatus(provider: FeatureProvider, newStatus: OpenFeatureProviderEvents): Boolean { | ||
val oldStatus = providerStatuses[provider] | ||
providerStatuses[provider] = newStatus | ||
|
||
return oldStatus != newStatus | ||
} | ||
|
||
/** | ||
* Shuts down all underlying providers. | ||
* This allows providers to clean up resources and complete any pending operations. | ||
*/ | ||
override fun shutdown() { | ||
uniqueProviders.forEach { it.shutdown() } | ||
} | ||
|
||
override suspend fun onContextSet( | ||
oldContext: EvaluationContext?, | ||
newContext: EvaluationContext | ||
) { | ||
uniqueProviders.forEach { it.onContextSet(oldContext, newContext) } | ||
} | ||
|
||
override fun getBooleanEvaluation( | ||
key: String, | ||
defaultValue: Boolean, | ||
context: EvaluationContext? | ||
): ProviderEvaluation<Boolean> { | ||
return strategy.evaluate( | ||
uniqueProviders, | ||
key, | ||
defaultValue, | ||
context, | ||
FeatureProvider::getBooleanEvaluation, | ||
) | ||
} | ||
|
||
override fun getStringEvaluation( | ||
key: String, | ||
defaultValue: String, | ||
context: EvaluationContext? | ||
): ProviderEvaluation<String> { | ||
return strategy.evaluate( | ||
uniqueProviders, | ||
key, | ||
defaultValue, | ||
context, | ||
FeatureProvider::getStringEvaluation, | ||
) | ||
} | ||
|
||
override fun getIntegerEvaluation( | ||
key: String, | ||
defaultValue: Int, | ||
context: EvaluationContext? | ||
): ProviderEvaluation<Int> { | ||
return strategy.evaluate( | ||
uniqueProviders, | ||
key, | ||
defaultValue, | ||
context, | ||
FeatureProvider::getIntegerEvaluation, | ||
) | ||
} | ||
|
||
override fun getDoubleEvaluation( | ||
key: String, | ||
defaultValue: Double, | ||
context: EvaluationContext? | ||
): ProviderEvaluation<Double> { | ||
return strategy.evaluate( | ||
uniqueProviders, | ||
key, | ||
defaultValue, | ||
context, | ||
FeatureProvider::getDoubleEvaluation, | ||
) | ||
} | ||
|
||
override fun getObjectEvaluation( | ||
key: String, | ||
defaultValue: Value, | ||
context: EvaluationContext? | ||
): ProviderEvaluation<Value> { | ||
return strategy.evaluate( | ||
uniqueProviders, | ||
key, | ||
defaultValue, | ||
context, | ||
FeatureProvider::getObjectEvaluation, | ||
) | ||
} | ||
} |
41 changes: 41 additions & 0 deletions
41
kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
package dev.openfeature.kotlin.sdk.multiprovider | ||
|
||
import dev.openfeature.kotlin.sdk.EvaluationContext | ||
import dev.openfeature.kotlin.sdk.FeatureProvider | ||
import dev.openfeature.kotlin.sdk.ProviderEvaluation | ||
|
||
/** | ||
* Type alias for a function that evaluates a feature flag using a FeatureProvider. | ||
* This represents an extension function on FeatureProvider that takes: | ||
* - key: The feature flag key to evaluate | ||
* - defaultValue: The default value to return if evaluation fails | ||
* - evaluationContext: Optional context for the evaluation | ||
* Returns a ProviderEvaluation containing the result | ||
*/ | ||
typealias FlagEval<T> = FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation<T> | ||
|
||
/** | ||
* Strategy interface defines how multiple feature providers should be evaluated | ||
* to determine the final result for a feature flag evaluation. | ||
* Different strategies can implement different logic for combining or selecting | ||
* results from multiple providers. | ||
*/ | ||
interface Strategy { | ||
bencehornak marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/** | ||
* Evaluates a feature flag across multiple providers using the strategy's logic. | ||
* | ||
* @param providers List of FeatureProvider instances to evaluate against | ||
* @param key The feature flag key to evaluate | ||
* @param defaultValue The default value to use if evaluation fails or no providers match | ||
* @param evaluationContext Optional context containing additional data for evaluation | ||
* @param flagEval Function reference to the specific evaluation method to call on each provider | ||
* @return ProviderEvaluation<T> containing the final evaluation result | ||
*/ | ||
fun <T> evaluate( | ||
providers: List<FeatureProvider>, | ||
key: String, | ||
defaultValue: T, | ||
evaluationContext: EvaluationContext?, | ||
flagEval: FlagEval<T>, | ||
): ProviderEvaluation<T> | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.