72

We are working with .NET Core Web Api, and looking for a lightweight solution to log requests with variable intensity into database, but don't want client's to wait for the saving process.
Unfortunately there's no HostingEnvironment.QueueBackgroundWorkItem(..) implemented in dnx, and Task.Run(..) is not safe.
Is there any elegant solution?

Askolein
  • 3,250
  • 3
  • 28
  • 40
  • 3
    `HostingEnvironment.QueueBackgroundWorkItem` wasn't safe, either. It was less unsafe than `Task.Run`, but it wasn't safe. – Stephen Cleary Oct 18 '16 at 13:01
  • A good question. I myself am trying to implement a signalR progression reporter (using the IProgress interface) but due to the asynchronous nature of SignalR I need to handle the progress reporting as tasks (albeit very short-lived tasks) without slowing down the operation they are reporting on. – Shazi Oct 15 '17 at 11:38
  • 2
    In case of webapi, you could simply use Response.OnCompleted(Func) that adds a delegate that will be invoked after the response has completed – Larsi Mar 10 '20 at 08:40

7 Answers7

28

As @axelheer mentioned IHostedService is the way to go in .NET Core 2.0 and above.

I needed a lightweight like for like ASP.NET Core replacement for HostingEnvironment.QueueBackgroundWorkItem, so I wrote DalSoft.Hosting.BackgroundQueue which uses.NET Core's 2.0 IHostedService.

PM> Install-Package DalSoft.Hosting.BackgroundQueue

In your ASP.NET Core Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
   services.AddBackgroundQueue(onException:exception =>
   {
                   
   });
}

To queue a background Task just add BackgroundQueue to your controller's constructor and call Enqueue.

public EmailController(BackgroundQueue backgroundQueue)
{
   _backgroundQueue = backgroundQueue;
}
    
[HttpPost, Route("/")]
public IActionResult SendEmail([FromBody]emailRequest)
{
   _backgroundQueue.Enqueue(async cancellationToken =>
   {
      await _smtp.SendMailAsync(emailRequest.From, emailRequest.To, request.Body);
   });

   return Ok();
}
DalSoft
  • 10,673
  • 3
  • 42
  • 55
  • is the BackgroundQueue registered as a singleton? – Ivan-Mark Debono Mar 26 '20 at 04:41
  • 2
    Yes but I'd look at Microsoft.NET.Sdk.Worker as it does everything my package does and more. – DalSoft Mar 26 '20 at 13:16
  • @DalSoft You mention Microsoft.NET.Sdk.Worker, but I'm not quite connecting the dots on how that's a _direct_ replacement for `HostingEnvironment.QueueBackgroundWorkItem`. Would you consider posting a new answer describing what that looks like? Thanks! – Todd Menier Sep 17 '20 at 16:32
  • @DalSoft yeah am also confused could you shed some light. i get urs but how is Worker " it does everything my package" i dont see it could you explain. – Seabizkit Oct 06 '20 at 12:41
  • 3
    Sorry guys see [Queued background tasks](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1&tabs=visual-studio#queued-background-tasks) MS documentation changed a bit. If you still feel a wrapping library is worthwhile let me know. – DalSoft Oct 06 '20 at 15:49
  • 1
    [MS is leaving it up to the end user](https://github.com/dotnet/extensions/issues/805) to decide how to implement, so I will continue to support and improve this package. – DalSoft Sep 02 '21 at 23:24
  • Hi, i tried your package, but I still get errors about disposed objects (e.g. IServiceProvider or IConfigurationService) any way to prevent that, or I have to rewrite my code because this is disposed at the end of every request – marhyno Feb 10 '22 at 12:29
  • @marhyno background tasks operate on their own lifecycle, so you have to scope services as appropriate. For example the background task could fire after the request has finished. Drop me a GitHub issue if your still having issues. – DalSoft Feb 11 '22 at 13:12
17

QueueBackgroundWorkItem is gone, but we've got IApplicationLifetime instead of IRegisteredObject, which is being used by the former one. And it looks quite promising for such scenarios, I think.

The idea (and I'm still not quite sure, if it's a pretty bad one; thus, beware!) is to register a singleton, which spawns and observes new tasks. Within that singleton we can furthermore register a "stopped event" in order to proper await still running tasks.

This "concept" could be used for short running stuff like logging, mail sending, and the like. Things, that should not take much time, but would produce unnecessary delays for the current request.

public class BackgroundPool
{
    protected ILogger<BackgroundPool> Logger { get; }

    public BackgroundPool(ILogger<BackgroundPool> logger, IApplicationLifetime lifetime)
    {
        if (logger == null)
            throw new ArgumentNullException(nameof(logger));
        if (lifetime == null)
            throw new ArgumentNullException(nameof(lifetime));

        lifetime.ApplicationStopped.Register(() =>
        {
            lock (currentTasksLock)
            {
                Task.WaitAll(currentTasks.ToArray());
            }

            logger.LogInformation(BackgroundEvents.Close, "Background pool closed.");
        });

        Logger = logger;
    }

    private readonly object currentTasksLock = new object();

    private readonly List<Task> currentTasks = new List<Task>();

    public void SendStuff(Stuff whatever)
    {
        var task = Task.Run(async () =>
        {
            Logger.LogInformation(BackgroundEvents.Send, "Sending stuff...");

            try
            {
                // do THE stuff

                Logger.LogInformation(BackgroundEvents.SendDone, "Send stuff returns.");
            }
            catch (Exception ex)
            {
                Logger.LogError(BackgroundEvents.SendFail, ex, "Send stuff failed.");
            }
        });

        lock (currentTasksLock)
        {
            currentTasks.Add(task);

            currentTasks.RemoveAll(t => t.IsCompleted);
        }
    }
}

Such a BackgroundPool should be registered as a singleton and can be used by any other component via DI. I'm currently using it for sending mails and it works fine (tested mail sending during app shutdown too).

Note: accessing stuff like the current HttpContext within the background task should not work. The old solution uses UnsafeQueueUserWorkItem to prohibit that anyway.

What do you think?

Update:

With ASP.NET Core 2.0 there's new stuff for background tasks, which get's better with ASP.NET Core 2.1: Implementing background tasks in .NET Core 2.x webapps or microservices with IHostedService and the BackgroundService class

Axel Heer
  • 1,863
  • 16
  • 22
  • In your ApplicationStopped.Register delegate you don't actually wait for the task returned from "Task.WaitAll(currentTask.ToArray());". Making that call kind of pointless. – Shazi Oct 15 '17 at 11:32
11

You can use Hangfire (http://hangfire.io/) for background jobs in .NET Core.

For example :

var jobId = BackgroundJob.Enqueue(
    () => Console.WriteLine("Fire-and-forget!"));
ycrumeyrolle
  • 495
  • 4
  • 15
  • 4
    This solution requires SQL Server. Not that it _can't_ replace some things you might do with `HostingEnvironment.QueueBackgroundWorkItem`, but it's a significantly "heavier" solution which I think is worth mentioning here. – Todd Menier Sep 17 '20 at 16:19
  • @ToddMenier Hangfire is able to work with many different storage solutions, including Redis and a new in-memory storage they're working on now. – Mason G. Zhwiti Jul 07 '21 at 16:53
6

Here is a tweaked version of Axel's answer that lets you pass in delegates and does more aggressive cleanup of completed tasks.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;

namespace Example
{
    public class BackgroundPool
    {
        private readonly ILogger<BackgroundPool> _logger;
        private readonly IApplicationLifetime _lifetime;
        private readonly object _currentTasksLock = new object();
        private readonly List<Task> _currentTasks = new List<Task>();

        public BackgroundPool(ILogger<BackgroundPool> logger, IApplicationLifetime lifetime)
        {
            if (logger == null)
                throw new ArgumentNullException(nameof(logger));
            if (lifetime == null)
                throw new ArgumentNullException(nameof(lifetime));

            _logger = logger;
            _lifetime = lifetime;

            _lifetime.ApplicationStopped.Register(() =>
            {
                lock (_currentTasksLock)
                {
                    Task.WaitAll(_currentTasks.ToArray());
                }

                _logger.LogInformation("Background pool closed.");
            });
        }

        public void QueueBackgroundWork(Action action)
        {
#pragma warning disable 1998
            async Task Wrapper() => action();
#pragma warning restore 1998

            QueueBackgroundWork(Wrapper);
        }

        public void QueueBackgroundWork(Func<Task> func)
        {
            var task = Task.Run(async () =>
            {
                _logger.LogTrace("Queuing background work.");

                try
                {
                    await func();

                    _logger.LogTrace("Background work returns.");
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex.HResult, ex, "Background work failed.");
                }
            }, _lifetime.ApplicationStopped);

            lock (_currentTasksLock)
            {
                _currentTasks.Add(task);
            }

            task.ContinueWith(CleanupOnComplete, _lifetime.ApplicationStopping);
        }

        private void CleanupOnComplete(Task oldTask)
        {
            lock (_currentTasksLock)
            {
                _currentTasks.Remove(oldTask);
            }
        }
    }
}
SpruceMoose
  • 9,737
  • 4
  • 39
  • 53
Scott Chamberlain
  • 124,994
  • 33
  • 282
  • 431
  • Just like in Axel's answer you don't actually wait for the task returned from "Task.WaitAll(currentTask.ToArray());". – Shazi Oct 15 '17 at 11:33
1

I know this is a little late, but we just ran into this issue too. So after reading lots of ideas, here's the solution we came up with.

    /// <summary>
    /// Defines a simple interface for scheduling background tasks. Useful for UnitTesting ASP.net code
    /// </summary>
    public interface ITaskScheduler
    {
        /// <summary>
        /// Schedules a task which can run in the background, independent of any request.
        /// </summary>
        /// <param name="workItem">A unit of execution.</param>
        [SecurityPermission(SecurityAction.LinkDemand, Unrestricted = true)]
        void QueueBackgroundWorkItem(Action<CancellationToken> workItem);

        /// <summary>
        /// Schedules a task which can run in the background, independent of any request.
        /// </summary>
        /// <param name="workItem">A unit of execution.</param>
        [SecurityPermission(SecurityAction.LinkDemand, Unrestricted = true)]
        void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem);
    }


    public class BackgroundTaskScheduler : BackgroundService, ITaskScheduler
    {
        public BackgroundTaskScheduler(ILogger<BackgroundTaskScheduler> logger)
        {
            _logger = logger;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _logger.LogTrace("BackgroundTaskScheduler Service started.");

            _stoppingToken = stoppingToken;

            _isRunning = true;
            try
            {
                await Task.Delay(-1, stoppingToken);
            }
            catch (TaskCanceledException)
            {
            }
            finally
            {
                _isRunning = false;
                _logger.LogTrace("BackgroundTaskScheduler Service stopped.");
            }
        }

        public void QueueBackgroundWorkItem(Action<CancellationToken> workItem)
        {
            if (workItem == null)
            {
                throw new ArgumentNullException(nameof(workItem));
            }

            if (!_isRunning)
                throw new Exception("BackgroundTaskScheduler is not running.");

            _ = Task.Run(() => workItem(_stoppingToken), _stoppingToken);
        }

        public void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem)
        {
            if (workItem == null)
            {
                throw new ArgumentNullException(nameof(workItem));
            }

            if (!_isRunning)
                throw new Exception("BackgroundTaskScheduler is not running.");

            _ = Task.Run(async () =>
                {
                    try
                    {
                        await workItem(_stoppingToken);
                    }
                    catch (Exception e)
                    {
                        _logger.LogError(e, "When executing background task.");
                        throw;
                    }
                }, _stoppingToken);
        }

        private readonly ILogger _logger;
        private volatile bool _isRunning;
        private CancellationToken _stoppingToken;
    }

The ITaskScheduler (which we already defined in our old ASP.NET client code for UTest test purposes) allows a client to add a background task. The main purpose of the BackgroundTaskScheduler is to capture the stop cancellation token (which is own by the Host) and to pass it into all the background Tasks; which by definition, runs in the System.Threading.ThreadPool so there is no need to create our own.

To configure Hosted Services properly see this post.

Enjoy!

Kabua
  • 889
  • 8
  • 19
  • This tells work items to stop their work (via CancellationToken), but it doesn't actually ensure any running work items are _complete_ before `ExecuteAsync` finishes, right? – StriplingWarrior Feb 03 '21 at 19:46
  • @StriplingWarrior the code functions just like ASP.NET in that the queued background task(s) will run from start to finish as long as IIS isn't being shut down. If IIS is being shutdown then the cancelation token will be signed and thus all the currently running tasks will be notified. – Kabua Feb 04 '21 at 15:34
  • @StriplingWarrior one enhancement might be to keep a collection of running tasks and not return from the ExecuteAsync() until all of the currently running tasks have gracefully completed. – Kabua Feb 04 '21 at 15:37
  • 1
    The ASP.NET QueueBackgroundWorkItem uses a [BackgroundWorkScheduler](https://referencesource.microsoft.com/#system.web/Hosting/BackgroundWorkScheduler.cs), which goes to a lot of trouble to make sure that its `IRegisteredObject` only gets de-registered after the last running work item gets completed. – StriplingWarrior Feb 04 '21 at 17:51
0

I have used Quartz.NET (does not require SQL Server) with the following extension method to easily set up and run a job:

public static class QuartzUtils
{
        public static async Task<JobKey> CreateSingleJob<JOB>(this IScheduler scheduler,
            string jobName, object data) where JOB : IJob
        {
            var jm = new JobDataMap { { "data", data } };

            var jobKey = new JobKey(jobName);

            await scheduler.ScheduleJob(
                JobBuilder.Create<JOB>()
                .WithIdentity(jobKey)
                .Build(),

                TriggerBuilder.Create()
                .WithIdentity(jobName)
                .UsingJobData(jm)
                .StartNow()
                .Build());

            return jobKey;
        }
}

Data is passed as an object that must be serializable. Create an IJob that processes the job like this:

public class MyJobAsync :IJob
{
   public async Task Execute(IJobExecutionContext context)
   {
          var data = (MyDataType)context.MergedJobDataMap["data"];
          ....

Execute like this:

await SchedulerInstance.CreateSingleJob<MyJobAsync>("JobTitle 123", myData);
Hakakou
  • 522
  • 3
  • 6
-5

The original HostingEnvironment.QueueBackgroundWorkItem was a one-liner and very convenient to use. The "new" way of doing this in ASP Core 2.x requires reading pages of cryptic documentation and writing considerable amount of code.

To avoid this you can use the following alternative method

    public static ConcurrentBag<Boolean> bs = new ConcurrentBag<Boolean>();

    [HttpPost("/save")]
    public async Task<IActionResult> SaveAsync(dynamic postData)
    {

    var id = (String)postData.id;

    Task.Run(() =>
                {
                    bs.Add(Create(id));
                });

     return new OkResult();

    }


    private Boolean Create(String id)
    {
      /// do work
      return true;
    }

The static ConcurrentBag<Boolean> bs will hold a reference to the object, this will prevent garbage collector from collecting the task after the controller returns.

user11658885
  • 105
  • 1
  • 7