2

I'm using EF Core, in an ASP.NET Core environment. My context is registered in my DI container as per-request.

I need to perform extra work before the context's SaveChanges() or SaveChangesAsync(), such as validation, auditing, dispatching notifications, etc. Some of that work is sync, and some is async.

So I want to raise a sync or async event to allow listeners do extra work, block until they are done (!), and then call the DbContext base class to actually save.

public class MyContext : DbContext
{

  // sync: ------------------------------

  // define sync event handler
  public event EventHandler<EventArgs> SavingChanges;

  // sync save
  public override int SaveChanges(bool acceptAllChangesOnSuccess)
  {
    // raise event for sync handlers to do work BEFORE the save
    var handler = SavingChanges;
    if (handler != null)
      handler(this, EventArgs.Empty);
    // all work done, now save
    return base.SaveChanges(acceptAllChangesOnSuccess);
  }

  // async: ------------------------------

  // define async event handler
  //public event /* ??? */ SavingChangesAsync;

  // async save
  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    // raise event for async handlers to do work BEFORE the save (block until they are done!)
    //await ???
    // all work done, now save
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess,  cancellationToken);
  }

}

As you can see, it's easy for SaveChanges(), but how do I do it for SaveChangesAsync()?

grokky
  • 8,537
  • 20
  • 62
  • 96

4 Answers4

2

So I want to raise a sync or async event to allow listeners do extra work, block until they are done (!), and then call the DbContext base class to actually save.

As you can see, it's easy for SaveChanges()

Not really... SaveChanges won't wait for any asynchronous handlers to complete. In general, blocking on async work isn't recommended; even in environments such as ASP.NET Core where you won't deadlock, it does impact your scalability. Since your MyContext allows asynchronous handlers, you'd probably want to override SaveChanges to just throw an exception. Or, you could choose to just block, and hope that users won't use asynchronous handlers with synchronous SaveChanges too much.

Regarding the implementation itself, there are a few approaches that I describe in my blog post on async events. My personal favorite is the deferral approach, which looks like this (using my Nito.AsyncEx.Oop library):

public class MyEventArgs: EventArgs, IDeferralSource
{
  internal DeferralManager DeferralManager { get; } = new DeferralManager();
  public IDisposable GetDeferral() => DeferralManager.DeferralSource.GetDeferral();
}

public class MyContext : DbContext
{
  public event EventHandler<MyEventArgs> SavingChanges;

  public override int SaveChanges(bool acceptAllChangesOnSuccess)
  {
    // You must decide to either throw or block here (see above).

    // Example code for blocking.
    var args = new MyEventArgs();
    SavingChanges?.Invoke(this, args);
    args.DeferralManager.WaitForDeferralsAsync().GetAwaiter().GetResult();

    return base.SaveChanges(acceptAllChangesOnSuccess);
  }

  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    var args = new MyEventArgs();
    SavingChanges?.Invoke(this, args);
    await args.DeferralManager.WaitForDeferralsAsync();

    return await base.SaveChangesAsync(acceptAllChangesOnSuccess,  cancellationToken);
  }
}

// Usage (synchronous handler):
myContext.SavingChanges += (sender, e) =>
{
  Thread.Sleep(1000); // Synchronous code
};

// Usage (asynchronous handler):
myContext.SavingChanges += async (sender, e) =>
{
  using (e.GetDeferral())
  {
    await Task.Delay(1000); // Asynchronous code
  }
};
Community
  • 1
  • 1
Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Stephen, correct me if I'm wrong but your approach is better for another reason (other than protecting shared resources after the first continuation). My approach necessitated a sync and async event, sync and async handlers, and correct calling of the context's sync `SaveChanges` or async `SaveChangesAsync`. Your way has only one event (which is "async-ready" by virtue of its deferral eventargs), and the calling code can have sync or async handlers, and can call `SaveChanges` or `SaveChangesAsync` however it needs, there is no limitation. (Obviously one should avoid the sync way if possible.) – grokky Apr 09 '17 at 13:42
  • 1
    Yes, that's correct. I would discourage sync SaveChanges (and consider throwing instead of blocking), but the code in my answer will just block. There's one event for both sync and async handlers. – Stephen Cleary Apr 09 '17 at 16:55
1

There is a simpler way (based on this).

Declare a multicast delegate which returns a Task:

namespace MyProject
{
  public delegate Task AsyncEventHandler<TEventArgs>(object sender, TEventArgs e);
}

Update the context (I'm only showing async stuff, because sync stuff is unchanged):

public class MyContext : DbContext
{

  public event AsyncEventHandler<EventArgs> SavingChangesAsync;

  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    var delegates = SavingChangesAsync;
    if (delegates != null)
    {
      var tasks = delegates
        .GetInvocationList()
        .Select(d => ((AsyncEventHandler<EventArgs>)d)(this, EventArgs.Empty))
        .ToList();
      await Task.WhenAll(tasks);
    }
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
  }

}

The calling code looks like this:

context.SavingChanges += OnContextSavingChanges;
context.SavingChangesAsync += OnContextSavingChangesAsync;

public void OnContextSavingChanges(object sender, EventArgs e)
{
  someSyncMethod();
}

public async Task OnContextSavingChangesAsync(object sender, EventArgs e)
{
  await someAsyncMethod();
}

I'm not sure if this is a 100% safe way to do this. Async events are tricky. I tested with multiple subscribers, and it worked. My environment is ASP.NET Core, so I don't know if it works elsewhere.

I don't know how it compares with the other solution, or which is better, but this one is simpler and makes more sense to me.

EDIT: this works well if your handler doesn't change shared state. If it does, see the much more robust approach by @stephencleary above

Community
  • 1
  • 1
grokky
  • 8,537
  • 20
  • 62
  • 96
  • 1
    That works. Bear in mind that your asynchronous event handler continuations are going to run in parallel, so if they *update* the data being saved (or any other shared data), then you'll need to protect that. – Stephen Cleary Apr 03 '17 at 14:47
  • @StephenCleary Thanks. Do you mean I must add `.ConfigureAwait(false)` to all async calls in the async handler? – grokky Apr 03 '17 at 14:52
  • No. I mean that any code in an asynchronous handler after an `await` is going to be multithreaded. It's the same situation as the ["implicit parallelism" mentioned in my blog post on ASP.NET Core's SynchronizationContext](https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html). – Stephen Cleary Apr 03 '17 at 14:58
0

I'd suggest a modification of this async event handler

public AsyncEvent SavingChangesAsync;

usage

  // async save
  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    await SavingChangesAsync?.InvokeAsync(cancellationToken);
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess,  cancellationToken);
  }

where

public class AsyncEvent
{
    private readonly List<Func<CancellationToken, Task>> invocationList;
    private readonly object locker;

    private AsyncEvent()
    {
        invocationList = new List<Func<CancellationToken, Task>>();
        locker = new object();
    }

    public static AsyncEvent operator +(
        AsyncEvent e, Func<CancellationToken, Task> callback)
    {
        if (callback == null) throw new NullReferenceException("callback is null");

        //Note: Thread safety issue- if two threads register to the same event (on the first time, i.e when it is null)
        //they could get a different instance, so whoever was first will be overridden.
        //A solution for that would be to switch to a public constructor and use it, but then we'll 'lose' the similar syntax to c# events             
        if (e == null) e = new AsyncEvent();

        lock (e.locker)
        {
            e.invocationList.Add(callback);
        }
        return e;
    }

    public static AsyncEvent operator -(
        AsyncEvent e, Func<CancellationToken, Task> callback)
    {
        if (callback == null) throw new NullReferenceException("callback is null");
        if (e == null) return null;

        lock (e.locker)
        {
            e.invocationList.Remove(callback);
        }
        return e;
    }

    public async Task InvokeAsync(CancellationToken cancellation)
    {
        List<Func<CancellationToken, Task>> tmpInvocationList;
        lock (locker)
        {
            tmpInvocationList = new List<Func<CancellationToken, Task>>(invocationList);
        }

        foreach (var callback in tmpInvocationList)
        {
            //Assuming we want a serial invocation, for a parallel invocation we can use Task.WhenAll instead
            await callback(cancellation);
        }
    }
}
Community
  • 1
  • 1
  • Wow that is a lot of boilerplate for something so simple. Thanks though I'll give it a go. – grokky Apr 02 '17 at 13:12
  • well, besides the AsyncEvent class itself, there are only a couple of lines of actual code... –  Apr 02 '17 at 13:14
0

For my case worked a little tweak to the @grokky answer. I had to not run the event handlers in parallel (as pointed out by @Stephen Cleary) so i ran it in the for each loop fashion instead of going for the Task.WhenAll.

public delegate Task AsyncEventHandler(object sender, EventArgs e);

public abstract class DbContextBase:DbContext
{
    public event AsyncEventHandler SavingChangesAsync;

    public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        await OnSavingChangesAsync(acceptAllChangesOnSuccess);

        return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

    private async Task OnSavingChangesAsync(bool acceptAllChangesOnSuccess)
    {
        if (SavingChangesAsync != null)
        {
            var asyncEventHandlers = SavingChangesAsync.GetInvocationList().Cast<AsyncEventHandler>();
            foreach (AsyncEventHandler asyncEventHandler in asyncEventHandlers)
            {
                await asyncEventHandler.Invoke(this, new SavingChangesEventArgs(acceptAllChangesOnSuccess));
            }
        }
    }
}
dominik737
  • 11
  • 2