Пользователь нажал «Выгрузить заказы за год» и закрыл вкладку. На экране тишина, но работа должна продолжиться независимо от этого жеста. Браузера, который ждёт ответа, больше нет, а выгрузка на сорок тысяч строк ещё даже не начиналась. Весь вопрос в том, что происходит между кликом и файлом, и кто за это отвечает.

Hangfire решает эту задачу так: веб-приложение не делает тяжёлую работу, а записывает в хранилище намерение её выполнить. Исполнитель, который может жить в том же процессе или в отдельном, достаёт запись и выполняет. Ниже мы пройдём этот путь на одном длительном примере выгрузки и посмотрим, где он ломается и как это пережить на Linux VPS.

Два разных ответа: «принято» и «готово»

Самая частая ошибка новичка в фоновых заданиях — смешивать два события. Первое: сервер принял заявку. Второе: файл существует и его можно скачать. Между ними может пройти секунда, а может и двадцать минут. Если эти события не разделены в интерфейсе и в модели данных, пользователь получает либо зависшую страницу, либо вечный индикатор.

Поэтому первым делом заведите в своей базе отдельную сущность — запись о выгрузке. Пусть это будет таблица exports с полями: идентификатор, владелец, параметры отчёта (период, фильтры), статус, время создания, время завершения, путь к файлу и текст последней ошибки. Статусы выберем простые: «принята», «выполняется», «готова», «не удалась».

Контроллер при клике делает три вещи в таком порядке: создаёт запись со статусом «принята», ставит задание в очередь Hangfire и возвращает пользователю ответ с идентификатором выгрузки. Код ответа — «принято, обрабатывается», а не «готово». Страница кабинета по этому идентификатору читает статус из вашей таблицы.

[HttpPost("exports")]
public async Task<IActionResult> Create(ExportRequest request)
{
    var export = new Export { OwnerId = User.GetId(), Status = ExportStatus.Accepted, Period = request.Period };
    db.Exports.Add(export);
    await db.SaveChangesAsync();

    BackgroundJob.Enqueue<ExportJob>(job => job.RunAsync(export.Id, CancellationToken.None));

    return Accepted(new { export.Id, export.Status });
}

Обратите внимание, что в задание передаётся только идентификатор, а не вся выборка и не готовый список заказов. Аргументы задания сериализуются и лежат в хранилище, а потом отображаются в панели (об этом ниже). Небольшой идентификатор дешевле хранить, его безопаснее показывать, и по нему задание всегда прочитает свежие параметры из базы.

Есть и тонкость порядка. Мы сначала сохраняем запись и только потом ставим задание. Если бы поменяли местами, исполнитель мог бы подхватить задание раньше, чем запись станет видна в базе, и не найти её. Но и наш порядок не идеален: приложение может упасть между сохранением и постановкой в очередь, и тогда останется запись «принята», за которой никто не следит. Эту дыру мы закроем позже простой сверкой по времени.

Где живёт задание, пока никто не работает

Hangfire хранит задания в постоянном хранилище, а не в памяти процесса. Это ключевое свойство: если веб-процесс перезапустили сразу после постановки, задание не пропало, оно лежит в хранилище и дождётся любого исполнителя, который подключён к тому же хранилищу. Для ASP.NET Core на VPS обычно выбирают то хранилище, которое у вас уже есть, чаще всего SQL Server или PostgreSQL через соответствующий пакет. Выбор пакета зависит от вашей версии Hangfire и базы, поэтому сверяйтесь с её документацией, а не с памятью.

Практический вывод для сервера: хранилище заданий — это такая же важная часть системы, как основная база. Его диск должен быть быстрым, а его потеря означает потерю очереди. На VPS с NVMe-диском задержки на опрос очереди и запись состояний обычно не станут узким местом, но это не повод не следить за размером таблиц Hangfire: история завершённых заданий копится, и ей нужен разумный срок хранения.

