C# fire and forget might not be suitable in ASP.NET Core

To fire and forget in C#, it is really simple:

Task.Run(() => FireAway());

But the same approach might not be suitable in ASP.NET Core Controller.

Consider the following example:

public class MyController : Controller
{
    private readonly MyHeavyDependency _hd;

    public MyController(MyHeavyDependency hd)
    {
        _hd = hd;
    }

    public IActionResult MyAction()
    {
        Task.Run(() => _hd.DoHeavyAsyncWork());
        return Json("Your job is started!");
    }
}

In the controller, we triggered a heavy job. And the job is running in a dependency. Now the job will successfully get started as a fire-and-forget. But...

After processing the HTTP response, you controller might be disposed. Which means that your dependency might not be alive. It is hard for us to control that so your job may just not able to finish.

Keep the dependency alive in a new singleton service

To keep our dependency always alive while we are firing the job, we need a new service to contain it. And the new service must be singleton. For singleton service will never be disposed.

And we need to get the dependency in your service, but not in the controller. Because the life-cycle of controller depends on HTTP context while your singleton service dosen't.

Create a new class:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;

namespace YourNamespace.Services
{
    public class CannonService
    {
        private readonly ILogger<CannonService> _logger;
        private readonly IServiceScopeFactory _scopeFactory;

        public CannonService(
            ILogger<CannonService> logger,
            IServiceScopeFactory scopeFactory)
        {
            _logger = logger;
            _scopeFactory = scopeFactory;
        }

        public void Fire<T>(Action<T> bullet, Action<Exception> handler = null)
        {
            _logger.LogInformation("Fired a new action.");
            Task.Run(() =>
            {
                using var scope = _scopeFactory.CreateScope();
                var dependency = scope.ServiceProvider.GetRequiredService<T>();
                try
                {
                    bullet(dependency);
                }
                catch (Exception e)
                {
                    _logger.LogError(e,"Cannon crashed!");
                    handler?.Invoke(e);
                }
                finally
                {
                    dependency = default;
                }
            });
        }

        public void FireAsync<T>(Func<T, Task> bullet, Action<Exception> handler = null)
        {
            _logger.LogInformation("Fired a new async action.");
            Task.Run(async () =>
            {
                using var scope = _scopeFactory.CreateScope();
                var dependency = scope.ServiceProvider.GetRequiredService<T>();
                try
                {
                    await bullet(dependency);
                }
                catch (Exception e)
                {
                    _logger.LogError(e,"Cannon crashed!");
                    handler?.Invoke(e);
                }
                finally
                {
                    dependency = default;
                }
            });
        }
    }
}

To use it, register it as a singleton.

// Call it in StartUp.cs, ConfigureServices method.
services.AddSingleton<CannonService>();

Use cannon to fire a method

To use it in controller, simply inject your cannon to your controller:

    public class OAuthController : Controller
    {
        private readonly CannonService _cannonService;

        public OAuthController(
            CannonService cannonService)
        {
            _cannonService = cannonService;
        }

    }

And call it with a function which depends something and cost long time:

            // Send him an confirmation email here:
            _cannonService.FireAsync<EmailSender>(async (sender) =>
            {
                await sender.SendAsync(); // Which may be slow. Depends on the sender to be alive.
            });

And the method will not block current thread. You can just return a result to the client side. The sender will always be alive while sending the mail.

Full Demo

Demo controller:

using Aiursoft.Scanner.Interfaces;
using Aiursoft.XelNaga.Services;
using Microsoft.AspNetCore.Mvc;

namespace Aiursoft.XelNaga.Tests.Models
{
    public class DemoController : IScopedDependency
    {
        private readonly CannonService _cannonService;

        public DemoController(
            CannonService cannonService)
        {
            _cannonService = cannonService;
        }

        public IActionResult DemoAction()
        {
            _cannonService.Fire<DemoService>(d => d.DoSomethingSlow());
            return null;
        }

        public IActionResult DemoActionAsync()
        {
            _cannonService.FireAsync<DemoService>(d => d.DoSomethingSlowAsync());
            return null;
        }
    }
}

Write a demo service which might be very slow:

using Aiursoft.Scanner.Interfaces;
using System.Threading;
using System.Threading.Tasks;

namespace Aiursoft.XelNaga.Tests.Models
{
    public class DemoService
    {
        public static bool Done = false;
        public static bool DoneAsync = false;

        public void DoSomethingSlow()
        {
            Done = false;
            Thread.Sleep(200);
            Done = true;
        }

        public async Task DoSomethingSlowAsync()
        {
            DoneAsync = false;
            await Task.Delay(200);
            DoneAsync = true;
        }
    }
}

Tests it with unit test:

using Aiursoft.XelNaga.Tests.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading.Tasks;

namespace Aiursoft.XelNaga.Tests.Services
{
    [TestClass]
	public class CannonServiceTests
	{
		private IServiceProvider _serviceProvider;

		[TestInitialize]
		public void Init()
		{
			_serviceProvider = new ServiceCollection()
				.AddLogging()
				.AddSingleton<CannonService>()
                                .AddTransient<DemoService>()
				.BuildServiceProvider();
		}

		[TestMethod]
		public async Task TestCannon()
		{
			var controller = _serviceProvider.GetRequiredService<DemoController>();
			var startTime = DateTime.UtcNow;
			controller.DemoAction();
			var endTime = DateTime.UtcNow;
			Assert.IsTrue(endTime - startTime < TimeSpan.FromMilliseconds(1000), "Demo action should finish very fast.");
			Assert.AreEqual(false, DemoService.Done, "When demo action finished, work is not over yet.");
			await Task.Delay(300);
			Assert.AreEqual(true, DemoService.Done, "After a while, the async job is done.");
		}

		[TestMethod]
		public async Task TestCannonAsync()
		{
			var controller = _serviceProvider.GetRequiredService<DemoController>();
			var startTime = DateTime.UtcNow;
			controller.DemoActionAsync();
			var endTime = DateTime.UtcNow;
			Assert.IsTrue(endTime - startTime < TimeSpan.FromMilliseconds(1000), "Demo action should finish very fast.");
			Assert.AreEqual(false, DemoService.DoneAsync, "When demo action finished, work is not over yet.");
			await Task.Delay(300);
			Assert.AreEqual(true, DemoService.DoneAsync, "After a while, the async job is done.");
		}
	}
}

And run the tests: