Async-background

от автора

Как родился async-background

Лёгкий cron, интервалы и очередь фоновых задач для Ruby-приложений на Async — без Redis, без Postgres, без отдельного процесса.


Откуда взялась идея

История началась с прозаичного: у меня было Falcon-приложение на Async, и в нём накопились задачи, которые надо было выполнять где-то рядом. Раз в минуту синхронизировать каталог. В 3:00 — отчёт. На действие пользователя — отправить письмо «через 5 минут после регистрации, если он не подтвердил почту».

Классический набор. Классический ответ — Sidekiq. Я открыл Gemfile, набрал gem "sidekiq" и остановился. Потому что для Sidekiq нужен Redis. А Redis — это ещё один контейнер в docker-compose.yml, ещё один процесс в проде, ещё одна точка отказа, ещё одна строка в счёте за инфраструктуру. Ради трёх задач.

Но дело было не только в Redis. У меня был и второй мотив, который оказался не менее важным, — контроль над тем, как фоновая работа потребляет ресурсы хоста. Когда у тебя на одной машине несколько Falcon-форков, и они одновременно крутят и cron, и пользовательские задачи из очереди, рано или поздно ты сталкиваешься с ситуацией, когда тяжёлый ночной отчёт залезает в воркер, который должен был обрабатывать письма, и пользователи начинают замечать задержки. Хотелось иметь возможность сказать прямо: «эти два воркера занимаются только cron, эти два — только очередью», или «вот эта конкретная задача всегда живёт на воркере №2, ни на каком другом». Без переписывания кода, без отдельного процесса под каждую роль, без оркестратора. Просто декларативной настройкой.

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


Что не подошло

whenever — генерирует системный crontab. Никакой очереди, никаких отложенных задач, никакой интеграции с приложением. Просто обёртка над cron.

rufus-scheduler — близко по духу, но он живёт в собственных тредах. В Async-приложении это значит постоянное переключение контекста между fiber-loop и thread-pool, плюс никакой защиты от дублирования при нескольких процессах Falcon.

good_job / solid_queue — отличные гемы, но завязаны на ActiveRecord и Postgres. И здесь стоит остановиться подробнее, потому что это важный архитектурный момент, который часто проходит мимо радара на этапе выбора инструмента.

В большинстве проектов Postgres — это основная база бизнес-логики. Пользователи, заказы, платежи — всё там. Если ты сажаешь на ту же базу ещё и очередь задач, ты получаешь общий ресурс, за который теперь конкурируют две очень разные нагрузки: транзакционная (короткие быстрые запросы от пользователей) и фоновая (длинные локи, частые LISTEN/NOTIFY, постоянные UPDATE статусов).

Под нагрузкой или при неудачно подобранных настройках (max_connections, work_mem, autovacuum-параметры) одно начинает мешать другому. Очередь раздувает таблицу, autovacuum не успевает, планировщик начинает выбирать неоптимальные планы для запросов от пользователей — и тормоза в фоновых задачах превращаются в тормоза на продакшен-сайте. Технически это решается отдельной БД для очереди или вынесением на отдельный инстанс, но тогда ты возвращаешься к тому, с чего начал: дополнительная инфраструктура.

Все проекты разные, и я не хочу, чтобы кто-то ловил такой каскад в проде только потому, что инструмент выбрали «по умолчанию».

sidekiq-cron / sidekiq-scheduler — возвращают нас к Redis и тому, с чего начали.

Получалось так: либо ты тащишь в проект внешнюю инфраструктуру (Redis/Postgres) ради двух задач в минуту, либо разделяешь инфраструктуру с бизнес-логикой и принимаешь риски связанности, либо пишешь костыль на Thread.new { loop { sleep 60; ... } } и молишься, чтобы он не упал молча.

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