Теперь исполнитель. Он берёт задание из очереди, помечает его как выполняющееся и вызывает ваш метод. Важно понимать, что вызывается именно метод вашего класса с аргументами, десериализованными из хранилища. Зависимости (контекст базы, клиент хранилища файлов) приходят из контейнера внедрения зависимостей, так что в задании пишите обычный сервис, а не статический код.

public class ExportJob(AppDb db, IFileStore files)
{
    public async Task RunAsync(int exportId, CancellationToken ct)
    {
        var export = await db.Exports.FindAsync(exportId);
        export.Status = ExportStatus.Running;
        await db.SaveChangesAsync(ct);

        var path = await BuildFileAsync(export, ct);

        export.FilePath = path;
        export.Status = ExportStatus.Ready;
        export.FinishedAt = DateTime.UtcNow;
        await db.SaveChangesAsync(ct);
    }
}

Этот код выглядит безобидно, и именно в нём прячется самое интересное.

Файл уже записан, а отметки «готово» ещё нет

Представим, что BuildFileAsync отработал: файл выгрузки лежит на диске. Дальше процесс падает или соединение с базой обрывается на последнем SaveChangesAsync. С точки зрения Hangfire задание не завершилось успешно. С точки зрения мира побочный эффект уже случился: файл существует. Но в нашей таблице статус всё ещё «выполняется».

Что будет дальше, зависит от повторов. Hangfire умеет автоматически повторять неуспешные задания: по документации, AutomaticRetry повторяет упавшую задачу с увеличивающейся задержкой, а число попыток настраивается (Hangfire: Dealing with exceptions). Это удобно: временный сбой сети или блокировка в базе проходят сами. Но у повтора есть цена, и её надо назвать прямо: задание может быть выполнено больше одного раза. Нельзя считать, что фоновое задание исполнится ровно один раз. Разумная рабочая модель такая: система старается довести работу до конца, а вы пишете код так, чтобы повторный запуск не навредил.

В нашем примере повтор запустит RunAsync заново. Если код наивный, он создаст второй файл, возможно, с другим именем, и первый останется сиротой на диске. А если выгрузка ещё и отправляет письмо со ссылкой, пользователь получит два письма. Файл — это ещё полбеды. Хуже, когда побочный эффект внешний: списание, отправка в чужую систему, уведомление клиенту.

Способы сделать повтор безопасным вытекают из самой задачи, и их три.

Детерминированное имя результата

Имя файла выводим из идентификатора выгрузки, например exports/{id}.csv, а не из случайного значения или текущего времени. Тогда повтор перезапишет тот же файл, а не создаст новый. Чтобы читатель не увидел недописанный файл, пишем сначала во временное имя и затем переименовываем: на одной файловой системе переименование обычно воспринимается как почти мгновенная замена, но проверьте это для вашего хранилища.

Проверка состояния в начале задания

Первым действием задание смотрит на статус записи. Если там уже «готова», делать нечего: просто выйти. Если «выполняется» и файл с ожидаемым именем есть, можно либо проверить его целостность и закрыть запись, либо спокойно пересоздать. В нашем сценарии пересоздание дёшево, поэтому проще всего ничего не выяснять, а перестроить файл по тому же имени.

if (export.Status == ExportStatus.Ready) return;

var path = $"exports/{export.Id}.csv";
await files.WriteAtomicAsync(path, stream, ct);

export.FilePath = path;
export.Status = ExportStatus.Ready;
await db.SaveChangesAsync(ct);

Отделить необратимое от повторяемого

Письмо «выгрузка готова» отправляйте не из этого же задания, а отдельным заданием, которое ставится после того, как статус «готова» надёжно записан. И перед отправкой оно тоже проверяет у вас в базе отметку «письмо отправлено». Так ошибка при формировании файла не вызовет повторной отправки, а повтор письма не заставит заново строить файл.

Здесь легко поддаться соблазну и решить, что одна транзакция базы объединит и запись файла, и статус, и внешний вызов. Не объединит: транзакция вашей базы не делает атомарным внешний вызов или запись на диск. Поэтому и нужна описанная дисциплина: повторяемые шаги, детерминированные результаты и проверки перед необратимыми действиями. Заодно не рассчитывайте, что новый идентификатор попытки сам сделает задание идемпотентным: если внешняя система не умеет распознавать повтор по вашему ключу, при каждой попытке она увидит новый запрос. Ключ, стабильный между попытками, например идентификатор выгрузки, — ваша ответственность.

