< Summary

Information
Class: Services.HangfireService.HangfireSchedulerWatchdogStorage
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/HangfireService/HangfireSchedulerWatchdogStorage.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 90
Coverable lines: 90
Total lines: 166
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 40
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
.cctor()100%210%
ObserveAsync()0%620%
RollbackXaAsync()0%4260%
DeleteStatelessJobsAsync()0%620%
ReadRecurringJobsAsync()0%210140%
ReadServerHeartbeatsAsync()0%4260%
ReadPreparedXaAsync()0%7280%
CountStatelessJobsAsync()0%620%
ExecuteAsync()0%620%
ParseUtc(...)0%620%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using MySqlConnector;
 4
 5namespace Services.HangfireService;
 6
 7/// <summary>
 8/// Доступ вотчдога к внутренним таблицам Hangfire (БД ST2_Hangfire) и к XA-состоянию сервера.
 9/// Работает под тем же пользователем, что и приложение: <c>XA RECOVER</c>/<c>XA ROLLBACK</c>
 10/// привилегии PROCESS не требуют (в отличие от information_schema.INNODB_TRX).
 11/// </summary>
 012public sealed class HangfireSchedulerWatchdogStorage(HangfireStorageConnection hangfireStorage)
 13{
 14    // XA RECOVER FORMAT='SQL' отдаёт data как X'..',X'..' — принимаем только такую форму,
 15    // потому что XA-выражения не параметризуются и текст уходит в запрос как есть.
 016    private static readonly Regex SqlXidPattern = new("^X'[0-9A-Fa-f]*',X'[0-9A-Fa-f]*',\\d+$", RegexOptions.Compiled);
 17
 18    public async Task<HangfireWatchdogObservation> ObserveAsync(
 19        DateTime nowUtc,
 20        TimeSpan statelessJobAge,
 21        CancellationToken cancellationToken)
 22    {
 023        await using var connection = new MySqlConnection(hangfireStorage.ConnectionString);
 024        await connection.OpenAsync(cancellationToken);
 25
 026        return new HangfireWatchdogObservation(
 027            nowUtc,
 028            await ReadRecurringJobsAsync(connection, cancellationToken),
 029            await ReadServerHeartbeatsAsync(connection, cancellationToken),
 030            await ReadPreparedXaAsync(connection, cancellationToken),
 031            await CountStatelessJobsAsync(connection, nowUtc - statelessJobAge, cancellationToken));
 032    }
 33
 34    /// <summary>Откатывает осиротевшую PREPARED-транзакцию; возвращает текст ошибки сервера, если не удалось.</summary>
 35    public async Task<string?> RollbackXaAsync(PreparedXaTransaction transaction, CancellationToken cancellationToken)
 36    {
 037        if (!SqlXidPattern.IsMatch(transaction.Xid))
 38        {
 039            return $"XID в неожиданном формате: {transaction.Xid}";
 40        }
 41
 42        try
 43        {
 044            await using var connection = new MySqlConnection(hangfireStorage.ConnectionString);
 045            await connection.OpenAsync(cancellationToken);
 046            await using var cmd = connection.CreateCommand();
 047            cmd.CommandText = $"XA ROLLBACK {transaction.Xid}";
 048            await cmd.ExecuteNonQueryAsync(cancellationToken);
 049            return null;
 050        }
 51        catch (MySqlException ex)
 52        {
 053            return ex.Message;
 54        }
 055    }
 56
 57    /// <summary>
 58    /// Удаляет джобы, у которых планировщик создал запись, но не смог зафиксировать состояние
 59    /// (StateName IS NULL). Hangfire их не видит и не чистит, а при заблокированном планировщике
 60    /// они прибывают по одной в минуту.
 61    /// </summary>
 62    public async Task<int> DeleteStatelessJobsAsync(DateTime olderThanUtc, CancellationToken cancellationToken)
 63    {
 064        await using var connection = new MySqlConnection(hangfireStorage.ConnectionString);
 065        await connection.OpenAsync(cancellationToken);
 66
 067        await ExecuteAsync(connection,
 068            "DELETE p FROM HangfireJobParameter p INNER JOIN HangfireJob j ON j.Id = p.JobId WHERE j.StateName IS NULL A
 069            olderThanUtc, cancellationToken);
 070        await ExecuteAsync(connection,
 071            "DELETE s FROM HangfireState s INNER JOIN HangfireJob j ON j.Id = s.JobId WHERE j.StateName IS NULL AND j.Cr
 072            olderThanUtc, cancellationToken);
 073        return await ExecuteAsync(connection,
 074            "DELETE FROM HangfireJob WHERE StateName IS NULL AND CreatedAt < @before",
 075            olderThanUtc, cancellationToken);
 076    }
 77
 78    private static async Task<IReadOnlyList<RecurringJobSnapshot>> ReadRecurringJobsAsync(
 79        MySqlConnection connection, CancellationToken cancellationToken)
 80    {
 081        await using var cmd = connection.CreateCommand();
 082        cmd.CommandText = """
 083            SELECT `Key`, Field, Value
 084            FROM HangfireHash
 085            WHERE `Key` LIKE 'recurring-job:%'
 086              AND Field IN ('Cron', 'NextExecution', 'LastExecution')
 087            """;
 88
 089        var rows = new Dictionary<string, (string? Cron, DateTime? Next, DateTime? Last)>(StringComparer.Ordinal);
 090        await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
 091        while (await reader.ReadAsync(cancellationToken))
 92        {
 093            var id = reader.GetString(0)["recurring-job:".Length..];
 094            var field = reader.GetString(1);
 095            var value = reader.IsDBNull(2) ? null : reader.GetString(2);
 096            rows.TryGetValue(id, out var acc);
 097            acc = field switch
 098            {
 099                "Cron" => (value, acc.Next, acc.Last),
 0100                "NextExecution" => (acc.Cron, ParseUtc(value), acc.Last),
 0101                "LastExecution" => (acc.Cron, acc.Next, ParseUtc(value)),
 0102                _ => acc
 0103            };
 0104            rows[id] = acc;
 105        }
 106
 0107        return rows.Select(r => new RecurringJobSnapshot(r.Key, r.Value.Cron, r.Value.Next, r.Value.Last)).ToList();
 0108    }
 109
 110    private static async Task<IReadOnlyList<DateTime>> ReadServerHeartbeatsAsync(
 111        MySqlConnection connection, CancellationToken cancellationToken)
 112    {
 0113        await using var cmd = connection.CreateCommand();
 0114        cmd.CommandText = "SELECT LastHeartbeat FROM HangfireServer WHERE LastHeartbeat IS NOT NULL";
 0115        var result = new List<DateTime>();
 0116        await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
 0117        while (await reader.ReadAsync(cancellationToken))
 118        {
 119            // Hangfire пишет UTC_TIMESTAMP без признака зоны.
 0120            result.Add(DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc));
 121        }
 122
 0123        return result;
 0124    }
 125
 126    private static async Task<IReadOnlyList<PreparedXaTransaction>> ReadPreparedXaAsync(
 127        MySqlConnection connection, CancellationToken cancellationToken)
 128    {
 0129        await using var cmd = connection.CreateCommand();
 0130        cmd.CommandText = "XA RECOVER FORMAT='SQL'";
 0131        var result = new List<PreparedXaTransaction>();
 0132        await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
 0133        while (await reader.ReadAsync(cancellationToken))
 134        {
 0135            var formatId = Convert.ToInt64(reader["formatID"], CultureInfo.InvariantCulture);
 0136            var data = Convert.ToString(reader["data"], CultureInfo.InvariantCulture) ?? string.Empty;
 0137            result.Add(new PreparedXaTransaction($"{data},{formatId}"));
 138        }
 139
 0140        return result;
 0141    }
 142
 143    private static async Task<int> CountStatelessJobsAsync(
 144        MySqlConnection connection, DateTime olderThanUtc, CancellationToken cancellationToken)
 145    {
 0146        await using var cmd = connection.CreateCommand();
 0147        cmd.CommandText = "SELECT COUNT(*) FROM HangfireJob WHERE StateName IS NULL AND CreatedAt < @before";
 0148        cmd.Parameters.AddWithValue("@before", olderThanUtc);
 0149        return Convert.ToInt32(await cmd.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
 0150    }
 151
 152    private static async Task<int> ExecuteAsync(
 153        MySqlConnection connection, string sql, DateTime before, CancellationToken cancellationToken)
 154    {
 0155        await using var cmd = connection.CreateCommand();
 0156        cmd.CommandText = sql;
 0157        cmd.Parameters.AddWithValue("@before", before);
 0158        return await cmd.ExecuteNonQueryAsync(cancellationToken);
 0159    }
 160
 161    private static DateTime? ParseUtc(string? value) =>
 0162        DateTime.TryParse(value, CultureInfo.InvariantCulture,
 0163            DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
 0164            ? parsed
 0165            : null;
 166}