< Summary

Information
Class: Services.HangfireService.HangfireSchedulerWatchdogAnalyzer
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/HangfireService/HangfireSchedulerWatchdogAnalyzer.cs
Line coverage
100%
Covered lines: 12
Uncovered lines: 0
Coverable lines: 12
Total lines: 67
Line coverage: 100%
Branch coverage
100%
Covered branches: 2
Total branches: 2
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
Analyze(...)100%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/HangfireService/HangfireSchedulerWatchdogAnalyzer.cs

#LineLine coverage
 1namespace Services.HangfireService;
 2
 3/// <summary>Recurring-джоб из HangfireHash: ключ, cron и следующий запуск.</summary>
 4public sealed record RecurringJobSnapshot(string Id, string? Cron, DateTime? NextExecutionUtc, DateTime? LastExecutionUt
 5
 6/// <summary>
 7/// XA-транзакция из <c>XA RECOVER FORMAT='SQL'</c>. <see cref="Xid"/> — уже готовое тело для
 8/// <c>XA ROLLBACK</c> (<c>X'..',X'..',formatId</c>), собранное самим сервером.
 9/// </summary>
 10public sealed record PreparedXaTransaction(string Xid);
 11
 12/// <summary>Что вотчдог увидел за один проход.</summary>
 13public sealed record HangfireWatchdogObservation(
 14    DateTime NowUtc,
 15    IReadOnlyList<RecurringJobSnapshot> RecurringJobs,
 16    IReadOnlyList<DateTime> ServerHeartbeatsUtc,
 17    IReadOnlyList<PreparedXaTransaction> PreparedXaTransactions,
 18    int OrphanedStatelessJobs);
 19
 20/// <summary>Решение вотчдога по одному проходу.</summary>
 21public sealed record HangfireWatchdogVerdict(
 22    IReadOnlyList<RecurringJobSnapshot> StuckJobs,
 23    bool ServersAlive,
 24    IReadOnlyList<PreparedXaTransaction> XaToRollback,
 25    int OrphanedStatelessJobs)
 26{
 27    /// <summary>Планировщик стоит: есть просроченные recurring-джобы при живых серверах Hangfire.</summary>
 28    public bool SchedulerStuck => ServersAlive && StuckJobs.Count > 0;
 29}
 30
 31/// <summary>
 32/// Чистая логика вотчдога планировщика Hangfire (без БД), чтобы её можно было тестировать.
 33/// </summary>
 34/// <remarks>
 35/// Сценарий 09–14.09.2026: осиротевшая XA-транзакция в состоянии PREPARED (MySqlConnector делает
 36/// TransactionScope через XA; соединение оборвалось при деплое) держала X-lock на строке HangfireHash.
 37/// RecurringJobScheduler ловил lock wait timeout на первом due-джобе и пять дней не ставил ни одного
 38/// recurring-джоба, при этом серверы были живы и healthcheck зелёный.
 39/// </remarks>
 40public static class HangfireSchedulerWatchdogAnalyzer
 41{
 42    /// <summary>Сервер считается живым, если heartbeat свежее этого порога.</summary>
 143    public static readonly TimeSpan ServerHeartbeatTtl = TimeSpan.FromMinutes(2);
 44
 45    public static HangfireWatchdogVerdict Analyze(
 46        HangfireWatchdogObservation observation,
 47        IReadOnlyCollection<string> xaSeenOnPreviousPass,
 48        TimeSpan stuckThreshold)
 49    {
 650        var serversAlive = observation.ServerHeartbeatsUtc
 1251            .Any(h => observation.NowUtc - h <= ServerHeartbeatTtl);
 52
 653        var stuck = observation.RecurringJobs
 654            .Where(j => j.NextExecutionUtc.HasValue
 655                        && observation.NowUtc - j.NextExecutionUtc.Value > stuckThreshold)
 356            .OrderBy(j => j.NextExecutionUtc)
 657            .ToList();
 58
 59        // PREPARED у живого TransactionScope длится миллисекунды (prepare → commit подряд).
 60        // Транзакция, пережившая два прохода вотчдога, никому не принадлежит — откатываем.
 661        var orphanedXa = observation.PreparedXaTransactions
 362            .Where(x => xaSeenOnPreviousPass.Contains(x.Xid, StringComparer.Ordinal))
 663            .ToList();
 64
 665        return new HangfireWatchdogVerdict(stuck, serversAlive, orphanedXa, observation.OrphanedStatelessJobs);
 66    }
 67}