Using background service in Asp.net Core Minimal APIs

Photo by Andrew Neel on Unsplash

Using background service in Asp.net Core Minimal APIs

Background tasks are services that aren’t meant to execute during a regular flow of a typical application, such as sending email confirmations or updating the database with new data. These tasks are not meant to interact with the customers or process user input. Instead, they run in the background, handling items from a queue or executing a long-running process.

IHostedService

We use the IHostedService interface to run tasks in the background. The hosted service indicates a class that contains background task logic.

When we register an IHostedService, .NET will call the StartAsync() and StopAsync() methods of our IHostedService type during application start and stop, respectively.

We are responsible for handling the stopping action of our services when the hosts trigger the StopAsync().

The following code shows the structure of the IHostedService.

    public interface IHostedService
    {
        Task StartAsync(CancellationToken cancellationToken);
        Task StopAsync(CancellationToken cancellationToken);
    }

BackgroundService

The BackgroundServiceclass is part of .NET Core. It’s an implementation of the IHostedService interface, which is meant to be used for running background jobs in ASP.NET Core.

When deriving from BackgroundService, thanks to that inherited implementation, we need to implement the ExecuteAsync() method in our custom-hosted service class.

The implementation of ExecuteAsync should finish promptly when the cancellation token is fired to stop the service immediately.

public abstract class BackgroundService : IHostedService, IDisposable
    {
        private Task _executeTask;
        private CancellationTokenSource _stoppingCts;

        public virtual Task ExecuteTask => _executeTask;

        protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

        public virtual Task StartAsync(CancellationToken cancellationToken)
        {
            _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

            _executeTask = ExecuteAsync(_stoppingCts.Token);
            if (_executeTask.IsCompleted)
            {
                return _executeTask;
            }

            return Task.CompletedTask;
        }

        public virtual async Task StopAsync(CancellationToken cancellationToken)
        {
            if (_executeTask == null)
            {
                return;
            }

            try
            {
                _stoppingCts.Cancel();
            }
            finally
            {
                await Task.WhenAny(_executeTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
            }

        }

        public virtual void Dispose()
        {
            _stoppingCts?.Cancel();
        }
    }

Timed background tasks

It is an implementation of IHostedService that uses the Timer class to trigger the function that needs to run.

Queued background tasks

Queue background tasks are an excellent example of long-running services where work items can be queued and worked on sequentially as previous work items are completed.

Quartz.NET

Quartz.NET is a free and open source .NET port of its Java Quartz Scheduling framework counterpart. It has been used for a long time and offers good support for dealing with cron expressions. There is also a Quartz.AspNetCore package that builds on the Quartz.Extensions.Hosting. It primarily adds health-check integration, though health checks can also be used with worker services!

Quartz.NET has three main actors:

  • A job. This is the background task, and it includes the code needed to execute a task or job.

  • A trigger. A trigger controls when and how a job runs.

  • A scheduler. This is responsible for coordinating the jobs and triggers and executing the jobs as required by the triggers.

Implementation

BackgroundService

Using the BackgroundService, we need to create a class that inherits from it and implement the method ExecuteAsync. The following codes show the implementation of the service and how to register it.

builder.Services.AddHostedService<TodoBackgroundService>();
public class TodoBackgroundService : BackgroundService
{
    private readonly ILogger<Todo> _logger;

    public TodoBackgroundService(ILogger<Todo> logger)
    {
        _logger = logger;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        if (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                _logger.LogInformation(
                    $"Executed TodoItemBackgroundService");

            }
            catch (Exception ex)
            {
                _logger.LogInformation(
                    $"Failed to execute TodoItemBackgroundService with exception message {ex.Message}.");
            }
        }

        return Task.CompletedTask;
    }
}

Timed background tasks

The Timed background task can be implemented using BackgroundService or IHostedService. The important thing is to use a class that helps to control the execution of the code in an interval of time. For this example, we will use the PeriodicTimer.

    public class TodoTimedBackgroundService : BackgroundService
    {
        private int _executionCount = 0;
        private TimeSpan _period;
        private readonly ILogger<Todo> _logger;

        public TodoTimedBackgroundService(ILogger<Todo> logger)
        {
            _logger = logger;
            _period = TimeSpan.FromSeconds(10);
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            using PeriodicTimer timer = new PeriodicTimer(_period);
            while (
                !stoppingToken.IsCancellationRequested &&
                await timer.WaitForNextTickAsync(stoppingToken))
            {
                try
                {
                    _executionCount++;
                    _logger.LogInformation(
                        $"{DateTime.Now.ToString("HH:mm:ss")} - Executed TodoTimedBackgroundService - Count: {_executionCount}");
                }
                catch (Exception ex)
                {
                    _logger.LogInformation(
                        $"Failed to execute TodoTimedBackgroundService with exception message {ex.Message}.");
                }
            }
        }
    }
info: ApiFluentValidator.Models.Todo[0]
      16:24:08 - Executed TodoTimedBackgroundService - Count: 1
info: ApiFluentValidator.Models.Todo[0]
      16:24:18 - Executed TodoTimedBackgroundService - Count: 2
info: ApiFluentValidator.Models.Todo[0]
      16:24:28 - Executed TodoTimedBackgroundService - Count: 3
info: ApiFluentValidator.Models.Todo[0]
      16:24:38 - Executed TodoTimedBackgroundService - Count: 4

Queued background tasks

For this type of background task, first, we will implement a Queue that our worker can use. The generic interface IBackgroundQueue<T> is created to be injected into our application.

public interface IBackgroundQueue<T>
{
    void Enqueue(T item);
    T Dequeue();
}
public class BackgroundQueue<T> : IBackgroundQueue<T> where T : class
{
    private readonly ConcurrentQueue<T> _items = new ConcurrentQueue<T>();

    public void Enqueue(T item)
    {
        if (item == null) throw new ArgumentNullException(nameof(item));

        _items.Enqueue(item);
    }

    public T Dequeue()
    {
        var success = _items.TryDequeue(out var workItem);

        return success
            ? workItem
            : null;
    }
}

Because the queue will be accessed from different threads, the ConcurrentQueue<T> provides the code with some thread safety.

The implementation of TodoBackgroundQueueService will use IBackgroundQueue<Todo>, ILogger and IServiceScopeFactory. The reason to use IServiceScopeFactoryis because the ITodoServiceis a Scoped Service while the TodoBackgroundQueueServiceis a Singleton and the TodoServiceuses an Entity Framework DbContext. In this case, to avoid weird behaviours of DbContext that might occur with other Scoped Services.

public class TodoBackgroundQueueService : BackgroundService
{
    private readonly IBackgroundQueue<Todo> _queue;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<TodoBackgroundQueueService> _logger;

    public TodoBackgroundQueueService(IBackgroundQueue<Todo> queue, IServiceScopeFactory scopeFactory,
      ILogger<TodoBackgroundQueueService> logger)
    {
        _queue = queue;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("{Type} is now running in the background.", nameof(TodoBackgroundQueueService));

        await TodoProcessing(stoppingToken);
    }

    public override Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogCritical(
            "The {Type} is stopping; queued items might not be processed anymore.",
            nameof(TodoBackgroundQueueService)
        );

        return base.StopAsync(cancellationToken);
    }

    private async Task TodoProcessing(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(1000, stoppingToken);

                var todo = _queue.Dequeue();

                if (todo == null)
                    continue;

                _logger.LogInformation("Todo Item found! Completing it ..");

                using var scope = _scopeFactory.CreateScope();
                var todoService = scope.ServiceProvider.GetRequiredService<ITodoService>();

                await todoService.CompleteTodo(todo);

                _logger.LogInformation("Todo Item is completed.");
            }
            catch (Exception ex)
            {
                _logger.LogCritical("An error occurred when completing a todo. Exception: {@Exception}", ex);
            }
        }
    }
}

The following code registers all the items that the code needs to work.

builder.Services.AddScoped<ITodoService, TodoService>();
builder.Services.AddSingleton<IBackgroundQueue<Todo>, BackgroundQueue<Todo>>();
builder.Services.AddHostedService<TodoBackgroundQueueService>();

The following code shows the endpoint enqueuing the Todo item.

