feat(unitFilter): Оптимизация фильрации юнитов. Проверка значений аттрибутов теперь выполняется в 2 запроса:

- проверяем доступные в базе значения
- проверяем наличие значений в юнитах по id значений
This commit is contained in:
Mikhail Kuznetsov
2026-07-28 16:34:04 +10:00
parent 556d895c7c
commit 343468cf71
11 changed files with 1529 additions and 281 deletions

View File

@@ -3,8 +3,8 @@ using PARR.Domain.Entities.Unit;
namespace PARR.Core.Repositories.Interfaces.Unit
{
public interface IUnitFieldValueRepository: IBaseRepository<UnitFieldValue>
public interface IUnitFieldValueRepository : IBaseRepository<UnitFieldValue>
{
Task<UnitFieldValue?> GetByValueNameAsync(string? value);
Task<List<Guid>> FindValueIdsByMaskAsync(string mask, CancellationToken ct = default);
}
}

View File

@@ -4,18 +4,18 @@ namespace PARR.Core.Repositories.Interfaces.Unit
{
public interface IUnitRepository : IBaseRepository<Domain.Entities.Unit.Unit>
{
/// <summary>
/// Поиск юнитов по списку ID значений.
/// Используется для эффективной фильтрации после предварительного поиска ValueId.
/// </summary>
Task<List<Guid>> FindUnitIdsByValueIdsAsync(
IReadOnlyList<Guid> unitIds,
Guid fieldId,
IReadOnlyList<Guid> valueIds,
CancellationToken ct = default);
IQueryable<Guid> GetInitialUnitIds(string dbValueMask);
/// <summary>
/// Получить юниты по Id атрибута и маски значения
/// </summary>
/// <param name="query"></param>
/// <param name="fieldId"></param>
/// <param name="valueMask"></param>
/// <param name="isInverse">true - не содержит, false - содержит</param>
/// <returns></returns>
IQueryable<Domain.Entities.Unit.Unit> GetUnitByFieldAndValue(IQueryable<Domain.Entities.Unit.Unit> query, Guid fieldId, string valueMask, bool isInverse = false);
IQueryable<Domain.Entities.Unit.Unit> GetWithIncludes();
}
}

View File

