| | | 1 | | using System.Data.Common; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using MySqlConnector; |
| | | 4 | | |
| | | 5 | | namespace Services.DataServices.TriggerSelfHealing; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Самолечение от бага MariaDB 11.4: после прерванного (KILL QUERY по таймауту) выполнения |
| | | 9 | | /// триггера закешированный инстанс таблицы остаётся с «пустой» инструкцией, и каждый следующий |
| | | 10 | | /// UPDATE на этом инстансе падает с 1711 «Unknown trigger has an error in its body: 'Query was empty'». |
| | | 11 | | /// Состояние живёт в mariadbd, рестарт приложения не помогает — лечит только DDL на триггере |
| | | 12 | | /// (сбрасывает все закешированные инстансы таблицы). 14.09.2026 так упало ~5000 UPDATE Orders за день. |
| | | 13 | | /// </summary> |
| | 4 | 14 | | public sealed class TriggerBodyErrorHealer( |
| | 4 | 15 | | ITriggerReloader reloader, |
| | 4 | 16 | | TimeSpan cooldown, |
| | 4 | 17 | | Func<DateTime>? utcNow = null) |
| | | 18 | | { |
| | | 19 | | /// <summary>ER_ERROR_IN_UNKNOWN_TRIGGER_BODY.</summary> |
| | | 20 | | public const int ErrorInUnknownTriggerBody = 1711; |
| | | 21 | | |
| | 1 | 22 | | private static readonly Regex TargetTablePattern = new( |
| | 1 | 23 | | @"^\s*(?:UPDATE|INSERT\s+INTO|DELETE\s+FROM|REPLACE\s+INTO)\s+`?(?<table>[A-Za-z0-9_]+)`?", |
| | 1 | 24 | | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline); |
| | | 25 | | |
| | 4 | 26 | | private readonly Func<DateTime> _utcNow = utcNow ?? (() => DateTime.UtcNow); |
| | 4 | 27 | | private readonly SemaphoreSlim _gate = new(1, 1); |
| | 4 | 28 | | private readonly Dictionary<string, DateTime> _lastReloadUtc = new(StringComparer.OrdinalIgnoreCase); |
| | | 29 | | |
| | | 30 | | public static bool IsTriggerBodyError(Exception? exception) |
| | | 31 | | { |
| | 62 | 32 | | for (var e = exception; e is not null; e = e.InnerException) |
| | | 33 | | { |
| | 24 | 34 | | if (e is MySqlException { Number: ErrorInUnknownTriggerBody }) |
| | 8 | 35 | | return true; |
| | | 36 | | } |
| | | 37 | | |
| | 7 | 38 | | return false; |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | /// <summary>Таблица, на которой сработал триггер, по тексту упавшей команды.</summary> |
| | | 42 | | public static string? ExtractTargetTable(string? commandText) |
| | | 43 | | { |
| | 15 | 44 | | if (string.IsNullOrWhiteSpace(commandText)) |
| | 2 | 45 | | return null; |
| | | 46 | | |
| | 13 | 47 | | var match = TargetTablePattern.Match(commandText); |
| | 13 | 48 | | return match.Success ? match.Groups["table"].Value : null; |
| | | 49 | | } |
| | | 50 | | |
| | | 51 | | public async Task<TriggerHealResult> TryHealAsync( |
| | | 52 | | Exception? exception, |
| | | 53 | | string? commandText, |
| | | 54 | | CancellationToken cancellationToken = default) |
| | | 55 | | { |
| | 8 | 56 | | if (!IsTriggerBodyError(exception)) |
| | 1 | 57 | | return new TriggerHealResult(TriggerHealOutcome.NotApplicable, null, 0, null); |
| | | 58 | | |
| | 7 | 59 | | var table = ExtractTargetTable(commandText); |
| | 7 | 60 | | if (table is null) |
| | 1 | 61 | | return new TriggerHealResult(TriggerHealOutcome.UnknownTable, null, 0, null); |
| | | 62 | | |
| | 6 | 63 | | await _gate.WaitAsync(cancellationToken); |
| | | 64 | | try |
| | | 65 | | { |
| | 6 | 66 | | var now = _utcNow(); |
| | 6 | 67 | | if (_lastReloadUtc.TryGetValue(table, out var last) && now - last < cooldown) |
| | 1 | 68 | | return new TriggerHealResult(TriggerHealOutcome.SkippedCooldown, table, 0, null); |
| | | 69 | | |
| | 5 | 70 | | _lastReloadUtc[table] = now; |
| | | 71 | | try |
| | | 72 | | { |
| | 5 | 73 | | var reloaded = await reloader.ReloadAsync(table, cancellationToken); |
| | 4 | 74 | | return new TriggerHealResult(TriggerHealOutcome.Reloaded, table, reloaded, null); |
| | | 75 | | } |
| | 1 | 76 | | catch (DbException ex) |
| | | 77 | | { |
| | 1 | 78 | | return new TriggerHealResult(TriggerHealOutcome.Failed, table, 0, ex.Message); |
| | | 79 | | } |
| | | 80 | | } |
| | | 81 | | finally |
| | | 82 | | { |
| | 6 | 83 | | _gate.Release(); |
| | | 84 | | } |
| | 8 | 85 | | } |
| | | 86 | | } |
| | | 87 | | |
| | | 88 | | public interface ITriggerReloader |
| | | 89 | | { |
| | | 90 | | /// <summary>Пересоздаёт все триггеры таблицы из их текущих определений; возвращает число триггеров.</summary> |
| | | 91 | | Task<int> ReloadAsync(string table, CancellationToken cancellationToken); |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | public enum TriggerHealOutcome |
| | | 95 | | { |
| | | 96 | | NotApplicable, |
| | | 97 | | UnknownTable, |
| | | 98 | | SkippedCooldown, |
| | | 99 | | Reloaded, |
| | | 100 | | Failed, |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | public sealed record TriggerHealResult(TriggerHealOutcome Outcome, string? Table, int ReloadedTriggers, string? Error); |