< Summary

Information
Class: OzonOrdersWeb.HostedServices.HangfireStartupHostedService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/HostedServices/HangfireStartupHostedService.cs
Line coverage
4%
Covered lines: 11
Uncovered lines: 260
Coverable lines: 271
Total lines: 498
Line coverage: 4%
Branch coverage
2%
Covered branches: 2
Total branches: 68
Branch coverage: 2.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using Hangfire;
 2using Hangfire.Storage;
 3using Newtonsoft.Json.Linq;
 4using OzonOrdersWeb.Extensions;
 5using OzonOrdersWeb.WebServices.Orders;
 6using OzonOrdersWeb.WebServices.SystemAlerts;
 7using Serilog;
 8using Services.ApiServices._1CApi.OData;
 9using Services.ArmtekEtpServices;
 10using Services.BackgroundJobsServices;
 11using Services.BitrixStockServices;
 12using Services.EmailRoutingServices;
 13using Services.HangfireService;
 14using Services.LabelServices;
 15using Services.OzonReturnsServices;
 16using Services.SearchServices.OrderSearch;
 17using Services.StockSyncServices;
 18using Services.OrderControlServices;
 19using Services.OzonWebhookServices.Monitoring;
 20using Services.SupplierStatusServices;
 21using Services.FidSupplierLinkServices;
 22
 23namespace OzonOrdersWeb.HostedServices;
 24
 25/// <summary>
 26/// Откладывает инициализацию Hangfire до полного старта приложения,
 27/// когда BackgroundJobServerHostedService уже установил JobStorage.Current.
 28/// </summary>
 29public sealed class HangfireStartupHostedService : IHostedService
 30{
 131    internal static readonly string[] StaleRecurringJobIds =
 132    [
 133        "DropboxLabelsFolderIndexRefreshJob", // осиротевшая запись после переименования
 134        "JobController.UploadingZzap",        // осиротевшая запись удалённого метода (#2126, #2127)
 135        "UploadingZzap",                       // альтернативный ID устаревшего Zzap-обновления
 136        WarehouseStockSyncJob.JobId           // устаревший джоб синхронизации остатков
 137    ];
 38
 39    internal static void CleanStaleRecurringJobs(IRecurringJobManager recurringJobManager)
 40    {
 241        ArgumentNullException.ThrowIfNull(recurringJobManager);
 1042        foreach (var staleJobId in StaleRecurringJobIds)
 43        {
 444            recurringJobManager.RemoveIfExists(staleJobId);
 45        }
 146    }
 47
 48    private readonly IServiceProvider _services;
 49    private readonly IConfiguration _configuration;
 50    private readonly IHostApplicationLifetime _lifetime;
 51
 052    public HangfireStartupHostedService(
 053        IServiceProvider services,
 054        IConfiguration configuration,
 055        IHostApplicationLifetime lifetime)
 56    {
 057        _services = services;
 058        _configuration = configuration;
 059        _lifetime = lifetime;
 060    }
 61
 62    public Task StartAsync(CancellationToken cancellationToken)
 63    {
 064        _lifetime.ApplicationStarted.Register(() =>
 065        {
 066            _ = RunInitializationAsync();
 067        });
 68
 069        return Task.CompletedTask;
 70    }
 71
 072    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 73
 74    private async Task RunInitializationAsync()
 75    {
 76        try
 77        {
 078            if (!_configuration.GetValue("Hangfire:ServerEnabled", true))
 79            {
 080                Log.Information("Hangfire startup initialization disabled by configuration");
 081                return;
 82            }
 83
 084            if (!await WaitForJobStorageAsync(TimeSpan.FromSeconds(30)))
 85            {
 086                Log.Warning("Hangfire: JobStorage не инициализирован за 30с — пропуск startup-инициализации");
 087                return;
 88            }
 89
 090            HangfireExtensions.ClearHangfireStaleJobs(_configuration);
 091            await HangfireExtensions.AbortStaleRunningExecutionsAsync(_services);
 092            await HangfireExtensions.RepairHangfireDefaultQueuesAsync(_services);
 093            RegisterBuiltInRecurringJobs(_configuration);
 94
 095            using (var scope = _services.CreateScope())
 96            {
 097                var scheduleService = scope.ServiceProvider.GetRequiredService<IBackgroundJobScheduleService>();
 098                await scheduleService.RegisterAllActiveSchedulesAsync();
 099                Log.Information("Background Jobs schedules registered");
 100
 0101                var bitrixAutoService = scope.ServiceProvider.GetRequiredService<IBitrixStockAutoScheduleService>();
 0102                await bitrixAutoService.RegisterAllActiveAsync();
 0103                Log.Information("BitrixStock auto schedules registered");
 0104            }
 105
 0106            Log.Information("Hangfire startup initialization completed");
 0107        }
 0108        catch (Exception ex)
 109        {
 0110            Log.Warning(ex, "Failed to initialize Hangfire recurring jobs at startup");
 0111        }
 0112    }
 113
 114    private static async Task<bool> WaitForJobStorageAsync(TimeSpan timeout)
 115    {
 0116        var deadline = DateTime.UtcNow + timeout;
 0117        while (DateTime.UtcNow < deadline)
 118        {
 119            try
 120            {
 0121                _ = JobStorage.Current;
 0122                return true;
 123            }
 0124            catch (InvalidOperationException)
 125            {
 0126                await Task.Delay(200);
 127            }
 128        }
 129
 0130        return false;
 0131    }
 132
 133    private static void RegisterBuiltInRecurringJobs(IConfiguration configuration)
 134    {
 0135        RegisterEmailRoutingRecurringJob(configuration);
 136
 0137        RecurringJob.AddOrUpdate<TransactionTimeTrackingCleanupJob>(
 0138            TransactionTimeTrackingCleanupJob.JobId,
 0139            x => x.ExecuteAsync(),
 0140            TransactionTimeTrackingCleanupJob.DefaultCron);
 141
 0142        RecurringJob.RemoveIfExists(OrderControlEvaluationJob.JobId);
 0143        RecurringJob.AddOrUpdate<OrderControlEvaluationJob>(
 0144            OrderControlEvaluationJob.JobId,
 0145            x => x.ExecuteAsync(),
 0146            OrderControlEvaluationJob.DefaultCron,
 0147            new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 148
 0149        foreach (var staleJobId in StaleRecurringJobIds)
 150        {
 0151            RecurringJob.RemoveIfExists(staleJobId);
 152        }
 153
 0154        RecurringJob.RemoveIfExists(LabelsFolderIndexRefreshJob.JobId);
 0155        RecurringJob.AddOrUpdate<LabelsFolderIndexRefreshJob>(
 0156            LabelsFolderIndexRefreshJob.JobId,
 0157            x => x.ExecuteAsync(),
 0158            LabelsFolderIndexRefreshJob.DefaultCron);
 159
 160        // Очередь здесь НЕ задаём. Перегрузка AddOrUpdate с параметром queue требует от хранилища
 161        // фичу Job.Queue (Hangfire 1.8), которой в Hangfire.MySqlStorage 2.0.3 нет: вызов падает,
 162        // исключение проглатывает внешний catch, и всё, что регистрируется ниже, тоже пропускается
 163        // (прод 31.07.2026 — джоб не появился, следом не отработала регистрация OrderSearch).
 164        // Маршрутизацию выполнения обеспечивает атрибут [Queue] на самом джобе, а хранимое поле
 165        // Queue выправляет RepairHangfireDefaultQueuesAsync на следующем старте.
 166        //
 167        // Регистрация обёрнута в свой try/catch: сбой одного джоба не должен обрывать цепочку.
 168        try
 169        {
 0170            RecurringJob.AddOrUpdate<OneCNomenclatureMapSyncJob>(
 0171                OneCNomenclatureMapSyncJob.JobId,
 0172                x => x.ExecuteAsync(),
 0173                OneCNomenclatureMapSyncJob.DefaultCron,
 0174                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 0175        }
 0176        catch (Exception ex)
 177        {
 0178            Log.Warning(ex, "Hangfire: не удалось зарегистрировать {JobId}", OneCNomenclatureMapSyncJob.JobId);
 0179        }
 180
 0181        RegisterSupplierStatusSyncJob(configuration);
 182
 183        try
 184        {
 0185            RecurringJob.AddOrUpdate<FidSupplierLinkDiscoveryJob>(
 0186                FidSupplierLinkDiscoveryJob.JobId,
 0187                x => x.ExecuteAsync(),
 0188                FidSupplierLinkDiscoveryJob.DefaultCron,
 0189                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 0190        }
 0191        catch (Exception ex)
 192        {
 0193            Log.Warning(ex, "Hangfire: не удалось зарегистрировать {JobId}", FidSupplierLinkDiscoveryJob.JobId);
 0194        }
 195
 0196        RegisterOrderSearchSyncJobs(configuration);
 197
 0198        RecurringJob.AddOrUpdate<SystemAlertEvaluationJob>(
 0199            SystemAlertEvaluationJob.JobId,
 0200            x => x.ExecuteAsync(),
 0201            SystemAlertEvaluationJob.DefaultCron,
 0202            new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 0203        RecurringJob.AddOrUpdate<OzonOrdersWeb.WebServices.DailySummary.DailyWorkSummaryJob>(
 0204            OzonOrdersWeb.WebServices.DailySummary.DailyWorkSummaryJob.JobId,
 0205            x => x.ExecuteAsync(),
 0206            OzonOrdersWeb.WebServices.DailySummary.DailyWorkSummaryJob.DefaultCron,
 0207            new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 0208        RecurringJob.AddOrUpdate<SystemMonitoringSignalCleanupJob>(
 0209            SystemMonitoringSignalCleanupJob.JobId, x => x.ExecuteAsync(), SystemMonitoringSignalCleanupJob.DefaultCron,
 0210            new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 211
 0212        RegisterOzonReturnsRecurringJobs(configuration);
 0213        RegisterArmtekEtpSyncJob(configuration);
 0214    }
 215
 216    private static void RegisterArmtekEtpSyncJob(IConfiguration configuration)
 217    {
 0218        if (!configuration.GetValue("ArmtekEtp:Enabled", false))
 219        {
 0220            RecurringJob.RemoveIfExists(ArmtekEtpSyncJob.JobId);
 0221            return;
 222        }
 223
 224        try
 225        {
 0226            RecurringJob.AddOrUpdate<ArmtekEtpSyncJob>(
 0227                ArmtekEtpSyncJob.JobId,
 0228                x => x.ExecuteAsync(),
 0229                configuration["ArmtekEtp:Cron"] is { Length: > 0 } cron ? cron : ArmtekEtpSyncJob.DefaultCron,
 0230                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 0231        }
 0232        catch (Exception ex)
 233        {
 0234            Log.Warning(ex, "Hangfire: не удалось зарегистрировать {JobId}", ArmtekEtpSyncJob.JobId);
 0235        }
 0236    }
 237
 238    /// <summary>
 239    /// Перерегистрация джобов загрузки возвратов Ozon. Расписание и период задаёт форма на
 240    /// /Studio2/Job, и хранит их сам Hangfire — поэтому при старте запись переписывается ТЕМИ ЖЕ
 241    /// параметрами: задача перерегистрации в том, чтобы починить ссылку на тип и сборку в
 242    /// InvocationData (см. docs/hangfire-recurring-job-registration.md), а не сбросить настройку админа.
 243    ///
 244    /// Уже зарегистрированные джобы перерегистрируются всегда, без проверки «существует ли».
 245    /// Создаётся пара с нуля только когда возвраты на стенде уже настроены (есть хотя бы одна из
 246    /// двух записей) или когда включён флаг OzonReturns:RecurringJobEnabled — иначе на чистой
 247    /// локальной базе появился бы джоб, каждые 10 минут ходящий в Ozon.
 248    /// </summary>
 249    private static void RegisterOzonReturnsRecurringJobs(IConfiguration configuration)
 250    {
 0251        var enabled = configuration.GetValue<bool?>("OzonReturns:RecurringJobEnabled");
 0252        if (enabled == false)
 253        {
 0254            RecurringJob.RemoveIfExists(LoadOzonReturnsJob.IncrementalJobId);
 0255            RecurringJob.RemoveIfExists(LoadOzonReturnsJob.FullWindowJobId);
 0256            Log.Information("Hangfire: загрузка возвратов Ozon отключена (OzonReturns:RecurringJobEnabled=false)");
 0257            return;
 258        }
 259
 0260        var stored = ReadStoredRecurringJobs(
 0261            LoadOzonReturnsJob.IncrementalJobId,
 0262            LoadOzonReturnsJob.FullWindowJobId);
 263
 0264        stored.TryGetValue(LoadOzonReturnsJob.IncrementalJobId, out var incremental);
 0265        stored.TryGetValue(LoadOzonReturnsJob.FullWindowJobId, out var fullWindow);
 266
 0267        if (incremental == null && fullWindow == null && enabled != true)
 268        {
 0269            Log.Information(
 0270                "Hangfire: загрузка возвратов Ozon не настроена — джобы не регистрируются " +
 0271                "(включить: форма на /Studio2/Job или OzonReturns:RecurringJobEnabled=true)");
 0272            return;
 273        }
 274
 0275        var incrementalCron = FirstNonEmpty(
 0276            incremental?.Cron,
 0277            configuration.GetValue<string?>("OzonReturns:IncrementalCron"),
 0278            LoadOzonReturnsJob.DefaultIncrementalCron);
 279
 0280        var fullWindowCron = FirstNonEmpty(
 0281            fullWindow?.Cron,
 0282            configuration.GetValue<string?>("OzonReturns:FullWindowCron"),
 0283            LoadOzonReturnsJob.DefaultFullWindowCron);
 284
 0285        var monthsCount =
 0286            ReadMonthsCount(LoadOzonReturnsJob.IncrementalJobId, incremental) ??
 0287            ReadMonthsCount(LoadOzonReturnsJob.FullWindowJobId, fullWindow) ??
 0288            configuration.GetValue<int?>("OzonReturns:MonthsCount") ??
 0289            LoadOzonReturnsJob.DefaultMonthsCount;
 290
 291        // Очередь здесь НЕ задаём — маршрутизацию обеспечивает атрибут [Queue] на методе.
 292        // См. комментарий про Job.Queue и Hangfire.MySqlStorage 2.0.3 выше.
 293        try
 294        {
 0295            RecurringJob.AddOrUpdate<LoadOzonReturnsJob>(
 0296                LoadOzonReturnsJob.IncrementalJobId,
 0297                j => j.RunIncrementalAsync(monthsCount),
 0298                incrementalCron);
 299
 0300            RecurringJob.AddOrUpdate<LoadOzonReturnsJob>(
 0301                LoadOzonReturnsJob.FullWindowJobId,
 0302                j => j.RunFullWindowAsync(monthsCount),
 0303                fullWindowCron,
 0304                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 305
 0306            Log.Information(
 0307                "Hangfire: возвраты Ozon зарегистрированы (инкрементально {IncrementalCron}, полная сверка {FullCron} MS
 0308                incrementalCron, fullWindowCron, monthsCount);
 0309        }
 0310        catch (Exception ex)
 311        {
 0312            Log.Warning(ex, "Hangfire: не удалось зарегистрировать джобы загрузки возвратов Ozon");
 0313        }
 0314    }
 315
 316    private static Dictionary<string, RecurringJobDto> ReadStoredRecurringJobs(params string[] jobIds)
 317    {
 318        try
 319        {
 0320            using var connection = JobStorage.Current.GetConnection();
 0321            var recurring = connection.GetRecurringJobs() ?? new List<RecurringJobDto>();
 0322            return recurring
 0323                .Where(x => jobIds.Contains(x.Id))
 0324                .GroupBy(x => x.Id)
 0325                .ToDictionary(g => g.Key, g => g.First());
 326        }
 0327        catch (Exception ex)
 328        {
 0329            Log.Warning(ex, "Hangfire: не удалось прочитать recurring jobs {JobIds}", string.Join(", ", jobIds));
 0330            return new Dictionary<string, RecurringJobDto>();
 331        }
 0332    }
 333
 334    /// <summary>
 335    /// Период (monthsCount) уже зарегистрированного джоба.
 336    ///
 337    /// Сначала пробуем разобранные аргументы. Если тип джоба переименован или переехал в другую
 338    /// сборку, Hangfire не может восстановить InvocationData и отдаёт Job == null — ровно тот
 339    /// случай, ради которого перерегистрация и делается. Тогда достаём аргументы из сырого хеша:
 340    /// они там лежат обычным JSON и от разрешения типа не зависят. Без этого настроенный админом
 341    /// период молча сбрасывался бы на значение по умолчанию при первом же переезде джоба.
 342    /// </summary>
 343    private static int? ReadMonthsCount(string jobId, RecurringJobDto? dto)
 344    {
 0345        if (dto == null)
 0346            return null;
 347
 0348        return TryParseMonths(dto.Job?.Args?.FirstOrDefault()) ?? ReadMonthsCountFromRawHash(jobId);
 349    }
 350
 351    private static int? ReadMonthsCountFromRawHash(string jobId)
 352    {
 353        try
 354        {
 0355            using var connection = JobStorage.Current.GetConnection();
 0356            var hash = connection.GetAllEntriesFromHash($"recurring-job:{jobId}");
 0357            if (hash == null || !hash.TryGetValue("Job", out var invocationJson) || string.IsNullOrWhiteSpace(invocation
 0358                return null;
 359
 0360            var arguments = JObject.Parse(invocationJson)["Arguments"]?.ToString();
 0361            if (string.IsNullOrWhiteSpace(arguments))
 0362                return null;
 363
 0364            return TryParseMonths(JArray.Parse(arguments).FirstOrDefault()?.ToString());
 365        }
 0366        catch (Exception ex)
 367        {
 0368            Log.Warning(ex, "Hangfire: не удалось прочитать аргументы {JobId} из хранилища", jobId);
 0369            return null;
 370        }
 0371    }
 372
 373    private static int? TryParseMonths(object? raw)
 374    {
 0375        if (raw == null)
 0376            return null;
 377
 378        try
 379        {
 0380            var months = Convert.ToInt32(raw);
 0381            return months is >= 1 and <= 12 ? months : null;
 382        }
 0383        catch
 384        {
 0385            return null;
 386        }
 0387    }
 388
 389    private static string FirstNonEmpty(params string?[] values) =>
 0390        values.First(v => !string.IsNullOrWhiteSpace(v))!;
 391
 392    private static void RegisterSupplierStatusSyncJob(IConfiguration configuration)
 393    {
 394        // Флаг нужен для локальной работы: АВД требует whitelist IP, и без него каждый прогон
 395        // упирается в таймаут. Значение по умолчанию — включено.
 0396        var syncEnabled = configuration.GetValue("SupplierStatuses:SyncEnabled", true);
 0397        if (!syncEnabled)
 398        {
 0399            RecurringJob.RemoveIfExists(SupplierOrderStatusSyncJob.JobId);
 0400            return;
 401        }
 402
 403        // Очередь задаётся атрибутом [Queue] на джобе, а не параметром AddOrUpdate — см. комментарий выше.
 404        try
 405        {
 0406            RecurringJob.AddOrUpdate<SupplierOrderStatusSyncJob>(
 0407                SupplierOrderStatusSyncJob.JobId,
 0408                x => x.ExecuteAsync(),
 0409                SupplierOrderStatusSyncJob.DefaultCron,
 0410                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 0411        }
 0412        catch (Exception ex)
 413        {
 0414            Log.Warning(ex, "Hangfire: не удалось зарегистрировать {JobId}", SupplierOrderStatusSyncJob.JobId);
 0415        }
 416
 417        try
 418        {
 0419            RecurringJob.AddOrUpdate<OrdersClientStatusReconcileJob>(
 0420                OrdersClientStatusReconcileJob.JobId,
 0421                x => x.ExecuteAsync(),
 0422                OrdersClientStatusReconcileJob.DefaultCron,
 0423                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 424
 0425            RecurringJob.AddOrUpdate<OzonWebhookAvailabilityJob>(
 0426                OzonWebhookAvailabilityJob.JobId,
 0427                x => x.ExecuteAsync(),
 0428                OzonWebhookAvailabilityJob.DefaultCron,
 0429                new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 430
 431            // Снимок для баннера админа живёт в памяти — после рестарта наполняем сразу, а не через 30 минут.
 0432            BackgroundJob.Enqueue<OzonWebhookAvailabilityJob>(x => x.ExecuteAsync());
 0433        }
 0434        catch (Exception ex)
 435        {
 0436            Log.Warning(ex, "Hangfire: не удалось зарегистрировать джобы статусов Ozon");
 0437        }
 0438    }
 439
 440    private static void RegisterOrderSearchSyncJobs(IConfiguration configuration)
 441    {
 0442        var useElasticsearch = configuration.GetValue<bool>("OrdersSearch:UseElasticsearch");
 0443        if (!useElasticsearch)
 444        {
 0445            RecurringJob.RemoveIfExists(OrderSearchIncrementalSyncJob.JobId);
 0446            RecurringJob.RemoveIfExists(OrderSearchFullReindexJob.JobId);
 0447            RecurringJob.RemoveIfExists(OrderSearchDriftCheckJob.JobId);
 0448            return;
 449        }
 450
 0451        RecurringJob.AddOrUpdate<OrderSearchIncrementalSyncJob>(
 0452            OrderSearchIncrementalSyncJob.JobId,
 0453            x => x.ExecuteAsync(),
 0454            OrderSearchIncrementalSyncJob.DefaultCron);
 455
 0456        RecurringJob.AddOrUpdate<OrderSearchFullReindexJob>(
 0457            OrderSearchFullReindexJob.JobId,
 0458            x => x.ExecuteAsync(),
 0459            OrderSearchFullReindexJob.DefaultCron,
 0460            new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone });
 461
 0462        RecurringJob.AddOrUpdate<OrderSearchDriftCheckJob>(
 0463            OrderSearchDriftCheckJob.JobId,
 0464            x => x.ExecuteAsync(),
 0465            OrderSearchDriftCheckJob.DefaultCron);
 466
 0467        Log.Information(
 0468            "OrderSearch sync jobs registered (incremental: {IncrementalCron}, full reindex: {FullReindexCron} MSK, drif
 0469            OrderSearchIncrementalSyncJob.DefaultCron,
 0470            OrderSearchFullReindexJob.DefaultCron,
 0471            OrderSearchDriftCheckJob.DefaultCron);
 0472    }
 473
 474    private static void RegisterEmailRoutingRecurringJob(IConfiguration configuration)
 475    {
 476        try
 477        {
 0478            var emailRoutingOptions = configuration.GetSection("EmailRouting").Get<EmailRoutingExecutionOptions>()
 0479                ?? new EmailRoutingExecutionOptions();
 0480            if (emailRoutingOptions.RecurringJobEnabled)
 481            {
 0482                EmailRoutingRecurringJobRegistrar.RegisterIfEnabled(emailRoutingOptions);
 0483                Log.Information(
 0484                    "EmailAttachmentRoutingJob registered: */{IntervalMinutes} min (MSK)",
 0485                    Math.Clamp(emailRoutingOptions.IntervalMinutes, 1, 1440));
 486            }
 487            else
 488            {
 0489                RecurringJob.RemoveIfExists(EmailAttachmentRoutingJob.JobId);
 0490                Log.Information("EmailAttachmentRoutingJob disabled by configuration");
 491            }
 0492        }
 0493        catch (Exception ex)
 494        {
 0495            Log.Warning(ex, "Failed to register EmailAttachmentRoutingJob");
 0496        }
 0497    }
 498}