| | | 1 | | using System.Diagnostics; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using Microsoft.Extensions.Configuration; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | |
| | | 8 | | namespace Services.SearchServices.OrderSearch; |
| | | 9 | | |
| | | 10 | | public class OrderSearchSyncService : IOrderSearchSyncService |
| | | 11 | | { |
| | | 12 | | private const int FullReindexBatchSize = 2000; |
| | | 13 | | private const int DriftRepairBatchSize = 2000; |
| | | 14 | | private const int DriftRepairDeleteBatchSize = 500; |
| | | 15 | | |
| | | 16 | | private readonly ApplicationDbContext _context; |
| | | 17 | | private readonly IOrderSearchService _searchService; |
| | | 18 | | private readonly IOrderSearchDriftChecker _driftChecker; |
| | | 19 | | private readonly IConfiguration _configuration; |
| | | 20 | | private readonly ILogger<OrderSearchSyncService> _logger; |
| | | 21 | | private readonly IOrderSearchDiagnosticsStore _diagnostics; |
| | | 22 | | |
| | 11 | 23 | | public OrderSearchSyncService( |
| | 11 | 24 | | ApplicationDbContext context, |
| | 11 | 25 | | IOrderSearchService searchService, |
| | 11 | 26 | | IOrderSearchDriftChecker driftChecker, |
| | 11 | 27 | | IConfiguration configuration, |
| | 11 | 28 | | ILogger<OrderSearchSyncService> logger, |
| | 11 | 29 | | IOrderSearchDiagnosticsStore diagnostics) |
| | | 30 | | { |
| | 11 | 31 | | _context = context; |
| | 11 | 32 | | _searchService = searchService; |
| | 11 | 33 | | _driftChecker = driftChecker; |
| | 11 | 34 | | _configuration = configuration; |
| | 11 | 35 | | _logger = logger; |
| | 11 | 36 | | _diagnostics = diagnostics; |
| | 11 | 37 | | } |
| | | 38 | | |
| | | 39 | | private static IQueryable<OrderSearchDocument> Project(IQueryable<Order> orders) => |
| | 11 | 40 | | orders.Select(o => new OrderSearchDocument |
| | 11 | 41 | | { |
| | 11 | 42 | | Id = o.Id, |
| | 11 | 43 | | ShipmentNumber = o.ShipmentNumber, |
| | 11 | 44 | | Scanit = o.Scanit, |
| | 11 | 45 | | ProductName = o.ProductName, |
| | 11 | 46 | | Article = o.Article, |
| | 11 | 47 | | ProductKey = o.ProductKey, |
| | 11 | 48 | | AppStatusName = o.AppStatus != null ? o.AppStatus.Name : null, |
| | 11 | 49 | | SupplierName = o.Supplier != null ? o.Supplier.Name : null, |
| | 11 | 50 | | ManufacturerName = o.EtProducer != null ? o.EtProducer.Name : null, |
| | 11 | 51 | | ShipmentWarehouseName = o.ShipmentWarehouse != null ? o.ShipmentWarehouse.Name : null, |
| | 11 | 52 | | OzonClientName = o.OzonClient != null ? o.OzonClient.Name : null, |
| | 11 | 53 | | IsArchived = o.OrderArchive != null, |
| | 11 | 54 | | UpdatedAt = o.UpdatedAt |
| | 11 | 55 | | }); |
| | | 56 | | |
| | | 57 | | public async Task SyncOrderAsync(int orderId) |
| | | 58 | | { |
| | | 59 | | try |
| | | 60 | | { |
| | 0 | 61 | | var document = await Project(_context.Orders.AsNoTracking().Where(o => o.Id == orderId)) |
| | 0 | 62 | | .FirstOrDefaultAsync(); |
| | | 63 | | |
| | 0 | 64 | | if (document == null) |
| | | 65 | | { |
| | 0 | 66 | | await _searchService.DeleteFromIndexAsync(orderId); |
| | 0 | 67 | | return; |
| | | 68 | | } |
| | | 69 | | |
| | 0 | 70 | | await _searchService.IndexOrderAsync(document); |
| | 0 | 71 | | } |
| | 0 | 72 | | catch (Exception ex) |
| | | 73 | | { |
| | 0 | 74 | | _logger.LogWarning(ex, "Не удалось переиндексировать заказ {OrderId} — подхватит incremental sync", orderId) |
| | 0 | 75 | | } |
| | 0 | 76 | | } |
| | | 77 | | |
| | | 78 | | public async Task<OrderSearchSyncCheckpoint?> SyncBatchAsync( |
| | | 79 | | DateTime sinceUtc, |
| | | 80 | | int sinceOrderId, |
| | | 81 | | int batchSize) |
| | | 82 | | { |
| | 6 | 83 | | var batch = await Project(_context.Orders.AsNoTracking() |
| | 6 | 84 | | .Where(o => o.UpdatedAt != null && |
| | 6 | 85 | | (o.UpdatedAt > sinceUtc || |
| | 6 | 86 | | (o.UpdatedAt == sinceUtc && o.Id > sinceOrderId))) |
| | 6 | 87 | | .OrderBy(o => o.UpdatedAt) |
| | 6 | 88 | | .ThenBy(o => o.Id)) |
| | 6 | 89 | | .Take(batchSize) |
| | 6 | 90 | | .ToListAsync(); |
| | | 91 | | |
| | 6 | 92 | | if (batch.Count == 0) |
| | | 93 | | { |
| | 1 | 94 | | return null; |
| | | 95 | | } |
| | | 96 | | |
| | 5 | 97 | | var ok = await _searchService.BulkIndexOrdersAsync(batch); |
| | 5 | 98 | | if (!ok) |
| | | 99 | | { |
| | 1 | 100 | | _logger.LogWarning("Incremental sync: bulk-индексация {Count} заказов завершилась с ошибками", batch.Count); |
| | | 101 | | // Нельзя подтверждать checkpoint: иначе заказы из неуспешного пакета будут |
| | | 102 | | // навсегда пропущены следующими запусками incremental sync. |
| | 1 | 103 | | throw new InvalidOperationException( |
| | 1 | 104 | | $"Incremental sync: не удалось проиндексировать пакет из {batch.Count} заказов."); |
| | | 105 | | } |
| | | 106 | | |
| | 4 | 107 | | var last = batch[^1]; |
| | 4 | 108 | | return new OrderSearchSyncCheckpoint(last.UpdatedAt!.Value, last.Id); |
| | 5 | 109 | | } |
| | | 110 | | |
| | | 111 | | public async Task FullReindexAsync() |
| | | 112 | | { |
| | 2 | 113 | | var sw = Stopwatch.StartNew(); |
| | 2 | 114 | | if (!await _searchService.DeleteIndexAsync()) |
| | | 115 | | { |
| | 0 | 116 | | throw new InvalidOperationException("Полная переиндексация: не удалось удалить предыдущий индекс."); |
| | | 117 | | } |
| | | 118 | | |
| | 2 | 119 | | if (!await _searchService.CreateIndexAsync()) |
| | | 120 | | { |
| | 0 | 121 | | throw new InvalidOperationException("Полная переиндексация: не удалось создать индекс."); |
| | | 122 | | } |
| | | 123 | | |
| | 2 | 124 | | var processed = 0; |
| | 2 | 125 | | var lastId = 0; |
| | | 126 | | |
| | 2 | 127 | | while (true) |
| | | 128 | | { |
| | 4 | 129 | | var batch = await Project(_context.Orders.AsNoTracking() |
| | 4 | 130 | | .Where(o => o.Id > lastId) |
| | 4 | 131 | | .OrderBy(o => o.Id)) |
| | 4 | 132 | | .Take(FullReindexBatchSize) |
| | 4 | 133 | | .ToListAsync(); |
| | | 134 | | |
| | 4 | 135 | | if (batch.Count == 0) |
| | | 136 | | { |
| | | 137 | | break; |
| | | 138 | | } |
| | | 139 | | |
| | 3 | 140 | | if (!await _searchService.BulkIndexOrdersAsync(batch)) |
| | | 141 | | { |
| | 1 | 142 | | throw new InvalidOperationException( |
| | 1 | 143 | | $"Полная переиндексация: не удалось проиндексировать пакет из {batch.Count} заказов."); |
| | | 144 | | } |
| | | 145 | | |
| | 2 | 146 | | processed += batch.Count; |
| | 2 | 147 | | lastId = batch[^1].Id; |
| | 2 | 148 | | } |
| | | 149 | | |
| | 1 | 150 | | _diagnostics.RecordIndex( |
| | 1 | 151 | | OrderSearchIndexOperation.FullReindex, |
| | 1 | 152 | | processed, |
| | 1 | 153 | | sw.ElapsedMilliseconds, |
| | 1 | 154 | | true, |
| | 1 | 155 | | $"Полная переиндексация: {processed} заказов"); |
| | | 156 | | |
| | 1 | 157 | | _logger.LogInformation("OrderSearch full reindex: проиндексировано {Count} заказов", processed); |
| | 1 | 158 | | } |
| | | 159 | | |
| | | 160 | | public async Task<OrderSearchDriftRepairResult> RepairDriftAsync( |
| | | 161 | | string trigger, |
| | | 162 | | CancellationToken cancellationToken = default) |
| | | 163 | | { |
| | 4 | 164 | | if (!_configuration.GetValue<bool>("OrdersSearch:UseElasticsearch")) |
| | | 165 | | { |
| | 1 | 166 | | return new OrderSearchDriftRepairResult |
| | 1 | 167 | | { |
| | 1 | 168 | | Skipped = true, |
| | 1 | 169 | | Trigger = trigger, |
| | 1 | 170 | | Message = "Elasticsearch выключен (OrdersSearch:UseElasticsearch=false)." |
| | 1 | 171 | | }; |
| | | 172 | | } |
| | | 173 | | |
| | 3 | 174 | | if (!await _searchService.IndexExistsAsync()) |
| | | 175 | | { |
| | 0 | 176 | | await _searchService.CreateIndexAsync(); |
| | | 177 | | } |
| | | 178 | | |
| | 3 | 179 | | var before = await _driftChecker.CheckAsync($"{trigger}/Before", cancellationToken); |
| | 3 | 180 | | if (before.Skipped) |
| | | 181 | | { |
| | 0 | 182 | | return new OrderSearchDriftRepairResult |
| | 0 | 183 | | { |
| | 0 | 184 | | Skipped = true, |
| | 0 | 185 | | Trigger = trigger, |
| | 0 | 186 | | Message = before.Message |
| | 0 | 187 | | }; |
| | | 188 | | } |
| | | 189 | | |
| | 3 | 190 | | if (before.AbsoluteDrift == 0) |
| | | 191 | | { |
| | 1 | 192 | | return new OrderSearchDriftRepairResult |
| | 1 | 193 | | { |
| | 1 | 194 | | Skipped = true, |
| | 1 | 195 | | Trigger = trigger, |
| | 1 | 196 | | Message = "Расхождение отсутствует — исправление не требуется.", |
| | 1 | 197 | | IndexCountBefore = before.IndexCount, |
| | 1 | 198 | | DbOrderCountBefore = before.DbOrderCount, |
| | 1 | 199 | | AbsoluteDriftBefore = before.AbsoluteDrift, |
| | 1 | 200 | | IndexCountAfter = before.IndexCount, |
| | 1 | 201 | | DbOrderCountAfter = before.DbOrderCount, |
| | 1 | 202 | | AbsoluteDriftAfter = before.AbsoluteDrift, |
| | 1 | 203 | | IsAlertAfter = before.IsAlert |
| | 1 | 204 | | }; |
| | | 205 | | } |
| | | 206 | | |
| | 2 | 207 | | var sw = Stopwatch.StartNew(); |
| | 2 | 208 | | var indexedMissing = 0; |
| | 2 | 209 | | var deletedOrphans = 0; |
| | | 210 | | |
| | 2 | 211 | | var dbIds = await _context.Orders.AsNoTracking() |
| | 2 | 212 | | .OrderBy(o => o.Id) |
| | 2 | 213 | | .Select(o => o.Id) |
| | 2 | 214 | | .ToListAsync(cancellationToken); |
| | 2 | 215 | | var dbIdSet = dbIds.ToHashSet(); |
| | | 216 | | |
| | 2 | 217 | | var processed = 0; |
| | 4 | 218 | | while (processed < dbIds.Count) |
| | | 219 | | { |
| | 2 | 220 | | var idBatch = dbIds.Skip(processed).Take(DriftRepairBatchSize).ToList(); |
| | 2 | 221 | | processed += idBatch.Count; |
| | | 222 | | |
| | 2 | 223 | | var existing = await _searchService.GetExistingOrderIdsAsync(idBatch, cancellationToken); |
| | 10 | 224 | | var missingIds = idBatch.Where(id => !existing.Contains(id)).ToList(); |
| | 2 | 225 | | if (missingIds.Count == 0) |
| | | 226 | | { |
| | | 227 | | continue; |
| | | 228 | | } |
| | | 229 | | |
| | 1 | 230 | | var documents = await Project(_context.Orders.AsNoTracking().Where(o => missingIds.Contains(o.Id))) |
| | 1 | 231 | | .ToListAsync(cancellationToken); |
| | | 232 | | |
| | 1 | 233 | | if (documents.Count == 0) |
| | | 234 | | { |
| | | 235 | | continue; |
| | | 236 | | } |
| | | 237 | | |
| | 1 | 238 | | if (await _searchService.BulkIndexOrdersAsync(documents)) |
| | | 239 | | { |
| | 1 | 240 | | indexedMissing += documents.Count; |
| | | 241 | | } |
| | 1 | 242 | | } |
| | | 243 | | |
| | 2 | 244 | | var indexedIds = await _searchService.GetAllIndexedOrderIdsAsync(cancellationToken); |
| | 12 | 245 | | var orphanIds = indexedIds.Where(id => !dbIdSet.Contains(id)).ToList(); |
| | | 246 | | |
| | 6 | 247 | | for (var i = 0; i < orphanIds.Count; i += DriftRepairDeleteBatchSize) |
| | | 248 | | { |
| | 1 | 249 | | var deleteBatch = orphanIds.Skip(i).Take(DriftRepairDeleteBatchSize).ToList(); |
| | 1 | 250 | | deletedOrphans += await _searchService.BulkDeleteFromIndexAsync(deleteBatch, cancellationToken); |
| | | 251 | | } |
| | | 252 | | |
| | 2 | 253 | | var after = await _driftChecker.CheckAsync(trigger, cancellationToken); |
| | | 254 | | |
| | 2 | 255 | | var message = after.AbsoluteDrift > 0 |
| | 2 | 256 | | ? $"Исправление завершено с остаточным расхождением: доиндексировано {indexedMissing:N0}, удалено сирот {del |
| | 2 | 257 | | : $"Расхождение исправлено: доиндексировано {indexedMissing:N0}, удалено сирот {deletedOrphans:N0}. {after.M |
| | | 258 | | |
| | 2 | 259 | | _diagnostics.RecordIndex( |
| | 2 | 260 | | OrderSearchIndexOperation.DriftRepair, |
| | 2 | 261 | | indexedMissing + deletedOrphans, |
| | 2 | 262 | | sw.ElapsedMilliseconds, |
| | 2 | 263 | | after.AbsoluteDrift == 0, |
| | 2 | 264 | | message); |
| | | 265 | | |
| | 2 | 266 | | _logger.LogInformation( |
| | 2 | 267 | | "OrderSearch drift repair ({Trigger}): indexed={Indexed}, deleted={Deleted}, driftBefore={DriftBefore}, drif |
| | 2 | 268 | | trigger, |
| | 2 | 269 | | indexedMissing, |
| | 2 | 270 | | deletedOrphans, |
| | 2 | 271 | | before.AbsoluteDrift, |
| | 2 | 272 | | after.AbsoluteDrift); |
| | | 273 | | |
| | 2 | 274 | | return new OrderSearchDriftRepairResult |
| | 2 | 275 | | { |
| | 2 | 276 | | Trigger = trigger, |
| | 2 | 277 | | Message = message, |
| | 2 | 278 | | IndexCountBefore = before.IndexCount, |
| | 2 | 279 | | DbOrderCountBefore = before.DbOrderCount, |
| | 2 | 280 | | AbsoluteDriftBefore = before.AbsoluteDrift, |
| | 2 | 281 | | IndexedMissing = indexedMissing, |
| | 2 | 282 | | DeletedOrphans = deletedOrphans, |
| | 2 | 283 | | IndexCountAfter = after.IndexCount, |
| | 2 | 284 | | DbOrderCountAfter = after.DbOrderCount, |
| | 2 | 285 | | AbsoluteDriftAfter = after.AbsoluteDrift, |
| | 2 | 286 | | IsAlertAfter = after.IsAlert |
| | 2 | 287 | | }; |
| | 4 | 288 | | } |
| | | 289 | | } |