Skip to content

Commit 807fcc1

Browse files
feat: Multi provider impl (#168)
Signed-off-by: penguindan <[email protected]> Signed-off-by: Daniel Kim <[email protected]> Co-authored-by: Bence Hornák <[email protected]>
1 parent a585962 commit 807fcc1

File tree

11 files changed

+1518
-0
lines changed

11 files changed

+1518
-0
lines changed

docs/multiprovider/README.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
## MultiProvider (OpenFeature Kotlin SDK)
2+
3+
Combine multiple `FeatureProvider`s into a single provider with deterministic ordering, pluggable evaluation strategies, and unified status/event handling.
4+
5+
### Why use MultiProvider?
6+
- **Layer providers**: fall back from an in-memory or experiment provider to a remote provider.
7+
- **Migrate safely**: put the new provider first, retain the old as fallback.
8+
- **Handle errors predictably**: choose whether errors should short-circuit or be skipped.
9+
10+
This implementation is adapted for Kotlin coroutines, flows, and OpenFeature error types.
11+
12+
### Quick start
13+
```kotlin
14+
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
15+
import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider
16+
import dev.openfeature.kotlin.sdk.multiprovider.FirstMatchStrategy
17+
// import dev.openfeature.kotlin.sdk.multiprovider.FirstSuccessfulStrategy
18+
19+
// 1) Construct your providers (examples)
20+
val experiments = MyExperimentProvider() // e.g., local overrides/experiments
21+
val remote = MyRemoteProvider() // e.g., network-backed
22+
23+
// 2) Wrap them with MultiProvider in the desired order
24+
val multi = MultiProvider(
25+
providers = listOf(experiments, remote),
26+
strategy = FirstMatchStrategy() // default; FirstSuccessfulStrategy() also available
27+
)
28+
29+
// 3) Set the SDK provider and wait until ready
30+
OpenFeatureAPI.setProviderAndWait()
31+
32+
// 4) Use the client as usual
33+
val client = OpenFeatureAPI.getClient("my-app")
34+
val enabled = client.getBooleanValue("new-ui", defaultValue = false)
35+
```
36+
37+
### How it works (at a glance)
38+
- The `MultiProvider` delegates each evaluation to its child providers in the order you supply.
39+
- A pluggable `Strategy` decides which child result to return.
40+
- Provider events are observed and converted into a single aggregate SDK status.
41+
- Context changes are forwarded to all children concurrently.
42+
43+
### Strategies
44+
45+
- **FirstMatchStrategy (default)**
46+
- Returns the first child result that is not "flag not found".
47+
- If a child returns an error other than `FLAG_NOT_FOUND`, that error is returned immediately.
48+
- If all children report `FLAG_NOT_FOUND`, the default value is returned with reason `DEFAULT`.
49+
50+
- **FirstSuccessfulStrategy**
51+
- Skips over errors from children and continues to the next provider.
52+
- Returns the first successful evaluation (no error code).
53+
- If no provider succeeds, the default value is returned with `FLAG_NOT_FOUND`.
54+
55+
Pick the strategy that best matches your failure-policy:
56+
- Prefer early, explicit error surfacing: use `FirstMatchStrategy`.
57+
- Prefer resilience and best-effort success: use `FirstSuccessfulStrategy`.
58+
59+
### Evaluation order matters
60+
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.
61+
62+
### Events and status aggregation
63+
`MultiProvider` listens to child provider events and emits a single, aggregate status via `OpenFeatureAPI.statusFlow`. The highest-precedence status among children wins:
64+
65+
1. Fatal
66+
2. NotReady
67+
3. Error
68+
4. Reconciling / Stale
69+
5. Ready
70+
71+
`ProviderConfigurationChanged` is re-emitted as-is. When the aggregate status changes due to a child event, the original triggering event is also emitted.
72+
73+
### Context propagation
74+
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.
75+
76+
### Provider metadata
77+
`MultiProvider.metadata` exposes:
78+
- `name = "multiprovider"`
79+
- `originalMetadata`: a map of child-name → child `ProviderMetadata`
80+
81+
Child names are derived from each provider’s `metadata.name`. If duplicates occur, stable suffixes are applied (e.g., `myProvider_1`, `myProvider_2`).
82+
83+
Example: inspect provider metadata
84+
```kotlin
85+
val meta = OpenFeatureAPI.getProviderMetadata()
86+
println(meta?.name) // "multiprovider"
87+
println(meta?.originalMetadata) // map of child names to their metadata
88+
```
89+
90+
### Shutdown behavior
91+
`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.
92+
93+
### Custom strategies
94+
You can provide your own composition policy by implementing `MultiProvider.Strategy`:
95+
```kotlin
96+
import dev.openfeature.kotlin.sdk.*
97+
import dev.openfeature.kotlin.sdk.multiprovider.MultiProvider
98+
99+
class MyStrategy : MultiProvider.Strategy {
100+
override fun <T> evaluate(
101+
providers: List<FeatureProvider>,
102+
key: String,
103+
defaultValue: T,
104+
evaluationContext: EvaluationContext?,
105+
flagEval: FeatureProvider.(String, T, EvaluationContext?) -> ProviderEvaluation<T>
106+
): ProviderEvaluation<T> {
107+
// Example: try all, prefer the highest integer value (demo only)
108+
var best: ProviderEvaluation<T>? = null
109+
for (p in providers) {
110+
val e = p.flagEval(key, defaultValue, evaluationContext)
111+
// ... decide whether to keep e as best ...
112+
best = best ?: e
113+
}
114+
return best ?: ProviderEvaluation(defaultValue)
115+
}
116+
}
117+
118+
val multi = MultiProvider(listOf(experiments, remote), strategy = MyStrategy())
119+
```
120+
121+
### Notes and limitations
122+
- Hooks on `MultiProvider` are currently not applied.
123+
- Ensure each child’s `metadata.name` is set for clearer diagnostics in `originalMetadata`.
124+
125+
126+

kotlin-sdk/api/android/kotlin-sdk.api

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata
261261
public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;
262262
public fun equals (Ljava/lang/Object;)Z
263263
public fun getName ()Ljava/lang/String;
264+
public fun getOriginalMetadata ()Ljava/util/Map;
264265
public fun hashCode ()I
265266
public fun toString ()Ljava/lang/String;
266267
}
@@ -382,6 +383,11 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation {
382383

383384
public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata {
384385
public abstract fun getName ()Ljava/lang/String;
386+
public fun getOriginalMetadata ()Ljava/util/Map;
387+
}
388+
389+
public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls {
390+
public static fun getOriginalMetadata (Ldev/openfeature/kotlin/sdk/ProviderMetadata;)Ljava/util/Map;
385391
}
386392

387393
public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum {
@@ -739,3 +745,56 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi
739745
public fun getMessage ()Ljava/lang/String;
740746
}
741747

748+
public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy {
749+
public fun <init> ()V
750+
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;
751+
}
752+
753+
public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy {
754+
public fun <init> ()V
755+
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;
756+
}
757+
758+
public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider {
759+
public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion;
760+
public fun <init> (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V
761+
public synthetic fun <init> (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
762+
public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
763+
public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
764+
public fun getHooks ()Ljava/util/List;
765+
public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
766+
public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata;
767+
public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
768+
public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow;
769+
public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
770+
public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
771+
public fun observe ()Lkotlinx/coroutines/flow/Flow;
772+
public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
773+
public fun shutdown ()V
774+
public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V
775+
}
776+
777+
public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildFeatureProvider : dev/openfeature/kotlin/sdk/FeatureProvider {
778+
public fun <init> (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;)V
779+
public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
780+
public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
781+
public fun getHooks ()Ljava/util/List;
782+
public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
783+
public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata;
784+
public final fun getName ()Ljava/lang/String;
785+
public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
786+
public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
787+
public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
788+
public fun observe ()Lkotlinx/coroutines/flow/Flow;
789+
public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
790+
public fun shutdown ()V
791+
public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V
792+
}
793+
794+
public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion {
795+
}
796+
797+
public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy {
798+
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;
799+
}
800+

kotlin-sdk/api/jvm/kotlin-sdk.api

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata
261261
public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata;
262262
public fun equals (Ljava/lang/Object;)Z
263263
public fun getName ()Ljava/lang/String;
264+
public fun getOriginalMetadata ()Ljava/util/Map;
264265
public fun hashCode ()I
265266
public fun toString ()Ljava/lang/String;
266267
}
@@ -382,6 +383,11 @@ public final class dev/openfeature/kotlin/sdk/ProviderEvaluation {
382383

383384
public abstract interface class dev/openfeature/kotlin/sdk/ProviderMetadata {
384385
public abstract fun getName ()Ljava/lang/String;
386+
public fun getOriginalMetadata ()Ljava/util/Map;
387+
}
388+
389+
public final class dev/openfeature/kotlin/sdk/ProviderMetadata$DefaultImpls {
390+
public static fun getOriginalMetadata (Ldev/openfeature/kotlin/sdk/ProviderMetadata;)Ljava/util/Map;
385391
}
386392

387393
public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum {
@@ -739,3 +745,56 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi
739745
public fun getMessage ()Ljava/lang/String;
740746
}
741747

748+
public final class dev/openfeature/kotlin/sdk/multiprovider/FirstMatchStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy {
749+
public fun <init> ()V
750+
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;
751+
}
752+
753+
public final class dev/openfeature/kotlin/sdk/multiprovider/FirstSuccessfulStrategy : dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy {
754+
public fun <init> ()V
755+
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;
756+
}
757+
758+
public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider {
759+
public static final field Companion Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion;
760+
public fun <init> (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V
761+
public synthetic fun <init> (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
762+
public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
763+
public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
764+
public fun getHooks ()Ljava/util/List;
765+
public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
766+
public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata;
767+
public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
768+
public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow;
769+
public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
770+
public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
771+
public fun observe ()Lkotlinx/coroutines/flow/Flow;
772+
public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
773+
public fun shutdown ()V
774+
public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V
775+
}
776+
777+
public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildFeatureProvider : dev/openfeature/kotlin/sdk/FeatureProvider {
778+
public fun <init> (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;)V
779+
public fun getBooleanEvaluation (Ljava/lang/String;ZLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
780+
public fun getDoubleEvaluation (Ljava/lang/String;DLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
781+
public fun getHooks ()Ljava/util/List;
782+
public fun getIntegerEvaluation (Ljava/lang/String;ILdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
783+
public fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata;
784+
public final fun getName ()Ljava/lang/String;
785+
public fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
786+
public fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
787+
public fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
788+
public fun observe ()Lkotlinx/coroutines/flow/Flow;
789+
public fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
790+
public fun shutdown ()V
791+
public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V
792+
}
793+
794+
public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Companion {
795+
}
796+
797+
public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy {
798+
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;
799+
}
800+
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
package dev.openfeature.kotlin.sdk
22

3+
/**
4+
* Provider metadata as defined by the OpenFeature specification.
5+
*
6+
* In a single provider, `name` identifies the provider. In a Multi-Provider, the outer provider
7+
* exposes its own `name` and surfaces the metadata of its managed providers via `originalMetadata`,
8+
* keyed by each provider's resolved unique name.
9+
*
10+
* See: https://openfeature.dev/specification/appendix-a/#metadata
11+
*/
312
interface ProviderMetadata {
13+
/**
14+
* Human-readable provider name.
15+
*
16+
* - Used in logs, events, and error reporting.
17+
* - In a Multi-Provider, names must be unique.
18+
*/
419
val name: String?
20+
21+
/**
22+
* For Multi-Provider: a map of child provider names to their metadata.
23+
*
24+
* - For normal providers this MUST be an empty map.
25+
* - For the Multi-Provider, this contains each inner provider's `ProviderMetadata`, keyed by
26+
* that provider's resolved unique name.
27+
*
28+
* Example shape:
29+
* {
30+
* "providerA": {...},
31+
* "providerB": {...}
32+
* }
33+
*
34+
* See: https://openfeature.dev/specification/appendix-a/#metadata
35+
*/
36+
val originalMetadata: Map<String, ProviderMetadata>
37+
get() = emptyMap()
538
}

0 commit comments

Comments
 (0)