BackgroundWorkerService cleanup and improve tests

This commit is contained in:
ngfrolov 2022-12-02 10:57:27 +05:00
parent f61db91dd2
commit 06fe0e09ff
6 changed files with 327 additions and 289 deletions

View File

@ -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
/// <summary>
/// Сервис для фонового выполнения работы
/// </summary>
public class BackgroundWorkerService : BackgroundService
{
private static readonly TimeSpan executePeriod = TimeSpan.FromSeconds(10);
@ -34,6 +35,16 @@ namespace AsbCloudInfrastructure.Services.Background
workQueue.Push(work);
}
/// <summary>
/// Удаление работы по ID
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
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
}
}
}
/// <summary>
/// <para>
/// Очередь работ
/// </para>
/// Не периодические задачи будут возвращаться первыми, как самые приоритетные.
/// </summary>
public class WorkQueue
{
private Queue<WorkBase> Primary = new (8);
private readonly List<WorkPeriodic> Periodic = new (8);
internal TimeSpan MaxTimeToNextWork { get; set; } = TimeSpan.FromSeconds(20);
/// <summary>
/// Добавление работы.
/// </summary>
/// <param name="work"></param>
/// <exception cref="ArgumentException">Id mast be unique</exception>
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);
}
/// <summary>
/// Удаление работы по ID
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
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<WorkBase>(Primary.Where(w => w.Id != id));
return true;
}
return false;
}
/// <summary>
/// <para>
/// Возвращает приоритетную задачу.
/// </para>
/// <para>
/// Если приоритетные закончились, то ищет ближайшую периодическую.
/// Если до старта ближайшей периодической работы меньше 20 сек,
/// то этой задаче устанавливается время последнего запуска в now и она возвращается.
/// Если больше 20 сек, то возвращается null.
/// </para>
/// </summary>
/// <param name="maxTimeToNextWork"></param>
/// <returns></returns>
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
{
/// <summary>
/// Идентификатор работы. Должен быть уникальным. Используется в логах и передается в колбэки.
/// </summary>
public string Id { get; private set; }
/// <summary>
/// Делегат работы.
/// <para>
/// Параметры:
/// <list type="number">
/// <item>
/// <term>string</term>
/// <description>Id Идентификатор работы</description>
/// </item>
/// <item>
/// <term>IServiceProvider</term>
/// <description>Поставщик сервисов</description>
/// </item>
/// <item>
/// <term>CancellationToken</term>
/// <description>Токен отмены задачи</description>
/// </item>
/// </list>
/// </para>
/// </summary>
public Func<string, IServiceProvider, CancellationToken, Task> ActionAsync { get; set; }
/// <summary>
/// Делегат обработки ошибки.
/// Не должен выполняться долго.
/// </summary>
public Func<string, Exception, CancellationToken, Task>? OnErrorAsync { get; set; }
/// <summary>
/// максимально допустимое время выполнения работы
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Фактическое время успешного выполнения работы
/// </summary>
public TimeSpan? ExecutionTime { get; set; }
/// <summary>
/// Время последнего запуска
/// </summary>
public DateTime LastStart { get; set; }
public WorkBase(string id, Func<string, IServiceProvider, CancellationToken, Task> actionAsync)
{
Id = id;
ActionAsync = actionAsync;
}
}
public class WorkPeriodic : WorkBase
{
/// <summary>
/// Период выполнения задачи
/// </summary>
public TimeSpan Period { get; set; }
/// <summary>
/// Время следующего запуска
/// </summary>
public DateTime NextStart => LastStart + Period;
public WorkPeriodic(string id, Func<string, IServiceProvider, CancellationToken, Task> actionAsync, TimeSpan period)
:base(id, actionAsync)
{
Period = period;
}
}
#nullable disable
}

View File