Закроем и обещанную дыру: запись «принята» без задания. Проще всего периодически сверять записи, которые слишком долго остались в статусе «принята», и ставить для них задание заново. Благодаря проверке статуса в начале это безопасно, даже если задание на самом деле было поставлено дважды.

Сколько раз пробовать и что делать, когда попытки кончились

Повторы нужны не для всего. Временная ошибка (таймаут, недоступная база, занятый ресурс) хорошо лечится ожиданием. Ошибка в данных (в параметрах выгрузки задан несуществующий период) ждать не вылечится, и десять попыток с нарастающими паузами лишь отложат неизбежное. Для таких случаев в самом задании различайте типы ошибок: на очевидно бесполезное повторение отвечайте переводом записи в «не удалась» с понятным текстом причины.

[AutomaticRetry(Attempts = 4)]
public async Task RunAsync(int exportId, CancellationToken ct) { ... }

Число попыток настраивается атрибутом, и по документации после исчерпания попыток задание переходит в состояние Failed, откуда его можно повторить вручную. Это хорошо ложится на человеческий процесс: автоматика гасит мелкие сбои, а то, что не прошло, попадает на глаза оператору. Но чтобы пользователь не остался в вечном ожидании, сам статус в вашей таблице тоже надо довести до конца. Для этого в Hangfire есть фильтры состояний и обработчики, но можно и проще: в последней попытке ловить исключение, записывать причину в запись выгрузки и только потом выбрасывать дальше.

Как вести себя в зависимости от природы ошибки
СитуацияПовторять?Что видит пользователь
Таймаут базы или сетиДа, с нарастающей паузой«Выполняется»
Исчерпаны попытки на временной ошибкеВручную, после разбора причины«Не удалась, мы уже разбираемся»
Некорректные параметры выгрузкиНет«Не удалась» с понятной причиной
Файл записан, статус не обновлёнДа, задание идемпотентно«Выполняется», затем «Готова»

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Панель Hangfire как пульт оператора

Когда задания копят ошибки, оператору нужно место, где их видно. Таким местом служит Dashboard. По документации, он показывает методы и сериализованные аргументы заданий и позволяет их повторять, удалять и запускать (Hangfire: Dashboard). Ошибки выполнения тоже доступны через мониторинг. Для нашей выгрузки это значит: на странице упавшего задания вы увидите исключение, номер попытки и идентификатор выгрузки, который можно сопоставить с записью в базе.

Из этого же описания следует и главное предостережение. Панель умеет менять судьбу заданий, а в аргументах лежат данные приложения. Поэтому по умолчанию Dashboard пускает только локальные запросы, и для production документация требует собственную проверку авторизации. Публиковать панель на внешнем адресе без такой проверки нельзя: любой, кто найдёт адрес, сможет читать аргументы и удалять или запускать задания.

Как устроить доступ на практике? Вариантов несколько, и они отличаются степенью строгости.

  • Только локальный доступ по SSH-туннелю. Панель остаётся на поведении по умолчанию, оператор пробрасывает порт к серверу и открывает её у себя на localhost. Ничего не публикуется, ошибиться трудно. Минус: неудобно, если оператор не администратор сервера.
  • Собственный фильтр авторизации. Вы реализуете IDashboardAuthorizationFilter и пропускаете только аутентифицированных пользователей с нужной ролью. Это тот самый «собственный контроль», о котором говорит документация.
  • Дополнительный барьер на уровне обратного прокси. Ограничение по адресам или отдельная аутентификация перед панелью. Это дополняет, но не заменяет проверку в приложении.
public class OperatorsOnly : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var http = context.GetHttpContext();
        return http.User.Identity?.IsAuthenticated == true
            && http.User.IsInRole("JobOperator");
    }
}

