feat(core, dal): WorkloadCacheService - методы для работы с кэшем отчетности workload

This commit is contained in:
Mikhail Trubnikov
2026-05-05 14:52:17 +10:00
parent a89605d02a
commit 7d42a80556
10 changed files with 380 additions and 27 deletions

View File

@@ -8,6 +8,7 @@ using PARR.API.Settings;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.Workload.Implementations;
namespace PARR.API.Controllers.V1
{
@@ -18,19 +19,22 @@ namespace PARR.API.Controllers.V1
private readonly IRedisCacheService redisCacheService;
private readonly INextRunService nextRunService;
private readonly ITemplateRepository templateService;
private readonly WorkloadCacheService workloadCacheService;
public TestController(
IClientService clientService,
IRedisCacheService redisCacheService,
INextRunService nextRunService,
ITemplateRepository templateService,
MqSettings mqSettings
MqSettings mqSettings,
WorkloadCacheService workloadCacheService
)
{
this.clientService = clientService;
this.redisCacheService = redisCacheService;
this.nextRunService = nextRunService;
this.templateService = templateService;
this.workloadCacheService = workloadCacheService;
}
@@ -74,6 +78,9 @@ namespace PARR.API.Controllers.V1
[HttpPost(ApiRoutes.Test.CreateCache)]
public async Task<IActionResult> CreateCache()
{
// var aaa = await workloadCacheService.GetTemplateReportDataAsync();
//var val = await redisCacheService.GetCachedDataAsync<object>("96dAoIH/8Q9Bg8VY");
//var hashKey = redisCacheService.GetKey(new[] { "test", "mxa" });

View File

@@ -62,6 +62,15 @@
string GetKey(string[] keyParts, string[]? keyPartsToHash = null);
/// <summary>
/// Удалить ключи по маске (template*)
/// </summary>
/// <param name="pattern"></param>
/// <param name="batchSize"></param>
/// <returns></returns>
Task<int> DeleteKeysByPatternAsync(string pattern, int batchSize = 100);
#region Работа с Hash
/// <summary>

View File

@@ -74,10 +74,14 @@ namespace PARR.Core
services.AddTransient<IShortcodesService, ShortcodesService>();
services.AddTransient<IMatchingStatusService, MatchingStatusService>();
//services.AddScoped<IUserService, UserService>();
#endregion
#region Workload
services.AddScoped<WorkloadCacheService>();
#endregion

View File

@@ -1,18 +1,28 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.TaskRepositories;
using PARR.Core.Services.Workload.Implementations;
using PARR.Domain.Entities.TaskEntities;
using PARR.Domain.Enums;
namespace PARR.Core.Services.TaskServices.Handlers
{
/// <summary>
/// Обработчик задачаи формирования отчета по загруженности (формирование КЭШ).
/// Обработчик задачи формирования отчета по загруженности (формирование КЭШ).
/// Формируется КЭШ шаблонов с ЗО и РГ
/// </summary>
public class WorkloadReportHandler : BaseTaskHandler
{
private readonly WorkloadCacheService workloadCacheService;
public WorkloadReportHandler(ITaskRepository taskRepository, ITaskErrorRepository taskErrorRepository, ILogger<WorkloadReportHandler> logger)
: base(taskRepository, taskErrorRepository, logger) { }
public WorkloadReportHandler(
ITaskRepository taskRepository,
ITaskErrorRepository taskErrorRepository,
ILogger<WorkloadReportHandler> logger,
WorkloadCacheService workloadCacheService)
: base(taskRepository, taskErrorRepository, logger)
{
this.workloadCacheService = workloadCacheService;
}
public override TaskTypeEnum SupportedType => TaskTypeEnum.Workload;
@@ -25,15 +35,23 @@ namespace PARR.Core.Services.TaskServices.Handlers
// ? new ReportPayload()
// : JsonSerializer.Deserialize<ReportPayload>(task.Payload);
//todo: тут логика построения отчета
// тут логика построения отчета
await workloadCacheService.DeleteWorkloadCacheAsync();
await workloadCacheService.DeleteTemplateCacheAsync();
await workloadCacheService.CreateTemplateCacheAsync();
//await System.Threading.Tasks.Task.CompletedTask;
await System.Threading.Tasks.Task.CompletedTask;
return HandlerResult.Success();
// return HandlerResult.Success();
//return HandlerResult.Failure("Тут капец какая ошибка! Просто жуть", new Exception("А это я создал эксепшен!!!"));
}
return HandlerResult.Failure("Тут капец какая ошибка! Просто жуть", new Exception("А это я создал эксепшен!!!"));
protected override Task OnSuccessAsync(TaskItem task, HandlerResult result)
{
// Пример хука: отправка уведомления после успеха
return base.OnSuccessAsync(task, result);
}
}
}

View File

@@ -31,7 +31,7 @@ namespace PARR.Core.Services.Workload.Implementations
}
public async System.Threading.Tasks.Task ProcessMessagesAsync(IMqSettings mqSettings)
public async Task ProcessMessagesAsync(IMqSettings mqSettings)
{
// этот метод вызывается в воркере
@@ -68,7 +68,7 @@ namespace PARR.Core.Services.Workload.Implementations
throw new Exception("Ошибка при подключении к RabbitMq");
}
public async System.Threading.Tasks.Task StopProcessingAsync()
public async Task StopProcessingAsync()
{
await rabbitService.DisposeAsync();
}

View File

@@ -1,33 +1,82 @@
using InfluxDB.Client.Api.Service;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.Shortcodes;
using PARR.Core.Services.Workload.Models;
using PARR.Domain.Cache.Models;
using PARR.Domain.Entities;
namespace PARR.Core.Services.Workload.Implementations
{
/// <summary>
/// Класс управления КЭШем для workload
/// </summary>
internal class WorkloadCacheService
public class WorkloadCacheService
{
private readonly ILogger<WorkloadCacheService> logger;
private readonly IRedisCacheService redisCacheService;
private readonly ITemplateRepository templateRepository;
private readonly IShortcodesService shortcodeService;
public WorkloadCacheService(
ILogger<WorkloadCacheService> logger,
IRedisCacheService redisCacheService,
ITemplateRepository templateRepository
//IShortcodeService
ITemplateRepository templateRepository,
IShortcodesService shortcodeService
)
{
this.logger = logger;
this.redisCacheService = redisCacheService;
this.templateRepository = templateRepository;
this.shortcodeService = shortcodeService;
}
//----------- Настройки, вынести -----------
/// <summary>
/// Ключ КЭШ шаблонов
/// </summary>
private readonly string[] cacheTemplateKey = { "template", "report", "params" };
/// <summary>
/// TTL кэша шаблонов
/// </summary>
private readonly TimeSpan cacheTemplateTtl = TimeSpan.FromHours(12);
/// <summary>
/// Допустимая разница записей в БД и в кэш
/// </summary>
private readonly int maxCacheDiff = 1000;
/// <summary>
/// Ключ КЭШ, отчет о загруженности, все ЗО
/// </summary>
private readonly string[] cacheWorkloadResponseAreaAllKey = { "workload", "response area", "all" };
/// <summary>
/// Ключ КЭШ, отчет о загруженности, одна ЗО
/// Формировать ключ, основа + Hash ЗО
/// </summary>
private readonly string[] cacheWorkloadResponseAreaItemKey = { "workload", "response area", "item" };
/// <summary>
/// Ключ КЭШ, отчет о загруженности, все РГ
/// </summary>
private readonly string[] cacheWorkloadWorkGroupAllKey = { "workload", "work group", "all" };
/// <summary>
/// Ключ КЭШ, отчет о загруженности, одна РГ
/// Формировать ключ, основа + Hash РГ
/// </summary>
private readonly string[] cacheWorkloadWorkGroupItemKey = { "workload", "work group", "item" };
/// <summary>
/// TTL кэша отчетов workload
/// </summary>
private readonly TimeSpan cacheWorkloadTtl = TimeSpan.FromMinutes(15);
//=========== Настройки, вынести ===========
/// <summary>
/// Создать КЭШ шаблонов.
/// КЭШ рабочих групп и зон ответственности
@@ -35,20 +84,51 @@ namespace PARR.Core.Services.Workload.Implementations
/// <returns></returns>
public async Task CreateTemplateCacheAsync()
{
await DeleteTemplateCacheAsync();
await DeleteWorkloadCacheAsync();
var dtStart = DateTimeOffset.UtcNow;
logger.LogInformation("Создание КЭШа шаблонов.");
//1. Получить из БД все шаблоны (тяжелый запрос, тянет вообще все данные, 8 сек)
var templates = await templateRepository.Get()
.AsNoTracking()
.Include(t=>t.Job)
// Загружаем дополнительные данные, чтоб быстрее считался шорткод
.Include(t => t.Job).ThenInclude(t => t.Group).ThenInclude(t => t.GroupType)
.ToListAsync();
logger.LogInformation("Получил из БД шаблонов {TemplateCount} шт.", templates.Count);
//2. Получить по каждому шаблону ЗО и РГ
//3. Сохранить в Redis
var cacheData = new List<TemplateCache>();
//redisCacheService
int templateCount = 0;
foreach (var template in templates)
{
var (workGroup, responseArea) = await ApplyShortcodesAsync(template);
logger.LogDebug("[{TemplateCount}] Для шаблона {TemplateId} {TemplateName} получена ЗО '{ResponseArea}', РГ '{WorkGroup}'", templateCount, template.Id, template.Name, responseArea, workGroup);
var cacheItem = new TemplateCache
{
Data = new TemplateCacheDto { TemplateId = template.Id, ResponseArea = responseArea, WorkGroup = workGroup },
Source = nameof(WorkloadCacheService),
Timestamp = DateTimeOffset.UtcNow
};
cacheData.Add(cacheItem);
templateCount++;
}
//3. Сохранить в Redis
logger.LogDebug("Для сохранения в Redis подготовлен массив из {Count} строк", cacheData.Count);
var hashKey = redisCacheService.GetKey(cacheTemplateKey);
foreach (var item in cacheData)
{
await redisCacheService.SetHashFieldAsync(hashKey, item.Data.TemplateId.ToString(), item, cacheTemplateTtl);
}
var duration = DateTimeOffset.UtcNow - dtStart;
logger.LogInformation("Завершено формирование кэша. Key: {HashKey}, ttl: {Ttl}, кол-во записей: {CacheCount}. Продолжительность формирования КЭШ {Duration}", hashKey, cacheTemplateTtl, cacheData.Count, duration);
}
/// <summary>
@@ -57,7 +137,10 @@ namespace PARR.Core.Services.Workload.Implementations
/// <returns></returns>
public async Task DeleteTemplateCacheAsync()
{
var hashKey = redisCacheService.GetKey(cacheTemplateKey);
logger.LogDebug("Удаляю кэш шаблонов, {HashKey}", redisCacheService.GetKey(cacheTemplateKey));
await redisCacheService.DeleteHashAsync(hashKey);
}
/// <summary>
@@ -66,9 +149,134 @@ namespace PARR.Core.Services.Workload.Implementations
/// <returns></returns>
public async Task DeleteWorkloadCacheAsync()
{
logger.LogDebug("Удаляю кэш отчетов");
await redisCacheService.DeleteCachedDataAsync(redisCacheService.GetKey(cacheWorkloadResponseAreaAllKey));
await redisCacheService.DeleteKeysByPatternAsync(redisCacheService.GetKey(cacheWorkloadResponseAreaItemKey) + "*");
await redisCacheService.DeleteCachedDataAsync(redisCacheService.GetKey(cacheWorkloadWorkGroupAllKey));
await redisCacheService.DeleteKeysByPatternAsync(redisCacheService.GetKey(cacheWorkloadWorkGroupItemKey) + "*");
}
/// <summary>
/// Получить кэш РГ и ЗО всех шаблонов, получить недостающие поля из БД.
/// Сравнит кол-во записей в БД и в КЭШ, если не хватает до 1000 шаблонов, доформирует кэш,
/// если больше 1000, вернет пустой список, так как считается кэш неактуальным.
/// </summary>
/// <returns></returns>
public async Task<Dictionary<Guid, TemplateReportData>> GetTemplateReportDataAsync()
{
logger.LogDebug("Получаю данные для формирования отчета.");
// Получим кэш целиком
var cacheData = await redisCacheService.GetAllHashFieldsAsync<TemplateCache>(redisCacheService.GetKey(cacheTemplateKey));
if (cacheData.Count == 0)
{
logger.LogDebug("Записей в кэш 0. Конец.");
return new Dictionary<Guid, TemplateReportData>();
}
// Сравним кол-во записей в БД и в КЭШ, вдруг там разница больше допустимой
var templatesCount = await templateRepository.Get().Select(t => t.Id).CountAsync();
if (templatesCount - cacheData.Count > maxCacheDiff)
{
logger.LogInformation("Разница в количестве шаблонов в БД ({TemplateCount}) и в КЭШ ({CacheCount}) больше допустимой {MaxCacheDiff}. Считаем КЭШ неактуальным. Требуется повторное формирование КЭШа.",
templatesCount, cacheData.Count, maxCacheDiff);
return new Dictionary<Guid, TemplateReportData>();
}
// Получаем из БД только нужные данные для отчета
var templates = await templateRepository.Get()
.AsNoTracking()
.Select(t => new { Id = t.Id, IsActiveTemplate = t.IsActiveTemplate, IsActiveSchedule = t.IsActiveSchedule, Name = t.Name, NextRun = t.NextRun })
.ToListAsync();
var reportData = new Dictionary<Guid, TemplateReportData>();
foreach (var template in templates)
{
cacheData.TryGetValue(template.Id.ToString(), out var cache);
var (workGroup, responseArea) = ("", "");
var cacheDate = DateTimeOffset.UtcNow;
if (cache != null)
{
workGroup = cache.Data.WorkGroup;
responseArea = cache.Data.ResponseArea;
cacheDate = cache.Timestamp;
}
else
{
(workGroup, responseArea) = await GetTemplateProps(template.Id);
}
var data = new TemplateReportData(
template.Id,
responseArea,
workGroup,
template.Name,
template.NextRun,
template.IsActiveTemplate,
template.IsActiveSchedule,
DateTimeOffset.UtcNow);
reportData.Add(template.Id, data);
}
logger.LogDebug("Сформировано итоговых данных для отчета, строк: {Count}", reportData.Count);
return reportData;
}
/// <summary>
/// Получить РГ и ЗО из шорткода. Сохранить в КЭШ.
/// </summary>
/// <param name="templateId"></param>
/// <returns></returns>
private async Task<(string WorkGroup, string ResponseArea)> GetTemplateProps(Guid templateId)
{
logger.LogDebug("Дозагружаю недостающие данные из БД для шаблона {TemplateId}", templateId);
var template = await templateRepository.Get()
.AsNoTracking()
// Загружаем дополнительные данные, чтоб быстрее считался шорткод
.Include(t => t.Job).ThenInclude(t => t.Group).ThenInclude(t => t.GroupType)
.FirstOrDefaultAsync(t => t.Id == templateId);
if (template == null)
{
logger.LogWarning("Шаблон {TemplateId} не найден в БД", templateId);
return ($"РГ {templateId}", $"ЗО {templateId}");
}
var (workGroup, responseArea) = await ApplyShortcodesAsync(template);
// Запишем результат в кэш
var cacheItem = new TemplateCache
{
Data = new TemplateCacheDto { TemplateId = template.Id, ResponseArea = responseArea, WorkGroup = workGroup },
Source = nameof(WorkloadCacheService),
Timestamp = DateTimeOffset.UtcNow
};
await redisCacheService.SetHashFieldAsync(redisCacheService.GetKey(cacheTemplateKey), templateId.ToString(), cacheItem);
logger.LogDebug("Добавлена недостающая запись в кэш. {TemplateId}, '{WorkGroup}', '{ResponseArea}'", templateId, workGroup, responseArea);
return (workGroup, responseArea);
}
/// <summary>
/// Получить РГ и ЗО из шорткодов
/// </summary>
/// <param name="template"></param>
/// <returns></returns>
private async Task<(string WorkGroup, string ResponseArea)> ApplyShortcodesAsync(Template template)
{
var workGroup = await shortcodeService.ApplyShortcodesAsync(template.Job!.WorkGroupMask, template);
var responseArea = await shortcodeService.ApplyShortcodesAsync(template.Job!.ResponseAreaMask, template);
return (workGroup, responseArea);
}
}

View File

@@ -0,0 +1,24 @@
namespace PARR.Core.Services.Workload.Models
{
/// <summary>
/// Модель данных для формирования отчета по шаблонам.
/// Склеенные данные полученные из кэш и БД
/// </summary>
/// <param name="TemplateId"></param>
/// <param name="ResponseArea"></param>
/// <param name="WorkGroup"></param>
/// <param name="Name"></param>
/// <param name="NextRun"></param>
/// <param name="IsActiveTemplate"></param>
/// <param name="IsActiveSchedule"></param>
public record TemplateReportData(
Guid TemplateId,
string ResponseArea,
string WorkGroup,
string Name,
DateTimeOffset NextRun,
bool IsActiveTemplate,
bool IsActiveSchedule,
DateTimeOffset CacheDate
);
}

View File

@@ -573,7 +573,7 @@ namespace PARR.DAL.Context
{
f.HasData(new TaskType[]
{
new() { Code = TaskTypeEnum.Workload, Name = TaskTypeEnum.Workload.ToString(), Description = "Формирование данных для отчетности - Загруженность", MaxRetries = 2, IsSingleton=true, MaxExecutionTimeMinutes=30, RetentionDays=90 }
new() { Code = TaskTypeEnum.Workload, Name = TaskTypeEnum.Workload.ToString(), Description = "Формирование данных для отчетности - Загруженность", MaxRetries = 2, IsSingleton=true, MaxExecutionTimeMinutes=60, RetentionDays=90 }
}
);
});

View File

@@ -0,0 +1,22 @@
using PARR.Domain.Cache.Models.Base;
namespace PARR.Domain.Cache.Models
{
/// <summary>
/// Модель кэша для построения отчетности о загруженности.
/// Шаблон + ЗО + РГ
/// </summary>
public record TemplateCache : IBaseCache<TemplateCacheDto>
{
public required TemplateCacheDto Data { get; set; }
public DateTimeOffset Timestamp { get; set; }
public required string Source { get; set; }
}
public record TemplateCacheDto
{
public Guid TemplateId { get; init; }
public required string WorkGroup { get; init; }
public required string ResponseArea { get; init; }
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
using StackExchange.Redis;
using System.Text.Json;
@@ -8,14 +9,19 @@ namespace PARR.Infrastructure.Redis
internal class RedisCacheService : IRedisCacheService
{
private readonly IDistributedCache cache;
private readonly IConnectionMultiplexer connectionMultiplexer;
private readonly ILogger<RedisCacheService> logger;
private readonly IDatabase redis;
public RedisCacheService(
IDistributedCache cache,
IConnectionMultiplexer connectionMultiplexer
IConnectionMultiplexer connectionMultiplexer,
ILogger<RedisCacheService> logger
)
{
this.cache = cache;
this.connectionMultiplexer = connectionMultiplexer;
this.logger = logger;
this.redis = connectionMultiplexer.GetDatabase();
}
@@ -80,6 +86,61 @@ namespace PARR.Infrastructure.Redis
#endregion
public async Task<int> DeleteKeysByPatternAsync(string pattern, int batchSize = 100)
{
if (string.IsNullOrEmpty(pattern))
return 0;
logger.LogDebug("Начало удаление ключей по маске '{Pattern}' из кэш", pattern);
var deletedCount = 0;
var keysToDelete = new List<RedisKey>();
// Получаем сервер для выполнения SCAN
//todo: Для кластера нужно обрабатывать каждый узел отдельно
var server = connectionMultiplexer.GetEndPoints()
.Select(t => connectionMultiplexer.GetServer(t))
.FirstOrDefault();
if (server == null)
{
logger.LogError("Не удалось получить сервер Redis для операции SCAN");
return 0;
}
// server.Keys() лениво сканирует ключи через SCAN (не блокирует!)
// Важно: не вызывай .ToList() — это загрузит всё в память!
foreach (var key in server.Keys(pattern: pattern, pageSize: batchSize))
{
keysToDelete.Add(key);
// Удаляем пакетом, чтобы не делать лишний сетевой запрос на каждый ключ
if (keysToDelete.Count >= batchSize)
{
await redis.KeyDeleteAsync(keysToDelete.ToArray());
deletedCount += keysToDelete.Count;
logger.LogDebug("Удалено {Count} ключей по масске '{Pattern}'", keysToDelete.Count, pattern);
keysToDelete.Clear();
}
}
// Удаляем остаток, если остался
if (keysToDelete.Count > 0)
{
await redis.KeyDeleteAsync(keysToDelete.ToArray());
deletedCount += keysToDelete.Count;
}
logger.LogInformation("Завершено удаление ключей по маске '{Pattern}'. Удалено: {DeletedCount}", pattern, deletedCount);
return deletedCount;
}
#region Нативные операции Redis, Redis Hash
public async Task SetHashFieldAsync<T>(string hashKey, string field, T value, TimeSpan? ttl = null)