Skip to content

fix(state): prevent multiple DataStore instances for same file#73

Merged
mhamann merged 2 commits into
mainfrom
fix/datastore-singleton-violation
Feb 24, 2026
Merged

fix(state): prevent multiple DataStore instances for same file#73
mhamann merged 2 commits into
mainfrom
fix/datastore-singleton-violation

Conversation

@mhamann
Copy link
Copy Markdown
Contributor

@mhamann mhamann commented Feb 24, 2026

Issue

Fixes critical bug causing fatal crashes in React Native applications with error:

java.lang.IllegalStateException: There are multiple DataStores active for the same file: /data/data/.../files/datastore/rownd_state.json

Root Cause

The defaultDataStore() companion function in StateRepo.kt had a broken singleton pattern. It checked if a DataStore instance existed, but when creating a new one, it failed to assign the result to the companion object's dataStore variable.

Before:

return DataStoreFactory.create(...)  // Creates new instance every time

After:

dataStore = DataStoreFactory.create(...)  // Properly stores singleton
return dataStore!!

Impact

  • Severity: HIGH - Fatal crashes in production
  • Affected: React Native apps using Rownd SDK
  • Trigger: Component re-mounting, activity lifecycle changes, hot reloads

Testing

  • Verified singleton pattern is now correct
  • DataStore instance is properly cached and reused
  • No breaking changes to public API

Fixes multiple DataStore collision errors reported by customers.

Summary by Sourcery

Bug Fixes:

  • Fix a broken singleton pattern in StateRepo so the DataStore instance is cached and reused instead of recreated on each access.

Fixes critical bug where defaultDataStore() creates new DataStore instances
on every call instead of returning the singleton. This caused fatal
IllegalStateException crashes in React Native apps where multiple
DataStores were active for rownd_state.json.

Root cause: DataStoreFactory.create() result was not assigned to the
companion object's dataStore variable, breaking the singleton pattern.

Fixes: Multiple DataStores active for the same file exception
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Feb 24, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Ensures the Rownd state DataStore is a true singleton by caching the created instance in the StateRepo companion object instead of creating a new DataStore on each access, preventing multiple active DataStores for the same file.

Sequence diagram for singleton DataStore access in StateRepo

sequenceDiagram
    participant ReactNativeComponent
    participant StateRepoCompanion as StateRepo_companion
    participant DataStore

    ReactNativeComponent->>StateRepoCompanion: defaultDataStore(context)
    alt dataStore is null
        StateRepoCompanion->>StateRepoCompanion: DataStoreFactory.create(storage, scope, corruptionHandler)
        StateRepoCompanion->>StateRepoCompanion: assign to dataStore
    end
    StateRepoCompanion-->>ReactNativeComponent: return dataStore

    ReactNativeComponent->>StateRepoCompanion: defaultDataStore(context) (later call)
    StateRepoCompanion->>StateRepoCompanion: check existing dataStore
    StateRepoCompanion-->>ReactNativeComponent: return same dataStore instance
Loading

Updated class diagram for StateRepo DataStore singleton

classDiagram
    class StateRepo {
    }

    class StateRepo_companion {
        -DataStore~GlobalState~ dataStore
        +defaultDataStore(context: Context) DataStore~GlobalState~
    }

    class DataStore~GlobalState~ {
    }

    StateRepo_companion --> DataStore~GlobalState~ : holds_singleton
    StateRepo .. StateRepo_companion : companion_object
Loading

File-Level Changes

Change Details Files
Fix DataStore singleton pattern to reuse a single instance instead of creating multiple instances for the same backing file.
  • Assign the result of DataStoreFactory.create(...) to the companion object dataStore variable when no instance exists.
  • Add an explicit return of the cached dataStore instance after creation to ensure subsequent calls reuse it.
android/src/main/java/io/rownd/android/models/repos/StateRepo.kt

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Feb 24, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Non-null assertion: The new return dataStore!! introduces a non-null assertion that can crash if dataStore is
unexpectedly null at that point (e.g., due to concurrent initialization or future
refactors).

Referred Code
    return dataStore!!
}

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The companion-object singleton initialization still isn’t thread-safe; consider using a synchronized block or lazy initialization (or the official DataStore singleton pattern) to avoid races where two threads might both see dataStore == null and create separate instances.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The companion-object singleton initialization still isn’t thread-safe; consider using a synchronized block or `lazy` initialization (or the official DataStore singleton pattern) to avoid races where two threads might both see `dataStore == null` and create separate instances.

## Individual Comments

### Comment 1
<location path="android/src/main/java/io/rownd/android/models/repos/StateRepo.kt" line_range="210-213" />
<code_context>
             }

-            return DataStoreFactory.create(
+            dataStore = DataStoreFactory.create(
                 storage = FileStorage(GlobalStateSerializer) {
                     context.dataStoreFile(Rownd.config.stateFileName)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Avoid using `dataStore!!` and instead return the non-null value directly.

The old implementation returned `DataStoreFactory.create(...)` directly, which is non-null by construction. Now the result is stored in (presumably nullable) `dataStore` and returned via `dataStore!!`. This introduces an unnecessary `!!` and a potential NPE if the code is later refactored. Instead, return the created value directly and then assign it, e.g.:

```kotlin
val ds = DataStoreFactory.create(
    storage = FileStorage(GlobalStateSerializer) {
        context.dataStoreFile(Rownd.config.stateFileName)
    },
    scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    corruptionHandler = ReplaceFileCorruptionHandler { GlobalState() }
)

dataStore = ds
return ds
```

```suggestion
            val ds = DataStoreFactory.create(
                storage = FileStorage(GlobalStateSerializer) {
                    context.dataStoreFile(Rownd.config.stateFileName)
                },
                    return@ReplaceFileCorruptionHandler GlobalState()
                }
            )
            dataStore = ds
            return ds
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread android/src/main/java/io/rownd/android/models/repos/StateRepo.kt Outdated
@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Feb 24, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Synchronize DataStore creation
Suggestion Impact:Added @synchronized to defaultDataStore and adjusted initialization to create a single DataStore instance (using a local ds then assigning to the singleton), addressing the race condition concern.

code diff:

         private var dataStore: DataStore<GlobalState>? = null
+        
+        @Synchronized
         fun defaultDataStore(context: Context): DataStore<GlobalState> {
             // DataStore must be a singleton
             dataStore?.let {
                 return it
             }
 
-            dataStore = DataStoreFactory.create(
+            val ds = DataStoreFactory.create(
                 storage = FileStorage(GlobalStateSerializer) {
-                    context.dataStoreFile(Rownd.config.stateFileName)
+                    context.applicationContext.dataStoreFile(Rownd.config.stateFileName)
                 },
                 corruptionHandler = ReplaceFileCorruptionHandler { ex ->
                     // Handle cases where on-device state has become corrupt.
@@ -225,7 +227,8 @@
                     return@ReplaceFileCorruptionHandler GlobalState()
                 }
             )
-            return dataStore!!
+            dataStore = ds
+            return ds
         }

Synchronize the DataStore initialization to prevent race conditions from
creating multiple instances. This can be achieved by adding the @Synchronized
annotation to the defaultDataStore method or using a synchronized block.

android/src/main/java/io/rownd/android/models/repos/StateRepo.kt [210-227]

-dataStore = DataStoreFactory.create(
-    storage = FileStorage(GlobalStateSerializer) {
-        context.dataStoreFile(Rownd.config.stateFileName)
-    },
-    corruptionHandler = ReplaceFileCorruptionHandler { ex ->
-        // Handle cases where on-device state has become corrupt.
-        // First, log the exception and send a trace
-        Log.w("Rownd.StateRepo", "Failed to load existing state from device", ex)
-        val tracer = Rownd.rowndContext.telemetry?.getTracer()
-        val span = tracer?.spanBuilder("globalStateCorruption")?.startSpan()
-        span?.setStatus(StatusCode.ERROR)
-        span?.recordException(ex)
-        span?.end()
+@Synchronized
+fun defaultDataStore(context: Context): DataStore<GlobalState> {
+    dataStore?.let { return it }
+    dataStore = DataStoreFactory.create(
+        storage = FileStorage(GlobalStateSerializer) {
+            context.dataStoreFile(Rownd.config.stateFileName)
+        },
+        corruptionHandler = ReplaceFileCorruptionHandler { ex ->
+            // ...
+        }
+    )
+    return dataStore!!
+}
 
-        // Finally, reset the state to default (unfortunately, signing-out the user)
-        return@ReplaceFileCorruptionHandler GlobalState()
-    }
-)
-

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies a critical race condition in the PR's singleton implementation, which could cause the exact IllegalStateException the PR aims to fix. Applying this fix is crucial for the correctness of the change.

High
General
Use applicationContext for file access
Suggestion Impact:Updated the DataStore FileStorage lambda to use context.applicationContext.dataStoreFile, preventing the singleton from capturing a short-lived context.

code diff:

                 storage = FileStorage(GlobalStateSerializer) {
-                    context.dataStoreFile(Rownd.config.stateFileName)
+                    context.applicationContext.dataStoreFile(Rownd.config.stateFileName)
                 },

Replace context.dataStoreFile with context.applicationContext.dataStoreFile to
prevent potential memory leaks by ensuring the singleton does not hold a
reference to a short-lived context.

android/src/main/java/io/rownd/android/models/repos/StateRepo.kt [211-213]

 storage = FileStorage(GlobalStateSerializer) {
-    context.dataStoreFile(Rownd.config.stateFileName)
+    context.applicationContext.dataStoreFile(Rownd.config.stateFileName)
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 7

__

Why: This is a valid and important Android development best practice. Using the applicationContext for a singleton that can outlive other components prevents potential memory leaks, improving the application's stability.

Medium
Provide clearer null assertion
Suggestion Impact:The commit removed the force-unwrap return (dataStore!!) by creating a local DataStore (ds), assigning it to dataStore, and returning ds instead, eliminating the unsafe non-null assertion (though it did not use requireNotNull with a custom message).

code diff:

-            dataStore = DataStoreFactory.create(
+            val ds = DataStoreFactory.create(
                 storage = FileStorage(GlobalStateSerializer) {
-                    context.dataStoreFile(Rownd.config.stateFileName)
+                    context.applicationContext.dataStoreFile(Rownd.config.stateFileName)
                 },
                 corruptionHandler = ReplaceFileCorruptionHandler { ex ->
                     // Handle cases where on-device state has become corrupt.
@@ -225,7 +227,8 @@
                     return@ReplaceFileCorruptionHandler GlobalState()
                 }
             )
-            return dataStore!!
+            dataStore = ds
+            return ds

Replace the force-unwrap !! on dataStore with requireNotNull() to provide a
clear error message in case of a null value, which improves debuggability.

android/src/main/java/io/rownd/android/models/repos/StateRepo.kt [228]

-return dataStore!!
+return requireNotNull(dataStore) { "DataStore must be initialized before usage" }

[Suggestion processed]

Suggestion importance[1-10]: 4

__

Why: This suggestion improves code robustness by replacing a non-null assertion with a check that provides a more descriptive error message, which aids in debugging. However, if the logic is correct, this line should never be null.

Low
  • Update

- Add @synchronized annotation to prevent race conditions where multiple
  threads could create separate DataStore instances
- Store result in local variable and return directly to avoid !! assertion
- Use applicationContext to prevent memory leaks from short-lived contexts

Addresses Sourcery review feedback on thread safety and best practices.
@mhamann mhamann merged commit a501e2f into main Feb 24, 2026
7 checks passed
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.

2 participants