From af65a59e2ffdcddbcd4df00bd44b18e183b5caa5 Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 19 Aug 2025 17:54:07 -0700 Subject: [PATCH 01/24] Multi provider impl draft Signed-off-by: penguindan --- .../sdk/multiprovider/FirstMatchStrategy.kt | 57 +++++ .../multiprovider/FirstSuccessfulStrategy.kt | 56 +++++ .../kotlin/sdk/multiprovider/MultiProvider.kt | 225 ++++++++++++++++++ .../kotlin/sdk/multiprovider/Strategy.kt | 41 ++++ 4 files changed, 379 insertions(+) create mode 100644 kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt create mode 100644 kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt create mode 100644 kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt create mode 100644 kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt new file mode 100644 index 0000000..443a51e --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt @@ -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 + */ + override fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation { + // 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) { + // 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) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt new file mode 100644 index 0000000..bbfbee5 --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt @@ -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. + */ +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 evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation { + // 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.") + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt new file mode 100644 index 0000000..b11403c --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -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, + private val strategy: Strategy = FirstMatchStrategy(), +) : FeatureProvider { + // Metadata identifying this as a multiprovider + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String? = "multiprovider" + } + + // TODO: Support hooks + override val hooks: List> = 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(replay = 1, extraBufferCapacity = 5).apply { + OpenFeatureProviderEvents.ProviderError(OpenFeatureError.ProviderNotReadyError()) + } + + // Track individual provider statuses + private val providerStatuses = mutableMapOf() + + // 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 + 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): List { + val setOfProviderNames = mutableSetOf() + val uniqueProviders = mutableListOf() + 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 = 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 { + return strategy.evaluate( + uniqueProviders, + key, + defaultValue, + context, + FeatureProvider::getBooleanEvaluation, + ) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + uniqueProviders, + key, + defaultValue, + context, + FeatureProvider::getStringEvaluation, + ) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + uniqueProviders, + key, + defaultValue, + context, + FeatureProvider::getIntegerEvaluation, + ) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + uniqueProviders, + key, + defaultValue, + context, + FeatureProvider::getDoubleEvaluation, + ) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value, + context: EvaluationContext? + ): ProviderEvaluation { + return strategy.evaluate( + uniqueProviders, + key, + defaultValue, + context, + FeatureProvider::getObjectEvaluation, + ) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt new file mode 100644 index 0000000..1780014 --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt @@ -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 = FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation + +/** + * 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 { + /** + * 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 containing the final evaluation result + */ + fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval, + ): ProviderEvaluation +} \ No newline at end of file From 79f59ba5d9c5e586f131034f5a6c8e5f7da96e83 Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 19 Aug 2025 17:59:58 -0700 Subject: [PATCH 02/24] Ktlint Signed-off-by: penguindan --- .../kotlin/sdk/multiprovider/MultiProvider.kt | 12 ++++++------ .../openfeature/kotlin/sdk/multiprovider/Strategy.kt | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index b11403c..3c65fca 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach */ class MultiProvider( providers: List, - private val strategy: Strategy = FirstMatchStrategy(), + private val strategy: Strategy = FirstMatchStrategy() ) : FeatureProvider { // Metadata identifying this as a multiprovider override val metadata: ProviderMetadata = object : ProviderMetadata { @@ -163,7 +163,7 @@ class MultiProvider( key, defaultValue, context, - FeatureProvider::getBooleanEvaluation, + FeatureProvider::getBooleanEvaluation ) } @@ -177,7 +177,7 @@ class MultiProvider( key, defaultValue, context, - FeatureProvider::getStringEvaluation, + FeatureProvider::getStringEvaluation ) } @@ -191,7 +191,7 @@ class MultiProvider( key, defaultValue, context, - FeatureProvider::getIntegerEvaluation, + FeatureProvider::getIntegerEvaluation ) } @@ -205,7 +205,7 @@ class MultiProvider( key, defaultValue, context, - FeatureProvider::getDoubleEvaluation, + FeatureProvider::getDoubleEvaluation ) } @@ -219,7 +219,7 @@ class MultiProvider( key, defaultValue, context, - FeatureProvider::getObjectEvaluation, + FeatureProvider::getObjectEvaluation ) } } \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt index 1780014..bdd8291 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt @@ -12,7 +12,8 @@ import dev.openfeature.kotlin.sdk.ProviderEvaluation * - evaluationContext: Optional context for the evaluation * Returns a ProviderEvaluation containing the result */ -typealias FlagEval = FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation +typealias FlagEval = + FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation /** * Strategy interface defines how multiple feature providers should be evaluated @@ -23,8 +24,7 @@ typealias FlagEval = FeatureProvider.(key: String, defaultValue: T, evaluatio interface Strategy { /** * Evaluates a feature flag across multiple providers using the strategy's logic. - * - * @param providers List of FeatureProvider instances to evaluate against + * * @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 @@ -36,6 +36,6 @@ interface Strategy { key: String, defaultValue: T, evaluationContext: EvaluationContext?, - flagEval: FlagEval, + flagEval: FlagEval ): ProviderEvaluation } \ No newline at end of file From 6127d6834f6bc55b8f769508e9ff99791c59fc78 Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 19 Aug 2025 18:22:38 -0700 Subject: [PATCH 03/24] Try emit initial state for Multi provider Signed-off-by: penguindan --- .../dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 3c65fca..4971a0d 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -46,7 +46,7 @@ class MultiProvider( // Shared flow because we don't want the distinct operator since it would break consecutive emits of // ProviderConfigurationChanged private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5).apply { - OpenFeatureProviderEvents.ProviderError(OpenFeatureError.ProviderNotReadyError()) + tryEmit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.ProviderNotReadyError())) } // Track individual provider statuses From 2142879407dd3b3905d456023e46a185266754a8 Mon Sep 17 00:00:00 2001 From: penguindan Date: Wed, 20 Aug 2025 07:49:11 -0700 Subject: [PATCH 04/24] Shared flow should always have content Signed-off-by: penguindan --- .../dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 4971a0d..6cf769f 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -119,7 +121,7 @@ class MultiProvider( // 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 currPrecedenceVal = eventFlow.first().run { eventPrecedence[this::class] } ?: 0 val updatedPrecedenceVal = eventPrecedence[event::class] ?: 0 if (updatedPrecedenceVal > currPrecedenceVal) { From c6f3a8f278bebdef2df52e776a7973730a4b4b6f Mon Sep 17 00:00:00 2001 From: penguindan Date: Wed, 20 Aug 2025 12:05:31 -0700 Subject: [PATCH 05/24] Add tests Signed-off-by: penguindan --- .../kotlin/sdk/multiprovider/MultiProvider.kt | 11 +- .../sdk/helpers/RecordingBooleanProvider.kt | 58 +++++ .../multiprovider/FirstMatchStrategyTests.kt | 168 +++++++++++++ .../FirstSuccessfulStrategyTests.kt | 98 ++++++++ .../sdk/multiprovider/MultiProviderTests.kt | 223 ++++++++++++++++++ 5 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt create mode 100644 kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt create mode 100644 kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt create mode 100644 kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 6cf769f..7a546e7 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -7,15 +7,12 @@ 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.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -47,9 +44,7 @@ class MultiProvider( // Shared flow because we don't want the distinct operator since it would break consecutive emits of // ProviderConfigurationChanged - private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5).apply { - tryEmit(OpenFeatureProviderEvents.ProviderError(OpenFeatureError.ProviderNotReadyError())) - } + private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) // Track individual provider statuses private val providerStatuses = mutableMapOf() @@ -121,7 +116,9 @@ class MultiProvider( // If the status has been updated, calculate what our new event should be if (hasStatusUpdated) { - val currPrecedenceVal = eventFlow.first().run { eventPrecedence[this::class] } ?: 0 + // Utilize the replay cache to get the current event with the highest precedence since the + // eventFlow may be empty if no events have been emitted yet. + val currPrecedenceVal = eventFlow.replayCache.firstOrNull()?.run { eventPrecedence[this::class] } ?: 0 val updatedPrecedenceVal = eventPrecedence[event::class] ?: 0 if (updatedPrecedenceVal > currPrecedenceVal) { diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt new file mode 100644 index 0000000..763968d --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt @@ -0,0 +1,58 @@ +package dev.openfeature.kotlin.sdk.helpers + +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 + +class RecordingBooleanProvider( + private val name: String, + private val behavior: () -> ProviderEvaluation +) : FeatureProvider { + override val hooks: List> = emptyList() + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String? = this@RecordingBooleanProvider.name + } + + var booleanEvalCalls: Int = 0 + private set + + override suspend fun initialize(initialContext: EvaluationContext?) { + // no-op + } + + override fun shutdown() { + // no-op + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + // no-op + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean, + context: EvaluationContext? + ): ProviderEvaluation { + booleanEvalCalls += 1 + return behavior() + } + + override fun getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?): ProviderEvaluation { + throw UnsupportedOperationException() + } + + override fun getIntegerEvaluation(key: String, defaultValue: Int, context: EvaluationContext?): ProviderEvaluation { + throw UnsupportedOperationException() + } + + override fun getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?): ProviderEvaluation { + throw UnsupportedOperationException() + } + + override fun getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?): ProviderEvaluation { + throw UnsupportedOperationException() + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt new file mode 100644 index 0000000..7d0fe54 --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt @@ -0,0 +1,168 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +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 +import dev.openfeature.kotlin.sdk.helpers.RecordingBooleanProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class FirstMatchStrategyTests { + + @Test + fun returns_first_success_without_calling_next_providers() { + val strategy = FirstMatchStrategy() + val first = RecordingBooleanProvider( + name = "first", + behavior = { ProviderEvaluation(true) } + ) + val second = RecordingBooleanProvider( + name = "second", + behavior = { ProviderEvaluation(false) } + ) + + val result = strategy.evaluate( + listOf(first, second), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, first.booleanEvalCalls) + // Short-circuits after first provider + assertEquals(0, second.booleanEvalCalls) + } + + @Test + fun skips_flag_not_found_and_returns_next_match() { + val strategy = FirstMatchStrategy() + val notFoundProvider = RecordingBooleanProvider( + name = "not-found", + behavior = { ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) } + ) + val matchingProvider = RecordingBooleanProvider( + name = "match", + behavior = { ProviderEvaluation(true) } + ) + val neverCalled = RecordingBooleanProvider( + name = "never", + behavior = { ProviderEvaluation(false) } + ) + + val result = strategy.evaluate( + listOf(notFoundProvider, matchingProvider, neverCalled), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + // First provider called but skipped + assertEquals(1, notFoundProvider.booleanEvalCalls) + // Second provider returned and short-circuited + assertEquals(1, matchingProvider.booleanEvalCalls) + assertEquals(0, neverCalled.booleanEvalCalls) + } + + @Test + fun treats_FlagNotFound_exception_as_not_found_and_continues() { + val strategy = FirstMatchStrategy() + val throwsNotFound = RecordingBooleanProvider( + name = "throws-not-found", + behavior = { throw OpenFeatureError.FlagNotFoundError("flag") } + ) + val matchingProvider = RecordingBooleanProvider( + name = "match", + behavior = { ProviderEvaluation(true) } + ) + + val result = strategy.evaluate( + listOf(throwsNotFound, matchingProvider), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, throwsNotFound.booleanEvalCalls) + assertEquals(1, matchingProvider.booleanEvalCalls) + } + + @Test + fun returns_error_result_other_than_not_found_and_short_circuits() { + val strategy = FirstMatchStrategy() + val errorProvider = RecordingBooleanProvider( + name = "error", + behavior = { ProviderEvaluation(false, errorCode = ErrorCode.GENERAL, errorMessage = "boom") } + ) + val neverCalled = RecordingBooleanProvider( + name = "never", + behavior = { ProviderEvaluation(true) } + ) + + val result = strategy.evaluate( + listOf(errorProvider, neverCalled), + key = "flag", + defaultValue = true, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(false, result.value) + assertEquals(ErrorCode.GENERAL, result.errorCode) + assertEquals(1, errorProvider.booleanEvalCalls) + assertEquals(0, neverCalled.booleanEvalCalls) + } + + @Test + fun bubbles_up_non_not_found_exceptions() { + val strategy = FirstMatchStrategy() + val throwsGeneral = RecordingBooleanProvider( + name = "throws-general", + behavior = { throw OpenFeatureError.GeneralError("fail") } + ) + + assertFailsWith { + strategy.evaluate( + listOf(throwsGeneral), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + } + assertEquals(1, throwsGeneral.booleanEvalCalls) + } + + @Test + fun returns_default_with_not_found_when_no_provider_matches() { + val strategy = FirstMatchStrategy() + val p1 = RecordingBooleanProvider( + name = "p1", + behavior = { ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) } + ) + val p2 = RecordingBooleanProvider( + name = "p2", + behavior = { throw OpenFeatureError.FlagNotFoundError("flag") } + ) + + val result = strategy.evaluate( + listOf(p1, p2), + key = "flag", + defaultValue = true, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.errorCode) + assertEquals(1, p1.booleanEvalCalls) + assertEquals(1, p2.booleanEvalCalls) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt new file mode 100644 index 0000000..b85d0de --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt @@ -0,0 +1,98 @@ +package dev.openfeature.kotlin.sdk.multiprovider + +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.helpers.RecordingBooleanProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class FirstSuccessfulStrategyTests { + + @Test + fun returns_first_success_ignoring_prior_errors() { + val strategy = FirstSuccessfulStrategy() + val error1 = RecordingBooleanProvider("e1") { + throw OpenFeatureError.GeneralError("boom1") + } + val error2 = RecordingBooleanProvider("e2") { + // Simulate provider returning error result (not success) + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.GENERAL) + } + val success = RecordingBooleanProvider("ok") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(true) + } + val never = RecordingBooleanProvider("never") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false) + } + + val result = strategy.evaluate( + listOf(error1, error2, success, never), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, error1.booleanEvalCalls) + assertEquals(1, error2.booleanEvalCalls) + assertEquals(1, success.booleanEvalCalls) + assertEquals(0, never.booleanEvalCalls) + } + + @Test + fun skips_flag_not_found_error_and_result_until_success() { + val strategy = FirstSuccessfulStrategy() + val notFoundThrow = RecordingBooleanProvider("nf-throw") { + throw OpenFeatureError.FlagNotFoundError("flag") + } + val notFoundResult = RecordingBooleanProvider("nf-result") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) + } + val success = RecordingBooleanProvider("ok") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(true) + } + + val result = strategy.evaluate( + listOf(notFoundThrow, notFoundResult, success), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + + assertEquals(true, result.value) + assertEquals(1, notFoundThrow.booleanEvalCalls) + assertEquals(1, notFoundResult.booleanEvalCalls) + assertEquals(1, success.booleanEvalCalls) + } + + @Test + fun throws_when_no_provider_returns_success() { + val strategy = FirstSuccessfulStrategy() + val error1 = RecordingBooleanProvider("e1") { + throw OpenFeatureError.GeneralError("boom1") + } + val error2 = RecordingBooleanProvider("e2") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.GENERAL) + } + val notFound = RecordingBooleanProvider("nf") { + dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) + } + + assertFailsWith { + strategy.evaluate( + listOf(error1, error2, notFound), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + } + assertEquals(1, error1.booleanEvalCalls) + assertEquals(1, error2.booleanEvalCalls) + assertEquals(1, notFound.booleanEvalCalls) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt new file mode 100644 index 0000000..3d11657 --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt @@ -0,0 +1,223 @@ +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.ImmutableContext +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class MultiProviderTests { + + @Test + fun deduplicates_providers_by_name() { + val p1 = FakeEventProvider(name = "dup") + val p2 = FakeEventProvider(name = "dup") + val p3 = FakeEventProvider(name = "unique") + + val multi = MultiProvider(listOf(p1, p2, p3)) + + assertEquals(2, multi.getProviderCount()) + } + + @Test + fun forwards_lifecycle_calls_to_underlying_providers() = runTest { + val provider = FakeEventProvider(name = "p") + val multi = MultiProvider(listOf(provider)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + assertEquals(1, provider.initializeCalls) + + val ctx = ImmutableContext("user-123") + multi.onContextSet(null, ctx) + assertEquals(1, provider.onContextSetCalls) + + multi.shutdown() + assertEquals(1, provider.shutdownCalls) + initJob.cancelAndJoin() + } + + @Test + fun observes_events_and_applies_precedence_after_configuration_change() = runTest { + // Including ProviderConfigurationChanged first allows subsequent lower-precedence READY to emit + val provider = FakeEventProvider( + name = "p", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val multi = MultiProvider(listOf(provider)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + // The last emitted event should be STALE given the sequence above + val last = multi.observe().first() + assertEquals(OpenFeatureProviderEvents.ProviderStale, last) + initJob.cancelAndJoin() + } + + @Test + fun uses_strategy_for_evaluations_and_preserves_unique_order() { + val p1 = FakeEventProvider(name = "A") + val dup = FakeEventProvider(name = "A") + val p2 = FakeEventProvider(name = "B") + + val recorder = RecordingStrategy(returnValue = ProviderEvaluation(true)) + val multi = MultiProvider(listOf(p1, dup, p2), strategy = recorder) + + val eval = multi.getBooleanEvaluation("flag", false, null) + + assertEquals(true, eval.value) + assertEquals(listOf("A", "B"), recorder.lastProviderNames) + } + + @Test + fun aggregates_event_precedence_across_multiple_providers() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val c = FakeEventProvider( + name = "C", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderNotReady, + OpenFeatureProviderEvents.ProviderError( + dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError.GeneralError("boom") + ) + ) + ) + val multi = MultiProvider(listOf(a, b, c)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + val last = multi.observe().first() + assertIs(last) + initJob.cancelAndJoin() + } +} + +// Helpers + +private class FakeEventProvider( + private val name: String, + private val eventsToEmitOnInit: List = emptyList() +) : FeatureProvider { + override val hooks: List> = emptyList() + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String? = this@FakeEventProvider.name + } + + private val events = MutableSharedFlow(replay = 1, extraBufferCapacity = 16) + + var initializeCalls: Int = 0 + private set + var shutdownCalls: Int = 0 + private set + var onContextSetCalls: Int = 0 + private set + + override suspend fun initialize(initialContext: EvaluationContext?) { + initializeCalls += 1 + // Emit any preconfigured events during initialize so MultiProvider observers receive them + eventsToEmitOnInit.forEach { events.emit(it) } + } + + override fun shutdown() { + shutdownCalls += 1 + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + onContextSetCalls += 1 + } + + override fun getBooleanEvaluation( + key: String, + defaultValue: Boolean, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getStringEvaluation( + key: String, + defaultValue: String, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getIntegerEvaluation( + key: String, + defaultValue: Int, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getDoubleEvaluation( + key: String, + defaultValue: Double, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun getObjectEvaluation( + key: String, + defaultValue: Value, + context: EvaluationContext? + ): ProviderEvaluation { + return ProviderEvaluation(defaultValue) + } + + override fun observe(): Flow = events +} + +private class RecordingStrategy( + private val returnValue: ProviderEvaluation +) : Strategy { + var lastProviderNames: List = emptyList() + private set + + override fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation { + lastProviderNames = providers.map { it.metadata.name.orEmpty() } + @Suppress("UNCHECKED_CAST") + return returnValue as ProviderEvaluation + } +} \ No newline at end of file From f2f415ad6a6df1e30c519247e510a78a64b8aacf Mon Sep 17 00:00:00 2001 From: penguindan Date: Wed, 20 Aug 2025 13:16:35 -0700 Subject: [PATCH 06/24] Update multi provider strategy to better align with Open Feature specs Signed-off-by: penguindan --- .../kotlin/sdk/multiprovider/MultiProvider.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 7a546e7..85030a9 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -44,7 +44,10 @@ class MultiProvider( // Shared flow because we don't want the distinct operator since it would break consecutive emits of // ProviderConfigurationChanged - private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + private val eventFlow = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 5, + ) // Track individual provider statuses private val providerStatuses = mutableMapOf() @@ -116,13 +119,16 @@ class MultiProvider( // If the status has been updated, calculate what our new event should be if (hasStatusUpdated) { - // Utilize the replay cache to get the current event with the highest precedence since the - // eventFlow may be empty if no events have been emitted yet. - val currPrecedenceVal = eventFlow.replayCache.firstOrNull()?.run { eventPrecedence[this::class] } ?: 0 - val updatedPrecedenceVal = eventPrecedence[event::class] ?: 0 + // Determine the highest-precedence status among all providers + val highestEvent = providerStatuses.values + .filter { it !is OpenFeatureProviderEvents.ProviderConfigurationChanged } + .maxByOrNull { eventPrecedence[it::class] ?: 0 } + + // Only emit if there's a change in overall status + val currentOverall = eventFlow.replayCache.lastOrNull() - if (updatedPrecedenceVal > currPrecedenceVal) { - eventFlow.emit(event) + if (highestEvent != null && highestEvent != currentOverall) { + eventFlow.emit(highestEvent) } } } From 199236ca7bf01c7b1e36741d59de5c72ffe7c097 Mon Sep 17 00:00:00 2001 From: penguindan Date: Wed, 20 Aug 2025 15:04:53 -0700 Subject: [PATCH 07/24] Add original metadata and allow all providers to shutdown Signed-off-by: penguindan --- kotlin-sdk/build.gradle.kts | 1 + .../kotlin/sdk/multiprovider/MultiProvider.kt | 78 +++++++++++++++++-- .../sdk/multiprovider/MultiProviderTests.kt | 52 ++++++++++++- 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/kotlin-sdk/build.gradle.kts b/kotlin-sdk/build.gradle.kts index d544c24..b8894ed 100644 --- a/kotlin-sdk/build.gradle.kts +++ b/kotlin-sdk/build.gradle.kts @@ -55,6 +55,7 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") } commonTest.dependencies { implementation("org.jetbrains.kotlin:kotlin-test:2.1.21") diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 85030a9..43b2f98 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -7,6 +7,7 @@ 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 @@ -15,6 +16,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject /** * MultiProvider is a FeatureProvider implementation that delegates flag evaluations @@ -33,20 +37,46 @@ class MultiProvider( providers: List, private val strategy: Strategy = FirstMatchStrategy() ) : FeatureProvider { - // Metadata identifying this as a multiprovider - override val metadata: ProviderMetadata = object : ProviderMetadata { - override val name: String? = "multiprovider" - } + private class ProviderShutdownException( + providerName: String, + cause: Throwable + ) : RuntimeException("Provider '$providerName' shutdown failed: ${cause.message}", cause) // TODO: Support hooks override val hooks: List> = emptyList() private val uniqueProviders = getUniqueSetOfProviders(providers) + // Metadata identifying this as a multiprovider + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String? + get() = constructName() + + /** + * Constructs the metadata for our MultiProvider according to + * https://openfeature.dev/specification/appendix-a#metadata + */ + private fun constructName(): String { + var unprovidedNameCounter = 1 + val multiproviderMetadataJson = buildJsonObject { + put("name", MULTIPROVIDER_NAME) + putJsonObject("originalMetadata") { + uniqueProviders.forEach { + putJsonObject(it.metadata.name ?: "$UNDEFINED_PROVIDER_NAME-${unprovidedNameCounter++}") { + put("name", it.metadata.name.orEmpty()) + } + } + } + } + + return multiproviderMetadataJson.toString() + } + } + // Shared flow because we don't want the distinct operator since it would break consecutive emits of // ProviderConfigurationChanged private val eventFlow = MutableSharedFlow( replay = 1, - extraBufferCapacity = 5, + extraBufferCapacity = 5 ) // Track individual provider statuses @@ -148,7 +178,31 @@ class MultiProvider( * This allows providers to clean up resources and complete any pending operations. */ override fun shutdown() { - uniqueProviders.forEach { it.shutdown() } + val shutdownErrors = mutableListOf>() + uniqueProviders.forEach { provider -> + try { + provider.shutdown() + } catch (t: Throwable) { + shutdownErrors += provider.metadata.getSafeName() to t + } + } + + if (shutdownErrors.isNotEmpty()) { + val message = buildString { + append("One or more providers failed to shutdown: ") + append( + shutdownErrors.joinToString(separator = "\n") { (name, err) -> + "$name: ${err.message}" + } + ) + } + + val aggregate = OpenFeatureError.GeneralError(message) + shutdownErrors.forEach { (name, err) -> + aggregate.addSuppressed(ProviderShutdownException(name, err)) + } + throw aggregate + } } override suspend fun onContextSet( @@ -227,4 +281,16 @@ class MultiProvider( FeatureProvider::getObjectEvaluation ) } + + /** + * Helps us have a consistent way to handle when providers don't provide a name + */ + private fun ProviderMetadata.getSafeName(): String { + return name ?: UNDEFINED_PROVIDER_NAME + } + + companion object { + private const val MULTIPROVIDER_NAME = "multiprovider" + private const val UNDEFINED_PROVIDER_NAME = "" + } } \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt index 3d11657..00a8da7 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt @@ -8,6 +8,7 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow @@ -18,7 +19,9 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class MultiProviderTests { @@ -124,13 +127,57 @@ class MultiProviderTests { assertIs(last) initJob.cancelAndJoin() } + + @Test + fun metadata_includes_original_metadata_and_handles_unnamed_providers() { + val named = FakeEventProvider(name = "A") + val unnamed = FakeEventProvider(name = null) + + val multi = MultiProvider(listOf(named, unnamed)) + + val metadataString = multi.metadata.name ?: error("metadata.name should not be null") + + // Simple substring assertions to validate structure without full JSON parsing + assertTrue(metadataString.contains("\"name\":\"multiprovider\"")) + assertTrue(metadataString.contains("\"originalMetadata\"")) + // Contains the named provider key + assertTrue(metadataString.contains("\"A\"")) + // Contains at least one generated key for unnamed providers + assertTrue(metadataString.contains("\"-")) + } + + @Test + fun shutdown_aggregates_errors_and_reports_provider_names() { + val ok = FakeEventProvider(name = "ok") + val bad1 = FakeEventProvider(name = "bad1", shutdownThrowable = IllegalStateException("oops1")) + val bad2 = FakeEventProvider(name = null, shutdownThrowable = RuntimeException("oops2")) + + val multi = MultiProvider(listOf(ok, bad1, bad2)) + + val error = assertFailsWith { + multi.shutdown() + } + + // Message contains each provider and message on separate lines + val msg = error.message + assertTrue(msg.contains("bad1: oops1")) + // unnamed should be rendered as "" + assertTrue(msg.contains(": oops2")) + + // Suppressed should include one per failure + assertEquals(2, error.suppressedExceptions.size) + val suppressedMessages = error.suppressedExceptions.map { it.message ?: "" } + assertTrue(suppressedMessages.any { it.contains("Provider 'bad1' shutdown failed: oops1") }) + assertTrue(suppressedMessages.any { it.contains("Provider '' shutdown failed: oops2") }) + } } // Helpers private class FakeEventProvider( - private val name: String, - private val eventsToEmitOnInit: List = emptyList() + private val name: String?, + private val eventsToEmitOnInit: List = emptyList(), + private val shutdownThrowable: Throwable? = null ) : FeatureProvider { override val hooks: List> = emptyList() override val metadata: ProviderMetadata = object : ProviderMetadata { @@ -154,6 +201,7 @@ private class FakeEventProvider( override fun shutdown() { shutdownCalls += 1 + shutdownThrowable?.let { throw it } } override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { From 38fac8cd69fd49e79bef31d949a8563c21fe6bde Mon Sep 17 00:00:00 2001 From: penguindan Date: Thu, 21 Aug 2025 13:06:49 -0700 Subject: [PATCH 08/24] Add default reason to default value in First Match Strategy Signed-off-by: penguindan --- .../kotlin/sdk/multiprovider/FirstMatchStrategy.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt index 443a51e..953beae 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt @@ -3,6 +3,7 @@ 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.Reason import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError @@ -52,6 +53,10 @@ class FirstMatchStrategy : Strategy { } // No provider knew about the flag, return default value with DEFAULT reason - return ProviderEvaluation(defaultValue, errorCode = ErrorCode.FLAG_NOT_FOUND) + return ProviderEvaluation( + defaultValue, + reason = Reason.DEFAULT.toString(), + errorCode = ErrorCode.FLAG_NOT_FOUND + ) } } \ No newline at end of file From 48033c5cbd475929b4448c305b106f2d7092169a Mon Sep 17 00:00:00 2001 From: penguindan Date: Thu, 21 Aug 2025 13:31:27 -0700 Subject: [PATCH 09/24] Remove json dependency and update ProviderMetadata Signed-off-by: penguindan --- kotlin-sdk/build.gradle.kts | 1 - .../kotlin/sdk/ProviderMetadata.kt | 33 ++++++++++++++++ .../kotlin/sdk/multiprovider/MultiProvider.kt | 39 +++++++++---------- .../sdk/multiprovider/MultiProviderTests.kt | 15 +++---- 4 files changed, 60 insertions(+), 28 deletions(-) diff --git a/kotlin-sdk/build.gradle.kts b/kotlin-sdk/build.gradle.kts index b8894ed..d544c24 100644 --- a/kotlin-sdk/build.gradle.kts +++ b/kotlin-sdk/build.gradle.kts @@ -55,7 +55,6 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") } commonTest.dependencies { implementation("org.jetbrains.kotlin:kotlin-test:2.1.21") diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt index 056516c..ccee3cb 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderMetadata.kt @@ -1,5 +1,38 @@ package dev.openfeature.kotlin.sdk +/** + * Provider metadata as defined by the OpenFeature specification. + * + * In a single provider, `name` identifies the provider. In a Multi-Provider, the outer provider + * exposes its own `name` and surfaces the metadata of its managed providers via `originalMetadata`, + * keyed by each provider's resolved unique name. + * + * See: https://openfeature.dev/specification/appendix-a/#metadata + */ interface ProviderMetadata { + /** + * Human-readable provider name. + * + * - Used in logs, events, and error reporting. + * - In a Multi-Provider, names must be unique. + */ val name: String? + + /** + * For Multi-Provider: a map of child provider names to their metadata. + * + * - For normal providers this MUST be an empty map. + * - For the Multi-Provider, this contains each inner provider's `ProviderMetadata`, keyed by + * that provider's resolved unique name. + * + * Example shape: + * { + * "providerA": {...}, + * "providerB": {...} + * } + * + * See: https://openfeature.dev/specification/appendix-a/#metadata + */ + val originalMetadata: Map + get() = emptyMap() } \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 43b2f98..ec6c03f 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -16,9 +16,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonObject /** * MultiProvider is a FeatureProvider implementation that delegates flag evaluations @@ -48,27 +45,29 @@ class MultiProvider( // Metadata identifying this as a multiprovider override val metadata: ProviderMetadata = object : ProviderMetadata { - override val name: String? - get() = constructName() - - /** - * Constructs the metadata for our MultiProvider according to - * https://openfeature.dev/specification/appendix-a#metadata - */ - private fun constructName(): String { + override val name: String? = MULTIPROVIDER_NAME + override val originalMetadata: Map = constructOriginalMetadata() + + private fun constructOriginalMetadata(): Map { var unprovidedNameCounter = 1 - val multiproviderMetadataJson = buildJsonObject { - put("name", MULTIPROVIDER_NAME) - putJsonObject("originalMetadata") { - uniqueProviders.forEach { - putJsonObject(it.metadata.name ?: "$UNDEFINED_PROVIDER_NAME-${unprovidedNameCounter++}") { - put("name", it.metadata.name.orEmpty()) - } - } + val originalMetadata = mutableMapOf() + uniqueProviders.forEach { + val providerName = it.metadata.name + if (providerName != null) { + originalMetadata[providerName] = it.metadata + } else { + originalMetadata["${it.metadata.getSafeName()}_$unprovidedNameCounter"] = it.metadata } } - return multiproviderMetadataJson.toString() + return originalMetadata + } + + override fun toString(): String { + return mapOf( + "name" to name, + "originalMetadata" to originalMetadata + ).toString() } } diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt index 00a8da7..b70fbf2 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt @@ -21,6 +21,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -135,15 +136,15 @@ class MultiProviderTests { val multi = MultiProvider(listOf(named, unnamed)) - val metadataString = multi.metadata.name ?: error("metadata.name should not be null") + val original = multi.metadata.originalMetadata + + // Contains the named provider key mapping to some metadata + assertTrue(original.containsKey("A")) + assertNotNull(original["A"], "Original metadata should include entry for named provider") - // Simple substring assertions to validate structure without full JSON parsing - assertTrue(metadataString.contains("\"name\":\"multiprovider\"")) - assertTrue(metadataString.contains("\"originalMetadata\"")) - // Contains the named provider key - assertTrue(metadataString.contains("\"A\"")) // Contains at least one generated key for unnamed providers - assertTrue(metadataString.contains("\"-")) + val unnamedKey = original.keys.firstOrNull { it.startsWith("") } + assertNotNull(original[unnamedKey], "Original metadata should include entry for unnamed provider") } @Test From 4fa33eba74676cc3d589784926a776c208d2841c Mon Sep 17 00:00:00 2001 From: penguindan Date: Thu, 21 Aug 2025 17:09:01 -0700 Subject: [PATCH 10/24] Align to Event spec Signed-off-by: penguindan --- .../kotlin/sdk/multiprovider/MultiProvider.kt | 174 +++++++------- .../sdk/multiprovider/MultiProviderTests.kt | 219 ++++++++++++++++-- .../kotlin/sdk/sampleapp/ExampleProvider.kt | 35 +-- .../kotlin/sdk/sampleapp/MainActivity.kt | 65 +++++- 4 files changed, 366 insertions(+), 127 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index ec6c03f..787c83c 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -3,6 +3,7 @@ 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.OpenFeatureStatus import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.ProviderMetadata import dev.openfeature.kotlin.sdk.Value @@ -13,9 +14,12 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update /** * MultiProvider is a FeatureProvider implementation that delegates flag evaluations @@ -39,9 +43,17 @@ class MultiProvider( cause: Throwable ) : RuntimeException("Provider '$providerName' shutdown failed: ${cause.message}", cause) + /** + * @property name The unique name of the [FeatureProvider] according to this MultiProvider + */ + class ChildFeatureProvider( + implementation: FeatureProvider, + val name: String, // Maybe there's a better variable name for this? + ): FeatureProvider by implementation + // TODO: Support hooks override val hooks: List> = emptyList() - private val uniqueProviders = getUniqueSetOfProviders(providers) + private val childFeatureProviders: List = providers.toChildFeatureProviders() // Metadata identifying this as a multiprovider override val metadata: ProviderMetadata = object : ProviderMetadata { @@ -49,18 +61,7 @@ class MultiProvider( override val originalMetadata: Map = constructOriginalMetadata() private fun constructOriginalMetadata(): Map { - var unprovidedNameCounter = 1 - val originalMetadata = mutableMapOf() - uniqueProviders.forEach { - val providerName = it.metadata.name - if (providerName != null) { - originalMetadata[providerName] = it.metadata - } else { - originalMetadata["${it.metadata.getSafeName()}_$unprovidedNameCounter"] = it.metadata - } - } - - return originalMetadata + return childFeatureProviders.associate { it.name to it.metadata } } override fun toString(): String { @@ -71,44 +72,47 @@ class MultiProvider( } } + private val _statusFlow = MutableStateFlow(OpenFeatureStatus.NotReady) + val statusFlow = _statusFlow.asStateFlow() + // Shared flow because we don't want the distinct operator since it would break consecutive emits of // ProviderConfigurationChanged - private val eventFlow = MutableSharedFlow( - replay = 1, - extraBufferCapacity = 5 - ) - - // Track individual provider statuses - private val providerStatuses = mutableMapOf() - - // 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 - 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): List { - val setOfProviderNames = mutableSetOf() - val uniqueProviders = mutableListOf() - providers.forEach { currProvider -> - val providerName = currProvider.metadata.name - if (setOfProviderNames.add(providerName.orEmpty())) { - uniqueProviders.add(currProvider) + private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + + // Track individual provider statuses, initial state of all providers is NotReady + private val childProviderStatuses: MutableMap = + childFeatureProviders.associateWithTo(mutableMapOf()) { OpenFeatureStatus.NotReady } + + private fun List.toChildFeatureProviders(): List { + // Extract a stable base name per provider, falling back for unnamed providers + val providerBaseNames: List = this.map { it.metadata.name ?: UNDEFINED_PROVIDER_NAME } + + // How many times each base name occurs in the inputs + val baseNameToTotalCount: Map = providerBaseNames.groupingBy { it }.eachCount() + + // Running index per base name used to generate suffixed unique names in order + val baseNameToNextIndex = mutableMapOf() + + return this.mapIndexed { providerIndex, provider -> + val baseName = providerBaseNames[providerIndex] + val occurrencesForBase = baseNameToTotalCount[baseName] ?: 0 + + val uniqueChildName = if (occurrencesForBase > 1) { + val nextIndex = (baseNameToNextIndex[baseName] ?: 0) + 1 + baseNameToNextIndex[baseName] = nextIndex + "${baseName}_${nextIndex}" } else { - println("Duplicate provider with name $providerName found") // Log error, no logging tool + baseName } - } - return uniqueProviders + ChildFeatureProvider(provider, uniqueChildName) + } } /** * @return Number of unique providers */ - fun getProviderCount(): Int = uniqueProviders.size + fun getProviderCount(): Int = childFeatureProviders.size override fun observe(): Flow = eventFlow.asSharedFlow() @@ -122,7 +126,7 @@ class MultiProvider( 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 -> + childFeatureProviders.forEach { provider -> provider.observe() .onEach { event -> handleProviderEvent(provider, event) @@ -131,45 +135,56 @@ class MultiProvider( } // State updates captured by observing individual Feature Flag providers - uniqueProviders + childFeatureProviders .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. + private suspend fun handleProviderEvent(provider: ChildFeatureProvider, event: OpenFeatureProviderEvents) { if (event is OpenFeatureProviderEvents.ProviderConfigurationChanged) { eventFlow.emit(event) return } - // If the status has been updated, calculate what our new event should be - if (hasStatusUpdated) { - // Determine the highest-precedence status among all providers - val highestEvent = providerStatuses.values - .filter { it !is OpenFeatureProviderEvents.ProviderConfigurationChanged } - .maxByOrNull { eventPrecedence[it::class] ?: 0 } + val newChildStatus = when (event) { + is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready + is OpenFeatureProviderEvents.ProviderNotReady -> OpenFeatureStatus.NotReady + is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale + is OpenFeatureProviderEvents.ProviderError -> + if (event.error is OpenFeatureError.ProviderFatalError) { + OpenFeatureStatus.Fatal(event.error) + } else { + OpenFeatureStatus.Error(event.error) + } + else -> error("Unexpected event $event") + } - // Only emit if there's a change in overall status - val currentOverall = eventFlow.replayCache.lastOrNull() + val previousStatus = _statusFlow.value + childProviderStatuses[provider] = newChildStatus + val newStatus = calculateAggregateStatus() - if (highestEvent != null && highestEvent != currentOverall) { - eventFlow.emit(highestEvent) - } + if (previousStatus != newStatus) { + _statusFlow.update { newStatus } + // Re-emit the original event that triggered the aggregate status change + 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 + private fun calculateAggregateStatus(): OpenFeatureStatus { + val highestPrecedenceStatus = childProviderStatuses.values.maxBy(::precedence) + return highestPrecedenceStatus + } - return oldStatus != newStatus + private fun precedence(status: OpenFeatureStatus): Int { + return when (status) { + is OpenFeatureStatus.Fatal -> 5 + is OpenFeatureStatus.NotReady -> 4 + is OpenFeatureStatus.Error -> 3 + is OpenFeatureStatus.Reconciling -> 2 // Not specified in precedence; treat similar to Stale + is OpenFeatureStatus.Stale -> 2 + is OpenFeatureStatus.Ready -> 1 + } } /** @@ -178,11 +193,11 @@ class MultiProvider( */ override fun shutdown() { val shutdownErrors = mutableListOf>() - uniqueProviders.forEach { provider -> + childFeatureProviders.forEach { provider -> try { provider.shutdown() } catch (t: Throwable) { - shutdownErrors += provider.metadata.getSafeName() to t + shutdownErrors += provider.name to t } } @@ -208,7 +223,13 @@ class MultiProvider( oldContext: EvaluationContext?, newContext: EvaluationContext ) { - uniqueProviders.forEach { it.onContextSet(oldContext, newContext) } + coroutineScope { + // If any of these fail, they should individually bubble up their fail + // event and that is handled by handleProviderEvent() + childFeatureProviders + .map { async { it.onContextSet(oldContext, newContext) } } + .awaitAll() + } } override fun getBooleanEvaluation( @@ -217,7 +238,7 @@ class MultiProvider( context: EvaluationContext? ): ProviderEvaluation { return strategy.evaluate( - uniqueProviders, + childFeatureProviders, key, defaultValue, context, @@ -231,7 +252,7 @@ class MultiProvider( context: EvaluationContext? ): ProviderEvaluation { return strategy.evaluate( - uniqueProviders, + childFeatureProviders, key, defaultValue, context, @@ -245,7 +266,7 @@ class MultiProvider( context: EvaluationContext? ): ProviderEvaluation { return strategy.evaluate( - uniqueProviders, + childFeatureProviders, key, defaultValue, context, @@ -259,7 +280,7 @@ class MultiProvider( context: EvaluationContext? ): ProviderEvaluation { return strategy.evaluate( - uniqueProviders, + childFeatureProviders, key, defaultValue, context, @@ -273,7 +294,7 @@ class MultiProvider( context: EvaluationContext? ): ProviderEvaluation { return strategy.evaluate( - uniqueProviders, + childFeatureProviders, key, defaultValue, context, @@ -281,13 +302,6 @@ class MultiProvider( ) } - /** - * Helps us have a consistent way to handle when providers don't provide a name - */ - private fun ProviderMetadata.getSafeName(): String { - return name ?: UNDEFINED_PROVIDER_NAME - } - companion object { private const val MULTIPROVIDER_NAME = "multiprovider" private const val UNDEFINED_PROVIDER_NAME = "" diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt index b70fbf2..e7ddc2b 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt @@ -4,6 +4,7 @@ import dev.openfeature.kotlin.sdk.EvaluationContext import dev.openfeature.kotlin.sdk.FeatureProvider import dev.openfeature.kotlin.sdk.Hook import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.OpenFeatureStatus import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.ProviderMetadata import dev.openfeature.kotlin.sdk.Value @@ -28,14 +29,57 @@ import kotlin.test.assertTrue class MultiProviderTests { @Test - fun deduplicates_providers_by_name() { - val p1 = FakeEventProvider(name = "dup") - val p2 = FakeEventProvider(name = "dup") - val p3 = FakeEventProvider(name = "unique") + fun unique_child_names_are_assigned_for_duplicates() { + val p1 = FakeEventProvider(name = "Provider") + val p2 = FakeEventProvider(name = "Provider") + val p3 = FakeEventProvider(name = "ProviderNew") val multi = MultiProvider(listOf(p1, p2, p3)) - assertEquals(2, multi.getProviderCount()) + // All providers should be present as children + assertEquals(3, multi.getProviderCount()) + + // Original metadata should be keyed by unique child names + val keys = multi.metadata.originalMetadata.keys + assertTrue(keys.contains("Provider_1")) + assertTrue(keys.contains("Provider_2")) + assertTrue(keys.contains("ProviderNew")) + } + + @Test + fun metadata_includes_original_metadata_and_handles_unnamed_providers() { + val named = FakeEventProvider(name = "A") + val unnamed = FakeEventProvider(name = null) + + val multi = MultiProvider(listOf(named, unnamed)) + + val original = multi.metadata.originalMetadata + + // Contains the named provider key mapping to some metadata + assertTrue(original.containsKey("A")) + assertNotNull(original["A"], "Original metadata should include entry for named provider") + + // Contains at least one generated key for unnamed providers + val unnamedKey = original.keys.firstOrNull { it.startsWith("") } + assertNotNull(original[unnamedKey], "Original metadata should include entry for unnamed provider") + } + + @Test + fun child_provider_naming_is_stable_and_suffixed_per_base_name_in_order() { + val unnamed1 = FakeEventProvider(name = null) + val x1 = FakeEventProvider(name = "X") + val unnamed2 = FakeEventProvider(name = null) + val x2 = FakeEventProvider(name = "X") + val y = FakeEventProvider(name = "Y") + + val multi = MultiProvider(listOf(unnamed1, x1, unnamed2, x2, y)) + + val keysInOrder = multi.metadata.originalMetadata.keys.toList() + + // Unnamed providers get "_1", "_2" in order of appearance + // Duplicate named providers get suffixed per base name in order + // Singletons keep their base name without suffix + assertEquals(listOf("_1", "X_1", "_2", "X_2", "Y"), keysInOrder) } @Test @@ -79,7 +123,7 @@ class MultiProviderTests { } @Test - fun uses_strategy_for_evaluations_and_preserves_unique_order() { + fun uses_strategy_for_evaluations_and_preserves_order_including_duplicates() { val p1 = FakeEventProvider(name = "A") val dup = FakeEventProvider(name = "A") val p2 = FakeEventProvider(name = "B") @@ -90,7 +134,8 @@ class MultiProviderTests { val eval = multi.getBooleanEvaluation("flag", false, null) assertEquals(true, eval.value) - assertEquals(listOf("A", "B"), recorder.lastProviderNames) + // The strategy receives all providers in order; duplicates are preserved + assertEquals(listOf("A", "A", "B"), recorder.lastProviderNames) } @Test @@ -115,7 +160,7 @@ class MultiProviderTests { OpenFeatureProviderEvents.ProviderConfigurationChanged, OpenFeatureProviderEvents.ProviderNotReady, OpenFeatureProviderEvents.ProviderError( - dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError.GeneralError("boom") + OpenFeatureError.GeneralError("boom") ) ) ) @@ -124,27 +169,159 @@ class MultiProviderTests { val initJob = launch { multi.initialize(null) } advanceUntilIdle() - val last = multi.observe().first() - assertIs(last) + // Final aggregate status should be ERROR (no providers remain NOT_READY) + val finalStatus = multi.statusFlow.value + assertIs(finalStatus) initJob.cancelAndJoin() } @Test - fun metadata_includes_original_metadata_and_handles_unnamed_providers() { - val named = FakeEventProvider(name = "A") - val unnamed = FakeEventProvider(name = null) + fun emits_provider_error_when_fatal_overrides_all() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.ProviderFatalError("fatal") + ) + ) + ) + val multi = MultiProvider(listOf(a, b)) - val multi = MultiProvider(listOf(named, unnamed)) + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() - val original = multi.metadata.originalMetadata + val finalStatus = multi.statusFlow.value + val errStatus = assertIs(finalStatus) + assertIs(errStatus.error) + initJob.cancelAndJoin() + } - // Contains the named provider key mapping to some metadata - assertTrue(original.containsKey("A")) - assertNotNull(original["A"], "Original metadata should include entry for named provider") + @Test + fun error_overrides_ready_but_stale_does_not_override_error() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.GeneralError("oops") + ) + ) + ) + val c = FakeEventProvider( + name = "C", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderStale + ) + ) - // Contains at least one generated key for unnamed providers - val unnamedKey = original.keys.firstOrNull { it.startsWith("") } - assertNotNull(original[unnamedKey], "Original metadata should include entry for unnamed provider") + val multi = MultiProvider(listOf(a, b, c)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + val finalStatus = multi.statusFlow.value + assertIs(finalStatus) + initJob.cancelAndJoin() + } + + @Test + fun not_ready_out_ranks_error_and_stale() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderNotReady + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderError(OpenFeatureError.GeneralError("e")) + ) + ) + val c = FakeEventProvider( + name = "C", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val multi = MultiProvider(listOf(a, b, c)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + val finalStatus = multi.statusFlow.value + assertIs(finalStatus) + initJob.cancelAndJoin() + } + + @Test + fun emits_events_only_on_status_change() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderReady, + OpenFeatureProviderEvents.ProviderReady, + OpenFeatureProviderEvents.ProviderStale + ) + ) + val multi = MultiProvider(listOf(provider)) + + val collected = mutableListOf() + val collectJob = launch { multi.observe().collect { collected.add(it) } } + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + collectJob.cancelAndJoin() + initJob.cancelAndJoin() + + val nonConfig = collected.filter { it !is OpenFeatureProviderEvents.ProviderConfigurationChanged } + // Should only emit Ready once (transition) and Stale once (transition) + assertEquals(listOf(OpenFeatureProviderEvents.ProviderReady, OpenFeatureProviderEvents.ProviderStale), nonConfig) + } + + @Test + fun configuration_changed_is_always_emitted() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged, + OpenFeatureProviderEvents.ProviderConfigurationChanged + ) + ) + val multi = MultiProvider(listOf(provider)) + + val collected = mutableListOf() + val collectJob = launch { multi.observe().collect { collected.add(it) } } + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + collectJob.cancelAndJoin() + initJob.cancelAndJoin() + + // Only configuration changed events should have been emitted + assertEquals(2, collected.size) + assertTrue(collected.all { it is OpenFeatureProviderEvents.ProviderConfigurationChanged }) } @Test diff --git a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt index 2cc9e44..c3ee87a 100644 --- a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt +++ b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt @@ -1,39 +1,40 @@ package dev.openfeature.kotlin.sdk.sampleapp import dev.openfeature.kotlin.sdk.* +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow -class ExampleProvider(override val hooks: List> = listOf()) : FeatureProvider { - +class ExampleProvider( + providerName: String, + private val flags: Map, + override val hooks: List> = listOf() +) : FeatureProvider { private var currentContext: EvaluationContext? = ImmutableContext() var delayTime = 1000L var returnDefaults = false - val flags = mutableMapOf().apply { - put("booleanFlag", true) - put("stringFlag", "this is a string") - put("intFlag", 1337) - put("doubleFlag", 42.0) - put( - "objectFlag", - Value.Structure(mapOf("key1" to Value.String("value"), "key2" to Value.Integer(10))) - ) - } + private val eventFlow = MutableSharedFlow() - override val metadata: ProviderMetadata - get() = object : ProviderMetadata { - override val name: String = "ExampleProvider" - } + override val metadata: ProviderMetadata = object : ProviderMetadata { + override val name: String = providerName + } override suspend fun initialize(initialContext: EvaluationContext?) { currentContext = initialContext // Simulate a delay in the provider initialization delay(delayTime) + eventFlow.emit(OpenFeatureProviderEvents.ProviderReady) } override fun shutdown() { } + override fun observe(): Flow = eventFlow.asSharedFlow() + override suspend fun onContextSet( oldContext: EvaluationContext?, newContext: EvaluationContext @@ -85,7 +86,7 @@ class ExampleProvider(override val hooks: List> = listOf()) : FeaturePro } else if (containsKey(key)) { ProviderEvaluation(defaultValue, null, reason = "invalid type") } else { - ProviderEvaluation(defaultValue, null, reason = "notfound") + ProviderEvaluation(defaultValue, null, reason = "notfound", errorCode = ErrorCode.FLAG_NOT_FOUND) } } } diff --git a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt index 74081bf..902dc19 100644 --- a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt +++ b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt @@ -43,38 +43,69 @@ import dev.openfeature.kotlin.sdk.ImmutableContext import dev.openfeature.kotlin.sdk.OpenFeatureAPI import dev.openfeature.kotlin.sdk.OpenFeatureStatus import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider import dev.openfeature.kotlin.sdk.sampleapp.ui.theme.OpenFeatureTheme +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.random.Random class MainActivity : ComponentActivity() { - private val exampleProvider = ExampleProvider() + private val exampleProvider1 = ExampleProvider( + "ExampleProvider1", + flags = mapOf( + "stringFlag" to "this is a string", + "intFlag" to 1337, + "doubleFlag" to 42.0 + ) + ) + private val exampleProvider2 = ExampleProvider( + "ExampleProvider2", + flags = mapOf( + "booleanFlag" to true, + "objectFlag" to Value.Structure( + mapOf("key1" to Value.String("value"), "key2" to Value.Integer(10)) + ) + ) + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - lifecycleScope.launch { - OpenFeatureAPI.setProviderAndWait(exampleProvider) + + val multiProvider = MultiProvider(listOf(exampleProvider1, exampleProvider2)) + lifecycleScope.launch(Dispatchers.IO) { + OpenFeatureAPI.setProviderAndWait(multiProvider) OpenFeatureAPI.statusFlow.collect { Log.i("OpenFeature", "Status: $it") } } + val statusFlow = OpenFeatureAPI.statusFlow.map { if (it is OpenFeatureStatus.Error) { "Error: ${it.error.errorCode()} - ${it.error.message}" } else it.javaClass.simpleName } + + val multiProviderEventFlow = multiProvider.statusFlow.map { + it.javaClass.simpleName + } + setContent { OpenFeatureTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> MainPage( modifier = Modifier.padding(innerPadding), - setDelay = { exampleProvider.delayTime = it }, + setDelay = { + exampleProvider1.delayTime = it + exampleProvider2.delayTime = it + }, statusFlow = statusFlow, + multiProviderStatusFlow = multiProviderEventFlow, toggleDefaults = { - exampleProvider.returnDefaults = !exampleProvider.returnDefaults + exampleProvider1.returnDefaults = !exampleProvider1.returnDefaults + exampleProvider2.returnDefaults = !exampleProvider2.returnDefaults } ) } @@ -88,6 +119,7 @@ fun MainPage( modifier: Modifier = Modifier, setDelay: (Long) -> Unit, statusFlow: Flow, + multiProviderStatusFlow: Flow, defaultTab: Int = 0, toggleDefaults: () -> Unit ) { @@ -121,7 +153,11 @@ fun MainPage( // Content for the currently selected tab when (selectedTabIndex) { - 0 -> ProviderAndStatus(setDelay = setDelay, statusFlow = statusFlow) + 0 -> ProviderAndStatus( + setDelay = setDelay, + statusFlow = statusFlow, + multiProviderStatusFlow = multiProviderStatusFlow, + ) 1 -> Evaluations(toggleDefaults = toggleDefaults) 2 -> Hooks() } @@ -185,8 +221,14 @@ fun Evaluations(toggleDefaults: () -> Unit) { } @Composable -fun ProviderAndStatus(statusFlow: Flow, setDelay: (Long) -> Unit) { +fun ProviderAndStatus( + statusFlow: Flow, + multiProviderStatusFlow: Flow, + setDelay: (Long) -> Unit, +) { val statusState by statusFlow.collectAsState(initial = "initial") + val multiProviderState by multiProviderStatusFlow.collectAsState(initial = "initial") + val coroutineScope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxSize()) { var sliderValue by remember { mutableStateOf(0.1f) } @@ -248,6 +290,10 @@ fun ProviderAndStatus(statusFlow: Flow, setDelay: (Long) -> Unit) { Row(Modifier.padding(top = 8.dp)) { Text(text = "Current SDK status: $statusState") } + + Row(Modifier.padding(top = 8.dp)) { + Text(text = "MultiProvider Latest event: $multiProviderState") + } } } @@ -323,7 +369,8 @@ fun MainPagePreview() { OpenFeatureTheme { MainPage( setDelay = { }, - statusFlow = emptyFlow(), + statusFlow = flowOf("initial"), + multiProviderStatusFlow = flowOf("initial"), defaultTab = 0, toggleDefaults = { } ) From d5f5546cc49304cb069352e3ffb669500a2552f7 Mon Sep 17 00:00:00 2001 From: penguindan Date: Thu, 21 Aug 2025 17:09:41 -0700 Subject: [PATCH 11/24] Ktlint Signed-off-by: penguindan --- .../openfeature/kotlin/sdk/multiprovider/MultiProvider.kt | 8 ++++---- .../kotlin/sdk/multiprovider/MultiProviderTests.kt | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 787c83c..2b20e2b 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -48,8 +48,8 @@ class MultiProvider( */ class ChildFeatureProvider( implementation: FeatureProvider, - val name: String, // Maybe there's a better variable name for this? - ): FeatureProvider by implementation + val name: String // Maybe there's a better variable name for this? + ) : FeatureProvider by implementation // TODO: Support hooks override val hooks: List> = emptyList() @@ -80,7 +80,7 @@ class MultiProvider( private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) // Track individual provider statuses, initial state of all providers is NotReady - private val childProviderStatuses: MutableMap = + private val childProviderStatuses: MutableMap = childFeatureProviders.associateWithTo(mutableMapOf()) { OpenFeatureStatus.NotReady } private fun List.toChildFeatureProviders(): List { @@ -100,7 +100,7 @@ class MultiProvider( val uniqueChildName = if (occurrencesForBase > 1) { val nextIndex = (baseNameToNextIndex[baseName] ?: 0) + 1 baseNameToNextIndex[baseName] = nextIndex - "${baseName}_${nextIndex}" + "${baseName}_$nextIndex" } else { baseName } diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt index e7ddc2b..f19bcac 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt @@ -296,7 +296,10 @@ class MultiProviderTests { val nonConfig = collected.filter { it !is OpenFeatureProviderEvents.ProviderConfigurationChanged } // Should only emit Ready once (transition) and Stale once (transition) - assertEquals(listOf(OpenFeatureProviderEvents.ProviderReady, OpenFeatureProviderEvents.ProviderStale), nonConfig) + assertEquals( + listOf(OpenFeatureProviderEvents.ProviderReady, OpenFeatureProviderEvents.ProviderStale), + nonConfig + ) } @Test From 8d8cbec0a911d360ca10267f58b45ca9c35488b4 Mon Sep 17 00:00:00 2001 From: penguindan Date: Fri, 22 Aug 2025 10:55:49 -0700 Subject: [PATCH 12/24] Update API dumps for multiprovider and ProviderMetadata changes Signed-off-by: penguindan --- kotlin-sdk/api/android/kotlin-sdk.api | 60 +++++++++++++++++++++++++++ kotlin-sdk/api/jvm/kotlin-sdk.api | 60 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/kotlin-sdk/api/android/kotlin-sdk.api b/kotlin-sdk/api/android/kotlin-sdk.api index 54446ed..78aeef4 100644 --- a/kotlin-sdk/api/android/kotlin-sdk.api +++ b/kotlin-sdk/api/android/kotlin-sdk.api @@ -261,6 +261,7 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata; public fun equals (Ljava/lang/Object;)Z public fun getName ()Ljava/lang/String; + public fun getOriginalMetadata ()Ljava/util/Map; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -382,6 +383,11 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation { public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata { public abstract fun getName ()Ljava/lang/String; + public abstract fun getOriginalMetadata ()Ljava/util/Map; +} + +public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls { + public static fun getOriginalMetadata (Ldev/openfeature/kotlin/sdk/ProviderMetadata;)Ljava/util/Map; } public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum { @@ -738,3 +744,57 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi public fun getMessage ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion; + public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;)V + public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public final fun getProviderCount ()I + public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildFeatureProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public fun (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public final fun getName ()Ljava/lang/String; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion { +} + +public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/Strategy { + public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + diff --git a/kotlin-sdk/api/jvm/kotlin-sdk.api b/kotlin-sdk/api/jvm/kotlin-sdk.api index 54446ed..78aeef4 100644 --- a/kotlin-sdk/api/jvm/kotlin-sdk.api +++ b/kotlin-sdk/api/jvm/kotlin-sdk.api @@ -261,6 +261,7 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata; public fun equals (Ljava/lang/Object;)Z public fun getName ()Ljava/lang/String; + public fun getOriginalMetadata ()Ljava/util/Map; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -382,6 +383,11 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation { public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata { public abstract fun getName ()Ljava/lang/String; + public abstract fun getOriginalMetadata ()Ljava/util/Map; +} + +public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls { + public static fun getOriginalMetadata (Ldev/openfeature/kotlin/sdk/ProviderMetadata;)Ljava/util/Map; } public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum { @@ -738,3 +744,57 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi public fun getMessage ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { + public fun ()V + public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion; + public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;)V + public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public final fun getProviderCount ()I + public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildFeatureProvider : dev/openfeature/kotlin/sdk/FeatureProvider { + public fun (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;)V + public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getHooks ()Ljava/util/List; + public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public final fun getName ()Ljava/lang/String; + public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observe ()Lkotlinx/coroutines/flow/Flow; + public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shutdown ()V + public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion { +} + +public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/Strategy { + public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; +} + From 3818a1146b201f349cbc213603ccc132765d746a Mon Sep 17 00:00:00 2001 From: penguindan Date: Sat, 23 Aug 2025 11:11:46 -0700 Subject: [PATCH 13/24] Use Lazy and ktlint Signed-off-by: penguindan --- .../kotlin/sdk/multiprovider/MultiProvider.kt | 9 +++++-- .../sdk/helpers/RecordingBooleanProvider.kt | 24 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 2b20e2b..254b4b9 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -53,12 +53,16 @@ class MultiProvider( // TODO: Support hooks override val hooks: List> = emptyList() - private val childFeatureProviders: List = providers.toChildFeatureProviders() + private val childFeatureProviders: List by lazy { + providers.toChildFeatureProviders() + } // Metadata identifying this as a multiprovider override val metadata: ProviderMetadata = object : ProviderMetadata { override val name: String? = MULTIPROVIDER_NAME - override val originalMetadata: Map = constructOriginalMetadata() + override val originalMetadata: Map by lazy { + constructOriginalMetadata() + } private fun constructOriginalMetadata(): Map { return childFeatureProviders.associate { it.name to it.metadata } @@ -157,6 +161,7 @@ class MultiProvider( } else { OpenFeatureStatus.Error(event.error) } + else -> error("Unexpected event $event") } diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt index 763968d..5a53a5f 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/RecordingBooleanProvider.kt @@ -40,19 +40,35 @@ class RecordingBooleanProvider( return behavior() } - override fun getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?): ProviderEvaluation { + override fun getStringEvaluation( + key: String, + defaultValue: String, + context: EvaluationContext? + ): ProviderEvaluation { throw UnsupportedOperationException() } - override fun getIntegerEvaluation(key: String, defaultValue: Int, context: EvaluationContext?): ProviderEvaluation { + override fun getIntegerEvaluation( + key: String, + defaultValue: Int, + context: EvaluationContext? + ): ProviderEvaluation { throw UnsupportedOperationException() } - override fun getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?): ProviderEvaluation { + override fun getDoubleEvaluation( + key: String, + defaultValue: Double, + context: EvaluationContext? + ): ProviderEvaluation { throw UnsupportedOperationException() } - override fun getObjectEvaluation(key: String, defaultValue: Value, context: EvaluationContext?): ProviderEvaluation { + override fun getObjectEvaluation( + key: String, + defaultValue: Value, + context: EvaluationContext? + ): ProviderEvaluation { throw UnsupportedOperationException() } } \ No newline at end of file From f834a43e151d219879684263701ae646d8e6bfe5 Mon Sep 17 00:00:00 2001 From: penguindan Date: Mon, 25 Aug 2025 17:58:49 -0700 Subject: [PATCH 14/24] Add TODO once EventDetails have been added Signed-off-by: penguindan --- .../dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 254b4b9..3362ccf 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -79,8 +79,6 @@ class MultiProvider( private val _statusFlow = MutableStateFlow(OpenFeatureStatus.NotReady) val statusFlow = _statusFlow.asStateFlow() - // Shared flow because we don't want the distinct operator since it would break consecutive emits of - // ProviderConfigurationChanged private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) // Track individual provider statuses, initial state of all providers is NotReady @@ -118,6 +116,7 @@ class MultiProvider( */ fun getProviderCount(): Int = childFeatureProviders.size + // TODO Add distinctUntilChanged operator once EventDetails have been added override fun observe(): Flow = eventFlow.asSharedFlow() /** From c299717a578c36d4a8a49c566d0539eb91fb81e8 Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 26 Aug 2025 09:58:55 -0700 Subject: [PATCH 15/24] PR comments; remove redundant comments, fix test definitions, move strategy into MultiProvider Signed-off-by: penguindan --- .../sdk/multiprovider/FirstMatchStrategy.kt | 23 ++---- .../multiprovider/FirstSuccessfulStrategy.kt | 19 +---- .../kotlin/sdk/multiprovider/MultiProvider.kt | 80 +++++++++++++------ .../kotlin/sdk/multiprovider/Strategy.kt | 41 ---------- .../multiprovider/FirstMatchStrategyTests.kt | 12 +-- .../FirstSuccessfulStrategyTests.kt | 6 +- .../sdk/multiprovider/MultiProviderTests.kt | 28 +++---- 7 files changed, 89 insertions(+), 120 deletions(-) delete mode 100644 kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt index 953beae..2c3aacc 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt @@ -8,24 +8,15 @@ 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. + * A [MultiProvider.Strategy] that returns the first result returned by a [FeatureProvider]. * - * 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. + * It skips providers that indicate they had no value due to [ErrorCode.FLAG_NOT_FOUND]. + * In all other cases, it uses the value returned by the provider. + * + * If any provider returns an error result other than [ErrorCode.FLAG_NOT_FOUND], the whole evaluation + * returns the provider's error. */ -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 - */ +class FirstMatchStrategy : MultiProvider.Strategy { override fun evaluate( providers: List, key: String, diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt index bbfbee5..0d85bbf 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt @@ -6,23 +6,12 @@ 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. + * A [MultiProvider.Strategy] similar to the [FirstMatchStrategy], except that errors from evaluated + * providers do not halt execution. * - * If no provider successfully responds, it will throw an error result. + * If no provider successfully responds, it throws an error result. */ -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 - */ +class FirstSuccessfulStrategy : MultiProvider.Strategy { override fun evaluate( providers: List, key: String, diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 3362ccf..30af01c 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt @@ -21,6 +21,17 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +/** + * 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 = + FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation + /** * MultiProvider is a FeatureProvider implementation that delegates flag evaluations * to multiple underlying providers using a configurable strategy. @@ -51,6 +62,41 @@ class MultiProvider( val name: String // Maybe there's a better variable name for this? ) : FeatureProvider by implementation + /** + * 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 { + /** + * 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 containing the final evaluation result + */ + fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FlagEval + ): ProviderEvaluation + } + + private val OpenFeatureStatus.precedence: Int + get() = when (this) { + is OpenFeatureStatus.Fatal -> 5 + is OpenFeatureStatus.NotReady -> 4 + is OpenFeatureStatus.Error -> 3 + is OpenFeatureStatus.Reconciling -> 2 // Not specified in precedence; treat similar to Stale + is OpenFeatureStatus.Stale -> 2 + is OpenFeatureStatus.Ready -> 1 + } + // TODO: Support hooks override val hooks: List> = emptyList() private val childFeatureProviders: List by lazy { @@ -61,11 +107,7 @@ class MultiProvider( override val metadata: ProviderMetadata = object : ProviderMetadata { override val name: String? = MULTIPROVIDER_NAME override val originalMetadata: Map by lazy { - constructOriginalMetadata() - } - - private fun constructOriginalMetadata(): Map { - return childFeatureProviders.associate { it.name to it.metadata } + childFeatureProviders.associate { it.name to it.metadata } } override fun toString(): String { @@ -114,7 +156,7 @@ class MultiProvider( /** * @return Number of unique providers */ - fun getProviderCount(): Int = childFeatureProviders.size + internal fun getProviderCount(): Int = childFeatureProviders.size // TODO Add distinctUntilChanged operator once EventDetails have been added override fun observe(): Flow = eventFlow.asSharedFlow() @@ -145,12 +187,13 @@ class MultiProvider( } private suspend fun handleProviderEvent(provider: ChildFeatureProvider, event: OpenFeatureProviderEvents) { - if (event is OpenFeatureProviderEvents.ProviderConfigurationChanged) { - eventFlow.emit(event) - return - } - val newChildStatus = when (event) { + // ProviderConfigurationChanged events should always re-emit + is OpenFeatureProviderEvents.ProviderConfigurationChanged -> { + eventFlow.emit(event) + return + } + is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready is OpenFeatureProviderEvents.ProviderNotReady -> OpenFeatureStatus.NotReady is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale @@ -160,8 +203,6 @@ class MultiProvider( } else { OpenFeatureStatus.Error(event.error) } - - else -> error("Unexpected event $event") } val previousStatus = _statusFlow.value @@ -176,21 +217,10 @@ class MultiProvider( } private fun calculateAggregateStatus(): OpenFeatureStatus { - val highestPrecedenceStatus = childProviderStatuses.values.maxBy(::precedence) + val highestPrecedenceStatus = childProviderStatuses.values.maxBy { it.precedence } return highestPrecedenceStatus } - private fun precedence(status: OpenFeatureStatus): Int { - return when (status) { - is OpenFeatureStatus.Fatal -> 5 - is OpenFeatureStatus.NotReady -> 4 - is OpenFeatureStatus.Error -> 3 - is OpenFeatureStatus.Reconciling -> 2 // Not specified in precedence; treat similar to Stale - is OpenFeatureStatus.Stale -> 2 - is OpenFeatureStatus.Ready -> 1 - } - } - /** * Shuts down all underlying providers. * This allows providers to clean up resources and complete any pending operations. diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt deleted file mode 100644 index bdd8291..0000000 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/Strategy.kt +++ /dev/null @@ -1,41 +0,0 @@ -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 = - FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation - -/** - * 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 { - /** - * 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 containing the final evaluation result - */ - fun evaluate( - providers: List, - key: String, - defaultValue: T, - evaluationContext: EvaluationContext?, - flagEval: FlagEval - ): ProviderEvaluation -} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt index 7d0fe54..7c4f38d 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt @@ -12,7 +12,7 @@ import kotlin.test.assertFailsWith class FirstMatchStrategyTests { @Test - fun returns_first_success_without_calling_next_providers() { + fun returnsFirstSuccessWithoutCallingNextProviders() { val strategy = FirstMatchStrategy() val first = RecordingBooleanProvider( name = "first", @@ -38,7 +38,7 @@ class FirstMatchStrategyTests { } @Test - fun skips_flag_not_found_and_returns_next_match() { + fun skipsFlagNotFoundAndReturnsNextMatch() { val strategy = FirstMatchStrategy() val notFoundProvider = RecordingBooleanProvider( name = "not-found", @@ -70,7 +70,7 @@ class FirstMatchStrategyTests { } @Test - fun treats_FlagNotFound_exception_as_not_found_and_continues() { + fun treatsFlagNotFoundExceptionAsNotFoundAndContinues() { val strategy = FirstMatchStrategy() val throwsNotFound = RecordingBooleanProvider( name = "throws-not-found", @@ -95,7 +95,7 @@ class FirstMatchStrategyTests { } @Test - fun returns_error_result_other_than_not_found_and_short_circuits() { + fun returnsErrorResultOtherThanNotFoundAndShortCircuits() { val strategy = FirstMatchStrategy() val errorProvider = RecordingBooleanProvider( name = "error", @@ -121,7 +121,7 @@ class FirstMatchStrategyTests { } @Test - fun bubbles_up_non_not_found_exceptions() { + fun bubblesUpNonNotFoundExceptions() { val strategy = FirstMatchStrategy() val throwsGeneral = RecordingBooleanProvider( name = "throws-general", @@ -141,7 +141,7 @@ class FirstMatchStrategyTests { } @Test - fun returns_default_with_not_found_when_no_provider_matches() { + fun returnsDefaultWithNotFoundWhenNoProviderMatches() { val strategy = FirstMatchStrategy() val p1 = RecordingBooleanProvider( name = "p1", diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt index b85d0de..e6e841c 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt @@ -11,7 +11,7 @@ import kotlin.test.assertFailsWith class FirstSuccessfulStrategyTests { @Test - fun returns_first_success_ignoring_prior_errors() { + fun returnsFirstSuccessIgnoringPriorErrors() { val strategy = FirstSuccessfulStrategy() val error1 = RecordingBooleanProvider("e1") { throw OpenFeatureError.GeneralError("boom1") @@ -43,7 +43,7 @@ class FirstSuccessfulStrategyTests { } @Test - fun skips_flag_not_found_error_and_result_until_success() { + fun skipsFlagNotFoundErrorAndResultUntilSuccess() { val strategy = FirstSuccessfulStrategy() val notFoundThrow = RecordingBooleanProvider("nf-throw") { throw OpenFeatureError.FlagNotFoundError("flag") @@ -70,7 +70,7 @@ class FirstSuccessfulStrategyTests { } @Test - fun throws_when_no_provider_returns_success() { + fun throwsWhenNoProviderReturnsSuccess() { val strategy = FirstSuccessfulStrategy() val error1 = RecordingBooleanProvider("e1") { throw OpenFeatureError.GeneralError("boom1") diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt index f19bcac..60eec8d 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProviderTests.kt @@ -29,7 +29,7 @@ import kotlin.test.assertTrue class MultiProviderTests { @Test - fun unique_child_names_are_assigned_for_duplicates() { + fun uniqueChildNamesAreAssignedForDuplicates() { val p1 = FakeEventProvider(name = "Provider") val p2 = FakeEventProvider(name = "Provider") val p3 = FakeEventProvider(name = "ProviderNew") @@ -47,7 +47,7 @@ class MultiProviderTests { } @Test - fun metadata_includes_original_metadata_and_handles_unnamed_providers() { + fun metadataIncludesOriginalMetadataAndHandlesUnnamedProviders() { val named = FakeEventProvider(name = "A") val unnamed = FakeEventProvider(name = null) @@ -65,7 +65,7 @@ class MultiProviderTests { } @Test - fun child_provider_naming_is_stable_and_suffixed_per_base_name_in_order() { + fun childProviderNamingIsStableAndSuffixedPerBaseNameInOrder() { val unnamed1 = FakeEventProvider(name = null) val x1 = FakeEventProvider(name = "X") val unnamed2 = FakeEventProvider(name = null) @@ -83,7 +83,7 @@ class MultiProviderTests { } @Test - fun forwards_lifecycle_calls_to_underlying_providers() = runTest { + fun forwardsLifecycleCallsToUnderlyingProviders() = runTest { val provider = FakeEventProvider(name = "p") val multi = MultiProvider(listOf(provider)) @@ -101,7 +101,7 @@ class MultiProviderTests { } @Test - fun observes_events_and_applies_precedence_after_configuration_change() = runTest { + fun observesEventsAndAppliesPrecedenceAfterConfigurationChange() = runTest { // Including ProviderConfigurationChanged first allows subsequent lower-precedence READY to emit val provider = FakeEventProvider( name = "p", @@ -123,7 +123,7 @@ class MultiProviderTests { } @Test - fun uses_strategy_for_evaluations_and_preserves_order_including_duplicates() { + fun usesStrategyForEvaluationsAndPreservesOrderIncludingDuplicates() { val p1 = FakeEventProvider(name = "A") val dup = FakeEventProvider(name = "A") val p2 = FakeEventProvider(name = "B") @@ -139,7 +139,7 @@ class MultiProviderTests { } @Test - fun aggregates_event_precedence_across_multiple_providers() = runTest { + fun aggregatesEventPrecedenceAcrossMultipleProviders() = runTest { val a = FakeEventProvider( name = "A", eventsToEmitOnInit = listOf( @@ -176,7 +176,7 @@ class MultiProviderTests { } @Test - fun emits_provider_error_when_fatal_overrides_all() = runTest { + fun emitsProviderErrorWhenFatalOverridesAll() = runTest { val a = FakeEventProvider( name = "A", eventsToEmitOnInit = listOf( @@ -205,7 +205,7 @@ class MultiProviderTests { } @Test - fun error_overrides_ready_but_stale_does_not_override_error() = runTest { + fun errorOverridesReadyButStaleDoesNotOverrideError() = runTest { val a = FakeEventProvider( name = "A", eventsToEmitOnInit = listOf( @@ -241,7 +241,7 @@ class MultiProviderTests { } @Test - fun not_ready_out_ranks_error_and_stale() = runTest { + fun notReadyOutRanksErrorAndStale() = runTest { val a = FakeEventProvider( name = "A", eventsToEmitOnInit = listOf( @@ -274,7 +274,7 @@ class MultiProviderTests { } @Test - fun emits_events_only_on_status_change() = runTest { + fun emitsEventsOnlyOnStatusChange() = runTest { val provider = FakeEventProvider( name = "A", eventsToEmitOnInit = listOf( @@ -303,7 +303,7 @@ class MultiProviderTests { } @Test - fun configuration_changed_is_always_emitted() = runTest { + fun configurationChangedIsAlwaysEmitted() = runTest { val provider = FakeEventProvider( name = "A", eventsToEmitOnInit = listOf( @@ -328,7 +328,7 @@ class MultiProviderTests { } @Test - fun shutdown_aggregates_errors_and_reports_provider_names() { + fun shutdownAggregatesErrorsAndReportsProviderNames() { val ok = FakeEventProvider(name = "ok") val bad1 = FakeEventProvider(name = "bad1", shutdownThrowable = IllegalStateException("oops1")) val bad2 = FakeEventProvider(name = null, shutdownThrowable = RuntimeException("oops2")) @@ -434,7 +434,7 @@ private class FakeEventProvider( private class RecordingStrategy( private val returnValue: ProviderEvaluation -) : Strategy { +) : MultiProvider.Strategy { var lastProviderNames: List = emptyList() private set From 23050561562b7ffce742ee726ba29b90b0a57c4b Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 26 Aug 2025 10:03:59 -0700 Subject: [PATCH 16/24] Return an error result for FirstSuccessfulStrategy rather than throwing Signed-off-by: penguindan --- .../sdk/multiprovider/FirstSuccessfulStrategy.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt index 0d85bbf..07c5184 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt @@ -3,13 +3,15 @@ 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.Reason +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError /** * A [MultiProvider.Strategy] similar to the [FirstMatchStrategy], except that errors from evaluated * providers do not halt execution. * - * If no provider successfully responds, it throws an error result. + * If no provider successfully responds, it returns an error result. */ class FirstSuccessfulStrategy : MultiProvider.Strategy { override fun evaluate( @@ -40,6 +42,11 @@ class FirstSuccessfulStrategy : MultiProvider.Strategy { // 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.") + return ProviderEvaluation( + value = defaultValue, + reason = Reason.DEFAULT.toString(), + errorCode = ErrorCode.FLAG_NOT_FOUND, + errorMessage = "No provider returned a successful evaluation for the requested flag." + ) } } \ No newline at end of file From 70a892df1fabf0ff6b09ab19e6fa58f43c3666be Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 26 Aug 2025 10:06:32 -0700 Subject: [PATCH 17/24] Revert sample app changes Signed-off-by: penguindan --- .../kotlin/sdk/sampleapp/ExampleProvider.kt | 35 +++++----- .../kotlin/sdk/sampleapp/MainActivity.kt | 65 +++---------------- 2 files changed, 26 insertions(+), 74 deletions(-) diff --git a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt index c3ee87a..2cc9e44 100644 --- a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt +++ b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/ExampleProvider.kt @@ -1,40 +1,39 @@ package dev.openfeature.kotlin.sdk.sampleapp import dev.openfeature.kotlin.sdk.* -import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents -import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -class ExampleProvider( - providerName: String, - private val flags: Map, - override val hooks: List> = listOf() -) : FeatureProvider { +class ExampleProvider(override val hooks: List> = listOf()) : FeatureProvider { + private var currentContext: EvaluationContext? = ImmutableContext() var delayTime = 1000L var returnDefaults = false - private val eventFlow = MutableSharedFlow() - - override val metadata: ProviderMetadata = object : ProviderMetadata { - override val name: String = providerName + val flags = mutableMapOf().apply { + put("booleanFlag", true) + put("stringFlag", "this is a string") + put("intFlag", 1337) + put("doubleFlag", 42.0) + put( + "objectFlag", + Value.Structure(mapOf("key1" to Value.String("value"), "key2" to Value.Integer(10))) + ) } + override val metadata: ProviderMetadata + get() = object : ProviderMetadata { + override val name: String = "ExampleProvider" + } + override suspend fun initialize(initialContext: EvaluationContext?) { currentContext = initialContext // Simulate a delay in the provider initialization delay(delayTime) - eventFlow.emit(OpenFeatureProviderEvents.ProviderReady) } override fun shutdown() { } - override fun observe(): Flow = eventFlow.asSharedFlow() - override suspend fun onContextSet( oldContext: EvaluationContext?, newContext: EvaluationContext @@ -86,7 +85,7 @@ class ExampleProvider( } else if (containsKey(key)) { ProviderEvaluation(defaultValue, null, reason = "invalid type") } else { - ProviderEvaluation(defaultValue, null, reason = "notfound", errorCode = ErrorCode.FLAG_NOT_FOUND) + ProviderEvaluation(defaultValue, null, reason = "notfound") } } } diff --git a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt index 902dc19..74081bf 100644 --- a/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt +++ b/sampleapp/src/main/kotlin/dev/openfeature/kotlin/sdk/sampleapp/MainActivity.kt @@ -43,69 +43,38 @@ import dev.openfeature.kotlin.sdk.ImmutableContext import dev.openfeature.kotlin.sdk.OpenFeatureAPI import dev.openfeature.kotlin.sdk.OpenFeatureStatus import dev.openfeature.kotlin.sdk.Value -import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider import dev.openfeature.kotlin.sdk.sampleapp.ui.theme.OpenFeatureTheme -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.random.Random class MainActivity : ComponentActivity() { - private val exampleProvider1 = ExampleProvider( - "ExampleProvider1", - flags = mapOf( - "stringFlag" to "this is a string", - "intFlag" to 1337, - "doubleFlag" to 42.0 - ) - ) - private val exampleProvider2 = ExampleProvider( - "ExampleProvider2", - flags = mapOf( - "booleanFlag" to true, - "objectFlag" to Value.Structure( - mapOf("key1" to Value.String("value"), "key2" to Value.Integer(10)) - ) - ) - ) + private val exampleProvider = ExampleProvider() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - val multiProvider = MultiProvider(listOf(exampleProvider1, exampleProvider2)) - lifecycleScope.launch(Dispatchers.IO) { - OpenFeatureAPI.setProviderAndWait(multiProvider) + lifecycleScope.launch { + OpenFeatureAPI.setProviderAndWait(exampleProvider) OpenFeatureAPI.statusFlow.collect { Log.i("OpenFeature", "Status: $it") } } - val statusFlow = OpenFeatureAPI.statusFlow.map { if (it is OpenFeatureStatus.Error) { "Error: ${it.error.errorCode()} - ${it.error.message}" } else it.javaClass.simpleName } - - val multiProviderEventFlow = multiProvider.statusFlow.map { - it.javaClass.simpleName - } - setContent { OpenFeatureTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> MainPage( modifier = Modifier.padding(innerPadding), - setDelay = { - exampleProvider1.delayTime = it - exampleProvider2.delayTime = it - }, + setDelay = { exampleProvider.delayTime = it }, statusFlow = statusFlow, - multiProviderStatusFlow = multiProviderEventFlow, toggleDefaults = { - exampleProvider1.returnDefaults = !exampleProvider1.returnDefaults - exampleProvider2.returnDefaults = !exampleProvider2.returnDefaults + exampleProvider.returnDefaults = !exampleProvider.returnDefaults } ) } @@ -119,7 +88,6 @@ fun MainPage( modifier: Modifier = Modifier, setDelay: (Long) -> Unit, statusFlow: Flow, - multiProviderStatusFlow: Flow, defaultTab: Int = 0, toggleDefaults: () -> Unit ) { @@ -153,11 +121,7 @@ fun MainPage( // Content for the currently selected tab when (selectedTabIndex) { - 0 -> ProviderAndStatus( - setDelay = setDelay, - statusFlow = statusFlow, - multiProviderStatusFlow = multiProviderStatusFlow, - ) + 0 -> ProviderAndStatus(setDelay = setDelay, statusFlow = statusFlow) 1 -> Evaluations(toggleDefaults = toggleDefaults) 2 -> Hooks() } @@ -221,14 +185,8 @@ fun Evaluations(toggleDefaults: () -> Unit) { } @Composable -fun ProviderAndStatus( - statusFlow: Flow, - multiProviderStatusFlow: Flow, - setDelay: (Long) -> Unit, -) { +fun ProviderAndStatus(statusFlow: Flow, setDelay: (Long) -> Unit) { val statusState by statusFlow.collectAsState(initial = "initial") - val multiProviderState by multiProviderStatusFlow.collectAsState(initial = "initial") - val coroutineScope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxSize()) { var sliderValue by remember { mutableStateOf(0.1f) } @@ -290,10 +248,6 @@ fun ProviderAndStatus( Row(Modifier.padding(top = 8.dp)) { Text(text = "Current SDK status: $statusState") } - - Row(Modifier.padding(top = 8.dp)) { - Text(text = "MultiProvider Latest event: $multiProviderState") - } } } @@ -369,8 +323,7 @@ fun MainPagePreview() { OpenFeatureTheme { MainPage( setDelay = { }, - statusFlow = flowOf("initial"), - multiProviderStatusFlow = flowOf("initial"), + statusFlow = emptyFlow(), defaultTab = 0, toggleDefaults = { } ) From 6fe18eb8342a694d467ca611276c5fb3efa94b28 Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 26 Aug 2025 10:25:38 -0700 Subject: [PATCH 18/24] Add README documentation for Multiprovider Signed-off-by: penguindan --- docs/multiprovider/README.md | 127 +++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/multiprovider/README.md diff --git a/docs/multiprovider/README.md b/docs/multiprovider/README.md new file mode 100644 index 0000000..640a6a6 --- /dev/null +++ b/docs/multiprovider/README.md @@ -0,0 +1,127 @@ +## MultiProvider (OpenFeature Kotlin SDK) + +Combine multiple `FeatureProvider`s into a single provider with deterministic ordering, pluggable evaluation strategies, and unified status/event handling. + +### Why use MultiProvider? +- **Layer providers**: fall back from an in-memory or experiment provider to a remote provider. +- **Migrate safely**: put the new provider first, retain the old as fallback. +- **Handle errors predictably**: choose whether errors should short-circuit or be skipped. + +This implementation is adapted for Kotlin coroutines, flows, and OpenFeature error types. + +### Quick start +```kotlin +import dev.openfeature.kotlin.sdk.OpenFeatureAPI +import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider +import dev.openfeature.kotlin.sdk.multiprovider.FirstMatchStrategy +// import dev.openfeature.kotlin.sdk.multiprovider.FirstSuccessfulStrategy + +// 1) Construct your providers (examples) +val experiments = MyExperimentProvider() // e.g., local overrides/experiments +val remote = MyRemoteProvider() // e.g., network-backed + +// 2) Wrap them with MultiProvider in the desired order +val multi = MultiProvider( + providers = listOf(experiments, remote), + strategy = FirstMatchStrategy() // default; FirstSuccessfulStrategy() also available +) + +// 3) Set the SDK provider and wait until ready (or observe status) +OpenFeatureAPI.setProvider(multi) +// Optionally await readiness via OpenFeatureAPI.statusFlow. + +// 4) Use the client as usual +val client = OpenFeatureAPI.getClient("my-app") +val enabled = client.getBooleanValue("new-ui", defaultValue = false) +``` + +### How it works (at a glance) +- The `MultiProvider` delegates each evaluation to its child providers in the order you supply. +- A pluggable `Strategy` decides which child result to return. +- Provider events are observed and converted into a single aggregate SDK status. +- Context changes are forwarded to all children concurrently. + +### Strategies + +- **FirstMatchStrategy (default)** + - Returns the first child result that is not "flag not found". + - If a child returns an error other than `FLAG_NOT_FOUND`, that error is returned immediately. + - If all children report `FLAG_NOT_FOUND`, the default value is returned with reason `DEFAULT`. + +- **FirstSuccessfulStrategy** + - Skips over errors from children and continues to the next provider. + - Returns the first successful evaluation (no error code). + - If no provider succeeds, the default value is returned with `FLAG_NOT_FOUND`. + +Pick the strategy that best matches your failure-policy: +- Prefer early, explicit error surfacing: use `FirstMatchStrategy`. +- Prefer resilience and best-effort success: use `FirstSuccessfulStrategy`. + +### Evaluation order matters +Children are evaluated in the order provided. Put the most authoritative or fastest provider first. For example, place a small in-memory override provider before a remote provider to reduce latency. + +### Events and status aggregation +`MultiProvider` listens to child provider events and emits a single, aggregate status via `OpenFeatureAPI.statusFlow`. The highest-precedence status among children wins: + +1. Fatal +2. NotReady +3. Error +4. Reconciling / Stale +5. Ready + +`ProviderConfigurationChanged` is re-emitted as-is. When the aggregate status changes due to a child event, the original triggering event is also emitted. + +### Context propagation +When the evaluation context changes, `MultiProvider` calls `onContextSet` on all child providers concurrently. Aggregate status transitions to Reconciling and then back to Ready (or Error) in line with SDK behavior. + +### Provider metadata +`MultiProvider.metadata` exposes: +- `name = "multiprovider"` +- `originalMetadata`: a map of child-name → child `ProviderMetadata` + +Child names are derived from each provider’s `metadata.name`. If duplicates occur, stable suffixes are applied (e.g., `myProvider_1`, `myProvider_2`). + +Example: inspect provider metadata +```kotlin +val meta = OpenFeatureAPI.getProviderMetadata() +println(meta?.name) // "multiprovider" +println(meta?.originalMetadata) // map of child names to their metadata +``` + +### Shutdown behavior +`shutdown()` is invoked on all children. If any child fails to shut down, an aggregated error is thrown that includes all individual failures. Resources should be released in child providers even if peers fail. + +### Custom strategies +You can provide your own composition policy by implementing `MultiProvider.Strategy`: +```kotlin +import dev.openfeature.kotlin.sdk.* +import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider + +class MyStrategy : MultiProvider.Strategy { + override fun evaluate( + providers: List, + key: String, + defaultValue: T, + evaluationContext: EvaluationContext?, + flagEval: FeatureProvider.(String, T, EvaluationContext?) -> ProviderEvaluation + ): ProviderEvaluation { + // Example: try all, prefer the highest integer value (demo only) + var best: ProviderEvaluation? = null + for (p in providers) { + val e = p.flagEval(key, defaultValue, evaluationContext) + // ... decide whether to keep e as best ... + best = best ?: e + } + return best ?: ProviderEvaluation(defaultValue) + } +} + +val multi = MultiProvider(listOf(experiments, remote), strategy = MyStrategy()) +``` + +### Notes and limitations +- Hooks on `MultiProvider` are currently not applied. +- Ensure each child’s `metadata.name` is set for clearer diagnostics in `originalMetadata`. + + + From 870ae07d18e7d70aa6787eb32561514a74afcbf4 Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 26 Aug 2025 10:39:51 -0700 Subject: [PATCH 19/24] Lets favor not throwing in the FirstMatchStrategy also Signed-off-by: penguindan --- .../kotlin/sdk/multiprovider/FirstMatchStrategy.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt index 2c3aacc..16f7d52 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt @@ -35,12 +35,17 @@ class FirstMatchStrategy : MultiProvider.Strategy { if (eval.errorCode != ErrorCode.FLAG_NOT_FOUND) { return eval } - // Continue to next provider if error is FLAG_NOT_FOUND } catch (_: OpenFeatureError.FlagNotFoundError) { // Handle FLAG_NOT_FOUND exception - continue to next provider continue + } catch (error: OpenFeatureError) { + return ProviderEvaluation( + defaultValue, + reason = Reason.ERROR.toString(), + errorCode = error.errorCode(), + errorMessage = error.message, + ) } - // 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 From 5472c08980d800bfb1c02c94c753363fba9f01e4 Mon Sep 17 00:00:00 2001 From: penguindan Date: Tue, 26 Aug 2025 10:49:22 -0700 Subject: [PATCH 20/24] Update tests to represent to non-throwing pattern and api dump Signed-off-by: penguindan --- kotlin-sdk/api/android/kotlin-sdk.api | 11 +++++----- kotlin-sdk/api/jvm/kotlin-sdk.api | 11 +++++----- .../multiprovider/FirstMatchStrategyTests.kt | 22 +++++++++---------- .../FirstSuccessfulStrategyTests.kt | 22 +++++++++---------- 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/kotlin-sdk/api/android/kotlin-sdk.api b/kotlin-sdk/api/android/kotlin-sdk.api index 78aeef4..aece5a7 100644 --- a/kotlin-sdk/api/android/kotlin-sdk.api +++ b/kotlin-sdk/api/android/kotlin-sdk.api @@ -744,27 +744,26 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi public fun getMessage ()Ljava/lang/String; } -public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { public fun ()V public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; } -public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { public fun ()V public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; } public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion; - public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;)V - public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V + public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun getHooks ()Ljava/util/List; public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; - public final fun getProviderCount ()I public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -794,7 +793,7 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildF public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion { } -public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/Strategy { +public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; } diff --git a/kotlin-sdk/api/jvm/kotlin-sdk.api b/kotlin-sdk/api/jvm/kotlin-sdk.api index 78aeef4..aece5a7 100644 --- a/kotlin-sdk/api/jvm/kotlin-sdk.api +++ b/kotlin-sdk/api/jvm/kotlin-sdk.api @@ -744,27 +744,26 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi public fun getMessage ()Ljava/lang/String; } -public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { public fun ()V public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; } -public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/Strategy { +public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { public fun ()V public fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; } public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion; - public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;)V - public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V + public synthetic fun (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun getHooks ()Ljava/util/List; public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; - public final fun getProviderCount ()I public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -794,7 +793,7 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildF public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion { } -public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/Strategy { +public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy { public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; } diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt index 7c4f38d..f034ccb 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategyTests.kt @@ -7,7 +7,6 @@ import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import dev.openfeature.kotlin.sdk.helpers.RecordingBooleanProvider import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith class FirstMatchStrategyTests { @@ -121,22 +120,21 @@ class FirstMatchStrategyTests { } @Test - fun bubblesUpNonNotFoundExceptions() { + fun returnsErrorResultForNonNotFoundExceptions() { val strategy = FirstMatchStrategy() val throwsGeneral = RecordingBooleanProvider( name = "throws-general", behavior = { throw OpenFeatureError.GeneralError("fail") } ) - - assertFailsWith { - strategy.evaluate( - listOf(throwsGeneral), - key = "flag", - defaultValue = false, - evaluationContext = null, - flagEval = FeatureProvider::getBooleanEvaluation - ) - } + val result = strategy.evaluate( + listOf(throwsGeneral), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + assertEquals(false, result.value) + assertEquals(ErrorCode.GENERAL, result.errorCode) assertEquals(1, throwsGeneral.booleanEvalCalls) } diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt index e6e841c..b9058d1 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategyTests.kt @@ -6,7 +6,6 @@ import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import dev.openfeature.kotlin.sdk.helpers.RecordingBooleanProvider import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith class FirstSuccessfulStrategyTests { @@ -70,7 +69,7 @@ class FirstSuccessfulStrategyTests { } @Test - fun throwsWhenNoProviderReturnsSuccess() { + fun returnsErrorWhenNoProviderReturnsSuccess() { val strategy = FirstSuccessfulStrategy() val error1 = RecordingBooleanProvider("e1") { throw OpenFeatureError.GeneralError("boom1") @@ -81,16 +80,15 @@ class FirstSuccessfulStrategyTests { val notFound = RecordingBooleanProvider("nf") { dev.openfeature.kotlin.sdk.ProviderEvaluation(false, errorCode = ErrorCode.FLAG_NOT_FOUND) } - - assertFailsWith { - strategy.evaluate( - listOf(error1, error2, notFound), - key = "flag", - defaultValue = false, - evaluationContext = null, - flagEval = FeatureProvider::getBooleanEvaluation - ) - } + val result = strategy.evaluate( + listOf(error1, error2, notFound), + key = "flag", + defaultValue = false, + evaluationContext = null, + flagEval = FeatureProvider::getBooleanEvaluation + ) + assertEquals(false, result.value) + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.errorCode) assertEquals(1, error1.booleanEvalCalls) assertEquals(1, error2.booleanEvalCalls) assertEquals(1, notFound.booleanEvalCalls) From 7df67726f649353b8f0431cabadb2b785b4b26a1 Mon Sep 17 00:00:00 2001 From: penguindan Date: Fri, 29 Aug 2025 18:10:01 -0700 Subject: [PATCH 21/24] Kotlin Format Signed-off-by: penguindan --- .../openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt index 16f7d52..671fb85 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy.kt @@ -43,7 +43,7 @@ class FirstMatchStrategy : MultiProvider.Strategy { defaultValue, reason = Reason.ERROR.toString(), errorCode = error.errorCode(), - errorMessage = error.message, + errorMessage = error.message ) } } From 2cc75c553fdc6d431c44346bf94130f394012b94 Mon Sep 17 00:00:00 2001 From: Daniel Kim Date: Sat, 30 Aug 2025 16:05:52 -0700 Subject: [PATCH 22/24] Update kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bence Hornák Signed-off-by: Daniel Kim --- .../kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt index 07c5184..514ba92 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy.kt @@ -11,7 +11,7 @@ import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError * A [MultiProvider.Strategy] similar to the [FirstMatchStrategy], except that errors from evaluated * providers do not halt execution. * - * If no provider successfully responds, it returns an error result. + * If no provider responds successfully, it returns an error result. */ class FirstSuccessfulStrategy : MultiProvider.Strategy { override fun evaluate( From e71d4480b1c4ac595cc766a6ffc5303ac6a54e96 Mon Sep 17 00:00:00 2001 From: penguindan Date: Sat, 30 Aug 2025 16:09:27 -0700 Subject: [PATCH 23/24] Update multi provider readme Signed-off-by: penguindan --- docs/multiprovider/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/multiprovider/README.md b/docs/multiprovider/README.md index 640a6a6..eb35431 100644 --- a/docs/multiprovider/README.md +++ b/docs/multiprovider/README.md @@ -26,9 +26,8 @@ val multi = MultiProvider( strategy = FirstMatchStrategy() // default; FirstSuccessfulStrategy() also available ) -// 3) Set the SDK provider and wait until ready (or observe status) -OpenFeatureAPI.setProvider(multi) -// Optionally await readiness via OpenFeatureAPI.statusFlow. +// 3) Set the SDK provider and wait until ready +OpenFeatureAPI.setProviderAndWait() // 4) Use the client as usual val client = OpenFeatureAPI.getClient("my-app") From f540783d7c3a3a7de199b701751e769d843e0ec4 Mon Sep 17 00:00:00 2001 From: penguindan Date: Mon, 1 Sep 2025 07:49:46 -0700 Subject: [PATCH 24/24] API dump Signed-off-by: penguindan --- kotlin-sdk/api/android/kotlin-sdk.api | 2 +- kotlin-sdk/api/jvm/kotlin-sdk.api | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kotlin-sdk/api/android/kotlin-sdk.api b/kotlin-sdk/api/android/kotlin-sdk.api index 2e57942..cafdfce 100644 --- a/kotlin-sdk/api/android/kotlin-sdk.api +++ b/kotlin-sdk/api/android/kotlin-sdk.api @@ -383,7 +383,7 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation { public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata { public abstract fun getName ()Ljava/lang/String; - public abstract fun getOriginalMetadata ()Ljava/util/Map; + public fun getOriginalMetadata ()Ljava/util/Map; } public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls { diff --git a/kotlin-sdk/api/jvm/kotlin-sdk.api b/kotlin-sdk/api/jvm/kotlin-sdk.api index 2e57942..cafdfce 100644 --- a/kotlin-sdk/api/jvm/kotlin-sdk.api +++ b/kotlin-sdk/api/jvm/kotlin-sdk.api @@ -383,7 +383,7 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation { public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata { public abstract fun getName ()Ljava/lang/String; - public abstract fun getOriginalMetadata ()Ljava/util/Map; + public fun getOriginalMetadata ()Ljava/util/Map; } public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls {