-
Notifications
You must be signed in to change notification settings - Fork 76
feat: State change notification for flags client #3025
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
feat: State change notification for flags client #3025
Conversation
|
🎯 Code Coverage 🔗 Commit SHA: c8b1143 | Docs | Datadog PR Page | Was this helpful? Give us feedback! |
features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt
Outdated
Show resolved
Hide resolved
features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt
Outdated
Show resolved
Hide resolved
features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt
Outdated
Show resolved
Hide resolved
features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt
Outdated
Show resolved
Hide resolved
...dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt
Outdated
Show resolved
Hide resolved
...k-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt
Outdated
Show resolved
Hide resolved
...dk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt
Outdated
Show resolved
Hide resolved
...dk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt
Outdated
Show resolved
Hide resolved
...lags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt
Outdated
Show resolved
Hide resolved
...lags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt
Outdated
Show resolved
Hide resolved
...d-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt
Outdated
Show resolved
Hide resolved
...dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt
Outdated
Show resolved
Hide resolved
…ral core subscription class
8cfaa65 to
be53823
Compare
typotter
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. I've removed coroutines and updated deps, etc. ptal
features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt
Show resolved
Hide resolved
features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt
Show resolved
Hide resolved
features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt
Show resolved
Hide resolved
...dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt
Show resolved
Hide resolved
detekt_custom_safe_calls.yml
Outdated
| - "kotlinx.coroutines.flow.MutableStateFlow(com.datadog.android.flags.model.FlagsClientState)" | ||
| - "kotlinx.coroutines.flow.MutableStateFlow.asStateFlow()" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
since coroutines are now removed from this module, this is not relevant
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
| /** | ||
| * Observable interface for tracking client state changes. | ||
| * | ||
| * Provides three ways to observe state: | ||
| * - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly) | ||
| * - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin) | ||
| * - Callback: [StateObservable.addListener] for traditional observers (Java-friendly) | ||
| * | ||
| * Example: | ||
| * ```kotlin | ||
| * // Synchronous | ||
| * val current = client.state.getCurrentState() | ||
| * | ||
| * // Reactive Flow | ||
| * client.state.flow.collect { state -> /* ... */ } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this comment is outdated, there is no Flow option
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
| override fun addListener(listener: FlagsStateListener) { /* no-op */ } | ||
| override fun removeListener(listener: FlagsStateListener) { /* no-op */ } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| override fun addListener(listener: FlagsStateListener) { /* no-op */ } | |
| override fun removeListener(listener: FlagsStateListener) { /* no-op */ } | |
| override fun addListener(listener: FlagsStateListener) = Unit | |
| override fun removeListener(listener: FlagsStateListener) = Unit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| @Test | ||
| fun `M return expected value W hasFlags() { for various states }`(forge: Forge) { | ||
| data class TestCase(val given: () -> Unit, val then: Boolean) | ||
|
|
||
| val testCases = listOf( | ||
| TestCase( | ||
| given = { /* no state set */ }, | ||
| then = false | ||
| ), | ||
| TestCase( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is better to split this into different standalone tests
| @Test | ||
| fun `M not block W hasFlags() { persistence still loading }`() { | ||
| // Given | ||
| val startTime = System.currentTimeMillis() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably it is not a right place to start counting time, it is better to move it right before slowRepository.hasFlags() call
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks. good catch
| fun `set up`() { | ||
| whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger | ||
| whenever(mockSdkCore.createSingleThreadExecutorService(FLAGS_CLIENT_EXECUTOR_NAME)) doReturn | ||
| whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn | |
| whenever(mockSdkCore.createSingleThreadExecutorService(any())) doReturn |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| @Test | ||
| fun `M delegate to state manager W state_addListener()`() { | ||
| // Given | ||
| val mockListener = mock(FlagsStateListener::class.java) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| val mockListener = mock(FlagsStateListener::class.java) | |
| val mockListener = mock<FlagsStateListener>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| @Test | ||
| fun `M delegate to state manager W state_removeListener()`() { | ||
| // Given | ||
| val mockListener = mock(FlagsStateListener::class.java) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| val mockListener = mock(FlagsStateListener::class.java) | |
| val mockListener = mock<FlagsStateListener>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| // region addListener / removeListener | ||
|
|
||
| @Test | ||
| fun `M notify listener W addListener() and notify`() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this test is already included by the parametrized test above
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
| testedManager.updateState(FlagsClientState.Ready) | ||
|
|
||
| // Then - no further notifications after removal | ||
| org.mockito.kotlin.verifyNoMoreInteractions(mockListener) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| org.mockito.kotlin.verifyNoMoreInteractions(mockListener) | |
| verifyNoMoreInteractions(mockListener) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
typotter
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks Nikita. ptal
| testedManager.updateState(FlagsClientState.Ready) | ||
|
|
||
| // Then - no further notifications after removal | ||
| org.mockito.kotlin.verifyNoMoreInteractions(mockListener) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| @Test | ||
| fun `M delegate to state manager W state_removeListener()`() { | ||
| // Given | ||
| val mockListener = mock(FlagsStateListener::class.java) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| @Test | ||
| fun `M delegate to state manager W state_addListener()`() { | ||
| // Given | ||
| val mockListener = mock(FlagsStateListener::class.java) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| override fun addListener(listener: FlagsStateListener) { /* no-op */ } | ||
| override fun removeListener(listener: FlagsStateListener) { /* no-op */ } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| /** | ||
| * Observable interface for tracking client state changes. | ||
| * | ||
| * Provides three ways to observe state: | ||
| * - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly) | ||
| * - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin) | ||
| * - Callback: [StateObservable.addListener] for traditional observers (Java-friendly) | ||
| * | ||
| * Example: | ||
| * ```kotlin | ||
| * // Synchronous | ||
| * val current = client.state.getCurrentState() | ||
| * | ||
| * // Reactive Flow | ||
| * client.state.flow.collect { state -> /* ... */ } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
| fun `set up`() { | ||
| whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger | ||
| whenever(mockSdkCore.createSingleThreadExecutorService(FLAGS_CLIENT_EXECUTOR_NAME)) doReturn | ||
| whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| @Test | ||
| fun `M not block W hasFlags() { persistence still loading }`() { | ||
| // Given | ||
| val startTime = System.currentTimeMillis() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks. good catch
detekt_custom_safe_calls.yml
Outdated
| - "kotlinx.coroutines.flow.MutableStateFlow(com.datadog.android.flags.model.FlagsClientState)" | ||
| - "kotlinx.coroutines.flow.MutableStateFlow.asStateFlow()" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
| /** | ||
| * Observable interface for tracking client state changes. | ||
| * | ||
| * Provides three ways to observe state: | ||
| * - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly) | ||
| * - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin) | ||
| * - Callback: [StateObservable.addListener] for traditional observers (Java-friendly) | ||
| * | ||
| * Example: | ||
| * ```kotlin | ||
| * // Synchronous | ||
| * val current = client.state.getCurrentState() | ||
| * | ||
| * // Reactive Flow | ||
| * client.state.flow.collect { state -> /* ... */ } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
| // region addListener / removeListener | ||
|
|
||
| @Test | ||
| fun `M notify listener W addListener() and notify`() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt
Outdated
Show resolved
Hide resolved
|
/merge |
|
View all feedbacks in Devflow UI.
This pull request is not mergeable according to GitHub. Common reasons include pending required checks, missing approvals, or merge conflicts — but it could also be blocked by other repository rules or settings.
tyler.potter@datadoghq.com unqueued this merge request |
|
Thanks. Just need one more stamp for the final change |
|
/remove |
|
View all feedbacks in Devflow UI.
|
Requested changes have been applied; reviewer is on PTO.
|
/merge |
|
View all feedbacks in Devflow UI.
The expected merge time in
Tests failed on this commit 1909853: What to do next?
|
What does this PR do?
Implements state management for
FlagsClientin thedd-sdk-android-flagsmodule, enabling applications and OpenFeature providers to observe client lifecycle states.Key Changes:
FlagsClientStateenum with states:NOT_READY,READY,RECONCILING,ERRORFlagsStateListenerinterface for receiving state change notificationsFlagsClientinterface with state management methods:getCurrentState(),addStateListener(),removeStateListener()FlagsStateChannelwrapper overDDCoreSubscriptionwith semantic notification methodsEvaluationsManagertoDatadogFlagsClientMotivation
This change is a prerequisite for implementing the OpenFeature provider's
observe()method. The OpenFeature specification requires providers to emit events (PROVIDER_READY, PROVIDER_RECONCILING, PROVIDER_ERROR) when their state changes.Without state tracking in the underlying
FlagsClient, the OpenFeature provider has no mechanism to know when flags are loaded, when context changes begin/complete, or when errors occur.Benefits:
observe()specificationAdditional Notes
Design Decisions:
Left out STALE state: The OpenFeature spec includes a
STALEstate for "cached flags may be outdated", but we removed it because there's no current mechanism to determine staleness. Added a note inFlagsClientStatedocs that this may be added in a future release.DDCoreSubscription with synchronization: Used the existing
DDCoreSubscriptionutility for listener management, wrapped withsynchronizedblocks to guarantee ordered delivery of state changes (critical forNOT_READY → RECONCILING → READYflows).FlagsStateChannel abstraction: Created a wrapper class with semantic methods (
notifyReady(),notifyReconciling(), etc.) to abstract DDCoreSubscription internals and improve code readability.State Transition Flow:
Thread Safety:
getCurrentState(): Lock-free atomic readaddStateListener()/removeStateListener(): Thread-safe viaDDCoreSubscriptionReview checklist (to be filled by reviewers)
Review checklist (to be filled by reviewers)