< Summary

Information
Class: OzonOrdersWeb.Observability.HangfireDistributedLockContentionFilter
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/HangfireSafeDistributedLockFilter.cs
Line coverage
90%
Covered lines: 36
Uncovered lines: 4
Coverable lines: 40
Total lines: 135
Line coverage: 90%
Branch coverage
69%
Covered branches: 29
Total branches: 42
Branch coverage: 69%
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%11100%
OnPerformed(...)62.5%1616100%
IsDistributedLockContentionException(...)73.07%302682.6%

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
 18    public HangfireSafeDistributedLockFilter()
 19    {
 20        // Выполняется после DisableConcurrentExecutionAttribute.OnPerforming (у того Order = -1 по умолчанию).
 21        // Поэтому в filterContext.Items уже лежит созданный DistributedLock.
 22        Order = int.MaxValue;
 23    }
 24
 25    public void OnPerforming(PerformingContext filterContext)
 26    {
 27        if (filterContext.Items.TryGetValue(DistributedLockKey, out var lockObj) &&
 28            lockObj is IDisposable rawLock &&
 29            lockObj is not SafeDistributedLockWrapper)
 30        {
 31            var logger = Log.ForContext("EventKind", "HangfireJob")
 32                .ForContext("EventCategory", "jobs")
 33                .ForContext("JobType", filterContext.BackgroundJob.Job.Type.FullName)
 34                .ForContext("JobMethod", filterContext.BackgroundJob.Job.Method.Name)
 35                .ForContext("JobId", filterContext.BackgroundJob.Id);
 36
 37            filterContext.Items[DistributedLockKey] = new SafeDistributedLockWrapper(rawLock, logger);
 38        }
 39    }
 40
 41    public void OnPerformed(PerformedContext filterContext)
 42    {
 43        // Не требуется: DisableConcurrentExecutionAttribute.OnPerformed сам вызовет Dispose() у обёрнутого замка.
 44    }
 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{
 458    public HangfireDistributedLockContentionFilter()
 59    {
 60        // Выполняется с приоритетом Order = -1 (минимально допустимый в Hangfire JobFilterAttribute).
 61        // Так как этот фильтр глобальный (JobFilterScope.Global), Hangfire ставит его перед
 62        // фильтрами уровня метода (JobFilterScope.Method) с таким же Order.
 63        // Поэтому в OnPerformed он перехватывает исключение захвата распределённого лока.
 464        Order = -1;
 465    }
 66
 67    public void OnPerforming(PerformingContext filterContext)
 68    {
 169    }
 70
 71    public void OnPerformed(PerformedContext filterContext)
 72    {
 373        if (filterContext.Exception is not null && IsDistributedLockContentionException(filterContext.Exception))
 74        {
 275            filterContext.ExceptionHandled = true;
 76
 277            var job = filterContext.BackgroundJob.Job;
 278            var jobType = job?.Type.Name ?? "Unknown";
 279            var jobMethod = job?.Method.Name ?? "Unknown";
 80
 281            Log.ForContext("EventCategory", "jobs")
 282                .ForContext("EventKind", "HangfireJob")
 283                .ForContext("JobType", job?.Type.FullName ?? jobType)
 284                .ForContext("JobMethod", jobMethod)
 285                .ForContext("JobId", filterContext.BackgroundJob.Id)
 286                .Information("Hangfire job {JobType}.{JobMethod} пропущен: параллельный запуск уже выполняется (заблокир
 287                    jobType, jobMethod);
 88        }
 389    }
 90
 91    public static bool IsDistributedLockContentionException(Exception? exception)
 92    {
 1093        if (exception is null)
 094            return false;
 95
 1096        var queue = new Queue<Exception>();
 1097        queue.Enqueue(exception);
 98
 1799        while (queue.Count > 0)
 100        {
 14101            var current = queue.Dequeue();
 14102            var typeName = current.GetType().FullName ?? string.Empty;
 103
 14104            if (typeName.Contains("DistributedLock", StringComparison.OrdinalIgnoreCase) &&
 14105                typeName.Contains("Exception", StringComparison.OrdinalIgnoreCase))
 106            {
 1107                return true;
 108            }
 109
 13110            var message = current.Message;
 13111            if (!string.IsNullOrEmpty(message) &&
 13112                (message.Contains("cannot acquire lock", StringComparison.OrdinalIgnoreCase) ||
 13113                 message.Contains("could not acquire lock", StringComparison.OrdinalIgnoreCase) ||
 13114                 message.Contains("AcquireDistributedLock", StringComparison.OrdinalIgnoreCase)))
 115            {
 6116                return true;
 117            }
 118
 7119            if (current is AggregateException agg)
 120            {
 0121                foreach (var inner in agg.InnerExceptions)
 122                {
 0123                    if (inner is not null)
 0124                        queue.Enqueue(inner);
 125                }
 126            }
 7127            else if (current.InnerException is not null)
 128            {
 4129                queue.Enqueue(current.InnerException);
 130            }
 131        }
 132
 3133        return false;
 134    }
 135}