Skip to content

fix: [SDK-5615] merge the anonymous user on composite login - #2816

Merged
nan-li merged 4 commits into
mainfrom
ar/composite-login-merge-anon
Oct 28, 2026
Merged

nan-li merged 4 commits into
mainfrom
ar/composite-login-merge-anon

Conversation

@abdulraqeeb33

@abdulraqeeb33 abdulraqeeb33 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Composite login from an anonymous user now attaches the external ID to that user instead of creating a new one.

Details

Fixes SDK-5615. Stacked on #2815.

Motivation

Found in the 5.11.0 bug bash. login(externalId) keeps the anonymous user's tags and push via SetAlias. Composite login sent one Create User with only the external ID, so it created a new user and left the anonymous data behind.

Scope

When a login has profile fields, identity verification is off, and the anonymous user exists on the server, LoginUserOperationExecutor adds its onesignal_id to the same Create User request. Create User then attaches the new external ID to that user (201).

If the external ID already belongs to another user, Create User returns 409 user-1 with onesignal_id in conflicting_aliases. The SDK retries without onesignal_id. That switches users, same as login. A handled 409 also logs that switch, so the error response is not the outcome.

Other merge failures:

  • 404 or 410: retry without onesignal_id.
  • 409: retry only when conflicting_aliases names onesignal_id, or the body cannot be read. user-1 is any alias clash, so the code alone is not enough. user-2 is not a Create User response and is final. An alias-only 409 is final.
  • 400: do not retry. The payload would fail the same way.
  • 429 and 5xx stay retryable. They are not turned into a second Create User.

Identity-only logins are unchanged (single SetAlias).

Note: the Create User docs only list external_id as a request identity field, though turbine accepts onesignal_id.

Testing

Unit testing

LoginUserOperationExecutorTests covers the merge:

  • The request sends onesignal_id.
  • A 409 that names onesignal_id, an unreadable 409, and a 404 each retry without it.
  • A 400, an alias-only 409, and a user-2 do not retry.
  • A local id or identity verification does not merge.

Manual testing

Emulator, demo app. The new-external-ID and identity-only rows are from the earlier session. The external-ID-owned row was rerun on 22abbc605 (local SDK 5.10.2). This commit does not change that switch:

Scenario Result
Anonymous user with tag, new external ID + email One Create User, 201, same user, tag kept
External ID owned by another user 409 user-1 lists both external_id and onesignal_id. Retry without onesignal_id returns 202 on the existing user
Identity-only login Single SetAlias

Anonymous user 0a3e06fb-fce2-4e8c-9f59-a52ceec01c76 logged in with profile to ext5615rerun1, already owned by c5322f8f-9885-46f1-be6c-9964e32a67b8. The 409 body:

{"errors":[{"code":"user-1","title":"Multiple existing Users found with provided set of Aliases","meta":{"conflicting_aliases":{"external_id":"ext5615rerun1","onesignal_id":"0a3e06fb-fce2-4e8c-9f59-a52ceec01c76"}}}]}

The follow-up Create User omitted onesignal_id and returned 202 with onesignal_id c5322f8f-9885-46f1-be6c-9964e32a67b8.

Affected code checklist

  • Notifications
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

@abdulraqeeb33
abdulraqeeb33 requested a review from a team as a code owner October 27, 2026 14:19
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • LoginUserOperationExecutor.kt: 31/31 touched executable lines (100.0%) (63 touched lines in diff)

Overall (aggregate gate)

31/31 touched executable lines covered (100.0% — requires ≥ 80%)

📥 View workflow run

@fadi-george

fadi-george commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Some findings:

  • Blocking: LoginUserOperationExecutor.kt:105, a merge attempt that fails with anything other than 409 returns FAIL_NORETRY and OperationRepo drops the login. A stale or just-created anon ID that 404s would kill a composite login, while plain login recovers through the SetAlias 404 retry window or the FAIL_NORETRY fallback at line 162. Can we fall back to createUser(loginUserOp, operations) on FAIL_NORETRY too? Also, what does the backend return for an unknown onesignal_id?
  • Non-blocking: LoginUserOperationExecutor.kt:105, a 409 from a custom alias conflict also triggers the retry. The retry 409s again on the same alias, so the outcome matches before, but it's an extra request. Checking the error code would avoid it.
  • Nit: LoginUserOperationExecutor.kt:93, the block comment still says this branch goes right to createUser.
  • Nit: LoginUserOperationExecutor.kt:324, the expected 409 on the merge attempt logs as an error before the retry.
  • Nit: LoginUserOperationExecutorTests.kt, add a test for a non-409 failure on the merge attempt.

Copy link
Copy Markdown
Contributor Author

Fixed in 0819022.

  1. A non-409 merge failure falls back to Create User without onesignal_id. Create User does not 404 an unknown onesignal_id. When the external id is new, turbine keeps that id.
  2. A 409 retries only when onesignal_id is in conflicting_aliases, or the code is user-2. An alias-only user-1 stays final.
  3. The createUser comment, the error log on that expected failure, and tests for a 404 merge and an alias-conflict 409 are updated.

