Skip to content

MSDK 14.x dropped account-resolution caching — ~44x AccountManager IPC increase drives Android ColdStart/WarmBootstrap regression - #3040

Merged
JohnsonEricAtSalesforce merged 21 commits into
forcedotcom:devfrom
JohnsonEricAtSalesforce:bugfix/msdk-14-x-dropped-account-resolution-caching-44x-accountmanager-ipc-increase-drives-android-coldstart-warmbootstrap-regression
Sep 18, 2026
Merged

JohnsonEricAtSalesforce merged 21 commits into
forcedotcom:devfrom
JohnsonEricAtSalesforce:bugfix/msdk-14-x-dropped-account-resolution-caching-44x-accountmanager-ipc-increase-drives-android-coldstart-warmbootstrap-regression

Conversation

@JohnsonEricAtSalesforce

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores account-resolution caching that was dropped, which was driving a large increase in AccountManager IPC calls on cold-start and warm-bootstrap paths.

  • Adds a per-user, single-slot cache for SalesforceSDKManager.clientManager, keyed by org/user ID, so repeated access for the same current user re-resolves the account once instead of on every call.
  • Routes getRestClient() through that cache instead of constructing a ClientManager directly. getRestClient() is called from SalesforceActivityDelegate.onResume(), so it's on the Activity-resume hot path — found during review that it was bypassing the new cache entirely and still paying full AccountManager IPC cost on every resume.
  • Adds a no-op guard to registerUsedAppFeature(code, user) and unregisterUsedAppFeature(code, user) so calls that don't actually change the per-user feature set skip the AccountManager persistence round-trip. LoginActivity's per-login marker-clearing sweeps call the unregister path for markers that are already unset on every login, so this was a guaranteed no-op write on every login completion before the fix.
  • Invalidates the clientManager cache in cleanUp(userAccount), the existing choke point every account-removal path (logout, malformed-account purge, corrupt-account cleanup) already funnels through, closing a gap where the cache could return a manager bound to an account that was removed while remaining the "current" identity.
  • Adds @CallSuper to cleanUp(UserAccount?). Kotlin has no language-level "must call super" enforcement; this makes Android Lint's MissingSuperCall check enforce it at build time for any subclass (e.g. SmartStoreSDKManager) that overrides it, since skipping the super call would silently reintroduce the invalidation gap above.
  • Removes an unused import (AuthenticatorService.KEY_INSTANCE_URL) found while working in this file; pre-existing and unrelated to this fix.

A note on scope

