honker добавляет в SQLite очередь задач и pub/sub без Redis

Очередь задач и pub/sub для SQLite-приложения — теперь без Redis и Celery. honker кладёт всё прямо в .db-файл, где уже лежат ваши данные, и коммитит задачи в одной транзакции с бизнес-записью.

Обложка: honker добавляет в SQLite очередь задач и pub/sub без Redis

Очередь фоновых задач и pub/sub — классические аргументы, чтобы рядом с SQLite-приложением поставить Redis и Celery. Расширение honker кладёт очередь, pub/sub и durable-стримы в тот же .db-файл, в котором уже лежат ваши данные, и коммитит их в одной транзакции с бизнес-записью. По сути автор переносит на SQLite привычную Postgres-связку NOTIFY/LISTEN — механизм, когда слушатели подписываются на именованный канал и получают события без опроса.

Ключевые факты
Что такое honker за 30 секунд
  • SQLite-расширение на Rust плюс биндинги для Python, Node.js, Rust, Go, Ruby, Bun, Elixir и C++. Ставится через pip install honker или из любого SQLite-клиента через SELECT load_extension('honker') — новый язык подключается одной SQL-командой, без ожидания биндинга.
  • Три примитива в одном файле БД: notify() для мгновенных событий без истории, durable-стрим с оффсетами (не теряется при рестарте) и очередь задач с at-least-once-доставкой (минимум один раз — значит обработчик должен быть идемпотентным), retries и dead-letter-таблицей.
  • Бизнес-запись и постановка задачи коммитятся в одной транзакции SQLite — при rollback пропадают вместе. Это транзакционный outbox по умолчанию: события и данные либо оба попадают в БД, либо оба откатываются, без сторонних библиотек и диспетчеров.
  • Cross-process уведомления идут за единицы миллисекунд: воркеры не опрашивают очередь, а ловят любой коммит в БД через PRAGMA data_version.
  • Лицензия Apache 2.0, статус «экспериментальный» — API ещё может меняться, в продакшен тащить стоит с оглядкой.

Зачем это нужно, если есть Redis

Обычный ответ на «в SQLite-приложении нужна очередь задач» — добавить Redis и Celery. Это работает, но в обмен вы получаете второй сервис: его приходится отдельно бэкапить, следить, чтобы запись в бизнес-таблицу и постановка задачи в очередь не разъехались при падении, и держать брокер в живом состоянии. Автор honker Рассел Ромни предлагает другой подход: если SQLite — основная база, очередь должна жить в том же файле.

В коде это выглядит так: INSERT INTO orders и queue.enqueue(...) коммитятся в одной транзакции. Rollback откатывает обе операции. Очередь — это просто строки в таблице с частичным индексом по состоянию, без дополнительного диспетчера или отдельной таблицы очередей. Это классический паттерн transactional outbox — когда события и бизнес-запись кладутся в одну транзакцию, — но по умолчанию и без сторонних библиотек.

Как уведомления приходят за миллисекунду

У SQLite нет сетевого протокола, поэтому серверного push быть не может — клиент должен сам читать. honker обходит это через PRAGMA data_version — монотонный счётчик, который SQLite увеличивает на каждом коммите из любого соединения. Один поток в процессе опрашивает его каждую миллисекунду; сам запрос стоит единицы микросекунд, так что 1000 проверок в секунду — это миллисекунды CPU, а не проценты. Счётчик меняется в любом режиме журнала и виден между процессами, поэтому работает одинаково на Linux, macOS и Windows.

Когда счётчик сдвинулся, подписчики просыпаются и делают один короткий SELECT ... WHERE id > last_seen по частичному индексу. Часть из них ничего полезного не найдёт — их канал не менялся. Автор сознательно оставляет эти лишние пробуждения: отфильтровать в SELECT дешевле, чем пропустить нужное событие. Итог — end-to-end задержка доставки между процессами по медиане одна–две миллисекунды на современном ноутбуке.

Три примитива в одном файле БД

Как выглядит минимальный пример на Python — постановка задачи в очередь атомарно с бизнес-записью:

			import honker

db = honker.open("app.db")
emails = db.queue("emails")

with db.transaction() as tx:
    tx.execute("INSERT INTO orders (user_id) VALUES (?)", [42])
    emails.enqueue({"to": "alice@example.com"}, tx=tx)

# В отдельном процессе — воркер
async for job in emails.claim("worker-1"):
    send(job.payload)
    job.ack()
		

notify — мгновенный pub/sub без истории. Слушатели подключаются к db.listen("orders") и получают новые события с момента подписки. История до старта слушателя не воспроизводится — offline-подписчик пропустит удалённые записи. Полезно, когда важно низколатентное «что-то произошло» без гарантии доставки.

