This article is contributed. See the original author and article here.

In a recent support case, our customer faced an intriguing issue where a query execution in a .NET application was unexpectedly canceled during asynchronous operations against Azure SQL Database. This experience highlighted the nuances of handling query cancellations, which could stem from either a CommandTimeout or a CancellationToken. Through this concise article, I aim to elucidate these two cancellation scenarios, alongside strategies for managing SQL errors, ensuring connection resilience through retries, and measuring query execution time. The accompanying code serves as a practical guide, demonstrating how to adjust timeouts dynamically in an attempt to successfully complete a query, should it face cancellation due to timeout constraints. This narrative not only shares a real-world scenario but also provides actionable insights for developers looking to fortify their .NET applications interacting with Azure SQL Database.


 


Introduction:


Understanding and managing query cancellations in asynchronous database operations are critical for maintaining the performance and reliability of .NET applications. This article stems from a real-world support scenario where a customer encountered unexpected query cancellations while interacting with Azure SQL Database. The issue brings to light the importance of distinguishing between cancellations caused by CommandTimeout and those triggered by CancellationToken, each requiring a distinct approach to error handling and application logic.


 


Cancellations: CommandTimeout vs. CancellationToken:


In asynchronous database operations, two primary types of cancellations can occur: one due to the command’s execution time exceeding the CommandTimeout limit, and the other due to a CancellationToken being invoked. Understanding the difference is crucial, as each scenario demands specific error handling strategies. A CommandTimeout cancellation typically indicates that the query is taking longer than expected, possibly due to database performance issues or query complexity. On the other hand, a cancellation triggered by a CancellationToken may be due to application logic deciding to abort the operation, often in response to user actions or to maintain application responsiveness.


 


Error Handling and Connection Resilience:


Errors during query execution, such as syntax errors or references to non-existent database objects, necessitate immediate attention and are not suitable for retry logic. The application must distinguish these errors from transient faults, where retry logic with exponential backoff can be beneficial. Moreover, connection resilience is paramount, and implementing a retry mechanism for establishing database connections ensures that transient network issues do not disrupt application functionality.


 


Measuring Query Execution Time:


Gauging the execution time of queries is instrumental in identifying performance bottlenecks and optimizing database interactions. The example code demonstrates using a Stopwatch to measure and log the duration of query execution, providing valuable insights for performance tuning.


 


Adaptive Timeout Strategy:


The code snippet illustrates an adaptive approach to handling query cancellations due to timeouts. By dynamically adjusting the CommandTimeout and CancellationToken timeout values upon encountering a timeout-related cancellation, the application attempts to afford the query additional time to complete in subsequent retries, where feasible.


 


Conclusion:


The intersection of CommandTimeout, CancellationToken, error handling, and connection resilience forms the crux of robust database interaction logic in .NET applications. This article, inspired by a real-world support case, sheds light on these critical aspects, offering a pragmatic code example that developers can adapt to enhance the reliability and performance of their applications when working with Azure SQL Database. The nuanced understanding and strategic handling of query cancellations, as discussed, are pivotal in crafting responsive and resilient .NET database applications.


 


Example C# code:


 

using System;
using System.Diagnostics;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;

namespace CancellationToken
{
    class Program
    {
        private static string ConnectionString = "Server=tcp:servername.database.windows.net,1433;User Id=MyUser;Password=MyPassword;Initial Catalog=MyDB;Persist Security Info=False;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;Pooling=true;Max Pool size=100;Min Pool Size=1;ConnectRetryCount=3;ConnectRetryInterval=10;Application Name=ConnTest";
        private static string Query = "waitfor delay '00:00:20'";

        static async Task Main(string[] args)
        {
            SqlConnection connection = await EstablishConnectionWithRetriesAsync(3, 2000);
            if (connection == null)
            {
                Console.WriteLine("Failed to establish a database connection.");
                return;
            }

            await ExecuteQueryWithRetriesAsync(connection, 5, 1000, 30000,15);

            connection.Close();
        }

