< Summary

Information
Class: OzonOrdersWeb.Observability.HangfireSafeDistributedLockFilter
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/HangfireSafeDistributedLockFilter.cs
Line coverage
92%
Covered lines: 13
Uncovered lines: 1
Coverable lines: 14
Total lines: 135
Line coverage: 92.8%
Branch coverage
100%
Covered branches: 6
Total branches: 6
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
OnPerforming(...)100%66100%
OnPerformed(...)100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/HangfireSafeDistributedLockFilter.cs

#LineLine coverage
 1using Hangfire.Common;
 2using Hangfire.Server;
 3using Serilog;
 4
 5namespace OzonOrdersWeb.Observability;
 6
 7/// <summary>
 8/// Фильтр для безопасного освобождения блокировок [DisableConcurrentExecution].
 9/// Hangfire в DisableConcurrentExecutionAttribute.OnPerformed вызывает Dispose() на блокировке,
 10/// и если соединение с MariaDB было разорвано/закрыто за время работы длинного задания (напр. RunAllPresetsJob),
 11/// вызов DO RELEASE_LOCK выбрасывает MySqlException: Failed to read the result set, роняя успешно выполненный джоб.
 12/// Этот фильтр оборачивает объект DistributedLock в безопасную обёртку SafeDistributedLockWrapper.
 13/// </summary>
 14public sealed class HangfireSafeDistributedLockFilter : JobFilterAttribute, IServerFilter
 15{
 16    public const string DistributedLockKey = "DistributedLock";
 17
 318    public HangfireSafeDistributedLockFilter()
 19    {
 20        // Выполняется после DisableConcurrentExecutionAttribute.OnPerforming (у того Order = -1 по умолчанию).
 21        // Поэтому в filterContext.Items уже лежит созданный DistributedLock.
 322        Order = int.MaxValue;
 323    }
 24
 25    public void OnPerforming(PerformingContext filterContext)
 26    {
 327        if (filterContext.Items.TryGetValue(DistributedLockKey, out var lockObj) &&
 328            lockObj is IDisposable rawLock &&
 329            lockObj is not SafeDistributedLockWrapper)
 30        {
 131            var logger = Log.ForContext("EventKind", "HangfireJob")
 132                .ForContext("EventCategory", "jobs")
 133                .ForContext("JobType", filterContext.BackgroundJob.Job.Type.FullName)
 134                .ForContext("JobMethod", filterContext.BackgroundJob.Job.Method.Name)
 135                .ForContext("JobId", filterContext.BackgroundJob.Id);
 36
 137            filterContext.Items[DistributedLockKey] = new SafeDistributedLockWrapper(rawLock, logger);
 38        }
 339    }
 40
 41    public void OnPerformed(PerformedContext filterContext)
 42    {
 43        // Не требуется: DisableConcurrentExecutionAttribute.OnPerformed сам вызовет Dispose() у обёрнутого замка.
 044    }
 45}
 46
 47/// <summary>
 48/// Фильтр для обработки конкуренции за распределённую блокировку [DisableConcurrentExecution].
 49/// Выполняется снаружи DisableConcurrentExecutionAttribute (Order = -20 против Order = -1 у атрибута).
 50/// Если параллельный запуск джоба уже выполняется другим воркером/процессом,
 51/// AcquireDistributedLock бросает MySqlDistributedLockException ("cannot acquire lock").
 52/// Этот фильтр перехватывает его в OnPerformed, выставляет filterContext.ExceptionHandled = true
 53/// и логирует Information вместо аварийного сбоя воркера (JobPerformanceException)
 54/// и бессмысленных повторов через AutomaticRetry.
 55/// </summary>
 56public sealed class HangfireDistributedLockContentionFilter : JobFilterAttribute, IServerFilter
 57{
 58    public HangfireDistributedLockContentionFilter()
 59    {
 60        // Выполняется с приоритетом Order = -1 (минимально допустимый в Hangfire JobFilterAttribute).
 61        // Так как этот фильтр глобальный (JobFilterScope.Global), Hangfire ставит его перед
 62        // фильтрами уровня метода (JobFilterScope.Method) с таким же Order.
 63        // Поэтому в OnPerformed он перехватывает исключение захвата распределённого лока.
 64        Order = -1;
 65    }
 66
 67    public void OnPerforming(PerformingContext filterContext)
 68    {
 69    }
 70
 71    public void OnPerformed(PerformedContext filterContext)
 72    {
 73        if (filterContext.Exception is not null && IsDistributedLockContentionException(filterContext.Exception))
 74        {
 75            filterContext.ExceptionHandled = true;
 76
 77            var job = filterContext.BackgroundJob.Job;
 78            var jobType = job?.Type.Name ?? "Unknown";
 79            var jobMethod = job?.Method.Name ?? "Unknown";
 80
 81            Log.ForContext("EventCategory", "jobs")
 82                .ForContext("EventKind", "HangfireJob")
 83                .ForContext("JobType", job?.Type.FullName ?? jobType)
 84                .ForContext("JobMethod", jobMethod)
 85                .ForContext("JobId", filterContext.BackgroundJob.Id)
 86                .Information("Hangfire job {JobType}.{JobMethod} пропущен: параллельный запуск уже выполняется (заблокир
 87                    jobType, jobMethod);
 88        }
 89    }
 90
 91    public static bool IsDistributedLockContentionException(Exception? exception)
 92    {
 93        if (exception is null)
 94            return false;
 95
 96        var queue = new Queue<Exception>();
 97        queue.Enqueue(exception);
 98
 99        while (queue.Count > 0)
 100        {
 101            var current = queue.Dequeue();
 102            var typeName = current.GetType().FullName ?? string.Empty;
 103
 104            if (typeName.Contains("DistributedLock", StringComparison.OrdinalIgnoreCase) &&
 105                typeName.Contains("Exception", StringComparison.OrdinalIgnoreCase))
 106            {
 107                return true;
 108            }
 109
 110            var message = current.Message;
 111            if (!string.IsNullOrEmpty(message) &&
 112                (message.Contains("cannot acquire lock", StringComparison.OrdinalIgnoreCase) ||
 113                 message.Contains("could not acquire lock", StringComparison.OrdinalIgnoreCase) ||
 114                 message.Contains("AcquireDistributedLock", StringComparison.OrdinalIgnoreCase)))
 115            {
 116                return true;
 117            }
 118
 119            if (current is AggregateException agg)
 120            {
 121                foreach (var inner in agg.InnerExceptions)
 122                {
 123                    if (inner is not null)
 124                        queue.Enqueue(inner);
 125                }
 126            }
 127            else if (current.InnerException is not null)
 128            {
 129                queue.Enqueue(current.InnerException);
 130            }
 131        }
 132
 133        return false;
 134    }
 135}