The clientManager cache (this PR's core change) touches multi-user account resolution, which is a correctness-sensitive area. It has not yet been reviewed by the original author of that multi-user contract — flagging that explicitly here rather than assuming silence means no concern.

Test plan

  • New/updated unit tests for the no-op guards on registerUsedAppFeature/unregisterUsedAppFeature (verified fail-without/pass-with the fix)
  • New unit tests for clientManager cache invalidation on account removal (verified fail-without/pass-with the fix)
  • New unit test for getRestClient() reusing the cached clientManager instead of bypassing it (verified fail-without/pass-with the fix)
  • Verified @CallSuper actually triggers Android Lint's MissingSuperCall at build time by temporarily removing a subclass's super.cleanUp() call and confirming the lint error, then restoring it
  • SalesforceSDKManagerTests + SalesforceSDKManagerClientManagerTest run together locally: full pass, no failures
  • Confirmed clean compile after the unused-import removal
  • Full sharded SalesforceSDK instrumented test suite — deferred to CI

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

…ger hot paths

registerUsedAppFeature(code, user) unconditionally persisted the per-user
feature set to AccountManager on every call, even when the code was already
registered. Guard the persist call behind the ConcurrentSkipListSet.add()
result so it's a true no-op on repeat calls.

clientManager constructed a fresh ClientManager (and re-resolved its backing
Account) on every access. Cache the constructed manager keyed to the current
user's org/user ID pair, invalidating on user switch to preserve the
per-current-user binding from the ClientManager multi-user fix.
The clientManager cache only re-validates account existence on a cache-key
miss (i.e. on user switch), not on every access. If the cached account is
removed or becomes malformed while it remains the current-user identity
(logout, corrupt-account cleanup), the cache could otherwise hand back a
manager bound to an account that no longer exists.

Clear the cache in cleanUp(userAccount), the existing choke point every
account-removal path already funnels through, alongside the sibling
clearCachedCurrentUser() call it already makes.
registerUsedAppFeature(code, user) already skips the AccountManager
persistence round-trip when the code is a no-op; unregisterUsedAppFeature
had the identical unconditional-persist defect. LoginActivity's per-login
marker-clearing sweeps call this for markers that are already unset, so
this was a guaranteed no-op AccountManager write on every login completion.
Kotlin has no language-level way to require overrides call super, so
cleanUp(UserAccount?) relied entirely on convention to keep its
cache-invalidating base logic (including cachedClientManager) intact in
subclass overrides. @callsuper makes Android Lint's MissingSuperCall check
enforce this at build time; verified by lint against SmartStoreSDKManager's
existing override.
Pre-existing unused import, unrelated to any AuthenticatorService key
referenced elsewhere in this file.

@wmathurin wmathurin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review feedback on the cache lifecycles and documented performance semantics.

Comment thread libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt Outdated
Comment thread libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt Outdated
@brandonpage
brandonpage self-requested a review September 17, 2026 15:29

@wmathurin wmathurin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the follow-up commits. The per-user feature cache is now cleared on account cleanup, clientManager uses the cached identity path, and the single-entry cache semantics are documented and covered by passing focused tests. The remaining SalesforceSDK CI failures are unrelated expired test credentials.

Comment thread libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt Outdated
@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Deep Review: cleanUp(userAccount: UserAccount?) is protected open, so it's part of the subclassing surface any consuming app extending SalesforceSDKManager relies on. Adding @CallSuper here means any existing downstream override that skips super.cleanUp(userAccount) will start failing Android Lint's MissingSuperCall check the next time that consumer runs lint against this updated SDK. This isn't a source/binary-breaking signature change, and any consumer actually hit by it already had a real latent bug (skipping base cleanup), so the annotation is doing its job — but it's a new build-time constraint surfacing across the module boundary, and it isn't called out in the PR body. Worth a sentence there so third-party subclassers aren't left to discover it via a lint failure after upgrading.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Deep Review: getRestClient()'s corrupt-account detection changed resolution direction, not just cache-vs-no-cache. Before, it resolved identity via buildUserAccount(account) directly off the exact currentAccount handle already in hand. After this PR, it goes through the clientManager property, which independently resolves identity via cachedCurrentUser and then reverse-looks-up an Account via UserAccountManager.buildAccount(user). I traced this and the two paths can't diverge under current invariants — storeCurrentUserInfo/clearStoredCurrentUserInfo are the only writers of the stored current-user identity and both invalidate cachedCurrentUserAccount, so cachedCurrentUser and currentAccount always agree at any single point read. Functionally correct and already covered by the new getRestClient_withCurrentUser_populatesClientManagerCacheWithDeliveredInstance test plus the existing corrupt-account tests — but the PR body's technical description only frames this as "reuses the cache," not that the resolution direction flipped. Worth a sentence if the body gets revised again.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

…t-user cache reads/writes against a concurrent switch)
…identity once in getRestClient() to avoid a switch-race snapshot mismatch)
@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

@brandonpage - I've got your review topics in-progress, but I'm taking a little longer this morning to really look them over. My first pass yesterday uncovered a couple other topics yours helped discover. Thanks!

…toredCurrentUserInfo() against a concurrent cache read)

clearStoredCurrentUserInfo() had the same clear-then-write race as
storeCurrentUserInfo() (finding #1): it cleared the cached current user
before clearing the stored user/org ID from SharedPreferences, so a
concurrent getCachedCurrentUser() call landing in that window could
repopulate the cache from the not-yet-cleared IDs and keep returning the
logged-out user after the call returns. Found as a derivative of Brandon's
finding #1 while self-reviewing the fix for that finding. Wraps the clear +
SharedPreferences clear().apply() in the same currentUserLock monitor used
by storeCurrentUserInfo().
…eterministic exclusion tests

testStoreCurrentUserInfoIsNotStaleAfterConcurrentCacheReadDuringSwitch and
testClearStoredCurrentUserInfoIsNotStaleAfterConcurrentCacheReadDuringClear
raced busy-loop reader threads against thousands of writer trials, hoping
the scheduler would land in the (now closed) vulnerable window. Against
the fix in place this can only prove absence of failure over N trials,
not presence of correct behavior, and cost 50-70+ seconds of wall-clock
per run.

Replaces both with a deterministic test per write path
(testStoreCurrentUserInfoBlocksUntilConcurrentCacheReadCompletes,
testClearStoredCurrentUserInfoBlocksUntilConcurrentCacheReadCompletes)
using a same-package UserAccountManager subclass that pins a reader
inside getCurrentAccount() via a CountDownLatch while it still holds
currentUserLock, then asserts a concurrent writer call provably blocks
until the reader releases the lock. This proves genuine mutual exclusion
on the shared monitor directly, in under a second per test, instead of
inferring it from thousands of racing trials.
getRestClient() built a UserAccount from the current Account, then
ClientManager.peekRestClient() rebuilt an independent UserAccount from
that same Account internally, decrypting the same AccountManager fields
twice on every call to this Activity-resume hot path. Add a
peekRestClient(UserAccount) overload that reuses the caller's already-
resolved UserAccount while preserving the same liveness re-check and
field validation, and route getRestClient() through it.
@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

While addressing your review comment on the current-user cache race, I found the same clear-then-write race also existed on the logout path: clearStoredCurrentUserInfo() did a locked cache clear followed by an unlocked SharedPreferences.clear().apply(). A concurrent cache-populate call landing in that unlocked window could rebuild cachedCurrentUserAccount from the not-yet-cleared stored IDs and publish a stale result after logout completes. Fixed in 4afc82148 with the same synchronized(currentUserLock) shape as the fix for your original comment — flagging separately since it's a derivative of your finding, not something you called out directly.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Followed up on the currentUserLock approach with an alternatives check + call-site impact analysis: considered a generation-counter/seqlock, ReentrantReadWriteLock, a lock-free AtomicReference swap, and shrinking the critical section to exclude the AccountManager IPC. All either reintroduce the original race or don't hold up structurally for a "read that can become a write" shape (getCachedCurrentUser() can fall through to getCurrentUser() on a cache miss). Traced ~17 call sites for getCurrentUser()/.currentUser and ~22 for getCachedCurrentUser()/.cachedCurrentUser: the Activity-resume hot path goes through the separate @Volatile cachedClientManager, not currentUserLock directly, so lock contention only bites during genuine concurrent identity switches — rare and user-initiated, not steady-state ColdStart/WarmBootstrap. Kept synchronized(currentUserLock) as shipped.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

One more finding from self-review: getRestClient() built a UserAccount from the current account, then ClientManager.peekRestClient() rebuilt an independent one internally via getValidatedUser() — decrypting the same ~40 AccountManager fields twice per call on the Activity-resume hot path. Confirmed this is pre-existing on upstream/dev, not introduced by this PR. Fixed in 602046a20 by adding a peekRestClient(UserAccount) overload that reuses the caller's already-resolved account while preserving the same liveness re-check and field validation.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
1 Warning
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java#L113 - Do not place Android context classes in static fields (static reference to UserAccountManager which has field context pointing to Context); this is a memory leak

Generated by 🚫 Danger

…ent(UserAccount) package-private again, bridged via a module-internal extension function, to satisfy the ClientManager API-surface guard test)
@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor Author

Follow-up: resolved a self-caught API-surface regression before merge

While re-checking CI ahead of merge, I found that an earlier commit on this branch (602046a20) had made the new peekRestClient(UserAccount) overload public, which tripped the MobileSdk14ApiSurfaceTest guard that deliberately keeps that overload off ClientManager's public API surface.

Fixed in commit 66f1a24: the overload is package-private again, and SalesforceSDKManager reaches it through a module-internal Kotlin extension (ClientManagerInternal.kt) so the API surface stays unchanged while the caller still avoids re-resolving the UserAccount. CI on the new head is green apart from the known dev-wide expired-test-credential flake (unrelated to this change).

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce merged commit 834aac5 into forcedotcom:dev Sep 18, 2026
5 of 6 checks passed
@JohnsonEricAtSalesforce
JohnsonEricAtSalesforce deleted the bugfix/msdk-14-x-dropped-account-resolution-caching-44x-accountmanager-ipc-increase-drives-android-coldstart-warmbootstrap-regression branch September 19, 2026 00:18
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