Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
af65a59
Multi provider impl draft
PenguinDan Aug 20, 2025
79f59ba
Ktlint
PenguinDan Aug 20, 2025
6127d68
Try emit initial state for Multi provider
PenguinDan Aug 20, 2025
2142879
Shared flow should always have content
PenguinDan Aug 20, 2025
c6f3a8f
Add tests
PenguinDan Aug 20, 2025
f2f415a
Update multi provider strategy to better align with Open Feature specs
PenguinDan Aug 20, 2025
199236c
Add original metadata and allow all providers to shutdown
PenguinDan Aug 20, 2025
38fac8c
Add default reason to default value in First Match Strategy
PenguinDan Aug 21, 2025
48033c5
Remove json dependency and update ProviderMetadata
PenguinDan Aug 21, 2025
4fa33eb
Align to Event spec
PenguinDan Aug 22, 2025
d5f5546
Ktlint
PenguinDan Aug 22, 2025
8d8cbec
Update API dumps for multiprovider and ProviderMetadata changes
PenguinDan Aug 22, 2025
3818a11
Use Lazy and ktlint
PenguinDan Aug 23, 2025
f834a43
Add TODO once EventDetails have been added
PenguinDan Aug 26, 2025
c299717
PR comments; remove redundant comments, fix test definitions, move st…
PenguinDan Aug 26, 2025
2305056
Return an error result for FirstSuccessfulStrategy rather than throwing
PenguinDan Aug 26, 2025
70a892d
Revert sample app changes
PenguinDan Aug 26, 2025
6fe18eb
Add README documentation for Multiprovider
PenguinDan Aug 26, 2025
870ae07
Lets favor not throwing in the FirstMatchStrategy also
PenguinDan Aug 26, 2025
5472c08
Update tests to represent to non-throwing pattern and api dump
PenguinDan Aug 26, 2025
7df6772
Kotlin Format
PenguinDan Aug 30, 2025
2cc75c5
Update kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/mu…
PenguinDan Aug 30, 2025
e71d448
Update multi provider readme
PenguinDan Aug 30, 2025
c4c49db
Merge branch 'main' into MultiProvider-Impl
PenguinDan Sep 1, 2025
f540783
API dump
PenguinDan Sep 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/multiprovider/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
## 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
OpenFeatureAPI.setProviderAndWait()

// 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 <T> evaluate(
providers: List<FeatureProvider>,
key: String,
defaultValue: T,
evaluationContext: EvaluationContext?,
flagEval: FeatureProvider.(String, T, EvaluationContext?) -> ProviderEvaluation<T>
): ProviderEvaluation<T> {
// Example: try all, prefer the highest integer value (demo only)
var best: ProviderEvaluation<T>? = 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`.



59 changes: 59 additions & 0 deletions kotlin-sdk/api/android/kotlin-sdk.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 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 {
Expand Down Expand Up @@ -739,3 +745,56 @@ 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/MultiProvider$Strategy {
public fun <init> ()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/MultiProvider$Strategy {
public fun <init> ()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 <init> (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V
public synthetic fun <init> (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 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 <init> (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/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;
}

59 changes: 59 additions & 0 deletions kotlin-sdk/api/jvm/kotlin-sdk.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 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 {
Expand Down Expand Up @@ -739,3 +745,56 @@ 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/MultiProvider$Strategy {
public fun <init> ()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/MultiProvider$Strategy {
public fun <init> ()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 <init> (Ljava/util/List;Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;)V
public synthetic fun <init> (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 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 <init> (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/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;
}

Original file line number Diff line number Diff line change
@@ -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<String, ProviderMetadata>
get() = emptyMap()
}
Loading