fix: restore local tags cleared by a concurrent FetchUser#1678
Conversation
On a successful UpdateProperties response, merge the server-confirmed tags back into the local model so a concurrent FetchUser whose response is missing the just-written tags can't leave the tag cache cleared. Merging (never replacing) keeps backend-managed tags intact; a "" value removes the tag. Co-authored-by: Cursor <cursoragent@cursor.com>
Add a unit test for mergeConfirmedTags and a regression test asserting tags cleared by a concurrent fetch are restored once the update is confirmed. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@claude review |
There was a problem hiding this comment.
LGTM — small, well-scoped fix for the FetchUser tag-clobber race, with the merge re-asserting only server-confirmed tags under the existing lock and good test coverage for both the merge semantics and the regression.
Extended reasoning...
Overview
This PR adds a narrowly-scoped fix for a known race where a stale concurrent FetchUser response wipes locally-set tags. Two source-file changes:
OSPropertiesModel.mergeConfirmedTags(_:)— a new method that overlays server-confirmed tags onto the localtagsdictionary under the existingtagsLock, treating""as a removal.OSPropertyOperationExecutor.executeUpdatePropertiesRequestsuccess handler — after a 202, if this is still the current user and the response echoes a non-emptyproperties.tags, the executor callsmergeConfirmedTagsto re-assert what the server just confirmed.
A unit test exercises the merge semantics (update/remove/add while leaving unrelated keys intact) and an integration-style regression test asserts that tags survive a simulated concurrent FetchUser clobber.
Security risks
None apparent. The change does not touch auth, crypto, permissions, network transport, identifiers, or serialization of credentials. It only mutates an in-memory tag dictionary that the SDK already owns, gated by a current-user check.
Level of scrutiny
Low-to-moderate. It is user-state code (not security-sensitive), the diff is small, the new primitive is pure and easy to reason about, and the call site is guarded (isCurrentUser, non-empty tags). The author already documented the residual race and limitations in the description, and the bug hunting pass found no issues.
Other factors
- Reuses the existing
tagsLock(recursive) — no new locking primitives, no risk of deadlock with the existingaddTags/removeTags/clearData/encode/hydrateModelcall sites that already use the same lock. - The merge is additive-only relative to keys present in the response, so it cannot drop backend-managed tags.
- The
!confirmedTags.isEmptyguard correctly preserves the documented "all-removals echoed astags: {}" no-op behavior, with the author's note that a later clean fetch reconciles. - No public API surface changes, no CODEOWNERS file in the repo, and the prior 'nan-li' approval is consistent with the scope of the change.
Description
One Line Summary
Restore locally set tags that a concurrent
FetchUsercan wipe, by re-asserting server-confirmed tags when anUpdatePropertiesrequest succeeds.Details
Motivation
getTags()could return{}or missing recently-added tags, even when the tags exist on the server.addTagswrites tags optimistically to the local model and sends them viaOSRequestUpdateProperties. If aFetchUserruns concurrently (e.g. a new session after backgrounding/foregrounding) and its response is missing the just-written tags, itsclearUserData()+ hydrate wipes the local tag cache and nothing restores it — sogetTags()keeps returning only a subset of tags. The write itself succeeds; only the SDK's local mirror ends up wrong.Fix
On a successful
OSRequestUpdateProperties(202) response, merge the tags the server just confirmed (echoed back in the response) into the local properties model. This is a merge, never a replace:""value is treated as a removal, matching how the server echoes removed tags.Scope
Affects only user tags, on
UpdatePropertiessuccess. TheFetchUserflow and other user properties are unchanged. No public API changes.Alternatives considered
FetchUserhydration: rejected — it would re-introduce stale local-only tags and drop tags the app's backend set via the REST API, clobbering backend state.FetchUserhydration: viable and race-equivalent to the chosen approach, but needs more cross-executor plumbing (inspecting the operation repo + property executor queues). Merge-on-confirm is simpler, self-contained, and only ever re-asserts tags the server just confirmed.Trade-offs / concessions / limitations
UpdatePropertiesresponse echoes only the tags that were sent (confirmed from logs), not the user's full tag set — so this is necessarily a merge, not a full resync.properties.tagsfrom the 202 response. If a response omits it, the merge simply no-ops for that round (no restoration); a later clean fetch reconciles.key: "": removed tags come back as a non-emptytagsobject with empty-string values, which the merge treats as deletions. If the backend ever collapsed an all-removals update totags: {}, the!confirmedTags.isEmptyguard would skip the merge; a later clean fetch still reconciles.FetchUserapplied after the write is already confirmed is not covered in that instant; it self-corrects on the next fetch user call.Testing
Unit testing
testMergeConfirmedTags_mergesAndRemovesWithoutTouchingOtherTags— merge semantics: updates,""removals, additions, and other tags left untouched (never a wholesale replace).testUpdatePropertiesResponse_restoresTagsClearedByConcurrentFetch— regression: add tags, simulate a concurrent fetch clearing the local cache, then assert the tags are restored once the update confirms.OneSignalUserTests,UserExecutorTests,UserConcurrencyTests, andSwitchUserIntegrationTestspass.Manual testing
Not performed on-device: the race is timing-dependent and not naturally reproducible, so it is covered by the regression test above, which simulates the clobber via the property model's
clearData()— the same clearing a staleFetchUserperforms.Affected code checklist
Checklist
Overview
Testing
Final pass