Skip to content

RUM-18168: Fix DataFlusher racing the upload scheduler and duplicating events - #3739

Draft
abrooksv wants to merge 1 commit into
developfrom
fix/dataflusher-upload-race
Draft

RUM-18168: Fix DataFlusher racing the upload scheduler and duplicating events#3739
abrooksv wants to merge 1 commit into
developfrom
fix/dataflusher-upload-race

Conversation

@abrooksv

@abrooksv abrooksv commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

DataFlusher.flush() read batch files directly from FileOrchestrator.getFlushableFiles(), bypassing ConsentAwareStorage's lockedReadBatches tracking. This let an explicit Flush() call and the SDK's own regular periodic upload scheduler independently claim and upload the same on-disk batch file at the same time, producing duplicate RUM events (confirmed via emulator repro with event-hash-level instrumentation).

Fix: route DataFlusher through Storage.readNextBatch()/confirmBatchRead() instead of FileOrchestrator directly, so a flush participates in the same lockedReadBatches coordination as every other reader.

At the old 10s bound, awaitTermination() timed out on every one of 8 on-device runs and reproduced the duplicate event in 2 of them; at the new bound (matching NETWORK_TIMEOUT_MS, 45s), 0 of 8 runs produced a duplicate.

Motivation

This should stabilize the RUM FIT test case test_multipage_navigation_with_resource_loading for MAUI-Android

FAILED scenarios/test_multipage_navigation.py::test_multipage_navigation_with_resource_loading - AssertionError: Expected image resource with URL containing 'imgix.datadoghq.com/img/about/presskit/usage/logousage_purple.png'. Found 2 resources: ['https://imgix.datadoghq.com/img/about/presskit/usage/logousage_purple.png?auto=format&fit=max&w=847&dpr=2', 'https://imgix.datadoghq.com/img/about/presskit/usage/logousage_purple.png?auto=format&fit=max&w=847&dpr=2']
assert 2 == 1
 +  where 2 = len([
    RumResourceEvent(
        type='resource',
        date=1787061879863,
        session=Session(id='27096e2b-4cd7-48f6-8473-291183610696', type='user', ...),
        view=View(id='fa619c25-5a82-43f9-bd88-59c90dc3ed84', url='RumTestApp/Scenarios/MultipageNavigation/Page2', name='page2', ...),
        resource=ResourceDetails(
            type='image',
            url='https://imgix.datadoghq.com/img/about/presskit/usage/logousage_purple.png?auto=format&fit=max&w=847&dpr=2',
            method='GET',
            status_code=200,
            duration=1333153375,
            size=118067,
            id='5bc74488-7fdf-43e3-9366-dc4acda58fc6',
        ),
        ...
    ),
    RumResourceEvent(
        type='resource',
        date=1787061879863,
        session=Session(id='27096e2b-4cd7-48f6-8473-291183610696', t
        view=View(id='fa619c25-5a82-43f9-bd88-59c90dc3ed84', url='RugeNavigation/Page2', name='page2', ...),
        resource=ResourceDetails(
            type='image',
            url='https://imgix.datadoghq.com/img/about/presskit/usage/logousage_purple.png?auto=format&fit=max&w=847&dpr=2',
            method='GET',
            status_code=200,
            duration=1333153375,
            size=118067,
            id='5bc74488-7fdf-43e3-9366-dc4acda58fc6',
        ),
        ...
    ),
])

Both events are byte-identical, confirming a duplicate upload of a single logical resource event rather than two distinct events.

Reproduction log:

12:26:55.953  Thread-4 (DataFlusher.flush)      READ  file=1787167593155  hashes=[-1353918861, ...]
12:26:55.954  Thread-4 (DataFlusher.flush)      UPLOAD requestId=28859dbf...  hashes=[-1353918861, ...]
12:26:56.455  datadog-upload-thread-1 (periodic) UPLOAD requestId=f0274407... hashes=[-1353918861, ...]  ← same file, same hashes
12:26:56.729  Thread-4 (DataFlusher.flush)      DELETE file=1787167593155
12:26:57.228  datadog-upload-thread-1           confirmBatchRead file=1787167593155 deleteBatch=true

Both uploads succeeded independently. The raw intake JSONL confirms two identical resource events, same resource.id (f9f07fa4-b4e1-40d0-8aff-706f0f5a77be). 

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)

@abrooksv

Copy link
Copy Markdown
Contributor Author

@codex review this

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

Copy link
Copy Markdown

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: 8bda85e6be

ℹ️ 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".

