refactor(templateMatcher): Переход на Pipeline-архитектуру для SimpleSync и GroupedSync.

- SimpleTemplateSynchronizer и GroupedTemplateSynchronizer переведены на паттерн Pipeline с разделением на Read/Write этапы
- Выделены контракты этапов (ISimpleSyncStage, IGroupedSyncStage) и контексты (SimpleSyncContext, GroupedSyncContext)
- Read-этапы безопасны для тестов (не пишут в БД/MQ), Write-этапы изолированы через отдельные интерфейсы
- Добавлено [Perf]-логирование каждого этапа с метриками времени выполнения
- Логи приведены к человекочитаемому формату 'Имя' (ID) для Job, JobGroup и Unit
- Устранено дублирование данных в контекстах (FilteredUnits перезаписывается, TemplateGroups строго типизирован)
- Константы неиспользуемых шаблонов вынесены в UnusedTemplateConstants
- Структура проекта реорганизована: SimpleSync, GroupedSync, Implementations, Interfaces
This commit is contained in:
Mikhail Kuznetsov
2026-06-11 15:09:46 +10:00
parent 16f4944a62
commit a438f18480
44 changed files with 1124 additions and 525 deletions

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.TemplateMatcher.Models;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
internal class AllocateTemplatesStage : ISimpleSyncWriteStage
{
private readonly ITemplateAllocationService _allocationService;
private readonly ILogger<AllocateTemplatesStage> _logger;
public string StageName => "Создание шаблонов";
public AllocateTemplatesStage(
ITemplateAllocationService allocationService,
ILogger<AllocateTemplatesStage> logger)
{
_allocationService = allocationService;
_logger = logger;
}
public async Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default)
{
const bool defaultUsedTemplateState = false;
const bool defaultUsedScheduleState = false;
foreach (var unitId in context.NewUnitIds)
{
var isActiveTemplate = context.Job.AutoControl?.InitUsedTemplateState ?? defaultUsedTemplateState;
var isActiveSchedule = context.Job.AutoControl?.InitUsedScheduleState ?? defaultUsedScheduleState;
var request = new TemplateAllocationRequest(
TargetJob: context.Job,
TargetUnitId: unitId,
TargetUnit: null,
Index: null,
UnitsInTemplate: new List<UnitInTemplateMessage>(),
IsActiveTemplate: isActiveTemplate,
IsActiveSchedule: isActiveSchedule,
Initiator: context.Initiator);
await _allocationService.AllocateAsync(request);
_logger.LogDebug("Job '{JobName}' ({JobId}): создан шаблон для Unit {Unit}",
context.JobName, context.JobId, context.FormatUnit(unitId));
}
return context;
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Domain.Enums;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
internal class AnalyzeChangesStage : ISimpleSyncStage
{
private readonly ITemplateRepository _templateRepository;
private readonly ITemplateNameNormalizer _nameNormalizer;
private readonly ILogger<AnalyzeChangesStage> _logger;
public string StageName => "Анализ изменений";
public AnalyzeChangesStage(
ITemplateRepository templateRepository,
ITemplateNameNormalizer nameNormalizer,
ILogger<AnalyzeChangesStage> logger)
{
_templateRepository = templateRepository;
_nameNormalizer = nameNormalizer;
_logger = logger;
}
public async Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default)
{
var existing = await _templateRepository.Get()
.Include(t => t.UnitsInTemplate)
.Include(t => t.Job).ThenInclude(t => t!.Group).ThenInclude(t => t!.GroupType)
.Include(t => t.Job).ThenInclude(t => t!.Tnk)
.Include(t => t.Unit)
.Where(t => t.JobId == context.JobId)
.ToListAsync(ct);
var used = existing.Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Used).ToList();
var existingUnitIds = used.Select(t => t.UnitId).ToHashSet();
context.ExistingUsedTemplates = used;
context.NewUnitIds = context.FilteredUnitIds.Except(existingUnitIds).ToList();
context.UnusedTemplates = used.Where(t => !context.FilteredUnitIds.Contains(t.UnitId)).ToList();
foreach (var template in used)
{
if (!context.FilteredUnitIds.Contains(template.UnitId)) continue;
var expectedName = await _nameNormalizer.GetNormalizedTemplateNameAsync(template);
if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase))
context.TemplatesToRename.Add((template, expectedName));
}
_logger.LogDebug(
"Job '{JobName}' ({JobId}): существующих шаблонов={Existing}, создать={Create}, деактивировать={Deactivate}, переименовать={Rename}",
context.JobName, context.JobId,
used.Count, context.NewUnitIds.Count, context.UnusedTemplates.Count, context.TemplatesToRename.Count);
return context;
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.Logging;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
internal class DeactivateTemplatesStage : ISimpleSyncWriteStage
{
private readonly ITemplateDeactivator _deactivator;
private readonly ILogger<DeactivateTemplatesStage> _logger;
public string StageName => "Деактивация шаблонов";
public DeactivateTemplatesStage(ITemplateDeactivator deactivator, ILogger<DeactivateTemplatesStage> logger)
{
_deactivator = deactivator;
_logger = logger;
}
public async Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default)
{
foreach (var template in context.UnusedTemplates)
{
_logger.LogInformation("Job '{JobName}' ({JobId}): деактивация шаблона '{TemplateName}' ({TemplateId}), Unit {Unit}",
context.JobName, context.JobId, template.Name, template.Id,
context.FormatUnit(template.UnitId));
await _deactivator.DeactivateTemplateAsync(template, context.Initiator);
}
return context;
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.UnitFilterService;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
internal class FilterUnitsStage : ISimpleSyncStage
{
private readonly IUnitFilterService _filterService;
private readonly ILogger<FilterUnitsStage> _logger;
public string StageName => "Фильтрация юнитов";
public FilterUnitsStage(IUnitFilterService filterService, ILogger<FilterUnitsStage> logger)
{
_filterService = filterService;
_logger = logger;
}
public async Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default)
{
var filtered = await _filterService.GetUnitsByJobFilterAsync(context.JobId, null, ct);
if (filtered != null)
{
context.FilteredUnitIds = filtered.Select(u => u.Id).ToHashSet();
context.UnitNames = filtered.ToDictionary(u => u.Id, u => u.Name);
}
_logger.LogDebug("Job '{JobName}' ({JobId}): отфильтровано {Count} юнитов",
context.JobName, context.JobId, context.FilteredUnitIds.Count);
return context;
}
}

