Skip to content

Commit af65a59

Browse files
committed
Multi provider impl draft
Signed-off-by: penguindan <[email protected]>
1 parent 4542eb9 commit af65a59

File tree

4 files changed

+379
-0
lines changed

4 files changed

+379
-0
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package dev.openfeature.kotlin.sdk.multiprovider
2+
3+
import dev.openfeature.kotlin.sdk.EvaluationContext
4+
import dev.openfeature.kotlin.sdk.FeatureProvider
5+
import dev.openfeature.kotlin.sdk.ProviderEvaluation
6+
import dev.openfeature.kotlin.sdk.exceptions.ErrorCode
7+
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
8+
9+
/**
10+
* Return the first result returned by a provider. Skip providers that indicate they had no value due to FLAG_NOT_FOUND.
11+
* In all other cases, use the value returned by the provider. If any provider returns an error result other than
12+
* FLAG_NOT_FOUND, the whole evaluation should error and "bubble up" the individual provider's error in the result.
13+
*
14+
* As soon as a value is returned by a provider, the rest of the operation should short-circuit and not call the
15+
* rest of the providers.
16+
*/
17+
class FirstMatchStrategy : Strategy {
18+
/**
19+
* Evaluates providers in sequence until finding one that has knowledge of the flag.
20+
*
21+
* @param providers List of providers to evaluate in order
22+
* @param key The feature flag key to look up
23+
* @param defaultValue Value to return if no provider knows about the flag
24+
* @param evaluationContext Optional context for evaluation
25+
* @param flagEval The specific evaluation method to call on each provider
26+
* @return ProviderEvaluation with the first match or default value
27+
*/
28+
override fun <T> evaluate(
29+
providers: List<FeatureProvider>,
30+
key: String,
31+
defaultValue: T,
32+
evaluationContext: EvaluationContext?,
33+
flagEval: FlagEval<T>
34+
): ProviderEvaluation<T> {
35+
// Iterate through each provider in the provided order
36+
for (provider in providers) {
37+
try {
38+
// Call the flag evaluation method on the current provider
39+
val eval = provider.flagEval(key, defaultValue, evaluationContext)
40+
41+
// If the provider knows about this flag (any result except FLAG_NOT_FOUND),
42+
// return this result immediately, even if it's an error
43+
if (eval.errorCode != ErrorCode.FLAG_NOT_FOUND) {
44+
return eval
45+
}
46+
// Continue to next provider if error is FLAG_NOT_FOUND
47+
} catch (_: OpenFeatureError.FlagNotFoundError) {
48+
// Handle FLAG_NOT_FOUND exception - continue to next provider
49+
continue
50+
}
51+
// We don't catch any other exception, but rather, bubble up the exceptions
52+
}
53+
54+
// No provider knew about the flag, return default value with DEFAULT reason
55+
return ProviderEvaluation(defaultValue, errorCode = ErrorCode.FLAG_NOT_FOUND)
56+
}
57+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package dev.openfeature.kotlin.sdk.multiprovider
2+
3+
import dev.openfeature.kotlin.sdk.EvaluationContext
4+
import dev.openfeature.kotlin.sdk.FeatureProvider
5+
import dev.openfeature.kotlin.sdk.ProviderEvaluation
6+
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
7+
8+
/**
9+
* Similar to "First Match", except that errors from evaluated providers do not halt execution.
10+
* Instead, it will return the first successful result from a provider.
11+
*
12+
* If no provider successfully responds, it will throw an error result.
13+
*/
14+
class FirstSuccessfulStrategy : Strategy {
15+
/**
16+
* Evaluates providers in sequence until finding one that returns a successful result.
17+
*
18+
* @param providers List of providers to evaluate in order
19+
* @param key The feature flag key to evaluate
20+
* @param defaultValue Value to use in provider evaluations
21+
* @param evaluationContext Optional context for evaluation
22+
* @param flagEval The specific evaluation method to call on each provider
23+
* @return ProviderEvaluation with the first successful result
24+
* @throws OpenFeatureError.GeneralError if no provider returns a successful evaluation
25+
*/
26+
override fun <T> evaluate(
27+
providers: List<FeatureProvider>,
28+
key: String,
29+
defaultValue: T,
30+
evaluationContext: EvaluationContext?,
31+
flagEval: FlagEval<T>
32+
): ProviderEvaluation<T> {
33+
// Iterate through each provider in the provided order
34+
for (provider in providers) {
35+
try {
36+
// Call the flag evaluation method on the current provider
37+
val eval = provider.flagEval(key, defaultValue, evaluationContext)
38+
39+
// If the provider returned a successful result (no error),
40+
// return this result immediately
41+
if (eval.errorCode == null) {
42+
return eval
43+
}
44+
// Continue to next provider if this one had an error
45+
} catch (_: OpenFeatureError) {
46+
// Handle any OpenFeature exceptions - continue to next provider
47+
// FirstSuccessful strategy skips errors and continues
48+
continue
49+
}
50+
}
51+
52+
// No provider returned a successful result, throw an error
53+
// This indicates that all providers either failed or had errors
54+
throw OpenFeatureError.GeneralError("No provider returned a successful evaluation for the requested flag.")
55+
}
56+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package dev.openfeature.kotlin.sdk.multiprovider
2+
3+
import dev.openfeature.kotlin.sdk.EvaluationContext
4+
import dev.openfeature.kotlin.sdk.FeatureProvider
5+
import dev.openfeature.kotlin.sdk.Hook
6+
import dev.openfeature.kotlin.sdk.ProviderEvaluation
7+
import dev.openfeature.kotlin.sdk.ProviderMetadata
8+
import dev.openfeature.kotlin.sdk.Value
9+
import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
10+
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
11+
import kotlinx.coroutines.async
12+
import kotlinx.coroutines.awaitAll
13+
import kotlinx.coroutines.coroutineScope
14+
import kotlinx.coroutines.flow.Flow
15+
import kotlinx.coroutines.flow.MutableSharedFlow
16+
import kotlinx.coroutines.flow.asSharedFlow
17+
import kotlinx.coroutines.flow.launchIn
18+
import kotlinx.coroutines.flow.onEach
19+
20+
/**
21+
* MultiProvider is a FeatureProvider implementation that delegates flag evaluations
22+
* to multiple underlying providers using a configurable strategy.
23+
*
24+
* This class acts as a composite provider that can:
25+
* - Combine multiple feature providers into a single interface
26+
* - Apply different evaluation strategies (FirstMatch, FirstSuccessful, etc.)
27+
* - Manage lifecycle events for all underlying providers
28+
* - Forward context changes to all providers
29+
*
30+
* @param providers List of FeatureProvider instances to delegate to
31+
* @param strategy Strategy to use for combining provider results (defaults to FirstMatchStrategy)
32+
*/
33+
class MultiProvider(
34+
providers: List<FeatureProvider>,
35+
private val strategy: Strategy = FirstMatchStrategy(),
36+
) : FeatureProvider {
37+
// Metadata identifying this as a multiprovider
38+
override val metadata: ProviderMetadata = object : ProviderMetadata {
39+
override val name: String? = "multiprovider"
40+
}
41+
42+
// TODO: Support hooks
43+
override val hooks: List<Hook<*>> = emptyList()
44+
private val uniqueProviders = getUniqueSetOfProviders(providers)
45+
46+
// Shared flow because we don't want the distinct operator since it would break consecutive emits of
47+
// ProviderConfigurationChanged
48+
private val eventFlow = MutableSharedFlow<OpenFeatureProviderEvents>(replay = 1, extraBufferCapacity = 5).apply {
49+
OpenFeatureProviderEvents.ProviderError(OpenFeatureError.ProviderNotReadyError())
50+
}
51+
52+
// Track individual provider statuses
53+
private val providerStatuses = mutableMapOf<FeatureProvider, OpenFeatureProviderEvents>()
54+
55+
// Event precedence (highest to lowest priority) - based on the specifications
56+
private val eventPrecedence = mapOf(
57+
OpenFeatureProviderEvents.ProviderError::class to 4, // FATAL/ERROR
58+
OpenFeatureProviderEvents.ProviderNotReady::class to 3, // NOT READY, Deprecated but still supporting
59+
OpenFeatureProviderEvents.ProviderStale::class to 2, // STALE
60+
OpenFeatureProviderEvents.ProviderReady::class to 1 // READY
61+
// ProviderConfigurationChanged doesn't affect status, so not included
62+
)
63+
64+
private fun getUniqueSetOfProviders(providers: List<FeatureProvider>): List<FeatureProvider> {
65+
val setOfProviderNames = mutableSetOf<String>()
66+
val uniqueProviders = mutableListOf<FeatureProvider>()
67+
providers.forEach { currProvider ->
68+
val providerName = currProvider.metadata.name
69+
if (setOfProviderNames.add(providerName.orEmpty())) {
70+
uniqueProviders.add(currProvider)
71+
} else {
72+
println("Duplicate provider with name $providerName found") // Log error, no logging tool
73+
}
74+
}
75+
76+
return uniqueProviders
77+
}
78+
79+
/**
80+
* @return Number of unique providers
81+
*/
82+
fun getProviderCount(): Int = uniqueProviders.size
83+
84+
override fun observe(): Flow<OpenFeatureProviderEvents> = eventFlow.asSharedFlow()
85+
86+
/**
87+
* Initializes all underlying providers with the given context.
88+
* This ensures all providers are ready before any evaluations occur.
89+
*
90+
* @param initialContext Optional evaluation context to initialize providers with
91+
*/
92+
override suspend fun initialize(initialContext: EvaluationContext?) {
93+
coroutineScope {
94+
// Listen to events emitted by providers to emit our own set of events
95+
// according to https://openfeature.dev/specification/appendix-a/#status-and-event-handling
96+
uniqueProviders.forEach { provider ->
97+
provider.observe()
98+
.onEach { event ->
99+
handleProviderEvent(provider, event)
100+
}
101+
.launchIn(this)
102+
}
103+
104+
// State updates captured by observing individual Feature Flag providers
105+
uniqueProviders
106+
.map { async { it.initialize(initialContext) } }
107+
.awaitAll()
108+
}
109+
}
110+
111+
private suspend fun handleProviderEvent(provider: FeatureProvider, event: OpenFeatureProviderEvents) {
112+
val hasStatusUpdated = updateProviderStatus(provider, event)
113+
114+
// This event should be re-emitted any time it occurs from any provider.
115+
if (event is OpenFeatureProviderEvents.ProviderConfigurationChanged) {
116+
eventFlow.emit(event)
117+
return
118+
}
119+
120+
// If the status has been updated, calculate what our new event should be
121+
if (hasStatusUpdated) {
122+
val currPrecedenceVal = eventFlow.replayCache.firstOrNull()?.run { eventPrecedence[this::class] } ?: 0
123+
val updatedPrecedenceVal = eventPrecedence[event::class] ?: 0
124+
125+
if (updatedPrecedenceVal > currPrecedenceVal) {
126+
eventFlow.emit(event)
127+
}
128+
}
129+
}
130+
131+
/**
132+
* @return true if the status has been updated to a different value, false otherwise
133+
*/
134+
private fun updateProviderStatus(provider: FeatureProvider, newStatus: OpenFeatureProviderEvents): Boolean {
135+
val oldStatus = providerStatuses[provider]
136+
providerStatuses[provider] = newStatus
137+
138+
return oldStatus != newStatus
139+
}
140+
141+
/**
142+
* Shuts down all underlying providers.
143+
* This allows providers to clean up resources and complete any pending operations.
144+
*/
145+
override fun shutdown() {
146+
uniqueProviders.forEach { it.shutdown() }
147+
}
148+
149+
override suspend fun onContextSet(
150+
oldContext: EvaluationContext?,
151+
newContext: EvaluationContext
152+
) {
153+
uniqueProviders.forEach { it.onContextSet(oldContext, newContext) }
154+
}
155+
156+
override fun getBooleanEvaluation(
157+
key: String,
158+
defaultValue: Boolean,
159+
context: EvaluationContext?
160+
): ProviderEvaluation<Boolean> {
161+
return strategy.evaluate(
162+
uniqueProviders,
163+
key,
164+
defaultValue,
165+
context,
166+
FeatureProvider::getBooleanEvaluation,
167+
)
168+
}
169+
170+
override fun getStringEvaluation(
171+
key: String,
172+
defaultValue: String,
173+
context: EvaluationContext?
174+
): ProviderEvaluation<String> {
175+
return strategy.evaluate(
176+
uniqueProviders,
177+
key,
178+
defaultValue,
179+
context,
180+
FeatureProvider::getStringEvaluation,
181+
)
182+
}
183+
184+
override fun getIntegerEvaluation(
185+
key: String,
186+
defaultValue: Int,
187+
context: EvaluationContext?
188+
): ProviderEvaluation<Int> {
189+
return strategy.evaluate(
190+
uniqueProviders,
191+
key,
192+
defaultValue,
193+
context,
194+
FeatureProvider::getIntegerEvaluation,
195+
)
196+
}
197+
198+
override fun getDoubleEvaluation(
199+
key: String,
200+
defaultValue: Double,
201+
context: EvaluationContext?
202+
): ProviderEvaluation<Double> {
203+
return strategy.evaluate(
204+
uniqueProviders,
205+
key,
206+
defaultValue,
207+
context,
208+
FeatureProvider::getDoubleEvaluation,
209+
)
210+
}
211+
212+
override fun getObjectEvaluation(
213+
key: String,
214+
defaultValue: Value,
215+
context: EvaluationContext?
216+
): ProviderEvaluation<Value> {
217+
return strategy.evaluate(
218+
uniqueProviders,
219+
key,
220+
defaultValue,
221+
context,
222+
FeatureProvider::getObjectEvaluation,
223+
)
224+
}
225+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package dev.openfeature.kotlin.sdk.multiprovider
2+
3+
import dev.openfeature.kotlin.sdk.EvaluationContext
4+
import dev.openfeature.kotlin.sdk.FeatureProvider
5+
import dev.openfeature.kotlin.sdk.ProviderEvaluation
6+
7+
/**
8+
* Type alias for a function that evaluates a feature flag using a FeatureProvider.
9+
* This represents an extension function on FeatureProvider that takes:
10+
* - key: The feature flag key to evaluate
11+
* - defaultValue: The default value to return if evaluation fails
12+
* - evaluationContext: Optional context for the evaluation
13+
* Returns a ProviderEvaluation containing the result
14+
*/
15+
typealias FlagEval<T> = FeatureProvider.(key: String, defaultValue: T, evaluationContext: EvaluationContext?) -> ProviderEvaluation<T>
16+
17+
/**
18+
* Strategy interface defines how multiple feature providers should be evaluated
19+
* to determine the final result for a feature flag evaluation.
20+
* Different strategies can implement different logic for combining or selecting
21+
* results from multiple providers.
22+
*/
23+
interface Strategy {
24+
/**
25+
* Evaluates a feature flag across multiple providers using the strategy's logic.
26+
*
27+
* @param providers List of FeatureProvider instances to evaluate against
28+
* @param key The feature flag key to evaluate
29+
* @param defaultValue The default value to use if evaluation fails or no providers match
30+
* @param evaluationContext Optional context containing additional data for evaluation
31+
* @param flagEval Function reference to the specific evaluation method to call on each provider
32+
* @return ProviderEvaluation<T> containing the final evaluation result
33+
*/
34+
fun <T> evaluate(
35+
providers: List<FeatureProvider>,
36+
key: String,
37+
defaultValue: T,
38+
evaluationContext: EvaluationContext?,
39+
flagEval: FlagEval<T>,
40+
): ProviderEvaluation<T>
41+
}

0 commit comments

Comments
 (0)