diff --git a/kotlin-sdk/api/android/kotlin-sdk.api b/kotlin-sdk/api/android/kotlin-sdk.api index 00d3bbe3..7f4769aa 100644 --- a/kotlin-sdk/api/android/kotlin-sdk.api +++ b/kotlin-sdk/api/android/kotlin-sdk.api @@ -286,10 +286,11 @@ public class dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance { public final fun getEvaluationContext ()Ldev/openfeature/kotlin/sdk/EvaluationContext; public final fun getHooks ()Ljava/util/List; public final fun getProvider ()Ldev/openfeature/kotlin/sdk/FeatureProvider; + public final fun getProviderEvents ()Lkotlinx/coroutines/flow/Flow; public final fun getProviderMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; public final fun getProvidersFlow ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; - public final fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; public final fun setEvaluationContext (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;)V public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V public final fun setEvaluationContextAndWait (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -329,7 +330,8 @@ public final class dev/openfeature/kotlin/sdk/OpenFeatureClient : dev/openfeatur public fun getObjectDetails (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails; public fun getObjectValue (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;)Ldev/openfeature/kotlin/sdk/Value; public fun getObjectValue (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ldev/openfeature/kotlin/sdk/Value; - public fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public synthetic fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; public fun getStringDetails (Ljava/lang/String;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails; 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; @@ -694,19 +696,21 @@ public abstract class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvent public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails { public fun ()V - public fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;)V - public synthetic fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;)V + public synthetic fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/Set; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode; public final fun component4 ()Ljava/util/Map; - public final fun copy (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; - public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; public fun equals (Ljava/lang/Object;)Z public final fun getErrorCode ()Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode; public final fun getEventMetadata ()Ljava/util/Map; public final fun getFlagsChanged ()Ljava/util/Set; public final fun getMessage ()Ljava/lang/String; + public final fun getProviderName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -724,6 +728,19 @@ public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$P public fun toString ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { + public fun ()V + public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V + public synthetic fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun copy (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public fun equals (Ljava/lang/Object;)Z + public fun getEventDetails ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderError : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { public fun ()V public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V @@ -750,6 +767,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 00d3bbe3..7f4769aa 100644 --- a/kotlin-sdk/api/jvm/kotlin-sdk.api +++ b/kotlin-sdk/api/jvm/kotlin-sdk.api @@ -286,10 +286,11 @@ public class dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance { public final fun getEvaluationContext ()Ldev/openfeature/kotlin/sdk/EvaluationContext; public final fun getHooks ()Ljava/util/List; public final fun getProvider ()Ldev/openfeature/kotlin/sdk/FeatureProvider; + public final fun getProviderEvents ()Lkotlinx/coroutines/flow/Flow; public final fun getProviderMetadata ()Ldev/openfeature/kotlin/sdk/ProviderMetadata; public final fun getProvidersFlow ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun getStatus ()Ldev/openfeature/kotlin/sdk/OpenFeatureStatus; - public final fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public final fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; public final fun setEvaluationContext (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;)V public static synthetic fun setEvaluationContext$default (Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)V public final fun setEvaluationContextAndWait (Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -329,7 +330,8 @@ public final class dev/openfeature/kotlin/sdk/OpenFeatureClient : dev/openfeatur public fun getObjectDetails (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails; public fun getObjectValue (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;)Ldev/openfeature/kotlin/sdk/Value; public fun getObjectValue (Ljava/lang/String;Ldev/openfeature/kotlin/sdk/Value;Ldev/openfeature/kotlin/sdk/FlagEvaluationOptions;)Ldev/openfeature/kotlin/sdk/Value; - public fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public synthetic fun getStatusFlow ()Lkotlinx/coroutines/flow/Flow; + public fun getStatusFlow ()Lkotlinx/coroutines/flow/StateFlow; public fun getStringDetails (Ljava/lang/String;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails; 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; @@ -694,19 +696,21 @@ public abstract class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvent public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails { public fun ()V - public fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;)V - public synthetic fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;)V + public synthetic fun (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/Set; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode; public final fun component4 ()Ljava/util/Map; - public final fun copy (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; - public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;Ljava/util/Set;Ljava/lang/String;Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode;Ljava/util/Map;Ljava/lang/String;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; public fun equals (Ljava/lang/Object;)Z public final fun getErrorCode ()Ldev/openfeature/kotlin/sdk/exceptions/ErrorCode; public final fun getEventMetadata ()Ljava/util/Map; public final fun getFlagsChanged ()Ljava/util/Set; public final fun getMessage ()Ljava/lang/String; + public final fun getProviderName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -724,6 +728,19 @@ public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$P public fun toString ()Ljava/lang/String; } +public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { + public fun ()V + public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V + public synthetic fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public final fun copy (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderContextChanged; + public fun equals (Ljava/lang/Object;)Z + public fun getEventDetails ()Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$ProviderError : dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents { public fun ()V public fun (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents$EventDetails;)V @@ -750,6 +767,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/OpenFeatureAPIInstance.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance.kt index 3e949453..d928823c 100644 --- a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance.kt +++ b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPIInstance.kt @@ -1,30 +1,35 @@ package dev.openfeature.kotlin.sdk import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents -import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatusError +import dev.openfeature.kotlin.sdk.events.toCurrentStateEvent +import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatus +import dev.openfeature.kotlin.sdk.events.withProviderName import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError +import dev.openfeature.kotlin.sdk.logging.LoggerFactory import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow 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.StateFlow import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +private const val EVENT_BUFFER_CAPACITY = 64 +private const val LOGGER_NAME = "OpenFeatureAPI" + /** * Core implementation of the OpenFeature API. * @@ -63,13 +68,32 @@ open class OpenFeatureAPIInstance internal constructor() { private var contextReconciliationTerminalProviderStatusGeneration: Long? = null val providersFlow: MutableStateFlow = MutableStateFlow(noOpProvider) - private val _statusFlow: MutableSharedFlow = - MutableSharedFlow(replay = 1, extraBufferCapacity = 5) - .apply { - tryEmit(OpenFeatureStatus.NotReady) - } + private val _status: MutableStateFlow = MutableStateFlow(OpenFeatureStatus.NotReady) - val statusFlow: Flow get() = _statusFlow.distinctUntilChanged() + val statusFlow: StateFlow get() = _status + + /** + * Events from the active provider, republished by the SDK so that the status is always updated + * before subscribers observe the event that caused it. + * + * Overflow drops the oldest event rather than suspending: a slow subscriber must never stall the + * SDK's own status derivation. + */ + private val _events: MutableSharedFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = EVENT_BUFFER_CAPACITY, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + @PublishedApi + internal val providerEvents: Flow + get() = _events.onSubscription { + // Replays the state, not the last event: a handler attached while the provider is already + // in a state must run, but a stateless event must not resurface. + _status.value.toCurrentStateEvent() + ?.withProviderName(getProvider().metadata.name) + ?.let { emit(it) } + } var hooks: List> = listOf() private set @@ -111,11 +135,12 @@ open class OpenFeatureAPIInstance internal constructor() { 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) + provider.observe().collect { event -> + dispatchProviderEvent(event, provider.metadata.name) + } } } - @OptIn(ExperimentalCoroutinesApi::class) private suspend fun setProviderInternal( provider: FeatureProvider, dispatcher: CoroutineDispatcher, @@ -124,10 +149,8 @@ open class OpenFeatureAPIInstance internal constructor() { try { trackProviderBinding(provider) } catch (e: Throwable) { - _statusFlow.emit( - OpenFeatureStatus.Error( - OpenFeatureError.GeneralError(e.message ?: "Unknown error") - ) + _status.value = OpenFeatureStatus.Error( + OpenFeatureError.GeneralError(e.message ?: "Unknown error") ) return } @@ -149,7 +172,7 @@ open class OpenFeatureAPIInstance internal constructor() { swapCommitted = true // Emit NotReady status after swapping provider - _statusFlow.emit(OpenFeatureStatus.NotReady) + _status.value = OpenFeatureStatus.NotReady // Shutdown the previous provider outside the mutex if (oldProvider !== provider) { @@ -164,7 +187,7 @@ open class OpenFeatureAPIInstance internal constructor() { listenToProviderEvents(provider, dispatcher) val state = getEvaluationState() state.provider.initialize(state.context) - _statusFlow.emit(OpenFeatureStatus.Ready) + _status.value = OpenFeatureStatus.Ready } } catch (e: CancellationException) { // if cancellation hit before we committed the swap, release the binding we just claimed @@ -209,7 +232,7 @@ open class OpenFeatureAPIInstance internal constructor() { } untrackProviderBinding(oldProvider) oldProvider.shutdown() - _statusFlow.emit(OpenFeatureStatus.NotReady) + _status.value = OpenFeatureStatus.NotReady } /** @@ -265,7 +288,7 @@ open class OpenFeatureAPIInstance internal constructor() { } } if (shouldEmitReconciling) { - _statusFlow.emit(OpenFeatureStatus.Reconciling) + _status.value = OpenFeatureStatus.Reconciling } } } @@ -330,7 +353,7 @@ open class OpenFeatureAPIInstance internal constructor() { statusToEmit != null && shouldEmitStatus ) { - _statusFlow.emit(statusToEmit) + _status.value = statusToEmit } } } @@ -343,13 +366,11 @@ open class OpenFeatureAPIInstance internal constructor() { } catch (e: CancellationException) { // This happens by design and shouldn't be treated as an error } catch (e: OpenFeatureError) { - _statusFlow.emit(OpenFeatureStatus.Error(e)) + _status.value = OpenFeatureStatus.Error(e) } catch (e: Throwable) { - _statusFlow.emit( - OpenFeatureStatus.Error( - OpenFeatureError.GeneralError( - e.message ?: "Unknown error" - ) + _status.value = OpenFeatureStatus.Error( + OpenFeatureError.GeneralError( + e.message ?: "Unknown error" ) ) } @@ -406,42 +427,34 @@ open class OpenFeatureAPIInstance internal constructor() { /** * Get the current [OpenFeatureStatus] of this instance. */ - fun getStatus(): OpenFeatureStatus = _statusFlow.replayCache.first() + fun getStatus(): OpenFeatureStatus = _status.value /** - * Observe events from the currently configured Provider. + * Observe events of type [T] from the currently configured Provider. + * + * The status is always updated before an event reaches subscribers. Subscribing while the provider + * is already in a state yields the event for that state immediately. */ - @OptIn(ExperimentalCoroutinesApi::class) - inline fun observe(): Flow = providersFlow - .flatMapLatest { it.observe() }.filterIsInstance() + inline fun observe(): Flow = providerEvents.filterIsInstance() /** * Aligning the state management to * https://openfeature.dev/specification/sections/events#requirement-535 */ - private val handleProviderEvents: FlowCollector = FlowCollector { providerEvent -> - when (providerEvent) { - is OpenFeatureProviderEvents.ProviderReady -> { - emitProviderStatus(OpenFeatureStatus.Ready) - } - - is OpenFeatureProviderEvents.ProviderStale -> { - emitProviderStatus(OpenFeatureStatus.Stale) - } - - is OpenFeatureProviderEvents.ProviderError -> { - emitProviderStatus(providerEvent.toOpenFeatureStatusError()) - } - - else -> { // All other states should not be emitted from here - } + private suspend fun dispatchProviderEvent(event: OpenFeatureProviderEvents, providerName: String?) { + val stamped = event.withProviderName(providerName) + stamped.toOpenFeatureStatus()?.let { emitProviderStatus(it) } + if (!_events.tryEmit(stamped)) { + LoggerFactory.getLogger(LOGGER_NAME).warn( + { "Dropped provider event ${stamped::class.simpleName}: a subscriber is not keeping up." } + ) } } private suspend fun emitProviderStatus(status: OpenFeatureStatus) { contextReconciliationMutex.withLock { providerStatusGeneration++ - _statusFlow.emit(status) + _status.value = status } } diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents.kt index 1b7d8681..b9fe5021 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 @@ -9,7 +9,8 @@ sealed class OpenFeatureProviderEvents { val flagsChanged: Set = emptySet(), val message: String? = null, val errorCode: ErrorCode? = null, - val eventMetadata: Map = emptyMap() + val eventMetadata: Map = emptyMap(), + val providerName: String? = null ) abstract val eventDetails: EventDetails? @@ -45,6 +46,67 @@ sealed class OpenFeatureProviderEvents { data class ProviderStale( override val eventDetails: EventDetails? = null ) : OpenFeatureProviderEvents() + + /** + * The provider started reconciling its state with a new [dev.openfeature.kotlin.sdk.EvaluationContext]. + * [eventDetails] may supply [EventDetails.flagsChanged], [EventDetails.message], [EventDetails.errorCode], and [EventDetails.eventMetadata] as applicable. + */ + data class ProviderReconciling( + override val eventDetails: EventDetails? = null + ) : OpenFeatureProviderEvents() + + /** + * The provider finished reconciling its state with a new [dev.openfeature.kotlin.sdk.EvaluationContext]. + * [eventDetails] may supply [EventDetails.flagsChanged], [EventDetails.message], [EventDetails.errorCode], and [EventDetails.eventMetadata] as applicable. + */ + data class ProviderContextChanged( + override val eventDetails: EventDetails? = null + ) : OpenFeatureProviderEvents() +} + +internal fun OpenFeatureProviderEvents.withProviderName(providerName: String?): OpenFeatureProviderEvents { + val details = (eventDetails ?: OpenFeatureProviderEvents.EventDetails()).copy(providerName = providerName) + return when (this) { + is OpenFeatureProviderEvents.ProviderReady -> copy(eventDetails = details) + is OpenFeatureProviderEvents.ProviderError -> copy(eventDetails = details) + is OpenFeatureProviderEvents.ProviderConfigurationChanged -> copy(eventDetails = details) + is OpenFeatureProviderEvents.ProviderStale -> copy(eventDetails = details) + is OpenFeatureProviderEvents.ProviderReconciling -> copy(eventDetails = details) + is OpenFeatureProviderEvents.ProviderContextChanged -> copy(eventDetails = details) + } +} + +/** + * Event representing this status, so that a handler attached once the provider is already in a given + * state runs immediately. + * + * Returns null for [OpenFeatureStatus.NotReady], which has no corresponding event type. + */ +internal fun OpenFeatureStatus.toCurrentStateEvent(): OpenFeatureProviderEvents? = when (this) { + is OpenFeatureStatus.Ready -> OpenFeatureProviderEvents.ProviderReady() + is OpenFeatureStatus.Stale -> OpenFeatureProviderEvents.ProviderStale() + is OpenFeatureStatus.Reconciling -> OpenFeatureProviderEvents.ProviderReconciling() + is OpenFeatureStatus.Error -> OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(message = error.message, errorCode = error.errorCode()) + ) + is OpenFeatureStatus.Fatal -> OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails(message = error.message, errorCode = ErrorCode.PROVIDER_FATAL) + ) + is OpenFeatureStatus.NotReady -> null +} + +/** + * Status implied by an event, per the event/status association table in the specification. + * + * Returns null for events that carry no status transition, leaving the current status untouched. + */ +internal fun OpenFeatureProviderEvents.toOpenFeatureStatus(): OpenFeatureStatus? = when (this) { + is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready + is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale + is OpenFeatureProviderEvents.ProviderError -> toOpenFeatureStatusError() + is OpenFeatureProviderEvents.ProviderReconciling -> OpenFeatureStatus.Reconciling + is OpenFeatureProviderEvents.ProviderContextChanged -> OpenFeatureStatus.Ready + is OpenFeatureProviderEvents.ProviderConfigurationChanged -> null } internal fun OpenFeatureProviderEvents.ProviderError.toOpenFeatureStatusError(): OpenFeatureStatus { diff --git a/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt b/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt index 00e6d057..e32cbd6c 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 @@ -9,7 +9,7 @@ import dev.openfeature.kotlin.sdk.ProviderMetadata import dev.openfeature.kotlin.sdk.TrackingEventDetails import dev.openfeature.kotlin.sdk.Value import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents -import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatusError +import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatus import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -202,16 +202,10 @@ class MultiProvider( } private suspend fun handleProviderEvent(provider: ChildFeatureProvider, event: OpenFeatureProviderEvents) { - val newChildStatus = when (event) { - // ProviderConfigurationChanged events should always re-emit - is OpenFeatureProviderEvents.ProviderConfigurationChanged -> { - eventFlow.emit(event) - return - } - - is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready - is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale - is OpenFeatureProviderEvents.ProviderError -> event.toOpenFeatureStatusError() + // Events carrying no status transition (e.g. ProviderConfigurationChanged) always re-emit. + val newChildStatus = event.toOpenFeatureStatus() ?: run { + eventFlow.emit(event) + return } val previousStatus = _statusFlow.value diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DeveloperExperienceTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DeveloperExperienceTests.kt index f735f4c8..9be0769e 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 @@ -231,13 +231,15 @@ class DeveloperExperienceTests { OpenFeatureAPI.shutdown() testScheduler.advanceUntilIdle() job.cancelAndJoin() - assertEquals(5, emittedStatuses.size) + // statusFlow is a snapshot: the transient Reconciling between onContextSet starting and + // completing is conflated away here. StatusTests covers that transition against a provider + // that holds onContextSet open. + assertEquals(4, emittedStatuses.size) assertTrue(emittedStatuses[0] is OpenFeatureStatus.NotReady) assertTrue(emittedStatuses[1] is OpenFeatureStatus.Error) assertTrue((emittedStatuses[1] as OpenFeatureStatus.Error).error is OpenFeatureError.ProviderNotReadyError) - assertTrue(emittedStatuses[2] is OpenFeatureStatus.Reconciling) - assertTrue(emittedStatuses[3] is OpenFeatureStatus.Ready) - assertTrue(emittedStatuses[4] is OpenFeatureStatus.NotReady) + assertTrue(emittedStatuses[2] is OpenFeatureStatus.Ready) + assertTrue(emittedStatuses[3] is OpenFeatureStatus.NotReady) } @Test @@ -326,6 +328,7 @@ class DeveloperExperienceTests { @Test fun testProviderEventFlowShouldSupportFiltering() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) val provider = OverlyEmittingProvider("Overly Emitting Provider") val staleEvents = mutableListOf() val job = launch { @@ -334,11 +337,14 @@ class DeveloperExperienceTests { staleEvents.add(it) } } + // Let the collector subscribe: the SDK relays events live and does not replay past ones. + testScheduler.runCurrent() // emits ProviderReady OpenFeatureAPI.setProviderAndWait( provider, - initialContext = ImmutableContext("first") + initialContext = ImmutableContext("first"), + dispatcher = testDispatcher ) // emits ProviderStale + ProviderStale + ProviderStale OpenFeatureAPI.getClient().track("hello-world") @@ -349,15 +355,9 @@ class DeveloperExperienceTests { OpenFeatureAPI.shutdown() job.cancelAndJoin() - assertEquals( - listOf( - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale() - ), - staleEvents - ) + assertEquals(4, staleEvents.size) + assertTrue(staleEvents.all { it is OpenFeatureProviderEvents.ProviderStale }) + assertTrue(staleEvents.all { it.eventDetails?.providerName == "Overly Emitting Provider" }) } @Test @@ -386,29 +386,13 @@ class DeveloperExperienceTests { OpenFeatureAPI.setProviderAndWait(provider) OpenFeatureAPI.setEvaluationContextAndWait(context) - val emittedStatuses = mutableListOf() - val job = launch { - OpenFeatureAPI.statusFlow.collect { - emittedStatuses.add(it) - } - } - testScheduler.advanceUntilIdle() - OpenFeatureAPI.setEvaluationContextAndWait(context) testScheduler.advanceUntilIdle() - job.cancelAndJoin() assertEquals(2, provider.onContextSetCalls.size) assertTrue(provider.onContextSetCalls[1].first === context) assertTrue(provider.onContextSetCalls[1].second === context) - assertEquals( - listOf( - OpenFeatureStatus.Ready, - OpenFeatureStatus.Reconciling, - OpenFeatureStatus.Ready - ), - emittedStatuses - ) + assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) } @Test diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/EventDetailsTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/EventDetailsTests.kt index ccdfdd0c..cb69e22c 100644 --- a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/EventDetailsTests.kt +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/EventDetailsTests.kt @@ -1,12 +1,14 @@ package dev.openfeature.kotlin.sdk import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatus import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatusError import dev.openfeature.kotlin.sdk.exceptions.ErrorCode import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNull class EventDetailsTests { @@ -79,4 +81,72 @@ class EventDetailsTests { val err = assertIs(errorStatus.error) assertEquals("test", err.message) } + + @Test + fun readyEventMapsToReadyStatus() { + assertEquals( + OpenFeatureStatus.Ready, + OpenFeatureProviderEvents.ProviderReady().toOpenFeatureStatus() + ) + } + + @Test + fun staleEventMapsToStaleStatus() { + assertEquals( + OpenFeatureStatus.Stale, + OpenFeatureProviderEvents.ProviderStale().toOpenFeatureStatus() + ) + } + + @Test + fun reconcilingEventMapsToReconcilingStatus() { + assertEquals( + OpenFeatureStatus.Reconciling, + OpenFeatureProviderEvents.ProviderReconciling().toOpenFeatureStatus() + ) + } + + @Test + fun contextChangedEventMapsToReadyStatus() { + assertEquals( + OpenFeatureStatus.Ready, + OpenFeatureProviderEvents.ProviderContextChanged().toOpenFeatureStatus() + ) + } + + @Test + fun configurationChangedEventMapsToNoStatusTransition() { + assertNull(OpenFeatureProviderEvents.ProviderConfigurationChanged().toOpenFeatureStatus()) + } + + @Test + fun errorEventMapsThroughToErrorStatus() { + val evt = OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + message = "flag missing", + errorCode = ErrorCode.FLAG_NOT_FOUND + ) + ) + + val error = assertIs(evt.toOpenFeatureStatus()) + assertIs(error.error) + } + + @Test + fun errorEventWithFatalCodeMapsThroughToFatalStatus() { + val evt = OpenFeatureProviderEvents.ProviderError( + OpenFeatureProviderEvents.EventDetails( + message = "unrecoverable", + errorCode = ErrorCode.PROVIDER_FATAL + ) + ) + + assertIs(evt.toOpenFeatureStatus()) + } + + @Test + fun eventDetailsCarryProviderName() { + val details = OpenFeatureProviderEvents.EventDetails(providerName = "my-provider") + assertEquals("my-provider", details.providerName) + } } \ No newline at end of file diff --git a/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderEventRelayTests.kt b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderEventRelayTests.kt new file mode 100644 index 00000000..70be6a6a --- /dev/null +++ b/kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/ProviderEventRelayTests.kt @@ -0,0 +1,198 @@ +package dev.openfeature.kotlin.sdk + +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents +import dev.openfeature.kotlin.sdk.helpers.DoSomethingProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The SDK republishes provider events itself rather than exposing the provider's stream directly, so + * that the status is always updated before subscribers see the event that caused it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProviderEventRelayTests { + + @BeforeTest + fun setUp() = runTest { + OpenFeatureAPI.shutdown() + } + + private class ControllableEmitter(name: String) : DoSomethingProvider( + metadata = object : ProviderMetadata { + override val name: String = name + } + ) { + private val emissions = MutableSharedFlow(extraBufferCapacity = 16) + + override suspend fun initialize(initialContext: EvaluationContext?) { + emissions.emit(OpenFeatureProviderEvents.ProviderReady()) + } + + override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) { + // Nothing: these tests drive emissions explicitly. + } + + override fun observe(): Flow = emissions + + fun emit(event: OpenFeatureProviderEvents) { + check(emissions.tryEmit(event)) { "Test emitter buffer exhausted" } + } + } + + @Test + fun statusReflectsTheEventBeforeSubscribersSeeIt() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val provider = ControllableEmitter("relay-ordering") + val statusesSeenByHandler = mutableListOf() + + val job = launch { + OpenFeatureAPI.observe().collect { + statusesSeenByHandler.add(OpenFeatureAPI.getStatus()) + } + } + testScheduler.runCurrent() + + OpenFeatureAPI.setProviderAndWait(provider, dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + + provider.emit(OpenFeatureProviderEvents.ProviderStale()) + testScheduler.advanceUntilIdle() + job.cancelAndJoin() + + assertEquals(listOf(OpenFeatureStatus.Stale), statusesSeenByHandler) + } + + @Test + fun handlerAttachedWhenAlreadyReadyRunsImmediately() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + OpenFeatureAPI.setProviderAndWait(ControllableEmitter("late-subscriber"), dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) + + val received = mutableListOf() + val job = launch { + OpenFeatureAPI.observe().collect { received.add(it) } + } + testScheduler.runCurrent() + job.cancelAndJoin() + + assertEquals(1, received.size) + assertEquals("late-subscriber", received.single().eventDetails?.providerName) + } + + @Test + fun handlerAttachedWhenAlreadyStaleReceivesStaleNotReady() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val provider = ControllableEmitter("stale-state") + OpenFeatureAPI.setProviderAndWait(provider, dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + provider.emit(OpenFeatureProviderEvents.ProviderStale()) + testScheduler.advanceUntilIdle() + + val received = mutableListOf() + val job = launch { + OpenFeatureAPI.observe().collect { received.add(it) } + } + testScheduler.runCurrent() + job.cancelAndJoin() + + assertIs(received.single()) + } + + @Test + fun statelessEventIsNotResurfacedForLateSubscribers() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val provider = ControllableEmitter("stateless-event") + OpenFeatureAPI.setProviderAndWait(provider, dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + + // The most recent event carries no status, so it must not be what a late subscriber is told. + provider.emit(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + testScheduler.advanceUntilIdle() + + val received = mutableListOf() + val job = launch { + OpenFeatureAPI.observe().collect { received.add(it) } + } + testScheduler.runCurrent() + job.cancelAndJoin() + + assertIs(received.single()) + } + + @Test + fun eventsFromAReplacedProviderAreNotResurfaced() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val first = ControllableEmitter("first") + OpenFeatureAPI.setProviderAndWait(first, dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + first.emit(OpenFeatureProviderEvents.ProviderStale()) + testScheduler.advanceUntilIdle() + + OpenFeatureAPI.setProviderAndWait(ControllableEmitter("second"), dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + + val received = mutableListOf() + val job = launch { + OpenFeatureAPI.observe().collect { received.add(it) } + } + testScheduler.runCurrent() + job.cancelAndJoin() + + assertIs(received.single()) + assertEquals("second", received.single().eventDetails?.providerName) + } + + @Test + fun shutdownRevertsToNotReadyWithoutEmittingAnEvent() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + OpenFeatureAPI.setProviderAndWait(ControllableEmitter("shutdown"), dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + + val received = mutableListOf() + val job = launch { + OpenFeatureAPI.observe().collect { received.add(it) } + } + testScheduler.runCurrent() + // Subscribing while Ready yields exactly one event, so a later count proves nothing was added. + assertEquals(1, received.size) + + OpenFeatureAPI.shutdown() + testScheduler.advanceUntilIdle() + job.cancelAndJoin() + + assertEquals(OpenFeatureStatus.NotReady, OpenFeatureAPI.getStatus()) + // There is no event type for NOT_READY: the SDK infers the transition instead of emitting. + assertEquals(1, received.size) + } + + @Test + fun everyRelayedEventCarriesTheEmittingProviderName() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val provider = ControllableEmitter("attribution") + val received = mutableListOf() + val job = launch { + OpenFeatureAPI.observe().collect { received.add(it) } + } + testScheduler.runCurrent() + + OpenFeatureAPI.setProviderAndWait(provider, dispatcher = testDispatcher) + testScheduler.advanceUntilIdle() + provider.emit(OpenFeatureProviderEvents.ProviderConfigurationChanged()) + testScheduler.advanceUntilIdle() + job.cancelAndJoin() + + assertTrue(received.isNotEmpty()) + assertTrue(received.all { it.eventDetails?.providerName == "attribution" }) + } +} \ 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 3b6f5d96..f7c882b2 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 @@ -89,6 +89,7 @@ class ProviderEventingTests { @Test fun testProviderEventFlowShouldSupportSwappingProviders() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) val firstProvider = OverlyEmittingProvider("First Provider") val secondProvider = OverlyEmittingProvider("Second Provider") @@ -98,27 +99,32 @@ class ProviderEventingTests { emittedEvents.add(it) } } + // Let the collector subscribe: the SDK relays events live and does not replay past ones. + testScheduler.runCurrent() // emits ProviderReady OpenFeatureAPI.setProviderAndWait( firstProvider, - initialContext = ImmutableContext("first") + initialContext = ImmutableContext("first"), + dispatcher = testDispatcher ) // emits ProviderStale + ProviderConfigurationChanged OpenFeatureAPI.setEvaluationContextAndWait(ImmutableContext("first.v2")) testScheduler.advanceUntilIdle() assertEquals( listOf( - OpenFeatureProviderEvents.ProviderReady(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderConfigurationChanged() + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderConfigurationChanged::class ), - emittedEvents + emittedEvents.map { it::class } ) + assertTrue(emittedEvents.all { it.eventDetails?.providerName == "First Provider" }) // emits ProviderReady OpenFeatureAPI.setProviderAndWait( secondProvider, - initialContext = ImmutableContext("second") + initialContext = ImmutableContext("second"), + dispatcher = testDispatcher ) testScheduler.advanceUntilIdle() // emits ProviderStale + ProviderStale + ProviderStale @@ -133,22 +139,28 @@ class ProviderEventingTests { job.cancelAndJoin() assertEquals( listOf( - OpenFeatureProviderEvents.ProviderReady(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderConfigurationChanged(), - OpenFeatureProviderEvents.ProviderReady(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderStale(), - OpenFeatureProviderEvents.ProviderConfigurationChanged() + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderConfigurationChanged::class, + OpenFeatureProviderEvents.ProviderReady::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderStale::class, + OpenFeatureProviderEvents.ProviderConfigurationChanged::class ), - emittedEvents + emittedEvents.map { it::class } + ) + // The relay attributes each event to whichever provider was active when it was emitted. + assertEquals( + List(3) { "First Provider" } + List(6) { "Second Provider" }, + emittedEvents.map { it.eventDetails?.providerName } ) } @Test fun clientObserveMatchesApiObserveWhenCollectingAllProviderEvents() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) val provider = OverlyEmittingProvider("Client parity provider") val fromApi = mutableListOf() val fromClient = mutableListOf() @@ -160,8 +172,14 @@ class ProviderEventingTests { val clientJob = launch { client.observe().collect { fromClient.add(it) } } + // Let both collectors subscribe: the SDK relays events live and does not replay past ones. + testScheduler.runCurrent() - OpenFeatureAPI.setProviderAndWait(provider, initialContext = ImmutableContext("ctx")) + OpenFeatureAPI.setProviderAndWait( + provider, + initialContext = ImmutableContext("ctx"), + dispatcher = testDispatcher + ) testScheduler.advanceUntilIdle() OpenFeatureAPI.shutdown() apiJob.cancelAndJoin() @@ -173,6 +191,7 @@ class ProviderEventingTests { @Test fun clientObserveFiltersByReifiedEventType() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) val provider = OverlyEmittingProvider("filter-by-type") val client = OpenFeatureAPI.getClient("filter-by-type") val staleEvents = mutableListOf() @@ -189,8 +208,14 @@ class ProviderEventingTests { .filterIsInstance() .collect { configurationChangedEvents.add(it) } } + // Let both collectors subscribe: the SDK relays events live and does not replay past ones. + testScheduler.runCurrent() - OpenFeatureAPI.setProviderAndWait(provider, initialContext = ImmutableContext("ctx")) + OpenFeatureAPI.setProviderAndWait( + provider, + initialContext = ImmutableContext("ctx"), + dispatcher = testDispatcher + ) testScheduler.advanceUntilIdle() OpenFeatureAPI.setEvaluationContextAndWait(ImmutableContext("ctx.v2")) testScheduler.advanceUntilIdle() @@ -198,10 +223,9 @@ class ProviderEventingTests { staleJob.cancelAndJoin() configJob.cancelAndJoin() - assertEquals(listOf(OpenFeatureProviderEvents.ProviderStale()), staleEvents) - assertEquals( - listOf(OpenFeatureProviderEvents.ProviderConfigurationChanged()), - configurationChangedEvents - ) + assertEquals(1, staleEvents.size) + assertEquals(1, configurationChangedEvents.size) + assertEquals("filter-by-type", staleEvents.single().eventDetails?.providerName) + assertEquals("filter-by-type", configurationChangedEvents.single().eventDetails?.providerName) } } \ No newline at end of file