Skip to content

Commit a818129

Browse files
committed
PR fixes and small improvements
Signed-off-by: Max Pinheiro <max.pinheiro@fluxon.com>
1 parent d08f327 commit a818129

14 files changed

Lines changed: 171 additions & 122 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,8 @@ public final class dev/openfeature/kotlin/sdk/ProviderStatusTracker {
407407
public fun <init> ()V
408408
public final fun getStatus ()Lkotlinx/coroutines/flow/StateFlow;
409409
public final fun observe ()Lkotlinx/coroutines/flow/Flow;
410-
public final fun send (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;)V
410+
public final fun send (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;Ldev/openfeature/kotlin/sdk/OpenFeatureStatus;)V
411+
public static synthetic fun send$default (Ldev/openfeature/kotlin/sdk/ProviderStatusTracker;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;Ldev/openfeature/kotlin/sdk/OpenFeatureStatus;ILjava/lang/Object;)V
411412
}
412413

413414
public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,8 @@ public final class dev/openfeature/kotlin/sdk/ProviderStatusTracker {
407407
public fun <init> ()V
408408
public final fun getStatus ()Lkotlinx/coroutines/flow/StateFlow;
409409
public final fun observe ()Lkotlinx/coroutines/flow/Flow;
410-
public final fun send (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;)V
410+
public final fun send (Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;Ldev/openfeature/kotlin/sdk/OpenFeatureStatus;)V
411+
public static synthetic fun send$default (Ldev/openfeature/kotlin/sdk/ProviderStatusTracker;Ldev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents;Ldev/openfeature/kotlin/sdk/OpenFeatureStatus;ILjava/lang/Object;)V
411412
}
412413

