-
Notifications
You must be signed in to change notification settings - Fork 2.2k
[NO REVIEW][Cosmos] Share PartitionKeyRangeCache across CosmosClients targeting the same account #49560
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
[NO REVIEW][Cosmos] Share PartitionKeyRangeCache across CosmosClients targeting the same account #49560
Changes from all commits
5a4b74d
f3fa638
75f93d5
05f6780
cbd47a8
9b43616
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,10 +20,13 @@ | |
| import reactor.core.publisher.Mono; | ||
| import reactor.test.StepVerifier; | ||
|
|
||
| import java.net.URI; | ||
| import java.util.Arrays; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.concurrent.atomic.AtomicInteger; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Fail.fail; | ||
| import static org.mockito.ArgumentMatchers.any; | ||
| import static org.mockito.ArgumentMatchers.eq; | ||
|
|
@@ -35,7 +38,7 @@ public class RxPartitionKeyRangeCacheTest { | |
| private RxDocumentClientImpl client; | ||
| private RxCollectionCache collectionCache; | ||
| private RxPartitionKeyRangeCache cache; | ||
|
|
||
| @BeforeMethod(groups = "unit") | ||
| public void before_test() { | ||
| client = Mockito.mock(RxDocumentClientImpl.class); | ||
|
|
@@ -245,4 +248,161 @@ public void tryLookupAsync_RetriesOnceAndConvertsToNotFoundException() { | |
| .expectNextMatches(s -> s.v == null) | ||
| .verifyComplete(); | ||
| } | ||
|
|
||
| @Test(groups = "unit") | ||
| public void twoCachesForSameEndpointShareRoutingMapStorage() throws Exception { | ||
| // Two RxPartitionKeyRangeCache instances pointing at the same endpoint should | ||
| // share their underlying AsyncCacheNonBlocking, so a routing map populated by | ||
| // one is immediately visible to the other without a second /pkranges call. | ||
| URI endpoint = new URI("https://test-shared-pkr-1.documents.azure.com:443/"); | ||
|
|
||
| RxDocumentClientImpl clientA = Mockito.mock(RxDocumentClientImpl.class); | ||
| RxDocumentClientImpl clientB = Mockito.mock(RxDocumentClientImpl.class); | ||
| RxCollectionCache collA = Mockito.mock(RxCollectionCache.class); | ||
| RxCollectionCache collB = Mockito.mock(RxCollectionCache.class); | ||
|
|
||
| String collectionRid = "shared-coll-1"; | ||
| DocumentCollection collection = new DocumentCollection(); | ||
| collection.setResourceId(collectionRid); | ||
| collection.setSelfLink("dbs/db1/colls/coll1"); | ||
|
|
||
| PartitionKeyRange range = new PartitionKeyRange(); | ||
| range.setId("0"); | ||
| range.setMinInclusive(PartitionKeyRange.MINIMUM_INCLUSIVE_EFFECTIVE_PARTITION_KEY); | ||
| range.setMaxExclusive(PartitionKeyRange.MAXIMUM_EXCLUSIVE_EFFECTIVE_PARTITION_KEY); | ||
|
|
||
| FeedResponse<PartitionKeyRange> response = Mockito.mock(FeedResponse.class); | ||
| when(response.getResults()).thenReturn(Arrays.asList(range)); | ||
| when(response.getContinuationToken()).thenReturn("etag-1"); | ||
|
|
||
| AtomicInteger clientACalls = new AtomicInteger(); | ||
| AtomicInteger clientBCalls = new AtomicInteger(); | ||
|
|
||
| when(collA.resolveCollectionAsync(any(), any())) | ||
| .thenReturn(Mono.just(new Utils.ValueHolder<>(collection))); | ||
| when(clientA.readPartitionKeyRanges(eq(collection.getSelfLink()), any(CosmosQueryRequestOptions.class))) | ||
| .thenAnswer(invocation -> { | ||
| clientACalls.incrementAndGet(); | ||
| return Flux.just(response); | ||
| }); | ||
|
|
||
| when(collB.resolveCollectionAsync(any(), any())) | ||
| .thenReturn(Mono.just(new Utils.ValueHolder<>(collection))); | ||
| when(clientB.readPartitionKeyRanges(eq(collection.getSelfLink()), any(CosmosQueryRequestOptions.class))) | ||
| .thenAnswer(invocation -> { | ||
| clientBCalls.incrementAndGet(); | ||
| return Flux.just(response); | ||
| }); | ||
|
|
||
| RxPartitionKeyRangeCache cacheA = new RxPartitionKeyRangeCache(clientA, collA, endpoint); | ||
| RxPartitionKeyRangeCache cacheB = new RxPartitionKeyRangeCache(clientB, collB, endpoint); | ||
|
|
||
| try { | ||
| // First client populates the cache. | ||
| StepVerifier.create(cacheA.tryLookupAsync(null, collectionRid, null, new HashMap<>())) | ||
| .expectNextMatches(v -> v != null && v.v != null) | ||
| .verifyComplete(); | ||
|
|
||
| // Second client must hit the shared cache without issuing its own /pkranges read. | ||
| StepVerifier.create(cacheB.tryLookupAsync(null, collectionRid, null, new HashMap<>())) | ||
| .expectNextMatches(v -> v != null && v.v != null) | ||
| .verifyComplete(); | ||
|
|
||
| assertThat(clientACalls.get()) | ||
| .as("client A populates the shared routing map") | ||
| .isEqualTo(1); | ||
| assertThat(clientBCalls.get()) | ||
| .as("client B must hit the shared cache without issuing its own /pkranges call") | ||
| .isZero(); | ||
| } finally { | ||
| cacheA.close(); | ||
| cacheB.close(); | ||
| } | ||
|
|
||
| assertThat(SharedRoutingMapCacheRegistry.getInstance().referenceCount(endpoint)) | ||
| .as("close() releases the shared cache reference") | ||
| .isZero(); | ||
| } | ||
|
|
||
| @Test(groups = "unit") | ||
| public void cachesForDifferentEndpointsDoNotShareStorage() throws Exception { | ||
| URI endpointA = new URI("https://test-shared-pkr-2a.documents.azure.com:443/"); | ||
| URI endpointB = new URI("https://test-shared-pkr-2b.documents.azure.com:443/"); | ||
|
|
||
| RxDocumentClientImpl clientA = Mockito.mock(RxDocumentClientImpl.class); | ||
| RxDocumentClientImpl clientB = Mockito.mock(RxDocumentClientImpl.class); | ||
| RxCollectionCache collA = Mockito.mock(RxCollectionCache.class); | ||
| RxCollectionCache collB = Mockito.mock(RxCollectionCache.class); | ||
|
|
||
| String collectionRid = "shared-coll-2"; | ||
| DocumentCollection collection = new DocumentCollection(); | ||
| collection.setResourceId(collectionRid); | ||
| collection.setSelfLink("dbs/db1/colls/coll2"); | ||
|
|
||
| PartitionKeyRange range = new PartitionKeyRange(); | ||
| range.setId("0"); | ||
| range.setMinInclusive(PartitionKeyRange.MINIMUM_INCLUSIVE_EFFECTIVE_PARTITION_KEY); | ||
| range.setMaxExclusive(PartitionKeyRange.MAXIMUM_EXCLUSIVE_EFFECTIVE_PARTITION_KEY); | ||
|
|
||
| FeedResponse<PartitionKeyRange> response = Mockito.mock(FeedResponse.class); | ||
| when(response.getResults()).thenReturn(Arrays.asList(range)); | ||
| when(response.getContinuationToken()).thenReturn("etag-2"); | ||
|
|
||
| AtomicInteger clientACalls = new AtomicInteger(); | ||
| AtomicInteger clientBCalls = new AtomicInteger(); | ||
|
|
||
| when(collA.resolveCollectionAsync(any(), any())) | ||
| .thenReturn(Mono.just(new Utils.ValueHolder<>(collection))); | ||
| when(clientA.readPartitionKeyRanges(eq(collection.getSelfLink()), any(CosmosQueryRequestOptions.class))) | ||
| .thenAnswer(invocation -> { | ||
| clientACalls.incrementAndGet(); | ||
| return Flux.just(response); | ||
| }); | ||
| when(collB.resolveCollectionAsync(any(), any())) | ||
| .thenReturn(Mono.just(new Utils.ValueHolder<>(collection))); | ||
| when(clientB.readPartitionKeyRanges(eq(collection.getSelfLink()), any(CosmosQueryRequestOptions.class))) | ||
| .thenAnswer(invocation -> { | ||
| clientBCalls.incrementAndGet(); | ||
| return Flux.just(response); | ||
| }); | ||
|
|
||
| RxPartitionKeyRangeCache cacheA = new RxPartitionKeyRangeCache(clientA, collA, endpointA); | ||
| RxPartitionKeyRangeCache cacheB = new RxPartitionKeyRangeCache(clientB, collB, endpointB); | ||
|
|
||
| try { | ||
| StepVerifier.create(cacheA.tryLookupAsync(null, collectionRid, null, new HashMap<>())) | ||
| .expectNextMatches(v -> v != null && v.v != null) | ||
| .verifyComplete(); | ||
|
|
||
| StepVerifier.create(cacheB.tryLookupAsync(null, collectionRid, null, new HashMap<>())) | ||
| .expectNextMatches(v -> v != null && v.v != null) | ||
| .verifyComplete(); | ||
|
|
||
| // Two different endpoints → no sharing, each client issues its own read. | ||
| assertThat(clientACalls.get()).isEqualTo(1); | ||
| assertThat(clientBCalls.get()).isEqualTo(1); | ||
| } finally { | ||
| cacheA.close(); | ||
| cacheB.close(); | ||
| } | ||
| } | ||
|
|
||
| @Test(groups = "unit") | ||
| public void closeIsIdempotent() throws Exception { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Suggestion: Test Coverage — Cross-client single-flight invariant is asserted in the PR description but not proven by any test public void closeIsIdempotent() throws Exception {The CHANGELOG and PR description both call out a stronger guarantee — "only one in-flight Suggest adding a test that:
Without this, a future regression where |
||
| URI endpoint = new URI("https://test-shared-pkr-3.documents.azure.com:443/"); | ||
| RxDocumentClientImpl mockClient = Mockito.mock(RxDocumentClientImpl.class); | ||
| RxCollectionCache mockColl = Mockito.mock(RxCollectionCache.class); | ||
|
|
||
| RxPartitionKeyRangeCache c = new RxPartitionKeyRangeCache(mockClient, mockColl, endpoint); | ||
| assertThat(SharedRoutingMapCacheRegistry.getInstance().referenceCount(endpoint)) | ||
| .isEqualTo(1); | ||
|
|
||
| c.close(); | ||
| c.close(); // second call must be a no-op | ||
| c.close(); | ||
|
|
||
| assertThat(SharedRoutingMapCacheRegistry.getInstance().referenceCount(endpoint)) | ||
| .as("repeated close() must not drive refcount negative") | ||
| .isZero(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Recommendation: Test Coverage — Cross-client invalidation/refresh visibility is not tested
This test proves positive sharing (B sees A's populated value), but the inverse — when A invalidates or force-refreshes a routing map, B must observe the new value — is the most safety-critical property of the shared model and is currently uncovered. Concretely, the following scenarios are not exercised:
PartitionKeyRangeGoneException(split scenario) on A causingAsyncCacheNonBlocking.remove(rid); assert B's nexttryLookupAsynctriggers a fresh fetch and observes the new routing map.tryLookupAsync(previousValue=…)force-refreshes (sinceshouldForceRefresh(prev, current)returns true); assert B's subsequent lookup sees the refreshed value, not the stale one.404 NotFoundon a background refresh evicts the entry — assert that both clients then re-populate consistently rather than B silently keeping a stale reference to the removedAsyncLazyWithRefresh.These are the exact scenarios the .NET SDK exercises (see
EmulatorTests/PartitionKeyRangeCacheTests.cs::TestRidRefreshOnNotFoundAsyncandVerifyPkRangeCacheRefreshOnSplitWithErrorsAsyncfor the patterns). Without them, a regression where invalidation doesn't propagate across sharing clients would slip past CI.Additionally,
twoCachesForSameEndpointShareRoutingMapStoragecould be tightened by asserting object identity of the resolvedCollectionRoutingMap(B sees the exact same instance A populated), which is the strongest evidence that storage truly shares.