Skip to content

Conversation

@typotter
Copy link
Contributor

@typotter typotter commented Dec 19, 2025

What does this PR do?

This PR changes the way that the current state's access and mutation is handled to ensure the ordering of state changes delivered to listeners. It also emphasizes the need for usercode listeners to catch their own exceptions lest they bubble and crash the app.

Motivation

As noted in #2998, the currentState was updated synchronously erroneously and prevented other components from realizing that state directly after updating

The decision was made to bubble usercode exceptions in order to fail fast and surface those errors to the dev rather than catching and logging them in our dispatch handler.

Additional Notes

Anything else we should know when reviewing?

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

@datadog-official
Copy link

datadog-official bot commented Dec 19, 2025

🎯 Code Coverage
Patch Coverage: 100.00%
Overall Coverage: 66.48% (-4.77%)

View detailed report

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 9d4291c | Docs | Datadog PR Page | Was this helpful? Give us feedback!

@codecov-commenter
Copy link

codecov-commenter commented Dec 19, 2025

Codecov Report

❌ Patch coverage is 77.77778% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.50%. Comparing base (17c117e) to head (9d4291c).
⚠️ Report is 38 commits behind head on develop.

Files with missing lines Patch % Lines
...in/kotlin/com/datadog/android/flags/FlagsClient.kt 0.00% 1 Missing ⚠️
...atadog/android/flags/internal/FlagsStateManager.kt 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3068      +/-   ##
===========================================
+ Coverage    71.40%   71.50%   +0.10%     
===========================================
  Files          881      881              
  Lines        32460    32471      +11     
  Branches      5466     5475       +9     
===========================================
+ Hits         23178    23218      +40     
+ Misses        7729     7717      -12     
+ Partials      1553     1536      -17     
Files with missing lines Coverage Δ
...in/kotlin/com/datadog/android/flags/FlagsClient.kt 36.52% <0.00%> (+2.31%) ⬆️
...atadog/android/flags/internal/FlagsStateManager.kt 92.31% <87.50%> (+5.94%) ⬆️

... and 42 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

