< Summary

Information
Class: OzonOrdersWeb.Extensions.HangfireExtensions
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Extensions/HangfireExtensions.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 177
Coverable lines: 177
Total lines: 302
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 38
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ClearHangfireStaleJobs(...)0%2040%
DeletePagedJobs(...)0%110100%
AddHangfireServices(...)0%506220%
AbortStaleRunningExecutionsAsync()100%210%
RepairHangfireDefaultQueuesAsync()100%210%
EnsureHangfireCommandTimeout(...)0%620%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Extensions/HangfireExtensions.cs

#LineLine coverage
 1using Hangfire;
 2using Hangfire.Dashboard;
 3using Hangfire.MySql;
 4using Hangfire.Storage.Monitoring;
 5using Microsoft.Extensions.Configuration;
 6using Microsoft.EntityFrameworkCore;
 7using OzonDomains.Models;
 8using OzonOrdersWeb.WebServices.HangfireAuthorization;
 9using OzonOrdersWeb.Observability;
 10using OzonRepositories.Context;
 11using Serilog;
 12using Services.HangfireService;
 13
 14namespace OzonOrdersWeb.Extensions;
 15
 16public static class HangfireExtensions
 17{
 18    public const string MainQueue = HangfireQueueNames.Main;
 19    public const string OzonWebhookQueue = HangfireQueueNames.OzonWebhook;
 20    public const string OrderStatusSyncQueue = HangfireQueueNames.OrderStatusSync;
 21    public const string OrderSearchQueue = HangfireQueueNames.OrderSearch;
 22    /// <summary>
 23    /// Удаляет задачи Hangfire (очередь, выполнение, отложенные), оставшиеся после предыдущего запуска.
 24    /// Recurring-расписания не трогает — они перерегистрируются при старте отдельно.
 25    /// Вызывать только после инициализации JobStorage (см. HangfireStartupHostedService).
 26    /// </summary>
 27    public static void ClearHangfireStaleJobs(IConfiguration configuration)
 28    {
 029        if (!configuration.GetValue("Hangfire:ClearQueuesOnStartup", true))
 30        {
 031            Log.Information("Hangfire: очистка очередей при старте отключена (Hangfire:ClearQueuesOnStartup=false)");
 032            return;
 33        }
 34
 35        try
 36        {
 037            var monitor = JobStorage.Current.GetMonitoringApi();
 038            var deleted = 0;
 39
 040            foreach (var queue in monitor.Queues())
 41            {
 042                deleted += DeletePagedJobs(queue.Length, from =>
 043                    monitor.EnqueuedJobs(queue.Name, (int)from, 1000));
 44
 45                try
 46                {
 047                    deleted += DeletePagedJobs(monitor.FetchedCount(queue.Name), from =>
 048                        monitor.FetchedJobs(queue.Name, (int)from, 1000));
 049                }
 050                catch (Exception ex)
 51                {
 052                    Log.Warning(ex, "Hangfire: не удалось очистить fetched-задачи очереди {Queue}", queue.Name);
 053                }
 54
 055                Log.Information("Hangfire: очищена очередь {Queue}", queue.Name);
 56            }
 57
 058            deleted += DeletePagedJobs(monitor.ProcessingCount(), from =>
 059                monitor.ProcessingJobs((int)from, 1000));
 60
 061            deleted += DeletePagedJobs(monitor.ScheduledCount(), from =>
 062                monitor.ScheduledJobs((int)from, 1000));
 63
 064            Log.Information(
 065                "Hangfire: при старте удалено {Count} задач (очередь/fetched/выполнение/отложенные/retry)", deleted);
 066        }
 067        catch (Exception ex)
 68        {
 069            Log.Warning(ex, "Hangfire: не удалось очистить очереди при старте");
 070        }
 071    }
 72
 73    private static int DeletePagedJobs<T>(long total, Func<long, JobList<T>> fetchPage)
 74    {
 075        if (total <= 0)
 76        {
 077            return 0;
 78        }
 79
 080        var deleted = 0;
 081        for (long from = 0; from < total; from += 1000)
 82        {
 083            var jobs = fetchPage(from);
 084            foreach (var job in jobs)
 85            {
 086                if (BackgroundJob.Delete(job.Key))
 87                {
 088                    deleted++;
 89                }
 90            }
 91
 092            if (jobs.Count < 1000)
 93            {
 94                break;
 95            }
 96        }
 97
 098        return deleted;
 99    }
 100
 101    public static void AddHangfireServices(this IServiceCollection services, IConfiguration configuration, bool isProd)
 102    {
 103        // Размер пула задаётся в connection string. Для трёх серверов Hangfire 12 соединений
 104        // недостаточно: служебные процессы и воркеры одновременно используют storage.
 0105        string hangfireConnection = (isProd
 0106            ? configuration.GetConnectionString("HangfireConnectionProd")
 0107            : configuration.GetConnectionString("HangfireConnectionLocal"))
 0108            ?? throw new InvalidOperationException("Hangfire connection string is not configured.");
 109
 0110        hangfireConnection = EnsureHangfireCommandTimeout(hangfireConnection, 120);
 0111        services.AddSingleton(new HangfireStorageConnection(hangfireConnection));
 0112        services.AddSingleton<HangfireSchedulerWatchdogStorage>();
 0113        var slowJobMilliseconds = configuration.GetValue<int?>(
 0114            $"{ObservabilityOptions.SectionName}:SlowLongOperationMilliseconds") ?? 30_000;
 115
 0116        services.AddHangfire((serviceProvider, configuration) =>
 0117        {
 0118#pragma warning disable CS0618 // Помечено Obsolete в расчёте на новый heartbeat-протокол Hangfire.SqlServer,
 0119            // которого в этом MySQL-порте (arnoldasgudas/Hangfire.MySqlStorage 2.0.3) нет — FetchedAt/
 0120            // InvisibilityTimeout по-прежнему единственный механизм переоткрытия «зависших» джоб (см. схему
 0121            // HangfireJobQueue). Default 30 мин реально отменяет CancellationToken у живого воркера —
 0122            // задевает BitrixStock этап 5 (полный скан каталога Ozon для крупных пресетов превышает 30 мин).
 0123            var mySqlStorageOptions = new MySqlStorageOptions
 0124            {
 0125                TablesPrefix = "Hangfire",
 0126                InvisibilityTimeout = TimeSpan.FromHours(6)
 0127            };
 0128#pragma warning restore CS0618
 0129
 0130            configuration.UseStorage(new MySqlStorage(hangfireConnection, mySqlStorageOptions));
 0131
 0132            // Без этого BackgroundJob.Enqueue попадает в default, которую воркеры не обрабатывают.
 0133            configuration.UseFilter(new QueueAttribute(MainQueue));
 0134            configuration.UseFilter(new HangfireDistributedLockContentionFilter());
 0135            configuration.UseFilter(new HangfireObservabilityFilter(slowJobMilliseconds));
 0136            configuration.UseFilter(new HangfireFailureTelegramFilter(serviceProvider));
 0137            configuration.UseFilter(new HangfireSafeDistributedLockFilter());
 0138        });
 139
 140
 0141        if (!configuration.GetValue("Hangfire:ServerEnabled", true))
 142        {
 0143            return;
 144        }
 145
 146        // Hangfire: WorkerCount воркеров + ~6 служебных потоков с соединениями к storage.
 147        // Пул Hangfire — отдельный (Application Name=st2-hangfire в connection string).
 0148        var configuredWorkers = configuration.GetValue<int?>("Hangfire:WorkerCount");
 0149        var workerCount = configuredWorkers is > 0
 0150            ? configuredWorkers.Value
 0151            : Math.Min(2, Math.Max(1, Environment.ProcessorCount));
 152
 0153        var pollIntervalSeconds = configuration.GetValue<int?>("Hangfire:PollIntervalSeconds");
 0154        services.AddHangfireServer(options =>
 0155        {
 0156            // ozon-webhook — отдельная очередь, чтобы всплеск вебхуков не блокировал main.
 0157            options.Queues = [MainQueue, OzonWebhookQueue];
 0158            options.WorkerCount = workerCount;
 0159            if (pollIntervalSeconds is > 0)
 0160                options.SchedulePollingInterval = TimeSpan.FromSeconds(pollIntervalSeconds.Value);
 0161        });
 162
 163        // Опрос Ozon не должен конкурировать с импортами, 1С и вебхуками за воркеры и пул ST2.
 164        // Отдельный сервер с одним воркером сохраняет порядок и жёстко ограничивает параллелизм.
 0165        services.AddHangfireServer(options =>
 0166        {
 0167            options.ServerName = $"{Environment.MachineName}:order-status-sync";
 0168            options.Queues = [OrderStatusSyncQueue];
 0169            options.WorkerCount = 1;
 0170            if (pollIntervalSeconds is > 0)
 0171                options.SchedulePollingInterval = TimeSpan.FromSeconds(pollIntervalSeconds.Value);
 0172        });
 173
 174        // Инкрементальная и полная переиндексация могут долго дренировать накопившиеся записи.
 175        // Она выполняется последовательно и не занимает воркеры прикладной очереди.
 0176        services.AddHangfireServer(options =>
 0177        {
 0178            options.ServerName = $"{Environment.MachineName}:order-search";
 0179            options.Queues = [OrderSearchQueue];
 0180            options.WorkerCount = 1;
 0181            if (pollIntervalSeconds is > 0)
 0182                options.SchedulePollingInterval = TimeSpan.FromSeconds(pollIntervalSeconds.Value);
 0183        });
 0184    }
 185
 186    /// <summary>
 187    /// Помечает «зависшие» прикладные выполнения со статусом Running как прерванные.
 188    /// Такие записи остаются после падения/перезапуска приложения, когда задача Hangfire
 189    /// не успела проставить финальный статус. Вызывать при старте приложения.
 190    /// </summary>
 191    public static async Task AbortStaleRunningExecutionsAsync(IServiceProvider services)
 192    {
 193        try
 194        {
 0195            using var scope = services.CreateScope();
 0196            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 0197            var now = DateTime.UtcNow;
 198            const string reason = "Прервано при перезапуске приложения";
 199
 0200            var background = await db.BackgroundJobExecutions
 0201                .Where(e => e.Status == "Running")
 0202                .ExecuteUpdateAsync(s => s
 0203                    .SetProperty(e => e.Status, "Failed")
 0204                    .SetProperty(e => e.CompletedAt, now)
 0205                    .SetProperty(e => e.ErrorMessage, reason));
 206
 0207            var clear = await db.BitrixStockClearExecutions
 0208                .Where(e => e.Status == BitrixStockClearExecutionStatus.Running)
 0209                .ExecuteUpdateAsync(s => s
 0210                    .SetProperty(e => e.Status, BitrixStockClearExecutionStatus.Failed)
 0211                    .SetProperty(e => e.CompletedAt, now)
 0212                    .SetProperty(e => e.ErrorMessage, reason));
 213
 0214            var push = await db.BitrixStockPushExecutions
 0215                .Where(e => e.Status == BitrixStockPushExecutionStatus.Running)
 0216                .ExecuteUpdateAsync(s => s
 0217                    .SetProperty(e => e.Status, BitrixStockPushExecutionStatus.Failed)
 0218                    .SetProperty(e => e.CompletedAt, now)
 0219                    .SetProperty(e => e.ErrorMessage, reason));
 220
 0221            var stockSync = await db.WarehouseStockSyncRuns
 0222                .Where(e => e.Status == WarehouseStockSyncRunStatus.Running)
 0223                .ExecuteUpdateAsync(s => s
 0224                    .SetProperty(e => e.Status, WarehouseStockSyncRunStatus.Failed)
 0225                    .SetProperty(e => e.CompletedAtUtc, now)
 0226                    .SetProperty(e => e.ErrorMessage, reason));
 227
 0228            var emailRouting = await db.EmailRoutingJobExecutions
 0229                .Where(e => e.Status == EmailRoutingJobExecutionStatus.Running)
 0230                .ExecuteUpdateAsync(s => s
 0231                    .SetProperty(e => e.Status, EmailRoutingJobExecutionStatus.Failed)
 0232                    .SetProperty(e => e.CompletedAtUtc, now)
 0233                    .SetProperty(e => e.ErrorMessage, reason));
 234
 0235            var archive = await db.BitrixStockArchiveExecutions
 0236                .Where(e => e.Status == BitrixStockArchiveExecutionStatus.Running)
 0237                .ExecuteUpdateAsync(s => s
 0238                    .SetProperty(e => e.Status, BitrixStockArchiveExecutionStatus.Failed)
 0239                    .SetProperty(e => e.CompletedAt, now)
 0240                    .SetProperty(e => e.ErrorMessage, reason));
 241
 0242            var unarchive = await db.BitrixStockUnarchiveExecutions
 0243                .Where(e => e.Status == BitrixStockUnarchiveExecutionStatus.Running)
 0244                .ExecuteUpdateAsync(s => s
 0245                    .SetProperty(e => e.Status, BitrixStockUnarchiveExecutionStatus.Failed)
 0246                    .SetProperty(e => e.CompletedAt, now)
 0247                    .SetProperty(e => e.ErrorMessage, reason));
 248
 0249            var returnRuns = await db.OrderReturnSyncRuns
 0250                .Where(e => e.Status == OrderReturnSyncRunStatus.Running)
 0251                .ExecuteUpdateAsync(s => s
 0252                    .SetProperty(e => e.Status, OrderReturnSyncRunStatus.Failed)
 0253                    .SetProperty(e => e.CompletedAtUtc, now)
 0254                    .SetProperty(e => e.ErrorMessage, reason));
 255
 0256            var syncReports = await db.Database.ExecuteSqlRawAsync(
 0257                "UPDATE BitrixStockSyncReports SET Status = 'Failed', CompletedAtUtc = UTC_TIMESTAMP(6) WHERE Status = '
 258
 0259            Log.Information(
 0260                "Startup cleanup: прерваны зависшие Running-выполнения (background={Bg}, clear={Clear}, push={Push}, sto
 0261                background, clear, push, stockSync, archive, unarchive, returnRuns, syncReports);
 0262        }
 0263        catch (Exception ex)
 264        {
 0265            Log.Warning(ex, "Startup cleanup: не удалось прервать зависшие Running-выполнения");
 0266        }
 0267    }
 268
 269    /// <summary>
 270    /// Переносит enqueued/recurring из default в main (и webhook-задачи в ozon-webhook).
 271    /// Вызывать при старте приложения.
 272    /// </summary>
 273    public static async Task RepairHangfireDefaultQueuesAsync(IServiceProvider services)
 274    {
 275        try
 276        {
 0277            using var scope = services.CreateScope();
 0278            var hangfireStorage = scope.ServiceProvider.GetRequiredService<HangfireStorageConnection>();
 0279            await HangfireQueueRepair.RepairDefaultQueuesAsync(hangfireStorage);
 0280            Log.Information("Hangfire: migrated jobs from default queue to main/ozon-webhook");
 0281        }
 0282        catch (Exception ex) when (HangfireQueueRepair.IsLockWaitTimeout(ex))
 283        {
 0284            Log.Information(ex,
 0285                "Hangfire: queue repair skipped because of transient storage lock contention; it will be retried at next
 0286        }
 0287        catch (Exception ex)
 288        {
 0289            Log.Warning(ex, "Hangfire: failed to repair default queue jobs");
 0290        }
 0291    }
 292
 293    private static string EnsureHangfireCommandTimeout(string connectionString, int seconds)
 294    {
 0295        if (connectionString.Contains("Default Command Timeout", StringComparison.OrdinalIgnoreCase))
 296        {
 0297            return connectionString;
 298        }
 299
 0300        return connectionString.TrimEnd(';') + $";Default Command Timeout={seconds}";
 301    }
 302}