From 06fe0e09ff1664d5dba8e84af556920a9af73e17 Mon Sep 17 00:00:00 2001 From: ngfrolov Date: Fri, 2 Dec 2022 10:57:27 +0500 Subject: [PATCH] BackgroundWorkerService cleanup and improve tests --- .../Background/BackgroundWorkerService.cs | 185 ++---------------- .../Services/Background/WorkBase.cs | 69 +++++++ .../Services/Background/WorkPeriodic.cs | 36 ++++ .../Services/Background/WorkQueue.cs | 101 ++++++++++ .../BackgroundWorkerServiceTest.cs | 116 ++++++++++- .../BackgroundWorkerService_WorkQueue_Test.cs | 109 ----------- 6 files changed, 327 insertions(+), 289 deletions(-) create mode 100644 AsbCloudInfrastructure/Services/Background/WorkBase.cs create mode 100644 AsbCloudInfrastructure/Services/Background/WorkPeriodic.cs create mode 100644 AsbCloudInfrastructure/Services/Background/WorkQueue.cs delete mode 100644 AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerService_WorkQueue_Test.cs diff --git a/AsbCloudInfrastructure/Services/Background/BackgroundWorkerService.cs b/AsbCloudInfrastructure/Services/Background/BackgroundWorkerService.cs index 4f940cfd..52bcbf6b 100644 --- a/AsbCloudInfrastructure/Services/Background/BackgroundWorkerService.cs +++ b/AsbCloudInfrastructure/Services/Background/BackgroundWorkerService.cs @@ -1,15 +1,16 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; namespace AsbCloudInfrastructure.Services.Background { # nullable enable + /// + /// Сервис для фонового выполнения работы + /// public class BackgroundWorkerService : BackgroundService { private static readonly TimeSpan executePeriod = TimeSpan.FromSeconds(10); @@ -34,6 +35,16 @@ namespace AsbCloudInfrastructure.Services.Background workQueue.Push(work); } + /// + /// Удаление работы по ID + /// + /// + /// + public bool Delete(string id) + { + return workQueue.Delete(id); + } + protected override async Task ExecuteAsync(CancellationToken token) { while (!token.IsCancellationRequested) @@ -73,175 +84,5 @@ namespace AsbCloudInfrastructure.Services.Background } } } - - /// - /// - /// Очередь работ - /// - /// Не периодические задачи будут возвращаться первыми, как самые приоритетные. - /// - public class WorkQueue - { - private Queue Primary = new (8); - private readonly List Periodic = new (8); - internal TimeSpan MaxTimeToNextWork { get; set; } = TimeSpan.FromSeconds(20); - - /// - /// Добавление работы. - /// - /// - /// 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; - } - - /// - /// - /// Возвращает приоритетную задачу. - /// - /// - /// Если приоритетные закончились, то ищет ближайшую периодическую. - /// Если до старта ближайшей периодической работы меньше 20 сек, - /// то этой задаче устанавливается время последнего запуска в now и она возвращается. - /// Если больше 20 сек, то возвращается null. - /// - /// - /// - /// - public WorkBase? Pop(TimeSpan? maxTimeToNextWork = null) - { - if (Primary.Any()) - return Primary.Dequeue(); - - var maxTimeToNextWorkLocal = maxTimeToNextWork ?? MaxTimeToNextWork; - var work = GetNextPeriodic(); - if (work is null || work.NextStart - DateTime.Now > maxTimeToNextWorkLocal) - return null; - - work.LastStart = DateTime.Now; - return work; - } - - private WorkPeriodic? GetNextPeriodic() - { - var work = Periodic - .OrderBy(w => w.NextStart) - .FirstOrDefault(); - return work; - } - } - - public class WorkBase - { - /// - /// Идентификатор работы. Должен быть уникальным. Используется в логах и передается в колбэки. - /// - public string Id { get; private set; } - - /// - /// Делегат работы. - /// - /// Параметры: - /// - /// - /// string - /// Id Идентификатор работы - /// - /// - /// IServiceProvider - /// Поставщик сервисов - /// - /// - /// CancellationToken - /// Токен отмены задачи - /// - /// - /// - /// - public Func ActionAsync { get; set; } - - /// - /// Делегат обработки ошибки. - /// Не должен выполняться долго. - /// - public Func? OnErrorAsync { get; set; } - - /// - /// максимально допустимое время выполнения работы - /// - public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1); - - /// - /// Фактическое время успешного выполнения работы - /// - public TimeSpan? ExecutionTime { get; set; } - - /// - /// Время последнего запуска - /// - public DateTime LastStart { get; set; } - - public WorkBase(string id, Func actionAsync) - { - Id = id; - ActionAsync = actionAsync; - } - } - - 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/Services/Background/WorkBase.cs b/AsbCloudInfrastructure/Services/Background/WorkBase.cs new file mode 100644 index 00000000..7f305bea --- /dev/null +++ b/AsbCloudInfrastructure/Services/Background/WorkBase.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AsbCloudInfrastructure.Services.Background +{ +#nullable enable + /// + /// Класс разовой работы. + /// Разовая работа приоритетнее периодической. + /// + public class WorkBase + { + /// + /// Идентификатор работы. Должен быть уникальным. Используется в логах и передается в колбэки. + /// + public string Id { get; private set; } + + /// + /// Делегат работы. + /// + /// Параметры: + /// + /// + /// string + /// Id Идентификатор работы + /// + /// + /// IServiceProvider + /// Поставщик сервисов + /// + /// + /// CancellationToken + /// Токен отмены задачи + /// + /// + /// + /// + public Func ActionAsync { get; set; } + + /// + /// Делегат обработки ошибки. + /// Не должен выполняться долго. + /// + public Func? OnErrorAsync { get; set; } + + /// + /// максимально допустимое время выполнения работы + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Фактическое время успешного выполнения работы + /// + public TimeSpan? ExecutionTime { get; set; } + + /// + /// Время последнего запуска + /// + public DateTime LastStart { get; set; } + + public WorkBase(string id, Func actionAsync) + { + Id = id; + ActionAsync = actionAsync; + } + } +#nullable disable +} diff --git a/AsbCloudInfrastructure/Services/Background/WorkPeriodic.cs b/AsbCloudInfrastructure/Services/Background/WorkPeriodic.cs new file mode 100644 index 00000000..288a37ca --- /dev/null +++ b/AsbCloudInfrastructure/Services/Background/WorkPeriodic.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AsbCloudInfrastructure.Services.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/Services/Background/WorkQueue.cs b/AsbCloudInfrastructure/Services/Background/WorkQueue.cs new file mode 100644 index 00000000..90ce7ec7 --- /dev/null +++ b/AsbCloudInfrastructure/Services/Background/WorkQueue.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AsbCloudInfrastructure.Services.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; + } + + /// + /// + /// Возвращает приоритетную задачу. + /// + /// + /// Если приоритетные закончились, то ищет ближайшую периодическую. + /// Если до старта ближайшей периодической работы меньше 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 3aad83ad..a2feb973 100644 --- a/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerServiceTest.cs +++ b/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerServiceTest.cs @@ -5,7 +5,6 @@ using AsbCloudInfrastructure.Services.Background; using System.Threading; using System.Threading.Tasks; using Xunit; -using Org.BouncyCastle.Asn1.X509.Qualified; namespace AsbCloudWebApi.Tests.ServicesTests { @@ -13,6 +12,8 @@ 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() { @@ -22,7 +23,7 @@ namespace AsbCloudWebApi.Tests.ServicesTests mockServiceScope.SetReturnsDefault(mockServiceProvider.Object); mockServiceProvider.SetReturnsDefault(mockServiceScopeFactory.Object); - mockServiceProvider.Setup(s=>s.GetService(It.IsAny())) + mockServiceProvider.Setup(s => s.GetService(It.IsAny())) .Returns(mockServiceScopeFactory.Object); mockServiceScopeFactory.SetReturnsDefault(mockServiceScope.Object); } @@ -32,8 +33,8 @@ namespace AsbCloudWebApi.Tests.ServicesTests { mockServiceScopeFactory.Invocations.Clear(); - var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); - var work = new WorkBase("", (_, _, _) => Task.CompletedTask ); + var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); + var work = new WorkBase("", (_, _, _) => Task.CompletedTask); backgroundService.Push(work); await backgroundService.StartAsync(CancellationToken.None); await Task.Delay(10); @@ -42,7 +43,7 @@ namespace AsbCloudWebApi.Tests.ServicesTests } [Fact] - public async Task Push_makes_primary_work_done() + public async Task Makes_primary_work_done() { var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); var workDone = false; @@ -59,7 +60,19 @@ namespace AsbCloudWebApi.Tests.ServicesTests } [Fact] - public async Task Push_makes_pperiodic_work_done() + public async Task Sets_ExecutionTime_after_work_done() + { + var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); + var work = new WorkBase("", someAction); + backgroundService.Push(work); + await backgroundService.StartAsync(CancellationToken.None); + await Task.Delay(10); + + Assert.True(work.ExecutionTime > TimeSpan.Zero); + } + + [Fact] + public async Task Makes_periodic_work_done() { var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); var workDone = false; @@ -70,6 +83,80 @@ namespace AsbCloudWebApi.Tests.ServicesTests }, TimeSpan.FromMilliseconds(10)); backgroundService.Push(work); + await backgroundService.StartAsync(CancellationToken.None); + await Task.Delay(20); + + Assert.True(workDone); + } + + [Fact] + public async Task Does_not_start_periodic_work() + { + var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); + var workDone = false; + var work = new WorkPeriodic("", (_, _, _) => + { + workDone = true; + return Task.CompletedTask; + }, + TimeSpan.FromSeconds(30)); + work.LastStart = DateTime.Now; + backgroundService.Push(work); + + await backgroundService.StartAsync(CancellationToken.None); + await Task.Delay(20); + + Assert.False(workDone); + } + + [Fact] + public async Task Follows_work_priority() + { + var order = 0; + var work1Order = -1; + var work2Order = -1; + + var work1 = new WorkPeriodic("1", (_, _, _) => + { + work1Order = order++; + return Task.CompletedTask; + }, + TimeSpan.FromMilliseconds(1) + ); + + var work2 = new WorkBase("2", (_, _, _) => + { + work2Order = order++; + return Task.CompletedTask; + }); + + var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); + backgroundService.Push(work2); + backgroundService.Push(work1); + + await backgroundService.StartAsync(CancellationToken.None); + await Task.Delay(2_100); + + Assert.True(work2Order < work1Order); + } + + [Fact] + public async Task Runs_second_after_delete_first() + { + var workDone = false; + + var work1 = new WorkBase("1", someAction); + var work2 = new WorkPeriodic("2", (_, _, _) => + { + workDone = true; + return Task.CompletedTask; + }, TimeSpan.FromMilliseconds(1)); + + var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); + backgroundService.Push(work1); + backgroundService.Push(work2); + backgroundService.Delete("1"); + await backgroundService.StartAsync(CancellationToken.None); await Task.Delay(10); @@ -81,7 +168,7 @@ namespace AsbCloudWebApi.Tests.ServicesTests { var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); var workCanceled = false; - var work = new WorkBase("", async(_, _, _) => await Task.Delay(1000000)); + var work = new WorkBase("", async (_, _, _) => await Task.Delay(1000000)); work.Timeout = TimeSpan.FromMilliseconds(1); work.OnErrorAsync = async (id, ex, token) => { @@ -91,7 +178,7 @@ namespace AsbCloudWebApi.Tests.ServicesTests backgroundService.Push(work); await backgroundService.StartAsync(CancellationToken.None); - await Task.Delay(20*4); + await Task.Delay(20 * 4); Assert.True(workCanceled); } @@ -116,5 +203,18 @@ namespace AsbCloudWebApi.Tests.ServicesTests Assert.True(work2done); } + + [Fact] + public void Push_not_unique_id_should_throw() + { + var work1 = new WorkPeriodic("1", someAction, TimeSpan.FromSeconds(30)); + var work2 = new WorkBase("1", someAction); + + var backgroundService = new BackgroundWorkerService(mockServiceProvider.Object); + backgroundService.Push(work1); + + Assert.Throws( + () => backgroundService.Push(work2)); + } } } diff --git a/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerService_WorkQueue_Test.cs b/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerService_WorkQueue_Test.cs deleted file mode 100644 index 1807e24a..00000000 --- a/AsbCloudWebApi.Tests/ServicesTests/BackgroundWorkerService_WorkQueue_Test.cs +++ /dev/null @@ -1,109 +0,0 @@ -using AsbCloudInfrastructure.Services.Background; -using System; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace AsbCloudWebApi.Tests.ServicesTests -{ - public class BackgroundWorkerService_WorkQueue_Test - { - private readonly TimeSpan period = TimeSpan.FromSeconds(10); - private readonly Func somAction = (string id, IServiceProvider scope, CancellationToken token) => Task.CompletedTask; - - [Fact] - public void Push_not_unique_id_should_throw() - { - var work1 = new WorkPeriodic("1", somAction, TimeSpan.FromSeconds(30)); - var work2 = new WorkBase("1", somAction); - - var queue = new WorkQueue(); - queue.Push(work1); - - Assert.Throws( - () => queue.Push(work2)); - } - - [Fact] - public void Pop_should_return_null() - { - var work1 = new WorkPeriodic("1", somAction, TimeSpan.FromSeconds(30)) - { LastStart = DateTime.Now }; - - var queue = new WorkQueue(); - queue.Push(work1); - var workpoPoped= queue.Pop(); - - Assert.Null(workpoPoped); - } - - [Fact] - public void Pop_primary_first() - { - var work1 = new WorkBase("1", somAction); - var work2 = new WorkPeriodic("1", somAction, period); - - var queue = new WorkQueue(); - queue.Push(work2); - queue.Push(work1); - var workpoPoped= queue.Pop(); - - Assert.Equal(work1, workpoPoped); - } - - [Fact] - public void Pop_second_after_delete_first() - { - var work1 = new WorkPeriodic("1", somAction, period); - var work2 = new WorkPeriodic("2", somAction, period); - - var queue = new WorkQueue(); - queue.Push(work1); - queue.Push(work2); - queue.Delete("1"); - - var workpoPoped= queue.Pop(); - - Assert.Equal(work2, workpoPoped); - } - - [Fact] - public void Pop_closest_to_nextStart() - { - var work1 = new WorkPeriodic("1", somAction, period) { - LastStart = DateTime.Now, - }; - var work2 = new WorkPeriodic("2", somAction, period); - - var queue = new WorkQueue(); - queue.Push(work1); - queue.Push(work2); - - var workpoPoped= queue.Pop(); - - Assert.Equal(work2, workpoPoped); - } - - [Fact] - public void Pop_closest_to_explicit_nextStart() - { - var baseTime = DateTime.Now - period; - var work1 = new WorkPeriodic("1", somAction, period) - { - LastStart = baseTime - TimeSpan.FromSeconds(-1), - }; - var work2 = new WorkPeriodic("2", somAction, period) - { - LastStart = baseTime, - }; - - var queue = new WorkQueue(); - queue.Push(work1); - queue.Push(work2); - - var workpoPoped= queue.Pop(); - - Assert.Equal(work2, workpoPoped); - } - } -}