| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Configuration; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | using OzonRepositories.Context; |
| | | 5 | | |
| | | 6 | | namespace Services.SearchServices.OrderSearch; |
| | | 7 | | |
| | | 8 | | public interface IOrderSearchDriftChecker |
| | | 9 | | { |
| | | 10 | | Task<OrderSearchDriftCheckResult> CheckAsync(string trigger, CancellationToken cancellationToken = default); |
| | | 11 | | } |
| | | 12 | | |
| | | 13 | | public sealed class OrderSearchDriftCheckResult |
| | | 14 | | { |
| | | 15 | | public bool Skipped { get; init; } |
| | | 16 | | |
| | | 17 | | public string Trigger { get; init; } = string.Empty; |
| | | 18 | | |
| | | 19 | | public long IndexCount { get; init; } |
| | | 20 | | |
| | | 21 | | public long DbOrderCount { get; init; } |
| | | 22 | | |
| | | 23 | | public long AbsoluteDrift { get; init; } |
| | | 24 | | |
| | | 25 | | public int AlertThreshold { get; init; } |
| | | 26 | | |
| | | 27 | | public bool IsAlert { get; init; } |
| | | 28 | | |
| | | 29 | | public bool IndexExists { get; init; } |
| | | 30 | | |
| | | 31 | | public string Message { get; init; } = string.Empty; |
| | | 32 | | } |
| | | 33 | | |
| | | 34 | | /// <summary> |
| | | 35 | | /// Сравнивает число документов в индексе ES с числом строк в <c>Orders</c>. |
| | | 36 | | /// При |ES − MySQL| > порога — запись в JobLogs и метрики Prometheus. |
| | | 37 | | /// </summary> |
| | | 38 | | public sealed class OrderSearchDriftChecker : IOrderSearchDriftChecker |
| | | 39 | | { |
| | | 40 | | private readonly IOrderSearchService _searchService; |
| | | 41 | | private readonly IOrderSearchDiagnosticsStore _store; |
| | | 42 | | private readonly ApplicationDbContext _context; |
| | | 43 | | private readonly IConfiguration _configuration; |
| | | 44 | | private readonly ILogger<OrderSearchDriftChecker> _logger; |
| | | 45 | | private readonly IOrderSearchDriftMetricsReporter? _metricsReporter; |
| | | 46 | | |
| | 14 | 47 | | public OrderSearchDriftChecker( |
| | 14 | 48 | | IOrderSearchService searchService, |
| | 14 | 49 | | IOrderSearchDiagnosticsStore store, |
| | 14 | 50 | | ApplicationDbContext context, |
| | 14 | 51 | | IConfiguration configuration, |
| | 14 | 52 | | ILogger<OrderSearchDriftChecker> logger, |
| | 14 | 53 | | IOrderSearchDriftMetricsReporter? metricsReporter = null) |
| | | 54 | | { |
| | 14 | 55 | | _searchService = searchService; |
| | 14 | 56 | | _store = store; |
| | 14 | 57 | | _context = context; |
| | 14 | 58 | | _configuration = configuration; |
| | 14 | 59 | | _logger = logger; |
| | 14 | 60 | | _metricsReporter = metricsReporter; |
| | 14 | 61 | | } |
| | | 62 | | |
| | | 63 | | public async Task<OrderSearchDriftCheckResult> CheckAsync(string trigger, CancellationToken cancellationToken = defa |
| | | 64 | | { |
| | 8 | 65 | | if (!_configuration.GetValue<bool>("OrdersSearch:UseElasticsearch")) |
| | | 66 | | { |
| | 1 | 67 | | return new OrderSearchDriftCheckResult |
| | 1 | 68 | | { |
| | 1 | 69 | | Skipped = true, |
| | 1 | 70 | | Trigger = trigger, |
| | 1 | 71 | | Message = "Elasticsearch выключен (OrdersSearch:UseElasticsearch=false)." |
| | 1 | 72 | | }; |
| | | 73 | | } |
| | | 74 | | |
| | 7 | 75 | | var threshold = GetAlertThreshold(); |
| | 7 | 76 | | var dbOrderCount = await _context.Orders.AsNoTracking().CountAsync(cancellationToken); |
| | 7 | 77 | | var indexExists = await _searchService.IndexExistsAsync(); |
| | 7 | 78 | | var indexCount = indexExists ? await _searchService.GetTotalCountAsync() : 0L; |
| | 7 | 79 | | var absoluteDrift = Math.Abs(indexCount - dbOrderCount); |
| | 7 | 80 | | var isAlert = indexExists && absoluteDrift > threshold; |
| | | 81 | | |
| | 7 | 82 | | var message = isAlert |
| | 7 | 83 | | ? $"Расхождение ES↔MySQL: индекс {indexCount:N0}, БД {dbOrderCount:N0}, |Δ|={absoluteDrift:N0} (порог {thres |
| | 7 | 84 | | : $"ES↔MySQL OK: индекс {indexCount:N0}, БД {dbOrderCount:N0}, |Δ|={absoluteDrift:N0}."; |
| | | 85 | | |
| | 7 | 86 | | _store.RecordDrift(new OrderSearchDriftEvent |
| | 7 | 87 | | { |
| | 7 | 88 | | Trigger = trigger, |
| | 7 | 89 | | IndexCount = indexCount, |
| | 7 | 90 | | DbOrderCount = dbOrderCount, |
| | 7 | 91 | | AbsoluteDrift = absoluteDrift, |
| | 7 | 92 | | AlertThreshold = threshold, |
| | 7 | 93 | | IsAlert = isAlert, |
| | 7 | 94 | | Message = message |
| | 7 | 95 | | }); |
| | | 96 | | |
| | 7 | 97 | | _metricsReporter?.Report(indexCount, dbOrderCount, absoluteDrift, isAlert); |
| | | 98 | | |
| | 7 | 99 | | if (isAlert) |
| | | 100 | | { |
| | 1 | 101 | | _logger.LogWarning( |
| | 1 | 102 | | "OrderSearch drift alert ({Trigger}): index={IndexCount}, db={DbCount}, drift={Drift}, threshold={Thresh |
| | 1 | 103 | | trigger, indexCount, dbOrderCount, absoluteDrift, threshold); |
| | | 104 | | } |
| | | 105 | | else |
| | | 106 | | { |
| | 6 | 107 | | _logger.LogInformation( |
| | 6 | 108 | | "OrderSearch drift check ({Trigger}): index={IndexCount}, db={DbCount}, drift={Drift}", |
| | 6 | 109 | | trigger, indexCount, dbOrderCount, absoluteDrift); |
| | | 110 | | } |
| | | 111 | | |
| | 7 | 112 | | return new OrderSearchDriftCheckResult |
| | 7 | 113 | | { |
| | 7 | 114 | | Trigger = trigger, |
| | 7 | 115 | | IndexCount = indexCount, |
| | 7 | 116 | | DbOrderCount = dbOrderCount, |
| | 7 | 117 | | AbsoluteDrift = absoluteDrift, |
| | 7 | 118 | | AlertThreshold = threshold, |
| | 7 | 119 | | IsAlert = isAlert, |
| | 7 | 120 | | IndexExists = indexExists, |
| | 7 | 121 | | Message = message |
| | 7 | 122 | | }; |
| | 8 | 123 | | } |
| | | 124 | | |
| | | 125 | | public static int GetAlertThreshold(IConfiguration configuration) => |
| | 17 | 126 | | Math.Clamp(configuration.GetValue("OrdersSearch:CountDriftAlertThreshold", 50), 0, 1_000_000); |
| | | 127 | | |
| | 7 | 128 | | private int GetAlertThreshold() => GetAlertThreshold(_configuration); |
| | | 129 | | } |