< Summary

Information
Class: OzonOrdersWeb.HostedServices.HangfireSchedulerWatchdogHostedService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/HostedServices/HangfireSchedulerWatchdogHostedService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 53
Coverable lines: 53
Total lines: 119
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 20
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
ExecuteAsync()0%7280%
RunPassAsync()0%156120%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/HostedServices/HangfireSchedulerWatchdogHostedService.cs

#LineLine coverage
 1using Serilog;
 2using Services.HangfireService;
 3
 4namespace OzonOrdersWeb.HostedServices;
 5
 6/// <summary>
 7/// Вотчдог планировщика Hangfire: замечает, что recurring-джобы не ставятся при живых серверах,
 8/// откатывает осиротевшие XA PREPARED-транзакции (они держат блокировки таблиц Hangfire),
 9/// чистит джобы без состояния и пишет Log.Error (лог-вотчер заведёт issue).
 10/// </summary>
 11/// <remarks>
 12/// Healthcheck и heartbeat серверов этот сбой не показывают: воркеры живы, просто планировщику
 13/// нечего им отдать. Инцидент 09–14.09.2026 — пять дней без загрузки заказов.
 14/// Конфиг: <c>Hangfire:Watchdog:{Enabled,IntervalMinutes,StuckThresholdMinutes}</c>.
 15/// </remarks>
 16public sealed class HangfireSchedulerWatchdogHostedService : BackgroundService
 17{
 018    private static readonly TimeSpan StatelessJobAge = TimeSpan.FromMinutes(5);
 019    private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(3);
 20
 21    private readonly IServiceProvider _services;
 22    private readonly IConfiguration _configuration;
 23
 024    private HashSet<string> _xaSeenOnPreviousPass = new(StringComparer.Ordinal);
 25    private bool _alertActive;
 26
 027    public HangfireSchedulerWatchdogHostedService(IServiceProvider services, IConfiguration configuration)
 28    {
 029        _services = services;
 030        _configuration = configuration;
 031    }
 32
 33    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 34    {
 035        if (!_configuration.GetValue("Hangfire:ServerEnabled", true)
 036            || !_configuration.GetValue("Hangfire:Watchdog:Enabled", true))
 37        {
 038            Log.Information("Hangfire watchdog: отключён конфигурацией");
 039            return;
 40        }
 41
 042        var interval = TimeSpan.FromMinutes(Math.Max(1, _configuration.GetValue("Hangfire:Watchdog:IntervalMinutes", 2))
 043        var stuckThreshold = TimeSpan.FromMinutes(Math.Max(1, _configuration.GetValue("Hangfire:Watchdog:StuckThresholdM
 44
 45        // После деплоя планировщик сам догоняет просроченное — не поднимать тревогу на старте.
 46        try
 47        {
 048            await Task.Delay(StartupDelay, stoppingToken);
 049        }
 050        catch (OperationCanceledException)
 51        {
 052            return;
 53        }
 54
 055        using var timer = new PeriodicTimer(interval);
 56        do
 57        {
 58            try
 59            {
 060                await RunPassAsync(stuckThreshold, stoppingToken);
 061            }
 062            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 63            {
 064                return;
 65            }
 066            catch (Exception ex)
 67            {
 068                Log.Warning(ex, "Hangfire watchdog: проход не удался");
 069            }
 070        } while (await timer.WaitForNextTickAsync(stoppingToken));
 071    }
 72
 73    private async Task RunPassAsync(TimeSpan stuckThreshold, CancellationToken ct)
 74    {
 075        using var scope = _services.CreateScope();
 076        var storage = scope.ServiceProvider.GetRequiredService<HangfireSchedulerWatchdogStorage>();
 77
 078        var nowUtc = DateTime.UtcNow;
 079        var observation = await storage.ObserveAsync(nowUtc, StatelessJobAge, ct);
 080        var verdict = HangfireSchedulerWatchdogAnalyzer.Analyze(observation, _xaSeenOnPreviousPass, stuckThreshold);
 081        _xaSeenOnPreviousPass = observation.PreparedXaTransactions.Select(x => x.Xid).ToHashSet(StringComparer.Ordinal);
 82
 083        foreach (var xa in verdict.XaToRollback)
 84        {
 085            var error = await storage.RollbackXaAsync(xa, ct);
 086            if (error is null)
 87            {
 088                Log.Warning("Hangfire watchdog: откатил осиротевшую XA-транзакцию {Xid}", xa.Xid);
 89            }
 90            else
 91            {
 092                Log.Error("Hangfire watchdog: не удалось откатить XA-транзакцию {Xid}: {Error}", xa.Xid, error);
 93            }
 094        }
 95
 096        if (verdict.OrphanedStatelessJobs > 0)
 97        {
 098            var deleted = await storage.DeleteStatelessJobsAsync(nowUtc - StatelessJobAge, ct);
 099            Log.Warning("Hangfire watchdog: удалено {Count} джобов без состояния", deleted);
 100        }
 101
 0102        if (verdict.SchedulerStuck)
 103        {
 0104            if (!_alertActive)
 105            {
 0106                _alertActive = true;
 0107                var oldest = verdict.StuckJobs[0];
 0108                Log.Error(
 0109                    "Hangfire watchdog: планировщик стоит — {Count} recurring-джобов просрочены при живых серверах, самы
 0110                    verdict.StuckJobs.Count, oldest.Id, oldest.NextExecutionUtc, observation.PreparedXaTransactions.Coun
 111            }
 112        }
 0113        else if (_alertActive)
 114        {
 0115            _alertActive = false;
 0116            Log.Information("Hangfire watchdog: планировщик снова ставит recurring-джобы");
 117        }
 0118    }
 119}