В экосистеме Async — а это уже не маленькая экосистема, Falcon серьёзно подрос — не было решения «всё в одном»: cron + интервалы + динамическая очередь + multi-process safety + контроль утилизации, без внешних зависимостей. Эту дыру я и решил закрыть.


Принципы, которые я зафиксировал на берегу

Перед тем как написать первую строчку, я выписал, каким гем должен быть и каким не должен.

  • Один event loop. Никаких отдельных тредов. Если приложение уже на Async — планировщик живёт в той же реактивной петле.

  • Zero infrastructure. Если у тебя есть диск — у тебя есть очередь. SQLite в WAL-режиме, файл в tmp/. Никаких Redis, Postgres, RabbitMQ.

  • Опциональные зависимости. sqlite3 нужен только для динамической очереди. async-utilization — только для метрик. Не установил — фича просто выключается.

  • Multi-process safety без координатора. Falcon форкается. Одна и та же daily_report не должна запуститься 4 раза в 4 воркерах. Решение должно быть детерминированным и не требовать распределённых локов.

  • Persistence как страховка, скорость как отдельный механизм. Очередь надёжна, потому что лежит в SQLite. Очередь быстрая, потому что воркер будится через UNIX-сокет, а не polling-ом.

  • Управляемая утилизация воркеров. Разработчик сам решает, какие воркеры берут только cron, какие только очередь, а какие смешанно — и пинит отдельные задачи к конкретным воркерам, если нужно. Без правки кода, через переменные окружения и YAML. Три рычага: ISOLATION_FORKS исключает воркер из обработки очереди (он остаётся cron-only), worker: в schedule.yml пинит конкретную cron- или интервальную задачу к конкретному воркеру, и CRC32-шардинг по умолчанию равномерно раскидывает остальное. Эти три рычага комбинируются как угодно — от «универсальный пул, ничего не настраиваем» до «воркер №1 это только nightly ETL и больше ничего, воркеры 2–4 — общий пул для лёгких задач».


Quick Look

class SendEmailJob  include Async::Background::Job  def perform(user_id, template)    Mailer.send(User.find(user_id), template)  endendSendEmailJob.perform_async(user_id, "welcome")SendEmailJob.perform_in(300, user_id, "reminder")SendEmailJob.perform_at(Time.new(2026, 4, 1, 9), user_id, "scheduled")

Расписание — в config/schedule.yml:

sync_products:  class: SyncProductsJob  every: 60daily_report:  class: DailyReportJob  cron: "0 3 * * *"  timeout: 120

Архитектура в одном абзаце

Главный цикл — Runner#scheduler_loop. Внутри — MinHeap из Entry, отсортированных по next_run_at. Цикл смотрит на голову кучи, считает, сколько спать до ближайшего срабатывания, и засыпает через task.with_timeout(wait) { shutdown.wait } — это даёт две вещи сразу: разбудить нас по сигналу остановки и дождаться нужного момента, не блокируя event loop. Когда время пришло — задача дёргается под Async::Semaphore, который ограничивает параллелизм. Параллельно живёт второй кооперативный таск — слушатель очереди: он висит на UNIX-сокете и просыпается либо по событию от продюсера, либо по polling-таймауту в 5 секунд как страховка.


Технические находки

1. Min-heap вместо тиков

Самый очевидный способ написать планировщик — loop { sleep 1; check_all_jobs }. Так делают многие. Так делать не надо: процесс просыпается каждую секунду, проверяет десятки записей, тратит CPU на пустоту.

MinHeap решает это в две операции: peek даёт ближайшую задачу за O(1), replace_top после её запуска — за O(log n). Спим ровно до момента, когда что-то должно произойти.

Реализация — 74 строки, без зависимостей. Бинарная куча на массиве, классические sift_up / sift_down. Случай, когда учебный алгоритм решает реальную инженерную задачу проще, чем библиотека.

2. Две шкалы времени — это не баг

Это место, где я провёл больше всего времени.

