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);