        private static async Task EstablishConnectionWithRetriesAsync(int maxRetries, int initialDelay)
        {
            SqlConnection connection = null;
            int retryDelay = initialDelay;

            for (int attempt = 1; attempt <= maxRetries; attempt++)
            {
                try
                {
                    connection = new SqlConnection(ConnectionString);
                    await connection.OpenAsync();
                    Console.WriteLine("Connection established successfully.");
                    return connection;
                }
                catch (SqlException ex)
                {
                    Console.WriteLine($"Failed to establish connection: {ex.Message}. Attempt {attempt} of {maxRetries}.");
                    if (attempt == maxRetries)
                    {
                        Console.WriteLine("Maximum number of connection attempts reached. The application will terminate.");
                        return null;
                    }

                    Console.WriteLine($"Waiting {retryDelay / 1000} seconds before the next connection attempt...");
                    await Task.Delay(retryDelay);

                    retryDelay *= 2;
                }
            }

            return null;
        }

        private static async Task ExecuteQueryWithRetriesAsync(SqlConnection connection, int maxRetries, int initialDelay, int CancellationTokenTimeout, int CommandSQLTimeout)
        {
            int retryDelay = initialDelay;

            for (int attempt = 1; attempt <= maxRetries; attempt++)
            {
                using (var cts = new CancellationTokenSource())
                {
                    cts.CancelAfter(CancellationTokenTimeout*attempt); // Set CancellationToken timeout 

                    try
                    {
                        using (SqlCommand command = new SqlCommand(Query, connection))
                        {
                            command.CommandTimeout = CommandSQLTimeout*attempt; 

                            Stopwatch stopwatch = Stopwatch.StartNew();

                            await command.ExecuteNonQueryAsync(cts.Token);

                            stopwatch.Stop();
                            Console.WriteLine($"Query executed successfully in {stopwatch.ElapsedMilliseconds} milliseconds.");

                            return;
                        }
                    }
                    catch (TaskCanceledException)
                    {
                        Console.WriteLine($"Query execution was canceled by the CancellationToken. Attempt {attempt} of {maxRetries}.");
                    }
                    catch (SqlException ex) when (ex.Number == -2)
                    {
                        Console.WriteLine($"Query execution was canceled due to CommandTimeout. Attempt {attempt} of {maxRetries}.");
                    }
                    catch (SqlException ex) when (ex.Number == 207 || ex.Number == 208 || ex.Number == 2627)
                    {
                        Console.WriteLine($"SQL error preventing retries: {ex.Message}");
                        return;
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine($"An exception occurred: {ex.Message}");
                        return;
                    }

                    Console.WriteLine($"Waiting {retryDelay / 1000} seconds before the next query attempt...");
                    await Task.Delay(retryDelay);

                    retryDelay *= 2;
                }
            }
        }
    }
}

 


 


Tests and Results:


In the course of addressing the query cancellation issue, we conducted a series of tests to understand the behavior under different scenarios and the corresponding exceptions thrown by the .NET application. Here are the findings:


 




  1. Cancellation Prior to Query Execution:



    • Scenario: The cancellation occurs before the query gets a chance to execute, potentially due to reasons such as application overload or a preemptive cancellation policy.

    • Exception Thrown: TaskCanceledException

    • Internal Error Message: “A task was canceled.”

    • Explanation: This exception is thrown when the operation is canceled through a CancellationToken, indicating that the asynchronous task was canceled before it could begin executing the SQL command. It reflects the application’s decision to abort the operation, often to maintain responsiveness or manage workload.




  2. Cancellation Due to CommandTimeout:



    • Scenario: The cancellation is triggered by reaching the CommandTimeout of SqlCommand, indicating that the query’s execution duration exceeded the specified timeout limit.

    • Exception Thrown: SqlException with an error number of -2

    • Internal Error Message: “Execution Timeout Expired. The timeout period elapsed prior to completion of the operation or the server is not responding.”

    • Explanation: This exception occurs when the query execution time surpasses the CommandTimeout value, prompting SQL Server to halt the operation. It suggests that the query may be too complex, the server is under heavy load, or there are network latency issues.




  3. Cancellation Before CommandTimeout is Reached:



    • Scenario: The cancellation happens before the CommandTimeout duration is met, not due to the CommandTimeout setting but possibly due to an explicit cancellation request or an unforeseen severe error during execution.

    • Exception Thrown: General Exception (or a more specific exception depending on the context)

    • Internal Error Message: “A severe error occurred on the current command. The results, if any, should be discarded.rnOperation cancelled by user.”

    • Explanation: This exception indicates an abrupt termination of the command, potentially due to an external cancellation signal or a critical error that necessitates aborting the command. Unlike the TaskCanceledException, this may not always originate from a CancellationToken and can indicate more severe issues with the command or the connection.




 


 

Brought to you by Dr. Ware, Microsoft Office 365 Silver Partner, Charleston SC.