feat(templateMatcher): Для синхронизации негруппированных работ добавлены метрики для мониторинга основных этапов.

This commit is contained in:
Mikhail Kuznetsov
2026-06-10 12:08:07 +10:00
parent 535e87ec21
commit a64ab59de1
3 changed files with 188 additions and 102 deletions

View File

@@ -1,11 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq; using Moq;
using PARR.Core.Common.Interfaces; using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.Job; using PARR.Core.Repositories.Interfaces.Job;
using PARR.Core.Repositories.Interfaces.Unit; using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Core.Services.UnitService.Interfaces;
using PARR.DAL.Context; using PARR.DAL.Context;
using PARR.Domain.Cache.Models; using PARR.Domain.Cache.Models;
using PARR.Domain.Entities.Job; using PARR.Domain.Entities.Job;
@@ -98,6 +101,13 @@ public class UnitFilterServiceTests
public Mock<IUnitFieldRepository> UnitField { get; set; } public Mock<IUnitFieldRepository> UnitField { get; set; }
public Mock<IJobRepository> Job { get; set; } public Mock<IJobRepository> Job { get; set; }
public Mock<IRedisCacheService> Cache { get; set; } public Mock<IRedisCacheService> Cache { get; set; }
// Новые зависимости после рефакторинга
public Mock<IUnitService> UnitService { get; set; }
public Mock<IUnitFieldMatcher> FieldMatcher { get; set; }
public Mock<IUnitRelationshipMatcher> RelationshipMatcher { get; set; }
public Mock<IUmbrellaFilter> UmbrellaFilter { get; set; }
public Mock<IUnitFilterResultLoader> ResultLoader { get; set; }
public Mock<IUnitNameResolver> NameResolver { get; set; }
} }
/// <summary> /// <summary>
@@ -137,6 +147,56 @@ public class UnitFilterServiceTests
cacheMock.Setup(r => r.GetCachedDataAsync<UnitFilterIds>(It.IsAny<string>(), It.IsAny<bool>())) cacheMock.Setup(r => r.GetCachedDataAsync<UnitFilterIds>(It.IsAny<string>(), It.IsAny<bool>()))
.ReturnsAsync((UnitFilterIds)null!); .ReturnsAsync((UnitFilterIds)null!);
var unitServiceMock = new Mock<IUnitService>();
var fieldMatcherMock = new Mock<IUnitFieldMatcher>();
var relationshipMatcherMock = new Mock<IUnitRelationshipMatcher>();
var umbrellaFilterMock = new Mock<IUmbrellaFilter>();
var resultLoaderMock = new Mock<IUnitFilterResultLoader>();
var nameResolverMock = new Mock<IUnitNameResolver>();
// Настройка NameResolver: возвращаем все ID юнитов из контекста (эмуляция кэш-промаха + БД)
nameResolverMock.Setup(r => r.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string mask, CancellationToken ct) =>
context.Units.Select(u => u.Id).ToList());
// Настройка FieldMatcher: эмуляция SQL-фильтрации через InMemory-контекст
fieldMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobFieldFilter>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobFieldFilter> filters, CancellationToken ct) =>
ids.ToList());
// Настройка RelationshipMatcher: возвращаем контексты без изменений
relationshipMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobRelationshipFilter>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
ids.Select(id => new UnitFilterMatchResult { UnitId = id }).ToList());
// Настройка UmbrellaFilter: пропускаем без изменений
umbrellaFilterMock.Setup(r => r.Apply(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<Job>()))
.Returns((List<UnitFilterMatchResult> ctx, Job j) => ctx);
// Настройка ResultLoader: формируем DTO из контекста
resultLoaderMock.Setup(r => r.LoadAsync(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((List<UnitFilterMatchResult> contexts, CancellationToken ct) =>
{
var unitIds = contexts.Select(c => c.UnitId).ToHashSet();
var units = context.Units.Where(u => unitIds.Contains(u.Id)).ToList();
return units.Select(u => new UnitFilterResultDto
{
Id = u.Id,
Name = u.Name,
Values = u.UnitValues?.Select(v => new UnitValueDto
{
FieldId = v.FieldId,
Value = v.Value?.Value
}).ToList() ?? new List<UnitValueDto>(),
Parents = contexts.First(c => c.UnitId == u.Id).ValidParentIds
.Select(pid => new RelatedUnitDto { UnitId = pid })
.ToList(),
Children = contexts.First(c => c.UnitId == u.Id).ValidChildIds
.Select(cid => new RelatedUnitDto { UnitId = cid })
.ToList()
}).ToList();
});
return new RepositoryMocks return new RepositoryMocks
{ {
Unit = unitRepoMock, Unit = unitRepoMock,
@@ -144,7 +204,13 @@ public class UnitFilterServiceTests
UnitInValue = unitInValueRepoMock, UnitInValue = unitInValueRepoMock,
UnitField = unitFieldRepoMock, UnitField = unitFieldRepoMock,
Job = jobRepoMock, Job = jobRepoMock,
Cache = cacheMock Cache = cacheMock,
UnitService = unitServiceMock,
FieldMatcher = fieldMatcherMock,
RelationshipMatcher = relationshipMatcherMock,
UmbrellaFilter = umbrellaFilterMock,
ResultLoader = resultLoaderMock,
NameResolver = nameResolverMock
}; };
} }
@@ -154,12 +220,10 @@ public class UnitFilterServiceTests
/// </summary> /// </summary>
/// <param name="mocks">Подготовленные моки репозиториев</param> /// <param name="mocks">Подготовленные моки репозиториев</param>
/// <param name="logger">Экземпляр логгера для сервиса</param> /// <param name="logger">Экземпляр логгера для сервиса</param>
private static PARR.Core.Services.UnitFilterService.UnitFilterService CreateSut( private static Core.Services.UnitFilterService.UnitFilterService CreateSut(
RepositoryMocks mocks, RepositoryMocks mocks,
ILogger<PARR.Core.Services.UnitFilterService.UnitFilterService> logger) ILogger<Core.Services.UnitFilterService.UnitFilterService> logger)
{ {
var serviceOptions = Options.Create(new PARR.Core.Services.UnitFilterService.Models.UnitFilterServiceOptions { LoadBatchSize = 100 });
return new PARR.Core.Services.UnitFilterService.UnitFilterService( return new PARR.Core.Services.UnitFilterService.UnitFilterService(
logger, logger,
mocks.Job.Object, mocks.Job.Object,
@@ -167,8 +231,13 @@ public class UnitFilterServiceTests
mocks.UnitInUnit.Object, mocks.UnitInUnit.Object,
mocks.UnitInValue.Object, mocks.UnitInValue.Object,
mocks.Cache.Object, mocks.Cache.Object,
serviceOptions, mocks.UnitField.Object,
mocks.UnitField.Object mocks.UnitService.Object,
mocks.FieldMatcher.Object,
mocks.RelationshipMatcher.Object,
mocks.UmbrellaFilter.Object,
mocks.ResultLoader.Object,
mocks.NameResolver.Object
); );
} }
@@ -264,7 +333,7 @@ public class UnitFilterServiceTests
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(fieldId, It.IsAny<string>())) mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(fieldId, It.IsAny<string>()))
.Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable()); .Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>(); var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger); var service = CreateSut(mocks, logger);
// === Act === // === Act ===
@@ -376,7 +445,7 @@ public class UnitFilterServiceTests
.Distinct() .Distinct()
.AsQueryable()); .AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>(); var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger); var service = CreateSut(mocks, logger);
// === Act === // === Act ===
@@ -481,7 +550,7 @@ public class UnitFilterServiceTests
.Distinct() .Distinct()
.AsQueryable()); .AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>(); var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger); var service = CreateSut(mocks, logger);
// === Act === // === Act ===
@@ -593,7 +662,7 @@ public class UnitFilterServiceTests
.Distinct() .Distinct()
.AsQueryable()); .AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>(); var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger); var service = CreateSut(mocks, logger);
// === Act === // === Act ===