stream — durable-pub/sub с гарантиями: события переживают рестарт воркера. Каждый именованный потребитель ведёт собственный offset в таблице _honker_stream_consumers, поэтому после рестарта продолжает с места остановки. Доставка at-least-once; оффсет можно сохранять автоматически каждые N событий или M секунд, а можно вручную через save_offset в той же транзакции, что и ваша запись, — тогда offset и бизнес-изменение либо оба закоммитятся, либо оба откатятся.

queue — собственно очередь задач. Захват строки воркером — это UPDATE ... RETURNING по частичному индексу, подтверждение — один DELETE. Если воркер упал в середине обработки, взятая им задача считается протухшей по visibility-таймауту (по умолчанию пять минут на обработку) и возвращается в очередь — другой воркер её переподхватит. После трёх неудачных попыток задача уезжает в таблицу _honker_dead, которую претенденты не сканируют. Благодаря этому горячий путь не зависит от истории очереди.

Что уже встроено

Что honker умеет помимо трёх базовых примитивов:

  • Cross-process pub/sub на одном файле БД.
  • Очередь с приоритетами, отложенными задачами, декларативными retries и экспоненциальным backoff.
  • Dead-letter-таблица для исчерпавших попытки задач.
  • Таймауты обработчиков, время жизни задач, именованные локи и rate-limiting.
  • Crontab-подобные периодические задачи с выбором лидера-планировщика.
  • Опциональное хранение результатов: enqueue возвращает id, воркер пишет ответ, вызывающий ждёт его через queue.wait_result(id).
  • Durable-стримы с per-consumer оффсетами и настраиваемым интервалом флаша.
  • Работа внутри соединения, которым владеет ORM: SQLAlchemy, SQLModel, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto.

В README явно сказано, чего в honker специально не будет: пайплайнов/цепочек/групп задач (как у Celery), multi-writer-репликации и оркестрации workflow через DAG. Автор берёт за образец Postgres-мир — pg-boss, Oban, pg_notify — и SQLite-проект Huey.

Где придётся остановиться

Первое ограничение — архитектурное: SQLite рассчитан на один файл, одного писателя и один хост. Два сервера, пишущие в один .db через NFS, рискуют получить повреждённую базу. Если нужна репликация между машинами, вариантов два: либо шардировать по файлам (отдельная .db на клиента или инстанс сервиса), либо переходить на Postgres.

Рекомендуемый режим журнала — WAL (Write-Ahead Log, отдельный файл журнала рядом с базой): он даёт конкурентных читателей при одном писателе и эффективный батчинг fsync. Другие режимы работают, но теряется именно конкурентное чтение при записи. Корректность wake-сигнала от PRAGMA data_version от WAL не зависит.

FAQ
1
Подходит ли honker для продакшена?

Автор сам помечает проект как экспериментальный — API ещё может меняться. Тесты на crash recovery уже есть, но рекомендуется использовать с оглядкой и следить за релизами. Для критичных нагрузок в Postgres-мире устоявшиеся pg-boss и Oban остаются более безопасным выбором.

2
Как это соотносится с Celery или RQ?

Celery и RQ — очереди поверх Redis/RabbitMQ. honker — очередь поверх SQLite без отдельного брокера. Выигрыш — один датастор, транзакционный outbox из коробки и задержка в миллисекунды. Цена — single-machine single-writer и отсутствие цепочек/DAG-оркестрации.

3
Что с multi-machine?

Multi-writer-репликация осознанно не входит в планы. Для нескольких серверов автор советует шардировать по файлам или переходить на Postgres.

4
Не дорого ли опрашивать базу каждую миллисекунду?

Один PRAGMA data_version — около 3,5 мкс. Даже на частоте 1000 запросов в секунду это 3,5 мс CPU в секунду на базу, а не на подписчика. Сто слушателей делят один поток опроса. Альтернативы — stat(2) на WAL-файле или ядерные watcher-ы (inotify / FSEvents / kqueue) — имеют свои баги: stat сбоит, когда WAL обнуляется и снова дорастает до прежнего размера, а FSEvents на macOS пропускает записи от того же процесса. Счётчик SQLite этих проблем лишён.

5
Можно ли использовать из моей ORM?

Да, расширение грузится на соединение, которым управляет ORM. В README есть примеры для SQLAlchemy, SQLModel, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord и Ecto. Ключевая идея — вызвать SQL-функцию honker_enqueue внутри той же транзакции, в которой идёт бизнес-запись.

Если у вас монорепа с SQLite, stand-alone-приложение или небольшой SaaS на одном-двух серверах — honker закрывает тот самый класс задач, ради которого обычно тянут Redis: очередь, pub/sub, периодические задачи, распределённые локи и rate-limiting. Если у вас уже кластер из десятка нод — это не для вас: SQLite упрётся в одного писателя и один хост. Код и документация — в репозитории на GitHub, обсуждение собралось на Hacker News, архитектуру разобрал Саймон Уиллисон у себя в блоге.