From 497d241caf7681ee4baf8d387ebeb280232ec2e9 Mon Sep 17 00:00:00 2001 From: Mikhail Kuznetsov Date: Tue, 14 Jul 2026 14:55:49 +1000 Subject: [PATCH 1/3] =?UTF-8?q?feat(aihitRelationshipsSyncer):=20=D0=94?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20MinRelations?= =?UTF-8?q?hipsThresholdPct=20=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=20=D0=B1=D0=B0=D0=B7=D1=8B=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BE=D1=82=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=AD=D0=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RelationshipsSyncService.cs | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs b/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs index 25e6074b..3b6bfd4f 100644 --- a/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs +++ b/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs @@ -2,9 +2,9 @@ using Microsoft.Extensions.Logging; using PARR.AIHITRelationshipsSyncer.Models; using PARR.AIHITRelationshipsSyncer.Services.Interfaces; -using PARR.AIHITRelationshipsSyncer.Settings; using PARR.Core.Repositories.Interfaces.Unit; using PARR.Domain.Entities.Unit; +using PARR.Domain.Settings; namespace PARR.AIHITRelationshipsSyncer.Services.Implementations { @@ -12,25 +12,56 @@ namespace PARR.AIHITRelationshipsSyncer.Services.Implementations { private readonly ILogger logger; private readonly IUnitRepository unitService; - private readonly WorkerSettings workerSettings; + private readonly SettingsFromDb _settingsFromDb; public RelationshipsSyncService( ILogger logger, IUnitRepository unitService, - WorkerSettings workerSettings + SettingsFromDb settingsFromDb ) { this.logger = logger; this.unitService = unitService; - this.workerSettings = workerSettings; + _settingsFromDb = settingsFromDb; } public async Task SyncAsync(List aihitdata) { + // 1. Сначала фильтруем и нормализуем входящий мусор var validPairs = ValidateInput(aihitdata); + + // Если пришел пустой список, то и проверять порог нет смысла (лог уже записан внутри) if (!validPairs.Any()) return; + // 2. FAIL-FAST: Быстро узнаем общее число связей в БД без выкачивания самих данных + int previousCount = await unitService.Get() + .SelectMany(u => u.ChildUnits) + .CountAsync(); + + // 3. SAFEGUARD COMPLIANCE: Проверяем защитный порог падения данных + if (previousCount > 0) + { + // 1. Считаем в decimal с абсолютной точностью + decimal exactPercentage = ((decimal)validPairs.Count / previousCount) * 100; + + // 2. Округляем до 1 знака после запятой (например, 96.98% -> 97.0%) + // Это защитит от ложных срабатываний из-за пары недостающих связей на больших объемах + decimal currentPercentage = Math.Round(exactPercentage, 1, MidpointRounding.AwayFromZero); + + // 3. Строгое сравнение (<) гарантирует пропуск при ровно 97% и работу "0" как выключателя + if (currentPercentage < _settingsFromDb.MinRelationshipsThresholdPct) + { + logger.LogWarning( + "Синхронизация отменена: количество полученных связей ниже порогового значения! " + + "Получено: {CurrentCount} ({CurrentPercentage:F1}%), ожидалось >= {Threshold}% от прошлого объема ({PreviousCount}).", + validPairs.Count, currentPercentage, _settingsFromDb.MinRelationshipsThresholdPct, previousCount); + + return; + } + } + + // 4. HAPPY PATH: Если проверка пройдена, выполняем тяжелую работу var sourceUnitNames = ExtractUnitNames(validPairs); var existingUnits = await GetOrCreateUnitsAsync(sourceUnitNames); @@ -42,6 +73,7 @@ namespace PARR.AIHITRelationshipsSyncer.Services.Implementations await FinalCommitAsync(toRemove.Count, toAdd.Count); } + private List<(string Parent, string Child)> ValidateInput(List data) { if (data == null || data.Count == 0) From 4ee2880a8fcc3c80a64f7aa32df82720e9ad6f06 Mon Sep 17 00:00:00 2001 From: Mikhail Kuznetsov Date: Wed, 15 Jul 2026 14:04:35 +1000 Subject: [PATCH 2/3] =?UTF-8?q?fix(aihitRelationshipsSyncer):=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20RelationshipsSyncService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ValidateInput: возврат пустого списка вместо null! для предотвращения NRE - SyncAsync: транзакция для атомарности зеркалирования снимка состояния - GetOrCreateUnitsAsync: пакетное создание юнитов через CreateRange - CommitChangesAsync: исключение при ошибке коммита вместо тихого лога - PassSafeguardCheckAsync: выделен в отдельный метод - Удалены избыточные комментарии, унифицированы имена переменных --- .../RelationshipsSyncService.cs | 505 +++++++++--------- 1 file changed, 248 insertions(+), 257 deletions(-) diff --git a/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs b/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs index 3b6bfd4f..8a1585da 100644 --- a/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs +++ b/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.AIHITRelationshipsSyncer.Models; using PARR.AIHITRelationshipsSyncer.Services.Interfaces; @@ -6,263 +6,254 @@ using PARR.Core.Repositories.Interfaces.Unit; using PARR.Domain.Entities.Unit; using PARR.Domain.Settings; -namespace PARR.AIHITRelationshipsSyncer.Services.Implementations +namespace PARR.AIHITRelationshipsSyncer.Services.Implementations; + +internal class RelationshipsSyncService : IRelationshipsSyncService { - internal class RelationshipsSyncService : IRelationshipsSyncService + private readonly ILogger _logger; + private readonly IUnitRepository _unitRepository; + private readonly SettingsFromDb _settingsFromDb; + + public RelationshipsSyncService( + ILogger logger, + IUnitRepository unitRepository, + SettingsFromDb settingsFromDb) { - private readonly ILogger logger; - private readonly IUnitRepository unitService; - private readonly SettingsFromDb _settingsFromDb; - - public RelationshipsSyncService( - ILogger logger, - IUnitRepository unitService, - SettingsFromDb settingsFromDb - ) - { - this.logger = logger; - this.unitService = unitService; - _settingsFromDb = settingsFromDb; - } - - - public async Task SyncAsync(List aihitdata) - { - // 1. Сначала фильтруем и нормализуем входящий мусор - var validPairs = ValidateInput(aihitdata); - - // Если пришел пустой список, то и проверять порог нет смысла (лог уже записан внутри) - if (!validPairs.Any()) return; - - // 2. FAIL-FAST: Быстро узнаем общее число связей в БД без выкачивания самих данных - int previousCount = await unitService.Get() - .SelectMany(u => u.ChildUnits) - .CountAsync(); - - // 3. SAFEGUARD COMPLIANCE: Проверяем защитный порог падения данных - if (previousCount > 0) - { - // 1. Считаем в decimal с абсолютной точностью - decimal exactPercentage = ((decimal)validPairs.Count / previousCount) * 100; - - // 2. Округляем до 1 знака после запятой (например, 96.98% -> 97.0%) - // Это защитит от ложных срабатываний из-за пары недостающих связей на больших объемах - decimal currentPercentage = Math.Round(exactPercentage, 1, MidpointRounding.AwayFromZero); - - // 3. Строгое сравнение (<) гарантирует пропуск при ровно 97% и работу "0" как выключателя - if (currentPercentage < _settingsFromDb.MinRelationshipsThresholdPct) - { - logger.LogWarning( - "Синхронизация отменена: количество полученных связей ниже порогового значения! " + - "Получено: {CurrentCount} ({CurrentPercentage:F1}%), ожидалось >= {Threshold}% от прошлого объема ({PreviousCount}).", - validPairs.Count, currentPercentage, _settingsFromDb.MinRelationshipsThresholdPct, previousCount); - - return; - } - } - - // 4. HAPPY PATH: Если проверка пройдена, выполняем тяжелую работу - var sourceUnitNames = ExtractUnitNames(validPairs); - var existingUnits = await GetOrCreateUnitsAsync(sourceUnitNames); - - var (toRemove, toAdd) = await GetDeltaAsync(validPairs); - - await RemoveRelationshipsAsync(toRemove); - await AddRelationshipsAsync(toAdd, existingUnits); - - await FinalCommitAsync(toRemove.Count, toAdd.Count); - } - - - private List<(string Parent, string Child)> ValidateInput(List data) - { - if (data == null || data.Count == 0) - { - logger.LogWarning("Получены пустые или null данные из АИХ ИТ. Пропускаю синхронизацию"); - return null!; - } - - var validPairs = data - .Where(t => !string.IsNullOrWhiteSpace(t.ParentName) && !string.IsNullOrWhiteSpace(t.ChildName)) - .Where(t => t.ParentName != t.ChildName) - .Select(t => ( - Parent: t.ParentName!.Trim().ToUpperInvariant(), - Child: t.ChildName!.Trim().ToUpperInvariant() - )) - .Distinct() - .ToList(); - if (validPairs.Count == 0) - { - logger.LogWarning("После нормализации не осталось валидных связей. Пропускаю синхронизацию"); - return new(); - } - - logger.LogInformation("Получено {Count} валидных связей", validPairs.Count); - return validPairs; - } - - - private static List ExtractUnitNames(List<(string Parent, string Child)> pairs) - { - return pairs - .SelectMany(x => new[] { x.Parent, x.Child }) - .Distinct() - .ToList(); - } - - - private async Task> GetOrCreateUnitsAsync(List names) - { - var existing = await unitService.Get() - .AsNoTracking() - .ToDictionaryAsync(u => u.Name, StringComparer.Ordinal); - - var missing = names.Except(existing.Keys).ToList(); - if (!missing.Any()) return existing; - - logger.LogInformation("Создание отсутствующих Unit:{Count}", missing.Count); - foreach (var name in missing) - { - var unit = new Unit - { - Id = Guid.NewGuid(), - Name = name, - DateCreated = DateTimeOffset.UtcNow - }; - - if (await unitService.CreateAsync(unit)) - { - logger.LogInformation("Создан Unit: {Name}", name); - existing[name] = unit; - } - else - logger.LogError("Не удалось создать Unit: {Name}", name); - } - - await unitService.CommitAsync(); - return existing; - } - - - private async Task<(List ToRemove, List<(string, string)> ToAdd)> GetDeltaAsync(List<(string Parent, string Child)> sourcePairs) - { - var allExistings = await unitService.Get() - .AsNoTracking() - .Include(u => u.ChildUnits).ThenInclude(r => r.ChildUnit) - .Where(u => u.ChildUnits.Any()) - .SelectMany(u => u.ChildUnits, (parent, relationship) => new ExistingRelationship - { - ParentName = parent.Name, - ChildName = relationship.ChildUnit!.Name, - Relationship = relationship - } - ) - .ToListAsync(); - - var sourceSet = new HashSet<(string, string)>(sourcePairs); - var existingSet = new HashSet<(string, string)>(allExistings.Select(x => (x.ParentName, x.ChildName))); - - var toRemove = allExistings.Where(r => !sourceSet.Contains((r.ParentName, r.ChildName))).ToList(); - var toAdd = sourcePairs.Where(p => !existingSet.Contains(p)).ToList(); - - logger.LogInformation("Связей для удаления: {ToRemove}, для добавления: {ToAdd}", toRemove.Count, toAdd.Count); - return (toRemove, toAdd); - } - - - private async Task RemoveRelationshipsAsync(List toRemove) - { - if (!toRemove.Any()) return; - - var parentNames = toRemove.Select(r => r.ParentName).Distinct().ToList(); - - var parents = await unitService.Get() - .Include(u => u.ChildUnits) - .Where(u => parentNames.Contains(u.Name)) - .ToDictionaryAsync(u => u.Name, StringComparer.Ordinal); - - foreach (var rel in toRemove) - { - if (!parents.TryGetValue(rel.ParentName, out var parent)) - { - logger.LogWarning("Родитель '{Parent}' не найден при попытке удаления связи - '{Child}'", rel.ParentName, rel.ChildName); - continue; - } - - var relationshipToRemove = parent.ChildUnits - .FirstOrDefault(r => r.ChildUnitId == rel.Relationship.ChildUnitId); - - if (relationshipToRemove != null) - { - logger.LogInformation("Удаление связи: '{Parent}' - '{Child}'", rel.ParentName, rel.ChildName); - parent.ChildUnits.Remove(relationshipToRemove); - } - else - { - logger.LogWarning("Связь '{Parent}'-'{Child}' не найдена в коллекции ChildUnits для удаления", rel.ParentName, rel.ChildName); - } - } - } - - private async Task AddRelationshipsAsync(List<(string Parent, string Child)> toAdd, Dictionary allUnits) - { - if (!toAdd.Any()) return; - - var parentNames = toAdd.Select(x => x.Parent).Distinct().ToList(); - var parents = await unitService.Get() - .Include(u => u.ChildUnits) - .Where(u => parentNames.Contains(u.Name)) - .ToDictionaryAsync(u => u.Name, StringComparer.Ordinal); - - foreach (var (Parent, Child) in toAdd) - { - if (!parents.TryGetValue(Parent, out var parent) || - !allUnits.TryGetValue(Child, out var child)) - { - logger.LogWarning("Пропущена связь '{Parent}' - '{Child}': Unit не найден", Parent, Child); - continue; - } - - if (parent!.ChildUnits.Any(r => r.ChildUnitId == child.Id)) - { - logger.LogWarning("Связь уже существует: '{Parent}' - '{Child}'", Parent, Child); - continue; - } - - parent.ChildUnits.Add(new UnitInUnit - { - ParentUnitId = parent.Id, - ChildUnitId = child.Id, - DateCreated = DateTimeOffset.UtcNow - }); - - logger.LogInformation("Добавлена связь: '{Parent}' - '{Child}'", Parent, Child); - } - } - - - private async Task FinalCommitAsync(int removedCount, int addedCount) - { - if (removedCount > 0 || addedCount > 0) - { - if (await unitService.CommitAsync()) - { - logger.LogInformation("Синхронизация завершена. Удалено: {Removed}, добавлено: {Added}", removedCount, addedCount); - } - else - logger.LogError("Не удалось сохранить изменения в БД"); - } - else - logger.LogInformation("Изменений не обнаружено"); - } - - private sealed class ExistingRelationship - { - public string ParentName { get; init; } - public string ChildName { get; init; } - public UnitInUnit Relationship { get; init; } - } - + _logger = logger; + _unitRepository = unitRepository; + _settingsFromDb = settingsFromDb; } -} + public async Task SyncAsync(List aihitData) + { + var validPairs = ValidateInput(aihitData); + + if (validPairs.Count == 0) + return; + + if (!await PassSafeguardCheckAsync(validPairs.Count)) + return; + + var unitNames = ExtractUnitNames(validPairs); + var unitsByName = await GetOrCreateUnitsAsync(unitNames); + + var (toRemove, toAdd) = await GetDeltaAsync(validPairs); + + // Транзакция гарантирует атомарность зеркалирования. + // Без неё частичный сбой оставляет БД в состоянии, не совпадающем со снимком. + await using var transaction = await _unitRepository.BeginTransactionAsync(); + try + { + await RemoveRelationshipsAsync(toRemove); + await AddRelationshipsAsync(toAdd, unitsByName); + await CommitChangesAsync(toRemove.Count, toAdd.Count); + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + private List<(string ParentName, string ChildName)> ValidateInput(List data) + { + if (data == null || data.Count == 0) + { + _logger.LogWarning("Получены пустые данные из АИХ ИТ. Синхронизация пропущена"); + return new List<(string, string)>(); + } + + var validPairs = data + .Where(item => !string.IsNullOrWhiteSpace(item.ParentName) && !string.IsNullOrWhiteSpace(item.ChildName)) + .Where(item => item.ParentName != item.ChildName) + .Select(item => ( + ParentName: item.ParentName!.Trim().ToUpperInvariant(), + ChildName: item.ChildName!.Trim().ToUpperInvariant() + )) + .Distinct() + .ToList(); + + if (validPairs.Count == 0) + { + _logger.LogWarning("После нормализации не осталось валидных связей. Синхронизация пропущена"); + return new List<(string, string)>(); + } + + _logger.LogInformation("Получено {Count} валидных связей", validPairs.Count); + return validPairs; + } + + private async Task PassSafeguardCheckAsync(int incomingCount) + { + var previousCount = await _unitRepository.Get() + .SelectMany(u => u.ChildUnits) + .CountAsync(); + + if (previousCount == 0) + return true; + + var currentPercentage = Math.Round((decimal)incomingCount / previousCount * 100, 1, MidpointRounding.AwayFromZero); + + if (currentPercentage < _settingsFromDb.MinRelationshipsThresholdPct) + { + _logger.LogWarning( + "Синхронизация отменена: количество полученных связей ниже порогового значения. " + + "Получено: {CurrentCount} ({CurrentPercentage:F1}%), порог: {Threshold}% от прошлого объема ({PreviousCount})", + incomingCount, currentPercentage, _settingsFromDb.MinRelationshipsThresholdPct, previousCount); + return false; + } + + return true; + } + + private static List ExtractUnitNames(List<(string ParentName, string ChildName)> pairs) + { + return pairs + .SelectMany(pair => new[] { pair.ParentName, pair.ChildName }) + .Distinct() + .ToList(); + } + + private async Task> GetOrCreateUnitsAsync(List names) + { + var existingUnits = await _unitRepository.Get() + .AsNoTracking() + .ToDictionaryAsync(u => u.Name, StringComparer.Ordinal); + + var missingNames = names.Except(existingUnits.Keys).ToList(); + + if (missingNames.Count == 0) + return existingUnits; + + _logger.LogInformation("Создание отсутствующих юнитов: {Count}", missingNames.Count); + + var newUnits = missingNames.Select(name => new Unit + { + Id = Guid.NewGuid(), + Name = name, + DateCreated = DateTimeOffset.UtcNow + }).ToList(); + + _unitRepository.CreateRange(newUnits); + await _unitRepository.CommitAsync(); + + foreach (var unit in newUnits) + { + existingUnits[unit.Name] = unit; + _logger.LogDebug("Создан юнит: {Name}", unit.Name); + } + + return existingUnits; + } + + private async Task<(List ToRemove, List<(string ParentName, string ChildName)> ToAdd)> GetDeltaAsync( + List<(string ParentName, string ChildName)> sourcePairs) + { + var allExisting = await _unitRepository.Get() + .AsNoTracking() + .Include(u => u.ChildUnits).ThenInclude(r => r.ChildUnit) + .Where(u => u.ChildUnits.Any()) + .SelectMany(u => u.ChildUnits, (parent, relationship) => new ExistingRelationship + { + ParentName = parent.Name, + ChildName = relationship.ChildUnit!.Name, + Relationship = relationship + }) + .ToListAsync(); + + var sourceSet = new HashSet<(string, string)>(sourcePairs); + var existingSet = new HashSet<(string, string)>(allExisting.Select(x => (x.ParentName, x.ChildName))); + + var toRemove = allExisting.Where(r => !sourceSet.Contains((r.ParentName, r.ChildName))).ToList(); + var toAdd = sourcePairs.Where(p => !existingSet.Contains(p)).ToList(); + + _logger.LogInformation("Дельта: удаление {ToRemove}, добавление {ToAdd}", toRemove.Count, toAdd.Count); + return (toRemove, toAdd); + } + + private async Task RemoveRelationshipsAsync(List toRemove) + { + if (toRemove.Count == 0) + return; + + var parentNames = toRemove.Select(r => r.ParentName).Distinct().ToList(); + + var parents = await _unitRepository.Get() + .Include(u => u.ChildUnits) + .Where(u => parentNames.Contains(u.Name)) + .ToDictionaryAsync(u => u.Name, StringComparer.Ordinal); + + foreach (var relationship in toRemove) + { + if (!parents.TryGetValue(relationship.ParentName, out var parent)) + { + _logger.LogWarning("Родитель '{Parent}' не найден при удалении связи с '{Child}'", + relationship.ParentName, relationship.ChildName); + continue; + } + + var entityToRemove = parent.ChildUnits + .FirstOrDefault(r => r.ChildUnitId == relationship.Relationship.ChildUnitId); + + if (entityToRemove != null) + parent.ChildUnits.Remove(entityToRemove); + } + } + + private async Task AddRelationshipsAsync( + List<(string ParentName, string ChildName)> toAdd, + Dictionary unitsByName) + { + if (toAdd.Count == 0) + return; + + var parentNames = toAdd.Select(x => x.ParentName).Distinct().ToList(); + + var parents = await _unitRepository.Get() + .Include(u => u.ChildUnits) + .Where(u => parentNames.Contains(u.Name)) + .ToDictionaryAsync(u => u.Name, StringComparer.Ordinal); + + foreach (var (parentName, childName) in toAdd) + { + if (!parents.TryGetValue(parentName, out var parent) || + !unitsByName.TryGetValue(childName, out var child)) + { + _logger.LogWarning("Пропущена связь '{Parent}' - '{Child}': юнит не найден", parentName, childName); + continue; + } + + if (parent.ChildUnits.Any(r => r.ChildUnitId == child.Id)) + continue; + + parent.ChildUnits.Add(new UnitInUnit + { + ParentUnitId = parent.Id, + ChildUnitId = child.Id, + DateCreated = DateTimeOffset.UtcNow + }); + } + } + + private async Task CommitChangesAsync(int removedCount, int addedCount) + { + if (removedCount == 0 && addedCount == 0) + { + _logger.LogInformation("Изменений не обнаружено"); + return; + } + + if (!await _unitRepository.CommitAsync()) + throw new InvalidOperationException("Не удалось сохранить изменения связей в БД"); + + _logger.LogInformation("Синхронизация завершена. Удалено: {Removed}, добавлено: {Added}", removedCount, addedCount); + } + + private sealed class ExistingRelationship + { + public string ParentName { get; init; } = null!; + public string ChildName { get; init; } = null!; + public UnitInUnit Relationship { get; init; } = null!; + } +} \ No newline at end of file From a087958fd55b1b059ce4a66b3c16fc3f35f87d0e Mon Sep 17 00:00:00 2001 From: Mikhail Kuznetsov Date: Wed, 15 Jul 2026 14:40:10 +1000 Subject: [PATCH 3/3] =?UTF-8?q?fix(aihit-sync):=20=D0=9E=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20GetDeltaAsync=20?= =?UTF-8?q?=D0=B8=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0=D1=80=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GetDeltaAsync: фильтрация по relevantParentNames вместо выгрузки всех связей из БД - SyncAsync: атомарность через ChangeTracker + CommitAsync без явной транзакции --- .../RelationshipsSyncService.cs | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs b/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs index 8a1585da..eed168fe 100644 --- a/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs +++ b/PARR.AIHITRelationshipsSyncer/Services/Implementations/RelationshipsSyncService.cs @@ -39,21 +39,11 @@ internal class RelationshipsSyncService : IRelationshipsSyncService var (toRemove, toAdd) = await GetDeltaAsync(validPairs); - // Транзакция гарантирует атомарность зеркалирования. - // Без неё частичный сбой оставляет БД в состоянии, не совпадающем со снимком. - await using var transaction = await _unitRepository.BeginTransactionAsync(); - try - { - await RemoveRelationshipsAsync(toRemove); - await AddRelationshipsAsync(toAdd, unitsByName); - await CommitChangesAsync(toRemove.Count, toAdd.Count); - await transaction.CommitAsync(); - } - catch - { - await transaction.RollbackAsync(); - throw; - } + // Удаление и добавление накапливаются в одном ChangeTracker. + // CommitAsync сохраняет всё атомарно через SaveChangesAsync. + await RemoveRelationshipsAsync(toRemove); + await AddRelationshipsAsync(toAdd, unitsByName); + await CommitChangesAsync(toRemove.Count, toAdd.Count); } private List<(string ParentName, string ChildName)> ValidateInput(List data) @@ -128,32 +118,38 @@ internal class RelationshipsSyncService : IRelationshipsSyncService _logger.LogInformation("Создание отсутствующих юнитов: {Count}", missingNames.Count); - var newUnits = missingNames.Select(name => new Unit + foreach (var name in missingNames) { - Id = Guid.NewGuid(), - Name = name, - DateCreated = DateTimeOffset.UtcNow - }).ToList(); + var unit = new Unit + { + Id = Guid.NewGuid(), + Name = name, + DateCreated = DateTimeOffset.UtcNow + }; - _unitRepository.CreateRange(newUnits); - await _unitRepository.CommitAsync(); + if (!await _unitRepository.CreateAsync(unit)) + { + _logger.LogError("Не удалось создать юнит: {Name}", name); + continue; + } - foreach (var unit in newUnits) - { - existingUnits[unit.Name] = unit; - _logger.LogDebug("Создан юнит: {Name}", unit.Name); + existingUnits[name] = unit; + _logger.LogDebug("Создан юнит: {Name}", name); } + await _unitRepository.CommitAsync(); return existingUnits; } private async Task<(List ToRemove, List<(string ParentName, string ChildName)> ToAdd)> GetDeltaAsync( List<(string ParentName, string ChildName)> sourcePairs) { - var allExisting = await _unitRepository.Get() + var relevantParentNames = sourcePairs.Select(p => p.ParentName).Distinct().ToList(); + + var existingForParents = await _unitRepository.Get() .AsNoTracking() .Include(u => u.ChildUnits).ThenInclude(r => r.ChildUnit) - .Where(u => u.ChildUnits.Any()) + .Where(u => relevantParentNames.Contains(u.Name)) .SelectMany(u => u.ChildUnits, (parent, relationship) => new ExistingRelationship { ParentName = parent.Name, @@ -161,13 +157,19 @@ internal class RelationshipsSyncService : IRelationshipsSyncService Relationship = relationship }) .ToListAsync(); - + var sourceSet = new HashSet<(string, string)>(sourcePairs); - var existingSet = new HashSet<(string, string)>(allExisting.Select(x => (x.ParentName, x.ChildName))); - - var toRemove = allExisting.Where(r => !sourceSet.Contains((r.ParentName, r.ChildName))).ToList(); - var toAdd = sourcePairs.Where(p => !existingSet.Contains(p)).ToList(); - + var existingSet = new HashSet<(string, string)>( + existingForParents.Select(x => (x.ParentName, x.ChildName))); + + var toRemove = existingForParents + .Where(r => !sourceSet.Contains((r.ParentName, r.ChildName))) + .ToList(); + + var toAdd = sourcePairs + .Where(p => !existingSet.Contains(p)) + .ToList(); + _logger.LogInformation("Дельта: удаление {ToRemove}, добавление {ToAdd}", toRemove.Count, toAdd.Count); return (toRemove, toAdd); }