@abrooksv
abrooksv force-pushed the fix/dataflusher-upload-race branch from 8bda85e to 2f3eb85 Compare August 19, 2026 20:35
@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Aug 19, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 73.78% (+1.85%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 0bc3ef6 | Docs | View more details | Give us feedback!

@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.13%. Comparing base (e578a74) to head (0bc3ef6).
⚠️ Report is 39 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3739      +/-   ##
===========================================
- Coverage    73.44%   73.13%   -0.31%     
===========================================
  Files          999      999              
  Lines        36575    36588      +13     
  Branches      6183     6321     +138     
===========================================
- Hits         26860    26756     -104     
- Misses        7994     8023      +29     
- Partials      1721     1809      +88     
Files with missing lines Coverage Δ
...n/com/datadog/android/core/internal/CoreFeature.kt 87.34% <100.00%> (-1.46%) ⬇️

... and 106 files with indirect coverage changes

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

@abrooksv
abrooksv force-pushed the fix/dataflusher-upload-race branch 4 times, most recently from 8182909 to 52294aa Compare August 19, 2026 23:08
@abrooksv
abrooksv marked this pull request as ready for review August 19, 2026 23:10
@abrooksv
abrooksv requested review from a team as code owners August 19, 2026 23:10

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

Copy link
Copy Markdown

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: 52294aa22c

ℹ️ 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".

// uploadExecutorService can be mid-upload when this runs, and only NETWORK_TIMEOUT_MS
// bounds how long that upload can take. Failing to wait long enough here can lead to
// a DataFlusher race where it uploads the same batch twice. See RUM-18168.
awaitTerminationLogged(uploadExecutorService, "uploadExecutorService", NETWORK_TIMEOUT_MS, TimeUnit.MILLISECONDS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait for the complete upload job before starting the flusher

When a running upload job contains multiple batches, this timeout does not guarantee that it has finished: DataUploadRunnable.run() can perform 20 sequential uploads by default, or 100 at HIGH, while NETWORK_TIMEOUT_MS bounds only each individual HTTP call. If two successful requests cumulatively exceed 45 seconds, awaitTermination returns false and flushStoredData() proceeds to DataFlusher, which directly enumerates files and can upload the batch still owned by the active runnable, reproducing the duplicate-event race this change is intended to fix. Do not proceed with the direct-file flush until the whole active upload job has terminated, or coordinate the flusher through the same batch-locking mechanism.

Useful? React with 👍 / 👎.

@abrooksv abrooksv Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

While true, this code path is only in support of our end to end tests and so batches should be ideally only 1 or 2 so it should be ok, we can revisit if its still flaky, since it would probably require making data flusher more aware of the how the uploader works

…g events

DataFlusher.flush() (the explicit Flush() call) can race the SDK's own periodic upload scheduler: both can independently claim and upload the same on-disk batch file, producing duplicate RUM events.

drainAndShutdownExecutors() shuts down the persistence/upload executors and waits for them to terminate before DataFlusher runs, but it waited only DRAIN_WAIT_SECONDS (10s) - shorter than the upload call's own timeout (NETWORK_TIMEOUT_MS, 45s). An in-flight upload could still be running when that wait gave up, so drainAndShutdownExecutors() returned anyway while the upload kept racing DataFlusher for the same batch file.

Fix: uploadExecutorService now waits up to NETWORK_TIMEOUT_MS instead of DRAIN_WAIT_SECONDS. Also log a warning if an executor doesn't terminate in time, so this doesn't silently regress.
@abrooksv
abrooksv force-pushed the fix/dataflusher-upload-race branch from 52294aa to 0bc3ef6 Compare August 19, 2026 23:29

@0xnm 0xnm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. But I guess PR title is wrong? There is no fix, but just logging?

}
}

@Suppress("UnsafeThirdPartyFunctionCall") // Used in Nightly tests only

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

probably comment is wrong, we don't have nightlies anymore

@aleksandr-gringauz

Copy link
Copy Markdown
Contributor

Fix: route DataFlusher through Storage.readNextBatch()/confirmBatchRead() instead of FileOrchestrator directly, so a flush participates in the same lockedReadBatches coordination as every other reader.

Same question Nikita is asking. Is this implemented in the PR?

@abrooksv

abrooksv commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@aleksandr-gringauz @0xnm

Sorry, PR description not in sync with the commit description:

DataFlusher.flush() (the explicit Flush() call) can race the SDK's own periodic upload scheduler: both can independently claim and upload the same on-disk batch file, producing duplicate RUM events.

drainAndShutdownExecutors() shuts down the persistence/upload executors and waits for them to terminate before DataFlusher runs, but it waited only DRAIN_WAIT_SECONDS (10s) - shorter than the upload call's own timeout (NETWORK_TIMEOUT_MS, 45s). An in-flight upload could still be running when that wait gave up, so drainAndShutdownExecutors() returned anyway while the upload kept racing DataFlusher for the same batch file.

Fix: uploadExecutorService now waits up to NETWORK_TIMEOUT_MS instead of DRAIN_WAIT_SECONDS. Also log a warning if an executor doesn't terminate in time, so this doesn't silently regress.

but it still feels like a bandaid fix

The root cause is the DataFlusher and Uploader are capable of running at the same time.
This is evident due to the awaitTermination returns false so the uploadRunnable is still executing when we go to execute the DataFlusher call.

12:26:55.953  Thread-4 (DataFlusher.flush)      READ  file=1787167593155  hashes=[-1353918861, ...]
12:26:55.954  Thread-4 (DataFlusher.flush)      UPLOAD requestId=28859dbf...  hashes=[-1353918861, ...]
12:26:56.455  datadog-upload-thread-1 (periodic) UPLOAD requestId=f0274407... hashes=[-1353918861, ...]  ← same file, same hashes
12:26:56.729  Thread-4 (DataFlusher.flush)      DELETE file=1787167593155
12:26:57.228  datadog-upload-thread-1           confirmBatchRead file=1787167593155 deleteBatch=true

Is a log showing 2 threads both uploaded the same batch with the same contents

So the "fix" at the moment was to let give Uploader more time to finish its iteration of the Runnable, and then let DataFlusher start.

Edit: Taking another look at this to see if a more robust fix can be made, will ping with what I find

@abrooksv
abrooksv marked this pull request as draft August 20, 2026 21:02
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.

4 participants