413414
public final class dev/openfeature/kotlin/sdk/Reason : java/lang/Enum {

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/FeatureProvider.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,23 @@ import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
44
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
55
import kotlinx.coroutines.flow.Flow
66
import kotlinx.coroutines.flow.emptyFlow
7+
import kotlin.DeprecationLevel
8+
import kotlin.ReplaceWith
79
import kotlin.coroutines.cancellation.CancellationException
810

11+
/**
12+
* Flag evaluation entry point for a feature flag backend.
13+
*
14+
* Plain implementations remain supported; the SDK may wrap them when registered.
15+
*/
16+
@Deprecated(
17+
message = "Implement StateManagingProvider for integrated lifecycle status and provider events.",
18+
replaceWith = ReplaceWith(
19+
expression = "StateManagingProvider",
20+
imports = ["dev.openfeature.kotlin.sdk.StateManagingProvider"]
21+
),
22+
level = DeprecationLevel.WARNING
23+
)
924
interface FeatureProvider {
1025
val hooks: List<Hook<*>>
1126
val metadata: ProviderMetadata

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/LegacyFeatureProviderAdapter.kt

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dev.openfeature.kotlin.sdk
22

33
import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
44
import dev.openfeature.kotlin.sdk.events.toOpenFeatureStatus
5+
import dev.openfeature.kotlin.sdk.exceptions.ErrorCode
56
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
67
import kotlinx.coroutines.CancellationException
78
import kotlinx.coroutines.CoroutineDispatcher
@@ -10,9 +11,12 @@ import kotlinx.coroutines.Job
1011
import kotlinx.coroutines.SupervisorJob
1112
import kotlinx.coroutines.cancel
1213
import kotlinx.coroutines.flow.Flow
14+
import kotlinx.coroutines.flow.MutableSharedFlow
1315
import kotlinx.coroutines.flow.MutableStateFlow
1416
import kotlinx.coroutines.flow.StateFlow
17+
import kotlinx.coroutines.flow.asSharedFlow
1518
import kotlinx.coroutines.flow.asStateFlow
19+
import kotlinx.coroutines.flow.collect
1620
import kotlinx.coroutines.launch
1721
import kotlinx.coroutines.yield
1822

@@ -33,18 +37,20 @@ internal class LegacyFeatureProviderAdapter(
3337

3438
private val _status = MutableStateFlow<OpenFeatureStatus>(OpenFeatureStatus.NotReady)
3539
override val status: StateFlow<OpenFeatureStatus> = _status.asStateFlow()
40+
private val eventFlow = MutableSharedFlow<OpenFeatureProviderEvents>(replay = 1, extraBufferCapacity = 5)
3641

3742
private val scope = CoroutineScope(SupervisorJob() + eventDispatcher)
3843
private var observeJob: Job? = null
3944

40-
override fun observe(): Flow<OpenFeatureProviderEvents> = inner.observe()
45+
override fun observe(): Flow<OpenFeatureProviderEvents> = eventFlow.asSharedFlow()
4146

4247
override suspend fun initialize(initialContext: EvaluationContext?) {
4348
observeJob?.cancel(CancellationException("Provider job was cancelled due to new provider"))
4449
_status.value = OpenFeatureStatus.NotReady
4550
observeJob = scope.launch {
4651
inner.observe().collect { event ->
4752
event.toOpenFeatureStatus()?.let { _status.value = it }
53+
eventFlow.emit(event)
4854
}
4955
}
5056
try {
@@ -56,20 +62,30 @@ internal class LegacyFeatureProviderAdapter(
5662
}
5763

5864
/**
59-
* [inner] is shut down first while the [observeJob] is still running so a provider can emit
60-
* a final [OpenFeatureProviderEvents] on [FeatureProvider.observe] and this adapter can still
61-
* apply [toOpenFeatureStatus] to [_status]. The observe job and [scope] (including
62-
* [SupervisorJob]) are cancelled in [finally] so cleanup and resource release always run.
65+
* Releases the adapter and underlying provider; updates [status] and [observe] per
66+
* [StateManagingProvider.shutdown], then tears down coroutine resources.
6367
*/
6468
override fun shutdown() {
6569
try {
66-
inner.shutdown()
70+
observeJob?.cancel(
71+
CancellationException("Provider event observe job was cancelled due to shutdown")
72+
)
73+
observeJob = null
74+
6775
_status.value = OpenFeatureStatus.NotReady
76+
eventFlow.tryEmit(
77+
OpenFeatureProviderEvents.ProviderError(
78+
OpenFeatureProviderEvents.EventDetails(
79+
message = "Provider shut down; not ready for evaluation",
80+
errorCode = ErrorCode.PROVIDER_NOT_READY
81+
)
82+
)
83+
)
84+
85+
inner.shutdown()
6886
} catch (e: Throwable) {
6987
handleError(e)
7088
} finally {
71-
observeJob?.cancel(CancellationException("Provider event observe job was cancelled due to shutdown"))
72-
observeJob = null
7389
scope.cancel(
7490
CancellationException("LegacyFeatureProviderAdapter scope cancelled due to shutdown")
7591
)

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/ProviderStatusTracker.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import kotlinx.coroutines.flow.asStateFlow
1313

1414
/**
1515
* Single place for [OpenFeatureStatus] and the provider event stream: call [send] for each lifecycle
16-
* step; [status] is derived with the same [toOpenFeatureStatus] rules as the rest of the SDK. Do not
17-
* set readiness from another [StateFlow] outside [send]. For [StateManagingProvider], expose
16+
* step. By default [status] is updated from [event] using [toOpenFeatureStatus]. Pass an explicit
17+
* [statusUpdate] when the snapshot differs (for example aggregated status in a multi-provider).
18+
* Do not set readiness from another [StateFlow] outside [send]. For [StateManagingProvider], expose
1819
* [status] and [observe] from the tracker, or use [OpenFeatureAPI.statusFlow] for readiness if registered.
1920
*
2021
* [observe] uses a [MutableSharedFlow] with `replay = 1`. New subscribers replay the
@@ -31,9 +32,12 @@ class ProviderStatusTracker {
3132

3233
val status: StateFlow<OpenFeatureStatus> = _status.asStateFlow()
3334

34-
fun send(event: OpenFeatureProviderEvents) {
35+
fun send(
36+
event: OpenFeatureProviderEvents,
37+
statusUpdate: OpenFeatureStatus? = event.toOpenFeatureStatus()
38+
) {
3539
synchronized(providerMutex) {
36-
event.toOpenFeatureStatus()?.let { _status.value = it }
40+
statusUpdate?.let { _status.value = it }
3741
_events.tryEmit(event)
3842
}
3943
}

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/StateManagingProvider.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.openfeature.kotlin.sdk
22

33
import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
4+
import dev.openfeature.kotlin.sdk.exceptions.ErrorCode
45
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
56
import kotlinx.coroutines.flow.Flow
67
import kotlinx.coroutines.flow.StateFlow
@@ -10,8 +11,7 @@ import kotlin.coroutines.cancellation.CancellationException
1011
* Provider that owns lifecycle [OpenFeatureStatus] in [status] and reports changes on [observe].
1112
*
1213
* Implementations must update [status] first, then emit on [observe], so readers of either see a
13-
* consistent order. Plain [FeatureProvider] implementations are wrapped internally; their status is
14-
* derived from [observe] via an SDK adapter.
14+
* consistent order.
1515
*/
1616
interface StateManagingProvider : FeatureProvider {
1717
/**
@@ -39,7 +39,8 @@ interface StateManagingProvider : FeatureProvider {
3939
* Called when the client lifecycle ends; release resources and threads.
4040
*
4141
* Before returning: set [status] to [OpenFeatureStatus.NotReady], then emit
42-
* [OpenFeatureProviderEvents.ProviderError] on [observe].
42+
* [OpenFeatureProviderEvents.ProviderError] on [observe] with [ErrorCode.PROVIDER_NOT_READY] in
43+
* [OpenFeatureProviderEvents.EventDetails.errorCode].
4344
*/
4445
override fun shutdown()
4546

@@ -58,12 +59,11 @@ interface StateManagingProvider : FeatureProvider {
5859
override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext)
5960

6061
/**
61-
* Provider lifecycle events. Replace [FeatureProvider.observe]'s empty default.
62+
* Lifecycle and provider events for this instance.
6263
*
63-
* Implementations update [status] before each matching emission. For [initialize], set [status]
64-
* first, then emit [OpenFeatureProviderEvents.ProviderReady] or
65-
* [OpenFeatureProviderEvents.ProviderError], or the SDK remains [OpenFeatureStatus.NotReady]. Other
66-
* events surface through [OpenFeatureAPI.observe].
64+
* For each step, update [status] first, then emit the matching [OpenFeatureProviderEvents] on this
65+
* flow; required pairings for [initialize], [shutdown], and [onContextSet] are described on those
66+
* methods.
6767
*/
6868
override fun observe(): Flow<OpenFeatureProviderEvents>
6969
}

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/events/OpenFeatureProviderEvents.kt

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,38 +52,27 @@ sealed class OpenFeatureProviderEvents {
5252
data class ProviderReconciling(
5353
override val eventDetails: EventDetails? = null
5454
) : OpenFeatureProviderEvents()
55-
56-
@Deprecated("Use ProviderError instead", ReplaceWith("ProviderError"))
57-
data object ProviderNotReady : OpenFeatureProviderEvents() {
58-
override val eventDetails = null
59-
}
6055
}
6156

6257
/**
63-
* Maps lifecycle events to [OpenFeatureStatus], or null when the event does not change readiness
64-
* (for example [OpenFeatureProviderEvents.ProviderConfigurationChanged]).
58+
* Derives the [OpenFeatureStatus] implied by this event for status aggregation and tracking, if any.
6559
*
66-
* [OpenFeatureProviderEvents.ProviderError] with [ErrorCode.PROVIDER_NOT_READY] matches the deprecated
67-
* [OpenFeatureProviderEvents.ProviderNotReady] object: both map to [OpenFeatureStatus.NotReady] for
68-
* aggregation and status derivation.
60+
* Returns `null` when the event does not represent a status transition from the SDK's perspective.
6961
*/
7062
internal fun OpenFeatureProviderEvents.toOpenFeatureStatus(): OpenFeatureStatus? = when (this) {
7163
is OpenFeatureProviderEvents.ProviderReady -> OpenFeatureStatus.Ready
72-
is OpenFeatureProviderEvents.ProviderNotReady -> OpenFeatureStatus.NotReady
7364
is OpenFeatureProviderEvents.ProviderReconciling -> OpenFeatureStatus.Reconciling
7465
is OpenFeatureProviderEvents.ProviderStale -> OpenFeatureStatus.Stale
75-
is OpenFeatureProviderEvents.ProviderError -> when (eventDetails?.errorCode) {
76-
ErrorCode.PROVIDER_NOT_READY -> OpenFeatureStatus.NotReady
77-
else -> toOpenFeatureStatusError()
78-
}
7966
is OpenFeatureProviderEvents.ProviderConfigurationChanged -> null
67+
is OpenFeatureProviderEvents.ProviderError -> toOpenFeatureStatusError()
8068
else -> null
8169
}
8270

8371
internal fun OpenFeatureProviderEvents.ProviderError.toOpenFeatureStatusError(): OpenFeatureStatus {
8472
val code = eventDetails?.errorCode ?: return OpenFeatureStatus.Error(
8573
OpenFeatureError.GeneralError(eventDetails?.message ?: "Unspecified error")
8674
)
75+
if (code == ErrorCode.PROVIDER_NOT_READY) return OpenFeatureStatus.NotReady
8776
val openFeatureError = OpenFeatureError.fromMessageAndErrorCode(
8877
errorMessage = eventDetails.message ?: "Provider did not supply an error message",
8978
errorCode = code

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/multiprovider/MultiProvider.kt

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import dev.openfeature.kotlin.sdk.Hook
66
import dev.openfeature.kotlin.sdk.OpenFeatureStatus
77
import dev.openfeature.kotlin.sdk.ProviderEvaluation
88
import dev.openfeature.kotlin.sdk.ProviderMetadata
9+
import dev.openfeature.kotlin.sdk.ProviderStatusTracker
910
import dev.openfeature.kotlin.sdk.StateManagingProvider
1011
import dev.openfeature.kotlin.sdk.TrackingEventDetails
1112
import dev.openfeature.kotlin.sdk.Value
@@ -21,14 +22,9 @@ import kotlinx.coroutines.async
2122
import kotlinx.coroutines.awaitAll
2223
import kotlinx.coroutines.coroutineScope
2324
import kotlinx.coroutines.flow.Flow
24-
import kotlinx.coroutines.flow.MutableSharedFlow
25-
import kotlinx.coroutines.flow.MutableStateFlow
2625
import kotlinx.coroutines.flow.StateFlow
27-
import kotlinx.coroutines.flow.asSharedFlow
28-
import kotlinx.coroutines.flow.asStateFlow
2926
import kotlinx.coroutines.flow.launchIn
3027
import kotlinx.coroutines.flow.onEach
31-
import kotlinx.coroutines.flow.update
3228
import kotlinx.coroutines.launch
3329

3430
/**
@@ -128,15 +124,13 @@ class MultiProvider(
128124
}
129125
}
130126

131-
private val _statusFlow = MutableStateFlow<OpenFeatureStatus>(OpenFeatureStatus.NotReady)
127+
private val statusTracker = ProviderStatusTracker()
132128

133-
override val status: StateFlow<OpenFeatureStatus> = _statusFlow.asStateFlow()
129+
override val status: StateFlow<OpenFeatureStatus> = statusTracker.status
134130

135131
// Legacy path: Add getter to return the status as statusFlow, an alias for backwards compatibility
136132
val statusFlow get() = status
137133

138-
private val eventFlow = MutableSharedFlow<OpenFeatureProviderEvents>(replay = 1, extraBufferCapacity = 5)
139-
140134
// Track individual provider statuses, initial state of all providers is NotReady
141135
private val childProviderStatuses: MutableMap<ChildFeatureProvider, OpenFeatureStatus> =
142136
childFeatureProviders.associateWithTo(mutableMapOf()) { OpenFeatureStatus.NotReady }
@@ -174,7 +168,7 @@ class MultiProvider(
174168
*/
175169
internal fun getProviderCount(): Int = childFeatureProviders.size
176170

177-
override fun observe(): Flow<OpenFeatureProviderEvents> = eventFlow.asSharedFlow()
171+
override fun observe(): Flow<OpenFeatureProviderEvents> = statusTracker.observe()
178172

179173
/**
180174
* Initializes all underlying providers with the given context.
@@ -208,23 +202,19 @@ class MultiProvider(
208202
}
209203
}
210204

211-
private suspend fun handleProviderEvent(provider: ChildFeatureProvider, event: OpenFeatureProviderEvents) {
212-
if (event is OpenFeatureProviderEvents.ProviderConfigurationChanged) {
213-
// Configuration-only: forward to observers; aggregate readiness is unchanged.
214-
eventFlow.emit(event)
205+
private fun handleProviderEvent(provider: ChildFeatureProvider, event: OpenFeatureProviderEvents) {
206+
val newChildStatus = event.toOpenFeatureStatus()
207+
if (newChildStatus == null) {
208+
statusTracker.send(event)
215209
return
216210
}
217211

218-
val newChildStatus = event.toOpenFeatureStatus() ?: return
219-
220-
val previousStatus = _statusFlow.value
212+
val previousStatus = statusTracker.status.value
221213
childProviderStatuses[provider] = newChildStatus
222214
val newStatus = calculateAggregateStatus()
223215

224216
if (previousStatus != newStatus) {
225-
_statusFlow.update { newStatus }
226-
// Re-emit the original event that triggered the aggregate status change
227-
eventFlow.emit(event)
217+
statusTracker.send(event, statusUpdate = newStatus)
228218
}
229219
}
230220

@@ -242,6 +232,15 @@ class MultiProvider(
242232
cause = CancellationException("Observe provider events job cancelled due to shutdown")
243233
)
244234

235+
statusTracker.send(
236+
OpenFeatureProviderEvents.ProviderError(
237+
OpenFeatureProviderEvents.EventDetails(
238+
message = "MultiProvider shut down; not ready for evaluation",
239+
errorCode = ErrorCode.PROVIDER_NOT_READY
240+
)
241+
)
242+
)
243+
245244
val shutdownErrors = mutableListOf<Pair<String, Throwable>>()
246245
childFeatureProviders.forEach { provider ->
247246
try {
@@ -267,15 +266,6 @@ class MultiProvider(
267266
}
268267
throw aggregate
269268
}
270-
_statusFlow.value = OpenFeatureStatus.NotReady
271-
eventFlow.tryEmit(
272-
OpenFeatureProviderEvents.ProviderError(
273-
OpenFeatureProviderEvents.EventDetails(
274-
message = "MultiProvider shut down; not ready for evaluation",
275-
errorCode = ErrorCode.PROVIDER_NOT_READY
276-
)
277-
)
278-
)
279269
}
280270

281271
override suspend fun onContextSet(

kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/DeveloperExperienceTests.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ class DeveloperExperienceTests {
328328

329329
@Test
330330
fun testProviderEventFlowShouldSupportFiltering() = runTest {
331+
val testDispatcher = StandardTestDispatcher(testScheduler)
331332
val provider = OverlyEmittingProvider("Overly Emitting Provider")
332333
val staleEvents = mutableListOf<OpenFeatureProviderEvents>()
333334
val job = launch {
@@ -340,8 +341,11 @@ class DeveloperExperienceTests {
340341
// emits ProviderReady
341342
OpenFeatureAPI.setProviderAndWait(
342343
provider,
343-
initialContext = ImmutableContext("first")
344+
initialContext = ImmutableContext("first"),
345+
dispatcher = testDispatcher
344346
)
347+
testScheduler.advanceTimeBy(2000)
348+
testScheduler.advanceUntilIdle()
345349
// emits ProviderStale + ProviderStale + ProviderStale
346350
OpenFeatureAPI.getClient().track("hello-world")
347351

kotlin-sdk/src/commonTest/kotlin/dev/openfeature/kotlin/sdk/EventDetailsTests.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ import kotlin.test.assertIs
1010

1111
class EventDetailsTests {
1212

13+
@Test
14+
fun providerErrorWithProviderNotReadyCodeMapsToNotReadyStatus() {
15+
val evt = OpenFeatureProviderEvents.ProviderError(
16+
OpenFeatureProviderEvents.EventDetails(
17+
message = "warming up",
18+
errorCode = ErrorCode.PROVIDER_NOT_READY
19+
)
20+
)
21+
22+
val status = evt.toOpenFeatureStatusError()
23+
assertEquals(OpenFeatureStatus.NotReady, status)
24+
}
25+
1326
@Test
1427
fun providerErrorEventDetailsMapToFatal() {
1528
val evt = OpenFeatureProviderEvents.ProviderError(

0 commit comments

Comments
 (0)