Search code examples
c#asynchronoussqldatareadercancellationtokensourcecancellation-token

Creating an async resource watcher in c# (service broker queue resource)


Partly as an exercise in exploring async, I though I'd try creating a ServiceBrokerWatcher class. The idea is much the same as a FileSystemWatcher - watch a resource and raise an event when something happens. I was hoping to do this with async rather than actually creating a thread, because the nature of the beast means that most of the time it is just waiting on a SQL waitfor (receive ...) statement. This seemed like an ideal use of async.

I have written code which "works", in that when I send a message through broker, the class notices it and fires off the appropriate event. I thought this was super neat.

But I suspect I have gotten something fundamentally wrong somewhere in my understanding of what is going on, because when I try to stop the watcher it doesn't behave as I expect.

First a brief overview of the components, and then the actual code:

I have a stored procedure which issues a waitfor (receive...) and returns a result set to the client when a message is received.

There is a Dictionary<string, EventHandler> which maps message type names (in the result set) to the appropriate event handler. For simplicity I only have the one message type in the example.

The watcher class has an async method which loops "forever" (until cancellation is requested), which contains the execution of the procedure and the raising of the events.

So, what's the problem? Well, I tried hosting my class in a simple winforms application, and when I hit a button to call the StopListening() method (see below), execution isn't cancelled right away as I thought it would be. The line listener?.Wait(10000) will in fact wait for 10 seconds (or however long I set the timeout). If I watch what happens with SQL profiler I can see that the attention event is being sent "straight away", but still the function does not exit.

I have added comments to the code starting with "!" where I suspect I have misunderstood something.

So, main question: Why isn't my ListenAsync method "honoring" my cancellation request?

Additionally, am I right in thinking that this program is (most of the time) consuming only one thread? Have I done anything dangerous?

Code follows, I tried to cut it down as much as I could:

// class members //////////////////////
private readonly SqlConnection sqlConnection;
private CancellationTokenSource cts;
private readonly CancellationToken ct;
private Task listener;
private readonly Dictionary<string, EventHandler> map;

public void StartListening()
{
    if (listener == null)
    {
        cts = new CancellationTokenSource();
        ct = cts.Token;
        // !I suspect assigning the result of the method to a Task is wrong somehow...
        listener = ListenAsync(ct); 
    }
}

public void StopListening()
{
    try
    {
        cts.Cancel(); 
        listener?.Wait(10000); // !waits the whole 10 seconds for some reason
    } catch (Exception) { 
        // trap the exception sql will raise when execution is cancelled
    } finally
    {
        listener = null;
    }
}

private async Task ListenAsync(CancellationToken ct)
{
    using (SqlCommand cmd = new SqlCommand("events.dequeue_target", sqlConnection))
    using (CancellationTokenRegistration ctr = ct.Register(cmd.Cancel)) // !necessary?
    {
        cmd.CommandTimeout = 0;
        while (!ct.IsCancellationRequested)
        {
            var events = new List<string>();    
            using (var rdr = await cmd.ExecuteReaderAsync(ct))
            {
                while (rdr.Read())
                {
                    events.Add(rdr.GetString(rdr.GetOrdinal("message_type_name")));
                }
            }
            foreach (var handler in events.Join(map, e => e, m => m.Key, (e, m) => m.Value))
            {
                if (handler != null && !ct.IsCancellationRequested)
                {
                    handler(this, null);
                }
            }
        }
    }
}

Solution

  • You don't show how you've bound it to the WinForms app, but if you are using regular void button1click methods, you may be running into this issue.

    So your code will run fine in a console app (it does when I try it) but deadlock when called via the UI thread.

    I'd suggest changing your controller class to expose async start and stop methods, and call them via e.g.:

        private async void btStart_Click(object sender, EventArgs e)
        {
            await controller.StartListeningAsync();
        }
    
        private async void btStop_Click(object sender, EventArgs e)
        {
            await controller.StopListeningAsync();
        }