Как родился 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-сокет как нерв
Динамическая очередь должна уметь две вещи:
-
Не терять задачи при падении процесса или хоста.
-
Реагировать на
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.
Решений два:
-
Named volume. Подмонтировать том напрямую (
queue-data:/app/tmp/queue), минуя overlay2. -
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 ел мою БД по ночам.
Если хотя бы одна из этих историй сэкономит кому-то день отладки — гем уже окупился.
ссылка на оригинал статьи https://habr.com/ru/articles/1024614/