app.MapPut("v{version:apiVersion}/complete", [Authorize] (IBackgroundQueue<Todo> queue, int id, TodoDb db) =>
{
    var todo = db.Todos.SingleOrDefault(c=> c.Id == id);

    if(todo is null)
    {
        return Results.NotFound();
    }

    queue.Enqueue(todo);

    return Results.Accepted();
})
.WithApiVersionSet(versionSet)
.MapToApiVersion(version1);
info: ApiFluentValidator.BackgroundServices.TodoBackgroundQueueService[0]
      Todo Item found! Completing it ...
info: ApiFluentValidator.BackgroundServices.TodoBackgroundQueueService[0]
      Todo Item is completed.

Quartz.NET

First, it is necessary to install the nugget package Quartz.AspNetCore. For the background work, we will get all the Todos not completed and complete them. We need to create a class that implements the IJob interface that contains the asynchronous Execute() method. The [DisallowConcurrentExecution] attribute prevents Quartz.NET from trying to run the same job concurrently.

[DisallowConcurrentExecution]
public class CompleteTodoItemJob : IJob
{
    private readonly ILogger<CompleteTodoItemJob> _logger;
    private readonly ITodoService _todoService;

    public CompleteTodoItemJob(ILogger<CompleteTodoItemJob> logger, ITodoService todoService)
    {
        _logger = logger;
        _todoService = todoService;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Getting all not completed Todos!");
        var todos = await _todoService.GetAllNotCompleted();

        if(todos.Count == 0)
            _logger.LogInformation("There is no Todo to be completed!");

        foreach (var todo in todos)
        {
            _logger.LogInformation($"Completing the Todo: {todo.Name}");

            await _todoService.CompleteTodo(todo);
        }
    }
}

Quartz.NET has some simple schedules for running jobs, but one of the most common approaches is using a Cron expression that allows complex timer scheduling. For more information about Cron expression, check out this link.

We need to define a Jobkey, which links the job and its trigger. After that, we need to add the job that will run and finally, we define the trigger; in this example, it will run every 10 seconds. The UseMicrosoftDependencyInjectionJobFactory method allows jobs to receive services through the DI.

builder.Services.AddQuartz(q =>
{
    // Create a "key" for the job
    var jobKey = new JobKey("CompleteTodoItemJob");

    q.UseMicrosoftDependencyInjectionJobFactory();

    // Register the job with the DI container
    q.AddJob<CompleteTodoItemJob>(opts => opts.WithIdentity(jobKey));

    // Create a trigger for the job
    q.AddTrigger(opts => opts
        .ForJob(jobKey) // link to the CompleteTodoItemJob
        .WithIdentity("CompleteTodoItemJob-trigger") 
        .WithCronSchedule("0/10 * * * * ?")); // run every 10 seconds

});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
info: ApiFluentValidator.BackgroundServices.Quartz.CompleteTodoItemJob[0]
      Getting all not completed Todos!
info: ApiFluentValidator.BackgroundServices.Quartz.CompleteTodoItemJob[0]
      There is no Todo to be completed!
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST https://localhost:7063/v1/todoitems application/json 52
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: POST v{version:apiVersion}/todoitems'
info: Program[0]
      Saving todoitem
info: Microsoft.EntityFrameworkCore.Update[30100]
      Saved 1 entities to in-memory store.
info: Program[0]
      TodoItem was saved
info: Microsoft.AspNetCore.Http.Result.CreatedResult[1]
      Writing value of type 'ApiFluentValidator.Models.Todo' with status code '201'.
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: POST v{version:apiVersion}/todoitems'
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 POST https://localhost:7063/v1/todoitems application/json 52 - 201 - application/json;+charset=utf-8 131.6760ms
info: ApiFluentValidator.BackgroundServices.Quartz.CompleteTodoItemJob[0]
      Getting all not completed Todos!
info: ApiFluentValidator.BackgroundServices.Quartz.CompleteTodoItemJob[0]
      Completing the Todo: Read a book
info: Microsoft.EntityFrameworkCore.Update[30100]
      Saved 1 entities to in-memory store.
info: ApiFluentValidator.BackgroundServices.Quartz.CompleteTodoItemJob[0]
      Getting all not completed Todos!
info: ApiFluentValidator.BackgroundServices.Quartz.CompleteTodoItemJob[0]
      There is no Todo to be completed!

Wrapping Up

You can find the complete code on my GitHub.