Skip to content

Move unsent forms query off the main thread#3566

Open
avazirna wants to merge 9 commits intomasterfrom
vaz/address-unsent-forms-query-blocking-main-thread
Open

Move unsent forms query off the main thread#3566
avazirna wants to merge 9 commits intomasterfrom
vaz/address-unsent-forms-query-blocking-main-thread

Conversation

@avazirna
Copy link
Contributor

@avazirna avazirna commented Feb 24, 2026

Product Description

The home screen sync button triggers a query to count unsent forms. This is a database query that is currently executed on the main thread, which can cause ANRs. Over the past 30 days, this ANR has been one of the most frequent, with over 3k occurences.
This PR moves the query to a background thread using a new `LatestTaskExecutor` utility, which posts the result back to the main thread via a callback once the query completes. Rapid successive calls cancel any previous query to avoid stale results.

Technical Summary

Key changes:

  • LatestTaskExecutor<T> — new Kotlin utility class wrapping a single-thread executor; cancels the previous task on each new submission and posts the result to the main thread via a interface Callback<T>
  • SyncDetailCalculations.updateSubText — `getNumUnsentForms()` now runs off the main thread; UI update runs in the callback on the main thread with an activity lifecycle guard

Safety Assurance

Safety story

  • The UI update callback checks `activity.isFinishing() || activity.isDestroyed()` before touching any views
  • Only one executor instance is used for the single sync button call site — cancel-previous behaviour is scoped correctly
  • All other logic in `updateSubText` (sync time display, colour) is unchanged

QA Plan

  • Open the home screen and verify the unsent forms count displays correctly
  • Submit forms and verify the count updates on next home screen refresh
  • Rapidly navigate away and back to verify no stale count or crash

Labels and Review

  • Do we need to enhance the manual QA test coverage ? If yes, the "QA Note" label is set correctly
  • Does the PR introduce any major changes worth communicating ? If yes, the "Release Note" label is set and a "Release Note" is specified in PR description.
  • Risk label is set correctly
  • The set of people pinged as reviewers is appropriate for the level of risk of the change

avazirna and others added 4 commits February 23, 2026 11:54
This Executor discard any running task when a new task is submitted
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CompletableFuture is only supported from API 23
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

A new LatestTaskExecutor<T> utility class is introduced to manage asynchronous task execution with a single-threaded work queue. When a task is submitted, any previously-running task is canceled, ensuring only the most recent task completes. SyncDetailCalculations.java is modified to use this executor for computing unsent forms count asynchronously instead of synchronously, with the UI update logic moved into the result callback to execute on the main thread.

Sequence Diagram

sequenceDiagram
    participant UI as UI/Activity
    participant Executor as LatestTaskExecutor
    participant BG as Background<br/>Thread
    participant Handler as Main<br/>Thread

    UI->>Executor: submit(task, callback)
    Executor->>Executor: cancelPreviousTask()
    Executor->>BG: execute task
    BG->>BG: task.call()
    alt Success
        BG->>Handler: postResult()
        Handler->>UI: callback.onResult(result)
    else Exception
        BG->>BG: Logger.exception()
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: moving the unsent forms query from the main thread to a background thread.
Description check ✅ Passed The PR description comprehensively covers all required template sections: product description with user impact, technical summary with design rationale, safety assurance with lifecycle guards and QA plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch vaz/address-unsent-forms-query-blocking-main-thread

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/org/commcare/tasks/LatestTaskExecutor.kt`:
- Around line 22-36: The submit method can still deliver stale callbacks because
Future.cancel(true) is best‑effort; add a monotonic task id (e.g. an AtomicLong
taskCounter) and associate the id with each submitted work, storing the latest
id in a shared AtomicLong/AtomicReference (or extend currentTask to hold the
id). In LatestTaskExecutor.submit, increment the counter to produce taskId
before submitting, capture that id inside the worker, and before calling
mainHandler.post { callback.onResult(result) } verify the captured id matches
the current/latest id (skip posting if it does not). Keep the existing
cancel(previous) behavior but use the id check to guarantee only the most recent
task's result is delivered.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9b1d18f and fc76b65.

📒 Files selected for processing (2)
  • app/src/org/commcare/tasks/LatestTaskExecutor.kt
  • app/src/org/commcare/utils/SyncDetailCalculations.java

Comment on lines 22 to 36
private val singleThreadExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val currentTask: AtomicReference<Future<*>?> = AtomicReference<Future<*>?>()
private val mainHandler = Handler(Looper.getMainLooper())

fun submit(task: Callable<T>, callback: Callback<T>) {
val future = singleThreadExecutor.submit {
try {
val result = task.call()
mainHandler.post { callback.onResult(result) }
} catch (e: Exception) {
Logger.exception("LatestTaskExecutor task failed", e)
}
}
val previous = currentTask.getAndSet(future)
previous?.cancel(true)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find app/src/org/commcare/tasks -name "LatestTaskExecutor.kt" -type f

Repository: dimagi/commcare-android

Length of output: 115


🏁 Script executed:

# Check if the file exists and read its contents
if [ -f "app/src/org/commcare/tasks/LatestTaskExecutor.kt" ]; then
  wc -l "app/src/org/commcare/tasks/LatestTaskExecutor.kt"
fi

Repository: dimagi/commcare-android

Length of output: 118


🏁 Script executed:

# List files in the tasks directory to understand context
fd . app/src/org/commcare/tasks --max-depth 1 2>/dev/null | head -20

Repository: dimagi/commcare-android

Length of output: 1109


🏁 Script executed:

cat -n "app/src/org/commcare/tasks/LatestTaskExecutor.kt"

Repository: dimagi/commcare-android

Length of output: 1649


🏁 Script executed:

# Run ktlint on the file to check formatting compliance
ktlint "app/src/org/commcare/tasks/LatestTaskExecutor.kt" 2>&1

Repository: dimagi/commcare-android

Length of output: 113


🏁 Script executed:

# Search for usages of LatestTaskExecutor to understand how it's used
rg "LatestTaskExecutor" --type kt --type java -B 2 -A 5

Repository: dimagi/commcare-android

Length of output: 93


🏁 Script executed:

# Also check if there are tests or other patterns that might guard against stale results
rg "submit.*task.*callback\|LatestTaskExecutor" --type kt --type java -A 10

Repository: dimagi/commcare-android

Length of output: 93


🏁 Script executed:

# Search for usages of LatestTaskExecutor without type filters
rg "LatestTaskExecutor" -B 2 -A 5

Repository: dimagi/commcare-android

Length of output: 2907


🏁 Script executed:

# Check if there are any other callback patterns or task management mechanisms
rg "Callback<" -B 2 -A 5 | head -100

Repository: dimagi/commcare-android

Length of output: 5346


🏁 Script executed:

# Look at the usage of LatestTaskExecutor in SyncDetailCalculations to understand the impact
rg "unsentFormsExecutor\.submit" -B 5 -A 10

Repository: dimagi/commcare-android

Length of output: 1648


🏁 Script executed:

# Check if there are any other mechanisms that might prevent stale results
rg "mainHandler\.post\|Handler\|Looper" app/src/org/commcare/tasks/LatestTaskExecutor.kt -B 2 -A 2

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Verify the actual behavior concern - check if there are concurrent task submission scenarios
rg "submit.*task\|LatestTaskExecutor" app/src/org/commcare/utils/SyncDetailCalculations.java -B 3 -A 15

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check if mainHandler operations have any guards or timeouts
rg "isFinishing\|isDestroyed\|Handler\|post" app/src/org/commcare/utils/SyncDetailCalculations.java -B 2 -A 2

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Read the full SyncDetailCalculations usage
sed -n '1,100p' "app/src/org/commcare/utils/SyncDetailCalculations.java" | head -50

Repository: dimagi/commcare-android

Length of output: 1932


🏁 Script executed:

# Check the actual submit call context with broader search
rg "unsentFormsExecutor" -B 5 -A 15

Repository: dimagi/commcare-android

Length of output: 2906


🏁 Script executed:

# Check if there are other patterns that show task cancellation race conditions
rg "cancel.*true\|Future\|Callable" app/src/org/commcare/tasks/ -B 2 -A 3

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check for Kotlin formatting baseline - look at nearby Kotlin files for style
fd "\.kt$" app/src/org/commcare/tasks/ -x head -20 {}

Repository: dimagi/commcare-android

Length of output: 4540


Prevent canceled tasks from posting stale results.

When a new task is submitted, the previous task's Future is canceled, but Future.cancel(true) is best‑effort—a task that has already computed its result or ignores interruption can still post to the main thread. This creates a race where an older task's callback overwrites a newer task's result. Track a monotonic task ID and check it before invoking the callback to ensure only the latest result is delivered.

🔧 Suggested fix (drop stale callbacks)
 import java.util.concurrent.Executors
 import java.util.concurrent.Future
+import java.util.concurrent.atomic.AtomicLong
 import java.util.concurrent.atomic.AtomicReference
@@
     private val singleThreadExecutor: ExecutorService = Executors.newSingleThreadExecutor()
     private val currentTask: AtomicReference<Future<*>?> = AtomicReference<Future<*>?>()
     private val mainHandler = Handler(Looper.getMainLooper())
+    private val latestTaskId = AtomicLong(0)
 
     fun submit(task: Callable<T>, callback: Callback<T>) {
+        val taskId = latestTaskId.incrementAndGet()
         val future = singleThreadExecutor.submit {
             try {
                 val result = task.call()
-                mainHandler.post { callback.onResult(result) }
+                mainHandler.post {
+                    if (latestTaskId.get() == taskId) {
+                        callback.onResult(result)
+                    }
+                }
             } catch (e: Exception) {
                 Logger.exception("LatestTaskExecutor task failed", e)
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private val singleThreadExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val currentTask: AtomicReference<Future<*>?> = AtomicReference<Future<*>?>()
private val mainHandler = Handler(Looper.getMainLooper())
fun submit(task: Callable<T>, callback: Callback<T>) {
val future = singleThreadExecutor.submit {
try {
val result = task.call()
mainHandler.post { callback.onResult(result) }
} catch (e: Exception) {
Logger.exception("LatestTaskExecutor task failed", e)
}
}
val previous = currentTask.getAndSet(future)
previous?.cancel(true)
private val singleThreadExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val currentTask: AtomicReference<Future<*>?> = AtomicReference<Future<*>?>()
private val mainHandler = Handler(Looper.getMainLooper())
private val latestTaskId = AtomicLong(0)
fun submit(task: Callable<T>, callback: Callback<T>) {
val taskId = latestTaskId.incrementAndGet()
val future = singleThreadExecutor.submit {
try {
val result = task.call()
mainHandler.post {
if (latestTaskId.get() == taskId) {
callback.onResult(result)
}
}
} catch (e: Exception) {
Logger.exception("LatestTaskExecutor task failed", e)
}
}
val previous = currentTask.getAndSet(future)
previous?.cancel(true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/tasks/LatestTaskExecutor.kt` around lines 22 - 36, The
submit method can still deliver stale callbacks because Future.cancel(true) is
best‑effort; add a monotonic task id (e.g. an AtomicLong taskCounter) and
associate the id with each submitted work, storing the latest id in a shared
AtomicLong/AtomicReference (or extend currentTask to hold the id). In
LatestTaskExecutor.submit, increment the counter to produce taskId before
submitting, capture that id inside the worker, and before calling
mainHandler.post { callback.onResult(result) } verify the captured id matches
the current/latest id (skip posting if it does not). Keep the existing
cancel(previous) behavior but use the id check to guarantee only the most recent
task's result is delivered.