@@ -1,26 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Domain.Entities.JobEntities;
using System.Diagnostics;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Сопоставляет юниты с фильтрами по атрибутам.
/// Использует двухэтапный поиск: сначала ValueId, затем UnitId.
/// </summary>
internal class UnitFieldMatcher : IUnitFieldMatcher
{
private const int chunkSize = 200;
private readonly IUnitRepository unitRepository;
private readonly ILogger<UnitFieldMatcher> logger;
private readonly IUnitRepository _unitRepository;
private readonly IUnitFieldValueRepository _unitFieldValueRepository;
private readonly ILogger<UnitFieldMatcher> _logger;
public UnitFieldMatcher(
IUnitRepository unitRepository,
IUnitFieldValueRepository unitFieldValueRepository,
ILogger<UnitFieldMatcher> logger)
{
this.unitRepository = unitRepository;
this.logger = logger;
_unitRepository = unitRepository;
_unitFieldValueRepository = unitFieldValueRepository;
_logger = logger;
}
public async Task<List<Guid>> MatchAsync(
@@ -32,32 +35,63 @@ internal class UnitFieldMatcher : IUnitFieldMatcher
if (filters == null || filters.Count == 0)
return new List<Guid>(unitIds);
logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
_logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
unitIds.Count, filters.Count);
var result = new List<Guid>(unitIds.Count);
var currentIds = new HashSet<Guid>(unitIds);
foreach (var chunk in unitIds.Chunk(chunkSize))
for (int i = 0; i < filters.Count; i++)
{
var query = unitRepository.Get().AsNoTracking()
.Where(u => chunk.Contains(u.Id));
var filter = filters[i];
var mask = filter.ValueMask?.Trim();
foreach (var fieldFilter in filters)
if (string.IsNullOrEmpty(mask))
continue;
var sw = Stopwatch.StartNew();
var inputCount = currentIds.Count;
var matchingValueIds = await _unitFieldValueRepository
.FindValueIdsByMaskAsync(mask, ct);
if (matchingValueIds.Count == 0 && !filter.IsInverse)
{
var valueMask = fieldFilter.ValueMask?.Trim();
if (string.IsNullOrEmpty(valueMask))
continue;
query = unitRepository.GetUnitByFieldAndValue(
query, fieldFilter.FieldId, fieldFilter.ValueMask!, fieldFilter.IsInverse);
sw.Stop();
_logger.LogDebug(
"UnitFieldMatcher: фильтр #{Index} (FieldId={FieldId}, Mask='{Mask}') | Вход: {InCount}, Выход: 0 (нет значений), Время: {Ms}ms",
i + 1, filter.FieldId, mask, inputCount, sw.ElapsedMilliseconds);
currentIds.Clear();
break;
}
var chunkResult = await query.Select(u => u.Id).ToListAsync(ct);
result.AddRange(chunkResult);
// Шаг 2: Найти/исключить юниты по ValueId
if (filter.IsInverse)
{
var unitsToExclude = await _unitRepository
.FindUnitIdsByValueIdsAsync(currentIds.ToList(), filter.FieldId, matchingValueIds, ct);
currentIds.ExceptWith(unitsToExclude);
}
else
{
var unitsToKeep = await _unitRepository
.FindUnitIdsByValueIdsAsync(currentIds.ToList(), filter.FieldId, matchingValueIds, ct);
currentIds.IntersectWith(unitsToKeep);
}
sw.Stop();
_logger.LogDebug(
"UnitFieldMatcher: фильтр #{Index} (FieldId={FieldId}, Mask='{Mask}', Inverse={IsInverse}, Values={ValCount}) | Вход: {InCount}, Выход: {OutCount}, Время: {Ms}ms",
i + 1, filter.FieldId, mask, filter.IsInverse, matchingValueIds.Count,
inputCount, currentIds.Count, sw.ElapsedMilliseconds);
if (currentIds.Count == 0)
{
_logger.LogDebug("UnitFieldMatcher: прерывание на фильтре #{Index} (0 юнитов)", i + 1);
break;
}
}
logger.LogDebug("UnitFieldMatcher: выход {UnitCount} юнитов", result.Count);
return result;
_logger.LogDebug("UnitFieldMatcher: итоговый выход {UnitCount} юнитов", currentIds.Count);
return currentIds.ToList();
}
}

View File