View File

@@ -18,4 +18,11 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Разрешаем тестовой сборке видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="PARR.Test" />
<!-- Разрешаем Castle DynamicProxy (Moq / NSubstitute) видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project> </Project>

View File

@@ -18,6 +18,7 @@ using PARR.Domain.Settings;
using PARR.TemplateMatcher.Models; using PARR.TemplateMatcher.Models;
using PARR.TemplateMatcher.Services.Interfaces; using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Settings; using PARR.TemplateMatcher.Settings;
using System.Diagnostics;
namespace PARR.TemplateMatcher.Services.Implementations; namespace PARR.TemplateMatcher.Services.Implementations;
@@ -99,8 +100,8 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
return; return;
} }
// === Обычная логика для всех остальных Job === var totalSw = Stopwatch.StartNew();
logger.LogDebug("Начало синхронизации шаблонов для Job {JobId}", jobId); logger.LogInformation("Начало синхронизации шаблонов для Job {JobId}", jobId);
// === Проверка: уже запущена? === // === Проверка: уже запущена? ===
var existingStatus = await matchingStatusService.GetStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); var existingStatus = await matchingStatusService.GetStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
@@ -126,6 +127,8 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
try try
{ {
// === ЭТАП 1: Загрузка Job ===
var stageSw = Stopwatch.StartNew();
var job = await jobService.Get() var job = await jobService.Get()
.AsNoTracking() .AsNoTracking()
.Include(j => j.AutoControl) .Include(j => j.AutoControl)
@@ -142,48 +145,22 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
await UpdateMatchingStatusAsync(jobId, "Job не найден"); await UpdateMatchingStatusAsync(jobId, "Job не найден");
return; return;
} }
stageSw.Stop();
logger.LogDebug("[Perf] Job {JobId} | Этап: Загрузка Job | Время: {Ms} мс",
jobId, stageSw.ElapsedMilliseconds);
// === Получение отфильтрованных юнитов с полной информацией === // === ЭТАП 2: Фильтрация юнитов ===
stageSw.Restart();
var filteredUnits = await unitFilterService.GetUnitsByJobFilterAsync(jobId); var filteredUnits = await unitFilterService.GetUnitsByJobFilterAsync(jobId);
if (filteredUnits == null || !filteredUnits.Any()) stageSw.Stop();
{ var filterCount = filteredUnits?.Count() ?? 0;
logger.LogInformation("Для Job {JobId} фильтры не дали Unit'ов.", jobId); logger.LogDebug("[Perf] Job {JobId} | Этап: Фильтрация юнитов | Время: {Ms} мс | Результат: {Count}",
jobId, stageSw.ElapsedMilliseconds, filterCount);
var existingTemplatesForDeactivation = await templateService.Get() var unitIds = filteredUnits?.Select(u => u.Id).ToHashSet() ?? new HashSet<Guid>();
.AsNoTracking()
.Include(t => t.UnitsInTemplate)
.Where(t => t.JobId == jobId && t.StatusTypeId == TemplateStatusTypeEnum.Used)
.ToListAsync();
await UpdateMatchingStatusAsync(jobId, $"Нет Unit'ов. Деактивация {existingTemplatesForDeactivation.Count} шаблонов...");
foreach (var unusedTemplate in existingTemplatesForDeactivation)
{
logger.LogInformation("Деактивация лишнего шаблона {TemplateId} (Job {JobId}, UnitId {UnitId}).", unusedTemplate.Id, jobId, unusedTemplate.UnitId);
await templateDeactivator.DeactivateTemplateAsync(unusedTemplate, initiator);
}
await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена: нет Unit'ов");
await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
logger.LogInformation("Синхронизация шаблонов завершена для Job {JobId}.", jobId);
return;
}
// Извлекаем ID юнитов для последующих операций
var unitIds = filteredUnits.Select(u => u.Id).ToList();
#if DEBUG
// Отладка: проверить, есть ли юнит в unitIds
if (unitIds.Contains(targetUnitId))
{
logger.LogDebug("Юнит {TargetUnitId} найден в unitIds.", targetUnitId);
}
else
{
logger.LogDebug("Юнит {TargetUnitId} НЕ найден в unitIds.", targetUnitId);
}
#endif
// === ЭТАП 3: Загрузка существующих шаблонов ===
stageSw.Restart();
var existingTemplates = await templateService.Get() var existingTemplates = await templateService.Get()
.Include(t => t.UnitsInTemplate) .Include(t => t.UnitsInTemplate)
.Include(t => t.Job) .Include(t => t.Job)
@@ -199,25 +176,48 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
.Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Used) .Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Used)
.ToList(); .ToList();
var existingUnitIds = existingUsedTemplates var existingUnitIds = existingUsedTemplates.Select(t => t.UnitId).ToHashSet();
.Select(t => t.UnitId) stageSw.Stop();
.ToHashSet(); logger.LogDebug("[Perf] Job {JobId} | Этап: Загрузка шаблонов | Время: {Ms} мс | Используется: {Count}",
jobId, stageSw.ElapsedMilliseconds, existingUsedTemplates.Count);
// === ЭТАП 4: Расчёт диффа (создание / деактивация / переименование) ===
stageSw.Restart();
var newUnitIds = unitIds.Except(existingUnitIds).ToList(); var newUnitIds = unitIds.Except(existingUnitIds).ToList();
var unusedTemplates = existingUsedTemplates var unusedTemplates = existingUsedTemplates
.Where(t => !unitIds.Contains(t.UnitId)) .Where(t => !unitIds.Contains(t.UnitId))
.ToList(); .ToList();
// Проверка имён существующих шаблонов
var templatesToRename = new List<(Template Template, string ExpectedName)>();
foreach (var template in existingUsedTemplates)
{
if (!unitIds.Contains(template.UnitId))
continue;
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(template);
if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase))
{
templatesToRename.Add((template, expectedName));
}
}
stageSw.Stop();
logger.LogDebug("[Perf] Job {JobId} | Этап: Расчёт диффа | Время: {Ms} мс | Создать: {Create}, Деактивировать: {Deactivate}, Переименовать: {Rename}",
jobId, stageSw.ElapsedMilliseconds, newUnitIds.Count, unusedTemplates.Count, templatesToRename.Count);
// === ЭТАП 5: Создание новых шаблонов ===
stageSw.Restart();
foreach (var unitId in newUnitIds) foreach (var unitId in newUnitIds)
{ {
// Вычисляем флаги активности из конфигурации автоконтрола
var isActiveTemplate = job.AutoControl?.InitUsedTemplateState ?? DefaultUsedTemplateState; var isActiveTemplate = job.AutoControl?.InitUsedTemplateState ?? DefaultUsedTemplateState;
var isActiveSchedule = job.AutoControl?.InitUsedScheduleState ?? DefaultUsedScheduleState; var isActiveSchedule = job.AutoControl?.InitUsedScheduleState ?? DefaultUsedScheduleState;
var request = new TemplateAllocationRequest( var request = new TemplateAllocationRequest(
TargetJob: job, TargetJob: job,
TargetUnitId: unitId, TargetUnitId: unitId,
TargetUnit: null, // Для простого шаблона не критично, нормализатор загрузит при необходимости TargetUnit: null,
Index: null, Index: null,
UnitsInTemplate: new List<UnitInTemplateMessage>(), UnitsInTemplate: new List<UnitInTemplateMessage>(),
IsActiveTemplate: isActiveTemplate, IsActiveTemplate: isActiveTemplate,
@@ -226,17 +226,16 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
await templateAllocationService.AllocateAsync(request); await templateAllocationService.AllocateAsync(request);
} }
stageSw.Stop();
logger.LogDebug("[Perf] Job {JobId} | Этап: Создание шаблонов | Время: {Ms} мс | Количество: {Count}",
jobId, stageSw.ElapsedMilliseconds, newUnitIds.Count);
// === Обработка существующих шаблонов (проверка имени) === // === ЭТАП 6: Обновление имён существующих шаблонов ===
foreach (var template in existingUsedTemplates) stageSw.Restart();
foreach (var (template, expectedName) in templatesToRename)
{ {
if (unitIds.Contains(template.UnitId)) logger.LogDebug("Шаблон {TemplateId} требует обновления имени: '{OldName}' → '{NewName}'",
{ template.Id, template.Name, expectedName);
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(template);
if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Шаблон {TemplateId} требует обновления имени: старое = '{OldName}', новое = '{NewName}'", template.Id, template.Name, expectedName);
var updateRequest = new TemplateUpdaterMessage var updateRequest = new TemplateUpdaterMessage
{ {
@@ -250,34 +249,45 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
Index = template.Index, Index = template.Index,
StatusTypeId = TemplateStatusTypeEnum.Used, StatusTypeId = TemplateStatusTypeEnum.Used,
Initiator = initiator, Initiator = initiator,
UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона UnitsInTemplate = new List<UnitInTemplateMessage>()
}; };
await templateMqPublisher.PublishUpdateAsync(updateRequest); await templateMqPublisher.PublishUpdateAsync(updateRequest);
} }
} stageSw.Stop();
} logger.LogDebug("[Perf] Job {JobId} | Этап: Обновление имён | Время: {Ms} мс | Количество: {Count}",
jobId, stageSw.ElapsedMilliseconds, templatesToRename.Count);
// === Деактивация лишних шаблонов === // === ЭТАП 7: Деактивация лишних шаблонов ===
stageSw.Restart();
foreach (var unusedTemplate in unusedTemplates) foreach (var unusedTemplate in unusedTemplates)
{ {
logger.LogInformation("Деактивация лишнего шаблона {TemplateId} (Job {JobId}, UnitId {UnitId}).", unusedTemplate.Id, jobId, unusedTemplate.UnitId); logger.LogInformation("Деактивация лишнего шаблона {TemplateId} (Job {JobId}, UnitId {UnitId}).",
unusedTemplate.Id, jobId, unusedTemplate.UnitId);
await templateDeactivator.DeactivateTemplateAsync(unusedTemplate, initiator); await templateDeactivator.DeactivateTemplateAsync(unusedTemplate, initiator);
} }
stageSw.Stop();
logger.LogDebug("[Perf] Job {JobId} | Этап: Деактивация | Время: {Ms} мс | Количество: {Count}",
jobId, stageSw.ElapsedMilliseconds, unusedTemplates.Count);
// === ИТОГО ===
totalSw.Stop();
logger.LogInformation("[Perf] Job {JobId} | ИТОГО: {TotalMs} мс", jobId, totalSw.ElapsedMilliseconds);
// === Успешное завершение ===
await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена успешно"); await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена успешно");
await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
logger.LogInformation("Синхронизация шаблонов завершена для Job {JobId}.", jobId); logger.LogInformation("Синхронизация шаблонов завершена для Job {JobId}.", jobId);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка при синхронизации Job {JobId}", jobId); totalSw.Stop();
logger.LogError(ex, "Ошибка при синхронизации Job {JobId} через {ElapsedMs} мс", jobId, totalSw.ElapsedMilliseconds);
await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}"); await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}");
throw; throw;
} }
} }
public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator) public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator)
{ {
logger.LogWarning("SimpleTemplateSynchronizer: SyncTemplatesForJobGroup вызван для JobGroup {JobGroupId}. Это не поддерживаемая операция.", jobGroupId); logger.LogWarning("SimpleTemplateSynchronizer: SyncTemplatesForJobGroup вызван для JobGroup {JobGroupId}. Это не поддерживаемая операция.", jobGroupId);