@avazirna avazirna marked this pull request as ready for review February 25, 2026 10:24
* Executor that ensures only the latest submitted task is executed, and any previous tasks are cancelled.
* The result is posted back to the main thread via the provided callback.
*/
class LatestTaskExecutor<T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

@avazirna I am curious why did you choose this way instead of going with the more standard Android patterns like Coroutines + Flow/Live Data to handle async processing.

I think we would want to ensure that a solution here -

  • is suited for long term and is aligned with Android standards patterns
  • Integrates seamlessly with Android lifecycle and doesn't need extra lifecycle management from us (that is the main reason we want to move off from Async tasks as well)
  • Ensures that the undelying tasks are cancellable i.e. the DB query should not continue running if the ui component is not alive
  • Ensures that Any Failures are not hidden from UI

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 curious why did you choose this way instead of going with the more standard Android patterns like Coroutines + Flow/Live Data to handle async processing.

I tried to balance the use case with the overhead of invoking coroutines from Java, especially given that CompletableFuture is not an option. I’m happy to revisit this if you have a different perspective.

I agree with all the points you highlighted. Absolutely, coroutines is the recommended approach, however, ExecutorService is still supported

About these two aspects:

Ensures that the undelying tasks are cancellable i.e. the DB query should not continue running if the ui component is not alive

SQLiteDatabase queries are blocking, so even with coroutines, if the ui gets destroyed they will continue running. Right? I can double-check this

Ensures that Any Failures are not hidden from UI

Given the use case, I'm inclined to log exceptions as a non-fatals to avoid impacting the user.

Copy link
Contributor

Choose a reason for hiding this comment

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

I tried to balance the use case with the overhead of invoking coroutines from Java, especially given that CompletableFuture is not an option. I’m happy to revisit this if you have a different perspective.

I think we can still use callbacks here with Coroutines, I am imagining it to be like this -

class CoroutineTaskExecutor<T>(
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    fun interface Callback<T> {
        fun onResult(result: T)
        fun onError(result: Exception)
    }

    private var currentJob: Job? = null

    fun submit(
        scope: CoroutineScope,
        task: Callable<T>,
        callback: Callback<T>,
    ) {
        currentJob?.cancel()
        currentJob = scope.launch {
            try {
                val result = withContext(ioDispatcher) { task.call() }
                callback.onResult(result)
            } catch (e: Exception) {
                callback.onError(e)
            }
        }
    }
}

and then calling this should look similar to -

CoroutineScope scope = LifecycleOwnerKt.getLifecycleScope(activity);
getUnsentFormsExecutor().submit(scope, SyncDetailCalculations::getNumUnsentForms,.. )

Curious do you still think the overheads here are significant to not go this way ?

SQLiteDatabase queries are blocking, so even with coroutines, if the ui gets destroyed they will continue running. Right? I can double-check this

right but Coroutines provide a clear cancellable interface that gets auto-triggered when the related lifecycle component gets cancelled, in this implementation this thread will keep runnning even after the activity is destroyed which is easy to correct but introduces additional state management.

Given the use case, I'm inclined to log exceptions as a non-fatals to avoid impacting the user.

Think that decision depends on the use case and we should not assume that this class will only get used for unsent forms query, the thread should instead expose onSuccess and onError callbacks and let the listeners decide on how to handle the error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@shubham1g5 I like this perspective, looks cleaner and it answers all my questions. Let me implement

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@avazirna avazirna requested a review from shubham1g5 February 25, 2026 18:46
@conroy-ricketts
Copy link
Contributor

Confirming that everything listed under "QA Plan" has also been tested locally successfully?

Copy link
Contributor

@conroy-ricketts conroy-ricketts left a comment

Choose a reason for hiding this comment

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

I don't have any additional concerns besides the clarifying question I posted previously. Think it'd be good to have another set of eyes on this before merging

Comment on lines +32 to +33
} catch (e: CancellationException) {
throw e
Copy link
Contributor

Choose a reason for hiding this comment

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

this will crash the app right ? Do we want that ?

syncStatus += "\n\n";
}
syncStatus += syncIndicator;
public static LatestTaskExecutor<Integer> getUnsentFormsExecutor() {
Copy link
Contributor

Choose a reason for hiding this comment

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

why public static ?

return;
}

Pair<Long, String> lastSyncTimeAndMessage = getLastSyncTimeAndMessage();
Copy link
Contributor

Choose a reason for hiding this comment

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

can we abstract the UI update logic to another method


@Override
public void onError(@NotNull Exception exception) {
Logger.exception("LatestTaskExecutor task failed", exception);
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: LatestTaskExecutor task failed -> Failed to get unsent forms

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants