< Summary

Information
Class: Services.DataServices.TriggerSelfHealing.TriggerBodyErrorHealer
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/DataServices/TriggerSelfHealing/TriggerBodyErrorHealer.cs
Line coverage
100%
Covered lines: 34
Uncovered lines: 0
Coverable lines: 34
Total lines: 103
Line coverage: 100%
Branch coverage
95%
Covered branches: 19
Total branches: 20
Branch coverage: 95%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
.cctor()100%11100%
IsTriggerBodyError(...)100%66100%
ExtractTargetTable(...)100%44100%
TryHealAsync()100%88100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/DataServices/TriggerSelfHealing/TriggerBodyErrorHealer.cs

#LineLine coverage
 1using System.Data.Common;
 2using System.Text.RegularExpressions;
 3using MySqlConnector;
 4
 5namespace 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>
 414public sealed class TriggerBodyErrorHealer(
 415    ITriggerReloader reloader,
 416    TimeSpan cooldown,
 417    Func<DateTime>? utcNow = null)
 18{
 19    /// <summary>ER_ERROR_IN_UNKNOWN_TRIGGER_BODY.</summary>
 20    public const int ErrorInUnknownTriggerBody = 1711;
 21
 122    private static readonly Regex TargetTablePattern = new(
 123        @"^\s*(?:UPDATE|INSERT\s+INTO|DELETE\s+FROM|REPLACE\s+INTO)\s+`?(?<table>[A-Za-z0-9_]+)`?",
 124        RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);
 25
 426    private readonly Func<DateTime> _utcNow = utcNow ?? (() => DateTime.UtcNow);
 427    private readonly SemaphoreSlim _gate = new(1, 1);
 428    private readonly Dictionary<string, DateTime> _lastReloadUtc = new(StringComparer.OrdinalIgnoreCase);
 29
 30    public static bool IsTriggerBodyError(Exception? exception)
 31    {
 6232        for (var e = exception; e is not null; e = e.InnerException)
 33        {
 2434            if (e is MySqlException { Number: ErrorInUnknownTriggerBody })
 835                return true;
 36        }
 37
 738        return false;
 39    }
 40
 41    /// <summary>Таблица, на которой сработал триггер, по тексту упавшей команды.</summary>
 42    public static string? ExtractTargetTable(string? commandText)
 43    {
 1544        if (string.IsNullOrWhiteSpace(commandText))
 245            return null;
 46
 1347        var match = TargetTablePattern.Match(commandText);
 1348        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    {
 856        if (!IsTriggerBodyError(exception))
 157            return new TriggerHealResult(TriggerHealOutcome.NotApplicable, null, 0, null);
 58
 759        var table = ExtractTargetTable(commandText);
 760        if (table is null)
 161            return new TriggerHealResult(TriggerHealOutcome.UnknownTable, null, 0, null);
 62
 663        await _gate.WaitAsync(cancellationToken);
 64        try
 65        {
 666            var now = _utcNow();
 667            if (_lastReloadUtc.TryGetValue(table, out var last) && now - last < cooldown)
 168                return new TriggerHealResult(TriggerHealOutcome.SkippedCooldown, table, 0, null);
 69
 570            _lastReloadUtc[table] = now;
 71            try
 72            {
 573                var reloaded = await reloader.ReloadAsync(table, cancellationToken);
 474                return new TriggerHealResult(TriggerHealOutcome.Reloaded, table, reloaded, null);
 75            }
 176            catch (DbException ex)
 77            {
 178                return new TriggerHealResult(TriggerHealOutcome.Failed, table, 0, ex.Message);
 79            }
 80        }
 81        finally
 82        {
 683            _gate.Release();
 84        }
 885    }
 86}
 87
 88public interface ITriggerReloader
 89{
 90    /// <summary>Пересоздаёт все триггеры таблицы из их текущих определений; возвращает число триггеров.</summary>
 91    Task<int> ReloadAsync(string table, CancellationToken cancellationToken);
 92}
 93
 94public enum TriggerHealOutcome
 95{
 96    NotApplicable,
 97    UnknownTable,
 98    SkippedCooldown,
 99    Reloaded,
 100    Failed,
 101}
 102
 103public sealed record TriggerHealResult(TriggerHealOutcome Outcome, string? Table, int ReloadedTriggers, string? Error);