Skip to content

Commit 40afd09

Browse files
committed
Handle stale cache in CosmosClient
Add a retry for unintentional distributed transactions
1 parent d17d1d8 commit 40afd09

2 files changed

Lines changed: 141 additions & 20 deletions

File tree

test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,10 @@ public override Task CleanAsync(DbContext context, bool createTables = true)
317317
private async Task CleanAsyncImpl(DbContext context, bool createTables)
318318
{
319319
var created = await EnsureCreatedAsync(context).ConfigureAwait(false);
320+
321+
// Containers are deleted and recreated below only when the database already existed. A freshly-created
322+
// database has brand-new containers whose metadata cannot be stale.
323+
var containersRecreated = !created;
320324
try
321325
{
322326
if (!created)
@@ -334,12 +338,23 @@ private async Task CleanAsyncImpl(DbContext context, bool createTables)
334338
created = await context.Database.EnsureCreatedAsync().ConfigureAwait(false);
335339
if (!created)
336340
{
341+
if (containersRecreated)
342+
{
343+
await RefreshContainerMetadataAsync(context).ConfigureAwait(false);
344+
}
345+
337346
await SeedAsync(context).ConfigureAwait(false);
338347
}
339348
}
340349
else
341350
{
342351
await CreateContainersAsync(context).ConfigureAwait(false);
352+
353+
if (containersRecreated)
354+
{
355+
await RefreshContainerMetadataAsync(context).ConfigureAwait(false);
356+
}
357+
343358
await SeedAsync(context).ConfigureAwait(false);
344359
}
345360
}
@@ -357,6 +372,68 @@ private async Task CleanAsyncImpl(DbContext context, bool createTables)
357372
}
358373
}
359374

375+
// Deleting and recreating a container gives it a new resource id (_rid), but the shared CosmosClient caches each
376+
// container's _rid (and the partition key ranges keyed by it) by container name. The first operation on the
377+
// recreated container - whether it is the reseed below or the next test's first query - can then fail with
378+
// "NotFound (404) ... GetTargetPartitionKeyRanges ... failed due to stale cache", and the query pipeline does not
379+
// transparently refresh and retry. Prime the client's caches here, right after recreating the containers and before
380+
// any test touches them. Each attempt re-reads the container by name (refreshing the collection cache with the new
381+
// _rid) and then runs a query - the exact path that otherwise fails. Because the recreate can take a moment to
382+
// become consistent on the emulator gateway, retry with a short delay until a query succeeds. This is the single
383+
// place that handles the stale-metadata problem for the emulator; it is fully best-effort and must never fail the
384+
// clean, so all errors are ultimately swallowed.
385+
private async Task RefreshContainerMetadataAsync(DbContext context)
386+
{
387+
if (CosmosTestEnvironment.UseTokenCredential)
388+
{
389+
return;
390+
}
391+
392+
const int maxAttempts = 10;
393+
394+
try
395+
{
396+
var cosmosClient = context.Database.GetCosmosClient();
397+
var database = cosmosClient.GetDatabase(Name);
398+
var model = context.GetService<IDesignTimeModel>().Model;
399+
var countQuery = new QueryDefinition("SELECT VALUE COUNT(1) FROM c");
400+
401+
foreach (var containerProperties in GetContainersToCreate(model))
402+
{
403+
var container = database.GetContainer(containerProperties.Id);
404+
405+
for (var attempt = 1; attempt <= maxAttempts; attempt++)
406+
{
407+
try
408+
{
409+
await container.ReadContainerAsync().ConfigureAwait(false);
410+
411+
using var iterator = container.GetItemQueryIterator<int>(countQuery);
412+
while (iterator.HasMoreResults)
413+
{
414+
await iterator.ReadNextAsync().ConfigureAwait(false);
415+
}
416+
417+
break;
418+
}
419+
catch when (attempt < maxAttempts)
420+
{
421+
// The recreate has not become consistent yet; wait for the gateway to catch up and retry.
422+
await Task.Delay(200).ConfigureAwait(false);
423+
}
424+
catch
425+
{
426+
// Best-effort priming; never let it fail the clean.
427+
}
428+
}
429+
}
430+
}
431+
catch
432+
{
433+
// Never let cache priming fail the clean.
434+
}
435+
}
436+
360437
private async Task CreateContainersAsync(DbContext context)
361438
{
362439
var databaseAccount = await GetDBAccount().ConfigureAwait(false);

test/EFCore.Relational.Specification.Tests/TransactionTestBase.cs

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -336,41 +336,85 @@ public virtual async Task SaveChanges_uses_ambient_transaction_with_connectionSt
336336
return;
337337
}
338338