currentState = newState
subscription.notifyListeners {
onStateChanged(newState)
executorService.executeSafe(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enqueue safe executions of the listeners instead of safe execution of the queue. Now listeners cannot not crash the rest

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move synchronized protection inside DDCoreSubscriptionImpl?, it seems to me the job of DDCoreSubscriptionImpl to guarantee the thread-safety no matter where it is used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DDCoreSubscription ensures atomicity wrt modifying the subscriber list during a notification loop. Our synchronized here is layering atomicity linking the notification mechanics of DDCoreSubscription with mutation of the currentState. This coupling is a specific use case of the DDCoreSubscription or another layer on top.

DDCoreSubscription is still susceptible to listeners crashing the notification loop. We could put a try-catch there, but it might be better to keep the onus on the caller (which easy to do via the block argument passed to notifyListeners.

wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need do executorService.executeSafe instead of simply calling onStateChanged(newState)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

originally, it was to ensure fairness of state changes - which we've actually lost here with the synchronized block (switched to a ReentrantLock now)

The executor lets us run the subscription consumer off-thread and not block the caller to setEvaluationContext on potential long running consumers. Would it be better to leave the thread mgmt to the caller here? Would the caller need to run their consumer specifically in the UI thread (or some other specific thread?)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to leave the thread mgmt to the caller here?

I would say yes, it is up to the caller to do it unless we are picky about listener performance and for us it is critical for it to be fast. And then we can wrap simply onStateChanged calls in try-catch if we want to continue work after consumer threw exception. But then this exception will be handled, so maybe it is actually better to let it propagate and crash the app (since it is exception from the customer code).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am in favour of the more straightforward approach here where we put onus for threading and exception handling on the user. Fail-fast prevents us from hiding exceptions from the dev and clear docs should point them in the right direction.

@typotter typotter changed the title FFL-1621 FFL-1621 Fix: set currentState in a blocking call Dec 19, 2025
@typotter typotter marked this pull request as ready for review December 19, 2025 08:27
@typotter typotter requested a review from a team as a code owner December 19, 2025 08:27
Copy link
Contributor Author

@typotter typotter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ambushwork ptal.

currentState = newState
subscription.notifyListeners {
onStateChanged(newState)
executorService.executeSafe(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DDCoreSubscription ensures atomicity wrt modifying the subscriber list during a notification loop. Our synchronized here is layering atomicity linking the notification mechanics of DDCoreSubscription with mutation of the currentState. This coupling is a specific use case of the DDCoreSubscription or another layer on top.

DDCoreSubscription is still susceptible to listeners crashing the notification loop. We could put a try-catch there, but it might be better to keep the onus on the caller (which easy to do via the block argument passed to notifyListeners.

wdyt?

Comment on lines 40 to 39
* The fair parameter ensures that threads acquire the lock in the order they requested it.
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

threads acquire the lock in the order they requested it

Does it really matter actually? I'm wondering if it is better to use ReadWriteLock since probably the number of read operations will dominate over the number of write operations, or is it a wrong assumption?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. We could use a ReentrantReadWriteLock to allow reads and still provide ordering for writes (to maintain the ordering of state change notifications sent to listeners).

In practice, I think the direct reads will not be that high, as the use case will typically be to, once on app load:

  1. check the current state
  2. listen to state changes (typically, to then load flag-critical parts of the app when the provider is READY)

At any rate, it makes sense to allow the current state to be read concurrently with notification operations. Thanks!

currentState = newState
subscription.notifyListeners {
onStateChanged(newState)
executorService.executeSafe(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to leave the thread mgmt to the caller here?

I would say yes, it is up to the caller to do it unless we are picky about listener performance and for us it is critical for it to be fast. And then we can wrap simply onStateChanged calls in try-catch if we want to continue work after consumer threw exception. But then this exception will be handled, so maybe it is actually better to let it propagate and crash the app (since it is exception from the customer code).

Copy link
Contributor Author

@typotter typotter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy new year!
Addressed the executor and lock suggestions. PTAL/

Comment on lines 40 to 39
* The fair parameter ensures that threads acquire the lock in the order they requested it.
*/
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. We could use a ReentrantReadWriteLock to allow reads and still provide ordering for writes (to maintain the ordering of state change notifications sent to listeners).

In practice, I think the direct reads will not be that high, as the use case will typically be to, once on app load:

  1. check the current state
  2. listen to state changes (typically, to then load flag-critical parts of the app when the provider is READY)

At any rate, it makes sense to allow the current state to be read concurrently with notification operations. Thanks!

currentState = newState
subscription.notifyListeners {
onStateChanged(newState)
executorService.executeSafe(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am in favour of the more straightforward approach here where we put onus for threading and exception handling on the user. Fail-fast prevents us from hiding exceptions from the dev and clear docs should point them in the right direction.

) {
val state = currentState
listener.onStateChanged(state)
stateLock.write {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in fact both addListener and removeListener don't need to be wrapped in the lock, because DDCoreSubscription is using copy-on-write under the hood, so changes to the subscription won't affect ongoing subscription.notifyListeners call

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to ensure that the listener gets the current state here before any other state is broadcast, so we lock the current state until done. However, if we notify listener with the current state before adding it to the listeners, we can get by without the lock.

dropped the lock for removeListener as well - DDCoreSubscription is indeed sufficient here

typotter and others added 3 commits January 6, 2026 08:53
Co-authored-by: Nikita Ogorodnikov <4046447+0xnm@users.noreply.github.com>
0xnm
0xnm previously approved these changes Jan 6, 2026
Copy link
Member

@0xnm 0xnm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm. Maybe you would like to get also a look from @aleksandr-gringauz before merging.

@typotter
Copy link
Contributor Author

typotter commented Jan 6, 2026

Thanks Nikita!

@aleksandr-gringauz
Copy link
Contributor

@codex review

@aleksandr-gringauz
Copy link
Contributor

I think I found one small problem (not 100% sure), let's see if codex finds it.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ad003c3de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 74 to 76
override fun addListener(listener: FlagsStateListener) {
listener.onStateChanged(currentState)
subscription.addListener(listener)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register listener after state read under lock

In addListener the new listener is invoked before registration and without acquiring stateLock even though currentState is only written under that lock and is no longer @Volatile. If updateState is writing at the same time, this path can read a stale state and deliver it before the listener is ever added, so an in-flight transition (e.g., Ready) is missed and the subscriber remains out of sync. The initial callback should be issued while holding the read lock and after the listener is added to avoid lost updates.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This is it.

I think it is better to put these 2 lines under the read-lock:

stateLock.read {
    listener.onStateChanged(currentState)
    subscription.addListener(listener)
}

Because otherwise in case of concurrent addListener(listener_1) and updateState(new_state) listener_1 might end up not with the latest state in the end.

The order could be the following:

  1. addListener(listener_1) and updateState(state_new) are called concurrently from different threads.
  2. read of currentState occurs in addListener and returns state_old.
  3. updateState does its thing, updating currentState=state_new and notifying listeners. But not listener_1, because it hasn't been added yet.
  4. listener.onStateChanged(state_old) inside addListener is called.
  5. listener_1 ends up with state_old.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverting back to the read lock here!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rewrapped the state access + addListener in the read lock plus added a test. ptal

@typotter typotter requested a review from 0xnm January 6, 2026 17:31
@typotter
Copy link
Contributor Author

typotter commented Jan 6, 2026

pushed a Small fix. PTAL for final merge

@typotter
Copy link
Contributor Author

typotter commented Jan 6, 2026

/merge

@dd-devflow-routing-codex
Copy link

dd-devflow-routing-codex bot commented Jan 6, 2026

View all feedbacks in Devflow UI.

2026-01-06 18:42:20 UTC ℹ️ Start processing command /merge


2026-01-06 18:42:26 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in develop is approximately 1h (p90).


2026-01-06 19:48:28 UTC ℹ️ MergeQueue: This merge request was merged

@dd-mergequeue dd-mergequeue bot merged commit 7b2040f into develop Jan 6, 2026
27 checks passed
@dd-mergequeue dd-mergequeue bot deleted the typo/FFL-1621-sdk-android branch January 6, 2026 19:48
typotter added a commit that referenced this pull request Jan 6, 2026
Cherry-picked from PR #3068 to enable OpenFeature provider implementation.

Adds state observation capabilities to FlagsClient with thread-safe
synchronous state management using ReentrantReadWriteLock.

Key changes:
- FlagsStateManager: Synchronous state updates with read/write lock
- State notification: Listeners called directly (no executor)
- Thread safety: Caller manages threading and exception handling
- Test updates: Enhanced coverage for state management

Original PR: #3068
Branch: typo/FFL-1621-sdk-android
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants