From 7d42a80556afcb366da7cfb7240975af604a583c Mon Sep 17 00:00:00 2001 From: Mikhail Trubnikov Date: Tue, 5 May 2026 14:52:17 +1000 Subject: [PATCH] =?UTF-8?q?feat(core,=20dal):=20WorkloadCacheService=20-?= =?UTF-8?q?=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D1=8B=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D0=BA=D1=8D?= =?UTF-8?q?=D1=88=D0=B5=D0=BC=20=D0=BE=D1=82=D1=87=D0=B5=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20workload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PARR.API/Controllers/V1/TestController.cs | 9 +- .../Common/Interfaces/IRedisCacheService.cs | 9 + PARR.Core/DependencyInjection.cs | 6 +- .../Handlers/WorkloadReportHandler.cs | 34 ++- .../WorkloadCacheBuilderService.cs | 4 +- .../Implementations/WorkloadCacheService.cs | 234 +++++++++++++++++- .../Workload/Models/TemplateReportData.cs | 24 ++ PARR.DAL/Context/DataContext.cs | 2 +- PARR.Domain/Cache/Models/TemplateCache.cs | 22 ++ .../Redis/RedisCacheService.cs | 63 ++++- 10 files changed, 380 insertions(+), 27 deletions(-) create mode 100644 PARR.Core/Services/Workload/Models/TemplateReportData.cs create mode 100644 PARR.Domain/Cache/Models/TemplateCache.cs diff --git a/PARR.API/Controllers/V1/TestController.cs b/PARR.API/Controllers/V1/TestController.cs index 8e2032dd..c2d75ce2 100644 --- a/PARR.API/Controllers/V1/TestController.cs +++ b/PARR.API/Controllers/V1/TestController.cs @@ -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 CreateCache() { + + // var aaa = await workloadCacheService.GetTemplateReportDataAsync(); + //var val = await redisCacheService.GetCachedDataAsync("96dAoIH/8Q9Bg8VY"); //var hashKey = redisCacheService.GetKey(new[] { "test", "mxa" }); diff --git a/PARR.Core/Common/Interfaces/IRedisCacheService.cs b/PARR.Core/Common/Interfaces/IRedisCacheService.cs index d8016821..fa5cfe52 100644 --- a/PARR.Core/Common/Interfaces/IRedisCacheService.cs +++ b/PARR.Core/Common/Interfaces/IRedisCacheService.cs @@ -62,6 +62,15 @@ string GetKey(string[] keyParts, string[]? keyPartsToHash = null); + /// + /// Удалить ключи по маске (template*) + /// + /// + /// + /// + Task DeleteKeysByPatternAsync(string pattern, int batchSize = 100); + + #region Работа с Hash /// diff --git a/PARR.Core/DependencyInjection.cs b/PARR.Core/DependencyInjection.cs index f9ee8c39..edf05db9 100644 --- a/PARR.Core/DependencyInjection.cs +++ b/PARR.Core/DependencyInjection.cs @@ -74,10 +74,14 @@ namespace PARR.Core services.AddTransient(); services.AddTransient(); - //services.AddScoped(); + #endregion + + #region Workload + + services.AddScoped(); #endregion diff --git a/PARR.Core/Services/TaskServices/Handlers/WorkloadReportHandler.cs b/PARR.Core/Services/TaskServices/Handlers/WorkloadReportHandler.cs index 70471c56..cebd4f8f 100644 --- a/PARR.Core/Services/TaskServices/Handlers/WorkloadReportHandler.cs +++ b/PARR.Core/Services/TaskServices/Handlers/WorkloadReportHandler.cs @@ -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 { /// - /// Обработчик задачаи формирования отчета по загруженности (формирование КЭШ). + /// Обработчик задачи формирования отчета по загруженности (формирование КЭШ). + /// Формируется КЭШ шаблонов с ЗО и РГ /// public class WorkloadReportHandler : BaseTaskHandler { + private readonly WorkloadCacheService workloadCacheService; - public WorkloadReportHandler(ITaskRepository taskRepository, ITaskErrorRepository taskErrorRepository, ILogger logger) - : base(taskRepository, taskErrorRepository, logger) { } + public WorkloadReportHandler( + ITaskRepository taskRepository, + ITaskErrorRepository taskErrorRepository, + ILogger 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(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); } } } diff --git a/PARR.Core/Services/Workload/Implementations/WorkloadCacheBuilderService.cs b/PARR.Core/Services/Workload/Implementations/WorkloadCacheBuilderService.cs index 12e96866..9d73f4df 100644 --- a/PARR.Core/Services/Workload/Implementations/WorkloadCacheBuilderService.cs +++ b/PARR.Core/Services/Workload/Implementations/WorkloadCacheBuilderService.cs @@ -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(); } diff --git a/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs b/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs index 9ff1f4a6..48a479ac 100644 --- a/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs +++ b/PARR.Core/Services/Workload/Implementations/WorkloadCacheService.cs @@ -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 { /// /// Класс управления КЭШем для workload /// - internal class WorkloadCacheService + public class WorkloadCacheService { private readonly ILogger logger; private readonly IRedisCacheService redisCacheService; private readonly ITemplateRepository templateRepository; + private readonly IShortcodesService shortcodeService; public WorkloadCacheService( ILogger logger, IRedisCacheService redisCacheService, - ITemplateRepository templateRepository - //IShortcodeService + ITemplateRepository templateRepository, + IShortcodesService shortcodeService ) { this.logger = logger; this.redisCacheService = redisCacheService; this.templateRepository = templateRepository; + this.shortcodeService = shortcodeService; } + //----------- Настройки, вынести ----------- + /// + /// Ключ КЭШ шаблонов + /// + private readonly string[] cacheTemplateKey = { "template", "report", "params" }; + + /// + /// TTL кэша шаблонов + /// + private readonly TimeSpan cacheTemplateTtl = TimeSpan.FromHours(12); + + /// + /// Допустимая разница записей в БД и в кэш + /// + private readonly int maxCacheDiff = 1000; + + /// + /// Ключ КЭШ, отчет о загруженности, все ЗО + /// + private readonly string[] cacheWorkloadResponseAreaAllKey = { "workload", "response area", "all" }; + + /// + /// Ключ КЭШ, отчет о загруженности, одна ЗО + /// Формировать ключ, основа + Hash ЗО + /// + private readonly string[] cacheWorkloadResponseAreaItemKey = { "workload", "response area", "item" }; + + /// + /// Ключ КЭШ, отчет о загруженности, все РГ + /// + private readonly string[] cacheWorkloadWorkGroupAllKey = { "workload", "work group", "all" }; + + /// + /// Ключ КЭШ, отчет о загруженности, одна РГ + /// Формировать ключ, основа + Hash РГ + /// + private readonly string[] cacheWorkloadWorkGroupItemKey = { "workload", "work group", "item" }; + + /// + /// TTL кэша отчетов workload + /// + private readonly TimeSpan cacheWorkloadTtl = TimeSpan.FromMinutes(15); + //=========== Настройки, вынести =========== + /// /// Создать КЭШ шаблонов. /// КЭШ рабочих групп и зон ответственности @@ -35,20 +84,51 @@ namespace PARR.Core.Services.Workload.Implementations /// 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(); - - //2. Получить по каждому шаблону ЗО и РГ - //3. Сохранить в Redis + logger.LogInformation("Получил из БД шаблонов {TemplateCount} шт.", templates.Count); - //redisCacheService + //2. Получить по каждому шаблону ЗО и РГ + var cacheData = new List(); + + 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); } /// @@ -57,7 +137,10 @@ namespace PARR.Core.Services.Workload.Implementations /// public async Task DeleteTemplateCacheAsync() { - + var hashKey = redisCacheService.GetKey(cacheTemplateKey); + logger.LogDebug("Удаляю кэш шаблонов, {HashKey}", redisCacheService.GetKey(cacheTemplateKey)); + + await redisCacheService.DeleteHashAsync(hashKey); } /// @@ -66,9 +149,134 @@ namespace PARR.Core.Services.Workload.Implementations /// 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) + "*"); } + /// + /// Получить кэш РГ и ЗО всех шаблонов, получить недостающие поля из БД. + /// Сравнит кол-во записей в БД и в КЭШ, если не хватает до 1000 шаблонов, доформирует кэш, + /// если больше 1000, вернет пустой список, так как считается кэш неактуальным. + /// + /// + public async Task> GetTemplateReportDataAsync() + { + logger.LogDebug("Получаю данные для формирования отчета."); + + // Получим кэш целиком + var cacheData = await redisCacheService.GetAllHashFieldsAsync(redisCacheService.GetKey(cacheTemplateKey)); + if (cacheData.Count == 0) + { + logger.LogDebug("Записей в кэш 0. Конец."); + return new Dictionary(); + } + + // Сравним кол-во записей в БД и в КЭШ, вдруг там разница больше допустимой + 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(); + } + + // Получаем из БД только нужные данные для отчета + 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(); + + 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; + } + + /// + /// Получить РГ и ЗО из шорткода. Сохранить в КЭШ. + /// + /// + /// + 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); + } + + /// + /// Получить РГ и ЗО из шорткодов + /// + /// + /// + 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); + } } diff --git a/PARR.Core/Services/Workload/Models/TemplateReportData.cs b/PARR.Core/Services/Workload/Models/TemplateReportData.cs new file mode 100644 index 00000000..0fa2b5fe --- /dev/null +++ b/PARR.Core/Services/Workload/Models/TemplateReportData.cs @@ -0,0 +1,24 @@ +namespace PARR.Core.Services.Workload.Models +{ + /// + /// Модель данных для формирования отчета по шаблонам. + /// Склеенные данные полученные из кэш и БД + /// + /// + /// + /// + /// + /// + /// + /// + public record TemplateReportData( + Guid TemplateId, + string ResponseArea, + string WorkGroup, + string Name, + DateTimeOffset NextRun, + bool IsActiveTemplate, + bool IsActiveSchedule, + DateTimeOffset CacheDate + ); +} diff --git a/PARR.DAL/Context/DataContext.cs b/PARR.DAL/Context/DataContext.cs index 13567dcd..256dd62a 100644 --- a/PARR.DAL/Context/DataContext.cs +++ b/PARR.DAL/Context/DataContext.cs @@ -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 } } ); }); diff --git a/PARR.Domain/Cache/Models/TemplateCache.cs b/PARR.Domain/Cache/Models/TemplateCache.cs new file mode 100644 index 00000000..c6b916fd --- /dev/null +++ b/PARR.Domain/Cache/Models/TemplateCache.cs @@ -0,0 +1,22 @@ +using PARR.Domain.Cache.Models.Base; + +namespace PARR.Domain.Cache.Models +{ + /// + /// Модель кэша для построения отчетности о загруженности. + /// Шаблон + ЗО + РГ + /// + public record TemplateCache : IBaseCache + { + 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; } + } +} diff --git a/PARR.Infrastructure/Redis/RedisCacheService.cs b/PARR.Infrastructure/Redis/RedisCacheService.cs index 451eb0d9..d7a0fff4 100644 --- a/PARR.Infrastructure/Redis/RedisCacheService.cs +++ b/PARR.Infrastructure/Redis/RedisCacheService.cs @@ -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 logger; private readonly IDatabase redis; public RedisCacheService( IDistributedCache cache, - IConnectionMultiplexer connectionMultiplexer + IConnectionMultiplexer connectionMultiplexer, + ILogger 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 DeleteKeysByPatternAsync(string pattern, int batchSize = 100) + { + if (string.IsNullOrEmpty(pattern)) + return 0; + + logger.LogDebug("Начало удаление ключей по маске '{Pattern}' из кэш", pattern); + + var deletedCount = 0; + var keysToDelete = new List(); + + // Получаем сервер для выполнения 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(string hashKey, string field, T value, TimeSpan? ttl = null)