Lee Z
Lee Z

Reputation: 964

CancellationToken not working in ASP.NET Core Web API HostedService

I have an ASP.NET Core 8 Web API that has a HostedService and a controller. The controller uses an interface that the HostedService also implements so that the service may be started, stopped, or restarted. By default, the intent is for no intervention.

The StartAsync method is using a PeriodicTimer so that work will be attempted every 15 seconds.

The issue I have is that when an attempt is made to stop the service, it does not stop. I have tried everything I can think of and was hoping to see if anyone can point out what I am doing incorrectly. When I say it does not stop, after the periodic timer ticks expire, another cycle of work is started. CancellationToken should be thread safe and yet it is not catching that the work is cancelled.

Code

Program.cs:

using HostedService.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddSingleton<IControllableBackgroundService, UserOfficeHostedService>();
builder.Services.AddHostedService<UserOfficeHostedService>();

builder.Services.Configure<HostOptions>(x =>
{
    x.ServicesStartConcurrently = true;
    x.ServicesStopConcurrently = false;
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseRouting();
app.UseCors(x => x
    .AllowAnyOrigin()
    .AllowAnyMethod()
    .AllowAnyHeader()
);

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

IControllableBackgroundService:

namespace HostedService.Services;

public interface IControllableBackgroundService
{
    Task StartServiceAsync();
    Task StopServiceAsync();
    Task RestartServiceAsync();
}

BackgroundServiceController:

using HostedService.Services;
using Microsoft.AspNetCore.Mvc;

namespace HostedService.Controllers;
[ApiController]
[Route("[controller]")]
public class BackgroundServiceController(IControllableBackgroundService backgroundService) : ControllerBase
{
    [HttpGet]
    [Route("start")]
    public async Task<IActionResult> StartService()
    {
        await backgroundService.StartServiceAsync();
        return Ok();
    }

    [HttpGet]
    [Route("stop")]
    public async Task<IActionResult> StopService()
    {
        await backgroundService.StopServiceAsync();
        return Ok();
    }

    [HttpGet]
    [Route("restart")]
    public async Task<IActionResult> RestartService()
    {
        await backgroundService.RestartServiceAsync();
        return Ok();
    }
}

UserOfficeHostedService:

namespace HostedService.Services;

public class UserOfficeHostedService(
    ILogger<UserOfficeHostedService> logger) : IHostedService, IControllableBackgroundService, IDisposable
{
    private CancellationTokenSource cts = new();
    private PeriodicTimer timer = new (TimeSpan.FromSeconds(15)));

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("starting service");
        cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

        while (await timer.WaitForNextTickAsync(cts.Token))
        {
            if (cts.Token.IsCancellationRequested)
                break;

            await UpdateUserOfficeCacheAsync(cts.Token);
        }
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("stopping service");

        await cts.CancelAsync();
        timer.Dispose();
    }

    public async Task StartServiceAsync()
    {
        if (!cts.IsCancellationRequested) 
            return;

        timer = new PeriodicTimer(TimeSpan.FromSeconds(serviceInterval));
        await StartAsync(cts.Token);
    }

    public async Task StopServiceAsync()
    {
        await cts.CancelAsync();
        await StopAsync(CancellationToken.None);
    }

    public async Task RestartServiceAsync()
    {
        await StopAsync(cts.Token);
        timer = new PeriodicTimer(TimeSpan.FromSeconds(serviceInterval));
        await StartAsync(cts.Token);
    }

    private async Task UpdateUserOfficeCacheAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation($"Performing update on UserOffice Cache. Time: {DateTimeOffset.Now}");

        try
        {
            if (cancellationToken.IsCancellationRequested)
            {
                logger.LogInformation($"Cancellation received before work attempted. Time: {DateTimeOffset.Now}");
                return;
            }

            // Simulate task
            await Task.Delay(16000, cancellationToken); // Replace this with actual long-running task logic

            if (cancellationToken.IsCancellationRequested)
            {
                logger.LogInformation($"Cancellation received after work started. Time: {DateTimeOffset.Now}");
            }
        }
        catch (TaskCanceledException)
        {
            logger.LogInformation("UserOffice Cache update was canceled.");
        }
        finally
        {
            logger.LogInformation("UserOffice Background Service released semaphore.");
        }
    }

    public void Dispose()
    {
        timer?.Dispose();
        cts?.Dispose();
    }
}

Results

info: HostedService.Services.UserOfficeHostedService[0]
      starting service
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7016
info: HostedService.Services.UserOfficeHostedService[0]
      Performing update on UserOffice Cache. Time: 7/16/2024 4:41:51 PM -04:00
info: HostedService.Services.UserOfficeHostedService[0]
      stopping service
info: HostedService.Services.UserOfficeHostedService[0]
      UserOffice Background Service released semaphore.
info: HostedService.Services.UserOfficeHostedService[0]
      Performing update on UserOffice Cache. Time: 7/16/2024 4:42:07 PM -04:00

As you can see, the service was stopped and then more work started. How do I fix this?

Upvotes: 1

Views: 306

Answers (1)

Guru Stron
Guru Stron

Reputation: 142008

At least one of the problems here is that the following:

builder.Services.AddSingleton<IControllableBackgroundService, UserOfficeHostedService>();
builder.Services.AddHostedService<UserOfficeHostedService>();

Will register two different instances of UserOfficeHostedService, one as IControllableBackgroundService and another as IHostedService, hence you will manage different instance compared to one running as a hosted service.

One workaround can be to register UserOfficeHostedService first and then use implementation factory registrations which will resolve the registered single instance:

builder.Services.AddSingleton<UserOfficeHostedService>();
builder.Services.AddSingleton<IControllableBackgroundService>(sp => sp.GetRequiredService<UserOfficeHostedService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<UserOfficeHostedService>());

Upvotes: 1

Related Questions