diff --git a/README.md b/README.md index dfb4a1c0..7c2f303e 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ coroutineScope.launch(Dispatchers.Default) { Asynchronous API that doesn't wait is also available. It's useful when you want to set a provider and continue with other tasks. -However, flag evaluations are only possible after the provider is Ready. +However, flag evaluations are only meaningful after the provider is Ready. The SDK does not gate them on the status: an evaluation made earlier reaches the provider, which reports its own unreadiness with a `PROVIDER_NOT_READY` or `PROVIDER_FATAL` error code, and the client returns the default value. ```kotlin OpenFeatureAPI.setProvider(MyProvider()) // can pass a dispatcher here @@ -356,9 +356,9 @@ Support for domains is currently in development. ### Eventing Events from the Provider allow the SDK to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions. -Events are optional which mean that not all Providers will emit them and it is not a must have. Some providers support additional events, such as `PROVIDER_CONFIGURATION_CHANGED`. +A provider reports every status transition as an event — that is how the SDK knows a provider is ready, stale or in error — and some providers report additional events, such as `PROVIDER_CONFIGURATION_CHANGED`. -Please refer to the documentation of the provider you're using to see what events are supported. +Please refer to the documentation of the provider you're using to see what additional events are supported. Example usage: ```kotlin @@ -415,8 +415,31 @@ in an Android app. To develop a provider, you need to create a new project and include the OpenFeature SDK as a dependency. You’ll then need to write the provider by implementing the `FeatureProvider` interface exported by the OpenFeature SDK. +#### Status ownership + +A provider is responsible for its own `status`. The SDK reads it but never sets it, so you must keep it +consistent with the events you emit, and it must be thread-safe because the SDK reads it from flag +evaluation paths on any thread. + +The easiest way to satisfy both requirements is to delegate to `ProviderStatusTracker`, which derives +`status` from the events you send it and replays the current status to new subscribers. Emit at least +one non-not-ready event before `initialize` returns, otherwise the provider stays `NotReady`: +throwing does not set a status. + +Refusing an evaluation made before the provider is ready is the provider's job too, per requirement +2.2.7: return or throw with error code `PROVIDER_NOT_READY`, or `PROVIDER_FATAL` where the failure is +irrecoverable, and the client returns the default value. + +#### Example implementation + ```kotlin class NewProvider(override val hooks: List>, override val metadata: ProviderMetadata) : FeatureProvider { + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + override fun getBooleanEvaluation( key: String, defaultValue: Boolean, @@ -466,11 +489,21 @@ class NewProvider(override val hooks: List>, override val metadata: Prov } override suspend fun initialize(initialContext: EvaluationContext?) { - // add context-aware provider initialization + // add context-aware provider initialization, then report the outcome + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } - override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { - // add necessary changes on context change + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) = statusTracker.reconciling { + // add necessary changes on context change; reconciling reports the transitions around this, + // and collapses overlapping invocations into a single reported outcome + } + + override fun shutdown() { + // release resources, and return to NotReady in case this provider is registered again + statusTracker.reset() } override fun track( @@ -481,9 +514,6 @@ class NewProvider(override val hooks: List>, override val metadata: Prov // Optionally track an event } - override fun observe(): Flow { - // Optionally return a `Flow` of OpenFeatureProviderEvents - } } ``` diff --git a/docs/multiprovider/README.md b/docs/multiprovider/README.md index 46c039c0..f82a8540 100644 --- a/docs/multiprovider/README.md +++ b/docs/multiprovider/README.md @@ -65,7 +65,9 @@ Children are evaluated in the order provided. Put the most authoritative or fast ### Events and status aggregation -`MultiProvider` listens to child provider events and emits a single, aggregate status via `OpenFeatureAPI.statusFlow`. Per the OpenFeature specification: a child stays `NOT_READY` until it emits `PROVIDER_READY`, `PROVIDER_ERROR`, or `PROVIDER_STALE` (or only `PROVIDER_CONFIGURATION_CHANGED`, which does not change readiness). The highest-precedence status among children wins: +`MultiProvider` owns a `ProviderStatusTracker` like any other provider, and reports a single aggregate status through it. A child stays `NOT_READY` until it reports `PROVIDER_READY`, `PROVIDER_ERROR` or `PROVIDER_STALE`; `PROVIDER_CONFIGURATION_CHANGED` carries no status and does not change readiness. + +Aggregation is `Strategy.status(providers)`, which you can override. The default reports the most severe child status, which is the order the specification's [Multi-Provider appendix](https://openfeature.dev/specification/appendix-a/#status-and-event-handling) defines: 1. Fatal 2. NotReady @@ -73,11 +75,11 @@ Children are evaluated in the order provided. Put the most authoritative or fast 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. +An aggregate transition carries the `EventDetails` of the child event that triggered it. ### 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. +When the evaluation context changes, `MultiProvider` reconciles through its `ProviderStatusTracker`: `PROVIDER_RECONCILING` is reported once even across overlapping context sets, `onContextSet` is called on all child providers concurrently, and only the last invocation to terminate reports an outcome. ### Provider metadata diff --git a/kotlin-sdk/api/android/kotlin-sdk.api b/kotlin-sdk/api/android/kotlin-sdk.api index 00d3bbe3..7e15e743 100644 --- a/kotlin-sdk/api/android/kotlin-sdk.api +++ b/kotlin-sdk/api/android/kotlin-sdk.api @@ -74,16 +74,16 @@ public abstract interface class dev/openfeature/kotlin/sdk/FeatureProvider { public abstract fun getLongEvaluation (Ljava/lang/String;JLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public abstract fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; public abstract fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public abstract fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; public abstract fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public abstract fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun observe ()Lkotlinx/coroutines/flow/Flow; + public abstract fun observe ()Lkotlinx/coroutines/flow/Flow; public abstract fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract 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/FeatureProvider$DefaultImpls { - public static fun observe (Ldev/openfeature/kotlin/sdk/FeatureProvider;)Lkotlinx/coroutines/flow/Flow; public static fun track (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V } @@ -252,6 +252,7 @@ public class dev/openfeature/kotlin/sdk/NoOpProvider : dev/openfeature/kotlin/sd public fun getLongEvaluation (Ljava/lang/String;JLdev/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 fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; 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; @@ -287,11 +288,10 @@ public class dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance { public final fun getHooks ()Ljava/util/List; public final fun getProvider ()Ldev/openfeature/kotlin/sdk/FeatureProvider; public final fun getProviderMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; - public final fun getProvidersFlow ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; public final fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; - public final fun setEvaluationContext (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;)V - public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public final fun observe ()Lkotlinx/coroutines/flow/Flow; + public final fun setEvaluationContext (Ldev/openfeature/kotlin/sdk/EvaluationContext;)V public final fun setEvaluationContextAndWait (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setProvider (Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;)V public static synthetic fun setProvider$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V @@ -354,12 +354,16 @@ public abstract interface class dev/openfeature/kotlin/sdk/OpenFeatureStatus { public final class dev/openfeature/kotlin/sdk/OpenFeatureStatus$Error : dev/openfeature/kotlin/sdk/OpenFeatureStatus { public fun (Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError;)V + public fun equals (Ljava/lang/Object;)Z public final fun getError ()Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError; + public fun hashCode ()I } public final class dev/openfeature/kotlin/sdk/OpenFeatureStatus$Fatal : dev/openfeature/kotlin/sdk/OpenFeatureStatus { public fun (Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError;)V + public fun equals (Ljava/lang/Object;)Z public final fun getError ()Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError; + public fun hashCode ()I } public final class dev/openfeature/kotlin/sdk/OpenFeatureStatus$NotReady : dev/openfeature/kotlin/sdk/OpenFeatureStatus { @@ -409,6 +413,15 @@ 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/ProviderStatusTracker { + public fun ()V + public final fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; + public final fun observe ()Lkotlinx/coroutines/flow/Flow; + public final fun reconciling (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun reset ()V + public final fun send (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;)V +} + public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum { public static final field CACHED Ldev/openfeature/kotlin/sdk/Reason; public static final field DEFAULT Ldev/openfeature/kotlin/sdk/Reason; @@ -724,6 +737,19 @@ public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$P public fun toString ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { + public fun ()V + public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V + public synthetic fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun copy (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public fun equals (Ljava/lang/Object;)Z + public fun getEventDetails ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderError : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { public fun ()V public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V @@ -750,6 +776,19 @@ public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$P public fun toString ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { + public fun ()V + public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V + public synthetic fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun copy (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling; + public fun equals (Ljava/lang/Object;)Z + public fun getEventDetails ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderStale : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { public fun ()V public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V @@ -913,11 +952,13 @@ public final class dev/openfeature/kotlin/sdk/logging/NoOpLogger : dev/openfeatu 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 fun status (Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; } 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 fun status (Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; } public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { @@ -931,7 +972,7 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/ public fun getLongEvaluation (Ljava/lang/String;JLdev/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 getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; 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; @@ -950,6 +991,7 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildF 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 getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; 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; @@ -963,5 +1005,10 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Compan 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; + public fun status (Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy$DefaultImpls { + public static fun status (Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; } diff --git a/kotlin-sdk/api/jvm/kotlin-sdk.api b/kotlin-sdk/api/jvm/kotlin-sdk.api index 00d3bbe3..7e15e743 100644 --- a/kotlin-sdk/api/jvm/kotlin-sdk.api +++ b/kotlin-sdk/api/jvm/kotlin-sdk.api @@ -74,16 +74,16 @@ public abstract interface class dev/openfeature/kotlin/sdk/FeatureProvider { public abstract fun getLongEvaluation (Ljava/lang/String;JLdev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public abstract fun getMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; public abstract fun getObjectEvaluation (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; + public abstract fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; public abstract fun getStringEvaluation (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation; public abstract fun initialize (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun observe ()Lkotlinx/coroutines/flow/Flow; + public abstract fun observe ()Lkotlinx/coroutines/flow/Flow; public abstract fun onContextSet (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract 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/FeatureProvider$DefaultImpls { - public static fun observe (Ldev/openfeature/kotlin/sdk/FeatureProvider;)Lkotlinx/coroutines/flow/Flow; public static fun track (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V } @@ -252,6 +252,7 @@ public class dev/openfeature/kotlin/sdk/NoOpProvider : dev/openfeature/kotlin/sd public fun getLongEvaluation (Ljava/lang/String;JLdev/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 fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; 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; @@ -287,11 +288,10 @@ public class dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance { public final fun getHooks ()Ljava/util/List; public final fun getProvider ()Ldev/openfeature/kotlin/sdk/FeatureProvider; public final fun getProviderMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; - public final fun getProvidersFlow ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; public final fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; - public final fun setEvaluationContext (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;)V - public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public final fun observe ()Lkotlinx/coroutines/flow/Flow; + public final fun setEvaluationContext (Ldev/openfeature/kotlin/sdk/EvaluationContext;)V public final fun setEvaluationContextAndWait (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setProvider (Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;)V public static synthetic fun setProvider$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V @@ -354,12 +354,16 @@ public abstract interface class dev/openfeature/kotlin/sdk/OpenFeatureStatus { public final class dev/openfeature/kotlin/sdk/OpenFeatureStatus$Error : dev/openfeature/kotlin/sdk/OpenFeatureStatus { public fun (Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError;)V + public fun equals (Ljava/lang/Object;)Z public final fun getError ()Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError; + public fun hashCode ()I } public final class dev/openfeature/kotlin/sdk/OpenFeatureStatus$Fatal : dev/openfeature/kotlin/sdk/OpenFeatureStatus { public fun (Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError;)V + public fun equals (Ljava/lang/Object;)Z public final fun getError ()Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError; + public fun hashCode ()I } public final class dev/openfeature/kotlin/sdk/OpenFeatureStatus$NotReady : dev/openfeature/kotlin/sdk/OpenFeatureStatus { @@ -409,6 +413,15 @@ 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/ProviderStatusTracker { + public fun ()V + public final fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; + public final fun observe ()Lkotlinx/coroutines/flow/Flow; + public final fun reconciling (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun reset ()V + public final fun send (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;)V +} + public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum { public static final field CACHED Ldev/openfeature/kotlin/sdk/Reason; public static final field DEFAULT Ldev/openfeature/kotlin/sdk/Reason; @@ -724,6 +737,19 @@ public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$P public fun toString ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { + public fun ()V + public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V + public synthetic fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun copy (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public fun equals (Ljava/lang/Object;)Z + public fun getEventDetails ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderError : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { public fun ()V public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V @@ -750,6 +776,19 @@ public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$P public fun toString ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { + public fun ()V + public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V + public synthetic fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun copy (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderReconciling; + public fun equals (Ljava/lang/Object;)Z + public fun getEventDetails ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderStale : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { public fun ()V public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V @@ -913,11 +952,13 @@ public final class dev/openfeature/kotlin/sdk/logging/NoOpLogger : dev/openfeatu 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 fun status (Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; } 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 fun status (Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; } public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/openfeature/kotlin/sdk/FeatureProvider { @@ -931,7 +972,7 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider : dev/ public fun getLongEvaluation (Ljava/lang/String;JLdev/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 getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; 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; @@ -950,6 +991,7 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$ChildF 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 getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; 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; @@ -963,5 +1005,10 @@ public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Compan 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; + public fun status (Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; +} + +public final class dev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy$DefaultImpls { + public static fun status (Ldev/openfeature/kotlin/sdk/multiprovider/MultiProvider$Strategy;Ljava/util/List;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; } diff --git a/kotlin-sdk/build.gradle.kts b/kotlin-sdk/build.gradle.kts index 81a3dbbc..302ad358 100644 --- a/kotlin-sdk/build.gradle.kts +++ b/kotlin-sdk/build.gradle.kts @@ -83,6 +83,14 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + + testOptions { + unitTests { + // The SDK logs through android.util.Log, which throws in the stub android.jar unit tests + // run against. + isReturnDefaultValues = true + } + } } // Configure Dokka for documentation diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/EvaluationState.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/EvaluationState.kt index be9d92cc..33a13153 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/EvaluationState.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/EvaluationState.kt @@ -1,9 +1,11 @@ package dev.openfeature.kotlin.sdk /** - * Atomic snapshot of the provider and evaluation context used for flag evaluation and tracking. + * Atomic snapshot of the provider, evaluation context and hooks used for flag evaluation and + * tracking. */ internal data class EvaluationState( val provider: FeatureProvider, - val context: EvaluationContext? + val context: EvaluationContext?, + val hooks: List> ) \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/FeatureProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/FeatureProvider.kt index f37c6eb7..1256dcea 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/FeatureProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/FeatureProvider.kt @@ -3,30 +3,64 @@ package dev.openfeature.kotlin.sdk import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow import kotlin.coroutines.cancellation.CancellationException +/** + * The interface implemented by upstream flag providers to resolve flags for their service. + * + * A provider is responsible for its own [status] and for emitting the events that explain it, which + * [ProviderStatusTracker] does on its behalf. The README carries a worked example. + */ interface FeatureProvider { val hooks: List> val metadata: ProviderMetadata /** - * Called by OpenFeatureAPI whenever the new Provider is registered - * This function should block until ready and throw exceptions if it fails to initialize + * The current lifecycle status of this provider, kept up to date by the provider alone. It must + * be [OpenFeatureStatus.NotReady] before [initialize] is called, must reflect the most recently + * emitted event thereafter, and must be thread-safe: the SDK reads it from flag evaluation paths + * on any thread. + */ + val status: OpenFeatureStatus + + /** + * Called by OpenFeatureAPI when this provider is registered, to do whatever asynchronous setup + * it needs. + * + * Emit at least one event before returning, so that [status] moves away from + * [OpenFeatureStatus.NotReady] — usually [OpenFeatureProviderEvents.ProviderReady] or + * [OpenFeatureProviderEvents.ProviderError]. Throwing does not set a status: the SDK logs the + * failure and a provider that throws without emitting stays [OpenFeatureStatus.NotReady]. + * + * Lifecycle calls are entered in the order they were made, but the SDK does not wait for one to + * finish before entering the next. + * * @param initialContext any initial context to be set before the provider is ready */ @Throws(OpenFeatureError::class, CancellationException::class) suspend fun initialize(initialContext: EvaluationContext?) /** - * Called when the lifecycle of the OpenFeatureClient is over to release resources/threads + * Called when the lifecycle of the OpenFeatureClient is over to release resources/threads. + * + * A provider that can be registered again must return to [OpenFeatureStatus.NotReady] here, so + * a reused instance does not report the status it held before it was shut down. */ fun shutdown() /** * Called by OpenFeatureAPI whenever the application sets the [EvaluationContext], including when * the new context is equal to or the same instance as the previous context. - * Implementations should suspend until the provider is ready again or throw an exception. + * + * Either return without emitting anything, where no reconciliation is needed, or emit + * [OpenFeatureProviderEvents.ProviderReconciling], do the work, and emit + * [OpenFeatureProviderEvents.ProviderContextChanged] or + * [OpenFeatureProviderEvents.ProviderError]. [ProviderStatusTracker.reconciling] does the + * latter, including collapsing overlapping invocations. + * + * This can be entered while previous reconciliation work is still in flight; a provider + * reconciling asynchronously should handle that, for instance by cancelling the work it + * supersedes. * * @param oldContext The old EvaluationContext * @param newContext The new EvaluationContext @@ -61,10 +95,8 @@ interface FeatureProvider { } /** - * Used by providers to expose internal events to the SDK or the application. - * This can be optionally implemented by the provider to expose a flow of internal events. + * The events this provider emits, for the SDK and the application. The SDK derives nothing from + * a provider's silence: every status transition must arrive here. */ - fun observe(): Flow { - return emptyFlow() - } + fun observe(): Flow } \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/NoOpProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/NoOpProvider.kt index 99baf8f3..adaf512f 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/NoOpProvider.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/NoOpProvider.kt @@ -1,13 +1,24 @@ package dev.openfeature.kotlin.sdk +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import kotlinx.coroutines.flow.Flow + +/** The default provider: it resolves every flag to the passed-in default and reports itself ready. */ open class NoOpProvider(override val hooks: List> = listOf()) : FeatureProvider { + private val statusTracker = ProviderStatusTracker() + override val metadata: ProviderMetadata = NoOpProviderMetadata("No-op provider") + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + override suspend fun initialize(initialContext: EvaluationContext?) { - // no-op + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() { - // no-op + statusTracker.reset() } override suspend fun onContextSet( diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance.kt index 3e949453..47c19575 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance.kt @@ -1,85 +1,129 @@ package dev.openfeature.kotlin.sdk import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents -import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatusError -import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatus +import dev.openfeature.kotlin.sdk.logging.LoggerFactory import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlin.coroutines.ContinuationInterceptor + +private const val LOGGER_NAME = "OpenFeatureAPI" /** * Core implementation of the OpenFeature API. * - * Each instance maintains its own independent state: provider, evaluation context, hooks, status, - * and events. The global singleton [OpenFeatureAPI] is one such instance. To create isolated, - * independent instances use - * [dev.openfeature.kotlin.sdk.isolated.createOpenFeatureAPIInstance]. + * Each instance maintains its own independent state: provider, evaluation context and hooks. The + * global singleton [OpenFeatureAPI] is one such instance. To create isolated, independent instances + * use [dev.openfeature.kotlin.sdk.isolated.createOpenFeatureAPIInstance]. + * + * Status belongs to the registered provider: [getStatus] and [statusFlow] both read + * [FeatureProvider.status], which the SDK never sets. * * @see OpenFeatureAPI * @see dev.openfeature.kotlin.sdk.isolated.createOpenFeatureAPIInstance */ @Suppress("TooManyFunctions") open class OpenFeatureAPIInstance internal constructor() { - private data class ContextReconciliation( - val oldContext: EvaluationContext?, + private val logger = LoggerFactory.getLogger(LOGGER_NAME) + private val stateLock = SynchronizedObject() + + /** The provider installed when none has been registered, or once one has been cleared. */ + private class NoProvider : NoOpProvider() + + /** + * One registration of one provider, boxed so that a swap restarts the subscriptions derived from + * it, which keeps a retired provider's events out of its successor's stream. Re-registering the + * same instance reuses its box, so nothing restarts. + */ + private class ProviderRegistration( val provider: FeatureProvider, - val providerGeneration: Long - ) + dispatcher: CoroutineDispatcher + ) { + /** Serial, so this provider's lifecycle calls are entered in the order they were made. */ + @OptIn(ExperimentalCoroutinesApi::class) + val scope = CoroutineScope( + SupervisorJob() + + dispatcher.limitedParallelism(1) + + CoroutineExceptionHandler { _, _ -> /* reported by dispatchLifecycle */ } + ) - private var setProviderJob: Job? = null - private var setEvaluationContextJob: Job? = null - private var observeProviderEventsJob: Job? = null + var providerJob: Job? = null + var contextSetJob: Job? = null + } - private val providerMutex = Mutex() - private val contextReconciliationMutex = Mutex() - private val stateLock = SynchronizedObject() - private val noOpProvider = NoOpProvider() - private var provider: FeatureProvider = noOpProvider - private var providerGeneration: Long = 0 - private var context: EvaluationContext? = null - private var contextReconciliationGeneration: Long? = null - private var activeContextReconciliations: Int = 0 - private var contextReconciliationInitialStatus: OpenFeatureStatus? = null - private var contextReconciliationTerminalStatus: OpenFeatureStatus? = null - private var providerStatusGeneration: Long = 0 - private var contextReconciliationTerminalProviderStatusGeneration: Long? = null - val providersFlow: MutableStateFlow = MutableStateFlow(noOpProvider) - - private val _statusFlow: MutableSharedFlow = - MutableSharedFlow(replay = 1, extraBufferCapacity = 5) - .apply { - tryEmit(OpenFeatureStatus.NotReady) + private var registration = ProviderRegistration(NoProvider(), Dispatchers.Default) + private val providerRegistrations = MutableStateFlow(registration) + + /** Never cancelled: a dropped retirement leaks the provider it was meant to release. */ + private val retirementScope = CoroutineScope( + SupervisorJob() + + Dispatchers.Default + + CoroutineExceptionHandler { _, throwable -> + logger.warn({ "Retiring a replaced provider failed" }, throwable = throwable) } + ) - val statusFlow: Flow get() = _statusFlow.distinctUntilChanged() + /** Retirements still in flight, so a provider registered again is ordered after its teardown. */ + private val retirements = mutableListOf>() + + private var context: EvaluationContext? = null var hooks: List> = listOf() private set /** - * Set the [FeatureProvider] for this instance. Returns immediately and initializes the provider - * in a coroutine scope. When successfully initialized, status transitions to Ready. + * The status of the registered provider, and every transition it reports. + * + * Derived from the provider's events, so it carries every transition the provider can express. + * [OpenFeatureStatus.NotReady] has no event: a provider that returns to it after registration — + * a [dev.openfeature.kotlin.sdk.multiprovider.MultiProvider] whose child was shut down behind + * its back, say — reports that through [getStatus] alone. A provider that reports nothing yields + * exactly one [OpenFeatureStatus.NotReady]. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val statusFlow: Flow = providerRegistrations + .flatMapLatest { current -> + current.provider.observe() + .transform { event -> + // The event's own status: a re-read loses the earlier of two transitions. + val reported = event.toOpenFeatureStatus() + reported?.let { emit(it) } + val live = current.provider.status + if (live != reported) emit(live) + } + .onStart { emit(current.provider.status) } + } + .distinctUntilChanged() + + /** + * Set the [FeatureProvider] for this instance. Returns once the provider is registered, having + * started its initialization; the provider reports readiness itself through its events. The + * outgoing provider is shut down in the background, so this does not wait for its teardown. * * @param provider the provider to set - * @param dispatcher the dispatcher for the initialization coroutine + * @param dispatcher the dispatcher this provider's lifecycle calls run on; a provider that is + * already registered keeps the dispatcher it was first registered with * @param initialContext the initial [EvaluationContext] for provider initialization */ fun setProvider( @@ -87,271 +131,235 @@ open class OpenFeatureAPIInstance internal constructor() { dispatcher: CoroutineDispatcher = Dispatchers.Default, initialContext: EvaluationContext? = null ) { - setProviderJob?.cancel(CancellationException("Provider set job was cancelled due to new provider")) - this.setProviderJob = CoroutineScope(SupervisorJob() + dispatcher).launch { - setProviderInternal(provider, dispatcher, initialContext) - } + swapProvider(provider, initialContext, dispatcher) } /** - * Set the [FeatureProvider] for this instance. Suspends until the provider is initialized. + * Set the [FeatureProvider] for this instance, suspending until its `initialize` has terminated. + * + * A provider reports its own outcome, so this does not throw when initialization fails: the + * failure arrives as an [OpenFeatureProviderEvents.ProviderError]. A provider that throws + * without reporting anything stays [OpenFeatureStatus.NotReady]. * * @param provider the [FeatureProvider] to set * @param initialContext the initial [EvaluationContext] for provider initialization - * @param dispatcher the dispatcher for event observation + * @param dispatcher the dispatcher this provider's lifecycle calls run on; the caller's own + * dispatcher by default, so that a caller controlling time controls the provider's lifecycle too */ suspend fun setProviderAndWait( provider: FeatureProvider, initialContext: EvaluationContext? = null, - dispatcher: CoroutineDispatcher = Dispatchers.Default + dispatcher: CoroutineDispatcher? = null ) { - setProviderInternal(provider, dispatcher, initialContext) + val swap = swapProvider(provider, initialContext, dispatcher ?: callerDispatcher()) + swap.retirement?.join() + swap.initialization.joinPropagatingCancellation() } - private fun listenToProviderEvents(provider: FeatureProvider, dispatcher: CoroutineDispatcher) { - observeProviderEventsJob?.cancel(CancellationException("Provider job was cancelled due to new provider")) - this.observeProviderEventsJob = CoroutineScope(SupervisorJob() + dispatcher).launch { - provider.observe().collect(handleProviderEvents) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - private suspend fun setProviderInternal( - provider: FeatureProvider, - dispatcher: CoroutineDispatcher, - initialContext: EvaluationContext? = null - ) { - try { - trackProviderBinding(provider) - } catch (e: Throwable) { - _statusFlow.emit( - OpenFeatureStatus.Error( - OpenFeatureError.GeneralError(e.message ?: "Unknown error") - ) - ) - return - } - - // Track whether the swap committed so a mid-flight cancellation can roll back the binding. - var swapCommitted = false - try { - // Atomically swap the old and new provider to prevent race conditions - val oldProvider = providerMutex.withLock { - synchronized(stateLock) { - val current = this.provider - this.provider = provider - providerGeneration++ - providersFlow.value = provider - if (initialContext != null) context = initialContext - current - } + /** The two pieces of work a swap starts: initializing the new provider, retiring the old one. */ + private class Swap(val initialization: Job, val retirement: Job?) + + /** What a swap decides under [stateLock], so retirement can run without holding the lock. */ + private class Commit(val current: ProviderRegistration, val retired: ProviderRegistration?) + + private suspend fun callerDispatcher(): CoroutineDispatcher = + currentCoroutineContext()[ContinuationInterceptor] as? CoroutineDispatcher ?: Dispatchers.Default + + private fun swapProvider( + newProvider: FeatureProvider, + initialContext: EvaluationContext?, + dispatcher: CoroutineDispatcher + ): Swap { + lateinit var initialization: Job + val commit = synchronized(stateLock) { + // Claimed under the lock that commits the swap, so a retirement of an earlier + // registration of this same provider cannot unbind what is about to be published. + trackProviderBinding(newProvider) + val previous = registration + // Reusing the registration keeps this initialize ordered against the provider's own + // in-flight work. + val rebinding = previous.provider === newProvider + val current = if (rebinding) previous else ProviderRegistration(newProvider, dispatcher) + registration = current + if (initialContext != null) context = initialContext + // Published under the lock that commits the swap, or two concurrent swaps can leave + // evaluations on one provider and statusFlow pinned to the other. + providerRegistrations.value = current + val initializationContext = context + val pendingRetirements = retirements.filter { it.first === newProvider }.map { it.second } + // Dispatched before the lock is released, or a setEvaluationContext that acquires the + // lock right after this one could queue onContextSet ahead of initialize on the + // registration's serial scope. + initialization = current.dispatchLifecycle("initialize") { + // A retirement of this provider that already committed to shutting it down is still + // running: initializing over it would race its teardown. + pendingRetirements.forEach { it.join() } + newProvider.initialize(initializationContext) } - swapCommitted = true + current.providerJob = initialization + Commit(current = current, retired = previous.takeUnless { rebinding }) + } - // Emit NotReady status after swapping provider - _statusFlow.emit(OpenFeatureStatus.NotReady) + // Not from the successor's job, which can be cancelled before it is ever dispatched. + val retirement = commit.retired?.let { retire(it) } + return Swap(initialization, retirement) + } - // Shutdown the previous provider outside the mutex - if (oldProvider !== provider) { - tryWithStatusEmitErrorHandling { - untrackProviderBinding(oldProvider) - oldProvider.shutdown() - } - } + /** + * Get the current [FeatureProvider] for this instance. + */ + fun getProvider(): FeatureProvider = synchronized(stateLock) { registration.provider } - // Initialize the new provider - tryWithStatusEmitErrorHandling { - listenToProviderEvents(provider, dispatcher) - val state = getEvaluationState() - state.provider.initialize(state.context) - _statusFlow.emit(OpenFeatureStatus.Ready) - } - } catch (e: CancellationException) { - // if cancellation hit before we committed the swap, release the binding we just claimed - // so the provider can be re-registered elsewhere. - if (!swapCommitted) { - withContext(NonCancellable) { - untrackProviderBinding(provider) - } - } - throw e - } + /** + * Snapshot of the provider, evaluation context and hooks for synchronous client operations. + */ + internal fun getEvaluationState(): EvaluationState = synchronized(stateLock) { + EvaluationState(registration.provider, context, hooks) } /** - * Get the current [FeatureProvider] for this instance. + * Clear the current [FeatureProvider] and reset to a no-op provider. + * + * Installs a provider that was never initialized, so the status is not-ready once this returns. */ - fun getProvider(): FeatureProvider { - return synchronized(stateLock) { provider } + suspend fun clearProvider() { + val next = ProviderRegistration(NoProvider(), Dispatchers.Default) + val previous = synchronized(stateLock) { + val previous = registration + registration = next + providerRegistrations.value = next + previous + } + retire(previous).join() } /** - * Snapshot of the current provider and evaluation context for synchronous client operations. + * Retires a replaced registration: stops its lifecycle work, then unbinds and shuts its provider + * down away from the caller's thread, since `shutdown` releases resources and threads. */ - internal fun getEvaluationState(): EvaluationState { - return synchronized(stateLock) { - EvaluationState(provider, context) + private fun retire(retired: ProviderRegistration): Job { + val cause = CancellationException("Provider registration was replaced") + retired.providerJob?.cancel(cause) + retired.contextSetJob?.cancel(cause) + retired.scope.cancel(cause) + val job = retirementScope.launch(start = CoroutineStart.LAZY) { retireProvider(retired.provider) } + // Recorded before it can run, so a swap committing meanwhile finds it and waits for it. + synchronized(stateLock) { retirements += retired.provider to job } + job.invokeOnCompletion { + synchronized(stateLock) { retirements.removeAll { (_, pending) -> pending === job } } } + job.start() + return job } /** - * Clear the current [FeatureProvider] and reset to a no-op provider. + * Unbinds a provider and shuts it down. + * + * Only for a provider that is actually being dropped: re-registering the same instance must not + * shut it down, whether the registration was reused or this retirement was simply outrun. */ - suspend fun clearProvider() { - val oldProvider = providerMutex.withLock { - synchronized(stateLock) { - val current = this.provider - this.provider = noOpProvider - providerGeneration++ - providersFlow.value = noOpProvider - current - } + private fun retireProvider(provider: FeatureProvider) { + val reRegistered = synchronized(stateLock) { + val reRegistered = registration.provider === provider + if (!reRegistered) untrackProviderBinding(provider) + reRegistered + } + if (reRegistered) return + try { + provider.shutdown() + } catch (e: Throwable) { + logger.warn({ "Provider ${provider.attributionName()} failed to shut down" }, throwable = e) } - untrackProviderBinding(oldProvider) - oldProvider.shutdown() - _statusFlow.emit(OpenFeatureStatus.NotReady) } /** - * Set the [EvaluationContext] for this instance. Suspends until the context is set and the - * provider has reconciled. + * Set the [EvaluationContext] for this instance, suspending until the provider's `onContextSet` + * has terminated. + * + * The provider reports the transitions around its own reconciliation, so a provider still + * reconciling in the background leaves the status [OpenFeatureStatus.Reconciling] when this + * returns. * * @param evaluationContext the [EvaluationContext] to set */ suspend fun setEvaluationContextAndWait(evaluationContext: EvaluationContext) { - setEvaluationContextInternal(evaluationContext) + updateContext(evaluationContext).joinPropagatingCancellation() } /** - * Set the [EvaluationContext] for this instance. Returns immediately and sets the context - * in a coroutine scope. + * Set the [EvaluationContext] for this instance. Returns once the reconciliation has started. + * + * Reconciliation runs on the dispatcher the provider was registered with, so that it is ordered + * against that provider's `initialize`. * * @param evaluationContext the [EvaluationContext] to set - * @param dispatcher the dispatcher for the context-set coroutine */ - fun setEvaluationContext( - evaluationContext: EvaluationContext, - dispatcher: CoroutineDispatcher = Dispatchers.Default - ) { - setEvaluationContextJob?.cancel(CancellationException("Set context job was cancelled due to new context")) - this.setEvaluationContextJob = CoroutineScope(SupervisorJob() + dispatcher).launch { - setEvaluationContextInternal(evaluationContext) + fun setEvaluationContext(evaluationContext: EvaluationContext) { + // Only this path supersedes the previous reconciliation: overlapping awaited ones are legal. + updateContext(evaluationContext) { current, job -> + current.contextSetJob?.cancel( + CancellationException("Set context job was cancelled due to new context") + ) + current.contextSetJob = job } } - private suspend fun setEvaluationContextInternal(evaluationContext: EvaluationContext) { - var reconciliation: ContextReconciliation? = null - var terminalStatus: OpenFeatureStatus? = null - try { - contextReconciliationMutex.withLock { - providerMutex.withLock { - var shouldEmitReconciling = false - synchronized(stateLock) { - val oldContext = context - context = evaluationContext - if (provider !== noOpProvider) { - reconciliation = ContextReconciliation(oldContext, provider, providerGeneration) - if (contextReconciliationGeneration != providerGeneration) { - contextReconciliationGeneration = providerGeneration - activeContextReconciliations = 0 - } - if (activeContextReconciliations == 0) { - contextReconciliationInitialStatus = getStatus() - contextReconciliationTerminalStatus = null - contextReconciliationTerminalProviderStatusGeneration = null - } - activeContextReconciliations++ - shouldEmitReconciling = activeContextReconciliations == 1 - } - } - if (shouldEmitReconciling) { - _statusFlow.emit(OpenFeatureStatus.Reconciling) - } - } - } - - val registeredReconciliation = reconciliation ?: return - registeredReconciliation.provider.onContextSet( - registeredReconciliation.oldContext, - evaluationContext - ) - terminalStatus = OpenFeatureStatus.Ready - } catch (e: CancellationException) { - // This happens by design and shouldn't be treated as an error - } catch (e: OpenFeatureError) { - terminalStatus = OpenFeatureStatus.Error(e) - } catch (e: Throwable) { - terminalStatus = OpenFeatureStatus.Error( - OpenFeatureError.GeneralError(e.message ?: "Unknown error") - ) - } finally { - val registeredReconciliation = reconciliation - if (registeredReconciliation != null) { - withContext(NonCancellable) { - completeContextReconciliation( - registeredReconciliation.provider, - registeredReconciliation.providerGeneration, - terminalStatus - ) - } + private fun updateContext( + newContext: EvaluationContext, + record: (ProviderRegistration, Job) -> Unit = { _, _ -> } + ): Job { + // Created lazily so that committing the context, superseding the previous job and recording + // this one are one step, and started outside the lock so that an immediate dispatcher runs + // the provider's onContextSet without stateLock held. + val job = synchronized(stateLock) { + val current = registration + val oldContext = context + context = newContext + + val job = current.dispatchLifecycle("onContextSet", CoroutineStart.LAZY) { + current.provider.onContextSet(oldContext, newContext) } + record(current, job) + job } + job.start() + return job } - private suspend fun completeContextReconciliation( - reconciliationProvider: FeatureProvider, - reconciliationProviderGeneration: Long, - terminalStatus: OpenFeatureStatus? - ) { - contextReconciliationMutex.withLock { - if (contextReconciliationGeneration != reconciliationProviderGeneration) return - - if (terminalStatus != null) { - contextReconciliationTerminalStatus = terminalStatus - contextReconciliationTerminalProviderStatusGeneration = providerStatusGeneration - } - activeContextReconciliations-- - if (activeContextReconciliations == 0) { - val retainedTerminalStatus = contextReconciliationTerminalStatus - val statusToEmit = retainedTerminalStatus ?: contextReconciliationInitialStatus - val shouldEmitStatus = if (retainedTerminalStatus != null) { - contextReconciliationTerminalProviderStatusGeneration == providerStatusGeneration - } else { - getStatus() is OpenFeatureStatus.Reconciling - } - contextReconciliationInitialStatus = null - contextReconciliationTerminalStatus = null - contextReconciliationTerminalProviderStatusGeneration = null - - providerMutex.withLock { - if ( - synchronized(stateLock) { provider === reconciliationProvider } && - providerGeneration == reconciliationProviderGeneration && - statusToEmit != null && - shouldEmitStatus - ) { - _statusFlow.emit(statusToEmit) - } - } - } + /** + * Awaits a lifecycle call, cancelling it if the caller is cancelled: the call runs on the + * registration's own scope, so cancelling the caller would otherwise leave it running. + */ + private suspend fun Job.joinPropagatingCancellation() { + try { + join() + } catch (e: CancellationException) { + cancel(e) + // Awaited so the provider has reported the outcome it owes before the caller returns. + withContext(NonCancellable) { join() } + throw e } } - private suspend fun tryWithStatusEmitErrorHandling(function: suspend () -> Unit) { + /** + * Runs one of the provider's lifecycle calls, logging a throw rather than deriving a status + * from it. Cancellation still propagates. + */ + private fun ProviderRegistration.dispatchLifecycle( + operation: String, + start: CoroutineStart = CoroutineStart.DEFAULT, + work: suspend () -> Unit + ): Job = scope.launch(start = start) { try { - function() + work() } catch (e: CancellationException) { - // This happens by design and shouldn't be treated as an error - } catch (e: OpenFeatureError) { - _statusFlow.emit(OpenFeatureStatus.Error(e)) + throw e } catch (e: Throwable) { - _statusFlow.emit( - OpenFeatureStatus.Error( - OpenFeatureError.GeneralError( - e.message ?: "Unknown error" - ) - ) - ) + logger.warn({ + "Provider ${provider.attributionName()} failed during $operation. The SDK does not " + + "derive status from a thrown exception: report the failure by emitting a " + + "ProviderError event." + }, throwable = e) } } @@ -380,14 +388,14 @@ open class OpenFeatureAPIInstance internal constructor() { * Add [Hook]s to this instance. */ fun addHooks(hooks: List>) { - this.hooks += hooks + synchronized(stateLock) { this.hooks += hooks } } /** * Clear all [Hook]s from this instance. */ fun clearHooks() { - this.hooks = listOf() + synchronized(stateLock) { this.hooks = listOf() } } /** @@ -395,59 +403,32 @@ open class OpenFeatureAPIInstance internal constructor() { */ suspend fun shutdown() { clearHooks() - setEvaluationContextJob?.cancel(CancellationException("Set context job was cancelled due to shutdown")) - setProviderJob?.cancel(CancellationException("Provider set job was cancelled due to shutdown")) - observeProviderEventsJob?.cancel( - CancellationException("Provider event observe job was cancelled due to shutdown") - ) clearProvider() } /** - * Get the current [OpenFeatureStatus] of this instance. + * Get the current [OpenFeatureStatus] of this instance, as reported by its provider. */ - fun getStatus(): OpenFeatureStatus = _statusFlow.replayCache.first() + fun getStatus(): OpenFeatureStatus = getProvider().status /** - * Observe events from the currently configured Provider. + * Observe the events emitted by the currently configured provider. + * + * Switches to the new provider on a swap, so a retired provider's events stop arriving. Narrow + * to one event type with the reified [observe] overload. */ @OptIn(ExperimentalCoroutinesApi::class) - inline fun observe(): Flow = providersFlow - .flatMapLatest { it.observe() }.filterIsInstance() + fun observe(): Flow = + providerRegistrations.flatMapLatest { it.provider.observe() } /** - * Aligning the state management to - * https://openfeature.dev/specification/sections/events#requirement-535 + * Claims [provider] for this instance. + * + * @throws IllegalStateException if another instance already owns [provider] */ - private val handleProviderEvents: FlowCollector = FlowCollector { providerEvent -> - when (providerEvent) { - is OpenFeatureProviderEvents.ProviderReady -> { - emitProviderStatus(OpenFeatureStatus.Ready) - } - - is OpenFeatureProviderEvents.ProviderStale -> { - emitProviderStatus(OpenFeatureStatus.Stale) - } - - is OpenFeatureProviderEvents.ProviderError -> { - emitProviderStatus(providerEvent.toOpenFeatureStatusError()) - } - - else -> { // All other states should not be emitted from here - } - } - } - - private suspend fun emitProviderStatus(status: OpenFeatureStatus) { - contextReconciliationMutex.withLock { - providerStatusGeneration++ - _statusFlow.emit(status) - } - } - - private suspend fun trackProviderBinding(provider: FeatureProvider) { - if (provider === noOpProvider) return - bindingMutex.withLock { + private fun trackProviderBinding(provider: FeatureProvider) { + if (provider is NoProvider) return + synchronized(bindingLock) { val existingOwner = boundProviders.findOwner(provider) if (existingOwner != null && existingOwner !== this) { throw IllegalStateException( @@ -459,9 +440,9 @@ open class OpenFeatureAPIInstance internal constructor() { } } - private suspend fun untrackProviderBinding(provider: FeatureProvider) { - if (provider === noOpProvider) return - bindingMutex.withLock { + private fun untrackProviderBinding(provider: FeatureProvider) { + if (provider is NoProvider) return + synchronized(bindingLock) { if (boundProviders.findOwner(provider) === this) { boundProviders.removeProvider(provider) } @@ -475,7 +456,7 @@ open class OpenFeatureAPIInstance internal constructor() { * when providers implement equals/hashCode. */ private val boundProviders = IdentityRegistry() - private val bindingMutex = Mutex() + private val bindingLock = SynchronizedObject() /** * Clear all provider bindings. Intended for test isolation only. @@ -486,6 +467,17 @@ open class OpenFeatureAPIInstance internal constructor() { } } +/** + * Observe one type of event from the currently configured provider. + * + * The unparameterised [OpenFeatureAPIInstance.observe] yields every event; this narrows it. + */ +inline fun OpenFeatureAPIInstance.observe(): Flow = + observe().filterIsInstance() + +/** Provider name for a log line, or null: naming a provider must never fail a registration. */ +internal fun FeatureProvider.attributionName(): String? = runCatching { metadata.name }.getOrNull() + /** * Simple identity-based registry. All lookups use referential equality (===) so that * distinct provider objects are never conflated, even if they share equals/hashCode. diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClient.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClient.kt index a252cd66..9b85f708 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClient.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClient.kt @@ -10,7 +10,6 @@ import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError.GeneralError -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow private val typeMatchingException = @@ -30,9 +29,7 @@ class OpenFeatureClient( override val statusFlow = openFeatureAPI.statusFlow - @OptIn(ExperimentalCoroutinesApi::class) - override fun observe(): Flow = - openFeatureAPI.observe() + override fun observe(): Flow = openFeatureAPI.observe() override fun getBooleanValue(key: String, defaultValue: Boolean): Boolean { return getBooleanDetails(key, defaultValue).value @@ -213,7 +210,8 @@ class OpenFeatureClient( var details = FlagEvaluationDetails(key, defaultValue) val state = openFeatureAPI.getEvaluationState() val provider = state.provider - val mergedHooks: List> = provider.hooks + options.hooks + hooks + openFeatureAPI.hooks + // One snapshot, so a concurrent addHooks cannot be observed half-applied mid-evaluation. + val mergedHooks: List> = provider.hooks + options.hooks + hooks + state.hooks val context = state.context val hooksWithContext: List, HookContext>> = mergedHooks @@ -231,7 +229,6 @@ class OpenFeatureClient( } try { hookSupport.beforeHooks(flagValueType, hooksWithContext, hints) - shortCircuitIfNotReady() val providerEval = createProviderEvaluation( flagValueType, key, @@ -260,15 +257,6 @@ class OpenFeatureClient( return details } - private fun shortCircuitIfNotReady() { - val providerStatus = openFeatureAPI.getStatus() - if (providerStatus == OpenFeatureStatus.NotReady) { - throw OpenFeatureError.ProviderNotReadyError() - } else if (providerStatus is OpenFeatureStatus.Fatal) { - throw OpenFeatureError.ProviderFatalError() - } - } - @Suppress("UNCHECKED_CAST") private fun createProviderEvaluation( flagValueType: FlagValueType, diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureStatus.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureStatus.kt index 40ac2a38..6a5f8654 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureStatus.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureStatus.kt @@ -15,13 +15,23 @@ sealed interface OpenFeatureStatus { /** * The provider is in an error state and unable to evaluate flags. + * + * Compared by the failure it describes, so the same failure reported twice is one status. */ - class Error(val error: OpenFeatureError) : OpenFeatureStatus + class Error(val error: OpenFeatureError) : OpenFeatureStatus { + override fun equals(other: Any?): Boolean = other is Error && describesSameError(error, other.error) + + override fun hashCode(): Int = errorHashCode(error) + } /** * The provider has entered an irrecoverable error state. */ - class Fatal(val error: OpenFeatureError) : OpenFeatureStatus + class Fatal(val error: OpenFeatureError) : OpenFeatureStatus { + override fun equals(other: Any?): Boolean = other is Fatal && describesSameError(error, other.error) + + override fun hashCode(): Int = errorHashCode(error) + } /** * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. @@ -32,4 +42,10 @@ sealed interface OpenFeatureStatus { * The provider is reconciling its state with a context change. */ object Reconciling : OpenFeatureStatus -} \ No newline at end of file +} + +private fun describesSameError(left: OpenFeatureError, right: OpenFeatureError): Boolean = + left.errorCode() == right.errorCode() && left.message == right.message + +private fun errorHashCode(error: OpenFeatureError): Int = + 31 * error.errorCode().hashCode() + error.message.hashCode() \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTracker.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTracker.kt new file mode 100644 index 00000000..5d978d5b --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTracker.kt @@ -0,0 +1,224 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.events.toCurrentStateEvent +import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatus +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.withContext + +private const val EVENT_BUFFER_CAPACITY = 64 + +/** Stamped on a replayed event, below every real one so it is never fenced. */ +private const val REPLAY_SEQUENCE = Long.MIN_VALUE + +/** + * Processes the [OpenFeatureProviderEvents] a provider emits, updates [status] accordingly, and + * republishes the events to subscribers. + * + * | Event | Resulting status | + * |----------------------------------------------|------------------| + * | `ProviderReady` | `Ready` | + * | `ProviderError` | `Error` | + * | `ProviderError` with `errorCode` `PROVIDER_FATAL` | `Fatal` | + * | `ProviderStale` | `Stale` | + * | `ProviderReconciling` | `Reconciling` | + * | `ProviderContextChanged` | `Ready` | + * | `ProviderConfigurationChanged` | *(no change)* | + * + * A new subscriber receives one synthetic event reflecting the current status, so attaching does not + * race the first live event. Nothing is replayed while the provider is [OpenFeatureStatus.NotReady], + * which has no corresponding event type. + * + * A provider delegates [FeatureProvider.status] and [FeatureProvider.observe] to this. Do not + * collect [observe] on [kotlinx.coroutines.Dispatchers.Unconfined], and do not call [send] from + * inside a collector: delivery would then run inline under the lock that orders events. + */ +class ProviderStatusTracker { + private val lock = SynchronizedObject() + + private var currentStatus: OpenFeatureStatus = OpenFeatureStatus.NotReady + private var sequence: Long = 0 + private var statusSequence: Long = 0 + + private val reconciliations = Reconciliations() + + private val events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = EVENT_BUFFER_CAPACITY, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private class Emission(val sequence: Long, val event: OpenFeatureProviderEvents) + + /** The status implied by the most recent event, safe to read from any thread. */ + val status: OpenFeatureStatus get() = synchronized(lock) { currentStatus } + + /** Reports [event], updating [status] and publishing it to [observe]'s subscribers. */ + fun send(event: OpenFeatureProviderEvents) = synchronized(lock) { record(event) } + + /** + * Publishes [event] and applies its status. The caller must hold [lock]: a reconciliation + * starting between the decision and the report would capture a status that is already replaced. + */ + private fun record(event: OpenFeatureProviderEvents) { + val status = event.toOpenFeatureStatus() + sequence++ + if (status != null) { + currentStatus = status + statusSequence = sequence + } + events.tryEmit(Emission(sequence, event)) + } + + /** Stream to return from [FeatureProvider.observe]. */ + fun observe(): Flow = flow { + var fence = 0L + emitAll( + events + .onSubscription { + val replayed = synchronized(lock) { + fence = sequence + currentStatus + } + replayed.toCurrentStateEvent()?.let { emit(Emission(REPLAY_SEQUENCE, it)) } + } + .filter { + // A status-carrying event from before the snapshot is already in the replay; one + // carrying no status is not, so it is never fenced. + it.sequence == REPLAY_SEQUENCE || + it.sequence > fence || + it.event.toOpenFeatureStatus() == null + } + .map { it.event } + ) + } + + /** + * Runs [block] as a context reconciliation, reporting the transitions around it. + * + * Sends [OpenFeatureProviderEvents.ProviderReconciling] on entry, then + * [OpenFeatureProviderEvents.ProviderContextChanged] on success or + * [OpenFeatureProviderEvents.ProviderError] on failure, and rethrows whatever [block] threw. + * + * Overlapping invocations are collapsed: reconciliation is reported once, and the outcome + * reported is that of the last invocation to terminate, as requirements 5.3.4.2 and 5.3.4.3 ask. + * Where every invocation was cancelled, the status preceding reconciliation is reported again. + * Where [block] reported a status of its own, that report stands. + * + * A reconciliation that begins while the provider is [OpenFeatureStatus.NotReady] reports + * nothing at all: readiness is [FeatureProvider.initialize]'s to report, and there is no earlier + * status to restore. A provider that does become usable during [block] can still say so itself. + */ + suspend fun reconciling(block: suspend () -> Unit) { + // One critical section: an overlapping invocation would otherwise read its mark before this + // send bumps the sequence, and mistake that send for a report of its own. + val registration = synchronized(lock) { + val registration = reconciliations.begin(currentStatus) + if (registration.first && currentStatus != OpenFeatureStatus.NotReady) { + record(OpenFeatureProviderEvents.ProviderReconciling()) + } + registration.copy(mark = statusSequence) + } + + var outcome: OpenFeatureProviderEvents? = null + try { + block() + outcome = OpenFeatureProviderEvents.ProviderContextChanged() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + outcome = e.toProviderErrorEvent() + throw e + } finally { + // A cancelled invocation still owes the reconciliation an outcome or a restoration. + withContext(NonCancellable) { + synchronized(lock) { + val reportedByBlock = statusSequence > registration.mark + reconciliations.end(registration, outcome, reportedByBlock)?.let { record(it) } + } + } + } + } + + /** + * Returns the tracker to [OpenFeatureStatus.NotReady]. + * + * Call this from [FeatureProvider.shutdown] where the provider can be registered again, so a + * reused instance does not report the status it held before it was shut down. + */ + fun reset() = synchronized(lock) { + currentStatus = OpenFeatureStatus.NotReady + reconciliations.reset() + } + + /** + * Collapses overlapping reconciliations into one reported transition. The outcome belongs to the + * reconciliation rather than to the invocation that produced it. + */ + private class Reconciliations { + private var generation = 0L + private var active = 0 + private var restore: OpenFeatureStatus? = null + private var terminal: OpenFeatureProviderEvents? = null + private var reportedByBlock = false + + data class Registration(val generation: Long, val first: Boolean, val mark: Long = 0) + + /** Registers an invocation, reporting whether it is the one that opens the reconciliation. */ + fun begin(restoreTo: OpenFeatureStatus): Registration { + if (active == 0) { + restore = restoreTo + terminal = null + reportedByBlock = false + } + return Registration(generation, ++active == 1) + } + + /** + * Registers an invocation's outcome, returning what to report if it was the last in flight. + * An invocation from a superseded generation reports nothing and disturbs nothing. + */ + fun end( + registration: Registration, + outcome: OpenFeatureProviderEvents?, + blockReported: Boolean + ): OpenFeatureProviderEvents? { + if (registration.generation != generation) return null + if (blockReported) reportedByBlock = true + if (outcome != null) terminal = outcome + if (--active > 0) return null + + // Decided only after the counter is decremented: returning earlier would strand it above + // zero and no later reconciliation would resolve. + return when { + reportedByBlock -> null + restore == OpenFeatureStatus.NotReady -> null + else -> terminal ?: restore?.toCurrentStateEvent() + } + } + + fun reset() { + generation++ + active = 0 + } + } +} + +private fun Throwable.toProviderErrorEvent() = OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + message = message ?: "Context reconciliation failed", + errorCode = (this as? OpenFeatureError)?.errorCode() + ) +) \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents.kt index 1b7d8681..bbf45ff3 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents.kt @@ -45,6 +45,22 @@ sealed class OpenFeatureProviderEvents { data class ProviderStale( override val eventDetails: EventDetails? = null ) : OpenFeatureProviderEvents() + + /** + * The provider started reconciling its state with a new [dev.openfeature.kotlin.sdk.EvaluationContext]. + * [eventDetails] may supply [EventDetails.flagsChanged], [EventDetails.message], [EventDetails.errorCode], and [EventDetails.eventMetadata] as applicable. + */ + data class ProviderReconciling( + override val eventDetails: EventDetails? = null + ) : OpenFeatureProviderEvents() + + /** + * The provider finished reconciling its state with a new [dev.openfeature.kotlin.sdk.EvaluationContext]. + * [eventDetails] may supply [EventDetails.flagsChanged], [EventDetails.message], [EventDetails.errorCode], and [EventDetails.eventMetadata] as applicable. + */ + data class ProviderContextChanged( + override val eventDetails: EventDetails? = null + ) : OpenFeatureProviderEvents() } internal fun OpenFeatureProviderEvents.ProviderError.toOpenFeatureStatusError(): OpenFeatureStatus { @@ -60,4 +76,29 @@ internal fun OpenFeatureProviderEvents.ProviderError.toOpenFeatureStatusError(): } else { OpenFeatureStatus.Error(openFeatureError) } +} + +internal fun OpenFeatureProviderEvents.toOpenFeatureStatus(): OpenFeatureStatus? = when (this) { + is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready + is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale + is OpenFeatureProviderEvents.ProviderError -> toOpenFeatureStatusError() + is OpenFeatureProviderEvents.ProviderReconciling -> OpenFeatureStatus.Reconciling + is OpenFeatureProviderEvents.ProviderContextChanged -> OpenFeatureStatus.Ready + is OpenFeatureProviderEvents.ProviderConfigurationChanged -> null +} + +internal fun OpenFeatureStatus.toCurrentStateEvent(): OpenFeatureProviderEvents? = when (this) { + is OpenFeatureStatus.NotReady -> null + is OpenFeatureStatus.Ready -> OpenFeatureProviderEvents.ProviderReady() + is OpenFeatureStatus.Stale -> OpenFeatureProviderEvents.ProviderStale() + is OpenFeatureStatus.Reconciling -> OpenFeatureProviderEvents.ProviderReconciling() + is OpenFeatureStatus.Error -> OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(message = error.message, errorCode = error.errorCode()) + ) + is OpenFeatureStatus.Fatal -> OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + message = error.message, + errorCode = ErrorCode.PROVIDER_FATAL + ) + ) } \ 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 00e6d057..852d222a 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 @@ -6,26 +6,25 @@ 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.ProviderStatusTracker import dev.openfeature.kotlin.sdk.TrackingEventDetails import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents -import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatusError +import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatus +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.logging.LoggerFactory +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel 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 import kotlinx.coroutines.launch /** @@ -92,17 +91,14 @@ class MultiProvider( 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 - } + /** + * Aggregates the statuses of [providers] into the MultiProvider's own status. The default + * reports the most severe, in the order the specification's Multi-Provider appendix defines. + */ + fun status(providers: List): OpenFeatureStatus = + providers.map { it.status }.maxByOrNull { it.severity } ?: OpenFeatureStatus.NotReady + } // TODO: Support hooks override val hooks: List> = emptyList() @@ -125,14 +121,11 @@ class MultiProvider( } } - private val _statusFlow = MutableStateFlow(OpenFeatureStatus.NotReady) - val statusFlow = _statusFlow.asStateFlow() + private val statusTracker = ProviderStatusTracker() - private val eventFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + private val logger = LoggerFactory.getLogger(MULTIPROVIDER_NAME) - // Track individual provider statuses, initial state of all providers is NotReady - private val childProviderStatuses: MutableMap = - childFeatureProviders.associateWithTo(mutableMapOf()) { OpenFeatureStatus.NotReady } + override val status: OpenFeatureStatus get() = statusTracker.status private fun List.toChildFeatureProviders(): List { // Extract a stable base name per provider, falling back for unnamed providers @@ -160,14 +153,18 @@ class MultiProvider( } } - private var observeProviderEventsJob: Job? = null + private val watchLock = SynchronizedObject() + private var watchScope: CoroutineScope? = null + + private val statusLock = SynchronizedObject() + private var openReconciliations = 0 /** * @return Number of unique providers */ internal fun getProviderCount(): Int = childFeatureProviders.size - override fun observe(): Flow = eventFlow.asSharedFlow() + override fun observe(): Flow = statusTracker.observe() /** * Initializes all underlying providers with the given context. @@ -177,73 +174,142 @@ class MultiProvider( */ override suspend fun initialize(initialContext: EvaluationContext?) { coroutineScope { - observeProviderEventsJob?.cancel( - cause = CancellationException("Observe provider events job cancelled due to new initialize call") - ) - observeProviderEventsJob = CoroutineScope(this.coroutineContext + SupervisorJob()).launch { - // 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 - childFeatureProviders.forEach { provider -> - provider.observe() - .onEach { event -> - handleProviderEvent(provider, event) - } - .launchIn(this) - } - } - - launch { - // State updates captured by observing individual Feature Flag providers + // Started before the children, not after: the terminal updateStatus() re-reads every + // child's status so a late watcher would still converge. + watchChildren() + try { childFeatureProviders - .map { async { it.initialize(initialContext) } } + .map { child -> async { child.reportingItsOwnFailure { initialize(initialContext) } } } .awaitAll() + } finally { + updateStatus() } } } - private suspend fun handleProviderEvent(provider: ChildFeatureProvider, event: OpenFeatureProviderEvents) { - val newChildStatus = when (event) { - // ProviderConfigurationChanged events should always re-emit - is OpenFeatureProviderEvents.ProviderConfigurationChanged -> { - eventFlow.emit(event) - return - } + /** + * Runs one of [this] child's lifecycle calls, leaving the failure to the child: it reports its + * own error event, so one failing child must not cancel the siblings. + */ + private suspend fun ChildFeatureProvider.reportingItsOwnFailure( + work: suspend ChildFeatureProvider.() -> Unit + ) { + try { + work() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // Status is still reported by the child; this only keeps the failure diagnosable. + logger.warn({ "Child provider $name threw during a lifecycle call" }, throwable = e) + } + } - is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready - is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale - is OpenFeatureProviderEvents.ProviderError -> event.toOpenFeatureStatusError() + /** + * Subscribes to every child's events, undispatched so each subscription is established before + * this returns: a child could otherwise report and finish before anyone was listening, and its + * replay only carries the status it settled on. + */ + private fun CoroutineScope.watchChildren() { + // Not a child of initialize's scope, which would either make initialize hang waiting for the + // collectors or stop them the moment it returned, but it does inherit its dispatcher. + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + // Installed under the lock because shutdown runs on the retirement scope, not on the + // dispatcher initialize was entered on, and has to see the scope it must cancel. + val replaced = synchronized(watchLock) { + val replaced = watchScope + watchScope = scope + replaced } + replaced?.cancel(CancellationException("Child provider watch replaced by a new initialize call")) + scope.launch(start = CoroutineStart.UNDISPATCHED) { + childFeatureProviders.forEach { child -> + launch(start = CoroutineStart.UNDISPATCHED) { + child.observe().collect { handleChildEvent(it) } + } + } + } + } - val previousStatus = _statusFlow.value - childProviderStatuses[provider] = newChildStatus - val newStatus = calculateAggregateStatus() + /** + * A child's event either changes the aggregate status or is a configuration change, which the + * specification's Multi-Provider appendix asks to be re-emitted whenever a child reports one. + */ + private fun handleChildEvent(event: OpenFeatureProviderEvents) { + // Re-aggregated on any child activity, a stateless event included: shutting a child down + // moves its status to not-ready, which has no event to report. + updateStatus(event) + // Forwarded after the aggregate has settled, so a subscriber that re-reads the status on + // this event sees the aggregate it triggered rather than the one it replaced. + if (event.toOpenFeatureStatus() == null) statusTracker.send(event) + } - if (previousStatus != newStatus) { - _statusFlow.update { newStatus } - // Re-emit the original event that triggered the aggregate status change - eventFlow.emit(event) + /** + * Reports the aggregate status, carrying [trigger]'s details where one triggered the change. + * + * Aggregating and reporting are one critical section: two children transitioning concurrently + * would otherwise let the thread holding the older aggregate report last. + */ + private fun updateStatus(trigger: OpenFeatureProviderEvents? = null) = synchronized(statusLock) { + val aggregate = strategy.status(childFeatureProviders) + val details = trigger?.eventDetails + val current = statusTracker.status + + if (aggregate is OpenFeatureStatus.NotReady) { + // No event describes not-ready, so it reaches getStatus() but not observe(). + if (current != OpenFeatureStatus.NotReady) statusTracker.reset() + return@synchronized } - } - private fun calculateAggregateStatus(): OpenFeatureStatus { - val highestPrecedenceStatus = childProviderStatuses.values.maxBy { it.precedence } - return highestPrecedenceStatus + // Only this provider's own reconciliation owns its resolution, and it reports the outcome + // itself once every child has finished. A child reconciling on its own account has no such + // resolution to wait for, so its return to readiness must be reported here. + if (aggregate is OpenFeatureStatus.Ready && openReconciliations > 0) return@synchronized + + val event = when (aggregate) { + is OpenFeatureStatus.Ready -> OpenFeatureProviderEvents.ProviderReady(details) + is OpenFeatureStatus.Stale -> OpenFeatureProviderEvents.ProviderStale(details) + is OpenFeatureStatus.Reconciling -> OpenFeatureProviderEvents.ProviderReconciling(details) + // The child's details are kept with the aggregate's error over the top: rebuilding from + // the status alone would drop flagsChanged and eventMetadata. + is OpenFeatureStatus.Error -> OpenFeatureProviderEvents.ProviderError( + details.describing(aggregate.error) + ) + is OpenFeatureStatus.Fatal -> OpenFeatureProviderEvents.ProviderError( + details.describing(aggregate.error, ErrorCode.PROVIDER_FATAL) + ) + is OpenFeatureStatus.NotReady -> return@synchronized + } + if (event.toOpenFeatureStatus() != current) statusTracker.send(event) } + private fun OpenFeatureProviderEvents.EventDetails?.describing( + error: OpenFeatureError, + errorCode: ErrorCode = error.errorCode() + ) = (this ?: OpenFeatureProviderEvents.EventDetails()).copy( + message = error.message, + errorCode = errorCode + ) + /** * Shuts down all underlying providers. * This allows providers to clean up resources and complete any pending operations. */ override fun shutdown() { - observeProviderEventsJob?.cancel( - cause = CancellationException("Observe provider events job cancelled due to shutdown") - ) + val watching = synchronized(watchLock) { + val watching = watchScope + watchScope = null + watching + } + watching?.cancel(CancellationException("Child provider watch cancelled due to shutdown")) + statusTracker.reset() val shutdownErrors = mutableListOf>() childFeatureProviders.forEach { provider -> try { provider.shutdown() } catch (t: Throwable) { + // A CancellationException too: this is not a suspending function, so one from a + // child is an ordinary failure, and rethrowing it would abandon the children after. shutdownErrors += provider.name to t } } @@ -270,12 +336,22 @@ class MultiProvider( oldContext: EvaluationContext?, newContext: EvaluationContext ) { - 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() + synchronized(statusLock) { openReconciliations++ } + try { + statusTracker.reconciling { + coroutineScope { + childFeatureProviders + .map { child -> + async { child.reportingItsOwnFailure { onContextSet(oldContext, newContext) } } + } + .awaitAll() + } + updateStatus() + } + } finally { + // Dropped only once the tracker has reported the outcome, which its own finally does + // before this one runs. + synchronized(statusLock) { openReconciliations-- } } } @@ -373,6 +449,7 @@ class MultiProvider( try { provider.track(trackingEventName, context, details) } catch (t: Throwable) { + // Collected rather than rethrown, for the same reason as in shutdown. trackingErrors += provider.name to t } } @@ -399,4 +476,16 @@ class MultiProvider( private const val MULTIPROVIDER_NAME = "multiprovider" private const val UNDEFINED_PROVIDER_NAME = "" } -} \ No newline at end of file +} + +/** Most severe wins, per the specification's Multi-Provider appendix. */ +private val OpenFeatureStatus.severity: Int + get() = when (this) { + is OpenFeatureStatus.Fatal -> 5 + is OpenFeatureStatus.NotReady -> 4 + is OpenFeatureStatus.Error -> 3 + // Not in the appendix's list; treated as Stale is, since both mean "usable but not current". + is OpenFeatureStatus.Reconciling -> 2 + is OpenFeatureStatus.Stale -> 2 + is OpenFeatureStatus.Ready -> 1 + } \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DeveloperExperienceTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DeveloperExperienceTests.kt index f735f4c8..4a204bc3 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DeveloperExperienceTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DeveloperExperienceTests.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.Test @@ -151,7 +152,8 @@ class DeveloperExperienceTests { val job = CoroutineScope(dispatcher).launch { OpenFeatureAPI.setProviderAndWait( SlowProvider(dispatcher = dispatcher), - ImmutableContext() + ImmutableContext(), + dispatcher = dispatcher ) } testScheduler.advanceTimeBy(1) // Make sure setProviderAndWait is called @@ -231,32 +233,45 @@ class DeveloperExperienceTests { OpenFeatureAPI.shutdown() testScheduler.advanceUntilIdle() job.cancelAndJoin() - assertEquals(5, emittedStatuses.size) + // BrokenInitProvider reports its failure and then reconciles without reporting anything, so + // the context set contributes no transition: the SDK no longer invents one on its behalf. + assertEquals(3, emittedStatuses.size, "collected $emittedStatuses") assertTrue(emittedStatuses[0] is OpenFeatureStatus.NotReady) assertTrue(emittedStatuses[1] is OpenFeatureStatus.Error) assertTrue((emittedStatuses[1] as OpenFeatureStatus.Error).error is OpenFeatureError.ProviderNotReadyError) - assertTrue(emittedStatuses[2] is OpenFeatureStatus.Reconciling) - assertTrue(emittedStatuses[3] is OpenFeatureStatus.Ready) - assertTrue(emittedStatuses[4] is OpenFeatureStatus.NotReady) + assertTrue(emittedStatuses[2] is OpenFeatureStatus.NotReady) } @Test fun testProviderThatErrorsButHealsThenReady() = runTest { val healDelayMillis: Long = 100 val healing = AutoHealingProvider(healDelay = healDelayMillis) + val statuses = mutableListOf() + val collector = launch { OpenFeatureAPI.statusFlow.collect { statuses.add(it) } } + runCurrent() + + // A test dispatcher, so the SDK's subscription to the provider is established before + // initialize emits: on Dispatchers.Default the error is raced away and never observed. val job = async { - OpenFeatureAPI.setProviderAndWait(healing, ImmutableContext()) - } - waitAssert { - assertEquals(OpenFeatureStatus.NotReady, OpenFeatureAPI.getStatus()) - } - waitAssert { - assertTrue(OpenFeatureAPI.getStatus() is OpenFeatureStatus.Error) + OpenFeatureAPI.setProviderAndWait( + healing, + ImmutableContext(), + dispatcher = StandardTestDispatcher(testScheduler) + ) } waitAssert { assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) } job.cancelAndJoin() + collector.cancelAndJoin() + + // The error is transient — the provider heals after healDelayMillis — so it is only visible + // in the collected sequence, not by polling getStatus(). + assertTrue( + statuses.any { it is OpenFeatureStatus.Error }, + "expected the provider to report an error before healing, collected $statuses" + ) + assertEquals(OpenFeatureStatus.Ready, statuses.last()) OpenFeatureAPI.shutdown() advanceUntilIdle() } diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/IsolatedAPIInstanceTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/IsolatedAPIInstanceTests.kt index 651e1c00..d51257ea 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/IsolatedAPIInstanceTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/IsolatedAPIInstanceTests.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertTrue @@ -157,9 +158,12 @@ class IsolatedAPIInstanceTests { val sharedProvider = DoSomethingProvider() OpenFeatureAPI.setProviderAndWait(sharedProvider, ImmutableContext()) - instance.setProviderAndWait(sharedProvider, ImmutableContext()) - assertTrue(instance.getStatus() is OpenFeatureStatus.Error) + // A double binding is a programming error, so registration fails loudly. + assertFailsWith { + instance.setProviderAndWait(sharedProvider, ImmutableContext()) + } + assertEquals(OpenFeatureStatus.NotReady, instance.getStatus()) } @OptIn(ExperimentalCoroutinesApi::class) @@ -170,10 +174,13 @@ class IsolatedAPIInstanceTests { val sharedProvider = DoSomethingProvider() OpenFeatureAPI.setProviderAndWait(sharedProvider, ImmutableContext()) - instance.setProvider(sharedProvider, dispatcher = testDispatcher) + + assertFailsWith { + instance.setProvider(sharedProvider, dispatcher = testDispatcher) + } advanceUntilIdle() - assertTrue(instance.getStatus() is OpenFeatureStatus.Error) + assertEquals(OpenFeatureStatus.NotReady, instance.getStatus()) } @Test @@ -300,11 +307,13 @@ class IsolatedAPIInstanceTests { } instance1.setProviderAndWait(sharedSubclass, ImmutableContext()) - instance2.setProviderAndWait(sharedSubclass, ImmutableContext()) // The subclass is not the instance's private fallback, so the guard must fire + assertFailsWith { + instance2.setProviderAndWait(sharedSubclass, ImmutableContext()) + } assertEquals(OpenFeatureStatus.Ready, instance1.getStatus()) - assertTrue(instance2.getStatus() is OpenFeatureStatus.Error) + assertEquals(OpenFeatureStatus.NotReady, instance2.getStatus()) } @Test diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/LoggingIntegrationTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/LoggingIntegrationTests.kt index 3d2cfd7d..cfff3841 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/LoggingIntegrationTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/LoggingIntegrationTests.kt @@ -4,7 +4,6 @@ import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.hooks.LoggingHook import dev.openfeature.kotlin.sdk.logging.TestLogger import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -19,16 +18,20 @@ class LoggingIntegrationTests { private val testProvider = object : FeatureProvider { override val metadata: ProviderMetadata = TestProviderMetadata() override val hooks: List> = listOf() - private val events = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() override suspend fun initialize(initialContext: EvaluationContext?) { - events.emit(OpenFeatureProviderEvents.ProviderReady()) + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() {} override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { - events.emit(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + statusTracker.send(OpenFeatureProviderEvents.ProviderConfigurationChanged()) } override fun getBooleanEvaluation( @@ -86,10 +89,6 @@ class LoggingIntegrationTests { ): ProviderEvaluation { return ProviderEvaluation(value = defaultValue) } - - override fun observe(): Flow { - return events - } } @BeforeTest @@ -277,10 +276,14 @@ class LoggingIntegrationTests { val errorProvider = object : FeatureProvider { override val metadata: ProviderMetadata = TestProviderMetadata("error-provider") override val hooks: List> = listOf() - private val events = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() override suspend fun initialize(initialContext: EvaluationContext?) { - events.emit(OpenFeatureProviderEvents.ProviderReady()) + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() {} @@ -334,10 +337,6 @@ class LoggingIntegrationTests { ): ProviderEvaluation { return ProviderEvaluation(value = defaultValue) } - - override fun observe(): Flow { - return events - } } OpenFeatureAPI.setProviderAndWait(errorProvider) diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderEventingTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderEventingTests.kt index 3b6f5d96..1f001692 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderEventingTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderEventingTests.kt @@ -8,8 +8,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.toCollection import kotlinx.coroutines.launch @@ -33,16 +31,15 @@ class ProviderEventingTests { val testDispatcher = StandardTestDispatcher(testScheduler) val healDelayMillis = 1000L val provider = object : DoSomethingProvider() { - val flow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) override suspend fun initialize(initialContext: EvaluationContext?) { - // no-op + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override suspend fun onContextSet( oldContext: EvaluationContext?, newContext: EvaluationContext ) { - flow.emit( + statusTracker.send( OpenFeatureProviderEvents.ProviderError( OpenFeatureProviderEvents.EventDetails( message = "test error", @@ -51,12 +48,8 @@ class ProviderEventingTests { ) ) delay(healDelayMillis) - flow.emit( - OpenFeatureProviderEvents.ProviderConfigurationChanged() - ) + statusTracker.send(OpenFeatureProviderEvents.ProviderConfigurationChanged()) } - - override fun observe(): Flow = flow } val statusList = mutableListOf() val j = async(testDispatcher) { @@ -78,13 +71,12 @@ class ProviderEventingTests { testScheduler.advanceUntilIdle() j.cancelAndJoin() waitAssert { - assertEquals(5, statusList.size) + assertEquals(3, statusList.size, "collected $statusList") } + // A configuration change carries no status, so it no longer clears the error. assertEquals(OpenFeatureStatus.Ready, statusList[0]) - assertEquals(OpenFeatureStatus.Reconciling, statusList[1]) - assertTrue(statusList[2] is OpenFeatureStatus.Error) - assertEquals(OpenFeatureStatus.Ready, statusList[3]) - assertEquals(OpenFeatureStatus.NotReady, statusList[4]) + assertTrue(statusList[1] is OpenFeatureStatus.Error) + assertEquals(OpenFeatureStatus.NotReady, statusList[2]) } @Test diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderLifecycleTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderLifecycleTests.kt new file mode 100644 index 00000000..3f10b3e8 --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderLifecycleTests.kt @@ -0,0 +1,207 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.helpers.SpyProvider +import dev.openfeature.kotlin.sdk.isolated.ExperimentalIsolatedApi +import dev.openfeature.kotlin.sdk.isolated.createOpenFeatureAPIInstance +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The lifecycle contracts the SDK owes a provider that reports its own status: a report made + * mid-`initialize`, a provider that reports nothing, a registration superseded before it runs. + */ +@OptIn(ExperimentalIsolatedApi::class, ExperimentalCoroutinesApi::class) +class ProviderLifecycleTests { + + @AfterTest + fun tearDown() { + OpenFeatureAPIInstance.clearBoundProviders() + } + + /** Reports [eventsOnInitialize] from inside `initialize`, then optionally throws. */ + private class ReportingProvider( + private val eventsOnInitialize: List = emptyList(), + private val initializeFailure: Throwable? = null + ) : NoOpProvider() { + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + + override suspend fun initialize(initialContext: EvaluationContext?) { + eventsOnInitialize.forEach { statusTracker.send(it) } + initializeFailure?.let { throw it } + } + + override fun shutdown() = statusTracker.reset() + } + + @Test + fun readinessReportedDuringInitializeIsNotOverwrittenWhenItReturns() = runTest { + val instance = createOpenFeatureAPIInstance() + // The provider decides it is stale while initializing, and says nothing further. + val provider = ReportingProvider(listOf(OpenFeatureProviderEvents.ProviderStale())) + + instance.setProviderAndWait(provider) + advanceUntilIdle() + + // The SDK concludes nothing from initialize having returned: the provider's own report stands. + assertEquals(OpenFeatureStatus.Stale, instance.getStatus()) + } + + @Test + fun aProviderThatThrowsWithoutReportingStaysNotReady() = runTest { + val instance = createOpenFeatureAPIInstance() + val provider = ReportingProvider(initializeFailure = OpenFeatureError.GeneralError("no connection")) + + // The failure is logged, not rethrown: a provider reports its own status, and this one did not. + instance.setProviderAndWait(provider) + advanceUntilIdle() + + assertEquals(OpenFeatureStatus.NotReady, instance.getStatus()) + } + + @Test + fun aProviderThatThrowsAfterReportingKeepsTheStatusItReported() = runTest { + val instance = createOpenFeatureAPIInstance() + val provider = ReportingProvider( + eventsOnInitialize = listOf( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(message = "handshake rejected") + ) + ), + initializeFailure = OpenFeatureError.GeneralError("handshake rejected") + ) + + instance.setProviderAndWait(provider) + advanceUntilIdle() + + val status = assertIs(instance.getStatus()) + assertEquals("handshake rejected", status.error.message) + } + + @Test + fun aProviderSupersededBeforeItsRegistrationRanIsStillRetired() = runTest { + val instance = createOpenFeatureAPIInstance() + val dispatcher = StandardTestDispatcher(testScheduler) + val superseded = SpyProvider() + + // Three fire-and-forget swaps with no chance to run in between: the middle registration's job + // is cancelled before it is ever dispatched, so retiring must not depend on that job running. + instance.setProvider(superseded, dispatcher = dispatcher) + instance.setProvider(NoOpProvider(), dispatcher = dispatcher) + instance.setProvider(NoOpProvider(), dispatcher = dispatcher) + + // Polled rather than advanced: a fire-and-forget swap retires its predecessor off the + // caller's thread, so the teardown does not run on this test's virtual clock. + waitAssert { + assertEquals(1, superseded.shutdownCalls.value, "a dropped provider must still be shut down") + } + + // ...and must have been released from the registry, so another instance can take it on. + val other = createOpenFeatureAPIInstance() + other.setProviderAndWait(superseded) + advanceUntilIdle() + assertTrue(other.getProvider() === superseded) + } + + @Test + fun reRegisteringTheSameProviderDoesNotShutItDown() = runTest { + val instance = createOpenFeatureAPIInstance() + val provider = SpyProvider() + + instance.setProviderAndWait(provider) + instance.setProviderAndWait(provider) + advanceUntilIdle() + + assertEquals(0, provider.shutdownCalls.value) + assertEquals(OpenFeatureStatus.Ready, instance.getStatus()) + } + + /** Blocks in whichever lifecycle method is gated, and records the ones that ran to completion. */ + private class GatedProvider( + private val gateInitialize: Boolean = false, + private val gateContextSet: Boolean = false + ) : NoOpProvider() { + private val statusTracker = ProviderStatusTracker() + + val completed = mutableListOf() + val initializeStarted = Channel(Channel.UNLIMITED) + val releaseInitialize = Channel(Channel.UNLIMITED) + val contextSetStarted = Channel(Channel.UNLIMITED) + val releaseContextSet = Channel(Channel.UNLIMITED) + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + + override suspend fun initialize(initialContext: EvaluationContext?) { + if (gateInitialize) { + initializeStarted.send(Unit) + releaseInitialize.receive() + } + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) + completed += "initialize" + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + if (gateContextSet) { + contextSetStarted.send(Unit) + releaseContextSet.receive() + } + completed += "onContextSet" + } + + override fun shutdown() = statusTracker.reset() + } + + @Test + fun reRegisteringTheSameProviderDoesNotCancelItsInFlightInitialize() = runTest { + val instance = createOpenFeatureAPIInstance() + val dispatcher = StandardTestDispatcher(testScheduler) + val provider = GatedProvider(gateInitialize = true) + + instance.setProvider(provider, dispatcher = dispatcher) + provider.initializeStarted.receive() + + // Re-registering keeps the registration, so the work already running on its scope survives. + instance.setProvider(provider, dispatcher = dispatcher) + provider.releaseInitialize.send(Unit) + provider.releaseInitialize.send(Unit) + advanceUntilIdle() + + assertEquals(listOf("initialize", "initialize"), provider.completed) + } + + @Test + fun reRegisteringTheSameProviderDoesNotCancelItsInFlightReconciliation() = runTest { + val instance = createOpenFeatureAPIInstance() + val dispatcher = StandardTestDispatcher(testScheduler) + val provider = GatedProvider(gateContextSet = true) + + instance.setProviderAndWait(provider, dispatcher = dispatcher) + instance.setEvaluationContext(ImmutableContext("ctx")) + provider.contextSetStarted.receive() + + instance.setProvider(provider, dispatcher = dispatcher) + provider.releaseContextSet.send(Unit) + advanceUntilIdle() + + assertTrue( + provider.completed.contains("onContextSet"), + "the reconciliation was cancelled by the rebind: ${provider.completed}" + ) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTrackerTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTrackerTests.kt new file mode 100644 index 00000000..b16f067e --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTrackerTests.kt @@ -0,0 +1,499 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Tests for [ProviderStatusTracker]'s status transitions, its replay-on-subscribe contract, and the + * reconciliation coalescing that requirements 5.3.4.2 and 5.3.4.3 describe. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProviderStatusTrackerTests { + + private class Recording(private val events: MutableList, private val job: Job) : + List by events { + suspend fun stop() = job.cancelAndJoin() + } + + /** Collects from [tracker], returning once the subscription is established. */ + private fun TestScope.record(tracker: ProviderStatusTracker): Recording { + val received = mutableListOf() + val job = launch { tracker.observe().collect { received.add(it) } } + testScheduler.runCurrent() + return Recording(received, job) + } + + // MARK: status transitions + + @Test + fun aFreshTrackerIsNotReady() { + assertEquals(OpenFeatureStatus.NotReady, ProviderStatusTracker().status) + } + + @Test + fun aProviderErrorCarryingProviderFatalBecomesFatal() { + val tracker = ProviderStatusTracker() + tracker.send( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(errorCode = ErrorCode.PROVIDER_FATAL) + ) + ) + assertIs(tracker.status) + } + + @Test + fun aProviderErrorWithoutProviderFatalBecomesError() { + val tracker = ProviderStatusTracker() + tracker.send( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(message = "boom") + ) + ) + val status = assertIs(tracker.status) + assertEquals("boom", status.error.message) + } + + @Test + fun contextChangedBecomesReady() { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderContextChanged()) + assertEquals(OpenFeatureStatus.Ready, tracker.status) + } + + @Test + fun configurationChangedLeavesTheStatusAlone() { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + tracker.send(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + assertEquals(OpenFeatureStatus.Stale, tracker.status) + } + + @Test + fun resetReturnsTheTrackerToNotReady() { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + tracker.reset() + assertEquals(OpenFeatureStatus.NotReady, tracker.status) + } + + // MARK: replay on subscribe + + @Test + fun nothingIsReplayedWhileNotReady() = runTest { + val tracker = ProviderStatusTracker() + val received = record(tracker) + advanceUntilIdle() + received.stop() + + assertEquals(emptyList(), received.map { it::class.simpleName }) + } + + @Test + fun theCurrentStatusIsReplayedOnceToANewSubscriber() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + + val received = record(tracker) + advanceUntilIdle() + received.stop() + + assertEquals(listOf(OpenFeatureProviderEvents.ProviderStale::class), received.map { it::class }) + } + + @Test + fun aFatalStatusIsReplayedAsAnErrorCarryingProviderFatal() = runTest { + val tracker = ProviderStatusTracker() + tracker.send( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + message = "unrecoverable", + errorCode = ErrorCode.PROVIDER_FATAL + ) + ) + ) + + val received = record(tracker) + advanceUntilIdle() + received.stop() + + val replayed = assertIs(received.single()) + assertEquals(ErrorCode.PROVIDER_FATAL, replayed.eventDetails?.errorCode) + assertEquals("unrecoverable", replayed.eventDetails?.message) + } + + @Test + fun theReplayIsFollowedByTheLiveStreamWithoutGapsOrDuplicates() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + val received = record(tracker) + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + tracker.send(OpenFeatureProviderEvents.ProviderReconciling()) + advanceUntilIdle() + received.stop() + + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderReconciling::class + ), + received.map { it::class } + ) + } + + @Test + fun aStatelessEventIsDeliveredButNeverReplayed() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + // Sent before anyone subscribes: it carries no status, so there is nothing to replay it in. + tracker.send(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + + val received = record(tracker) + tracker.send(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + advanceUntilIdle() + received.stop() + + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderConfigurationChanged::class + ), + received.map { it::class } + ) + } + + @Test + fun subscribersReplayIndependently() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + val first = record(tracker) + + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + advanceUntilIdle() + val second = record(tracker) + advanceUntilIdle() + + first.stop() + second.stop() + + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderStale::class + ), + first.map { it::class } + ) + assertEquals(listOf(OpenFeatureProviderEvents.ProviderStale::class), second.map { it::class }) + } + + @Test + fun aCancelledSubscriberStopsReceivingEvents() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + val received = record(tracker) + advanceUntilIdle() + received.stop() + + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + advanceUntilIdle() + + assertEquals(listOf(OpenFeatureProviderEvents.ProviderReady::class), received.map { it::class }) + } + + @Test + fun theStatusIsCurrentByTheTimeASubscriberSeesTheEvent() = runTest { + val tracker = ProviderStatusTracker() + val seen = mutableListOf() + val job = launch { tracker.observe().collect { seen.add(tracker.status) } } + testScheduler.runCurrent() + + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + advanceUntilIdle() + job.cancelAndJoin() + + assertEquals(listOf(OpenFeatureStatus.Stale), seen) + } + + // MARK: reconciliation + + @Test + fun aReconciliationReportsReconcilingThenContextChanged() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + val received = record(tracker) + tracker.reconciling { } + advanceUntilIdle() + received.stop() + + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderReconciling::class, + OpenFeatureProviderEvents.ProviderContextChanged::class + ), + received.map { it::class } + ) + assertEquals(OpenFeatureStatus.Ready, tracker.status) + } + + @Test + fun aFailedReconciliationReportsAnErrorAndRethrows() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + val received = record(tracker) + var thrown: Throwable? = null + try { + tracker.reconciling { throw OpenFeatureError.GeneralError("reconcile failed") } + } catch (e: Throwable) { + thrown = e + } + advanceUntilIdle() + received.stop() + + assertIs(thrown) + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderReconciling::class, + OpenFeatureProviderEvents.ProviderError::class + ), + received.map { it::class } + ) + val status = assertIs(tracker.status) + assertEquals("reconcile failed", status.error.message) + } + + @Test + fun aBlockThatReportsItsOwnFailureIsNotReportedForTwice() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + val received = record(tracker) + try { + tracker.reconciling { + tracker.send( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(message = "reported by the provider") + ) + ) + throw OpenFeatureError.GeneralError("thrown as well") + } + } catch (_: Throwable) { + // Reported through the event stream; the throw is the provider's own to see. + } + advanceUntilIdle() + received.stop() + + val errors = received.filterIsInstance() + assertEquals(1, errors.size, "collected ${received.map { it::class.simpleName }}") + assertEquals("reported by the provider", errors.single().eventDetails?.message) + } + + @Test + fun overlappingReconciliationsReportReconcilingOnceAndTheLastOutcome() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + + val received = record(tracker) + val first = launch { + tracker.reconciling { + firstStarted.complete(Unit) + releaseFirst.await() + } + } + firstStarted.await() + val second = launch { tracker.reconciling { } } + runCurrent() + + // The second finished, but the first is still in flight, so no outcome is reported yet. + assertEquals(OpenFeatureStatus.Reconciling, tracker.status) + + releaseFirst.complete(Unit) + first.join() + second.join() + advanceUntilIdle() + received.stop() + + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderReconciling::class, + OpenFeatureProviderEvents.ProviderContextChanged::class + ), + received.map { it::class } + ) + } + + @Test + fun aReconciliationOnANotReadyProviderReportsNothing() = runTest { + val tracker = ProviderStatusTracker() + + val received = record(tracker) + tracker.reconciling { } + advanceUntilIdle() + received.stop() + + // Readiness is initialize's to report, so reconciling a context cannot confer it. + assertEquals(emptyList(), received.map { it::class.simpleName }) + assertEquals(OpenFeatureStatus.NotReady, tracker.status) + } + + @Test + fun aFailedReconciliationOnANotReadyProviderReportsNothingAndStillThrows() = runTest { + val tracker = ProviderStatusTracker() + + val received = record(tracker) + var thrown: Throwable? = null + try { + tracker.reconciling { throw OpenFeatureError.GeneralError("reconcile failed") } + } catch (e: Throwable) { + thrown = e + } + advanceUntilIdle() + received.stop() + + assertIs(thrown) + assertEquals(emptyList(), received.map { it::class.simpleName }) + assertEquals(OpenFeatureStatus.NotReady, tracker.status) + } + + @Test + fun aNotReadyProviderThatReportsFromInsideTheBlockIsBelieved() = runTest { + val tracker = ProviderStatusTracker() + + val received = record(tracker) + // The gate suppresses what the SDK would synthesise, not the provider's own voice. + tracker.reconciling { tracker.send(OpenFeatureProviderEvents.ProviderReady()) } + advanceUntilIdle() + received.stop() + + assertEquals( + listOf(OpenFeatureProviderEvents.ProviderReady::class), + received.map { it::class } + ) + assertEquals(OpenFeatureStatus.Ready, tracker.status) + } + + @Test + fun aReconciliationAfterANotReadyOneStillResolves() = runTest { + val tracker = ProviderStatusTracker() + // Reports nothing, but must still count itself out: a stranded counter leaves every later + // reconciliation reading the stale not-ready restore point. + tracker.reconciling { } + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + try { + tracker.reconciling { throw OpenFeatureError.GeneralError("later reconcile failed") } + } catch (_: Throwable) { + // Reported through the event stream; the throw is the provider's own to see. + } + advanceUntilIdle() + + val status = assertIs(tracker.status) + assertEquals("later reconcile failed", status.error.message) + } + + @Test + fun aCancelledReconciliationOnANotReadyProviderLeavesItNotReady() = runTest { + val tracker = ProviderStatusTracker() + + val job = launch { tracker.reconciling { awaitCancellation() } } + runCurrent() + job.cancelAndJoin() + advanceUntilIdle() + + // Reconciling was never announced, so there is nothing to restore and nothing to get stuck on. + assertEquals(OpenFeatureStatus.NotReady, tracker.status) + } + + @Test + fun aReconciliationSupersededByResetCannotReportOverTheNextOne() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + val staleStarted = CompletableDeferred() + val releaseStale = CompletableDeferred() + val freshStarted = CompletableDeferred() + val releaseFresh = CompletableDeferred() + + val stale = launch { + tracker.reconciling { + staleStarted.complete(Unit) + releaseStale.await() + } + } + staleStarted.await() + tracker.reset() + assertEquals(OpenFeatureStatus.NotReady, tracker.status) + + // A reconciliation belonging to the generation that replaced the superseded one. + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + val fresh = launch { + runCatching { + tracker.reconciling { + freshStarted.complete(Unit) + releaseFresh.await() + throw OpenFeatureError.GeneralError("fresh reconciliation failed") + } + } + } + freshStarted.await() + + // The superseded invocation terminates while the fresh one is still in flight. Counting it + // out would make it look like the last in flight and hand it the fresh one's outcome. + releaseStale.complete(Unit) + stale.join() + releaseFresh.complete(Unit) + fresh.join() + advanceUntilIdle() + + val status = assertIs(tracker.status) + assertEquals("fresh reconciliation failed", status.error.message) + } + + @Test + fun aReconciliationCancelledThroughoutRestoresThePrecedingStatus() = runTest { + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + + val received = record(tracker) + val job = launch { tracker.reconciling { awaitCancellation() } } + runCurrent() + assertEquals(OpenFeatureStatus.Reconciling, tracker.status) + + job.cancelAndJoin() + advanceUntilIdle() + received.stop() + + assertEquals(OpenFeatureStatus.Stale, tracker.status) + assertTrue( + received.map { it::class }.containsAll( + listOf( + OpenFeatureProviderEvents.ProviderReconciling::class, + OpenFeatureProviderEvents.ProviderStale::class + ) + ), + "collected ${received.map { it::class.simpleName }}" + ) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/StatusTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/StatusTests.kt index 55326fcf..15a5986d 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/StatusTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/StatusTests.kt @@ -5,6 +5,7 @@ import dev.openfeature.kotlin.sdk.helpers.BrokenInitProvider import dev.openfeature.kotlin.sdk.helpers.DoSomethingProvider import dev.openfeature.kotlin.sdk.helpers.SlowProvider import dev.openfeature.kotlin.sdk.helpers.SpyProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation @@ -12,7 +13,6 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -81,6 +81,7 @@ class StatusTests { } @Test + @OptIn(ExperimentalCoroutinesApi::class) fun testProviderTransitionsToReconcilingOnContextSet() = runTest { waitAssert { assertEquals(OpenFeatureStatus.NotReady, OpenFeatureAPI.getStatus()) @@ -94,11 +95,17 @@ class StatusTests { OpenFeatureAPI.setProviderAndWait(DoSomethingProvider()) waitAssert { assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) } OpenFeatureAPI.setEvaluationContextAndWait(ImmutableContext("some value")) - waitAssert { assertEquals(OpenFeatureStatus.Reconciling, OpenFeatureAPI.getStatus()) } - waitAssert { - assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) - } + advanceUntilIdle() job.cancelAndJoin() + + // Reconciling is transient: it has already been superseded by the time the call returns, so + // it can only be observed in the collected sequence, not by polling getStatus(). + assertTrue( + statuses.contains(OpenFeatureStatus.Reconciling), + "expected a Reconciling transition, collected $statuses" + ) + assertEquals(OpenFeatureStatus.Ready, statuses.last()) + assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) } @Test @@ -148,11 +155,12 @@ class StatusTests { fun testCancelledContextSetFinishingLastUsesReplacementStatus() = runTest { val provider = CancellationRaceProvider() val dispatcher = StandardTestDispatcher(testScheduler) - OpenFeatureAPI.setProviderAndWait(provider) + // The registration's dispatcher is what runs its reconciliations, so virtual time needs it. + OpenFeatureAPI.setProviderAndWait(provider, dispatcher = dispatcher) - OpenFeatureAPI.setEvaluationContext(ImmutableContext("first"), dispatcher) + OpenFeatureAPI.setEvaluationContext(ImmutableContext("first")) provider.firstContextSetStarted.receive() - OpenFeatureAPI.setEvaluationContext(ImmutableContext("replacement"), dispatcher) + OpenFeatureAPI.setEvaluationContext(ImmutableContext("replacement")) provider.firstContextSetCancellationStarted.receive() provider.replacementContextSetCompleted.receive() runCurrent() @@ -172,9 +180,9 @@ class StatusTests { OpenFeatureAPI.setProviderAndWait(provider, dispatcher = dispatcher) runCurrent() - OpenFeatureAPI.setEvaluationContext(ImmutableContext("first"), dispatcher) + OpenFeatureAPI.setEvaluationContext(ImmutableContext("first")) provider.firstContextSetStarted.receive() - OpenFeatureAPI.setEvaluationContext(ImmutableContext("replacement"), dispatcher) + OpenFeatureAPI.setEvaluationContext(ImmutableContext("replacement")) provider.firstContextSetCancellationStarted.receive() provider.replacementContextSetCompleted.receive() runCurrent() @@ -296,15 +304,30 @@ class StatusTests { } private class ControllableContextProvider : NoOpProvider() { + private val statusTracker = ProviderStatusTracker() + val contextSetStarted = Channel(Channel.UNLIMITED) val allowContextSetToComplete = Channel(Channel.UNLIMITED) val contextSetCompleted = Channel(Channel.UNLIMITED) - override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + + override suspend fun initialize(initialContext: EvaluationContext?) { + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) + } + + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) = statusTracker.reconciling { contextSetStarted.send(Unit) allowContextSetToComplete.receive() contextSetCompleted.send(Unit) } + + override fun shutdown() = statusTracker.reset() } private class CancellationRaceProvider : NoOpProvider() { @@ -313,16 +336,27 @@ private class CancellationRaceProvider : NoOpProvider() { val allowFirstContextSetToFinish = Channel(Channel.UNLIMITED) val replacementContextSetCompleted = Channel(Channel.UNLIMITED) - private val events = MutableSharedFlow(extraBufferCapacity = 1) + private val statusTracker = ProviderStatusTracker() private var contextSetCalls = 0 - override fun observe(): Flow = events + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + + override suspend fun initialize(initialContext: EvaluationContext?) { + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) + } + + override fun shutdown() = statusTracker.reset() fun emitStale() { - events.tryEmit(OpenFeatureProviderEvents.ProviderStale()) + statusTracker.send(OpenFeatureProviderEvents.ProviderStale()) } - override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) = statusTracker.reconciling { contextSetCalls++ if (contextSetCalls == 1) { firstContextSetStarted.send(Unit) @@ -342,14 +376,18 @@ private class CancellationRaceProvider : NoOpProvider() { private fun Duration.Companion.randomMs(min: Int, max: Int): Duration = Random.nextInt(min, max + 1).milliseconds +/** Retries [function] until it passes, rethrowing its last failure once [timeoutMs] is exhausted. */ @OptIn(ExperimentalCoroutinesApi::class) suspend fun TestScope.waitAssert(timeoutMs: Long = 5000, function: () -> Unit) { var timeWaited = 0L - while (timeWaited < timeoutMs) { + while (true) { try { function() return + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { + if (timeWaited >= timeoutMs) throw e delay(10) timeWaited += 10 advanceUntilIdle() diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/AutoHealingProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/AutoHealingProvider.kt index db650146..fc472123 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/AutoHealingProvider.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/AutoHealingProvider.kt @@ -3,15 +3,17 @@ 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.OpenFeatureStatus import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.ProviderStatusTracker import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import kotlinx.atomicfu.atomic import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow class AutoHealingProvider( val healDelay: Long = 1000L, @@ -20,11 +22,20 @@ class AutoHealingProvider( override val metadata: ProviderMetadata = object : ProviderMetadata { override val name: String = "AutoHealingProvider" } - private var ready = false - private val _events = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + private val readyState = atomic(false) + private var ready: Boolean + get() = readyState.value + set(value) { readyState.value = value } + + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + override suspend fun initialize(initialContext: EvaluationContext?) { ready = false - _events.emit( + statusTracker.send( OpenFeatureProviderEvents.ProviderError( OpenFeatureProviderEvents.EventDetails( message = "AutoHealingProvider got an error. trying to heal", @@ -33,12 +44,13 @@ class AutoHealingProvider( ) ) delay(healDelay) - _events.emit(OpenFeatureProviderEvents.ProviderReady()) ready = true + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() { - // no-op + ready = false + statusTracker.reset() } override suspend fun onContextSet( @@ -101,8 +113,4 @@ class AutoHealingProvider( if (!ready) throw OpenFeatureError.FlagNotFoundError(key) return ProviderEvaluation(Value.Null) } - - override fun observe(): Flow { - return _events - } } \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/BrokenInitProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/BrokenInitProvider.kt index e1892416..2edb2743 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/BrokenInitProvider.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/BrokenInitProvider.kt @@ -3,23 +3,41 @@ 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.OpenFeatureStatus import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.ProviderStatusTracker import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError.FlagNotFoundError +import kotlinx.coroutines.flow.Flow class BrokenInitProvider( override var hooks: List> = listOf(), override var metadata: ProviderMetadata = AlwaysBrokenProviderMetadata() -) : - FeatureProvider { +) : FeatureProvider { + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + override suspend fun initialize(initialContext: EvaluationContext?) { - throw OpenFeatureError.ProviderNotReadyError("test error from $this") + val error = OpenFeatureError.ProviderNotReadyError("test error from $this") + statusTracker.send( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + message = error.message, + errorCode = error.errorCode() + ) + ) + ) + throw error } override fun shutdown() { - // no-op + statusTracker.reset() } override suspend fun onContextSet( diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/DoSomethingProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/DoSomethingProvider.kt index 113d49ab..f192c4de 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/DoSomethingProvider.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/DoSomethingProvider.kt @@ -4,20 +4,23 @@ import dev.openfeature.kotlin.sdk.EvaluationContext import dev.openfeature.kotlin.sdk.EvaluationMetadata 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.ProviderStatusTracker import dev.openfeature.kotlin.sdk.TrackingEventDetails import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow open class DoSomethingProvider( override val hooks: List> = listOf(), override val metadata: ProviderMetadata = DoSomethingProviderMetadata() ) : FeatureProvider { - protected val events = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) + protected val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status companion object { val evaluationMetadata = EvaluationMetadata.builder() .putString("key1", "value1") @@ -27,19 +30,19 @@ open class DoSomethingProvider( override suspend fun initialize(initialContext: EvaluationContext?) { delay(1000) - events.emit(OpenFeatureProviderEvents.ProviderReady()) + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() { - // no-op + statusTracker.reset() } override suspend fun onContextSet( oldContext: EvaluationContext?, newContext: EvaluationContext - ) { + ) = statusTracker.reconciling { delay(500) - events.emit(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + statusTracker.send(OpenFeatureProviderEvents.ProviderConfigurationChanged()) } override fun getBooleanEvaluation( @@ -95,9 +98,7 @@ open class DoSomethingProvider( class DoSomethingProviderMetadata(override val name: String? = "something") : ProviderMetadata - override fun observe(): Flow { - return events - } + override fun observe(): Flow = statusTracker.observe() } class OverlyEmittingProvider(name: String) : DoSomethingProvider( @@ -109,8 +110,8 @@ class OverlyEmittingProvider(name: String) : DoSomethingProvider( oldContext: EvaluationContext?, newContext: EvaluationContext ) { - events.emit(OpenFeatureProviderEvents.ProviderStale()) - events.emit(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + statusTracker.send(OpenFeatureProviderEvents.ProviderStale()) + statusTracker.send(OpenFeatureProviderEvents.ProviderConfigurationChanged()) } override fun track( @@ -119,8 +120,8 @@ class OverlyEmittingProvider(name: String) : DoSomethingProvider( details: TrackingEventDetails? ) { super.track(trackingEventName, context, details) - events.tryEmit(OpenFeatureProviderEvents.ProviderStale()) - events.tryEmit(OpenFeatureProviderEvents.ProviderStale()) - events.tryEmit(OpenFeatureProviderEvents.ProviderStale()) + statusTracker.send(OpenFeatureProviderEvents.ProviderStale()) + statusTracker.send(OpenFeatureProviderEvents.ProviderStale()) + statusTracker.send(OpenFeatureProviderEvents.ProviderStale()) } } \ No newline at end of file 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 202cc25f..44b218d2 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 @@ -3,14 +3,24 @@ 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.OpenFeatureStatus import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.ProviderStatusTracker import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import kotlinx.coroutines.flow.Flow class RecordingBooleanProvider( private val name: String, private val behavior: () -> ProviderEvaluation ) : FeatureProvider { + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + override val hooks: List> = emptyList() override val metadata: ProviderMetadata = object : ProviderMetadata { override val name: String? = this@RecordingBooleanProvider.name @@ -20,11 +30,11 @@ class RecordingBooleanProvider( private set override suspend fun initialize(initialContext: EvaluationContext?) { - // no-op + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() { - // no-op + statusTracker.reset() } override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SlowProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SlowProvider.kt index 6fb4ea5d..fb029350 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SlowProvider.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SlowProvider.kt @@ -3,36 +3,48 @@ 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.OpenFeatureStatus import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.ProviderStatusTracker import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow class SlowProvider( override val hooks: List> = listOf(), private var dispatcher: CoroutineDispatcher, override val metadata: ProviderMetadata = SlowProviderMetadata("Slow provider") ) : FeatureProvider { + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + internal var ready = false override suspend fun initialize(initialContext: EvaluationContext?) { CoroutineScope(dispatcher).async { delay(2000) }.await() ready = true + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() { - // no-op + ready = false + statusTracker.reset() } override suspend fun onContextSet( oldContext: EvaluationContext?, newContext: EvaluationContext - ) { + ) = statusTracker.reconciling { CoroutineScope(dispatcher).async { delay(2000) }.await() diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SpyProvider.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SpyProvider.kt index 288c97ca..d1c48f9e 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SpyProvider.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/helpers/SpyProvider.kt @@ -3,12 +3,22 @@ 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.OpenFeatureStatus import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.ProviderStatusTracker import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import kotlinx.atomicfu.atomic +import kotlinx.coroutines.flow.Flow class SpyProvider : FeatureProvider { + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + override val hooks: List> get() = TODO("Not yet implemented") override val metadata: ProviderMetadata @@ -19,17 +29,19 @@ class SpyProvider : FeatureProvider { val shutdownCalls = atomic(0) override suspend fun initialize(initialContext: EvaluationContext?) { + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) initializeCalls.add(initialContext) } override fun shutdown() { shutdownCalls.incrementAndGet() + statusTracker.reset() } override suspend fun onContextSet( oldContext: EvaluationContext?, newContext: EvaluationContext - ) { + ) = statusTracker.reconciling { onContextSetCalls.add(Pair(oldContext, newContext)) } 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 1dfcf586..1a08bd54 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 @@ -7,14 +7,16 @@ 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.ProviderStatusTracker import dev.openfeature.kotlin.sdk.TrackingEventDetails import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -174,7 +176,7 @@ class MultiProviderTests { advanceUntilIdle() // Final aggregate status should be ERROR (C ends in ERROR; beats READY and STALE) - val finalStatus = multi.statusFlow.value + val finalStatus = multi.status assertIs(finalStatus) initJob.cancelAndJoin() } @@ -205,7 +207,7 @@ class MultiProviderTests { val initJob = launch { multi.initialize(null) } advanceUntilIdle() - val finalStatus = multi.statusFlow.value + val finalStatus = multi.status val errStatus = assertIs(finalStatus) assertIs(errStatus.error) initJob.cancelAndJoin() @@ -245,7 +247,7 @@ class MultiProviderTests { val initJob = launch { multi.initialize(null) } advanceUntilIdle() - val finalStatus = multi.statusFlow.value + val finalStatus = multi.status assertIs(finalStatus) initJob.cancelAndJoin() } @@ -283,7 +285,7 @@ class MultiProviderTests { val initJob = launch { multi.initialize(null) } advanceUntilIdle() - val finalStatus = multi.statusFlow.value + val finalStatus = multi.status assertIs(finalStatus) initJob.cancelAndJoin() } @@ -294,8 +296,7 @@ class MultiProviderTests { name = "A", eventsToEmitOnInit = listOf( OpenFeatureProviderEvents.ProviderReady(), - OpenFeatureProviderEvents.ProviderReady(), - OpenFeatureProviderEvents.ProviderStale() + OpenFeatureProviderEvents.ProviderReady() ) ) val multi = MultiProvider(listOf(provider)) @@ -306,11 +307,16 @@ class MultiProviderTests { val initJob = launch { multi.initialize(null) } advanceUntilIdle() + // A later transition is reported, an unchanged aggregate is not. + provider.emit(OpenFeatureProviderEvents.ProviderStale()) + advanceUntilIdle() + provider.emit(OpenFeatureProviderEvents.ProviderStale()) + 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(), @@ -345,6 +351,196 @@ class MultiProviderTests { assertTrue(collected.all { it is OpenFeatureProviderEvents.ProviderConfigurationChanged }) } + @Test + fun aCancelledContextSetDoesNotLeaveTheAggregateReconciling() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf(OpenFeatureProviderEvents.ProviderReady()), + gateContextSet = true + ) + val multi = MultiProvider(listOf(provider)) + multi.initialize(null) + advanceUntilIdle() + assertEquals(OpenFeatureStatus.Ready, multi.status) + + val contextSet = launch { multi.onContextSet(null, ImmutableContext("ctx")) } + provider.contextSetStarted.receive() + assertEquals(OpenFeatureStatus.Reconciling, multi.status) + + contextSet.cancelAndJoin() + advanceUntilIdle() + + // The tracker restores the status that preceded the reconciliation rather than stranding it. + assertEquals(OpenFeatureStatus.Ready, multi.status) + } + + @Test + fun overlappingContextSetsReportReconcilingOnceAndOnlyTheLastOutcome() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf(OpenFeatureProviderEvents.ProviderReady()), + gateContextSet = true + ) + val multi = MultiProvider(listOf(provider)) + multi.initialize(null) + advanceUntilIdle() + + val collected = mutableListOf() + val collectJob = launch { multi.observe().collect { collected.add(it) } } + advanceUntilIdle() + collected.clear() + + val first = launch { multi.onContextSet(null, ImmutableContext("first")) } + provider.contextSetStarted.receive() + val second = launch { multi.onContextSet(null, ImmutableContext("second")) } + provider.contextSetStarted.receive() + + provider.allowContextSetToComplete.send(Unit) + first.join() + advanceUntilIdle() + // The first to terminate must not resolve a reconciliation the second is still running. + assertEquals(OpenFeatureStatus.Reconciling, multi.status) + + provider.allowContextSetToComplete.send(Unit) + second.join() + advanceUntilIdle() + collectJob.cancelAndJoin() + + assertEquals(OpenFeatureStatus.Ready, multi.status) + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReconciling::class, + OpenFeatureProviderEvents.ProviderContextChanged::class + ), + collected.map { it::class } + ) + } + + @Test + fun anErrorAggregateCarriesTheTriggeringChildDetails() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf(OpenFeatureProviderEvents.ProviderReady()) + ) + val multi = MultiProvider(listOf(provider)) + multi.initialize(null) + advanceUntilIdle() + + val collected = mutableListOf() + val collectJob = launch { multi.observe().collect { collected.add(it) } } + advanceUntilIdle() + collected.clear() + + provider.emit( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + flagsChanged = setOf("a", "b"), + message = "child failed", + eventMetadata = mapOf("origin" to "A") + ) + ) + ) + advanceUntilIdle() + collectJob.cancelAndJoin() + + val reported = assertIs(collected.single()) + assertEquals(setOf("a", "b"), reported.eventDetails?.flagsChanged) + assertEquals(mapOf("origin" to "A"), reported.eventDetails?.eventMetadata) + } + + @Test + fun aChildFailingToInitializeDoesNotStopItsSiblings() = runTest { + val failing = FakeEventProvider( + name = "failing", + initializeThrowable = OpenFeatureError.GeneralError("cannot start") + ) + val healthy = FakeEventProvider( + name = "healthy", + eventsToEmitOnInit = listOf(OpenFeatureProviderEvents.ProviderReady()) + ) + val multi = MultiProvider(listOf(failing, healthy)) + + multi.initialize(null) + advanceUntilIdle() + + assertEquals(1, failing.initializeCalls) + assertEquals(1, healthy.initializeCalls, "a failing sibling must not cancel this one") + // The failing child reported nothing, so it is still not-ready and outranks the healthy one. + assertEquals(OpenFeatureStatus.NotReady, multi.status) + } + + @Test + fun anAggregateReturningToNotReadyIsReported() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf(OpenFeatureProviderEvents.ProviderReady()) + ) + val multi = MultiProvider(listOf(provider)) + multi.initialize(null) + advanceUntilIdle() + assertEquals(OpenFeatureStatus.Ready, multi.status) + + // No event describes not-ready, so this is observable through the status, not observe(). + provider.shutdown() + provider.emit(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + advanceUntilIdle() + + assertEquals(OpenFeatureStatus.NotReady, multi.status) + } + + @Test + fun aChildReconcilingOnItsOwnAccountDoesNotLatchTheAggregate() = runTest { + val provider = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf(OpenFeatureProviderEvents.ProviderReady()) + ) + val multi = MultiProvider(listOf(provider)) + multi.initialize(null) + advanceUntilIdle() + + // Not driven by MultiProvider.onContextSet, so there is no reconciliation of its own to + // report the outcome: the aggregate has to follow the child back to ready. + provider.emit(OpenFeatureProviderEvents.ProviderReconciling()) + advanceUntilIdle() + assertEquals(OpenFeatureStatus.Reconciling, multi.status) + + provider.emit(OpenFeatureProviderEvents.ProviderContextChanged()) + advanceUntilIdle() + assertEquals(OpenFeatureStatus.Ready, multi.status) + } + + @Test + fun aChildCancellingItsOwnShutdownDoesNotStopTheOthers() { + // shutdown is not suspending, so a child's CancellationException is an ordinary failure. + val first = FakeEventProvider( + name = "first", + shutdownThrowable = CancellationException("child cancelled its own scope") + ) + val second = FakeEventProvider(name = "second") + + val multi = MultiProvider(listOf(first, second)) + val error = assertFailsWith { multi.shutdown() } + + assertEquals(1, first.shutdownCalls) + assertEquals(1, second.shutdownCalls, "a cancelling sibling must not stop this one") + assertTrue(error.message.contains("first: child cancelled its own scope"), error.message) + } + + @Test + fun aChildCancellingItsOwnTrackingDoesNotStopTheOthers() { + val first = FakeEventProvider( + name = "first", + trackThrowable = CancellationException("child cancelled its own scope") + ) + val second = FakeEventProvider(name = "second") + + val multi = MultiProvider(listOf(first, second)) + assertFailsWith { multi.track("event", null, null) } + + assertEquals(1, first.trackingCalls) + assertEquals(1, second.trackingCalls, "a cancelling sibling must not stop this one") + } + @Test fun shutdownAggregatesErrorsAndReportsProviderNames() { val ok = FakeEventProvider(name = "ok") @@ -423,14 +619,22 @@ private class FakeEventProvider( private val name: String?, private val eventsToEmitOnInit: List = emptyList(), private val shutdownThrowable: Throwable? = null, - private val trackThrowable: Throwable? = null + private val trackThrowable: Throwable? = null, + private val initializeThrowable: Throwable? = null, + private val gateContextSet: Boolean = false ) : FeatureProvider { + val contextSetStarted = Channel(Channel.UNLIMITED) + val allowContextSetToComplete = Channel(Channel.UNLIMITED) 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) + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() var initializeCalls: Int = 0 private set @@ -444,16 +648,24 @@ private class FakeEventProvider( override suspend fun initialize(initialContext: EvaluationContext?) { initializeCalls += 1 // Emit any preconfigured events during initialize so MultiProvider observers receive them - eventsToEmitOnInit.forEach { events.emit(it) } + eventsToEmitOnInit.forEach { statusTracker.send(it) } + initializeThrowable?.let { throw it } } + fun emit(event: OpenFeatureProviderEvents) = statusTracker.send(event) + override fun shutdown() { shutdownCalls += 1 + statusTracker.reset() shutdownThrowable?.let { throw it } } override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { onContextSetCalls += 1 + if (gateContextSet) { + contextSetStarted.send(Unit) + allowContextSetToComplete.receive() + } } override fun getBooleanEvaluation( @@ -504,8 +716,6 @@ private class FakeEventProvider( return ProviderEvaluation(defaultValue) } - override fun observe(): Flow = events - override fun track( trackingEventName: String, context: EvaluationContext?, diff --git a/kotlin-sdk/src/iosMain/kotlin/dev/openfeature/kotlin/sdk/logging/LoggerFactory.kt b/kotlin-sdk/src/iosMain/kotlin/dev/openfeature/kotlin/sdk/logging/LoggerFactory.kt index 6ff5e631..814a0860 100644 --- a/kotlin-sdk/src/iosMain/kotlin/dev/openfeature/kotlin/sdk/logging/LoggerFactory.kt +++ b/kotlin-sdk/src/iosMain/kotlin/dev/openfeature/kotlin/sdk/logging/LoggerFactory.kt @@ -1,6 +1,8 @@ package dev.openfeature.kotlin.sdk.logging import platform.Foundation.NSLog +import platform.Foundation.NSString +import platform.Foundation.create /** * iOS platform implementation of LoggerFactory. @@ -19,19 +21,22 @@ actual object LoggerFactory { internal class IosLogger(private val tag: String) : Logger { private fun prefix(level: String) = "[$level] $tag - " + /** Bridged through NSString: variadic NSLog reads the argument as a pointer and segfaults. */ + private fun log(line: String) = NSLog("%@", NSString.create(string = line)) + override fun debug(message: () -> String, attributes: () -> Map, throwable: Throwable?) { - NSLog("%@", formatLogLine(prefix("DEBUG") + message(), attributes(), throwable)) + log(formatLogLine(prefix("DEBUG") + message(), attributes(), throwable)) } override fun info(message: () -> String, attributes: () -> Map, throwable: Throwable?) { - NSLog("%@", formatLogLine(prefix("INFO") + message(), attributes(), throwable)) + log(formatLogLine(prefix("INFO") + message(), attributes(), throwable)) } override fun warn(message: () -> String, attributes: () -> Map, throwable: Throwable?) { - NSLog("%@", formatLogLine(prefix("WARN") + message(), attributes(), throwable)) + log(formatLogLine(prefix("WARN") + message(), attributes(), throwable)) } override fun error(message: () -> String, attributes: () -> Map, throwable: Throwable?) { - NSLog("%@", formatLogLine(prefix("ERROR") + message(), attributes(), throwable)) + log(formatLogLine(prefix("ERROR") + message(), attributes(), throwable)) } } \ No newline at end of file diff --git a/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderLifecycleOrderingTest.kt b/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderLifecycleOrderingTest.kt new file mode 100644 index 00000000..e09e3165 --- /dev/null +++ b/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderLifecycleOrderingTest.kt @@ -0,0 +1,63 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.isolated.ExperimentalIsolatedApi +import dev.openfeature.kotlin.sdk.isolated.createOpenFeatureAPIInstance +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val ITERATIONS = 300 + +/** + * Racing setProvider against a concurrent setEvaluationContext, which only JVM and native can do — + * JS is single-threaded. + */ +@OptIn(ExperimentalIsolatedApi::class) +class ProviderLifecycleOrderingTest { + + @AfterTest + fun tearDown() { + OpenFeatureAPIInstance.clearBoundProviders() + } + + private class OrderRecordingProvider(private val order: MutableList) : NoOpProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + order += "initialize" + super.initialize(initialContext) + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + order += "onContextSet" + } + } + + @Test + fun aConcurrentContextSetNeverEntersBeforeInitializeOnTheSameRegistration() = runBlocking { + repeat(ITERATIONS) { iteration -> + val instance = createOpenFeatureAPIInstance() + val order = CopyOnWriteArrayList() + val provider = OrderRecordingProvider(order) + + val setProviderJob = launch(Dispatchers.Default) { instance.setProvider(provider) } + val setContextJob = launch(Dispatchers.Default) { instance.setEvaluationContext(ImmutableContext()) } + setProviderJob.join() + setContextJob.join() + // Both calls only start work on the registration's own scope; give it time to run. + delay(20) + + val recorded = order.toList() + if (recorded.contains("onContextSet")) { + assertEquals( + "initialize", + recorded.first(), + "iteration $iteration observed onContextSet before initialize: $recorded" + ) + } + } + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderRetirementTest.kt b/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderRetirementTest.kt new file mode 100644 index 00000000..f0027876 --- /dev/null +++ b/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderRetirementTest.kt @@ -0,0 +1,106 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.isolated.ExperimentalIsolatedApi +import dev.openfeature.kotlin.sdk.isolated.createOpenFeatureAPIInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Retiring a replaced provider must not happen on the caller's thread, or registering a provider + * from a UI thread blocks on its predecessor's whole teardown. + * + * JVM-only because it needs to block a real thread, which common test code cannot portably do. + */ +@OptIn(ExperimentalIsolatedApi::class) +class ProviderRetirementTest { + + @AfterTest + fun tearDown() { + OpenFeatureAPIInstance.clearBoundProviders() + } + + private class BlockingShutdownProvider : NoOpProvider() { + val shutdownEntered = CountDownLatch(1) + val releaseShutdown = CountDownLatch(1) + + override fun shutdown() { + shutdownEntered.countDown() + releaseShutdown.await(30, TimeUnit.SECONDS) + super.shutdown() + } + } + + @Test + fun setProviderDoesNotBlockOnTheOutgoingProvidersShutdown() { + val instance = createOpenFeatureAPIInstance() + val outgoing = BlockingShutdownProvider() + runBlocking { instance.setProviderAndWait(outgoing) } + + val returned = CountDownLatch(1) + thread { + instance.setProvider(NoOpProvider()) + returned.countDown() + } + + try { + assertTrue( + returned.await(5, TimeUnit.SECONDS), + "setProvider blocked on the outgoing provider's shutdown" + ) + assertTrue( + outgoing.shutdownEntered.await(5, TimeUnit.SECONDS), + "the outgoing provider was never shut down" + ) + } finally { + outgoing.releaseShutdown.countDown() + } + } + + @Test + fun aProviderRegisteredAgainWhileItsRetirementIsPendingIsNotTornDown() { + val instance = createOpenFeatureAPIInstance() + val provider = BlockingShutdownProvider() + runBlocking { instance.setProviderAndWait(provider) } + + instance.setProvider(NoOpProvider()) + assertTrue( + provider.shutdownEntered.await(5, TimeUnit.SECONDS), + "the replaced provider was never retired" + ) + + // Registered again while its own teardown is still running: initializing over that would + // leave the live registration reporting the status its retirement reset. + instance.setProvider(provider) + provider.releaseShutdown.countDown() + + runBlocking { + withTimeout(5.seconds) { + instance.statusFlow.first { it == OpenFeatureStatus.Ready } + } + } + assertTrue(instance.getProvider() === provider) + assertEquals(OpenFeatureStatus.Ready, instance.getStatus()) + } + + @Test + fun setProviderAndWaitReportsTheOutgoingProviderDown() { + val instance = createOpenFeatureAPIInstance() + val outgoing = BlockingShutdownProvider() + outgoing.releaseShutdown.countDown() + runBlocking { instance.setProviderAndWait(outgoing) } + + // A suspending caller can be told the provider it replaced is actually down. + runBlocking { instance.setProviderAndWait(NoOpProvider()) } + + assertEquals(0, outgoing.shutdownEntered.count) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTrackerConcurrencyTest.kt b/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTrackerConcurrencyTest.kt new file mode 100644 index 00000000..21ef4fca --- /dev/null +++ b/kotlin-sdk/src/jvmTest/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTrackerConcurrencyTest.kt @@ -0,0 +1,144 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val ITERATIONS = 500 +private const val EVENTS_PER_ITERATION = 8 + +/** + * Racing subscribe against send, which only JVM and native can do — JS is single-threaded. + * + * A subscriber's slot is allocated before it reads the status to replay, so an event landing in + * between is both folded into the replay and buffered for delivery. These assertions pin that this + * never produces a duplicate or a reordering. + */ +class ProviderStatusTrackerConcurrencyTest { + + @Test + fun subscribingWhileEventsAreSentNeverDuplicatesOrReordersThem() = runBlocking { + repeat(ITERATIONS) { iteration -> + val tracker = ProviderStatusTracker() + tracker.send(errorNumbered(0)) + + val observed = mutableListOf() + val subscribed = CompletableDeferred() + val collector = launch(Dispatchers.Default) { + tracker.observe().collect { event -> + subscribed.complete(Unit) + observed.add(event.number()) + } + } + + // Deliberately not waiting for the subscription: the point is to land inside the window. + launch(Dispatchers.Default) { + for (number in 1..EVENTS_PER_ITERATION) tracker.send(errorNumbered(number)) + }.join() + + withTimeout(5_000) { subscribed.await() } + collector.cancel() + collector.join() + + // Strict monotonicity forbids both a replay that repeats a live event and any reordering. + val snapshot = observed.toList() + assertTrue( + snapshot.zipWithNext().all { (previous, next) -> previous < next }, + "iteration $iteration observed a duplicate or out-of-order sequence: $snapshot" + ) + } + } + + @Test + fun statusIsConsistentWithTheLastEventSentUnderConcurrentSenders() = runBlocking { + repeat(ITERATIONS) { + val tracker = ProviderStatusTracker() + val senders = (1..4).map { + launch(Dispatchers.Default) { + repeat(EVENTS_PER_ITERATION) { tracker.send(OpenFeatureProviderEvents.ProviderStale()) } + } + } + senders.forEach { it.join() } + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + assertEquals(OpenFeatureStatus.Ready, tracker.status) + } + } + + @Test + fun racingReconciliationsNeverLeaveTheTrackerReconciling() = runBlocking { + repeat(ITERATIONS) { iteration -> + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + // The mark, the registration and the reconciling report have to be one step, or one + // invocation mistakes the other's report for its own and neither reports the outcome. + (1..2).map { launch(Dispatchers.Default) { tracker.reconciling { } } }.forEach { it.join() } + + assertEquals( + OpenFeatureStatus.Ready, + tracker.status, + "iteration $iteration was left mid-reconciliation" + ) + } + } + + @Test + fun theReplayHandsOffToTheLiveStreamWithoutLoss() = runBlocking { + repeat(ITERATIONS) { iteration -> + val tracker = ProviderStatusTracker() + tracker.send(OpenFeatureProviderEvents.ProviderReady()) + + val observed = mutableListOf() + val replayed = CompletableDeferred() + val handedOff = CompletableDeferred() + val collector = launch(Dispatchers.Default) { + tracker.observe().collect { + observed.add(it) + replayed.complete(Unit) + if (observed.size == 3) handedOff.complete(Unit) + } + } + // Sent only once the replay has arrived, so the handover point is unambiguous. + withTimeout(5_000) { replayed.await() } + launch(Dispatchers.Default) { + tracker.send(OpenFeatureProviderEvents.ProviderStale()) + tracker.send(OpenFeatureProviderEvents.ProviderReconciling()) + }.join() + + // Signalled by the collector rather than polled: observed is the collector's alone until + // it has joined. + withTimeout(5_000) { handedOff.await() } + collector.cancel() + collector.join() + + assertEquals( + listOf( + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderReconciling::class + ), + observed.take(3).map { it::class }, + "iteration $iteration" + ) + } + } + + // The fence's third clause — an event carrying no status is never fenced out — has no sound race + // test: its window is not observable from outside the class, and an event sent a moment earlier is + // correctly never delivered, so the buggy and the legitimate reading are indistinguishable. It is + // covered by aStatelessEventIsDeliveredButNeverReplayed in ProviderStatusTrackerTests. + + private fun errorNumbered(number: Int) = OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(message = number.toString()) + ) + + private fun OpenFeatureProviderEvents.number(): Int = + requireNotNull(eventDetails?.message) { "event carried no number" }.toInt() +} \ No newline at end of file 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 1f2c423a..632387a1 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,7 +1,9 @@ package dev.openfeature.kotlin.sdk.sampleapp import dev.openfeature.kotlin.sdk.* +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow class ExampleProvider(override val hooks: List> = listOf()) : FeatureProvider { @@ -25,20 +27,27 @@ class ExampleProvider(override val hooks: List> = listOf()) : FeaturePro override val name: String = "ExampleProvider" } + private val statusTracker = ProviderStatusTracker() + + override val status: OpenFeatureStatus get() = statusTracker.status + + override fun observe(): Flow = statusTracker.observe() + override suspend fun initialize(initialContext: EvaluationContext?) { currentContext = initialContext // Simulate a delay in the provider initialization delay(delayTime) + statusTracker.send(OpenFeatureProviderEvents.ProviderReady()) } override fun shutdown() { - + statusTracker.reset() } override suspend fun onContextSet( oldContext: EvaluationContext?, newContext: EvaluationContext - ) { + ) = statusTracker.reconciling { currentContext = newContext delay(delayTime) }