@ -0,0 +1,69 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace AsbCloudInfrastructure.Services.Background
{
#nullable enable
/// <summary>
/// Класс разовой работы.
/// Разовая работа приоритетнее периодической.
/// </summary>
public class WorkBase
{
/// <summary>
/// Идентификатор работы. Должен быть уникальным. Используется в логах и передается в колбэки.
/// </summary>
public string Id { get; private set; }
/// <summary>
/// Делегат работы.
/// <para>
/// Параметры:
/// <list type="number">
/// <item>
/// <term>string</term>
/// <description>Id Идентификатор работы</description>
/// </item>
/// <item>
/// <term>IServiceProvider</term>
/// <description>Поставщик сервисов</description>
/// </item>
/// <item>
/// <term>CancellationToken</term>
/// <description>Токен отмены задачи</description>
/// </item>
/// </list>
/// </para>
/// </summary>
public Func<string, IServiceProvider, CancellationToken, Task> ActionAsync { get; set; }
/// <summary>
/// Делегат обработки ошибки.
/// Не должен выполняться долго.
/// </summary>
public Func<string, Exception, CancellationToken, Task>? OnErrorAsync { get; set; }
/// <summary>
/// максимально допустимое время выполнения работы
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Фактическое время успешного выполнения работы
/// </summary>
public TimeSpan? ExecutionTime { get; set; }
/// <summary>
/// Время последнего запуска
/// </summary>
public DateTime LastStart { get; set; }
public WorkBase(string id, Func<string, IServiceProvider, CancellationToken, Task> actionAsync)
{
Id = id;
ActionAsync = actionAsync;
}
}
#nullable disable
}

View File

@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace AsbCloudInfrastructure.Services.Background
{
#nullable enable
/// <summary>
/// Класс периодической работы.
/// </summary>
public class WorkPeriodic : WorkBase
{
/// <summary>
/// Период выполнения задачи
/// </summary>
public TimeSpan Period { get; set; }
/// <summary>
/// Время следующего запуска
/// </summary>
public DateTime NextStart => LastStart + Period;
/// <summary>
/// Класс периодической работы
/// </summary>
/// <param name="id">Идентификатор работы. Должен быть уникальным. Используется в логах и передается в колбэки</param>
/// <param name="actionAsync">Делегат работы</param>
/// <param name="period">Период выполнения задачи</param>
public WorkPeriodic(string id, Func<string, IServiceProvider, CancellationToken, Task> actionAsync, TimeSpan period)
:base(id, actionAsync)
{
Period = period;
}
}
#nullable disable
}

View File

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AsbCloudInfrastructure.Services.Background
{
#nullable enable
/// <summary>
/// <para>
/// Очередь работ
/// </para>
/// Не периодические задачи будут возвращаться первыми, как самые приоритетные.
/// </summary>
class WorkQueue
{
private Queue<WorkBase> Primary = new (8);
private readonly List<WorkPeriodic> Periodic = new (8);
/// <summary>
/// Добавление работы.
/// </summary>
/// <param name="work"></param>
/// <exception cref="ArgumentException">Id mast be unique</exception>
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);
}
/// <summary>
/// Удаление работы по ID
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
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<WorkBase>(Primary.Where(w => w.Id != id));
return true;
}
return false;
}
/// <summary>
/// <para>
/// Возвращает приоритетную задачу.
/// </para>
/// <para>
/// Если приоритетные закончились, то ищет ближайшую периодическую.
/// Если до старта ближайшей периодической работы меньше 20 сек,
/// то этой задаче устанавливается время последнего запуска в now и она возвращается.
/// Если больше 20 сек, то возвращается null.
/// </para>
/// </summary>
/// <param name="maxTimeToNextWork"></param>
/// <returns></returns>
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
}

View File

@ -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<IServiceProvider> mockServiceProvider;
private readonly Mock<IServiceScopeFactory> mockServiceScopeFactory;
private readonly TimeSpan period = TimeSpan.FromSeconds(10);
private readonly Func<string, IServiceProvider, CancellationToken, Task> 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<Type>()))
mockServiceProvider.Setup(s => s.GetService(It.IsAny<Type>()))
.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<ArgumentException>(
() => backgroundService.Push(work2));
}
}
}

View File

@ -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<string, IServiceProvider, CancellationToken, Task> 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<ArgumentException>(
() => 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);
}
}
}