| | | 1 | | namespace Services.HangfireService; |
| | | 2 | | |
| | | 3 | | /// <summary>Recurring-джоб из HangfireHash: ключ, cron и следующий запуск.</summary> |
| | 22 | 4 | | public 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> |
| | | 10 | | public sealed record PreparedXaTransaction(string Xid); |
| | | 11 | | |
| | | 12 | | /// <summary>Что вотчдог увидел за один проход.</summary> |
| | | 13 | | public 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> |
| | | 21 | | public 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> |
| | | 40 | | public static class HangfireSchedulerWatchdogAnalyzer |
| | | 41 | | { |
| | | 42 | | /// <summary>Сервер считается живым, если heartbeat свежее этого порога.</summary> |
| | | 43 | | 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 | | { |
| | | 50 | | var serversAlive = observation.ServerHeartbeatsUtc |
| | | 51 | | .Any(h => observation.NowUtc - h <= ServerHeartbeatTtl); |
| | | 52 | | |
| | | 53 | | var stuck = observation.RecurringJobs |
| | | 54 | | .Where(j => j.NextExecutionUtc.HasValue |
| | | 55 | | && observation.NowUtc - j.NextExecutionUtc.Value > stuckThreshold) |
| | | 56 | | .OrderBy(j => j.NextExecutionUtc) |
| | | 57 | | .ToList(); |
| | | 58 | | |
| | | 59 | | // PREPARED у живого TransactionScope длится миллисекунды (prepare → commit подряд). |
| | | 60 | | // Транзакция, пережившая два прохода вотчдога, никому не принадлежит — откатываем. |
| | | 61 | | var orphanedXa = observation.PreparedXaTransactions |
| | | 62 | | .Where(x => xaSeenOnPreviousPass.Contains(x.Xid, StringComparer.Ordinal)) |
| | | 63 | | .ToList(); |
| | | 64 | | |
| | | 65 | | return new HangfireWatchdogVerdict(stuck, serversAlive, orphanedXa, observation.OrphanedStatelessJobs); |
| | | 66 | | } |
| | | 67 | | } |