- 在 ASP.NET Core 中使用托管服务实现后台任务Background tasks with hosted services in ASP.NET Core
- 辅助角色服务模板Worker Service template
- PackagePackage
- IHostedService 接口IHostedService interface
- BackgroundService 基类BackgroundService base class
- 计时的后台任务Timed background tasks
- 在后台任务中使用有作用域的服务Consuming a scoped service in a background task
- 排队的后台任务Queued background tasks
- PackagePackage
- IHostedService 接口IHostedService interface
- 计时的后台任务Timed background tasks
- 在后台任务中使用有作用域的服务Consuming a scoped service in a background task
- 排队的后台任务Queued background tasks
- 其他资源Additional resources
在 ASP.NET Core 中使用托管服务实现后台任务Background tasks with hosted services in ASP.NET Core
本文内容
作者:Jeow Li Huan
在 ASP.NET Core 中,后台任务作为托管服务实现 。托管服务是一个类,具有实现 IHostedService 接口的后台任务逻辑。本主题提供了三个托管服务示例:
- 在计时器上运行的后台任务。
- 激活有作用域的服务的托管服务。有作用域的服务可使用依赖项注入 (DI)。
- 按顺序运行的已排队后台任务。
辅助角色服务模板Worker Service template
ASP.NET Core 辅助角色服务模板可作为编写长期服务应用的起点。通过辅助角色服务模板创建的应用将在其项目文件中指定 Worker SDK:
<Project Sdk="Microsoft.NET.Sdk.Worker">
要使用该模板作为编写托管服务应用的基础:
- 创建新项目。
- 选择“辅助角色服务”。选择“下一步”。
- 在“项目名称”字段提供项目名称,或接受默认项目名称。选择“创建”。
- 在“创建辅助角色服务”对话框中,选择“创建”。
- 创建新项目。
- 在侧栏中的“.NET Core”下,选择“应用”。
- 在“ASP.NET Core”下,选择“辅助角色”。选择“下一步”。
- 对于“目标框架”,选择“.NET Core 3.0”或更高版本。选择“下一步”。
- 在“项目名称”字段中提供名称。选择“创建”。
将辅助角色服务 (worker
) 模板用于命令行界面中的 dotnet new 命令。下面的示例中创建了名为 ContosoWorker
的辅助角色服务应用。执行命令时会自动为 ContosoWorker
应用创建文件夹。
dotnet new worker -o ContosoWorker
PackagePackage
基于辅助角色服务模板的应用使用 Microsoft.NET.Sdk.Worker
SDK,并且具有对 Microsoft.Extensions.Hosting 包的显式包引用。有关示例,请参阅示例应用的项目文件 (BackgroundTasksSample.csproj)。
对于使用 Microsoft.NET.Sdk.Web
SDK 的 Web 应用,通过共享框架隐式引用 Microsoft.Extensions.Hosting 包。在应用的项目文件中不需要显式包引用。
IHostedService 接口IHostedService interface
IHostedService 接口为主机托管的对象定义了两种方法:
StartAsync(CancellationToken) –
StartAsync
包含启动后台任务的逻辑。在以下操作之前调用StartAsync
:- 已配置应用的请求处理管道 (
Startup.Configure
)。 - 已启动服务器且已触发 IApplicationLifetime.ApplicationStarted。
可以更改默认行为,以便在配置应用的管道并调用ApplicationStarted
之后,运行托管服务的StartAsync
。若要更改默认行为,请在调用ConfigureWebHostDefaults
后添加托管服务(以下示例中的VideosWatcher
):
- 已配置应用的请求处理管道 (
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
.ConfigureServices(services =>
{
services.AddHostedService<VideosWatcher>();
});
}
- StopAsync(CancellationToken) – 主机正常关闭时触发。
StopAsync
包含结束后台任务的逻辑。实现 IDisposable 和终结器(析构函数)以处置任何非托管资源。
默认情况下,取消令牌会有五秒超时,以指示关闭进程不再正常。在令牌上请求取消时:
- 应中止应用正在执行的任何剩余后台操作。
StopAsync
中调用的任何方法都应及时返回。
但是,在请求取消后,将不会放弃任务 — 调用方等待所有任务完成。
如果应用意外关闭(例如,应用的进程失败),则可能不会调用 StopAsync
。因此,在 StopAsync
中执行的任何方法或操作都可能不会发生。
若要延长默认值为 5 秒的关闭超时值,请设置:
- ShutdownTimeout(当使用通用主机时)。有关详细信息,请参阅 .NET 通用主机。
- 使用 Web 主机时为关闭超时值主机配置设置。有关详细信息,请参阅 ASP.NET Core Web 主机。
托管服务在应用启动时激活一次,在应用关闭时正常关闭。如果在执行后台任务期间引发错误,即使未调用 StopAsync
,也应调用 Dispose
。
BackgroundService 基类BackgroundService base class
BackgroundService 是用于实现长时间运行的 IHostedService 的基类。
调用 ExecuteAsync(CancellationToken) 来运行后台服务。实现返回一个 Task,其表示后台服务的整个生存期。在 ExecuteAsync 变为异步(例如通过调用 await
)之前,不会启动任何其他服务。避免在 ExecuteAsync
中执行长时间的阻塞初始化工作。StopAsync(CancellationToken) 中的主机块等待完成 ExecuteAsync
。
调用 IHostedService.StopAsync 时,将触发取消令牌。当激发取消令牌以便正常关闭服务时,ExecuteAsync
的实现应立即完成。否则,服务将在关闭超时后不正常关闭。有关更多信息,请参阅 IHostedService interface 部分。
计时的后台任务Timed background tasks
定时后台任务使用 System.Threading.Timer 类。计时器触发任务的 DoWork
方法。在 StopAsync
上禁用计时器,并在 Dispose
上处置服务容器时处置计时器:
public class TimedHostedService : IHostedService, IDisposable
{
private int executionCount = 0;
private readonly ILogger<TimedHostedService> _logger;
private Timer _timer;
public TimedHostedService(ILogger<TimedHostedService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Timed Hosted Service running.");
_timer = new Timer(DoWork, null, TimeSpan.Zero,
TimeSpan.FromSeconds(5));
return Task.CompletedTask;
}
private void DoWork(object state)
{
var count = Interlocked.Increment(ref executionCount);
_logger.LogInformation(
"Timed Hosted Service is working. Count: {Count}", count);
}
public Task StopAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Timed Hosted Service is stopping.");
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
}
}
Timer 不等待先前的 DoWork
执行完成,因此所介绍的方法可能并不适用于所有场景。使用 Interlocked.Increment 以原子操作的形式将执行计数器递增,这可确保多个线程不会并行更新 executionCount
。
已使用 AddHostedService
扩展方法在 IHostBuilder.ConfigureServices
(Program.cs) 中注册该服务:
services.AddHostedService<TimedHostedService>();
在后台任务中使用有作用域的服务Consuming a scoped service in a background task
要在 BackgroundService 中使用有作用域的服务,请创建作用域。默认情况下,不会为托管服务创建作用域。
作用域后台任务服务包含后台任务的逻辑。如下示例中:
- 服务是异步的。
DoWork
方法返回Task
。出于演示目的,在DoWork
方法中等待 10 秒的延迟。 - ILogger 注入到服务中。
internal interface IScopedProcessingService
{
Task DoWork(CancellationToken stoppingToken);
}
internal class ScopedProcessingService : IScopedProcessingService
{
private int executionCount = 0;
private readonly ILogger _logger;
public ScopedProcessingService(ILogger<ScopedProcessingService> logger)
{
_logger = logger;
}
public async Task DoWork(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
executionCount++;
_logger.LogInformation(
"Scoped Processing Service is working. Count: {Count}", executionCount);
await Task.Delay(10000, stoppingToken);
}
}
}
托管服务创建一个作用域来解决作用域后台任务服务以调用其 DoWork
方法。DoWork
返回 ExecuteAsync
等待的 Task
:
public class ConsumeScopedServiceHostedService : BackgroundService
{
private readonly ILogger<ConsumeScopedServiceHostedService> _logger;
public ConsumeScopedServiceHostedService(IServiceProvider services,
ILogger<ConsumeScopedServiceHostedService> logger)
{
Services = services;
_logger = logger;
}
public IServiceProvider Services { get; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Consume Scoped Service Hosted Service running.");
await DoWork(stoppingToken);
}
private async Task DoWork(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Consume Scoped Service Hosted Service is working.");
using (var scope = Services.CreateScope())
{
var scopedProcessingService =
scope.ServiceProvider
.GetRequiredService<IScopedProcessingService>();
await scopedProcessingService.DoWork(stoppingToken);
}
}
public override async Task StopAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Consume Scoped Service Hosted Service is stopping.");
await Task.CompletedTask;
}
}
已在 IHostBuilder.ConfigureServices
(Program.cs) 中注册这些服务。已使用 AddHostedService
扩展方法注册托管服务:
services.AddHostedService<ConsumeScopedServiceHostedService>();
services.AddScoped<IScopedProcessingService, ScopedProcessingService>();
排队的后台任务Queued background tasks
后台任务队列基于 .NET 4.x QueueBackgroundWorkItem(暂定为 ASP.NET Core 内置版本):
public interface IBackgroundTaskQueue
{
void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem);
Task<Func<CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken);
}
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private ConcurrentQueue<Func<CancellationToken, Task>> _workItems =
new ConcurrentQueue<Func<CancellationToken, Task>>();
private SemaphoreSlim _signal = new SemaphoreSlim(0);
public void QueueBackgroundWorkItem(
Func<CancellationToken, Task> workItem)
{
if (workItem == null)
{
throw new ArgumentNullException(nameof(workItem));
}
_workItems.Enqueue(workItem);
_signal.Release();
}
public async Task<Func<CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken)
{
await _signal.WaitAsync(cancellationToken);
_workItems.TryDequeue(out var workItem);
return workItem;
}
}
在以下 QueueHostedService
示例中:
BackgroundProcessing
方法返回ExecuteAsync
中等待的Task
。- 在
BackgroundProcessing
中,取消排队并执行队列中的后台任务。 - 服务在
StopAsync
中停止之前,将等待工作项。
public class QueuedHostedService : BackgroundService
{
private readonly ILogger<QueuedHostedService> _logger;
public QueuedHostedService(IBackgroundTaskQueue taskQueue,
ILogger<QueuedHostedService> logger)
{
TaskQueue = taskQueue;
_logger = logger;
}
public IBackgroundTaskQueue TaskQueue { get; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
$"Queued Hosted Service is running.{Environment.NewLine}" +
$"{Environment.NewLine}Tap W to add a work item to the " +
$"background queue.{Environment.NewLine}");
await BackgroundProcessing(stoppingToken);
}
private async Task BackgroundProcessing(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem =
await TaskQueue.DequeueAsync(stoppingToken);
try
{
await workItem(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error occurred executing {WorkItem}.", nameof(workItem));
}
}
}
public override async Task StopAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Queued Hosted Service is stopping.");
await base.StopAsync(stoppingToken);
}
}
每当在输入设备上选择 w
键时,MonitorLoop
服务将处理托管服务的排队任务:
IBackgroundTaskQueue
注入到MonitorLoop
服务中。- 调用
IBackgroundTaskQueue.QueueBackgroundWorkItem
来将工作项排入队列。 - 工作项模拟长时间运行的后台任务:
- 将执行三次 5 秒的延迟 (
Task.Delay
)。 - 如果任务已取消,
try-catch
语句将捕获 OperationCanceledException。
- 将执行三次 5 秒的延迟 (
public class MonitorLoop
{
private readonly IBackgroundTaskQueue _taskQueue;
private readonly ILogger _logger;
private readonly CancellationToken _cancellationToken;
public MonitorLoop(IBackgroundTaskQueue taskQueue,
ILogger<MonitorLoop> logger,
IHostApplicationLifetime applicationLifetime)
{
_taskQueue = taskQueue;
_logger = logger;
_cancellationToken = applicationLifetime.ApplicationStopping;
}
public void StartMonitorLoop()
{
_logger.LogInformation("Monitor Loop is starting.");
// Run a console user input loop in a background thread
Task.Run(() => Monitor());
}
public void Monitor()
{
while (!_cancellationToken.IsCancellationRequested)
{
var keyStroke = Console.ReadKey();
if (keyStroke.Key == ConsoleKey.W)
{
// Enqueue a background work item
_taskQueue.QueueBackgroundWorkItem(async token =>
{
// Simulate three 5-second tasks to complete
// for each enqueued work item
int delayLoop = 0;
var guid = Guid.NewGuid().ToString();
_logger.LogInformation(
"Queued Background Task {Guid} is starting.", guid);
while (!token.IsCancellationRequested && delayLoop < 3)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(5), token);
}
catch (OperationCanceledException)
{
// Prevent throwing if the Delay is cancelled
}
delayLoop++;
_logger.LogInformation(
"Queued Background Task {Guid} is running. " +
"{DelayLoop}/3", guid, delayLoop);
}
if (delayLoop == 3)
{
_logger.LogInformation(
"Queued Background Task {Guid} is complete.", guid);
}
else
{
_logger.LogInformation(
"Queued Background Task {Guid} was cancelled.", guid);
}
});
}
}
}
}
已在 IHostBuilder.ConfigureServices
(Program.cs) 中注册这些服务。已使用 AddHostedService
扩展方法注册托管服务:
services.AddSingleton<MonitorLoop>();
services.AddHostedService<QueuedHostedService>();
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
已在 Program.Main
中启动 MontiorLoop
:
var monitorLoop = host.Services.GetRequiredService<MonitorLoop>();
monitorLoop.StartMonitorLoop();
在 ASP.NET Core 中,后台任务作为托管服务实现 。托管服务是一个类,具有实现 IHostedService 接口的后台任务逻辑。本主题提供了三个托管服务示例:
- 在计时器上运行的后台任务。
- 激活有作用域的服务的托管服务。有作用域的服务可使用依赖项注入 (DI)
- 按顺序运行的已排队后台任务。
PackagePackage
引用 Microsoft.AspNetCore.App 元包或将包引用添加到 Microsoft.Extensions.Hosting 包。
IHostedService 接口IHostedService interface
托管服务实现 IHostedService 接口。该接口为主机托管的对象定义了两种方法:
StartAsync(CancellationToken) –
StartAsync
包含启动后台任务的逻辑。当使用 Web 主机时,会在启动服务器并触发 IApplicationLifetime.ApplicationStarted 后调用StartAsync
。当使用通用主机时,会在触发ApplicationStarted
之前调用StartAsync
。StopAsync(CancellationToken) – 主机正常关闭时触发。
StopAsync
包含结束后台任务的逻辑。实现 IDisposable 和终结器(析构函数)以处置任何非托管资源。
默认情况下,取消令牌会有五秒超时,以指示关闭进程不再正常。在令牌上请求取消时:
- 应中止应用正在执行的任何剩余后台操作。
StopAsync
中调用的任何方法都应及时返回。
但是,在请求取消后,将不会放弃任务 — 调用方等待所有任务完成。
如果应用意外关闭(例如,应用的进程失败),则可能不会调用 StopAsync
。因此,在 StopAsync
中执行的任何方法或操作都可能不会发生。
若要延长默认值为 5 秒的关闭超时值,请设置:
- ShutdownTimeout(当使用通用主机时)。有关详细信息,请参阅 .NET 通用主机。
- 使用 Web 主机时为关闭超时值主机配置设置。有关详细信息,请参阅 ASP.NET Core Web 主机。
托管服务在应用启动时激活一次,在应用关闭时正常关闭。如果在执行后台任务期间引发错误,即使未调用 StopAsync
,也应调用 Dispose
。
计时的后台任务Timed background tasks
定时后台任务使用 System.Threading.Timer 类。计时器触发任务的 DoWork
方法。在 StopAsync
上禁用计时器,并在 Dispose
上处置服务容器时处置计时器:
internal class TimedHostedService : IHostedService, IDisposable
{
private readonly ILogger _logger;
private Timer _timer;
public TimedHostedService(ILogger<TimedHostedService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Timed Background Service is starting.");
_timer = new Timer(DoWork, null, TimeSpan.Zero,
TimeSpan.FromSeconds(5));
return Task.CompletedTask;
}
private void DoWork(object state)
{
_logger.LogInformation("Timed Background Service is working.");
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Timed Background Service is stopping.");
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
}
}
Timer 不等待先前的 DoWork
执行完成,因此所介绍的方法可能并不适用于所有场景。
已使用 AddHostedService
扩展方法在 Startup.ConfigureServices
中注册该服务:
services.AddHostedService<TimedHostedService>();
在后台任务中使用有作用域的服务Consuming a scoped service in a background task
要在 IHostedService
中使用有作用域的服务,请创建一个作用域。默认情况下,不会为托管服务创建作用域。
作用域后台任务服务包含后台任务的逻辑。在以下示例中,将 ILogger 注入到服务中:
internal interface IScopedProcessingService
{
void DoWork();
}
internal class ScopedProcessingService : IScopedProcessingService
{
private readonly ILogger _logger;
public ScopedProcessingService(ILogger<ScopedProcessingService> logger)
{
_logger = logger;
}
public void DoWork()
{
_logger.LogInformation("Scoped Processing Service is working.");
}
}
托管服务创建一个作用域来解决作用域后台任务服务以调用其 DoWork
方法:
internal class ConsumeScopedServiceHostedService : IHostedService
{
private readonly ILogger _logger;
public ConsumeScopedServiceHostedService(IServiceProvider services,
ILogger<ConsumeScopedServiceHostedService> logger)
{
Services = services;
_logger = logger;
}
public IServiceProvider Services { get; }
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(
"Consume Scoped Service Hosted Service is starting.");
DoWork();
return Task.CompletedTask;
}
private void DoWork()
{
_logger.LogInformation(
"Consume Scoped Service Hosted Service is working.");
using (var scope = Services.CreateScope())
{
var scopedProcessingService =
scope.ServiceProvider
.GetRequiredService<IScopedProcessingService>();
scopedProcessingService.DoWork();
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(
"Consume Scoped Service Hosted Service is stopping.");
return Task.CompletedTask;
}
}
已在 Startup.ConfigureServices
中注册这些服务。已使用 AddHostedService
扩展方法注册 IHostedService
实现:
services.AddHostedService<ConsumeScopedServiceHostedService>();
services.AddScoped<IScopedProcessingService, ScopedProcessingService>();
排队的后台任务Queued background tasks
后台任务队列基于 .NET Framework 4.x QueueBackgroundWorkItem(暂定为 ASP.NET Core 内置版本):
public interface IBackgroundTaskQueue
{
void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem);
Task<Func<CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken);
}
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private ConcurrentQueue<Func<CancellationToken, Task>> _workItems =
new ConcurrentQueue<Func<CancellationToken, Task>>();
private SemaphoreSlim _signal = new SemaphoreSlim(0);
public void QueueBackgroundWorkItem(
Func<CancellationToken, Task> workItem)
{
if (workItem == null)
{
throw new ArgumentNullException(nameof(workItem));
}
_workItems.Enqueue(workItem);
_signal.Release();
}
public async Task<Func<CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken)
{
await _signal.WaitAsync(cancellationToken);
_workItems.TryDequeue(out var workItem);
return workItem;
}
}
在 QueueHostedService
中,队列中的后台任务会取消排队,并作为 BackgroundService 执行,此类是用于实现长时间运行 IHostedService
的基类:
public class QueuedHostedService : BackgroundService
{
private readonly ILogger _logger;
public QueuedHostedService(IBackgroundTaskQueue taskQueue,
ILoggerFactory loggerFactory)
{
TaskQueue = taskQueue;
_logger = loggerFactory.CreateLogger<QueuedHostedService>();
}
public IBackgroundTaskQueue TaskQueue { get; }
protected async override Task ExecuteAsync(
CancellationToken cancellationToken)
{
_logger.LogInformation("Queued Hosted Service is starting.");
while (!cancellationToken.IsCancellationRequested)
{
var workItem = await TaskQueue.DequeueAsync(cancellationToken);
try
{
await workItem(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error occurred executing {WorkItem}.", nameof(workItem));
}
}
_logger.LogInformation("Queued Hosted Service is stopping.");
}
}
已在 Startup.ConfigureServices
中注册这些服务。已使用 AddHostedService
扩展方法注册 IHostedService
实现:
services.AddHostedService<QueuedHostedService>();
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
在索引页模型类中:
- 将
IBackgroundTaskQueue
注入构造函数并分配给Queue
。 - 注入 IServiceScopeFactory 并将其分配给
_serviceScopeFactory
。工厂用于创建 IServiceScope 的实例,用于在范围内创建服务。创建范围是为了使用应用的AppDbContext
(设置了范围的服务),以在IBackgroundTaskQueue
(单一实例服务)中写入数据库记录。
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
private readonly ILogger _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
public IndexModel(AppDbContext db, IBackgroundTaskQueue queue,
ILogger<IndexModel> logger, IServiceScopeFactory serviceScopeFactory)
{
_db = db;
_logger = logger;
Queue = queue;
_serviceScopeFactory = serviceScopeFactory;
}
public IBackgroundTaskQueue Queue { get; }
在索引页上选择“添加任务”按钮时,会执行 OnPostAddTask
方法 。调用 QueueBackgroundWorkItem
来将工作项排入队列:
public IActionResult OnPostAddTaskAsync()
{
Queue.QueueBackgroundWorkItem(async token =>
{
var guid = Guid.NewGuid().ToString();
using (var scope = _serviceScopeFactory.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<AppDbContext>();
for (int delayLoop = 1; delayLoop < 4; delayLoop++)
{
try
{
db.Messages.Add(
new Message()
{
Text = $"Queued Background Task {guid} has " +
$"written a step. {delayLoop}/3"
});
await db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex,
"An error occurred writing to the " +
"database. Error: {Message}", ex.Message);
}
await Task.Delay(TimeSpan.FromSeconds(5), token);
}
}
_logger.LogInformation(
"Queued Background Task {Guid} is complete. 3/3", guid);
});
return RedirectToPage();
}