@fadi-george

Copy link
Copy Markdown
Contributor
  • Non-blocking: LoginUserOperationExecutor.kt:473, OneSignalError.fromBackendResponse already parses errors[] with code and meta. Reusing it would also cut the returns detekt flags.
  • Nit: LoginUserOperationExecutor.kt:480, sawConflict is set for any error object, not just conflicts.
  • Nit: LoginUserOperationExecutor.kt:469, a 400 like an invalid email now sends a second request that will fail the same way.
  • Nit: LoginUserOperationExecutorTests.kt, no test for the user-2 code or an unreadable 409 body.

@fadi-george

Copy link
Copy Markdown
Contributor
  • Blocking: LoginUserOperationExecutor.kt:466 and :473, detekt ReturnCount fails code-quality on this commit.

@fadi-george

Copy link
Copy Markdown
Contributor

Double check this: If the backend only lists external_id, that body looks the same as your alias-conflict test and the switch-to-existing-user case fails. The manual "409, retry 202" result was on the previous commit. Can you rerun it on this one and paste the 409 body

@abdulraqeeb33
abdulraqeeb33 force-pushed the ar/composite-login-merge-anon branch from 8adba35 to 22abbc6 Compare October 27, 2026 17:56
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Second pass is in 22abbc6.

  1. Conflict parsing uses OneSignalError.fromBackendResponse. ReturnCount is clear.
  2. A 400 is not retried. Tests cover user-2 and an unreadable 409.
  3. The switch-user 409 includes onesignal_id. Create User adds it when one alias matches and the sent onesignal_id is a different user (create.go, numUsers == 1). That body retries. A body without onesignal_id is the two-alias conflict (numUsers >= 2) and stays final. I have not rerun the emulator on this commit, so I do not have a fresh 409 body to paste.

@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Thanks for the note, worth checking.

The switch-user 409 from Create User is:

{
  "errors": [
    {
      "code": "user-1",
      "title": "Multiple existing Users found with provided set of Aliases",
      "meta": {
        "conflicting_aliases": {
          "external_id": "<existing external id>",
          "onesignal_id": "<anonymous onesignal_id sent on the request>"
        }
      }
    }
  ]
}

Create User adds onesignal_id when one alias matches and the sent id belongs to a different user. That key is what the retry looks for, so this case still falls back to Create User without onesignal_id and switches users. A 409 whose conflicting_aliases has no onesignal_id is the two-alias conflict and stays final.

@fadi-george

Copy link
Copy Markdown
Contributor
  • Nit: PR body still says three new tests, and Scope doesn't mention the 400 / 404 / alias-conflict rules.

@fadi-george

fadi-george commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Thanks, the create.go explanation makes sense. I'll do some more testing but if you get a chance, a quick emulator rerun of the external-ID-owned case on 22abbc6 would be nice to confirm.

@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Reran the external-ID-owned case on 22abbc6.

409 body:

{"errors":[{"code":"user-1","title":"Multiple existing Users found with provided set of Aliases","meta":{"conflicting_aliases":{"external_id":"ext5615rerun1","onesignal_id":"0a3e06fb-fce2-4e8c-9f59-a52ceec01c76"}}}]}

The retry omitted onesignal_id and returned 202 on c5322f8f-9885-46f1-be6c-9964e32a67b8, the user that already owned the external ID.

@nan-li
nan-li force-pushed the ar/composite-login-merge-anon branch 2 times, most recently from ee2091a to 21b5a1c Compare October 27, 2026 22:42
Base automatically changed from ar/sdk-5606 to main October 27, 2026 22:48
AR Abdul Azeez and others added 4 commits October 27, 2026 15:48
Composite login creates the user directly, so data set on the anonymous user
before login was left behind. It now sends the anonymous user's onesignal_id
with the external ID, and Create User attaches the external ID to that user,
matching what login(externalId) does through SetAlias. A 409 means the external
ID already belongs to another user, so it retries without the onesignal_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ot be kept

A non-conflict failure was dropped. Retry Create User without onesignal_id, and skip that retry when the 409 is an alias conflict.

Co-authored-by: Cursor <cursoragent@cursor.com>
…he payload

A 400 would fail the same way without the anonymous id. Conflict checks now go through OneSignalError, and only an alias conflict that does not name onesignal_id is final.

Co-authored-by: Cursor <cursoragent@cursor.com>
Create User never returns user-2, and a handled 409 should not read as a failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nan-li
nan-li force-pushed the ar/composite-login-merge-anon branch from 21b5a1c to fc520cd Compare October 27, 2026 22:48
@nan-li
nan-li merged commit a0f26fe into main Oct 28, 2026
7 checks passed
@nan-li
nan-li deleted the ar/composite-login-merge-anon branch October 28, 2026 00:24
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