Raise async event in EF's DbContext.SaveChangesAsync()

async-await asynchronous c# entity-framework entity-framework-core

Question

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()?

1
2
4/2/2017 12:37:05 PM

Accepted Answer

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
  }
};
2
4/3/2017 2:43:07 PM

Popular Answer

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



Related Questions





Related

Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow
Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow