|
| 1 | +//------------------------------------------------------------ |
| 2 | +// Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | +//------------------------------------------------------------ |
| 4 | + |
| 5 | +namespace Microsoft.Azure.Cosmos.SDK.EmulatorTests |
| 6 | +{ |
| 7 | + using System; |
| 8 | + using System.Net; |
| 9 | + using System.Threading; |
| 10 | + using System.Threading.Tasks; |
| 11 | + using Microsoft.Azure.Cosmos.Routing; |
| 12 | + using Microsoft.Azure.Cosmos.Tracing; |
| 13 | + using Microsoft.Azure.Documents; |
| 14 | + using Microsoft.VisualStudio.TestTools.UnitTesting; |
| 15 | + |
| 16 | + /// <summary> |
| 17 | + /// Emulator tests that exercise <see cref="Routing.CollectionCache"/> behaviour |
| 18 | + /// directly, so that edge-case paths (e.g. a database RID leaking into |
| 19 | + /// <c>ResolvedCollectionRid</c>) can be verified end-to-end against the real |
| 20 | + /// Cosmos emulator. |
| 21 | + /// </summary> |
| 22 | + [TestClass] |
| 23 | + public class CollectionCacheEmulatorTests |
| 24 | + { |
| 25 | + private CosmosClient cosmosClient; |
| 26 | + private Cosmos.Database database; |
| 27 | + private Container container; |
| 28 | + private ContainerProperties containerProperties; |
| 29 | + |
| 30 | + [TestInitialize] |
| 31 | + public async Task TestInitialize() |
| 32 | + { |
| 33 | + this.cosmosClient = TestCommon.CreateCosmosClient(); |
| 34 | + this.database = await this.cosmosClient.CreateDatabaseAsync(Guid.NewGuid().ToString()); |
| 35 | + this.containerProperties = new ContainerProperties( |
| 36 | + id: Guid.NewGuid().ToString(), |
| 37 | + partitionKeyPath: "/pk"); |
| 38 | + ContainerResponse containerResponse = await this.database.CreateContainerAsync(this.containerProperties); |
| 39 | + this.container = containerResponse.Container; |
| 40 | + } |
| 41 | + |
| 42 | + [TestCleanup] |
| 43 | + public async Task TestCleanup() |
| 44 | + { |
| 45 | + if (this.database != null) |
| 46 | + { |
| 47 | + await this.database.DeleteStreamAsync(); |
| 48 | + } |
| 49 | + |
| 50 | + this.cosmosClient?.Dispose(); |
| 51 | + } |
| 52 | + |
| 53 | + /// <summary> |
| 54 | + /// Repro for the scenario where <c>ResolvedCollectionRid</c> is set to a |
| 55 | + /// database-level RID instead of a collection-level RID. |
| 56 | + /// |
| 57 | + /// SCENARIO |
| 58 | + /// -------- |
| 59 | + /// In some code paths (e.g. via <see cref="RenameCollectionAwareClientRetryPolicy"/> |
| 60 | + /// or a stale cache after container delete+recreate) the |
| 61 | + /// <c>DocumentServiceRequest.RequestContext.ResolvedCollectionRid</c> field can |
| 62 | + /// end up holding the database RID (4-byte form, e.g. "jy2ekg==") rather than |
| 63 | + /// a proper collection RID (8-byte form, e.g. "jy2eklxnboe="). When that |
| 64 | + /// happens, the subsequent RID-based cache look-up fails or routes to the |
| 65 | + /// wrong container. |
| 66 | + /// |
| 67 | + /// FIX |
| 68 | + /// --- |
| 69 | + /// <see cref="CollectionCache.ResolveCollectionAsync"/> now: |
| 70 | + /// 1. Logs a <c>TraceWarning</c> when it detects that the pre-existing |
| 71 | + /// <c>ResolvedCollectionRid</c> is not a collection RID. |
| 72 | + /// 2. Falls back to name-based resolution in that case. |
| 73 | + /// 3. Throws <see cref="InvalidOperationException"/> at the assignment site |
| 74 | + /// if the resolved ResourceId is still not a collection RID (guards against |
| 75 | + /// a corrupt response from the server). |
| 76 | + /// |
| 77 | + /// TEST |
| 78 | + /// ---- |
| 79 | + /// This test injects the real database RID into |
| 80 | + /// <c>request.RequestContext.ResolvedCollectionRid</c> to simulate the bug, |
| 81 | + /// then verifies that <see cref="CollectionCache.ResolveCollectionAsync"/> |
| 82 | + /// still returns the correct <see cref="ContainerProperties"/> (with the |
| 83 | + /// proper collection RID) via the name-based fallback. |
| 84 | + /// </summary> |
| 85 | + [TestMethod] |
| 86 | + public async Task ResolveCollectionAsync_WithDatabaseRidInResolvedCollectionRid_FallsBackToNameResolutionReproTest() |
| 87 | + { |
| 88 | + // Arrange: get the real database RID from the emulator. |
| 89 | + DatabaseResponse databaseResponse = await this.database.ReadAsync(); |
| 90 | + string databaseRid = databaseResponse.Resource.ResourceId; |
| 91 | + Assert.IsNotNull(databaseRid, "Database ResourceId must not be null"); |
| 92 | + |
| 93 | + // Sanity-check: the database RID must NOT be a collection RID. |
| 94 | + // (A database RID is 4 bytes base64-encoded; a collection RID is 8 bytes.) |
| 95 | + Assert.IsFalse( |
| 96 | + IsCollectionRid(databaseRid), |
| 97 | + $"Expected '{databaseRid}' to be a database RID, not a collection RID."); |
| 98 | + |
| 99 | + // Get the container's actual collection RID so we can assert against it. |
| 100 | + ContainerResponse containerResponse = await this.container.ReadContainerAsync(); |
| 101 | + string expectedCollectionRid = containerResponse.Resource.ResourceId; |
| 102 | + Assert.IsNotNull(expectedCollectionRid, "Container ResourceId must not be null"); |
| 103 | + Assert.IsTrue( |
| 104 | + IsCollectionRid(expectedCollectionRid), |
| 105 | + $"Expected '{expectedCollectionRid}' to be a valid collection RID."); |
| 106 | + |
| 107 | + // Build a name-based DocumentServiceRequest that simulates a read on a |
| 108 | + // document inside the container so the request goes through the |
| 109 | + // IsNameBased path of CollectionCache.ResolveCollectionAsync. |
| 110 | + string documentPath = $"dbs/{this.database.Id}/colls/{this.container.Id}/docs/someDoc"; |
| 111 | + using DocumentServiceRequest request = DocumentServiceRequest.CreateFromName( |
| 112 | + OperationType.Read, |
| 113 | + documentPath, |
| 114 | + ResourceType.Document, |
| 115 | + AuthorizationTokenType.PrimaryMasterKey, |
| 116 | + null); |
| 117 | + |
| 118 | + // Simulate the bug: set ResolvedCollectionRid to the database RID. |
| 119 | + request.RequestContext.ResolvedCollectionRid = databaseRid; |
| 120 | + |
| 121 | + // Act: call the collection cache directly. |
| 122 | + ClientCollectionCache collectionCache = |
| 123 | + await this.cosmosClient.DocumentClient.GetCollectionCacheAsync(NoOpTrace.Singleton); |
| 124 | + |
| 125 | + ContainerProperties resolved = await collectionCache.ResolveCollectionAsync( |
| 126 | + request, |
| 127 | + CancellationToken.None, |
| 128 | + NoOpTrace.Singleton); |
| 129 | + |
| 130 | + // Assert: the cache must have fallen back to name-based resolution and |
| 131 | + // returned the correct collection RID — not the database RID. |
| 132 | + Assert.IsNotNull(resolved, "ResolveCollectionAsync must return a non-null ContainerProperties"); |
| 133 | + Assert.AreEqual( |
| 134 | + expectedCollectionRid, |
| 135 | + resolved.ResourceId, |
| 136 | + $"Expected collection RID '{expectedCollectionRid}' but got '{resolved.ResourceId}'. " + |
| 137 | + "The cache should have fallen back to name-based resolution and returned the correct collection RID."); |
| 138 | + |
| 139 | + Assert.IsTrue( |
| 140 | + IsCollectionRid(resolved.ResourceId), |
| 141 | + $"Resolved ResourceId '{resolved.ResourceId}' must be a collection RID, not a database RID."); |
| 142 | + |
| 143 | + Assert.AreNotEqual( |
| 144 | + databaseRid, |
| 145 | + resolved.ResourceId, |
| 146 | + "Resolved ResourceId must not be the database RID that was injected."); |
| 147 | + } |
| 148 | + |
| 149 | + /// <summary> |
| 150 | + /// Verifies that end-to-end item operations (read/write) still succeed on a |
| 151 | + /// container whose collection cache was primed with a database RID. This |
| 152 | + /// tests the full SDK retry stack rather than just the cache layer. |
| 153 | + /// </summary> |
| 154 | + [TestMethod] |
| 155 | + public async Task ItemRead_AfterDatabaseRidInjectedIntoCollectionCache_Succeeds() |
| 156 | + { |
| 157 | + // Create a test item so we have something to read back. |
| 158 | + string pk = Guid.NewGuid().ToString("N"); |
| 159 | + string id = Guid.NewGuid().ToString("N"); |
| 160 | + var testItem = new { id, pk }; |
| 161 | + await this.container.CreateItemAsync(testItem, new Cosmos.PartitionKey(pk)); |
| 162 | + |
| 163 | + // Get the database RID from the emulator. |
| 164 | + DatabaseResponse databaseResponse = await this.database.ReadAsync(); |
| 165 | + string databaseRid = databaseResponse.Resource.ResourceId; |
| 166 | + |
| 167 | + // Warm up the collection cache so that subsequent reads use the cache |
| 168 | + // hit path; this ensures the cache is initialised before we corrupt it. |
| 169 | + ContainerResponse containerResponse = await this.container.ReadContainerAsync(); |
| 170 | + string expectedCollectionRid = containerResponse.Resource.ResourceId; |
| 171 | + |
| 172 | + // Build the request and inject the database RID as ResolvedCollectionRid. |
| 173 | + string documentPath = $"dbs/{this.database.Id}/colls/{this.container.Id}/docs/{id}"; |
| 174 | + using DocumentServiceRequest request = DocumentServiceRequest.CreateFromName( |
| 175 | + OperationType.Read, |
| 176 | + documentPath, |
| 177 | + ResourceType.Document, |
| 178 | + AuthorizationTokenType.PrimaryMasterKey, |
| 179 | + null); |
| 180 | + |
| 181 | + request.RequestContext.ResolvedCollectionRid = databaseRid; |
| 182 | + |
| 183 | + ClientCollectionCache collectionCache = |
| 184 | + await this.cosmosClient.DocumentClient.GetCollectionCacheAsync(NoOpTrace.Singleton); |
| 185 | + |
| 186 | + // The cache must resolve to the correct collection RID via name-based fallback. |
| 187 | + ContainerProperties resolved = await collectionCache.ResolveCollectionAsync( |
| 188 | + request, |
| 189 | + CancellationToken.None, |
| 190 | + NoOpTrace.Singleton); |
| 191 | + |
| 192 | + Assert.AreEqual(expectedCollectionRid, resolved.ResourceId); |
| 193 | + |
| 194 | + // The real item read through the full SDK stack must also succeed. |
| 195 | + ItemResponse<dynamic> itemResponse = await this.container.ReadItemAsync<dynamic>( |
| 196 | + id, |
| 197 | + new Cosmos.PartitionKey(pk)); |
| 198 | + |
| 199 | + Assert.AreEqual(HttpStatusCode.OK, itemResponse.StatusCode); |
| 200 | + } |
| 201 | + |
| 202 | + /// <summary> |
| 203 | + /// Mirrors the static <c>CollectionCache.IsCollectionRid</c> helper so tests |
| 204 | + /// can assert on whether a given ResourceId is a collection RID without |
| 205 | + /// depending on the private SDK method. |
| 206 | + /// </summary> |
| 207 | + private static bool IsCollectionRid(string resourceId) |
| 208 | + { |
| 209 | + if (string.IsNullOrWhiteSpace(resourceId) || |
| 210 | + !ResourceId.TryParse(resourceId, out ResourceId resourceIdParsed)) |
| 211 | + { |
| 212 | + return false; |
| 213 | + } |
| 214 | + |
| 215 | + string collectionRid = resourceIdParsed.DocumentCollectionId.ToString(); |
| 216 | + return StringComparer.Ordinal.Equals(collectionRid, resourceId); |
| 217 | + } |
| 218 | + } |
| 219 | +} |
0 commit comments