Skip to content

AsyncCache: Adds support for stack trace optimization during exceptions for AsyncCache and AsyncCacheNonblocking #5069

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

Merged
merged 10 commits into from
Mar 26, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Microsoft.Azure.Cosmos/src/ExceptionHandlingUtility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace Microsoft.Azure.Cosmos
{
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Azure.Documents;

/// <summary>
/// Utility for post-processing of exceptions.
/// </summary>
internal static class ExceptionHandlingUtility
{
/// <summary>
/// Tries to create a shallow copy of specific exception types (e.g., TaskCanceledException, TimeoutException, OperationCanceledException)
/// to prevent excessive stack trace growth. Returns true if the exception was cloned, otherwise false.
/// </summary>
public static bool TryCloneException(Exception e, out Exception clonedException)
{
clonedException = e switch
{
ICloneable cloneableEx => (Exception)cloneableEx.Clone(),
OperationCanceledException operationCanceledException => AddMessageData((Exception)Activator.CreateInstance(operationCanceledException.GetType(), operationCanceledException.Message, operationCanceledException), e), //Handles all OperationCanceledException types
TimeoutException timeoutEx => AddMessageData(new TimeoutException(timeoutEx.Message, timeoutEx), e),
_ => null
};

return clonedException is not null;
}

private static Exception AddMessageData(Exception target, Exception source)
{
if (source.Data.Contains("Message"))
{
target.Data["Message"] = source.Data["Message"];
}

return target;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace Microsoft.Azure.Cosmos
/// <summary>
/// The Cosmos Client exception
/// </summary>
public class CosmosException : Exception
public class CosmosException : Exception, ICloneable
{
private readonly string stackTrace;
private readonly Lazy<string> lazyMessage;
Expand Down Expand Up @@ -196,7 +196,7 @@ internal ResponseMessage ToCosmosResponseMessage(RequestMessage request)
trace: this.Trace);

return responseMessage;
}
}

private static string GetMessageHelper(
HttpStatusCode statusCode,
Expand Down Expand Up @@ -301,6 +301,17 @@ internal static void RecordOtelAttributes(CosmosException exception, DiagnosticS
{
CosmosDbEventSource.RecordDiagnosticsForExceptions(exception.Diagnostics);
}
}
}

/// <summary>
/// Creates a shallow copy of the current exception instance.
/// This ensures that the cloned exception retains the same properties but does not
/// excessively proliferate stack traces or deep-copy unnecessary objects.
/// </summary>
/// <returns>A shallow copy of the current <see cref="CosmosException"/>.</returns>
public object Clone()
{
return this.MemberwiseClone();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ namespace Microsoft.Azure.Cosmos
/// diagnostics of the operation that was canceled.
/// </summary>
[Serializable]
public class CosmosOperationCanceledException : OperationCanceledException
public class CosmosOperationCanceledException : OperationCanceledException, ICloneable
{
private readonly OperationCanceledException originalException;
private readonly Lazy<string> lazyMessage;
Expand Down Expand Up @@ -155,6 +155,17 @@ public override void GetObjectData(SerializationInfo info, StreamingContext cont
info.AddValue("tokenCancellationRequested", this.tokenCancellationRequested);
info.AddValue("lazyMessage", this.lazyMessage.Value);
info.AddValue("toStringMessage", this.toStringMessage.Value);
}
}

/// <summary>
/// Creates a shallow copy of the current exception instance.
/// This ensures that the cloned exception retains the same properties but does not
/// excessively proliferate stack traces or deep-copy unnecessary objects.
/// </summary>
/// <returns>A shallow copy of the current <see cref="CosmosOperationCanceledException"/>.</returns>
public object Clone()
{
return this.MemberwiseClone();
}
}
}
31 changes: 28 additions & 3 deletions Microsoft.Azure.Cosmos/src/Routing/AsyncCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ internal sealed class AsyncCache<TKey, TValue>
private readonly bool enableAsyncCacheExceptionNoSharing;
private readonly IEqualityComparer<TValue> valueEqualityComparer;
private readonly IEqualityComparer<TKey> keyEqualityComparer;

private ConcurrentDictionary<TKey, AsyncLazy<TValue>> values;

public AsyncCache(
Expand Down Expand Up @@ -154,12 +153,38 @@ public async Task<TValue> GetAsync(
{
return await generator;
}
catch (Exception) when (object.ReferenceEquals(actualValue, newLazyValue))
catch (Exception ex) when (object.ReferenceEquals(actualValue, newLazyValue))
{
// If the lambda this thread added to values triggered an exception remove it from the cache.
this.TryRemoveValue(key, actualValue);

if (this.enableAsyncCacheExceptionNoSharing)
{
// Creates a shallow copy of specific exception types to prevent stack trace proliferation
// and rethrows them, doesn't process other exceptions.
if (ExceptionHandlingUtility.TryCloneException(ex, out Exception clone))
{
throw clone;
}
}

throw;
}
}
catch (Exception ex)
{
if (this.enableAsyncCacheExceptionNoSharing)
{
// Creates a shallow copy of specific exception types to prevent stack trace proliferation
// and rethrows them, doesn't process other exceptions.
if (ExceptionHandlingUtility.TryCloneException(ex, out Exception clone))
{
throw clone;
}
}

throw;
}

}

public void Remove(TKey key)
Expand Down
31 changes: 25 additions & 6 deletions Microsoft.Azure.Cosmos/src/Routing/AsyncCacheNonBlocking.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ internal sealed class AsyncCacheNonBlocking<TKey, TValue> : IDisposable
private readonly Func<Exception, bool> removeFromCacheOnBackgroundRefreshException;

private readonly IEqualityComparer<TKey> keyEqualityComparer;
private bool isDisposed;

private bool isDisposed;
public AsyncCacheNonBlocking(
Func<Exception, bool> removeFromCacheOnBackgroundRefreshException = null,
IEqualityComparer<TKey> keyEqualityComparer = null,
Expand Down Expand Up @@ -119,8 +119,17 @@ public async Task<TValue> GetAsync(
key,
removed,
e);
}

}

if (this.enableAsyncCacheExceptionNoSharing)
{
// Creates a shallow copy of specific exception types to prevent stack trace proliferation
// and rethrows them, doesn't process other exceptions.
if (ExceptionHandlingUtility.TryCloneException(e, out Exception clone))
{
throw clone;
}
}
throw;
}

Expand Down Expand Up @@ -160,10 +169,20 @@ public async Task<TValue> GetAsync(
e.ToString());

// Remove the failed task from the dictionary so future requests can send other calls..
this.values.TryRemove(key, out _);
this.values.TryRemove(key, out _);

if (this.enableAsyncCacheExceptionNoSharing)
{
// Creates a shallow copy of specific exception types to prevent stack trace proliferation
// and rethrows them, doesn't process other exceptions.
if (ExceptionHandlingUtility.TryCloneException(e, out Exception clone))
{
throw clone;
}
}
throw;
}
}
}

public void Set(
TKey key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,75 @@ public async Task MultiRegionAccountTest()
Assert.IsNotNull(cosmosClient);
AccountProperties properties = await cosmosClient.ReadAccountAsync();
Assert.IsNotNull(properties);
}

[DataTestMethod]
[DataRow(true)]
[DataRow(false)]
[Owner("amudumba")]
public async Task ValidateAsyncExceptionNoSharing(bool asyncCacheExceptionNoSharing)
{
TimeoutException exception = new TimeoutException("HTTP Timeout exception", new TimeoutException("Inner exception message"));
TaskCompletionSource<object> blockSendingHandlers = new(TaskCreationOptions.RunContinuationsAsynchronously);

CosmosClientOptions cosmosClientOptions = new CosmosClientOptions()
{
ConsistencyLevel = Cosmos.ConsistencyLevel.Session,
SendingRequestEventArgs = (sender, e) =>
{
if (e.IsHttpRequest())
{
string endWith = "partitionKeyRangeIds=0";
if (e.HttpRequest.Method == HttpMethod.Get &&
e.HttpRequest.RequestUri.OriginalString.EndsWith(endWith))
{
blockSendingHandlers.Task.Wait(); // block here until all enter
throw exception;
}
}
},
EnableAsyncCacheExceptionNoSharing = asyncCacheExceptionNoSharing
};

Cosmos.Database db = null;
try
{
CosmosClient cosmosClient = TestCommon.CreateCosmosClient(clientOptions: cosmosClientOptions);

db = await cosmosClient.CreateDatabaseIfNotExistsAsync("TimeoutFaultTest");
Container container = await db.CreateContainerIfNotExistsAsync("TimeoutFaultContainer", "/pk");

int iterations = 3;
List<Task> createTasks = new();

for (int i = 0; i < iterations; i++)
{
ToDoActivity testItem = ToDoActivity.CreateRandomToDoActivity();
createTasks.Add(container.CreateItemAsync(testItem)
.ContinueWith(t => {
Assert.IsTrue(t.IsFaulted);
if (asyncCacheExceptionNoSharing)
{
//asyncCacheExceptionNoSharing feature is enabled. Shallow copies of the exception will be thrown.
Assert.IsFalse(Object.ReferenceEquals(t.Exception, exception), "Exception should not be the same");
}
else
{
//asyncCacheExceptionNoSharing feature is disabled. Exceptions will be thrown as is.
Assert.IsTrue(Object.ReferenceEquals(t.Exception.InnerException, exception), "Exception should be the same");
}
}));
}

blockSendingHandlers.SetResult(null);

// Wait for all tasks to complete (they should all fail)
await Task.WhenAll(createTasks);
}
finally
{
if (db != null) await db.DeleteAsync();
}
}

[TestMethod]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ namespace Microsoft.Azure.Cosmos.Tests
using System.Threading.Tasks;
using Microsoft.Azure.Cosmos.Common;
using Microsoft.Azure.Documents;
using Microsoft.VisualStudio.TestTools.UnitTesting;

using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class AsyncCacheTest
{
Expand Down Expand Up @@ -306,6 +305,45 @@ await Task.Factory.StartNew(() =>
TaskCreationOptions.None,
new SingleTaskScheduler()
);
}

[DataTestMethod]
[DataRow(true)]
[DataRow(false)]
public async Task GetAsync_Assert_OnExceptionPostProcessing(bool enabled)
{
AsyncCache<string, string> cacheDefault = new AsyncCache<string, string>(enableAsyncCacheExceptionNoSharing: enabled);

// Arrange
Exception testException = new TimeoutException("Simulated timeout exception");
CancellationToken cancellationToken = CancellationToken.None;

// Simulate a failing lambda function
Func<Task<string>> failingLambda = () => Task.FromException<string>(testException);

// Act
try
{
await cacheDefault.GetAsync(
"testKey",
obsoleteValue: null,
singleValueInitFunc: failingLambda,
cancellationToken,
forceRefresh: false);
}
catch (TimeoutException ex)
{
if (enabled)
{
// Assert that the expected exception was rethrown
Assert.IsFalse(Object.ReferenceEquals(testException, ex));
} else
{
// Assert that no cloning and rethrowing was done on the exceptions,
Assert.IsTrue(Object.ReferenceEquals(testException, ex));
}

}
}

private int GenerateIntFuncThatThrows()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3537,6 +3537,11 @@
"Attributes": [],
"MethodInfo": "System.Nullable`1[System.TimeSpan] RetryAfter;CanRead:True;CanWrite:False;System.Nullable`1[System.TimeSpan] get_RetryAfter();IsAbstract:False;IsStatic:False;IsVirtual:True;IsGenericMethod:False;IsConstructor:False;IsFinal:False;"
},
"System.Object Clone()": {
"Type": "Method",
"Attributes": [],
"MethodInfo": "System.Object Clone();IsAbstract:False;IsStatic:False;IsVirtual:True;IsGenericMethod:False;IsConstructor:False;IsFinal:True;"
},
"System.String ActivityId": {
"Type": "Property",
"Attributes": [],
Expand Down Expand Up @@ -3663,6 +3668,11 @@
"Attributes": [],
"MethodInfo": "System.Exception GetBaseException();IsAbstract:False;IsStatic:False;IsVirtual:True;IsGenericMethod:False;IsConstructor:False;IsFinal:False;"
},
"System.Object Clone()": {
"Type": "Method",
"Attributes": [],
"MethodInfo": "System.Object Clone();IsAbstract:False;IsStatic:False;IsVirtual:True;IsGenericMethod:False;IsConstructor:False;IsFinal:True;"
},
"System.String get_HelpLink()": {
"Type": "Method",
"Attributes": [],
Expand Down
Loading
Loading