diff --git a/AsbCloudInfrastructure/Background/BackgroundWorkerService.cs b/AsbCloudInfrastructure/Background/BackgroundWorkerService.cs
new file mode 100644
index 00000000..e823284d
--- /dev/null
+++ b/AsbCloudInfrastructure/Background/BackgroundWorkerService.cs
@@ -0,0 +1,100 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AsbCloudInfrastructure.Background
+{
+# nullable enable
+ ///
+ /// Сервис для фонового выполнения работы
+ ///
+ public class BackgroundWorker : BackgroundService
+ {
+ private static readonly TimeSpan executePeriod = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan minDelay = TimeSpan.FromSeconds(2);
+ private static readonly TimeSpan exceptionHandleTimeout = TimeSpan.FromSeconds(2);
+ private readonly IServiceProvider serviceProvider;
+ private readonly WorkQueue workQueue = new WorkQueue();
+
+ public BackgroundWorker(IServiceProvider serviceProvider)
+ {
+ this.serviceProvider = serviceProvider;
+#warning move StartAsync(CancellationToken.None).Wait() to THE factory
+ Task.Delay(1_000)
+ .ContinueWith(_=> StartAsync(CancellationToken.None).Wait());
+ }
+
+ ///
+ /// Добавление задачи в очередь.
+ /// Не периодические задачи будут выполняться вперед.
+ ///
+ ///
+ /// Id mast be unique
+ public void Push(WorkBase work)
+ {
+ workQueue.Push(work);
+ }
+
+ ///
+ /// Проверяет наличие работы с указанным Id
+ ///
+ ///
+ ///
+ public bool Contains(string id)
+ {
+ return workQueue.Contains(id);
+ }
+
+ ///
+ /// Удаление работы по ID
+ ///
+ ///
+ ///
+ public bool Delete(string id)
+ {
+ return workQueue.Delete(id);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken token)
+ {
+ while (!token.IsCancellationRequested)
+ {
+ var dateStart = DateTime.Now;
+ var work = workQueue.Pop();
+ if (work is null)
+ {
+ await Task.Delay(executePeriod, token);
+ continue;
+ }
+
+ using var scope = serviceProvider.CreateScope();
+
+ try
+ {
+ var task = work.ActionAsync(work.Id, scope.ServiceProvider, token);
+ await task.WaitAsync(work.Timeout, token);
+
+ work.ExecutionTime = DateTime.Now - dateStart;
+ Trace.TraceInformation($"Backgroud work:\"{work.Id}\" done. ExecutionTime: {work.ExecutionTime:hh\\:mm\\:ss\\.fff}");
+ }
+ catch (Exception exception)
+ {
+ Trace.TraceError($"Backgroud work:\"{work.Id}\" throw exception: {exception.Message}");
+ if (work.OnErrorAsync is not null)
+ {
+ using var task = Task.Run(
+ async () => await work.OnErrorAsync(work.Id, exception, token),
+ token);
+ await task.WaitAsync(exceptionHandleTimeout, token);
+ }
+ }
+
+ await Task.Delay(minDelay, token);
+ }
+ }
+ }
+#nullable disable
+}
diff --git a/AsbCloudInfrastructure/Background/WorkBase.cs b/AsbCloudInfrastructure/Background/WorkBase.cs
new file mode 100644
index 00000000..ce07a2fa
--- /dev/null
+++ b/AsbCloudInfrastructure/Background/WorkBase.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AsbCloudInfrastructure.Background
+{
+#nullable enable
+ ///
+ /// Класс разовой работы.
+ /// Разовая работа приоритетнее периодической.
+ ///
+ public class WorkBase
+ {
+ ///
+ /// Идентификатор работы. Должен быть уникальным. Используется в логах и передается в колбэки.
+ ///
+ public string Id { get; private set; }
+
+ ///
+ /// Делегат работы.
+ ///
+ /// Параметры:
+ ///
+ /// -
+ /// string
+ /// Id Идентификатор работы
+ ///
+ /// -
+ /// IServiceProvider
+ /// Поставщик сервисов
+ ///
+ /// -
+ /// CancellationToken
+ /// Токен отмены задачи
+ ///
+ ///
+ ///
+ ///
+ internal Func ActionAsync { get; set; }
+
+ ///
+ /// Делегат обработки ошибки.
+ /// Не должен выполняться долго.
+ ///
+ public Func? OnErrorAsync { get; set; }
+
+ ///
+ /// максимально допустимое время выполнения работы
+ ///
+ public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);
+
+ ///
+ /// Фактическое время успешного выполнения работы
+ ///
+ public TimeSpan? ExecutionTime { get; internal set; }
+
+ ///
+ /// Время последнего запуска
+ ///
+ public DateTime LastStart { get; set; }
+
+ public WorkBase(string id, Func actionAsync)
+ {
+ Id = id;
+ ActionAsync = actionAsync;
+ }
+ }
+#nullable disable
+}
diff --git a/AsbCloudInfrastructure/Background/WorkPeriodic.cs b/AsbCloudInfrastructure/Background/WorkPeriodic.cs
new file mode 100644
index 00000000..ae29ee78
--- /dev/null
+++ b/AsbCloudInfrastructure/Background/WorkPeriodic.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AsbCloudInfrastructure.Background
+{
+#nullable enable
+ ///
+ /// Класс периодической работы.
+ ///
+ public class WorkPeriodic : WorkBase
+ {
+ ///
+ /// Период выполнения задачи
+ ///
+ public TimeSpan Period { get; set; }
+
+ ///
+ /// Время следующего запуска
+ ///
+ public DateTime NextStart => LastStart + Period;
+
+ ///
+ /// Класс периодической работы
+ ///
+ /// Идентификатор работы. Должен быть уникальным. Используется в логах и передается в колбэки
+ /// Делегат работы
+ /// Период выполнения задачи
+ public WorkPeriodic(string id, Func actionAsync, TimeSpan period)
+ : base(id, actionAsync)
+ {
+ Period = period;
+ }
+ }
+#nullable disable
+}
diff --git a/AsbCloudInfrastructure/Background/WorkQueue.cs b/AsbCloudInfrastructure/Background/WorkQueue.cs
new file mode 100644
index 00000000..5521d373
--- /dev/null
+++ b/AsbCloudInfrastructure/Background/WorkQueue.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace AsbCloudInfrastructure.Background
+{
+#nullable enable
+ ///
+ ///
+ /// Очередь работ
+ ///
+ /// Не периодические задачи будут возвращаться первыми, как самые приоритетные.
+ ///
+ class WorkQueue
+ {
+ private Queue Primary = new(8);
+ private readonly List Periodic = new(8);
+
+ ///
+ /// Добавление работы.
+ ///
+ ///
+ /// Id mast be unique
+ public void Push(WorkBase work)
+ {
+ if (Periodic.Any(w => w.Id == work.Id))
+ throw new ArgumentException("work.Id is not unique", nameof(work));
+
+ if (Primary.Any(w => w.Id == work.Id))
+ throw new ArgumentException("work.Id is not unique", nameof(work));
+
+ if (work is WorkPeriodic workPeriodic)
+ {
+ Periodic.Add(workPeriodic);
+ return;
+ }
+
+ Primary.Enqueue(work);
+ }
+
+ ///
+ /// Удаление работы по ID
+ ///
+ ///
+ ///
+ public bool Delete(string id)
+ {
+ var workPeriodic = Periodic.FirstOrDefault(w => w.Id == id);
+ if (workPeriodic is not null)
+ {
+ Periodic.Remove(workPeriodic);
+ return true;
+ }
+
+ var work = Primary.FirstOrDefault(w => w.Id == id);
+ if (work is not null)
+ {
+ Primary = new Queue(Primary.Where(w => w.Id != id));
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool Contains(string id)
+ {
+ var result = Periodic.Any(w => w.Id == id) || Primary.Any(w => w.Id == id);
+ return result;
+ }
+
+ ///
+ ///
+ /// Возвращает приоритетную задачу.
+ ///
+ ///
+ /// Если приоритетные закончились, то ищет ближайшую периодическую.
+ /// Если до старта ближайшей периодической работы меньше 20 сек,
+ /// то этой задаче устанавливается время последнего запуска в now и она возвращается.
+ /// Если больше 20 сек, то возвращается null.
+ ///
+ ///
+ ///
+ ///
+ public WorkBase? Pop()
+ {
+ if (Primary.Any())
+ return Primary.Dequeue();
+
+ var work = GetNextPeriodic();
+ if (work is null || work.NextStart > DateTime.Now)
+ return null;
+
+ work.LastStart = DateTime.Now;
+ return work;
+ }
+
+ private WorkPeriodic? GetNextPeriodic()
+ {
+ var work = Periodic
+ .OrderBy(w => w.NextStart)
+ .ThenByDescending(w => w.Period)
+ .FirstOrDefault();
+ return work;
+ }
+ }
+#nullable disable
+}
diff --git a/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerServiceTest.cs b/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerServiceTest.cs
index a2feb973..24b8e999 100644
--- a/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerServiceTest.cs
+++ b/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerServiceTest.cs
@@ -12,7 +12,6 @@ namespace AsbCloudWebApi.Tests.ServicesTests
{
private readonly Mock mockServiceProvider;
private readonly Mock mockServiceScopeFactory;
- private readonly TimeSpan period = TimeSpan.FromSeconds(10);
private readonly Func someAction = (string id, IServiceProvider scope, CancellationToken token) => Task.CompletedTask;
public BackgroundWorkerServiceTest()
@@ -28,13 +27,34 @@ namespace AsbCloudWebApi.Tests.ServicesTests
mockServiceScopeFactory.SetReturnsDefault(mockServiceScope.Object);
}
+ [Fact]
+ public void Contains_returns_true()
+ {
+ mockServiceScopeFactory.Invocations.Clear();
+
+ var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object);
+ const string work1Id = "long name 1";
+ const string work2Id = "long name 2";
+
+ var work1 = new WorkBase(work1Id, someAction);
+ var work2 = new WorkPeriodic(work2Id, someAction, TimeSpan.Zero);
+
+ backgroundService.Push(work1);
+ backgroundService.Push(work2);
+
+ Assert.True(backgroundService.Contains(work1Id));
+ Assert.True(backgroundService.Contains(work2Id));
+ Assert.False(backgroundService.Contains(work2Id + work1Id));
+ Assert.False(backgroundService.Contains(string.Empty));
+ }
+
[Fact]
public async Task Push_makes_new_scope_after_start()
{
mockServiceScopeFactory.Invocations.Clear();
var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object);
- var work = new WorkBase("", (_, _, _) => Task.CompletedTask);
+ var work = new WorkBase("", someAction);
backgroundService.Push(work);
await backgroundService.StartAsync(CancellationToken.None);
await Task.Delay(10);
@@ -99,8 +119,10 @@ namespace AsbCloudWebApi.Tests.ServicesTests
workDone = true;
return Task.CompletedTask;
},
- TimeSpan.FromSeconds(30));
- work.LastStart = DateTime.Now;
+ TimeSpan.FromSeconds(30))
+ {
+ LastStart = DateTime.Now
+ };
backgroundService.Push(work);
await backgroundService.StartAsync(CancellationToken.None);
@@ -168,12 +190,14 @@ namespace AsbCloudWebApi.Tests.ServicesTests
{
var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object);
var workCanceled = false;
- var work = new WorkBase("", async (_, _, _) => await Task.Delay(1000000));
- work.Timeout = TimeSpan.FromMilliseconds(1);
- work.OnErrorAsync = async (id, ex, token) =>
+ var work = new WorkBase("", async (_, _, token) => await Task.Delay(1000000, token))
{
- workCanceled = ex is System.TimeoutException;
- await Task.CompletedTask;
+ Timeout = TimeSpan.FromMilliseconds(1),
+ OnErrorAsync = async (id, ex, token) =>
+ {
+ workCanceled = ex is System.TimeoutException;
+ await Task.CompletedTask;
+ }
};
backgroundService.Push(work);