Skip to content

Commit 4f86977

Browse files
siddhijainCopilotCopilot
authored
Optimize token cache remove path and add filter-first-clone flight for filtered retrieval, Fixes AB#3570409 (#3074)
Fixes [AB#3570409](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3570409) `removeAccount()`/`removeCredential()` were triggering decrypt-all via `keySet()` → `getAll()` just to check key existence. Adds a flight-gated optimization for filtered retrieval and elapsed-time telemetry for `deleteAccessTokensWithIntersectingScopes`. ## Changes - **`removeAccount()` / `removeCredential()`**: Replace `mSharedPreferencesFileManager.keySet().contains(cacheKey)` with O(1) in-memory `containsKey()` on `mCachedAccountRecordsWithKeys` / `mCachedCredentialsWithKeys` - **Flight `ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE`**: When enabled, `getAccountsFilteredBy()` / `getCredentialsFilteredBy()` filter directly on uncloned in-memory references, then clone only matching items — avoiding cloning records that will be filtered out. `getAccounts()` / `getCredentials()` are unaffected and always return cloned lists. - **Telemetry**: Add `elapsed_time_cache_delete_access_tokens_with_intersecting_scopes` span attribute in `MsalOAuth2TokenCache`; remove unused `elapsed_time_save_account_shared_preferences`; add `is_filter_then_clone_enabled` attribute in `getCredentials()` - **Tests**: `KeySetTrackingStorage` wrapper asserts `keySet()`/`getAll()` are never called during remove; flight on/off tests verify clone-only-matching behavior for filtered methods - **Changelog**: vNext `[PATCH]` entry `#3074` --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: siddhijain <30181294+siddhijain@users.noreply.github.com>
1 parent 4539cfa commit 4f86977

6 files changed

Lines changed: 537 additions & 35 deletions

File tree

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
vNext
22
----------
3+
- [PATCH] Optimize AcquireTokenSilent save path: replace keySet() decrypt-all with in-memory map lookup in removeAccount()/removeCredential(), add telemetry for deleteAccessTokensWithIntersectingScopes, and remove unused elapsed_time_save_account_shared_preferences attribute (#3074)
34
- [MINOR] Add DeviceRegistrationClientApplication as public API for OneAuth device registration with mandatory correlationId, DeviceState and DrsDiscoveryEndpoint enums (#3073)
45
- [MINOR] Move device registration protocol types, domain types, controller, and packer from broker to common to enable OneAuth device registration support (#3066)
56
- [MINOR] Upgrade compileSdkVersion to 36 and buildToolsVersion to 36.0.0 (#3065)

common/src/test/java/com/microsoft/identity/common/SharedPreferencesAccountCredentialCacheWithMemoryCacheTest.java

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.google.gson.JsonPrimitive;
3131
import com.microsoft.identity.common.components.AndroidPlatformComponentsFactory;
3232
import com.microsoft.identity.common.components.InMemoryStorageSupplier;
33+
import com.microsoft.identity.common.internal.mocks.MockCommonFlightsManager;
3334
import com.microsoft.identity.common.java.authscheme.BearerAuthenticationSchemeInternal;
3435
import com.microsoft.identity.common.java.cache.CacheKeyValueDelegate;
3536
import com.microsoft.identity.common.java.cache.SharedPreferencesAccountCredentialCacheWithMemoryCache;
@@ -40,20 +41,28 @@
4041
import com.microsoft.identity.common.java.dto.IdTokenRecord;
4142
import com.microsoft.identity.common.java.dto.PrimaryRefreshTokenRecord;
4243
import com.microsoft.identity.common.java.dto.RefreshTokenRecord;
44+
import com.microsoft.identity.common.java.flighting.CommonFlight;
45+
import com.microsoft.identity.common.java.flighting.CommonFlightsManager;
46+
import com.microsoft.identity.common.java.flighting.IFlightsProvider;
4347
import com.microsoft.identity.common.java.interfaces.INameValueStorage;
48+
import com.microsoft.identity.common.java.util.ported.Predicate;
4449
import com.microsoft.identity.common.shadows.ShadowAndroidSdkStorageEncryptionManager;
4550

4651
import org.junit.After;
4752
import org.junit.Before;
4853
import org.junit.Test;
4954
import org.junit.runner.RunWith;
55+
import org.mockito.Mockito;
5056
import org.robolectric.RobolectricTestRunner;
5157
import org.robolectric.annotation.Config;
5258

5359
import java.util.HashMap;
60+
import java.util.Iterator;
5461
import java.util.List;
5562
import java.util.Locale;
5663
import java.util.Map;
64+
import java.util.Set;
65+
import java.util.concurrent.atomic.AtomicInteger;
5766

5867
import static com.microsoft.identity.common.java.cache.CacheKeyValueDelegate.CACHE_VALUE_SEPARATOR;
5968
import static org.junit.Assert.assertEquals;
@@ -62,6 +71,7 @@
6271
import static org.junit.Assert.assertNotSame;
6372
import static org.junit.Assert.assertNull;
6473
import static org.junit.Assert.assertTrue;
74+
import static org.mockito.Mockito.when;
6575

6676
@RunWith(RobolectricTestRunner.class)
6777
@Config(shadows = {ShadowAndroidSdkStorageEncryptionManager.class})
@@ -2515,4 +2525,322 @@ public void testReturnedAllCredentialsAreCloned() {
25152525
creds1.get(0).setCachedAt("banana");
25162526
assertNotEquals(creds1.get(0), creds2.get(0));
25172527
}
2528+
2529+
// =====================================================================
2530+
// Flight-gated behavior tests for ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE
2531+
// =====================================================================
2532+
2533+
private void enableFilterThenCloneFlight() {
2534+
CommonFlightsManager.INSTANCE.resetFlightsManager();
2535+
final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class);
2536+
when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE))
2537+
.thenReturn(true);
2538+
final MockCommonFlightsManager mockFlightsManager = new MockCommonFlightsManager();
2539+
mockFlightsManager.setMockCommonFlightsProvider(mockFlightsProvider);
2540+
CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(mockFlightsManager);
2541+
}
2542+
2543+
private void resetFlight() {
2544+
CommonFlightsManager.INSTANCE.resetFlightsManager();
2545+
}
2546+
2547+
@Test
2548+
public void getAccounts_flightDisabled_returnsMutableListOfClones() {
2549+
final AccountRecord account = buildDefaultAccountRecord();
2550+
mSharedPreferencesAccountCredentialCache.saveAccount(account);
2551+
2552+
final List<AccountRecord> accounts1 = mSharedPreferencesAccountCredentialCache.getAccounts();
2553+
assertEquals(1, accounts1.size());
2554+
final List<AccountRecord> accounts2 = mSharedPreferencesAccountCredentialCache.getAccounts();
2555+
assertEquals(1, accounts2.size());
2556+
2557+
// The list itself should be a new mutable list each time
2558+
assertNotSame(accounts1, accounts2);
2559+
2560+
// The returned objects should be clones, not the same references
2561+
assertNotSame(accounts1.get(0), accounts2.get(0));
2562+
assertEquals(accounts1.get(0), accounts2.get(0));
2563+
2564+
// Mutating a returned object should not affect subsequent retrievals
2565+
accounts1.get(0).setLocalAccountId("mutated");
2566+
final List<AccountRecord> accounts3 = mSharedPreferencesAccountCredentialCache.getAccounts();
2567+
assertNotEquals("mutated", accounts3.get(0).getLocalAccountId());
2568+
2569+
// The returned list should be mutable (no exception thrown)
2570+
accounts1.add(new AccountRecord());
2571+
}
2572+
2573+
@Test
2574+
public void getAccounts_flightEnabled_stillReturnsMutableListOfClones() {
2575+
enableFilterThenCloneFlight();
2576+
try {
2577+
final AccountRecord account = buildDefaultAccountRecord();
2578+
mSharedPreferencesAccountCredentialCache.saveAccount(account);
2579+
2580+
final List<AccountRecord> accounts1 = mSharedPreferencesAccountCredentialCache.getAccounts();
2581+
assertEquals(1, accounts1.size());
2582+
final List<AccountRecord> accounts2 = mSharedPreferencesAccountCredentialCache.getAccounts();
2583+
assertEquals(1, accounts2.size());
2584+
2585+
// Even with flight enabled, getAccounts() still returns clones
2586+
assertNotSame(accounts1.get(0), accounts2.get(0));
2587+
assertEquals(accounts1.get(0), accounts2.get(0));
2588+
2589+
// Mutating a returned object should not affect subsequent retrievals
2590+
accounts1.get(0).setLocalAccountId("mutated");
2591+
final List<AccountRecord> accounts3 = mSharedPreferencesAccountCredentialCache.getAccounts();
2592+
assertNotEquals("mutated", accounts3.get(0).getLocalAccountId());
2593+
2594+
// The returned list should be mutable (no exception thrown)
2595+
accounts1.add(new AccountRecord());
2596+
} finally {
2597+
resetFlight();
2598+
}
2599+
}
2600+
2601+
@Test
2602+
public void getCredentials_flightDisabled_returnsMutableListOfClones() {
2603+
final RefreshTokenRecord rt = buildDefaultRefreshToken();
2604+
mSharedPreferencesAccountCredentialCache.saveCredential(rt);
2605+
2606+
final List<Credential> creds1 = mSharedPreferencesAccountCredentialCache.getCredentials();
2607+
assertEquals(1, creds1.size());
2608+
final List<Credential> creds2 = mSharedPreferencesAccountCredentialCache.getCredentials();
2609+
assertEquals(1, creds2.size());
2610+
2611+
// The list itself should be a new mutable list each time
2612+
assertNotSame(creds1, creds2);
2613+
2614+
// The returned objects should be clones, not the same references
2615+
assertNotSame(creds1.get(0), creds2.get(0));
2616+
assertEquals(creds1.get(0), creds2.get(0));
2617+
2618+
// Mutating a returned object should not affect subsequent retrievals
2619+
creds1.get(0).setCachedAt("mutated");
2620+
final List<Credential> creds3 = mSharedPreferencesAccountCredentialCache.getCredentials();
2621+
assertNotEquals("mutated", creds3.get(0).getCachedAt());
2622+
2623+
// The returned list should be mutable (no exception thrown)
2624+
creds1.add(new RefreshTokenRecord());
2625+
}
2626+
2627+
@Test
2628+
public void getCredentials_flightEnabled_stillReturnsMutableListOfClones() {
2629+
enableFilterThenCloneFlight();
2630+
try {
2631+
final RefreshTokenRecord rt = buildDefaultRefreshToken();
2632+
mSharedPreferencesAccountCredentialCache.saveCredential(rt);
2633+
2634+
final List<Credential> creds1 = mSharedPreferencesAccountCredentialCache.getCredentials();
2635+
assertEquals(1, creds1.size());
2636+
final List<Credential> creds2 = mSharedPreferencesAccountCredentialCache.getCredentials();
2637+
assertEquals(1, creds2.size());
2638+
2639+
// Even with flight enabled, getCredentials() still returns clones
2640+
assertNotSame(creds1.get(0), creds2.get(0));
2641+
assertEquals(creds1.get(0), creds2.get(0));
2642+
2643+
// Mutating a returned object should not affect subsequent retrievals
2644+
creds1.get(0).setCachedAt("mutated");
2645+
final List<Credential> creds3 = mSharedPreferencesAccountCredentialCache.getCredentials();
2646+
assertNotEquals("mutated", creds3.get(0).getCachedAt());
2647+
2648+
// The returned list should be mutable (no exception thrown)
2649+
creds1.add(new RefreshTokenRecord());
2650+
} finally {
2651+
resetFlight();
2652+
}
2653+
}
2654+
2655+
// =====================================================================
2656+
// Tests verifying filter-then-clone optimization in FilteredBy methods
2657+
// =====================================================================
2658+
2659+
@Test
2660+
public void getAccountsFilteredBy_flightEnabled_returnsClonedMatches() {
2661+
enableFilterThenCloneFlight();
2662+
try {
2663+
final AccountRecord account = buildDefaultAccountRecord();
2664+
mSharedPreferencesAccountCredentialCache.saveAccount(account);
2665+
2666+
// Also save a non-matching account
2667+
final AccountRecord otherAccount = new AccountRecord();
2668+
otherAccount.setHomeAccountId("other-home-id");
2669+
otherAccount.setEnvironment("other.environment.com");
2670+
otherAccount.setRealm("other-realm");
2671+
otherAccount.setLocalAccountId("other-local-id");
2672+
otherAccount.setUsername("other-user");
2673+
otherAccount.setAuthorityType(AUTHORITY_TYPE);
2674+
mSharedPreferencesAccountCredentialCache.saveAccount(otherAccount);
2675+
2676+
// Filter by the default account's realm — should return only one match
2677+
final List<AccountRecord> filtered = mSharedPreferencesAccountCredentialCache
2678+
.getAccountsFilteredBy(HOME_ACCOUNT_ID, ENVIRONMENT, REALM);
2679+
assertEquals(1, filtered.size());
2680+
assertEquals(HOME_ACCOUNT_ID, filtered.get(0).getHomeAccountId());
2681+
2682+
// Returned objects should be clones — mutating should not affect cache
2683+
filtered.get(0).setLocalAccountId("mutated");
2684+
final List<AccountRecord> filtered2 = mSharedPreferencesAccountCredentialCache
2685+
.getAccountsFilteredBy(HOME_ACCOUNT_ID, ENVIRONMENT, REALM);
2686+
assertNotEquals("mutated", filtered2.get(0).getLocalAccountId());
2687+
} finally {
2688+
resetFlight();
2689+
}
2690+
}
2691+
2692+
@Test
2693+
public void getCredentialsFilteredBy_flightEnabled_returnsClonedMatches() {
2694+
enableFilterThenCloneFlight();
2695+
try {
2696+
final RefreshTokenRecord rt = buildDefaultRefreshToken();
2697+
mSharedPreferencesAccountCredentialCache.saveCredential(rt);
2698+
2699+
// Also save a non-matching credential
2700+
final RefreshTokenRecord otherRt = new RefreshTokenRecord();
2701+
otherRt.setCredentialType(CredentialType.RefreshToken.name());
2702+
otherRt.setHomeAccountId("other-home-id");
2703+
otherRt.setEnvironment("other.environment.com");
2704+
otherRt.setClientId("other-client-id");
2705+
otherRt.setCachedAt(CACHED_AT);
2706+
otherRt.setSecret(SECRET);
2707+
mSharedPreferencesAccountCredentialCache.saveCredential(otherRt);
2708+
2709+
// Filter by the default credential's attributes — should return only one match
2710+
final List<Credential> filtered = mSharedPreferencesAccountCredentialCache
2711+
.getCredentialsFilteredBy(
2712+
HOME_ACCOUNT_ID, ENVIRONMENT, CredentialType.RefreshToken,
2713+
CLIENT_ID, null, null, null, null, null);
2714+
assertEquals(1, filtered.size());
2715+
assertEquals(CLIENT_ID, filtered.get(0).getClientId());
2716+
2717+
// Returned objects should be clones — mutating should not affect cache
2718+
filtered.get(0).setCachedAt("mutated");
2719+
final List<Credential> filtered2 = mSharedPreferencesAccountCredentialCache
2720+
.getCredentialsFilteredBy(
2721+
HOME_ACCOUNT_ID, ENVIRONMENT, CredentialType.RefreshToken,
2722+
CLIENT_ID, null, null, null, null, null);
2723+
assertNotEquals("mutated", filtered2.get(0).getCachedAt());
2724+
} finally {
2725+
resetFlight();
2726+
}
2727+
}
2728+
2729+
// =====================================================================
2730+
// Tests verifying removeAccount/removeCredential do not call keySet()
2731+
// =====================================================================
2732+
2733+
/**
2734+
* An {@link INameValueStorage} wrapper that counts calls to {@link #keySet()} and {@link #getAll()}.
2735+
* Used to verify that the {@code removeAccount()} and {@code removeCredential()} optimization
2736+
* avoids the expensive decrypt-all path by not calling these methods on the backing storage.
2737+
*/
2738+
private static class KeySetTrackingStorage implements INameValueStorage<String> {
2739+
private final INameValueStorage<String> mDelegate;
2740+
// AtomicInteger used because the cache performs its initial load on a background thread.
2741+
private final AtomicInteger mKeySetCallCount = new AtomicInteger(0);
2742+
private final AtomicInteger mGetAllCallCount = new AtomicInteger(0);
2743+
2744+
KeySetTrackingStorage(final INameValueStorage<String> delegate) {
2745+
mDelegate = delegate;
2746+
}
2747+
2748+
@Override
2749+
public String get(final String name) {
2750+
return mDelegate.get(name);
2751+
}
2752+
2753+
@Override
2754+
public Map<String, String> getAll() {
2755+
mGetAllCallCount.incrementAndGet();
2756+
return mDelegate.getAll();
2757+
}
2758+
2759+
@Override
2760+
public void put(final String name, final String value) {
2761+
mDelegate.put(name, value);
2762+
}
2763+
2764+
@Override
2765+
public void remove(final String name) {
2766+
mDelegate.remove(name);
2767+
}
2768+
2769+
@Override
2770+
public void clear() {
2771+
mDelegate.clear();
2772+
}
2773+
2774+
@Override
2775+
public Set<String> keySet() {
2776+
mKeySetCallCount.incrementAndGet();
2777+
return mDelegate.keySet();
2778+
}
2779+
2780+
@Override
2781+
public Iterator<Map.Entry<String, String>> getAllFilteredByKey(final Predicate<String> keyFilter) {
2782+
return mDelegate.getAllFilteredByKey(keyFilter);
2783+
}
2784+
2785+
int getKeySetCallCount() {
2786+
return mKeySetCallCount.get();
2787+
}
2788+
2789+
int getAllCallCount() {
2790+
return mGetAllCallCount.get();
2791+
}
2792+
2793+
void resetCallCounts() {
2794+
mKeySetCallCount.set(0);
2795+
mGetAllCallCount.set(0);
2796+
}
2797+
}
2798+
2799+
@Test
2800+
public void removeAccount_doesNotCallKeySetOrGetAll() throws Exception {
2801+
final INameValueStorage<String> baseStorage = new InMemoryStorageSupplier()
2802+
.getEncryptedNameValueStore(
2803+
sAccountCredentialSharedPreferences + "_removeAccountTest",
2804+
String.class);
2805+
final KeySetTrackingStorage trackingStorage = new KeySetTrackingStorage(baseStorage);
2806+
final SharedPreferencesAccountCredentialCacheWithMemoryCache cache =
2807+
new SharedPreferencesAccountCredentialCacheWithMemoryCache(mDelegate, trackingStorage);
2808+
2809+
final AccountRecord account = buildDefaultAccountRecord();
2810+
cache.saveAccount(account);
2811+
2812+
// Force initial load to complete (getAccounts blocks on waitForInitialLoad),
2813+
// then reset counters so only removeAccount() calls are measured.
2814+
cache.getAccounts();
2815+
trackingStorage.resetCallCounts();
2816+
2817+
cache.removeAccount(account);
2818+
2819+
assertEquals("removeAccount() must not call keySet()", 0, trackingStorage.getKeySetCallCount());
2820+
assertEquals("removeAccount() must not call getAll()", 0, trackingStorage.getAllCallCount());
2821+
}
2822+
2823+
@Test
2824+
public void removeCredential_doesNotCallKeySetOrGetAll() throws Exception {
2825+
final INameValueStorage<String> baseStorage = new InMemoryStorageSupplier()
2826+
.getEncryptedNameValueStore(
2827+
sAccountCredentialSharedPreferences + "_removeCredentialTest",
2828+
String.class);
2829+
final KeySetTrackingStorage trackingStorage = new KeySetTrackingStorage(baseStorage);
2830+
final SharedPreferencesAccountCredentialCacheWithMemoryCache cache =
2831+
new SharedPreferencesAccountCredentialCacheWithMemoryCache(mDelegate, trackingStorage);
2832+
2833+
final RefreshTokenRecord rt = buildDefaultRefreshToken();
2834+
cache.saveCredential(rt);
2835+
2836+
// Force initial load to complete (getCredentials blocks on waitForInitialLoad),
2837+
// then reset counters so only removeCredential() calls are measured.
2838+
cache.getCredentials();
2839+
trackingStorage.resetCallCounts();
2840+
2841+
cache.removeCredential(rt);
2842+
2843+
assertEquals("removeCredential() must not call keySet()", 0, trackingStorage.getKeySetCallCount());
2844+
assertEquals("removeCredential() must not call getAll()", 0, trackingStorage.getAllCallCount());
2845+
}
25182846
}

0 commit comments

Comments
 (0)