Honker - SQLiteでPostgresのNOTIFY/LISTENを実装する拡張
(github.com/russellromney)- 1つのSQLiteファイルに耐久性キュー、ストリーム、pub/sub、スケジューラを統合し、Redis・Celeryのような別個のブローカーなしで非同期ジョブ処理が可能
PRAGMA data_versionを1ms間隔でポーリングし、プロセス間で1桁ミリ秒の反応速度を実現。アプリケーションレベルのポーリングやデーモンは不要notify(),stream(),queue()はすべて呼び出し元のトランザクション内で記録され、ビジネス書き込みと一緒にコミットまたはロールバックされるため、dual-write問題を軽減- ジョブキューは再試行、優先度、遅延実行、dead-letter、scheduler、named lock、rate limitingを含み、ストリームはコンシューマごとのオフセットを保存するat-least-once配信をサポート
- SQLiteを主ストレージとして使う環境では、アプリケーションと非同期処理を1つのデータベースファイルにまとめて運用の複雑さを下げられる
- 3つの中核プリミティブを提供
- queue(): at-least-onceジョブキュー — 再試行、優先度、遅延ジョブ、dead-letter、visibility timeout
- stream(): 耐久性pub/sub — コンシューマごとのオフセット追跡、at-least-onceリプレイ
- notify(): 一時的pub/sub — fire-and-forget、履歴リプレイなし
- Hueyスタイルの**
@queue.task()デコレータ**で関数をキュージョブに変換し、crontab()ベースの定期ジョブ + リーダー選出スケジューラをサポート - キュースキーマは
_honker_liveテーブルにpartial indexを適用し、claimはUPDATE … RETURNING1回、ackはDELETE1回で処理するため、dead行数に関係なく一定の性能 - SQLiteのロード可能拡張(
libhonker_ext)として提供され、すべてのSQLite 3.9+クライアントで同一テーブルにアクセス可能 — Pythonワーカーが他言語からpushされたジョブをclaim可能 - SQLAlchemy, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ectoなど主要ORMと連携するガイドを提供
- SIGKILL中のトランザクションもSQLite ACIDで安全であり、ワーカークラッシュ時はvisibility timeout満了後に自動で再claim
- Python, Node.js, Rust, Go, Ruby, Bun, Elixir, C++の8言語バインディングを提供し、それぞれPyPI・npm・crates.io・Hex・RubyGemsで個別公開
- Rustで実装(honker-core + honker-extension)
- Apache 2.0ライセンス
1件のコメント
Hacker Newsのコメント
これを自分で作った。Honker は SQLite に プロセス間の NOTIFY/LISTEN を追加し、デーモンやブローカーなしで既存の SQLite ファイルだけを使って、1桁 ms の遅延で push 型のイベント配信を実現する
SQLite には Postgres のようなサーバーがないので、一定間隔でクエリする代わりに、WAL ファイルに対する軽量な
stat(2)にポーリングの対象を移したのが肝。SQLite は小さなクエリをたくさん投げても効率的なので(https://www.sqlite.org/np1queryprob.html)、ものすごいアップグレードと言うほどではないが、WAL を監視して SQLite 関数を呼ぶだけでいいので、言語に依存しないのは面白いさらに、ephemeral pub/sub、リトライと dead-letter を備えた durable work queue、コンシューマーごとのオフセットを持つ event stream も載せている。3つとも既存アプリの
.dbファイル内の row なので、ビジネス書き込みと アトミックにコミット でき、ロールバックされれば両方とも一緒に消える元は litenotify/joblite という名前だったが、
honker.devを冗談半分で買っていたのを見て、Oban、pg-boss、Huey、RabbitMQ、Celery、Sidekiq みたいにみんな名前が妙なので、そのままこの名前にした。役に立つか、せめて笑えてくれればうれしいし、アルファソフトウェアだという注意もそのまま当てはまるJava/Go/Clojure/C# のような環境では、SQLite はどうせ single writer なので、アプリケーション側でその writer を管理しつつ、言語レベルの concurrent queue でどの書き込みが発生したかを把握して関係するスレッドだけを起こすほうが、より単純でクリーンに思える
それでも、WAL をこういう形で創造的に使ったのは面白いし、Python/JS/TS/Ruby のようにプロセスベースの並行性が一般的な言語では、notify メカニズムとしてかなりうまく合いそう
自分のハードウェアでは呼び出し 1 回あたり 1μs もかからないので、この程度のポーリングなら CPU 使用率は 0.1% 未満になる
stat(2)よりPRAGMA data_versionのほうがよくないかと思うhttps://sqlite.org/pragma.html#pragma_data_version
C API なら、より直接的な
SQLITE_FCNTL_DATA_VERSIONもあるhttps://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
これを 軽量な Kafka のような永続メッセージストリームとしても使えるのか気になる。特定の topic について、ある timestamp 以降の過去+リアルタイムのメッセージを全部 replay するようなセマンティクスも可能だろうか
pub/sub のようにポーリングでそれらしく再現はできるだろうが、言う通り最適ではなさそう
読み取り位置、queue 名、フィルタなどを保存しておけば、
stat(2)の変化のたびに全 subscription thread を起こしてそれぞれが N=1 SELECT をする代わりに、polling thread がEvents INNER JOIN Subscribersを実行して、実際に一致する subscriber だけを起こせるフィードバックありがとう。提案を取り入れた PR を上げた
https://github.com/russellromney/honker/pulls/1
現在は 3層のポーリング構造 に変わっている。1ms ごとの
PRAGMA data_version、100ms ごとのstat、そしてエラー時の再接続処理だPRAGMA data_versionを使って、従来のstatベースの size/mtime 変更検知を置き換えた。SQLite 自体の commit counter なので monotonic で、clock skew の影響もなく、WAL truncation や rollback も正しく扱える。約 3µs の nonblocking query で、性能のためではなく 正確性 のために変更した。むしろ少し遅い。truncation のリスクも思ったより現実的だったテストしたところ、C API の
SQLITE_FCNTL_DATA_VERSIONは connection 間では動かなかった。なので今も VFS layer を経由するコストは払っており、その tradeoff を明示的に受け入れているdata_versionクエリが失敗したら、ディスクの一時的な障害、NFS の hiccup、connection の破損などを想定して再接続を試み、予防的に subscriber も起こすstatで(dev, ino)を startup 時点の値と比較し、ファイル置き換え を検出する。atomic rename、litestream restore、volume remount などのケースだが、data_versionは開いている fd を追いかけるため、ファイルが変わっても元の inode を見続けてしまい、これを検出できないおかげで Honker はより良くなったし、自分も多くを学べた
ちょっと宣伝すると、今後の PostgreSQL 19 では LISTEN/NOTIFY が selective signaling でずっとスケールするよう最適化された
多数の backend がそれぞれ異なる channel を listen しているケースを狙ったパッチだ
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9
ポーリングなしで inotify やクロスプラットフォームな wrapper を使って WAL の変更を監視できないだろうか
statはただどこでも動く別の IPC より魅力的なのは、ビジネスデータとアトミックにコミット される点だ
外部のメッセージ配信では、常に「通知は送られたのにトランザクションはロールバックされた」という問題があり、これがすぐに厄介になる
ひとつ気になるのは WAL checkpoint だ。SQLite が WAL を再び 0 に truncate するとき、
stat()ポーリングがそれをちゃんと処理するのかわからない。イベントを取りこぼす区間がありそうな気がする以前 Postgres+SQS の組み合わせで、enqueue を別 connection 上で commit が見える前に trigger で飛ばしてしまい苦労した。retry logic を付け、worker 側のポーリングも入れ、結局 enqueue を transaction 内に移したが、そうなると結局 Honker がやっていることを、より多くの moving parts で作り直しているだけだった
「notification は送られたのに row はまだ commit されていない」系のバグは、たいてい静かでタイミング依存なので、本当に追跡がつらい
ただ、この部分のテストはまだないので、さらに確認は必要。いい指摘なので見ておく
ありがとう
SQLite ベースの小さなアプリがかなり増え、その多くで queue と scheduler が必要になる
いくつか自分で回してはいるが、いつも Postgres 系ソリューション の優雅さが惜しかった
これはすぐ試してみるつもり
問題にぶつかったら repo に PR か issue を投げてもらえるとうれしい
ここでは kqueue/FSEvents を使いたくなるが、Darwin は同じプロセスからの通知を落とすと認識している
publisher と listener が同じプロセスだと listener がまったく起きないことがあり、追跡がかなり厄介だ。
statポーリングは見た目は不格好でも、結局どこでも実際に動くのはこちらだと思うWAL checkpoint のときにファイルが再び小さくなったら wakeup が発生するのか、それとも poller が size の減少を無視するのかも気になる
kqueue の VNODE イベントは、そのプロセスがファイルへのアクセス権を持っていれば配信され、同じプロセスだからといって除外するフィルタはない
確認してからまた報告する
とてもクールだ。負荷がかかったときのボトルネックが主に SQLite の write throughput なのか、それとも WAL notification layer なのか気になる
journal mode と synchronous mode によってもかなり変わる
notification は以前の
stat(2)方式でも新しいPRAGMAベースでも非常に安価だ。ほかのコメントでもstat(2)はおよそ 1µs レベルだと言っていたいいプロジェクトだ。自分も SQLite を普通の用途よりずっと押し広げるものを作っている
SQLite が実際にどこまでできるのか、もっと多くの人が探っているのを見ると励まされる
SQLAlchemy を使う場合でも統合できるのか気になる
今の見た目だと DB connection を自前で作ろうとしているように見える