Интервальные задачи (every: 60) используют CLOCK_MONOTONIC через Process.clock_gettime. Почему: монотонные часы не прыгают. NTP подкрутил время на 2 секунды назад — Time.now вернулся, монотонный счётчик нет. Если измерять интервалы по Time.now, ты получишь либо двойные срабатывания, либо пропуски при каждой синхронизации времени.

Cron-задачи (cron: "0 3 * * *") используют wall-clock — Time.now. Почему: «каждый день в 3:00» — это утверждение о настенных часах, а не об интервале с момента старта процесса. Переход на летнее время? Пользователь хочет, чтобы отчёт пришёл в 3:00 нового времени, а не «через 86400 секунд от прошлого запуска».

В коде это видно прямо в build_heap:

next_run_at = if task_config[:interval]  now + jitter + task_config[:interval]else  now_wall = Time.now  wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f  now + jitter + [wall_wait, MIN_SLEEP_TIME].maxend

Внутри куча хранит всё в монотонном времени (чтобы сравнения были корректны), но расчёт следующего срабатывания для cron делается через wall-clock и потом конвертируется.

3. Шардинг через CRC32 — координация без координации

Falcon форкается. У нас 4 воркера. Как сделать так, чтобы sync_products не запустилась 4 раза?

Очевидный путь — distributed lock в Redis или Postgres. Возвращаемся к зависимостям, которых я хотел избежать.

Менее очевидный путь — детерминированное распределение. Каждой задаче по имени считается Zlib.crc32(name) % total_workers + 1. Это даёт стабильное число от 1 до N. Если оно равно worker_index текущего процесса — задача загружается в кучу, иначе пропускается.

assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)next unless assigned == worker_index

Никакой координации между процессами не нужно: они договариваются заранее, кто что делает, через одну и ту же формулу. Если хочется явно прибить задачу к воркеру — есть worker: в YAML.

4. Skip-on-overlap

Что делать, если интервал — 60 секунд, а задача занимает 90?

Запустить новую параллельно? Через час у тебя 60 копий одной задачи, конкурирующих за БД, и каскад падений.

Правильнее — пропустить тик, залогировать, перенести на следующий слот:

if entry.running  logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }  metrics.job_skipped(entry)  entry.reschedule(monotonic_now)  heap.replace_top(entry)  nextend

Флаг entry.running ставится перед запуском и снимается в ensure-блоке.

5. Очередь: SQLite как источник истины + UNIX-сокет как нерв

Динамическая очередь должна уметь две вещи:

  1. Не терять задачи при падении процесса или хоста.

  2. Реагировать на perform_async без задержки, ощутимой пользователю.

Первое требование тянет к persistence (диск, fsync). Второе — к чему-то in-memory. В async-background эти роли разделены.

SQLite — источник истины. Задача записывается в БД через Queue::Client#push. Всё, что попало в БД, выполнится. WAL-режим даёт конкурентные чтения и записи.

UNIX-сокет — сигнал. Сразу после записи SocketNotifier шлёт байт в сокет нужного воркера (определяемого тем же CRC32-шардингом). Воркер висит на wait(timeout: QUEUE_POLL_INTERVAL), просыпается по сигналу, идёт в БД и забирает задачу.

Polling каждые 5 секунд — safety net. Если сокет потерян (упал воркер, race при старте) — задача всё равно подберётся через QUEUE_POLL_INTERVAL. Пропущенный wake-up не равен потерянной задаче, потому что источник истины — не сокет, а БД.

6. Recovery: что делать с висящими задачами

Если воркер упал посреди выполнения — задача останется в БД со статусом «в работе». При следующем старте Queue::Store#recover(worker_index) находит такие записи (привязанные к этому воркеру) и возвращает их в очередь. Никаких ручных действий.

7. Опциональные зависимости как принцип дизайна

Мне важно, чтобы человек, которому нужен только cron, не тащил sqlite3. И чтобы человек, которому не нужны метрики, не тащил async-utilization.