app.MapHangfireDashboard("/ops/jobs", new DashboardOptions
{
    Authorization = [new OperatorsOnly()]
});

Тут есть мысль глубже: права оператора стоит разделять. Те, кто смотрит на состояние, и те, кто может удалять и запускать, — не обязательно одни и те же люди. Нажатие «Удалить» на задании выгрузки означает, что пользователь никогда не получит файл. Нажатие «Повторить» на задании, которое не идемпотентно, может отправить второе письмо. Чем меньше людей с кнопками, тем меньше сюрпризов. Если ваша версия Hangfire позволяет сделать панель только для чтения, этот вариант полезен для дежурных, но подтвердите его в документации вашей версии, а не предполагайте.

Отдельно про данные. Так как аргументы видны в панели, не кладите в них то, чего оператору видеть не нужно: персональные данные, токены, пароли. Идентификатор записи подходит лучше всего, и это ещё один довод в пользу решения, принятого в начале.

Исполнитель в отдельном процессе

Пока что мы молча предполагали, что исполнитель живёт внутри веб-приложения. Это самый простой вариант: один процесс, одна служба, ничего лишнего. Для небольшого проекта он вполне годится. Но у него есть неприятное свойство: жизнь заданий связана с жизнью веб-части. Обновили сайт, процесс перезапустился, выполнявшаяся выгрузка прервалась.

Разобьём на два процесса: веб-приложение только ставит задания в очередь, а отдельное приложение-воркер подключено к тому же хранилищу и выполняет их. В воркере нет контроллеров и страниц, он запускает сервер Hangfire внутри обычного узла хоста.

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<AppDb>(/* строка подключения */);
builder.Services.AddScoped<ExportJob>();
builder.Services.AddHangfire(c => c.UseSqlServerStorage(/* строка хранилища */));
builder.Services.AddHangfireServer();
await builder.Build().RunAsync();

Код задания и модели общие, поэтому их удобно вынести в библиотеку, на которую ссылаются оба проекта. Важное условие: воркер должен знать тип ExportJob, потому что в хранилище записан вызов метода именно этого типа. Если версию кода у веб-части и воркера развести слишком далеко, задание, поставленное новой веб-частью, может не найти метод у старого воркера. Поэтому при изменении сигнатур задания действуйте осторожно: сначала выкатите воркер, который умеет обе сигнатуры, затем веб-часть, которая ставит новую.

Запускайте воркер как отдельную службу systemd на VPS:

[Unit]
Description=Export worker
After=network.target

[Service]
WorkingDirectory=/opt/worker
ExecStart=/usr/bin/dotnet /opt/worker/Worker.dll
User=appworker
Restart=on-failure
TimeoutStopSec=120
Environment=DOTNET_ENVIRONMENT=Production

[Install]
WantedBy=multi-user.target

Параметр TimeoutStopSec здесь не декоративный. При остановке службы systemd посылает сигнал завершения и ждёт указанное время, прежде чем убить процесс принудительно. Сервер Hangfire в составе хоста при штатной остановке старается не брать новые задания и дать текущим шанс завершиться. Но если выгрузка идёт двадцать минут, а на остановку отведены две, процесс будет убит посреди работы. Это нормальная ситуация, и отсюда вывод: код задания обязан принимать CancellationToken и уважать его, а длинные операции стоит делить на этапы, между которыми можно безопасно остановиться.

Что происходит при обновлении веб-части

Теперь сценарий, ради которого воркер вынесен отдельно. Вы выкатываете новую версию сайта. Веб-процесс останавливается и поднимается снова. Воркер при этом не затронут: он продолжает исполнять выгрузку, пользователь не заметит ничего, а страница кабинета после перезагрузки покажет тот же статус из таблицы. Если же вам нужно обновить сам воркер, то это уже его собственная остановка: текущие задания получают шанс завершиться в отведённое время, а прерванные вернутся в очередь и будут выполнены повторно. Вот где окупается идемпотентность, о которой мы говорили выше. Прерывание при деплое по своей сути ничем не отличается от временной ошибки.

