diff --git a/kotlin-sdk/api/android/kotlin-sdk.api b/kotlin-sdk/api/android/kotlin-sdk.api index c1a10a87..14b0ef04 100644 --- a/kotlin-sdk/api/android/kotlin-sdk.api +++ b/kotlin-sdk/api/android/kotlin-sdk.api @@ -20,9 +20,20 @@ public abstract interface class dev/openfeature/kotlin/sdk/Client : dev/openfeat public abstract fun getHooks ()Ljava/util/List; public abstract fun getMetadata ()Ldev/openfeature/kotlin/sdk/ClientMetadata; public abstract fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public abstract fun observeEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract 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/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V + public abstract fun setProviderAndWait (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun setProviderAndWait$default (Ldev/openfeature/kotlin/sdk/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class dev/openfeature/kotlin/sdk/Client$DefaultImpls { + public static synthetic fun setProvider$default (Ldev/openfeature/kotlin/sdk/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V + public static synthetic fun setProviderAndWait$default (Ldev/openfeature/kotlin/sdk/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public abstract interface class dev/openfeature/kotlin/sdk/ClientMetadata { + public abstract fun getDomain ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; } @@ -219,6 +230,10 @@ public final class dev/openfeature/kotlin/sdk/ImmutableContext : dev/openfeature public fun withTargetingKey (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/ImmutableContext; } +public final class dev/openfeature/kotlin/sdk/ImmutableContextKt { + public static final fun mergeWith (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/EvaluationContext; +} + public final class dev/openfeature/kotlin/sdk/ImmutableStructure : dev/openfeature/kotlin/sdk/Structure { public fun ()V public fun (Ljava/util/Map;)V @@ -265,20 +280,37 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata public final class dev/openfeature/kotlin/sdk/OpenFeatureAPI { public static final field INSTANCE Ldev/openfeature/kotlin/sdk/OpenFeatureAPI; public final fun addHooks (Ljava/util/List;)V + public final fun clearEvaluationContext (Ljava/lang/String;Lkotlinx/coroutines/CoroutineDispatcher;)V + public final fun clearEvaluationContext (Lkotlinx/coroutines/CoroutineDispatcher;)V + public static synthetic fun clearEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ljava/lang/String;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public static synthetic fun clearEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public final fun clearEvaluationContextAndWait (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun clearEvaluationContextAndWait (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun clearHooks ()V public final fun clearProvider (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getClient (Ljava/lang/String;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/Client; public static synthetic fun getClient$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/Client; public final fun getEvaluationContext ()Ldev/openfeature/kotlin/sdk/EvaluationContext; + public final fun getEvaluationContext (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/EvaluationContext; + public final fun getEventsFlowForDomain (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; public final fun getHooks ()Ljava/util/List; public final fun getProvider ()Ldev/openfeature/kotlin/sdk/FeatureProvider; + public final fun getProvider (Ljava/lang/String;)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 getProviderMetadata (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public final fun getProviderStatus (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; + public final fun getProviderStatusFlow (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public final fun getProvidersFlow ()Lkotlinx/coroutines/flow/Flow; + public final fun getProvidersFlowForDomain (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public final fun getRepository ()Ldev/openfeature/kotlin/sdk/ProviderRepository; 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 final fun setEvaluationContext (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;)V public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V public final fun setEvaluationContextAndWait (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setEvaluationContextAndWait (Ljava/lang/String;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/OpenFeatureAPI;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V public final fun setProviderAndWait (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -313,15 +345,21 @@ public final class dev/openfeature/kotlin/sdk/OpenFeatureClient : dev/openfeatur public fun getStringDetails (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails; public fun getStringValue (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; public fun getStringValue (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ljava/lang/String; + public fun observeEvents ()Lkotlinx/coroutines/flow/Flow; + public fun setProvider (Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;)V + public fun setProviderAndWait (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V } public final class dev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata : dev/openfeature/kotlin/sdk/ClientMetadata { - public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; - public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; public fun equals (Ljava/lang/Object;)Z + public fun getDomain ()Ljava/lang/String; public fun getName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -672,6 +710,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;Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError;)V @@ -709,6 +760,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 diff --git a/kotlin-sdk/api/jvm/kotlin-sdk.api b/kotlin-sdk/api/jvm/kotlin-sdk.api index c1a10a87..14b0ef04 100644 --- a/kotlin-sdk/api/jvm/kotlin-sdk.api +++ b/kotlin-sdk/api/jvm/kotlin-sdk.api @@ -20,9 +20,20 @@ public abstract interface class dev/openfeature/kotlin/sdk/Client : dev/openfeat public abstract fun getHooks ()Ljava/util/List; public abstract fun getMetadata ()Ldev/openfeature/kotlin/sdk/ClientMetadata; public abstract fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public abstract fun observeEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract 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/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V + public abstract fun setProviderAndWait (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun setProviderAndWait$default (Ldev/openfeature/kotlin/sdk/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class dev/openfeature/kotlin/sdk/Client$DefaultImpls { + public static synthetic fun setProvider$default (Ldev/openfeature/kotlin/sdk/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V + public static synthetic fun setProviderAndWait$default (Ldev/openfeature/kotlin/sdk/Client;Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public abstract interface class dev/openfeature/kotlin/sdk/ClientMetadata { + public abstract fun getDomain ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; } @@ -219,6 +230,10 @@ public final class dev/openfeature/kotlin/sdk/ImmutableContext : dev/openfeature public fun withTargetingKey (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/ImmutableContext; } +public final class dev/openfeature/kotlin/sdk/ImmutableContextKt { + public static final fun mergeWith (Ldev/openfeature/kotlin/sdk/EvaluationContext;Ldev/openfeature/kotlin/sdk/EvaluationContext;)Ldev/openfeature/kotlin/sdk/EvaluationContext; +} + public final class dev/openfeature/kotlin/sdk/ImmutableStructure : dev/openfeature/kotlin/sdk/Structure { public fun ()V public fun (Ljava/util/Map;)V @@ -265,20 +280,37 @@ public final class dev/openfeature/kotlin/sdk/NoOpProvider$NoOpProviderMetadata public final class dev/openfeature/kotlin/sdk/OpenFeatureAPI { public static final field INSTANCE Ldev/openfeature/kotlin/sdk/OpenFeatureAPI; public final fun addHooks (Ljava/util/List;)V + public final fun clearEvaluationContext (Ljava/lang/String;Lkotlinx/coroutines/CoroutineDispatcher;)V + public final fun clearEvaluationContext (Lkotlinx/coroutines/CoroutineDispatcher;)V + public static synthetic fun clearEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ljava/lang/String;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public static synthetic fun clearEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public final fun clearEvaluationContextAndWait (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun clearEvaluationContextAndWait (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun clearHooks ()V public final fun clearProvider (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getClient (Ljava/lang/String;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/Client; public static synthetic fun getClient$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/Client; public final fun getEvaluationContext ()Ldev/openfeature/kotlin/sdk/EvaluationContext; + public final fun getEvaluationContext (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/EvaluationContext; + public final fun getEventsFlowForDomain (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; public final fun getHooks ()Ljava/util/List; public final fun getProvider ()Ldev/openfeature/kotlin/sdk/FeatureProvider; + public final fun getProvider (Ljava/lang/String;)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 getProviderMetadata (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/ProviderMetadata; + public final fun getProviderStatus (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; + public final fun getProviderStatusFlow (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public final fun getProvidersFlow ()Lkotlinx/coroutines/flow/Flow; + public final fun getProvidersFlowForDomain (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public final fun getRepository ()Ldev/openfeature/kotlin/sdk/ProviderRepository; 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 final fun setEvaluationContext (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;)V public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V + public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPI;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V public final fun setEvaluationContextAndWait (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setEvaluationContextAndWait (Ljava/lang/String;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/OpenFeatureAPI;Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;ILjava/lang/Object;)V public final fun setProviderAndWait (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -313,15 +345,21 @@ public final class dev/openfeature/kotlin/sdk/OpenFeatureClient : dev/openfeatur public fun getStringDetails (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails; public fun getStringValue (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; public fun getStringValue (Ljava/lang/String;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ljava/lang/String; + public fun observeEvents ()Lkotlinx/coroutines/flow/Flow; + public fun setProvider (Ldev/openfeature/kotlin/sdk/FeatureProvider;Lkotlinx/coroutines/CoroutineDispatcher;Ldev/openfeature/kotlin/sdk/EvaluationContext;)V + public fun setProviderAndWait (Ldev/openfeature/kotlin/sdk/FeatureProvider;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun track (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/TrackingEventDetails;)V } public final class dev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata : dev/openfeature/kotlin/sdk/ClientMetadata { - public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; - public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/OpenFeatureClient$Metadata; public fun equals (Ljava/lang/Object;)Z + public fun getDomain ()Ljava/lang/String; public fun getName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -672,6 +710,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;Ldev/openfeature/kotlin/sdk/exceptions/OpenFeatureError;)V @@ -709,6 +760,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 diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/Client.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/Client.kt index 13aefdf2..bed5aa9a 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/Client.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/Client.kt @@ -1,6 +1,8 @@ package dev.openfeature.kotlin.sdk +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance interface Client : Features, Tracking { val metadata: ClientMetadata @@ -8,4 +10,39 @@ interface Client : Features, Tracking { val statusFlow: Flow fun addHooks(hooks: List>) -} \ No newline at end of file + + /** + * Provide a flow of Provider events, respecting the dynamic binding of this client via its domain. + */ + fun observeEvents(): Flow + + /** + * Set the [FeatureProvider] for this client's domain. This method will return immediately and initialize the provider in a coroutine scope. + * @param provider the provider to set + * @param dispatcher the dispatcher to use for the provider initialization coroutine + * @param initialContext the initial [EvaluationContext] to use for the provider initialization + */ + fun setProvider( + provider: FeatureProvider, + dispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, + initialContext: EvaluationContext? = null + ) + + /** + * Set the [FeatureProvider] for this client's domain. This method will block until the provider is initialized. + * @param provider the provider to set + * @param initialContext the initial [EvaluationContext] to use for the provider initialization + * @param dispatcher the dispatcher to use for the provider initialization coroutine + */ + suspend fun setProviderAndWait( + provider: FeatureProvider, + initialContext: EvaluationContext? = null, + dispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default + ) +} + +/** + * Observe a specific event type from the provider associated with this client's domain. + */ +inline fun Client.observe(): Flow = + observeEvents().filterIsInstance() \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ClientMetadata.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ClientMetadata.kt index b45a9b07..df49c121 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ClientMetadata.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ClientMetadata.kt @@ -1,5 +1,8 @@ package dev.openfeature.kotlin.sdk interface ClientMetadata { + @Deprecated("Use domain instead", ReplaceWith("domain")) val name: String? + + val domain: String? } \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ImmutableContext.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ImmutableContext.kt index 7d770099..d97e49e4 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ImmutableContext.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ImmutableContext.kt @@ -46,4 +46,15 @@ class ImmutableContext( return true } +} + +/** + * Merges this EvaluationContext with a higher-precedence EvaluationContext. + * Keys in the higher-precedence context will overwrite keys in this context. + */ +fun EvaluationContext.mergeWith(higher: EvaluationContext): EvaluationContext { + val merged = asMap().toMutableMap() + merged.putAll(higher.asMap()) + val newTargetingKey = higher.getTargetingKey().ifEmpty { getTargetingKey() } + return ImmutableContext(newTargetingKey, merged) } \ No newline at end of file diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPI.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPI.kt index f21bf145..a46c71c2 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPI.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPI.kt @@ -1,7 +1,6 @@ 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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -11,9 +10,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob 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 @@ -23,126 +19,275 @@ import kotlinx.coroutines.sync.withLock @Suppress("TooManyFunctions") object OpenFeatureAPI { - private var setProviderJob: Job? = null - private var setEvaluationContextJob: Job? = null - private var observeProviderEventsJob: Job? = null - private val providerMutex = Mutex() - private val NOOP_PROVIDER = NoOpProvider() - private var provider: FeatureProvider = NOOP_PROVIDER + @PublishedApi + internal val repository = ProviderRepository() + private val globalContextMutex = Mutex() + private val jobMutex = Mutex() private var context: EvaluationContext? = null - val providersFlow: MutableStateFlow = MutableStateFlow(NOOP_PROVIDER) - private val _statusFlow: MutableSharedFlow = - MutableSharedFlow(replay = 1, extraBufferCapacity = 5) - .apply { - tryEmit(OpenFeatureStatus.NotReady) - } + @OptIn(ExperimentalCoroutinesApi::class) + val providersFlow: Flow get() = repository.getStateFlow(null) + .flatMapLatest { it.providersFlow }.distinctUntilChanged() - val statusFlow: Flow get() = _statusFlow.distinctUntilChanged() + @OptIn(ExperimentalCoroutinesApi::class) + val statusFlow: Flow get() = repository.getStateFlow(null) + .flatMapLatest { it.statusFlow }.distinctUntilChanged() var hooks: List> = listOf() private set /** * Set the [FeatureProvider] for the SDK. This method will return immediately and initialize the provider in a coroutine scope - * When the provider is successfully initialized it will set the status to Ready. - * If the provider fails to initialize it will set the status to Error. + * @param provider the provider to set + * @param dispatcher the dispatcher to use for the provider initialization coroutine + * @param initialContext the initial [EvaluationContext] to use for the provider initialization * - * This method requires you to manually wait for the status to be Ready before using the SDK for flag evaluations. - * This can be done by using the [statusFlow] and waiting for the first Ready status or by accessing [getStatus] + * When the provider successfully reconciles it will set the status to [OpenFeatureStatus.Ready]. + * If the provider fails to reconcile it will set the status to [OpenFeatureStatus.Error]. * - * @param provider the provider to set - * @param dispatcher the dispatcher to use for the provider initialization coroutine. Defaults to [Dispatchers.Default] if not set. - * @param initialContext the initial [EvaluationContext] to use for the provider initialization. Defaults to an null context if not set. + * This method requires you to manually wait for the status to be Ready before using the SDK for flag evaluations. + * This can be done by using the [statusFlow] and waiting for the first Ready status or by accessing [getStatus]. */ fun setProvider( provider: FeatureProvider, 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) + launchProviderJob(null, provider, dispatcher, initialContext, isGlobalContext = true) + } + + /** + * Set the [FeatureProvider] for a specific domain. + */ + internal fun setProvider( + domain: String?, + provider: FeatureProvider, + dispatcher: CoroutineDispatcher = Dispatchers.Default, + initialContext: EvaluationContext? = null + ) { + launchProviderJob(domain, provider, dispatcher, initialContext, isGlobalContext = false) + } + + private fun launchProviderJob( + domain: String?, + provider: FeatureProvider, + dispatcher: CoroutineDispatcher, + initialContext: EvaluationContext?, + isGlobalContext: Boolean + ) { + CoroutineScope(SupervisorJob() + dispatcher).launch { + val state = repository.getOrCreateState(domain) + state.jobMutex.withLock { + state.setProviderJob?.cancel( + CancellationException("Provider set job was cancelled due to new provider") + ) + state.setProviderJob = coroutineContext[Job] + } + setProviderInternal(state, provider, dispatcher, initialContext, isGlobalContext) } } /** * Set the [FeatureProvider] for the SDK. This method will block until the provider is initialized. - * - * @param provider the [FeatureProvider] to set - * @param initialContext the initial [EvaluationContext] to use for the provider initialization. Defaults to an null context if not set. */ suspend fun setProviderAndWait( provider: FeatureProvider, initialContext: EvaluationContext? = null, dispatcher: CoroutineDispatcher = Dispatchers.Default ) { - setProviderInternal(provider, dispatcher, initialContext) + setProviderInternal( + repository.getOrCreateState(null), + provider, + dispatcher, + initialContext, + isGlobalContext = true + ) } - 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) - } + /** + * Set the [FeatureProvider] for a specific domain. This method will block until the provider is initialized. + */ + internal suspend fun setProviderAndWait( + domain: String?, + provider: FeatureProvider, + initialContext: EvaluationContext? = null, + dispatcher: CoroutineDispatcher = Dispatchers.Default + ) { + setProviderInternal( + repository.getOrCreateState(domain), + provider, + dispatcher, + initialContext, + isGlobalContext = false + ) } @OptIn(ExperimentalCoroutinesApi::class) private suspend fun setProviderInternal( + state: DomainState, provider: FeatureProvider, dispatcher: CoroutineDispatcher, - initialContext: EvaluationContext? = null + initialContext: EvaluationContext? = null, + isGlobalContext: Boolean = false ) { + repository.attachProvider(provider) + // Atomically swap the old and new provider to prevent race conditions - val oldProvider = providerMutex.withLock { - val current = this@OpenFeatureAPI.provider - this@OpenFeatureAPI.provider = provider - providersFlow.value = provider - if (initialContext != null) context = initialContext + val oldProvider = state.providerMutex.withLock { + val current = state.provider + state.providersFlow.value = provider + state.emitStatus(OpenFeatureStatus.NotReady) current } - // Emit NotReady status after swapping provider - _statusFlow.emit(OpenFeatureStatus.NotReady) + // Shutdown the previous provider isolated from stream errors + if (repository.detachProvider(oldProvider)) { + try { + oldProvider.shutdown() + } catch (e: Exception) { + // Ignore termination exceptions from dead configurations natively securely + } + } + + if (initialContext != null) { + updateContextOnProviderSet(state, initialContext, isGlobalContext, dispatcher) + } + + initializeProvider(state, provider, dispatcher) + } + + private suspend fun updateContextOnProviderSet( + state: DomainState, + initialContext: EvaluationContext, + isGlobalContext: Boolean, + dispatcher: CoroutineDispatcher + ) { + if (!isGlobalContext) { + val globalCtx = globalContextMutex.withLock { context } + state.contextMutex.withLock { + state.context = initialContext + state.mergedContext = globalCtx?.mergeWith(initialContext) ?: initialContext + } + return + } + + val states = globalContextMutex.withLock { + context = initialContext + repository.getAllStates() + } - // Shutdown the previous provider outside the mutex - tryWithStatusEmitErrorHandling { - oldProvider.shutdown() + states.forEach { s -> + if (s === state) { + s.contextMutex.withLock { + s.mergedContext = s.context?.let { initialContext.mergeWith(it) } ?: initialContext + } + } else { + s.jobMutex.withLock { + s.setEvaluationContextJob?.cancel( + CancellationException("Set context job cancelled by global provider") + ) + s.setEvaluationContextJob = CoroutineScope(SupervisorJob() + dispatcher).launch { + applyContextToState(s, initialContext) + } + } + } } + } + + private suspend fun initializeProvider( + state: DomainState, + provider: FeatureProvider, + dispatcher: CoroutineDispatcher + ) { + state.initializeListener(dispatcher) + state.ioMutex.withLock { + tryWithStatusEmitErrorHandling(state) { + val globalCtx = globalContextMutex.withLock { context } + val resolvedContext = state.contextMutex.withLock { state.mergedContext } ?: globalCtx - // Initialize the new provider - tryWithStatusEmitErrorHandling { - listenToProviderEvents(provider, dispatcher) - getProvider().initialize(context) - _statusFlow.emit(OpenFeatureStatus.Ready) + if (provider !is NoOpProvider) { + val initMutex = repository.getInitMutex(provider) + initMutex.withLock { + if (!repository.isInitialized(provider)) { + provider.initialize(resolvedContext) + repository.markInitialized(provider) + repository.updateGlobalProviderStatus(provider, OpenFeatureStatus.Ready) + state.emitStatus(OpenFeatureStatus.Ready) + state.emitEvent(OpenFeatureProviderEvents.ProviderReady()) + } else { + val currentStatus = repository.getGlobalProviderStatus(provider) ?: OpenFeatureStatus.Ready + state.emitStatus(currentStatus) + when (currentStatus) { + is OpenFeatureStatus.Ready -> state.emitEvent(OpenFeatureProviderEvents.ProviderReady()) + is OpenFeatureStatus.Error -> state.emitEvent( + OpenFeatureProviderEvents.ProviderError( + dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents.EventDetails( + message = "Provider in error state" + ) + ) + ) + is OpenFeatureStatus.Stale -> state.emitEvent(OpenFeatureProviderEvents.ProviderStale()) + else -> {} + } + } + } + } else { + provider.initialize(resolvedContext) + state.emitStatus(OpenFeatureStatus.Ready) + state.emitEvent(OpenFeatureProviderEvents.ProviderReady()) + } + } } } + private suspend fun applyContextToState( + state: DomainState, + globalCtx: EvaluationContext?, + applyDomainCtx: ((EvaluationContext?) -> EvaluationContext?)? = null + ) { + val (oldMerged, newMerged) = state.contextMutex.withLock { + val oldMerged = state.mergedContext + + applyDomainCtx?.let { handler -> + state.context = handler(state.context) + } + + state.mergedContext = if (globalCtx != null && state.context != null) { + globalCtx.mergeWith(state.context!!) + } else { + globalCtx ?: state.context + } + + oldMerged to (state.mergedContext ?: ImmutableContext()) + } + + setEvaluationContextForState(state, oldMerged, newMerged) + } + /** - * Get the current [FeatureProvider] for the SDK. + * Get the current default [FeatureProvider] for the SDK. */ fun getProvider(): FeatureProvider { - return provider + return repository.getState().provider } /** - * Clear the current [FeatureProvider] for the SDK and set it to a no-op provider. + * Get the [FeatureProvider] for the specified domain. + */ + fun getProvider(domain: String?): FeatureProvider { + return repository.getState(domain).provider + } + + /** + * Clear all current [FeatureProvider]s for the SDK and set it to a no-op provider. */ suspend fun clearProvider() { - getProvider().shutdown() - provider = NOOP_PROVIDER - providersFlow.value = NOOP_PROVIDER - _statusFlow.emit(OpenFeatureStatus.NotReady) + repository.clearAll() } /** - * Set the [EvaluationContext] for the SDK. This method will block until the context is set and the provider is ready. - * - * If the new context is different compare to the old context, this will cause the provider to reconcile with the new context. - * When the provider "Reconciles" it will set the status to [OpenFeatureStatus.Reconciling]. - * When the provider successfully reconciles it will set the status to [OpenFeatureStatus.Ready]. - * If the provider fails to reconcile it will set the status to [OpenFeatureStatus.Error]. + * Set the [EvaluationContext] for the SDK. This method will block until the context is set and the providers form all domains are ready. * * @param evaluationContext the [EvaluationContext] to set */ @@ -151,16 +296,39 @@ object OpenFeatureAPI { } /** - * Set the [EvaluationContext] for the SDK. This method will return immediately and set the context in a coroutine scope. + * Set the [EvaluationContext] for a specific domain. This method will block until the context is set and the provider is ready. * - * If the new context is different compare to the old context, this will cause the provider to reconcile with the new context. - * When the provider "Reconciles" it will set the status to [OpenFeatureStatus.Reconciling]. - * When the provider successfully reconciles it will set the status to [OpenFeatureStatus.Ready]. - * If the provider fails to reconcile it will set the status to [OpenFeatureStatus.Error]. - * - * This method requires you to manually wait for the status to be Ready before using the SDK for flag evaluations. - * This can be done by using the [statusFlow] and waiting for the first Ready status or by accessing [getStatus] + * @param domain the domain + * @param evaluationContext the [EvaluationContext] to set + */ + suspend fun setEvaluationContextAndWait(domain: String?, evaluationContext: EvaluationContext) { + if (domain == null) { + setEvaluationContextAndWait(evaluationContext) + return + } + setEvaluationContextInternal(domain, evaluationContext) + } + + /** + * Clear the [EvaluationContext] for the SDK. This method will block until the context is cleared and the providers from all domains are ready. + */ + suspend fun clearEvaluationContextAndWait() { + clearEvaluationContextInternal(null) + } + + /** + * Clear the [EvaluationContext] for a specific domain. This method will block until the context is cleared and the provider is ready. * + * @param domain the domain + */ + suspend fun clearEvaluationContextAndWait(domain: String?) { + clearEvaluationContextInternal(domain) + } + + private var setEvaluationContextJob: Job? = null + + /** + * Set the [EvaluationContext] for the SDK. This method will return immediately and set the context in a coroutine scope. * * @param evaluationContext the [EvaluationContext] to set */ @@ -168,36 +336,167 @@ object OpenFeatureAPI { evaluationContext: EvaluationContext, dispatcher: CoroutineDispatcher = Dispatchers.Default ) { - setEvaluationContextJob?.cancel(CancellationException("Set context job was cancelled due to new context")) - this.setEvaluationContextJob = CoroutineScope(SupervisorJob() + dispatcher).launch { + launchEvaluationContextJob(null, dispatcher, "Set context job was cancelled due to new context") { setEvaluationContextInternal(evaluationContext) } } + /** + * Set the [EvaluationContext] for a specific domain. This method will return immediately and set the context in a coroutine scope. + * + * @param domain the domain + * @param evaluationContext the [EvaluationContext] to set + */ + fun setEvaluationContext( + domain: String?, + evaluationContext: EvaluationContext, + dispatcher: CoroutineDispatcher = Dispatchers.Default + ) { + launchEvaluationContextJob(domain, dispatcher, "Set context job was cancelled due to new context") { + if (domain == null) { + setEvaluationContextInternal(evaluationContext) + } else { + setEvaluationContextInternal(domain, evaluationContext) + } + } + } + + /** + * Clear the [EvaluationContext] for the SDK. This method will return immediately and clear the context in a coroutine scope. + */ + fun clearEvaluationContext( + dispatcher: CoroutineDispatcher = Dispatchers.Default + ) { + launchEvaluationContextJob(null, dispatcher, "Clear context job was cancelled due to new context") { + clearEvaluationContextInternal(null) + } + } + + /** + * Clear the [EvaluationContext] for a specific domain. This method will return immediately and clear the context in a coroutine scope. + * + * @param domain the domain + */ + fun clearEvaluationContext( + domain: String?, + dispatcher: CoroutineDispatcher = Dispatchers.Default + ) { + launchEvaluationContextJob(domain, dispatcher, "Clear context job was cancelled due to new context") { + clearEvaluationContextInternal(domain) + } + } + + private fun launchEvaluationContextJob( + domain: String?, + dispatcher: CoroutineDispatcher, + cancellationMessage: String, + block: suspend () -> Unit + ) { + CoroutineScope(SupervisorJob() + dispatcher).launch { + if (domain == null) { + jobMutex.withLock { + setEvaluationContextJob?.cancel(CancellationException(cancellationMessage)) + setEvaluationContextJob = coroutineContext[Job] + } + } else { + val state = repository.getOrCreateState(domain) + state.jobMutex.withLock { + state.setEvaluationContextJob?.cancel(CancellationException(cancellationMessage)) + state.setEvaluationContextJob = coroutineContext[Job] + } + } + block() + } + } + private suspend fun setEvaluationContextInternal(evaluationContext: EvaluationContext) { - val oldContext = context - context = evaluationContext - if (oldContext != evaluationContext) { - _statusFlow.emit(OpenFeatureStatus.Reconciling) - tryWithStatusEmitErrorHandling { - getProvider().onContextSet(oldContext, evaluationContext) - _statusFlow.emit(OpenFeatureStatus.Ready) + val states = globalContextMutex.withLock { + context = evaluationContext + repository.getAllStates() + } + + kotlinx.coroutines.coroutineScope { + states.forEach { state -> + launch { applyContextToState(state, evaluationContext) } } } } - private suspend fun tryWithStatusEmitErrorHandling(function: suspend () -> Unit) { + private suspend fun setEvaluationContextInternal(domain: String?, evaluationContext: EvaluationContext) { + if (domain == null) { + setEvaluationContextInternal(evaluationContext) + return + } + val globalCtx = globalContextMutex.withLock { context } + val state = repository.getOrCreateState(domain) + applyContextToState(state, globalCtx) { evaluationContext } + } + + private suspend fun clearEvaluationContextInternal(domain: String?) { + if (domain == null) { + val states = globalContextMutex.withLock { + context = null + repository.getAllStates() + } + kotlinx.coroutines.coroutineScope { + states.forEach { state -> + launch { applyContextToState(state, null) } + } + } + return + } + + val globalCtx = globalContextMutex.withLock { context } + applyContextToState(repository.getState(domain), globalCtx) { null } + } + + private suspend fun setEvaluationContextForState( + state: DomainState, + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) { + if (oldContext == newContext) return + + state.ioMutex.withLock { + val activeProvider = state.providerMutex.withLock { + state.provider.takeIf { state.getStatus() != OpenFeatureStatus.NotReady } + } + + activeProvider?.let { provider -> + state.emitStatus(OpenFeatureStatus.Reconciling) + state.emitEvent(OpenFeatureProviderEvents.ProviderReconciling()) + tryWithStatusEmitErrorHandling(state) { + provider.onContextSet(oldContext, newContext) + state.emitStatus(OpenFeatureStatus.Ready) + state.emitEvent(OpenFeatureProviderEvents.ProviderContextChanged()) + } + } + } + } + + private suspend fun tryWithStatusEmitErrorHandling(state: DomainState, function: suspend () -> Unit) { try { function() } catch (e: CancellationException) { // This happens by design and shouldn't be treated as an error } catch (e: OpenFeatureError) { - _statusFlow.emit(OpenFeatureStatus.Error(e)) + state.emitStatus(OpenFeatureStatus.Error(e)) + state.emitEvent( + OpenFeatureProviderEvents.ProviderError( + eventDetails = OpenFeatureProviderEvents.EventDetails( + message = e.message ?: "Unknown error", + errorCode = e.errorCode() + ) + ) + ) } catch (e: Throwable) { - _statusFlow.emit( - OpenFeatureStatus.Error( - OpenFeatureError.GeneralError( - e.message ?: "Unknown error" + val generalError = OpenFeatureError.GeneralError(e.message ?: "Unknown error") + state.emitStatus(OpenFeatureStatus.Error(generalError)) + state.emitEvent( + OpenFeatureProviderEvents.ProviderError( + eventDetails = OpenFeatureProviderEvents.EventDetails( + message = generalError.message, + errorCode = generalError.errorCode() ) ) ) @@ -205,25 +504,41 @@ object OpenFeatureAPI { } /** - * Get the current [EvaluationContext] for the SDK. + * Get the current global [EvaluationContext] for the SDK. */ fun getEvaluationContext(): EvaluationContext? { return context } /** - * Get the [ProviderMetadata] for the current [FeatureProvider]. + * Get the [EvaluationContext] for the specified domain. If not set, returns the global context. + */ + fun getEvaluationContext(domain: String?): EvaluationContext? { + if (domain == null) return context + val state = repository.getState(domain) + return state.context?.let { state.mergedContext } ?: context + } + + /** + * Get the [ProviderMetadata] for the current default [FeatureProvider]. */ fun getProviderMetadata(): ProviderMetadata? { return getProvider().metadata } + /** + * Get the [ProviderMetadata] for the [FeatureProvider] of the specified domain. + */ + fun getProviderMetadata(domain: String?): ProviderMetadata? { + return getProvider(domain).metadata + } + /** * Get a [Client] for the SDK. * This client can be used to evaluate flags. */ - fun getClient(name: String? = null, version: String? = null): Client { - return OpenFeatureClient(this, name, version) + fun getClient(domain: String? = null, version: String? = null): Client { + return OpenFeatureClient(this, domain, version) } /** @@ -246,47 +561,51 @@ object OpenFeatureAPI { * The SDK status will be set to [OpenFeatureStatus.NotReady]. */ suspend fun shutdown() { + jobMutex.withLock { + setEvaluationContextJob?.cancel(CancellationException("Job cancelled due to 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 the SDK. + * Get the current [OpenFeatureStatus] of the default SDK provider. */ - fun getStatus(): OpenFeatureStatus = _statusFlow.replayCache.first() + fun getStatus(): OpenFeatureStatus = repository.getState().getStatus() /** - * Observe events from currently configured Provider. + * Get the current [OpenFeatureStatus] of the provider associated with the specified domain. */ - @OptIn(ExperimentalCoroutinesApi::class) - inline fun observe(): Flow = providersFlow - .flatMapLatest { it.observe() }.filterIsInstance() + fun getProviderStatus(domain: String?): OpenFeatureStatus = repository.getState(domain).getStatus() /** - * Aligning the state management to - * https://openfeature.dev/specification/sections/events#requirement-535 + * Get the status flow of the provider associated with the specified domain. */ - private val handleProviderEvents: FlowCollector = FlowCollector { providerEvent -> - when (providerEvent) { - is OpenFeatureProviderEvents.ProviderReady -> { - _statusFlow.emit(OpenFeatureStatus.Ready) - } + @OptIn(ExperimentalCoroutinesApi::class) + fun getProviderStatusFlow(domain: String?): Flow = repository.getStateFlow(domain) + .flatMapLatest { it.statusFlow }.distinctUntilChanged() - is OpenFeatureProviderEvents.ProviderStale -> { - _statusFlow.emit(OpenFeatureStatus.Stale) - } + @PublishedApi + @OptIn(ExperimentalCoroutinesApi::class) + internal fun getProvidersFlowForDomain(domain: String?): Flow = + repository.getStateFlow(domain).flatMapLatest { it.providersFlow } - is OpenFeatureProviderEvents.ProviderError -> { - _statusFlow.emit(providerEvent.toOpenFeatureStatusError()) - } + @PublishedApi + @OptIn(ExperimentalCoroutinesApi::class) + internal fun getEventsFlowForDomain(domain: String?): Flow = + repository.getStateFlow(domain).flatMapLatest { it.eventsFlow } - else -> { // All other states should not be emitted from here - } - } - } + /** + * Observe events from currently configured default provider. + */ + @OptIn(ExperimentalCoroutinesApi::class) + inline fun observe(): Flow = + getEventsFlowForDomain(null).filterIsInstance() + + /** + * Observe events from currently configured provider for the specified domain. + */ + @OptIn(ExperimentalCoroutinesApi::class) + inline fun observe(domain: String?): Flow = + getEventsFlowForDomain(domain).filterIsInstance() } \ No newline at end of file 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 07591d31..056ae096 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 @@ -5,26 +5,48 @@ import dev.openfeature.kotlin.sdk.FlagValueType.DOUBLE import dev.openfeature.kotlin.sdk.FlagValueType.INTEGER import dev.openfeature.kotlin.sdk.FlagValueType.OBJECT import dev.openfeature.kotlin.sdk.FlagValueType.STRING +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.flow.Flow private val typeMatchingException = GeneralError("Unable to match default value type with flag value type") class OpenFeatureClient( private val openFeatureAPI: OpenFeatureAPI, - name: String? = null, + private val domain: String? = null, version: String? = null, override val hooks: MutableList> = mutableListOf() ) : Client { - override val metadata: ClientMetadata = Metadata(name) + override val metadata: ClientMetadata = Metadata(domain, domain) private val hookSupport = HookSupport() override fun addHooks(hooks: List>) { this.hooks += hooks } - override val statusFlow = openFeatureAPI.statusFlow + override val statusFlow = openFeatureAPI.getProviderStatusFlow(domain) + + override fun observeEvents(): Flow { + return openFeatureAPI.getEventsFlowForDomain(domain) + } + + override fun setProvider( + provider: FeatureProvider, + dispatcher: kotlinx.coroutines.CoroutineDispatcher, + initialContext: EvaluationContext? + ) { + openFeatureAPI.setProvider(domain, provider, dispatcher, initialContext) + } + + override suspend fun setProviderAndWait( + provider: FeatureProvider, + initialContext: EvaluationContext?, + dispatcher: kotlinx.coroutines.CoroutineDispatcher + ) { + openFeatureAPI.setProviderAndWait(domain, provider, initialContext, dispatcher) + } override fun getBooleanValue(key: String, defaultValue: Boolean): Boolean { return getBooleanDetails(key, defaultValue).value @@ -92,10 +114,7 @@ class OpenFeatureClient( return getIntegerDetails(key, defaultValue, options).value } - override fun getIntegerDetails( - key: String, - defaultValue: Int - ): FlagEvaluationDetails { + override fun getIntegerDetails(key: String, defaultValue: Int): FlagEvaluationDetails { return getIntegerDetails(key, defaultValue, FlagEvaluationOptions()) } @@ -146,10 +165,7 @@ class OpenFeatureClient( return getObjectDetails(key, defaultValue, options).value } - override fun getObjectDetails( - key: String, - defaultValue: Value - ): FlagEvaluationDetails { + override fun getObjectDetails(key: String, defaultValue: Value): FlagEvaluationDetails { return getObjectDetails(key, defaultValue, FlagEvaluationOptions()) } @@ -163,8 +179,9 @@ class OpenFeatureClient( override fun track(trackingEventName: String, details: TrackingEventDetails?) { validateTrackingEventName(trackingEventName) - openFeatureAPI.getProvider() - .track(trackingEventName, openFeatureAPI.getEvaluationContext(), details) + openFeatureAPI + .getProvider(domain) + .track(trackingEventName, openFeatureAPI.getEvaluationContext(domain), details) } private fun evaluateFlag( @@ -176,9 +193,9 @@ class OpenFeatureClient( val options = optionsIn ?: FlagEvaluationOptions(listOf(), mapOf()) val hints = options.hookHints var details = FlagEvaluationDetails(key, defaultValue) - val provider = openFeatureAPI.getProvider() + val provider = openFeatureAPI.getProvider(domain) val mergedHooks: List> = provider.hooks + options.hooks + hooks + openFeatureAPI.hooks - val context = openFeatureAPI.getEvaluationContext() + val context = openFeatureAPI.getEvaluationContext(domain) val hooksWithContext: List, HookContext>> = mergedHooks .filter { it.supportsFlagValueType(flagValueType) } @@ -196,27 +213,24 @@ class OpenFeatureClient( try { hookSupport.beforeHooks(flagValueType, hooksWithContext, hints) shortCircuitIfNotReady() - val providerEval = createProviderEvaluation( - flagValueType, - key, - context, - defaultValue, - provider - ) + val providerEval = + createProviderEvaluation(flagValueType, key, context, defaultValue, provider) details = FlagEvaluationDetails.from(providerEval, key) hookSupport.afterHooks(flagValueType, details, hooksWithContext, hints) } catch (error: Exception) { - val errorCode = if (error is OpenFeatureError) { - error.errorCode() - } else { - ErrorCode.GENERAL - } + val errorCode = + if (error is OpenFeatureError) { + error.errorCode() + } else { + ErrorCode.GENERAL + } - details = details.copy( - errorMessage = error.message, - reason = Reason.ERROR.toString(), - errorCode = errorCode - ) + details = + details.copy( + errorMessage = error.message, + reason = Reason.ERROR.toString(), + errorCode = errorCode + ) hookSupport.errorHooks(flagValueType, error, hooksWithContext, hints) } @@ -225,7 +239,7 @@ class OpenFeatureClient( } private fun shortCircuitIfNotReady() { - val providerStatus = openFeatureAPI.getStatus() + val providerStatus = openFeatureAPI.getProviderStatus(domain) if (providerStatus == OpenFeatureStatus.NotReady) { throw OpenFeatureError.ProviderNotReadyError() } else if (providerStatus is OpenFeatureStatus.Fatal) { @@ -248,28 +262,24 @@ class OpenFeatureClient( provider.getBooleanEvaluation(key, defaultBoolean, context) eval as? ProviderEvaluation ?: throw typeMatchingException } - STRING -> { val defaultString = defaultValue as? String ?: throw typeMatchingException val eval: ProviderEvaluation = provider.getStringEvaluation(key, defaultString, context) eval as? ProviderEvaluation ?: throw typeMatchingException } - INTEGER -> { val defaultInteger = defaultValue as? Int ?: throw typeMatchingException val eval: ProviderEvaluation = provider.getIntegerEvaluation(key, defaultInteger, context) eval as? ProviderEvaluation ?: throw typeMatchingException } - DOUBLE -> { val defaultDouble = defaultValue as? Double ?: throw typeMatchingException val eval: ProviderEvaluation = provider.getDoubleEvaluation(key, defaultDouble, context) eval as? ProviderEvaluation ?: throw typeMatchingException } - OBJECT -> { val defaultObject = defaultValue as? Value ?: throw typeMatchingException val eval: ProviderEvaluation = @@ -279,7 +289,10 @@ class OpenFeatureClient( } } - data class Metadata(override val name: String?) : ClientMetadata + data class Metadata( + @Deprecated("Use domain instead", ReplaceWith("domain")) override val name: String?, + override val domain: String? = name + ) : ClientMetadata } private fun validateTrackingEventName(name: String) { diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderRepository.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderRepository.kt new file mode 100644 index 00000000..f2f2e94e --- /dev/null +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderRepository.kt @@ -0,0 +1,255 @@ +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.ErrorCode +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.concurrent.Volatile + +internal class DomainState( + private val onStatusUpdate: suspend (FeatureProvider, OpenFeatureStatus) -> Unit = { _, _ -> } +) { + var setProviderJob: Job? = null + var setEvaluationContextJob: Job? = null + val jobMutex = Mutex() + + val providerMutex = Mutex() + val providersFlow: MutableStateFlow = MutableStateFlow(NoOpProvider()) + val provider: FeatureProvider get() = providersFlow.value + + val contextMutex = Mutex() + val ioMutex = Mutex() + var context: EvaluationContext? = null + var mergedContext: EvaluationContext? = null + + val _statusFlow: MutableSharedFlow = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 5, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ).apply { + tryEmit(OpenFeatureStatus.NotReady) + } + + val statusFlow: Flow get() = _statusFlow.distinctUntilChanged() + + private val _eventsFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val eventsFlow: Flow get() = _eventsFlow + + private var domainScope: CoroutineScope? = null + + @Volatile + private var currentDispatcher: CoroutineDispatcher? = null + + suspend fun initializeListener(dispatcher: CoroutineDispatcher) { + providerMutex.withLock { + currentDispatcher = dispatcher + + if (domainScope != null) return@withLock + val scope = CoroutineScope(SupervisorJob() + dispatcher) + domainScope = scope + + providersFlow + .flatMapLatest { currentProvider -> + currentProvider.observe() + .retryWhen { cause, _ -> + emit( + OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + message = cause.message ?: "Provider observe() crashed", + errorCode = ErrorCode.GENERAL + ) + ) + ) + delay(3000L) + true + } + .map { event -> currentProvider to event } + } + .onEach { (currentProvider, providerEvent) -> + val activeDispatcher = currentDispatcher ?: dispatcher + withContext(activeDispatcher) { + if (providersFlow.value === currentProvider) { + processProviderEvent(currentProvider, providerEvent) + emitEvent(providerEvent) + } + } + } + .launchIn(scope) + } + } + + private suspend fun processProviderEvent(eventProvider: FeatureProvider, event: OpenFeatureProviderEvents) { + val status = when (event) { + is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready + is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale + is OpenFeatureProviderEvents.ProviderError -> event.toOpenFeatureStatusError() + else -> null + } + + if (status != null) { + emitStatus(status) + onStatusUpdate(eventProvider, status) + } + } + + suspend fun emitStatus(status: OpenFeatureStatus) { + _statusFlow.emit(status) + } + + suspend fun emitEvent(event: OpenFeatureProviderEvents) { + _eventsFlow.emit(event) + } + + fun getStatus(): OpenFeatureStatus = _statusFlow.replayCache.firstOrNull() ?: OpenFeatureStatus.NotReady + + suspend fun resetAndGetProvider(): FeatureProvider { + jobMutex.withLock { + setProviderJob?.cancel(CancellationException("Provider set job was cancelled due to shutdown")) + setEvaluationContextJob?.cancel(CancellationException("Set context job was cancelled due to shutdown")) + } + return providerMutex.withLock { + domainScope?.cancel(CancellationException("DomainScope was cancelled due to shutdown")) + domainScope = null + currentDispatcher = null + val current = provider + providersFlow.value = NoOpProvider() + _statusFlow.tryEmit(OpenFeatureStatus.NotReady) + current + } + } +} + +internal class ProviderRepository { + private val onStatusUpdate: suspend (FeatureProvider, OpenFeatureStatus) -> Unit = { provider, status -> + updateGlobalProviderStatus(provider, status) + } + + private val defaultDomainState = DomainState(onStatusUpdate) + internal val defaultStateFlow = MutableStateFlow(defaultDomainState) + private val domainsFlow = MutableStateFlow>(emptyMap()) + + private val repositoryMutex = Mutex() + private val referencesMutex = Mutex() + private val providerReferences = mutableMapOf() + private val providerInitMutexes = mutableMapOf() + private val initializedProviders = mutableSetOf() + private val providerStatuses = mutableMapOf() + + suspend fun attachProvider(provider: FeatureProvider) { + if (provider is NoOpProvider) return + referencesMutex.withLock { + val count = providerReferences[provider] ?: 0 + providerReferences[provider] = count + 1 + } + } + + suspend fun detachProvider(provider: FeatureProvider): Boolean { + if (provider is NoOpProvider) return false + return referencesMutex.withLock { + val count = (providerReferences[provider] ?: 0) - 1 + if (count <= 0) { + providerReferences.remove(provider) + providerInitMutexes.remove(provider) + initializedProviders.remove(provider) + providerStatuses.remove(provider) + true + } else { + providerReferences[provider] = count + false + } + } + } + + suspend fun getInitMutex(provider: FeatureProvider): Mutex { + return referencesMutex.withLock { + providerInitMutexes.getOrPut(provider) { Mutex() } + } + } + + suspend fun isInitialized(provider: FeatureProvider): Boolean { + return referencesMutex.withLock { initializedProviders.contains(provider) } + } + + suspend fun markInitialized(provider: FeatureProvider) { + referencesMutex.withLock { initializedProviders.add(provider) } + } + + suspend fun getGlobalProviderStatus(provider: FeatureProvider): OpenFeatureStatus? { + return referencesMutex.withLock { providerStatuses[provider] } + } + + suspend fun updateGlobalProviderStatus(provider: FeatureProvider, status: OpenFeatureStatus) { + referencesMutex.withLock { providerStatuses[provider] = status } + } + + fun getState(domain: String? = null): DomainState { + if (domain == null) return defaultDomainState + return domainsFlow.value[domain] ?: defaultDomainState + } + + suspend fun getOrCreateState(domain: String? = null): DomainState { + if (domain == null) return defaultDomainState + + return domainsFlow.value[domain] ?: repositoryMutex.withLock { + domainsFlow.value[domain] ?: DomainState(onStatusUpdate).also { newState -> + domainsFlow.update { currentMap -> currentMap + (domain to newState) } + } + } + } + + fun getStateFlow(domain: String?): Flow { + if (domain == null) return defaultStateFlow + return domainsFlow.map { it[domain] ?: defaultDomainState }.distinctUntilChanged() + } + + fun getAllStates(): List { + return listOf(defaultDomainState) + domainsFlow.value.values.toList() + } + + suspend fun clearAll() { + // Evaluate and detach existing states safely + val allStatesToShutdown = repositoryMutex.withLock { + val all = getAllStates() + domainsFlow.value = emptyMap() + all + } + + // Shutdown cleanly outside the repository lock to prevent lock-inversion deadlocks! + allStatesToShutdown.forEach { state -> + val oldProvider = state.resetAndGetProvider() + if (detachProvider(oldProvider)) { + try { + oldProvider.shutdown() + } catch (e: Exception) { + // Safely suppress exceptions crashing custom provider teardown loops + } + } + } + } +} \ 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 a121c9d2..496f5f14 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 @@ -41,6 +41,20 @@ sealed class OpenFeatureProviderEvents { override val eventDetails: EventDetails? = null ) : OpenFeatureProviderEvents() + /** + * The provider is reconciling its state due to a context change. + */ + data class ProviderReconciling( + override val eventDetails: EventDetails? = null + ) : OpenFeatureProviderEvents() + + /** + * The provider successfully updated its state following a context change. + */ + data class ProviderContextChanged( + override val eventDetails: EventDetails? = null + ) : OpenFeatureProviderEvents() + @Deprecated("Use ProviderError instead", ReplaceWith("ProviderError")) data object ProviderNotReady : OpenFeatureProviderEvents() { override val eventDetails = null 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 ea02f2df..8cf4b55e 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 @@ -209,6 +209,16 @@ class MultiProvider( return } + is OpenFeatureProviderEvents.ProviderReconciling -> { + eventFlow.emit(event) + OpenFeatureStatus.Reconciling + } + + is OpenFeatureProviderEvents.ProviderContextChanged -> { + eventFlow.emit(event) + OpenFeatureStatus.Ready + } + is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready is OpenFeatureProviderEvents.ProviderNotReady -> OpenFeatureStatus.NotReady is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale 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 d4de0194..4549b1e0 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 @@ -336,9 +336,11 @@ class DeveloperExperienceTests { } // emits ProviderReady + val testDispatcher = StandardTestDispatcher(testScheduler) OpenFeatureAPI.setProviderAndWait( provider, - initialContext = ImmutableContext("first") + initialContext = ImmutableContext("first"), + dispatcher = testDispatcher ) // emits ProviderStale + ProviderStale + ProviderStale OpenFeatureAPI.getClient().track("hello-world") diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DomainE2ETest.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DomainE2ETest.kt new file mode 100644 index 00000000..72aae343 --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DomainE2ETest.kt @@ -0,0 +1,411 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.helpers.BrokenInitProvider +import dev.openfeature.kotlin.sdk.helpers.DoSomethingProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class DomainE2ETest { + @AfterTest + fun tearDown() = runTest { + OpenFeatureAPI.shutdown() + } + + /** + * Requirement 1.1.2.3: "If a provider has not been set for the domain ... of a Client, + * the provider will default to the default provider." + */ + @Test + fun testDefaultProviderFallback() = runTest { + // Setup default provider + OpenFeatureAPI.setProviderAndWait(DoSomethingProvider()) + + // Use a domain-bound client + val client = OpenFeatureAPI.getClient(domain = "my-domain") + + // Evaluate boolean flag to verify it returns correctly using default provider fallback + val result = client.getBooleanValue("test-flag", false) + + // DoSomethingProvider returns inverted boolean by default + assertEquals(true, result) + } + + /** + * Requirement 1.1.2.2: "The API MUST provide a function for binding a given provider to one or more domains. + * If a provider is bound to a domain, the provider is used to evaluate flags from clients associated with that domain." + */ + @Test + fun testDomainSpecificProvider() = runTest { + // Setup fallback default provider + val defaultProvider = DoSomethingProvider() + OpenFeatureAPI.setProviderAndWait(defaultProvider) + + // Setup domain specific provider + val domainProvider = NoOpProvider() + OpenFeatureAPI.getClient("specific-domain").setProviderAndWait(domainProvider) + + val defaultClient = OpenFeatureAPI.getClient(domain = "default-domain") + val specificClient = OpenFeatureAPI.getClient(domain = "specific-domain") + + // Specific client should use NoOpProvider, returning the default user value + val specificResult = specificClient.getBooleanValue("test-flag", defaultValue = true) + assertEquals(true, specificResult) + + // Default client should fall back to DoSomethingProvider, which inverts the default + val defaultResult = defaultClient.getBooleanValue("test-flag", defaultValue = true) + assertEquals(false, defaultResult) + + assertEquals(domainProvider, OpenFeatureAPI.getProvider("specific-domain")) + assertEquals(defaultProvider, OpenFeatureAPI.getProvider("default-domain")) + } + + /** + * Requirement 5.2.1: "The API MUST provide a function to get the status of the provider." + * Validates that state is isolated per domain, so a broken provider doesn't halt other domains. + */ + @Test + fun testDomainStatusIsolation() = runTest { + OpenFeatureAPI.getClient("fast-domain").setProviderAndWait(NoOpProvider()) + + val brokenProvider = BrokenInitProvider() + OpenFeatureAPI.getClient("broken-domain").setProviderAndWait(brokenProvider) + + val fastStatus = OpenFeatureAPI.getProviderStatus("fast-domain") + val brokenStatus = OpenFeatureAPI.getProviderStatus("broken-domain") + + assertIs(fastStatus) + assertIs(brokenStatus) + } + + /** + * Requirement 3.2.2: "The client MUST provide a method for adding context. Client context MUST NOT be shared + * between other clients." + * Requirement 3.1.2: Global execution context should be inherited if domain context is missing. + */ + @Test + fun testDomainSpecificEvaluationContext() = runTest { + val globalContext = ImmutableContext(targetingKey = "global") + val domainContext = ImmutableContext(targetingKey = "domain-specific") + + // Set global context + OpenFeatureAPI.setEvaluationContextAndWait(globalContext) + assertEquals(globalContext, OpenFeatureAPI.getEvaluationContext()) + + // Unset domain falls back to global + assertEquals(globalContext, OpenFeatureAPI.getEvaluationContext("my-domain")) + + // Set domain context + OpenFeatureAPI.setEvaluationContextAndWait("my-domain", domainContext) + + // Domain context is now specific + assertEquals(domainContext, OpenFeatureAPI.getEvaluationContext("my-domain")) + + // Global context remains unchanged + assertEquals(globalContext, OpenFeatureAPI.getEvaluationContext()) + + // Mutating global context does not affect bounded domain context + val newGlobalContext = ImmutableContext(targetingKey = "new-global") + OpenFeatureAPI.setEvaluationContextAndWait(newGlobalContext) + + assertEquals(newGlobalContext, OpenFeatureAPI.getEvaluationContext()) + assertEquals(domainContext, OpenFeatureAPI.getEvaluationContext("my-domain")) + assertEquals(newGlobalContext, OpenFeatureAPI.getEvaluationContext("fallback-domain")) + } + + /** + * Requirement 5.3.3: "Event listeners MUST only be triggered for events associated with their client's domain." + * Validates that events on one domain are not leaked or propagated to observers of another domain. + */ + @Test + fun testEventStreamIsolation() = runTest { + val domainA = "domain-a" + val domainB = "domain-b" + + val flowA = MutableSharedFlow() + val flowB = MutableSharedFlow() + + val providerA = object : FeatureProvider by NoOpProvider() { + override fun observe() = flowA + } + val providerB = object : FeatureProvider by NoOpProvider() { + override fun observe() = flowB + } + + val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler) + OpenFeatureAPI.getClient(domainA).setProviderAndWait(providerA, dispatcher = testDispatcher) + OpenFeatureAPI.getClient(domainB).setProviderAndWait(providerB, dispatcher = testDispatcher) + + val eventsA = mutableListOf() + val eventsB = mutableListOf() + + val jobA = launch { + OpenFeatureAPI.observe(domainA).collect { eventsA.add(it) } + } + val jobB = launch { + OpenFeatureAPI.observe(domainB).collect { eventsB.add(it) } + } + + testScheduler.advanceUntilIdle() + + // providers emit their Ready events initially if set up, but MockEventProvider relies on manual emission. + flowA.emit(OpenFeatureProviderEvents.ProviderStale()) + testScheduler.advanceUntilIdle() + + assertEquals(1, eventsA.size) + assertIs(eventsA.first()) + // domain B should not receive A's event + assertEquals(0, eventsB.size) + flowB.emit(OpenFeatureProviderEvents.ProviderReady()) + testScheduler.advanceUntilIdle() + + assertEquals(1, eventsA.size) // still 1 + assertEquals(1, eventsB.size) + assertIs(eventsB.first()) + + jobA.cancelAndJoin() + jobB.cancelAndJoin() + } + + /** + * Requirement 4.1.3: "Client hooks MUST ONLY execute for flags evaluated by that client." + * Validates that hooks bound specifically to one domain are completely isolated from global + * contexts or evaluations triggered natively by other standalone domains. + */ + @Test + fun testHookPropagationAndIsolation() = runTest { + val globalProvider = DoSomethingProvider() + OpenFeatureAPI.setProviderAndWait(globalProvider) + + val clientA = OpenFeatureAPI.getClient(domain = "domain-a") + val clientB = OpenFeatureAPI.getClient(domain = "domain-b") + + var hookAInvocations = 0 + var hookGlobalInvocations = 0 + + val hookA = object : Hook { + override fun before(ctx: HookContext, hints: Map) { + hookAInvocations++ + } + } + val hookGlobal = object : Hook { + override fun before(ctx: HookContext, hints: Map) { + hookGlobalInvocations++ + } + } + + clientA.addHooks(listOf(hookA)) + OpenFeatureAPI.addHooks(listOf(hookGlobal)) + + // Trigger evaluation on Client A (should hit hook A and global hook) + clientA.getBooleanValue("flag", false) + assertEquals(1, hookAInvocations) + assertEquals(1, hookGlobalInvocations) + + // Trigger evaluation on Client B (should hit ONLY global hook, NOT hook A) + clientB.getBooleanValue("flag", false) + assertEquals(1, hookAInvocations) + assertEquals(2, hookGlobalInvocations) + + OpenFeatureAPI.clearHooks() + } + + /** + * Requirement 1.1.2.3 and 5.1.3: Changing the bound provider MUST execute its initialization lifecycle. + * Validates that if a domain specifically falls back to the global provider, changing that global provider + * properly bubbles status events dynamically to observers of the fallback domain. + */ + @Test + fun testDynamicFallbackLifecycleUpdates() = runTest { + val flow1 = MutableSharedFlow() + val defaultProvider1 = object : FeatureProvider by NoOpProvider() { + override fun observe() = flow1 + } + + val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler) + OpenFeatureAPI.setProviderAndWait(defaultProvider1, dispatcher = testDispatcher) + + val events = mutableListOf() + val job = launch { + OpenFeatureAPI.observe("unbound-domain").collect { events.add(it) } + } + + testScheduler.advanceUntilIdle() + + flow1.emit(OpenFeatureProviderEvents.ProviderReady()) + testScheduler.advanceUntilIdle() + + assertEquals(1, events.size) + assertIs(events.last()) + + // Swap the global fallback provider dynamically + val flow2 = MutableSharedFlow() + val defaultProvider2 = object : FeatureProvider by NoOpProvider() { + override fun observe() = flow2 + } + OpenFeatureAPI.setProviderAndWait(defaultProvider2, dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + + flow2.emit(OpenFeatureProviderEvents.ProviderStale()) + testScheduler.advanceUntilIdle() + + // We received Stale on the unbound domain because it correctly maps to the NEW global provider! + // We also received ProviderReady from the SDK when defaultProvider2 was set. + assertEquals(3, events.size) + assertIs(events.last()) + + job.cancelAndJoin() + } + + /** + * Requirement 4.3.4: "When a flag is evaluated, hooks MUST be executed in the following order: + * before: API, Client, Invocation." + * Validates the execution order of dynamically stacked hooks from varying logical layers. + */ + @Test + fun testHookExecutionOrder() = runTest { + val globalProvider = DoSomethingProvider() + OpenFeatureAPI.setProviderAndWait(globalProvider) + + val executionOrder = mutableListOf() + + val globalHook = object : Hook { + override fun before(ctx: HookContext, hints: Map) { + executionOrder.add("API") + } + } + + val clientHook = object : Hook { + override fun before(ctx: HookContext, hints: Map) { + executionOrder.add("Client") + } + } + + val invocationHook = object : Hook { + override fun before(ctx: HookContext, hints: Map) { + executionOrder.add("Invocation") + } + } + + OpenFeatureAPI.addHooks(listOf(globalHook)) + + val client = OpenFeatureAPI.getClient("hook-order-domain") + client.addHooks(listOf(clientHook)) + + val options = FlagEvaluationOptions(hooks = listOf(invocationHook)) + client.getBooleanValue("test-flag", false, options) + + assertEquals(listOf("API", "Client", "Invocation"), executionOrder) + + OpenFeatureAPI.clearHooks() + } + + /** + * Requirement: `setProvider` for a specific domain with an `initialContext` should + * only update the context for that domain, and initialize the provider with it. + * The global context should remain untouched. + */ + @Test + fun testSetProviderIsolatesInitialContext() = runTest { + var initializedContext: EvaluationContext? = null + val testProvider = object : FeatureProvider by NoOpProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + initializedContext = initialContext + } + } + val domainContext = ImmutableContext(targetingKey = "domain-only") + val globalContext = ImmutableContext(targetingKey = "global-fallback") + + OpenFeatureAPI.setEvaluationContextAndWait(globalContext) + + // Set the provider for a specific domain with an initial context + OpenFeatureAPI.getClient("isolated-domain").setProviderAndWait(testProvider, domainContext) + + // Verify the provider received the domain context upon initialization + assertEquals(domainContext, initializedContext) + + // Verify the domain context was isolated and properly written to the domain state + assertEquals(domainContext, OpenFeatureAPI.getEvaluationContext("isolated-domain")) + + // Verify the global context was NOT mutated by the domain-specific setProvider call + assertEquals(globalContext, OpenFeatureAPI.getEvaluationContext()) + } + + /** + * Requirement: `setEvaluationContext` with a null domain should act as a global update. + * It must update the global concept and propagate only to domains without specific overrides. + */ + @Test + fun testSetEvaluationContextWithNullDomainUpdatesGlobalAndDefault() = runTest { + // Give "override-domain" a specific context overriding the global one + val overrideContext = ImmutableContext(targetingKey = "override-context") + OpenFeatureAPI.setEvaluationContextAndWait("override-domain", overrideContext) + + // Set providers + OpenFeatureAPI.getClient("override-domain").setProviderAndWait(NoOpProvider()) + OpenFeatureAPI.getClient("fallback-domain").setProviderAndWait(NoOpProvider()) + OpenFeatureAPI.setProviderAndWait(NoOpProvider()) // default domain + + // Set global context passing a null domain + val globalContext = ImmutableContext(targetingKey = "global-context") + OpenFeatureAPI.setEvaluationContextAndWait(null as String?, globalContext) + + // Verify the global context was updated + assertEquals(globalContext, OpenFeatureAPI.getEvaluationContext()) + + // Verify that default domain gets the global context + assertEquals(globalContext, OpenFeatureAPI.getEvaluationContext(null)) + + // Verify that domains without specific overrides fall back to the global context + assertEquals(globalContext, OpenFeatureAPI.getEvaluationContext("fallback-domain")) + + // Verify that domain with a specific override keeps its context + assertEquals(overrideContext, OpenFeatureAPI.getEvaluationContext("override-domain")) + } + + /** + * Requirement: `setEvaluationContextAndWait` with a null domain should suspend until all + * providers across all domains without specific overrides have finished `onContextSet`. + * Furthermore, it should utilize `coroutineScope` parallel execution. + */ + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun testSetEvaluationContextAndWaitWithNullDomainSuspendsUntilAllProvidersComplete() = runTest { + var completedProviders = 0 + + val slowProvider = object : FeatureProvider by NoOpProvider() { + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + delay(100) // Simulate a slow context update + completedProviders++ + } + } + + OpenFeatureAPI.getClient("domain-1").setProviderAndWait(slowProvider) + OpenFeatureAPI.getClient("domain-2").setProviderAndWait(slowProvider) + OpenFeatureAPI.getClient("domain-3").setProviderAndWait(slowProvider) + + val newContext = ImmutableContext(targetingKey = "new-context") + + val timeToRun = currentTime + // Null domain triggers a global update targeting all fallback domains (1, 2, 3) + OpenFeatureAPI.setEvaluationContextAndWait(null as String?, newContext) + val elapsed = currentTime - timeToRun + + // By checking immediately after the call, we prove it cleanly suspended the caller + assertEquals(3, completedProviders) + + // In virtual time, since all 3 delays of 100ms executed in parallel, + // the elapsed virtual time should be precisely 100ms, not 300ms! + assertEquals(100L, elapsed) + } +} \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ImmutableContextTest.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ImmutableContextTest.kt index 951fa337..6dc6f18d 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ImmutableContextTest.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ImmutableContextTest.kt @@ -74,4 +74,23 @@ class ImmutableContextTest { assertEquals(42, contextNestedList?.get(1)?.asInteger()) assertEquals(2, contextNestedList?.size) } + + @Test + fun `mergeWith should correctly overwrite colliding keys and preserve distinct ones`() { + val baseContext = ImmutableContext( + targetingKey = "base-user", + attributes = mapOf("color" to Value.String("red"), "age" to Value.Integer(30)) + ) + val domainContext = ImmutableContext( + targetingKey = "domain-user", + attributes = mapOf("color" to Value.String("blue"), "planet" to Value.String("mars")) + ) + + val merged = baseContext.mergeWith(domainContext) + + assertEquals("domain-user", merged.getTargetingKey()) + assertEquals("blue", merged.getValue("color")?.asString()) // Overwritten + assertEquals(30, merged.getValue("age")?.asInteger()) // Preserved + assertEquals("mars", merged.getValue("planet")?.asString()) // Appended + } } \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClientTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClientTests.kt index 75f49127..5a71f4b1 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClientTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureClientTests.kt @@ -1,10 +1,15 @@ package dev.openfeature.kotlin.sdk +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import dev.openfeature.kotlin.sdk.helpers.GenericSpyHookMock +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue class OpenFeatureClientTests { @@ -20,4 +25,202 @@ class OpenFeatureClientTests { val stringValue = OpenFeatureAPI.getClient().getStringValue("test", "defaultTest") assertEquals(stringValue, "defaultTest") } + + @Test + fun testEvaluationContextMergingPrecedence() = runTest { + val globalContext = ImmutableContext( + targetingKey = "global-user", + attributes = mapOf( + "globalKey" to Value.String("globalValue"), + "conflictKey" to Value.String("globalConflict") + ) + ) + val domainContext = ImmutableContext( + targetingKey = "domain-user", + attributes = mapOf( + "domainKey" to Value.String("domainValue"), + "conflictKey" to Value.String("domainConflict") + ) + ) + + OpenFeatureAPI.setEvaluationContextAndWait(globalContext) + OpenFeatureAPI.setEvaluationContextAndWait("test-domain", domainContext) + + val mergedContext = OpenFeatureAPI.getEvaluationContext("test-domain") + + assertEquals("domain-user", mergedContext?.getTargetingKey()) + assertEquals("globalValue", mergedContext?.getValue("globalKey")?.asString()) + assertEquals("domainValue", mergedContext?.getValue("domainKey")?.asString()) + assertEquals("domainConflict", mergedContext?.getValue("conflictKey")?.asString()) + } + + @Test + fun testEvaluationContextCachePreventsSubsequentAllocations() = runTest { + val globalContext = ImmutableContext(targetingKey = "global") + val domainContext = ImmutableContext(targetingKey = "domain") + + OpenFeatureAPI.setEvaluationContextAndWait(globalContext) + OpenFeatureAPI.setEvaluationContextAndWait("cache-domain", domainContext) + + val firstFetch = OpenFeatureAPI.getEvaluationContext("cache-domain") + val secondFetch = OpenFeatureAPI.getEvaluationContext("cache-domain") + + assertNotNull(firstFetch) + assertSame(firstFetch, secondFetch) // O(1) GC Leak Protection Validation Wrapper + } + + @Test + fun testProviderOnContextSetReceivesMergedContext() = runTest { + var capturedNewContext: EvaluationContext? = null + val provider = object : NoOpProvider() { + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + capturedNewContext = newContext + } + } + + OpenFeatureAPI.getClient("mock-domain").setProviderAndWait(provider) + OpenFeatureAPI.setEvaluationContextAndWait( + ImmutableContext(attributes = mapOf("global" to Value.String("globalVal"))) + ) + OpenFeatureAPI.setEvaluationContextAndWait( + "mock-domain", + ImmutableContext(attributes = mapOf("domain" to Value.String("domainVal"))) + ) + + val captured = capturedNewContext + assertNotNull(captured) + assertEquals("globalVal", captured.getValue("global")?.asString()) + assertEquals("domainVal", captured.getValue("domain")?.asString()) + } + + /** + * Rationale: + * When hot-swapping providers, the OpenFeatureAPI correctly executes `oldProvider.shutdown()`. + * However, the domain's live `statusFlow` has already been allocated to represent the incoming new provider. + * If the old provider's shutdown implementation dynamically throws an unhandled exception, it must be + * safely discarded. It must NEVER emit an `OpenFeatureStatus.Error` onto the live streaming pipeline, + * which would falsely signal to subscribers that the newly bound active provider crashed. + */ + @Test + fun testUncaughtExceptionInOldProviderShutdownDoesNotEmitError() = runTest { + var shutdownCalled = false + val trapProvider = object : FeatureProvider by NoOpProvider() { + override fun shutdown() { + shutdownCalled = true + throw RuntimeException("Explosion during teardown") + } + } + + OpenFeatureAPI.getClient("crash-domain").setProviderAndWait(trapProvider) + + val events = mutableListOf() + val job = launch { + OpenFeatureAPI.observe("crash-domain").collect { events.add(it) } + } + + testScheduler.advanceUntilIdle() + events.clear() + + val newProvider = NoOpProvider() + OpenFeatureAPI.getClient("crash-domain").setProviderAndWait(newProvider) + + assertTrue(shutdownCalled) + assertTrue(events.none { it is OpenFeatureProviderEvents.ProviderError }, "Emitted stray error!") + job.cancel() + } + + @Test + fun testClearEvaluationContextRevertsToGlobalContext() = runTest { + var capturedNewContext: EvaluationContext? = null + val provider = object : NoOpProvider() { + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + capturedNewContext = newContext + } + } + + OpenFeatureAPI.getClient("mock-domain").setProviderAndWait(provider) + OpenFeatureAPI.setEvaluationContextAndWait( + ImmutableContext(attributes = mapOf("global" to Value.String("globalVal"))) + ) + OpenFeatureAPI.setEvaluationContextAndWait( + "mock-domain", + ImmutableContext(attributes = mapOf("domain" to Value.String("domainVal"))) + ) + + assertNotNull(capturedNewContext) + assertEquals("globalVal", capturedNewContext!!.getValue("global")?.asString()) + assertEquals("domainVal", capturedNewContext!!.getValue("domain")?.asString()) + + // Action: Clear the specific domain evaluation context + OpenFeatureAPI.clearEvaluationContextAndWait("mock-domain") + + // Assert: The context correctly reverts gracefully to pure global context without crashes + assertNotNull(capturedNewContext) + assertEquals("globalVal", capturedNewContext!!.getValue("global")?.asString()) + assertTrue(capturedNewContext!!.asMap().containsKey("domain").not(), "Domain key should be removed") + + // Action: Clear global evaluation context + OpenFeatureAPI.clearEvaluationContextAndWait() + + // Assert: The fallback context clears out to empty ImmutableContext safely + assertNotNull(capturedNewContext) + assertTrue(capturedNewContext!!.asMap().isEmpty()) + } + + @Test + fun testClientObserveEvents() = runTest { + val client = OpenFeatureAPI.getClient("client-events-domain") + val events = mutableListOf() + val job = launch { + client.observeEvents().collect { events.add(it) } + } + testScheduler.runCurrent() + + val eventProvider = object : FeatureProvider by NoOpProvider() { + override fun observe(): kotlinx.coroutines.flow.Flow = + kotlinx.coroutines.flow.flowOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged(), + OpenFeatureProviderEvents.ProviderStale() + ) + } + + val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler) + OpenFeatureAPI.getClient("client-events-domain").setProviderAndWait(eventProvider, dispatcher = testDispatcher) + + testScheduler.advanceUntilIdle() + + assertTrue(events.any { it is OpenFeatureProviderEvents.ProviderConfigurationChanged }) + assertTrue(events.any { it is OpenFeatureProviderEvents.ProviderStale }) + job.cancel() + } + + @Test + fun testClientObserveFilteredEvents() = runTest { + val client = OpenFeatureAPI.getClient("client-filtered-domain") + val configEvents = mutableListOf() + val job = launch { + client.observe().collect { configEvents.add(it) } + } + testScheduler.runCurrent() + + val eventProvider = object : FeatureProvider by NoOpProvider() { + override fun observe(): kotlinx.coroutines.flow.Flow = + kotlinx.coroutines.flow.flowOf( + OpenFeatureProviderEvents.ProviderConfigurationChanged(), + OpenFeatureProviderEvents.ProviderStale() + ) + } + + val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler) + OpenFeatureAPI.getClient("client-filtered-domain").setProviderAndWait( + eventProvider, + dispatcher = testDispatcher + ) + + testScheduler.advanceUntilIdle() + + assertTrue(configEvents.isNotEmpty()) + assertTrue(configEvents.all { it is OpenFeatureProviderEvents.ProviderConfigurationChanged }) + job.cancel() + } } \ No newline at end of file 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 aedabaf4..20a9346a 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 @@ -95,16 +95,24 @@ class ProviderEventingTests { } } + val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler) + // emits ProviderReady OpenFeatureAPI.setProviderAndWait( firstProvider, - initialContext = ImmutableContext("first") + initialContext = ImmutableContext("first"), + dispatcher = testDispatcher ) // emits ProviderStale + ProviderConfigurationChanged - OpenFeatureAPI.setEvaluationContextAndWait(ImmutableContext("first.v2")) + OpenFeatureAPI.setEvaluationContextAndWait( + ImmutableContext("first.v2") + ) testScheduler.advanceUntilIdle() assertEquals( listOf( + OpenFeatureProviderEvents.ProviderReady(), + OpenFeatureProviderEvents.ProviderReconciling(), + OpenFeatureProviderEvents.ProviderContextChanged(), OpenFeatureProviderEvents.ProviderReady(), OpenFeatureProviderEvents.ProviderStale(), OpenFeatureProviderEvents.ProviderConfigurationChanged() @@ -114,7 +122,8 @@ class ProviderEventingTests { // emits ProviderReady OpenFeatureAPI.setProviderAndWait( secondProvider, - initialContext = ImmutableContext("second") + initialContext = ImmutableContext("second"), + dispatcher = testDispatcher ) testScheduler.advanceUntilIdle() // emits ProviderStale + ProviderStale + ProviderStale @@ -122,7 +131,9 @@ class ProviderEventingTests { testScheduler.advanceUntilIdle() // emits ProviderStale + ProviderConfigurationChanged - OpenFeatureAPI.setEvaluationContextAndWait(ImmutableContext("second.v2")) + OpenFeatureAPI.setEvaluationContextAndWait( + ImmutableContext("second.v2") + ) testScheduler.advanceUntilIdle() OpenFeatureAPI.shutdown() @@ -130,16 +141,275 @@ class ProviderEventingTests { assertEquals( listOf( OpenFeatureProviderEvents.ProviderReady(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderConfigurationChanged(), + OpenFeatureProviderEvents.ProviderReconciling(), + OpenFeatureProviderEvents.ProviderContextChanged(), OpenFeatureProviderEvents.ProviderReady(), OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderConfigurationChanged() + OpenFeatureProviderEvents.ProviderConfigurationChanged(), + // Second provider events: + OpenFeatureProviderEvents.ProviderReady(), // SDK init (onMultiProvider with second provider + OpenFeatureProviderEvents.ProviderReady(), // Provider background init (flushed by advanceUntilIdle) + OpenFeatureProviderEvents.ProviderStale(), // track background flushed + OpenFeatureProviderEvents.ProviderStale(), // track background flushed + OpenFeatureProviderEvents.ProviderStale(), // track background flushed + OpenFeatureProviderEvents.ProviderReconciling(), // SDK pre-context + OpenFeatureProviderEvents.ProviderContextChanged(), // SDK post-context + OpenFeatureProviderEvents.ProviderStale(), // onContextSet background flushed + OpenFeatureProviderEvents.ProviderConfigurationChanged() // onContextSet background flushed ), emittedEvents ) } + + @Test + fun testProviderOnContextSetThrowsExceptionEmitsErrorEvent() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val provider = object : DoSomethingProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + // no-op + } + + override suspend fun onContextSet( + oldContext: EvaluationContext?, + newContext: EvaluationContext + ) { + throw IllegalStateException("Intentional crash during reconciliation") + } + } + + val emittedEvents = mutableListOf() + val job = launch(testDispatcher) { + OpenFeatureAPI.observe().collect { + emittedEvents.add(it) + } + } + + // 1. Set the provider, wait for initialize + OpenFeatureAPI.setProviderAndWait( + provider, + dispatcher = testDispatcher, + initialContext = ImmutableContext() + ) + testScheduler.advanceUntilIdle() + + emittedEvents.clear() // clear the ProviderReady event from init + + // 2. Set evaluation context, which triggers onContextSet and throws + OpenFeatureAPI.setEvaluationContextAndWait(ImmutableContext("new-context")) + testScheduler.advanceUntilIdle() + + // 3. Verify exactly Reconciling followed by Error (no ContextChanged) + assertEquals(2, emittedEvents.size) + assertEquals(OpenFeatureProviderEvents.ProviderReconciling(), emittedEvents[0]) + assertTrue(emittedEvents[1] is OpenFeatureProviderEvents.ProviderError) + val errorEvent = emittedEvents[1] as OpenFeatureProviderEvents.ProviderError + assertEquals("Intentional crash during reconciliation", errorEvent.eventDetails?.message) + + job.cancelAndJoin() + } + + @Test + fun testSharedProviderIsNotShutdownUntilLastDomainIsCleared() = runTest { + var shutdownCalls = 0 + val sharedProvider = object : DoSomethingProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + // no-op + } + override fun shutdown() { + shutdownCalls++ + } + } + + // Bind shared provider to domain A + OpenFeatureAPI.setProviderAndWait("domainA", sharedProvider, dispatcher = StandardTestDispatcher(testScheduler)) + testScheduler.advanceUntilIdle() + + // Bind shared provider to domain B + OpenFeatureAPI.setProviderAndWait("domainB", sharedProvider, dispatcher = StandardTestDispatcher(testScheduler)) + testScheduler.advanceUntilIdle() + + assertEquals(0, shutdownCalls, "Should not be shut down yet") + + // Swap provider on domain A + val newProvider = DoSomethingProvider() + OpenFeatureAPI.setProviderAndWait("domainA", newProvider, dispatcher = StandardTestDispatcher(testScheduler)) + testScheduler.advanceUntilIdle() + + // Verify sharedProvider was NOT shut down because it's still bound to domain B + assertEquals(0, shutdownCalls, "Should not be shut down since domain B still uses it") + + // Clear all providers (including domain B) + OpenFeatureAPI.clearProvider() + testScheduler.advanceUntilIdle() + + // Now it should be shut down exactly once + assertEquals(1, shutdownCalls, "Should be shut down exactly once after all bindings are removed") + } + + @Test + fun testSharedProviderIsInitializedOnlyOnce() = runTest { + var initializeCalls = 0 + val sharedProvider = object : DoSomethingProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + initializeCalls++ + delay(10) // Simulate some work + } + } + + val jobA = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setProviderAndWait( + "domainA", + sharedProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + + val jobB = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setProviderAndWait( + "domainB", + sharedProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + + testScheduler.advanceUntilIdle() + + jobA.join() + jobB.join() + + assertEquals(1, initializeCalls, "Should be initialized exactly once globally") + + val clientA = OpenFeatureAPI.getClient("domainA") + val clientB = OpenFeatureAPI.getClient("domainB") + + assertEquals( + OpenFeatureStatus.Ready, + OpenFeatureAPI.getProviderStatus("domainA") + ) + assertEquals( + OpenFeatureStatus.Ready, + OpenFeatureAPI.getProviderStatus("domainB") + ) + } + + @Test + fun testSharedProviderSyncsErrorState() = runTest { + val eventFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val sharedProvider = object : DoSomethingProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + delay(10) + } + override fun observe(): Flow = eventFlow + } + + // Domain A binds and initializes the provider + val jobA = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setProviderAndWait( + "domainA", + sharedProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + testScheduler.advanceUntilIdle() + jobA.join() + + assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getProviderStatus("domainA")) + + // Simulate a crash emitting an Error event natively from the provider + eventFlow.emit( + OpenFeatureProviderEvents.ProviderError( + dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents.EventDetails(message = "Simulated Crash") + ) + ) + testScheduler.advanceUntilIdle() + + // Domain A should now reflect the Error status + val statusA = OpenFeatureAPI.getProviderStatus("domainA") + assertTrue(statusA is OpenFeatureStatus.Error) + + // Domain B now binds the SAME provider, bypassing initialization + val jobB = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setProviderAndWait( + "domainB", + sharedProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + testScheduler.advanceUntilIdle() + jobB.join() + + // Domain B should correctly sync the Error state, NOT falsely emit Ready + val statusB = OpenFeatureAPI.getProviderStatus("domainB") + assertTrue(statusB is OpenFeatureStatus.Error) + } + + /** + * Verifies that when a domain binds to an already initialized shared provider, it successfully + * bypasses the redundant `initialize()` call, syncs the global status from the provider, + * AND synthetically broadcasts the corresponding event (e.g. ProviderReady) to its + * locally bound event streams so that clients observing events receive the latest state. + */ + @Test + fun testSharedProviderEmitsSyntheticEventsOnBypass() = runTest { + val sharedProvider = object : DoSomethingProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + delay(10) + } + } + + // Domain A initializes the provider + val jobA = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setProviderAndWait( + "domainA", + sharedProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + testScheduler.advanceUntilIdle() + jobA.join() + + assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getProviderStatus("domainA")) + + // Pre-initialize Domain B with a NoOpProvider so the state is created and the flow is wired up + val jobPreB = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setProviderAndWait( + "domainB", + NoOpProvider(), + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + testScheduler.advanceUntilIdle() + jobPreB.join() + + // Set up Domain B's client and listen to events + val clientB = OpenFeatureAPI.getClient("domainB") + val eventsReceived = mutableListOf() + + val eventJob = launch(StandardTestDispatcher(testScheduler)) { + clientB.observeEvents().collect { event -> + eventsReceived.add(event) + } + } + testScheduler.advanceUntilIdle() // Ensure collection starts + + // Domain B binds the provider, bypassing initialization + val jobB = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setProviderAndWait( + "domainB", + sharedProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + + testScheduler.advanceUntilIdle() + jobB.join() + + // Verify the synthetic Ready event was broadcasted to Domain B's clients + assertTrue( + eventsReceived.any { it is OpenFeatureProviderEvents.ProviderReady }, + "Domain B should have received a synthetic ProviderReady event" + ) + + eventJob.cancel() + } } \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderRepositoryTest.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderRepositoryTest.kt new file mode 100644 index 00000000..68ff006e --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderRepositoryTest.kt @@ -0,0 +1,493 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ProviderRepositoryTest { + + @Test + fun `getOrCreateState should return default state when domain is null`() = runTest { + val repository = ProviderRepository() + + val defaultState = repository.getOrCreateState(null) + val defaultStateFromGet = repository.getState(null) + + assertSame(defaultState, defaultStateFromGet) + } + + @Test + fun `getOrCreateState should isolate domain states`() = runTest { + val repository = ProviderRepository() + + val defaultState = repository.getOrCreateState(null) + val domainStateA = repository.getOrCreateState("domainA") + val domainStateB = repository.getOrCreateState("domainB") + + assertNotEquals(defaultState, domainStateA) + assertNotEquals(domainStateA, domainStateB) + + assertSame(domainStateA, repository.getState("domainA")) + assertSame(domainStateB, repository.getState("domainB")) + } + + @Test + fun `getAllStates should reflect created domains`() = runTest { + val repository = ProviderRepository() + + assertEquals(1, repository.getAllStates().size) // Default state + + repository.getOrCreateState("domainX") + repository.getOrCreateState("domainY") + + assertEquals(3, repository.getAllStates().size) + } + + @Test + fun `getStateFlow should track newly created domains`() = runTest { + val repository = ProviderRepository() + + val flow = repository.getStateFlow("dynamicDomain") + + // Before creation, it yields the default state fallback logic + val defaultMappedState = flow.first() + assertSame(repository.getState(null), defaultMappedState) + + // Dynamically create the state + val dynamicState = repository.getOrCreateState("dynamicDomain") + + val newMappedState = flow.first() + assertSame(dynamicState, newMappedState) + } + + @Test + fun `getStateFlow should memoize null domain state flow`() = runTest { + val repository = ProviderRepository() + + val firstFlow = repository.getStateFlow(null) + val secondFlow = repository.getStateFlow(null) + + assertSame(firstFlow, secondFlow) + assertSame(repository.defaultStateFlow, firstFlow) + + val nonNullFlow = repository.getStateFlow("specific-domain") + assertNotSame(repository.defaultStateFlow, nonNullFlow) + } + + @Test + fun `getOrCreateState should be thread-safe for concurrent creation`() = runTest { + val repository = ProviderRepository() + + val jobs = List(100) { + launch { + repository.getOrCreateState("concurrentDomain") + } + } + + // Wait for all 100 concurrent creation jobs to finish + jobs.forEach { it.join() } + + // Even with 100 concurrent jobs racing to create the domain, + // there should only be 1 total state per domain inside the repository (Default + concurrentDomain = 2) + assertEquals(2, repository.getAllStates().size) + } + + @Test + fun `clearAll should recreate empty map and isolate future calls`() = runTest { + val repository = ProviderRepository() + + val domainA = repository.getOrCreateState("domainA") + + assertEquals(2, repository.getAllStates().size) + + repository.clearAll() + + // Ensure ONLY the default state object remains + assertEquals(1, repository.getAllStates().size) + + // Subsequent creation should yield a NEW distinct state reference since the old one was removed + val newDomainA = repository.getOrCreateState("domainA") + assertNotEquals(domainA, newDomainA) + } + + @Test + fun `clearAll should successfully terminate array even if provider shutdown throws`() = runTest { + val repository = ProviderRepository() + + val stateA = repository.getOrCreateState("domainA") + val stateB = repository.getOrCreateState("domainB") + + val explosiveProvider = object : FeatureProvider by NoOpProvider() { + override fun shutdown() { + throw RuntimeException("Simulated hostile provider shutdown crash") + } + } + + stateA.providersFlow.value = explosiveProvider + + // If clearAll crashed upon iterating stateA, stateB wouldn't successfully transition to NotReady securely. + repository.clearAll() + + // Assert BOTH safely resolved their inner _statusFlow sequences + assertEquals(OpenFeatureStatus.NotReady, stateA.getStatus()) + assertEquals(OpenFeatureStatus.NotReady, stateB.getStatus()) + + // Assert structure correctly purged isolated arrays unconditionally + assertEquals(1, repository.getAllStates().size) + } + + @Test + fun `DomainState should automatically cancel old provider event listeners on swap`() = runTest { + val oldProviderEvents = MutableSharedFlow() + val newProviderEvents = MutableSharedFlow() + + class MockEventProvider( + val source: MutableSharedFlow + ) : FeatureProvider by NoOpProvider() { + override fun observe() = source + } + + val oldProvider = MockEventProvider(oldProviderEvents) + val newProvider = MockEventProvider(newProviderEvents) + + val state = DomainState() + state.initializeListener(StandardTestDispatcher(testScheduler)) + + // 1. Assign the old provider + state.providersFlow.value = oldProvider + + // Wait for collectLatest coroutine to subscribe + testScheduler.advanceUntilIdle() + + // 2. Emit an event from the old provider + oldProviderEvents.emit(OpenFeatureProviderEvents.ProviderReady()) + testScheduler.advanceUntilIdle() + + // Assert it was successfully mapped and propagated + assertEquals(OpenFeatureStatus.Ready, state.getStatus()) + + // 3. Swap in the totally new provider + state.providersFlow.value = newProvider + testScheduler.advanceUntilIdle() + + // 4. Fire a hostile error event from the OLD, strictly-abandoned provider + oldProviderEvents.emit( + OpenFeatureProviderEvents.ProviderError(error = OpenFeatureError.ProviderNotReadyError()) + ) + testScheduler.advanceUntilIdle() + + // Flow conflation and auto-cancellation guarantees that the old provider error strictly gets ignored + assertEquals(OpenFeatureStatus.Ready, state.getStatus()) + + // 5. Fire an event from the new provider to prove the listener was properly hot-swapped + newProviderEvents.emit(OpenFeatureProviderEvents.ProviderStale()) + testScheduler.advanceUntilIdle() + + // Verify the newly mapped listener successfully overrides state + assertEquals(OpenFeatureStatus.Stale, state.getStatus()) + } + + @Test + fun `DomainState should restart event listener if dispatcher changes`() = runTest { + val state = DomainState() + + val dispatcher1 = StandardTestDispatcher(testScheduler) + val dispatcher2 = StandardTestDispatcher(testScheduler) + + val providerEvents = MutableSharedFlow() + class MockEventProvider : FeatureProvider by NoOpProvider() { + override fun observe() = providerEvents + } + val provider = MockEventProvider() + state.providersFlow.value = provider + + // Step 1: Initialize with dispatcher1 + state.initializeListener(dispatcher1) + testScheduler.advanceUntilIdle() + + // Emit event to verify dispatcher1 listener is working + providerEvents.emit(OpenFeatureProviderEvents.ProviderReady()) + testScheduler.advanceUntilIdle() + assertEquals(OpenFeatureStatus.Ready, state.getStatus()) + + // Step 2: Initialize with the EXACT SAME dispatcher, shouldn't disrupt anything + state.initializeListener(dispatcher1) + testScheduler.advanceUntilIdle() + + // Step 3: Initialize with a DIFFERENT dispatcher (dispatcher2) + state.initializeListener(dispatcher2) + testScheduler.advanceUntilIdle() + + // Fire another event, it should be processed by the NEW listener that was just hot-swapped + providerEvents.emit(OpenFeatureProviderEvents.ProviderStale()) + testScheduler.advanceUntilIdle() + assertEquals(OpenFeatureStatus.Stale, state.getStatus()) + } + + @Test + fun `DomainState should automatically retry and emit error on provider unhandled exception`() = runTest { + val state = DomainState() + val errorMsg = "Simulated internal crash" + var observeCallCount = 0 + + class UnstableProvider : FeatureProvider by NoOpProvider() { + override fun observe(): kotlinx.coroutines.flow.Flow = flow { + observeCallCount++ + throw RuntimeException(errorMsg) + } + } + + state.providersFlow.value = UnstableProvider() + val testDispatcher = StandardTestDispatcher(testScheduler) + state.initializeListener(testDispatcher) + + testScheduler.advanceTimeBy(100L) // Process first crash without entering infinite loop + + val status = state.getStatus() + assertTrue(status is OpenFeatureStatus.Error) + assertEquals(errorMsg, status.error.message) + assertEquals(1, observeCallCount) + + // Then, advance by 3000ms. It should trigger retryWhen delay and retry + testScheduler.advanceTimeBy(3500L) + // Ensure it re-observed! + assertEquals(2, observeCallCount) + + // Cancel scope explicitly to avoid hanging the `runTest` finalizer loop + state.resetAndGetProvider() + testScheduler.advanceUntilIdle() + } + + @Test + fun `DomainState should drop oldest status seamlessly and avoid suspending backpressure when blasted`() = runTest { + val state = DomainState() + val eventsFlow = MutableSharedFlow() + + class SpammyProvider : FeatureProvider by NoOpProvider() { + override fun observe() = eventsFlow + } + + state.providersFlow.value = SpammyProvider() + val testDispatcher = StandardTestDispatcher(testScheduler) + state.initializeListener(testDispatcher) + testScheduler.advanceUntilIdle() + + // Mimic a slow processor that purposefully does not collect from statusFlow. + val slowSubscriber = launch(StandardTestDispatcher(testScheduler)) { + state.statusFlow.collect { + delay(10000L) // Extremely slow + } + } + testScheduler.advanceUntilIdle() + + // Blast 50 items simultaneously into eventsFlow -> emitStatus. + // If it was BufferOverflow.SUSPEND this wouldn't finish. + val job = launch(StandardTestDispatcher(testScheduler)) { + for (i in 1..50) { + eventsFlow.emit( + if (i % 2 == 0) { + OpenFeatureProviderEvents.ProviderReady() + } else { + OpenFeatureProviderEvents.ProviderStale() + } + ) + } + } + testScheduler.advanceUntilIdle() + + // Assert the job actually finished and didn't hang + assertTrue(job.isCompleted) + slowSubscriber.cancel() + + // Assert the state correctly processed them + val finalStatus = state.getStatus() + assertTrue(finalStatus is OpenFeatureStatus.Ready || finalStatus is OpenFeatureStatus.Stale) + } + + @Test + fun `ProviderRepository clearAll should not deadlock against busy domain state shutdowns`() = runTest { + val repository = ProviderRepository() + val state = repository.getOrCreateState("deadlock-domain") + + // Thread A: Holds providerMutex and tries to get repositoryMutex + val threadA = launch(StandardTestDispatcher(testScheduler)) { + state.providerMutex.withLock { + delay(50L) // Wait to ensure Thread B traps repositoryMutex + repository.getOrCreateState("new-domain") // Wants repositoryMutex + } + } + + // Thread B: Runs clearAll + val threadB = launch(StandardTestDispatcher(testScheduler)) { + repository.clearAll() // Holds repositoryMutex initially, then wants providerMutex (via shutdown) + } + + testScheduler.advanceUntilIdle() + + // If the vulnerability exists, both threads will be permanently blocked! + assertTrue(threadA.isCompleted) + assertTrue(threadB.isCompleted) + } + + @Test + fun testConcurrentGlobalAndDomainContextUpdatesAreSynchronized() = runTest { + val testDomain = "syncTestDomain" + val state = ProviderRepository().getOrCreateState(testDomain) + val globalContextMutex = kotlinx.coroutines.sync.Mutex() + + val jobA = launch(StandardTestDispatcher(testScheduler)) { + state.contextMutex.withLock { + state.context = ImmutableContext(attributes = mapOf("A" to Value.Boolean(true))) + val globalCtx = globalContextMutex.withLock { null } + state.mergedContext = globalCtx?.mergeWith(state.context!!) ?: state.context + } + } + + val jobB = launch(StandardTestDispatcher(testScheduler)) { + globalContextMutex.withLock { + state.contextMutex.withLock { + val globalCtx = ImmutableContext(attributes = mapOf("B" to Value.Boolean(true))) + state.mergedContext = state.context?.let { globalCtx.mergeWith(it) } ?: globalCtx + } + } + } + + testScheduler.advanceUntilIdle() + + assertTrue(jobA.isCompleted) + assertTrue(jobB.isCompleted) + assertNotNull(state.mergedContext) + } + + @Test + fun testContextHooksBypassUninitializedProviders() = runTest { + val testDomain = "bypassDomain" + OpenFeatureAPI.getClient(testDomain).setProviderAndWait( + NoOpProvider(), + dispatcher = StandardTestDispatcher(testScheduler) + ) + + var initFired = false + var hookFired = false + + val slowProvider = object : NoOpProvider() { + override suspend fun initialize(initialContext: EvaluationContext?) { + delay(100) // Suspend strictly + initFired = true + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + hookFired = true + } + } + + // Simultaneously trigger setProvider and Context Updates + val contextJob = launch { + // Trigger Context Update first natively queuing onto IO + OpenFeatureAPI.setEvaluationContext( + testDomain, + ImmutableContext(attributes = mapOf("key" to Value.Boolean(true))), + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + + val providerJob = launch { + delay(10) // Queue Provider Swap Second Native Block! + OpenFeatureAPI.getClient(testDomain).setProviderAndWait( + slowProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + } + + testScheduler.advanceUntilIdle() + + assertTrue(providerJob.isCompleted) + assertTrue(contextJob.isCompleted) + assertTrue(initFired, "Provider must have initialized correctly successfully") + assertFalse(hookFired, "Context Hook MUST have bypassed natively because Provider was strictly uninitialized!") + } + + @Test + fun testShutdownCancelsPhantomContextCoroutines() = runTest { + var hookFired = false + + val testDomain = "phantomDomain" + OpenFeatureAPI.getClient(testDomain).setProviderAndWait( + NoOpProvider(), + dispatcher = StandardTestDispatcher(testScheduler) + ) + + val zombieProvider = object : NoOpProvider() { + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + delay(50) + hookFired = true + } + } + + OpenFeatureAPI.getClient(testDomain).setProviderAndWait( + zombieProvider, + dispatcher = StandardTestDispatcher(testScheduler) + ) + + // Trigger a Global Evaluation Context which runs async natively + val evalJob = launch(StandardTestDispatcher(testScheduler)) { + OpenFeatureAPI.setEvaluationContext(ImmutableContext()) + } + + delay(10) // Yield to allow Evaluation Cascade instantiation cleanly + + // Execute Shutdown strictly mid-way targeting precise cancellation interception! + OpenFeatureAPI.shutdown() + + testScheduler.advanceUntilIdle() + + assertFalse( + hookFired, + "Shutdown MUST explicitly track and natively intercept Phantom Domain Hook evaluations statically!" + ) + } + + @Test + fun testConcurrentSetEvaluationContextIsThreadSafe() = runTest { + val testDomain = "concurrent-domain" + val testDispatcher = StandardTestDispatcher(testScheduler) + + // Spawn 100 concurrent requests into the test dispatcher queue to induce rapid cancellation overrides natively + for (index in 0 until 100) { + OpenFeatureAPI.setEvaluationContext( + testDomain, + ImmutableContext(attributes = mapOf("key" to Value.Integer(index))), + dispatcher = testDispatcher + ) + } + + // Let the test environment gracefully resolve all internally queued lock transfers and cancellations + testScheduler.advanceUntilIdle() + + // If the JobMutex works, no CancellationExceptions should have leaked natively and + // the final surviving context should strictly be mathematically intact and valid! + val resultingContext = OpenFeatureAPI.getEvaluationContext(testDomain) + assertNotNull(resultingContext, "Context MUST NOT be completely destroyed by concurrent Job cancellations!") + assertTrue(resultingContext.asMap().containsKey("key")) + + OpenFeatureAPI.shutdown() + } +} \ 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 41fe1bfc..97da651e 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 @@ -96,7 +96,7 @@ class StatusTests { OpenFeatureAPI.setProviderAndWait(SlowProvider(dispatcher = dispatcher)) waitAssert { assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) } for (i in 1..30) { - OpenFeatureAPI.setEvaluationContext(ImmutableContext("test_$i")) + OpenFeatureAPI.setEvaluationContext(ImmutableContext("test_$i"), dispatcher = dispatcher) delay(Duration.randomMs(0, 10)) } diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/hooks/LoggingHookTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/hooks/LoggingHookTests.kt index 73cb29c2..b2f163ea 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/hooks/LoggingHookTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/hooks/LoggingHookTests.kt @@ -18,7 +18,10 @@ import kotlin.test.assertTrue class LoggingHookTests { private class TestProviderMetadata(override val name: String = "test-provider") : ProviderMetadata - private class TestClientMetadata(override val name: String = "test-client") : ClientMetadata + private class TestClientMetadata( + override val domain: String? = "test-client", + override val name: String? = domain + ) : ClientMetadata private fun createHookContext( flagKey: String = "test-flag", 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 dd730e47..02296a3a 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 @@ -289,6 +289,40 @@ class MultiProviderTests { initJob.cancelAndJoin() } + @Test + fun reconcilingOutRanksReadyButNotError() = runTest { + val a = FakeEventProvider( + name = "A", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderReady() + ) + ) + val b = FakeEventProvider( + name = "B", + eventsToEmitOnInit = listOf( + OpenFeatureProviderEvents.ProviderReconciling() + ) + ) + val multi = MultiProvider(listOf(a, b)) + + val initJob = launch { multi.initialize(null) } + advanceUntilIdle() + + // Reconciling (2) > Ready (1) + var finalStatus = multi.statusFlow.value + assertIs(finalStatus) + + // Now emit ContextChanged from B + b.emitEvent(OpenFeatureProviderEvents.ProviderContextChanged()) + advanceUntilIdle() + + // Both are now Ready + finalStatus = multi.statusFlow.value + assertIs(finalStatus) + + initJob.cancelAndJoin() + } + @Test fun emitsEventsOnlyOnStatusChange() = runTest { val provider = FakeEventProvider( @@ -442,6 +476,10 @@ private class FakeEventProvider( var trackingCalls: Int = 0 private set + suspend fun emitEvent(event: OpenFeatureProviderEvents) { + events.emit(event) + } + override suspend fun initialize(initialContext: EvaluationContext?) { initializeCalls += 1 // Emit any preconfigured events during initialize so MultiProvider observers receive them