@@ -84,8 +84,7 @@ internal class UnitFilterService : IUnitFilterService
public async Task<IEnumerable<UnitFilterResultDto>?> GetUnitsByJobFilterAsync(
Job job,
int? takeCount = null,
CancellationToken cancellationToken = default
)
CancellationToken cancellationToken = default)
{
if (job.Group == null)
throw new ArgumentNullException(nameof(job.Group), $"Job {job.Id} не содержит Group");
@@ -97,13 +96,9 @@ internal class UnitFilterService : IUnitFilterService
var totalStopwatch = Stopwatch.StartNew();
logger.LogInformation("Начало фильтрации юнитов для Job {JobId} с {FilterCount} фильтрами", job.Id, job.UnitFilters.Count);
// Собираем все контексты юнитов, прошедших фильтрацию
var allFilteredContexts = new List<UnitFilterMatchResult>();
// Преобразуем в список для индексации
var unitFiltersList = job.UnitFilters.ToList();
// Этап 1: Применение основных фильтров (Field, Relationship) на уровне SQL
for (int i = 0; i < unitFiltersList.Count; i++)
{
var filter = unitFiltersList[i];
@@ -113,14 +108,21 @@ internal class UnitFilterService : IUnitFilterService
{
logger.LogDebug("Применение фильтра #{Index} (Id={FilterId})", i + 1, filter.Id);
// 1. Найти ID юнитов по UnitFilter
// 1. Resolve
var resolveSw = Stopwatch.StartNew();
var initialUnitIds = await nameResolver.ResolveAsync(filter.UnitFilter, cancellationToken);
resolveSw.Stop();
if (!initialUnitIds.Any())
{
logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов)", i + 1);
logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов после Resolve, {ResolveMs}ms)",
i + 1, resolveSw.ElapsedMilliseconds);
continue;
}
logger.LogDebug("Фильтр #{Index}: Resolve вернул {Count} юнитов за {Ms}ms",
i + 1, initialUnitIds.Count, resolveSw.ElapsedMilliseconds);
#if DEBUG
if (initialUnitIds.Contains(debugTargetUnitId))
{
@@ -128,14 +130,19 @@ internal class UnitFilterService : IUnitFilterService
}
#endif
// 2. Применить FieldFilters
var fieldStopwatch = Stopwatch.StartNew();
//var fieldFilteredIds = await ApplyFieldFiltersOnDbAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
// 2. FieldFilters
var fieldSw = Stopwatch.StartNew();
var fieldFilteredIds = await unitFieldMatcher.MatchAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
fieldSw.Stop();
logger.LogDebug("Фильтр #{Index}: FieldMatcher вернул {Count} юнитов за {Ms}ms",
i + 1, fieldFilteredIds.Count, fieldSw.ElapsedMilliseconds);
if (!fieldFilteredIds.Any())
{
logger.LogDebug("Фильтр #{Index}: 0 юнитов после FieldFilters", i + 1);
filterStopwatch.Stop();
logger.LogDebug("Фильтр #{Index}: завершён (0 юнитов). [Resolve: {R}ms, Field: {F}ms, Total: {T}ms]",
i + 1, resolveSw.ElapsedMilliseconds, fieldSw.ElapsedMilliseconds, filterStopwatch.ElapsedMilliseconds);
continue;
}
@@ -146,16 +153,24 @@ internal class UnitFilterService : IUnitFilterService
}
#endif
// 3. Применить RelationshipFilters
var relStopwatch = Stopwatch.StartNew();
// 3. RelationshipFilters
var relSw = Stopwatch.StartNew();
var relationshipFilteredContexts = await unitRelationshipMatcher.MatchAsync(
fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
relSw.Stop();
if (!relationshipFilteredContexts.Any())
{
logger.LogDebug("Фильтр #{Index}: 0 юнитов после RelationshipFilters", i + 1);
continue;
}
filterStopwatch.Stop();
allFilteredContexts.AddRange(relationshipFilteredContexts);
logger.LogDebug(
"Фильтр #{Index}: добавлено {Count} юнитов. Всего: {Total}. [Resolve: {R}ms, Field: {F}ms, Rel: {Rel}ms, Total: {T}ms]",
i + 1,
relationshipFilteredContexts.Count,
allFilteredContexts.Count,
resolveSw.ElapsedMilliseconds,
fieldSw.ElapsedMilliseconds,
relSw.ElapsedMilliseconds,
filterStopwatch.ElapsedMilliseconds);
#if DEBUG
var targetContext = relationshipFilteredContexts.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
@@ -165,25 +180,13 @@ internal class UnitFilterService : IUnitFilterService
debugTargetUnitId, i + 1, targetContext.ValidParentIds.Count, targetContext.ValidChildIds.Count);
}
#endif
// Добавляем отфильтрованные контексты в общий набор
allFilteredContexts.AddRange(relationshipFilteredContexts);
filterStopwatch.Stop();
logger.LogDebug(
"Фильтр #{Index}: добавлено {Count} юнитов. Всего: {Total}. [Field: {F}ms, Rel: {R}ms, Total: {T}ms]",
i + 1,
relationshipFilteredContexts.Count,
allFilteredContexts.Count,
fieldStopwatch.ElapsedMilliseconds,
relStopwatch.ElapsedMilliseconds,
filterStopwatch.ElapsedMilliseconds
);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при обработке фильтра {FilterId} для Job {JobId}",
filter.Id, job.Id);
filterStopwatch.Stop();
logger.LogError(ex, "Ошибка при обработке фильтра #{Index} (Id={FilterId}) для Job {JobId}. Время до ошибки: {Ms}ms",
i + 1, filter.Id, job.Id, filterStopwatch.ElapsedMilliseconds);
throw; // КРИТИЧНО: прерываем выполнение, чтобы не маскировать проблему
}
}
@@ -197,6 +200,7 @@ internal class UnitFilterService : IUnitFilterService
ValidChildIds = new HashSet<Guid>(g.SelectMany(c => c.ValidChildIds))
})
.ToList();
logger.LogInformation("Этап базовой фильтрации завершён: собрано {UnitCount} уникальных юнитов", mergedContexts.Count);
// Этап 2: Применение Umbrella-фильтра
@@ -219,6 +223,346 @@ internal class UnitFilterService : IUnitFilterService
return result;
}
private async Task<Job?> LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default)
{
return await jobRepository