Это сделано через lazy require в setup_queue:

return unless queue_socket_dirrequire_relative 'queue/store'require_relative 'queue/socket_waker'require_relative 'queue/client'

Очередь не инициализируется — соответствующие файлы даже не подгружаются.


Гочи, которые я нашёл лбом

Docker + SQLite + overlay2

Я запустил гем в Docker. Локально всё работало. В CI всё работало. На staging, через несколько часов под нагрузкой — БД крашилась с database disk image is malformed. Перезапуск помогал на пять минут.

Оказалось: SQLite в WAL-режиме использует mmap() для чтения. Docker по умолчанию хранит файлы контейнера на overlay2, и overlay2 нарушает когерентность между write() и mmap() — записанные через write() страницы не всегда видны через mmap(). Под конкурентным доступом из нескольких воркеров это приводит к коррапту WAL.

Решений два:

  1. Named volume. Подмонтировать том напрямую (queue-data:/app/tmp/queue), минуя overlay2.

  2. queue_mmap: false. Если named volume невозможен — отключить mmap. Чуть медленнее, но безопасно.

Эту ловушку трудно нагуглить заранее, и я задокументировал её прямо в README.

fork() и SQLite-соединения

SQLite-соединения нельзя расшаривать через fork(). Гем открывает соединения лениво — уже после форка — но если ты создаёшь Queue::Store руками для миграций, его обязательно надо close перед форком.


Границы применимости

Пара слов о том, чего гем сознательно не делает — и где его не стоит ставить.

Не миллион задач в секунду. async-background целится в десятки-сотни задач в секунду на хост. Если у тебя нагрузка выше — это уже задача для специализированных систем очередей.

Не распределённая очередь. SQLite — локальный файл. Если у тебя 50 нод и нужна общая очередь — это другой класс инструментов.

Нет web UI. Намеренно. UI — это отдельный продукт, отдельная поддержка, отдельные баги. Гем фокусируется на runtime. Метрики экспортируются через shared memory — стройте дашборды чем хотите.

Только Async-экосистема. Это не general-purpose шедулер.


Несколько слов про Sidekiq

Я несколько раз упомянул Sidekiq и должен быть честен: это зрелый проект с огромным комьюнити, в нём накоплено большое количество фич, и если вам нужны эти фичи — он остаётся стандартом индустрии.

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

Есть и более тонкий момент, который касается именно Async-экосистемы. Sidekiq в своё время экспериментировал с event-loop моделью и в итоге остался на тредах — это осознанный выбор автора, и для его целевой аудитории он правильный. Но для проекта на Falcon это означает фундаментальное несоответствие моделей конкурентности. Когда вы запускаете тред-ориентированный планировщик внутри fiber-ориентированного приложения, вы не можете гарантировать, что весь стек кода в задаче будет fiber-friendly: что какой-нибудь HTTP-клиент или библиотека работы с БД не заблокирует весь reactor на синхронном системном вызове.

Это не критика Sidekiq — это констатация того, что инструмент и среда должны быть из одной парадигмы. Если вы на Falcon и Async, вам нужен планировщик, который тоже на Async. Иначе вы платите цену интеграции каждый день.


Финал

Я писал этот гем, потому что не нашёл того, что хотел сам. Каждое решение в нём — ответ на конкретную боль. Min-heap — потому что tick-based съедал CPU. Две шкалы времени — потому что NTP сломал интервалы. CRC32-шардинг — потому что я не хотел тащить Redis. UNIX-сокеты — потому что polling в 5 секунд — это вечность для пользователя. Named volumes — потому что overlay2 ел мою БД по ночам.

Если хотя бы одна из этих историй сэкономит кому-то день отладки — гем уже окупился.

🔗 github.com/roman-haidarov/async-background 📖 Get Started

ссылка на оригинал статьи https://habr.com/ru/articles/1024614/