Повторное выполнение прерванного задания не происходит мгновенно: Hangfire определяет, что исполнитель пропал, не в тот же миг, а спустя настроенный промежуток. Поэтому после грубой остановки пользователь может увидеть «Выполняется» дольше обычного. Это следует учесть в тексте интерфейса и не обещать конкретных секунд, пока вы не измерили поведение на своей конфигурации.

Какой VPS нужен фоновой работе

Здесь придётся рассуждать от нагрузки, а не от названия плана. Выгрузка нагружает три ресурса: процессор на формирование строк, память на буферы и диск на запись файла и состояний. Если построить файл потоково, не загружая всё в память, аппетит по RAM остаётся скромным, а длительность определяется базой и диском. Если же собирать весь результат в списке, память вырастет вместе с размером выгрузки, и воркер может быть убит системой при нехватке памяти.

Само число воркеров тоже надо выбирать осознанно. Hangfire позволяет настроить количество потоков исполнителя. Увеличивая его, вы ускоряете очередь, но каждая параллельная выгрузка тянет базу и диск. Значение без замеров выбирать не стоит: начните с небольшого, наблюдайте за временем выполнения и нагрузкой на сервер и поднимайте постепенно. Универсального числа, подходящего всем, не существует.

В каталоге VPS MAATRIX доступны NVMe-диски, процессоры AMD EPYC и root-доступ, а значит, на машине можно поставить .NET, завести две службы systemd и настроить обратный прокси так, как нужно именно вам. Планы различаются по ресурсам: Ba — 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe; Uab — 2 vCPU, 4 ГБ и 80 ГБ; Heka — 4 vCPU, 8 ГБ и 160 ГБ; Maat — 8 vCPU, 16 ГБ и 240 ГБ; Djed — 16 vCPU, 32 ГБ и 320 ГБ. Это описание ресурсов, а не обещание ёмкости: сколько выгрузок поместится одновременно, покажут ваши замеры. На практике удобно начать с плана, где веб-часть, воркер и база помещаются вместе с запасом, а затем при росте выделить воркер или базу на отдельный сервер. Для общей картины развёртывания .NET на Linux пригодится материал VPS для .NET / ASP.NET Core на Linux.

Отдельный довод про разные процессы на одном сервере: воркер и веб-часть делят одни и те же процессор и диск. Разделение по службам даёт независимые перезапуски, но не отказоустойчивость: если упадёт сам сервер, остановится и то, и другое. Если вам нужна работа при отказе машины, это отдельная архитектурная задача, которую нельзя решить одним лишь количеством служб.

Как проверить, что всё сложилось

Лучший способ убедиться в надёжности — устроить контролируемые неприятности и посмотреть, что скажет кабинет пользователя. Три испытания хватает.

  1. Остановить воркер посреди выгрузки. Запустите задание, а затем остановите службу. После запуска воркера выгрузка должна завершиться, а в каталоге не должно остаться двух файлов под разными именами.
  2. Перезапустить веб-часть во время выгрузки. Выгрузка не должна прерваться, а страница кабинета должна показывать корректный статус до и после перезапуска.
  3. Вызвать ошибку в данных. Укажите период, которого не существует. Запись должна перейти в «не удалась» с понятной причиной, без череды бессмысленных попыток.

Каждый из этих сценариев проверяет не Hangfire, а ваш код задания. Библиотека доставляет и повторяет, а безопасность повтора вы закладываете сами.

Возвращаясь в кабинет

Теперь вернёмся к пользователю, который закрыл вкладку. Через пять минут он открывает кабинет. Он не видит бесконечный индикатор, потому что индикатору нечего крутить: на странице строка «Выгрузка заказов за 2025 год», статус «Готова», время завершения и кнопка скачивания. Если что-то пошло не так, в той же строке стоит «Не удалась» и причина человеческим языком, а за кулисами оператор уже смотрит на то же задание в закрытой панели и решает, нажимать ли «Повторить». Выгрузка не зависела ни от его вкладки, ни от обновления сайта, и каждое её состояние записано там, где его можно прочитать.