fix: [SDK-5137] user request queue and identity model follow-ups from the JWT review - #1740
Conversation
…reate User `uncacheUserRequests` on 5.7-main kept a restored Identify User only when Identity Verification was on or the Request could prepare at once, and the Identify prepare needs the `onesignal_id` of the user it identifies. That dropped the archive an offline first launch with a `login` leaves behind, the anonymous Create User followed by the Identify User that logs A in, on relaunch whenever the requirement was off or unknown, so the login was lost. On main the Identify was kept whenever its identify model was already in the repo, which a restored Create User ahead of it guarantees, and it went out once that response supplied the id. PR6 (#1711) collapsed those cases into one check. Restore main's rule. Keep the Identify when its identify model is in the repo, or while the requirement is not known to be off so reshape can decide, otherwise only when it can be sent as is. Keep main's guard too. Once the requirement is known off, `reshapeInvalidRequests` drops an Identify whose user never received an `onesignal_id` and has nothing queued to supply one, because the FIFO loop stops at the first unexecutable Request and would otherwise retry it for the life of the install. Three tests in UserExecutorTests: the archive above with the requirement off, the same archive turning out to require auth, and a never-preparable Identify kept while unknown then dropped once auth is known to be off. The first two fail without the fix.
…its model `OSIdentityModel.hydrateModel` fired the user-state observer for whichever model it hydrated. Stepping over a parked Create User (#1711) lets a later login proceed, so that parked Request can now complete after another user is current, and its response reported the earlier user as signed in and persisted that pair, so nothing later re-reported the current user. Move the fire out of the model to the three executor sites that hydrate an identity model (Create User and Fetch User through `parseFetchUserResponse`, Identify User, and Fetch Identity By Subscription), through `OSUserStateSnapshot.fireUserStateChangedIfCurrent`, which reports only when the model still belongs to the current user. The hydration itself stays, since Requests queued behind that user need the `onesignal_id`. Test in a new UserStateReportingTests, since UserJwtLifecycleTests is at SwiftLint's type body limit: login(A) with no token, login(B) with one, then answer A's ask; the app hears nothing new and the persisted pair still names B. Fails without the fix.
…ting for a token `_executePendingRequests` steps over a Request the auth layer parked for a token, so a login for another user behind it is not stranded, and it schedules no retry for a wait only the app can end. It told a park apart from any other failed prepare with `awaitsToken`, which only asked whether the owner had a token. A Create User still inside the new-records cool-down on its push subscription fails to prepare before the auth layer runs, so with Identity Verification on and no token yet it was stepped over as if parked: nobody was asked, nothing retried, and the login sat in the queue until the next launch. Replace `awaitsToken` with `parkedForToken`, which answers whether the last authorization of that Request actually parked it. `OSRequestAuth` keeps the parked Requests in a weak table: `park` adds one, every authorization starts by forgetting it so the entry reflects the latest attempt, and the executor consumes the answer right after a failed prepare. A prepare that fails before authorizing therefore reads as not parked and gets the delayed retry. Tests: three unit tests on the mark's lifecycle in OSRequestAuthTests, and UserExecutorRetryTests, a new file since UserExecutorTests is at SwiftLint's type body limit, holding a Create User in the cool-down and asserting the app is asked once it lifts. Fails without the fix.
… hydrates `OSIdentityModel.clearData` blanked every alias in preparation for the Fetch User response that hydrates them, and `executeFetchUserRequest` runs that clear right before parsing the response, on the response thread. A properties or subscription Delta built in that gap on another queue read the current user's `externalId` as nil, and with Identity Verification on `OSOperationRepo.enqueueDelta` drops an anonymous Delta before it is ever persisted, so the change was lost for good. Keep `external_id` in `clearData`. The fetch that follows is by `onesignal_id`, so it cannot change who the user is, and its response overwrites the alias anyway. One behavior change comes with it: a fetch response that omits `external_id` no longer demotes the local user to anonymous. Only a server-side unlink produces one, and the next `login` corrects it, so that reads as the right trade against silently dropping an identified user's work. Test in OSIdentityModelTests: an identified model keeps `externalId` across `clearData` and loses every other alias. Fails without the fix. The ownership convention comment in OSUserRequest.swift no longer cites the blanked aliases as the reason for the owner stamp.
…tests `OSOperationRepoTestSupport.snapshotDeltaQueue()` reads `deltaQueue` on the repo's own queue, but it lived in OneSignalOSCoreTests, so DeltaOwnershipTests and UserJwtLifecycleTests read the array directly from the test thread while the repo appends to it on `dispatchQueue`. Move the helper into OneSignalOSCoreMocks, which both test targets link, and route the two reads through it. No remove or update coverage is added, per the decision on #1710: neither Delta's owner reaches the server.
…g it for a token
`OSRequestDeleteSubscription.prepareForExecution` ran the Request through
`authorize`, so under Identity Verification a delete whose owner had no
valid token was parked and the app was asked for one, and the executor's
failure handler treated a 401 as a rejected token. The route the SDK uses,
`DELETE /apps/{app}/subscriptions/{id}`, performs no JWT check: the server
registers it with auth skipped, only the by-alias variant validates
Identity Verification claims, and the OAuth layer in front treats the user
bearer as not applicable. Signing bought nothing, and parking cost an
unsubscribe: `removeEmail` or `removeSms` while the user had no token,
followed by a logout or a switch, left the delete parked, restored on every
launch, asking for a token the app could no longer supply, and the
subscription stayed on the server.
Drop the `authorize` call and the 401 branch. The owner stays on the
Request for the anonymous purge, which is what it was for. The ownership
convention in OSUserRequest.swift records the exception next to Update
Subscription, which sends unsigned for the same reason.
Test in ExecutorAnonymousPurgeTests: with Identity Verification on, a delete
owned by a user with no token goes out with no Authorization header and
nobody is asked. Fails without the fix.
…se hydrates the id `dropIdentifyUsersThatCanNeverPrepare` runs on every send and drops an Identify User whose user has no `onesignal_id` and no queued Create User or Fetch Identity By Subscription to supply one. The response handlers that supply an id dispatched `removeFromQueue` first and hydrated the model after it, on the callback thread. A send pass landing on the executor queue between the two saw no id and no supplier, dropped the Identify User and persisted the drop. Narrow, since the pass has to be dispatched inside that gap by remote params hydrating, a session start, a login, a token update or a delayed retry, but the loss is the offline login the restored `[Create User, Identify User]` archive exists to keep. Dispatch the removal after the hydrate at the three sites that supply an id (Create User, Fetch Identity By Subscription, Identify User). A pass that runs before the removal still sees the supplier queued; one that runs after it is ordered behind the hydrate by the removal's dispatch. The drop itself stays per send, since it also ends the livelock `main` had when a Fetch Identity By Subscription failed for good. The ordering is documented on the drop rule and at each site; no deterministic test can pin it without a hook inside the response handler, so none is added. Found by the adversarial review of #1740.
…estAuth 0b5fc76 stopped signing Delete Subscription by skipping `authorize` altogether, while `sendsUnsigned` still read as if only Update Subscription went out with no header. Two mechanisms for one exemption, and the header doc's claim that `OSRequestAuth` is the one place a header is decided no longer held. Have `authorize` let any `sendsUnsigned` Request through unsigned up front, owner or not, and have Delete Subscription declare the flag and call `authorize` again from its prepare. Update Subscription is unaffected: its owner is always nil, so it took the exempt branch either way. The doc on `sendsUnsigned` now names both Requests, and the ownership convention says Delete declares the flag rather than that it is "never signed either". Tests in OSRequestAuthTests: an owned exempt Request with no token goes through unsigned, unparked and without an ask, and one with a token on hand is still not signed. Both fail against the previous commit's `authorize`, as does the purge test's unsigned delete, since the Delete prepare calls `authorize` again. Found by the adversarial review of #1740.
… sites 1d1f050 moved the user-state report to three executor sites but tested only the parked Create User. The other two are reachable with Identity Verification off: a `login` while anonymous followed by a second `login` before the Identify User returns reported the first user after the app had switched to the second, and a 3.x fetch-identity landing after a `login` reported the anonymous user the app had logged in over. Both new tests fail against an unconditional fire at their site. A deleted call would not fail them, since the Fetch User that follows re-reports the current user; the guard is what they pin. Also from the review of that file: the idle check read the mock client's request lists off the test thread while the mock appends under its lock, so the mock gains a lock-backed `isIdle` and the test observer keeps its states under a lock. The negative count assertion does not depend on the 0.1s pause after it, since the report decision is made inside the response block and the mock records completion after that block returns; the comment now says so, and the pause stays to drain the executor queue before teardown.
…nal_id d9d6f11 keeps `external_id` across `clearData` and accepted that a Fetch User response omitting it no longer demotes the user, but the test stopped at the clear. What makes the kept alias safe is `internalAddAliases` merging rather than replacing the dictionary, and nothing locked that in. Add the hydrate step: after a clear, a response carrying only `onesignal_id` leaves `external_id` in place.
Comment-only. Each hydrate-then-remove site is down to one line that points at `dropIdentifyUsersThatCanNeverPrepare`, which states the constraint once. The test docs name the scenario in a sentence or two instead of retelling it, and the rest drop wording that contrasted with the code they replaced.
There was a problem hiding this comment.
Multi-model review (Claude Opus 5, GPT 5.6 Sol, Cursor Grok 4.6) of the SDK-5137 user-queue / identity-model follow-ups.
The five fixes match the stated intent: hydrate-before-dequeue closes the Identify drop race, token-park is no longer inferred from a missing JWT, external_id survives clearData, and Delete Subscription is unsigned on purpose. No consensus blocker on the main paths.
Act on: none
Consider
- Empty-success Create User / Fetch Identity dequeue without waking the queue, then a later send can drop the Identify (Grok; related Opus).
- When IV is off, a dead Identify is dropped rather than promoted to Create User (Opus). Same-user
loginis a no-op, so that promotion can be lost for the session. parkedForTokenis consumable; an innerexecute*Requestpark plus a later pre-authorize failure can skip the delayed retry (Opus warning, Grok nit).fireUserStateChangedIfCurrentreleasescurrentUser(matching:)before persist/notify, so a concurrent login can still report the previous user (Sol; Grok dismissed as overwritten by a later hydrate).authorizenow lets anysendsUnsignedrequest through before the no-owner refuse (Opus nit + Grok warning; Sol dismissed). Purge still drops anonymous deletes.
Noted: a fetch that omits external_id no longer demotes the user (documented; empty string still unlinks).
Dismissed: hydrate-then-remove race (2/3 closed); treating a 401 on unsigned Delete as JWT handling (2/3 Sol+Grok; same as Update Subscription).
Sent by Cursor Automation: PR Reviews
|
Potential issues:
|
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Hydrate after clearData merges, so a Fetch User that omits external_id never unlinks. Same-user login("A") no-ops because the kept alias still matches. Under IV those writes are addressed as A.
Keep the alias only in the clear-to-hydrate gap. Replace the identity map when a complete identity object arrives.
…ser hydrates `clearData` kept only `external_id`, so for the moment between the clear and the hydrate a live user's model had no `onesignal_id`. `dropIdentifyUsersThatCanNeverPrepare` runs on every send and does not count a Fetch User as a supplier, so a `login` landing in that moment, with a send pass behind it, dropped the Identify User and persisted the drop. Keep `onesignal_id` too: the fetch is addressed by one of the two ids, so neither can change. The same doc comment claimed the next `login` corrects a fetch response that omits `external_id`. It does not; a same-user login returns early while the local alias still matches, so only a login as someone else replaces it. The comment and the test docs now say that. The replace-on-hydrate alternative raised in review is deferred to its own ticket. The clear test's `onesignal_id` assertion flips from nil to kept, which the previous commit fails by construction.
Fixed in de1bb50. The clear now keeps the OneSignal ID as well as the external ID, so the drop rule never sees a live user without an ID; the fetch is addressed by one of the two, so neither can change. Keeping Fetch User queued would not have covered it on its own, since the rule does not count a Fetch User as a supplier. Promoting a dead Identify User is a separate, pre-existing case on main. |
UserExecutorTests is 348 body lines on this branch and 331 on 5.8-main, each under SwiftLint's 350-line type_body_length error, but the two additions meet in the merge and the merged class is 407 lines, which fails the Swift Lint job. Move the three restored-login tests and their three helpers into UserExecutorRestoredLoginTests in the same file, next to the archive tests that already live there, so nothing changes in the project file. The branch class drops to 272 lines and the merged one to 331.
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Thank you for addressing the changes


Description
One Line Summary
Six follow-ups from the Identity Verification review in the user request queue, request authorization, and the identity model, tracked on SDK-5137.
Details
Motivation
Review feedback on the Identity Verification stack was collected on [SDK-5137] instead of amending the stacked branches. This PR takes the items that live in the user request queue and the identity model. One of them is a regression against
mainthat does not depend on Identity Verification.Scope
Each fix is its own commit with its own test, and each test was run against the previous commit to confirm it fails without the fix.
mainkept it. That rule is restored, along withmain's guard against a request that can never be sent blocking the queue.Follow-ups from review, each its own commit:
Not changed: the public API, the push subscription path, and in-app messaging.
Testing
Unit testing
One test per fix, next to the code it covers. Two new files,
UserStateReportingTestsandUserExecutorRetryTests, because the existing test classes are at SwiftLint's size limit.Manual testing
Not run on a device. The full unit test plan was run locally on an iPhone 17 Pro simulator, 479 tests. One known intermittent test failed once and passed on re-run; nothing else failed. Each new test was also run against the commit before its fix and fails there.
Affected code checklist
Checklist
Overview
Testing
Final pass
🤖 Generated with Claude Code