339-
DbConnection connection;
340-
using (var context = CreateContextWithConnectionString())
339+
DbConnection connection = null;
340+
341+
await RetryOnDistributedTransactionNotSupportedAsync(async () =>
341342
{
342-
using (TestUtilities.TestStore.CreateTransactionScope())
343+
using (var context = CreateContextWithConnectionString())
343344
{
344-
context.Database.AutoTransactionBehavior = autoTransactionBehavior;
345+
using (TestUtilities.TestStore.CreateTransactionScope())
346+
{
347+
context.Database.AutoTransactionBehavior = autoTransactionBehavior;
345348

346-
connection = context.Database.GetDbConnection();
347-
Assert.Equal(ConnectionState.Closed, connection.State);
349+
connection = context.Database.GetDbConnection();
350+
Assert.Equal(ConnectionState.Closed, connection.State);
348351

349-
await context.AddAsync(
350-
new TransactionCustomer { Id = 77, Name = "Bobble" });
352+
await context.AddAsync(
353+
new TransactionCustomer { Id = 77, Name = "Bobble" });
351354

352-
context.Entry(context.Set<TransactionCustomer>().OrderBy(c => c.Id).Last()).State = EntityState.Added;
355+
context.Entry(context.Set<TransactionCustomer>().OrderBy(c => c.Id).Last()).State = EntityState.Added;
353356

354-
if (async)
355-
{
356-
await Assert.ThrowsAsync<DbUpdateException>(() => context.SaveChangesAsync());
357-
}
358-
else
359-
{
360-
Assert.Throws<DbUpdateException>(() => context.SaveChanges());
361-
}
357+
// SaveChanges is expected to fail with DbUpdateException (duplicate key).
358+
try
359+
{
360+
if (async)
361+
{
362+
await context.SaveChangesAsync();
363+
}
364+
else
365+
{
366+
context.SaveChanges();
367+
}
368+
369+
Assert.Fail("Expected a DbUpdateException to be thrown.");
370+
}
371+
catch (PlatformNotSupportedException)
372+
{
373+
// When the ambient transaction is promoted to a distributed one and throw PlatformNotSupportedException let it
374+
// propagate so the whole operation is retried.
375+
throw;
376+
}
377+
catch (Exception exception)
378+
{
379+
Assert.IsType<DbUpdateException>(exception);
380+
}
362381

363-
Assert.Equal(ConnectionState.Closed, connection.State);
382+
Assert.Equal(ConnectionState.Closed, connection.State);
364383

365-
context.Database.AutoTransactionBehavior = AutoTransactionBehavior.WhenNeeded;
384+
context.Database.AutoTransactionBehavior = AutoTransactionBehavior.WhenNeeded;
385+
}
366386
}
367-
}
387+
});
368388

369389
Assert.Equal(ConnectionState.Closed, connection.State);
370390

371391
AssertStoreInitialState();
372392
}
373393

394+
// Sometimes an ambient/enlisted transaction ends up spanning more than one physical connection due to concurrency issues
395+
// and is promoted to a distributed transaction, which throws PlatformNotSupportedException.
396+
// Retry the operation a few times (with exponential back-off) so a run that keeps to a single connection (and
397+
// therefore reaches the behavior under test) is used.
398+
private static async Task RetryOnDistributedTransactionNotSupportedAsync(Func<Task> test)
399+
{
400+
const int maxAttempts = 3;
401+
var delay = TimeSpan.FromSeconds(1);
402+
403+
for (var attempt = 1; ; attempt++)
404+
{
405+
try
406+
{
407+
await test();
408+
return;
409+
}
410+
catch (PlatformNotSupportedException) when (attempt < maxAttempts)
411+
{
412+
await Task.Delay(delay);
413+
delay *= 2;
414+
}
415+
}
416+
}
417+
374418
[Theory, InlineData(true), InlineData(false)]
375419
public virtual void SaveChanges_throws_for_suppressed_ambient_transactions(bool connectionString)
376420
{

0 commit comments

Comments
 (0)