View File

@@ -0,0 +1,12 @@
namespace PARR.TemplateMatcher.Services.SimpleSync
{
/// <summary>
/// Этап синхронизации, который только читает данные и заполняет контекст.
/// НЕ выполняет запись в БД, MQ или кэш.
/// </summary>
public interface ISimpleSyncStage
{
string StageName { get; }
Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,10 @@
namespace PARR.TemplateMatcher.Services.SimpleSync
{
/// <summary>
/// Этап синхронизации, который выполняет побочные эффекты (запись в БД, MQ).
/// В тестах не подключается — тип системы гарантирует безопасность.
/// </summary>
public interface ISimpleSyncWriteStage : ISimpleSyncStage
{
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Job;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
internal class LoadJobStage : ISimpleSyncStage
{
private readonly IJobRepository _jobRepository;
private readonly ILogger<LoadJobStage> _logger;
public string StageName => "Загрузка Job";
public LoadJobStage(IJobRepository jobRepository, ILogger<LoadJobStage> logger)
{
_jobRepository = jobRepository;
_logger = logger;
}
public async Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default)
{
var job = await _jobRepository.Get()
.AsNoTracking()
.AsSplitQuery()
.Include(j => j.AutoControl)
.Include(j => j.Tnk)
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters)
.FirstOrDefaultAsync(j => j.Id == context.JobId, ct);
if (job == null)
throw new InvalidOperationException($"Job '{context.JobId}' не найден");
context.Job = job;
context.JobName = job.Name;
_logger.LogDebug("Job '{JobName}' ({JobId}) загружен", job.Name, job.Id);
return context;
}
}

View File

@@ -0,0 +1,39 @@
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.Job;
namespace PARR.TemplateMatcher.Services.SimpleSync
{
/// <summary>
/// Контекст синхронизации простого шаблона. Передаётся между этапами.
/// </summary>
public class SimpleSyncContext
{
public Guid JobId { get; init; }
public string JobName { get; set; } = string.Empty;
public HistoryInitiator Initiator { get; init; } = null!;
public Job Job { get; set; } = null!;
public HashSet<Guid> FilteredUnitIds { get; set; } = new();
/// <summary>
/// Имена юнитов для логирования. Заполняется на этапе фильтрации.
/// </summary>
public Dictionary<Guid, string> UnitNames { get; set; } = new();
public List<Template> ExistingUsedTemplates { get; set; } = new();
public List<Guid> NewUnitIds { get; set; } = new();
public List<Template> UnusedTemplates { get; set; } = new();
public List<(Template Template, string ExpectedName)> TemplatesToRename { get; set; } = new();
/// <summary>
/// Возвращает строку вида 'Имя' (ID) для логирования.
/// Если имя неизвестно — возвращает только ID.
/// </summary>
public string FormatUnit(Guid unitId)
{
return UnitNames.TryGetValue(unitId, out var name)
? $"'{name}' ({unitId})"
: $"({unitId})";
}
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.Logging;
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.Domain.Enums;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
internal class UpdateNamesStage : ISimpleSyncWriteStage
{
private readonly ITemplateMqPublisher _mqPublisher;
private readonly ILogger<UpdateNamesStage> _logger;
public string StageName => "Обновление имён";
public UpdateNamesStage(ITemplateMqPublisher mqPublisher, ILogger<UpdateNamesStage> logger)
{
_mqPublisher = mqPublisher;
_logger = logger;
}
public async Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default)
{
foreach (var (template, expectedName) in context.TemplatesToRename)
{
var updateRequest = new TemplateUpdaterMessage
{
TemplateId = template.Id,
JobId = context.JobId,
UnitId = template.UnitId,
Name = expectedName,
IsActiveTemplate = template.IsActiveTemplate,
IsActiveSchedule = template.IsActiveSchedule,
IsNew = false,
Index = template.Index,
StatusTypeId = TemplateStatusTypeEnum.Used,
Initiator = context.Initiator,
UnitsInTemplate = new List<UnitInTemplateMessage>()
};
await _mqPublisher.PublishUpdateAsync(updateRequest);
_logger.LogDebug("Job '{JobName}' ({JobId}): шаблон '{TemplateName}' ({TemplateId}) для Unit {Unit} переименован в '{NewName}'",
context.JobName, context.JobId, template.Name, template.Id,
context.FormatUnit(template.UnitId), expectedName);
}
return context;
}
}