2 ポイント 投稿者 GN⁺ 6 일 전 | 1件のコメント | WhatsAppで共有
  • SQLite拡張と複数の言語バインディングにより、同じ.dbファイル内でdurable pub/sub、ジョブキュー、イベントストリームを、クライアントポーリングや別個のdaemon・brokerなしであわせて処理できるようにする
  • notify(), stream(), queue()はすべて呼び出し元のトランザクション内で記録され、ビジネス書き込みとともにコミットまたは一緒にロールバックされるため、dual-write問題を減らせる
  • プロセス間のウェイクアップはPRAGMA **data_version**を1msごとに確認する方式で動作し、1桁ミリ秒レベルの遅延と非常に小さい問い合わせコストを目標に調整されている
  • ジョブキューはリトライ、優先順位、遅延実行、dead-letter、scheduler、named lock、rate limitingを含み、ストリームはコンシューマーごとのオフセットを保存するat-least-once配信をサポートする
  • SQLiteを主ストレージとして使う環境で、アプリケーションと非同期処理を1つのデータベースファイルにまとめて運用の複雑さを下げる構成であり、APIはまだExperimental段階にある

概要

  • SQLite拡張と複数の言語バインディングでPostgres式のNOTIFY/LISTEN動作をSQLiteに加え、durable pub/sub、ジョブキュー、イベントストリームをクライアントポーリングや別個のdaemon・brokerなしで同じ.dbファイル内で処理できるようにする
  • Rustで一度定義したオンディスクレイアウトをベースに、Python、Node、Bun、Ruby、Go、Elixir、C++バインディングがすべて同じloadable extensionを薄くラップする構造になっている
  • データベースを1msごとに読む方式でアプリケーションレベルのポーリングを置き換え、PRAGMA data_versionの照会コストは1桁マイクロ秒レベル、プロセス間の通知伝達は1桁ミリ秒レベルに調整されている
  • SQLiteを主ストレージとして使う場合、ビジネス書き込みとキュー投入を同じトランザクションでコミットまたはロールバックできるため、別個のdatastore運用とdual-write問題を減らせる
  • APIはまだExperimental状態で、変更される可能性がある
  • すでにPostgresを運用しているなら、pg_notifypg-bossObanの利用がより適していることを明確にしている

主な機能

  • プロセス間のnotify/listen、リトライと優先順位・遅延実行・dead-letterテーブルを備えたジョブキュー、コンシューマーごとのオフセットを持つdurable streamを1つの.dbファイルであわせて提供する
  • すべてのsend動作はビジネス書き込みとアトミックに結合でき、一緒にコミットまたは一緒にロールバックされる
  • クロスプロセスの応答時間は1桁ミリ秒レベルで、handler timeout、exponential backoffベースのリトライ、delayed jobs、task expiration、named lock、rate limitingも含む
  • leader electionベースのschedulerとcrontabスタイルのperiodic task、opt-in方式のtask result保存もサポートする
    • enqueueはidを返し、workerは戻り値を保存し、呼び出し元はqueue.wait_result(id)で結果を待てる
  • SQLite loadable extension形式を提供するため、どのSQLiteクライアントでも同じテーブルを読める
  • ORMが所有するSQLite接続内でも動作し、ORMガイドではSQLAlchemy、SQLModel、Django、Drizzle、Kysely、sqlx、GORM、ActiveRecord、Ectoとの連携を扱っている
  • 逆に、意図的に含めていない範囲も明確にしている
    • task pipeline、chain、group、chordはサポートしない
    • multi-writer replicationはサポートしない
    • DAGベースのworkflow orchestrationはサポートしない

クイックスタート

  • Python キュー

    • honker.open("app.db") でデータベースを開き、db.queue("emails") のようにキューを取得してジョブを投入・消費できる
    • with db.transaction() as tx: ブロック内で注文の INSERT と emails.enqueue(..., tx=tx) を一緒に実行すると、注文の書き込みとメールジョブの投入が同じトランザクションに束ねられる
    • worker は async for job in emails.claim("worker-1"): の形でジョブを1件ずつ取得し、成功時は job.ack()、失敗時は job.retry(delay_s=60, error=str(e)) で処理する
    • claim() は非同期イテレータであり、内部的には各反復ごとに claim_batch(worker_id, 1) を呼び出す
    • データベース上のどのコミットでも起床し、commit watcher が動作できないときだけ 5 秒の paranoia poll にフォールバックする
    • バッチ処理は claim_batch(worker_id, n)queue.ack_batch(ids, worker_id) を直接使うように分けられており、デフォルトの visibility は 300 秒
  • Python タスク

    • @emails.task(retries=3, timeout_s=30) デコレータを使うと、関数呼び出しが直接キュー投入に変わり、TaskResult を返す
    • 呼び出し側は send_email("alice@example.com", "Hi") のように使い、r.get(timeout=10) で worker の実行結果を待てる
    • worker は python -m honker worker myapp.tasks:db --queue=emails --concurrency=4 のように別プロセスまたは in-process で実行できる
    • 自動名は {module}.{qualname} で、本番環境では名前変更によって pending job が孤児化するのを防ぐため、@emails.task(name="...") のような明示的な名前を推奨する
    • periodic task は @emails.periodic_task(crontab("0 3 * * *")) の形を使う
    • 詳しい例は packages/honker/examples/tasks.py にある
  • Python ストリーム

    • db.stream("user-events") は durable pub/sub を提供し、ビジネス UPDATE と stream.publish(..., tx=tx) を同じトランザクションで実行できる
    • async for event in stream.subscribe(consumer="dashboard"): で購読すると、保存済みオフセット以降の行を再生したあと、その後はコミットベースのリアルタイム配信へ切り替わる
    • 各 named consumer のオフセットは _honker_stream_consumers テーブルに保存される
    • オフセットの自動保存はデフォルトで 1000 イベントごと、または 1 秒ごとに 1 回だけ行われるため、高スループット時でも single-writer スロットを過度に叩かない
    • save_every_n=save_every_s= で調整でき、両方を 0 にすると自動保存を無効にして stream.save_offset(consumer, offset, tx=tx) を直接呼び出せる
    • クラッシュが発生すると、最後に flush されたオフセット以降の in-flight イベントが再配信される at-least-once モデルに従う
  • Python notify

    • async for n in db.listen("orders"): で ephemeral pub/sub を購読し、トランザクション内で tx.notify("orders", {"id": 42}) により通知を送れる
    • listener は現在の MAX(id) 時点から接続するため、過去の履歴は再生しない
    • durable replay が必要なら db.stream() を使うべき
    • notifications テーブルは自動では整理されないため、スケジュールされたジョブで db.prune_notifications(older_than_s=…, max_keep=…) を呼び出す必要がある
    • task payload は JSON として有効である必要があり、Python writer と Node reader が同じチャネルを共有できる
  • Node.js

    • Node バインディングでも open('app.db')db.transaction()tx.notify(...)db.listen('orders') パターンで同じ機能を使う
    • ビジネス書き込みと notify は同じ commit に束ねられ、listen はデータベース上のどの commit でも起床したあとチャネルでフィルタする
  • SQLite extension

    • .load ./libhonker_ext の後、SELECT honker_bootstrap(); で初期化し、SQL 関数だけでキュー、ロック、rate limit、scheduler、stream、result 保存機能を使える
    • honker_claim_batchhonker_ack_batchhonker_sweep_expiredhonker_lock_acquirehonker_rate_limit_tryhonker_scheduler_tickhonker_stream_publishhonker_stream_read_sincehonker_result_save のような関数が提供される
    • Python バインディングと extension は _honker_live_honker_dead_honker_notifications を共有するため、別の言語が extension 経由で投入したジョブを Python worker が取得できる
    • スキーマ互換性は tests/test_extension_interop.py に固定されている

設計

  • このリポジトリには honker SQLite loadable extension と Python、Node、Rust、Go、Ruby、Bun、Elixir バインディングが含まれている
  • SQLite を主ストレージとして使うアプリケーションを対象とし、package logic を SQLite extension に移して、複数の言語やフレームワークから似た方法で使えるようにすることに重点を置いている
  • 中核となる primitive は 3 つ
    • ephemeral pub/sub である notify()
    • コンシューマーごとのオフセットを持つ durable pub/sub である stream()
    • at-least-once ジョブキューである queue()
  • これら 3 つの primitive はすべて呼び出し元のトランザクション内で INSERT として記録され、ジョブ送信とビジネス書き込みが 一緒にコミットされるか、一緒にロールバックされる
  • 目標は、アプリケーションレベルのポーリングなしで NOTIFY/LISTEN に近い動作を実装し、高速な応答時間を実現すること
  • 既存の SQLite ファイルをそのまま使うと、データベース上のすべてのコミットが worker を起こし、多くのトリガーは実際の処理を伴わず、メッセージやキューを読んで空結果で終わることがある
  • この overtriggering は意図されたトレードオフであり、push に近い動作と高速な応答時間のために選ばれている

WAL 推奨デフォルト

  • 言語バインディングはデフォルトで journal_mode = WAL を使用し、これは同時 reader・単一 writer 構成、効率的な fsync バッチング、wal_autocheckpoint = 10000 設定を提供する
  • DELETE、TRUNCATE、MEMORY のような他のモードでも動作し、コミット検知はすべての journal mode で増加する PRAGMA data_version に基づいて行われる
  • 非 WAL モードで失われるのは 読み取りと同時の書き込み 特性だけであり、correctness とプロセス間 wake 自体は WAL に依存しない
  • システム全体は 1 つの .db ファイルで構成され、WAL を有効にした場合は .db-wal.db-shm のサイドカーが追加されることがある
  • claim は partial index を介した 1 回の UPDATE … RETURNING、ack は 1 回の DELETE で処理される
  • どの journal mode でもある時点の writer は 1 つだけであり、同時 reader の利点は WAL が提供する
  • PRAGMA data_version はコミットごとおよび checkpoint ごとに増加するため、WAL truncation、journal ファイルの生成と削除、同じサイズでの再利用といった状況も正しく処理する
  • SQLite には wire protocol がないためサーバープッシュは不可能で、consumer は自分で読み取りを開始する必要がある
    • wake シグナルは counter の増加
    • その後の実際の取得は SELECT
  • トランザクションは低コストなので、jobs、events、notifications は呼び出し側の開いた with db.transaction() ブロック内に outbox パターンのように記録する
  • WAL ファイルのサイズ・mtime を stat(2) で見る方式や、FSEventsinotifykqueue のような kernel watcher の代わりに PRAGMA data_version を使う
    • data_version はどの接続によるコミットでも SQLite が増加させる monotonic counter である
    • WAL truncation、clock skew、rollback されたトランザクションを正しく処理する
    • macOS の kernel watcher は同一プロセスの書き込みを見逃し、(size, mtime) ベースの stat(2) は WAL が truncate された後に同じサイズまで再び増えた場合にコミットを見逃す可能性がある
    • Linux、macOS、Windows で同一に動作し、1ms レベルの解像度でも CPU コストは非常に小さい
    • クエリあたりのコストは約 3.5µs、1kHz 基準で合計約 3.5ms/sec と明記している
  • SQLite のロックモデルは single machine, single writer を前提としており、2 台のサーバーが NFS 上の同じ .db に書き込むと破損する
    • この場合はファイル単位のシャーディングか Postgres への移行が必要になる

アーキテクチャ

  • Wake 経路

    • Database ごとに PRAGMA poll thread を 1 つ置き、data_version を 1ms ごとに問い合わせる
    • counter が変わると、各 subscriber の bounded channel に tick を fan-out する
    • 各 subscriber は partial index を活用した SELECT … WHERE id > last_seen を実行して新しい行を返し、その後 снова 待機する
    • subscriber が 100 人いても poll thread は 1 つでよい
    • idle listener は SQL クエリを一切実行しない
    • idle コストはデータベースごとに 1ms あたり 1 回の PRAGMA data_version クエリだけであり、listener 数は SQLite counter read を使う構造のおかげでほぼ無料に近くスケールする
    • honker-coreSharedWalWatcher が poll thread を所有し、subscriber id ごとの bounded SyncSender<()> チャネルへ fan-out を行う
    • db.wal_events() 呼び出しは subscriber を登録し、返された handle が Drop されると自動的に購読解除される
    • listener が drop されると bridge thread の rx.recv() -> Err が発生し、後始末をして終了する
  • キュースキーマ

    • _honker_live には pending と processing 状態の行が入る
    • partial index は (queue, priority DESC, run_at, id) WHERE state IN ('pending','processing') という形である
    • claim はこのインデックスを使った 1 回の UPDATE … RETURNING で行われる
    • ack は 1 回の DELETE である
    • リトライ上限を超えた行は _honker_dead に移され、claim 経路では再スキャンされない
    • state に対する partial index により、claim の hot path は履歴全体のサイズではなく working set のサイズ によって制限される
    • dead row が 100k 件あっても、claim 速度は dead row のないキューと同じに保たれる
  • Claim イテレータ

    • async for job in q.claim(id)claim_batch(id, 1) を繰り返し呼び出してジョブを 1 件ずつ取り出す
    • Job.ack() は自身のトランザクション内での単一の DELETE であり、返り値は claim がまだ有効なら True、visibility window を過ぎて別の worker に再取得されていれば False になる
    • どのプロセスのデータベース commit でも wake し、5 秒ごとの paranoia poll が唯一の fallback である
    • バッチ処理は claim_batch(worker_id, n)queue.ack_batch(ids, worker_id) を直接使う必要がある
    • ライブラリはイテレータの背後にバッチを隠さず、トランザクションコストと at-most-once visibility の挙動をより明確に扱えるようにしている
  • トランザクション結合

    • notify() は writer connection に登録される SQL scalar function である
    • 呼び出し側の開いたトランザクション配下で _honker_notifications に INSERT する
    • queue.enqueue(…, tx=tx)stream.publish(…, tx=tx) も同じ方式で動作する
    • rollback が起きれば job、event、notification も一緒に消える
    • これは組み込みの transactional outbox パターンであり、追加ライブラリなしでビジネス書き込みと side effect enqueue を一緒に処理する
    • 別個の dispatch table や dispatcher process はなく、side effect row 自体がコミット済みの行となり、データベースを監視するどのプロセスでも約 1ms 以内にこれを拾える
  • ポーリングより速い over-triggering

    • data_version の変更はその Database の全 subscriber を wake し、コミットされたチャネルだけを選択的に wake するわけではない
    • 誤って wake された場合のコストは indexed SELECT 1 回でマイクロ秒レベルにとどまる
    • 逆に wake すべき対象を見逃すと、静かな correctness bug につながる
    • チャネルのフィルタリングは trigger 通知段階ではなく SELECT 経路で処理される
    • SQLite は 小さなクエリを多数実行するパターン も効率的に処理できる
  • 保持ポリシー

    • キューのジョブは ack されるまで残り、リトライ上限を超えると _honker_dead に移される
    • stream イベントは保持され、各 named consumer が自分のオフセットを追跡する
    • notify は fire-and-forget で、自動クリーンアップはない
    • 保持ポリシーは primitive ごとに呼び出し側が選択し、db.prune_notifications(older_than_s=…, max_keep=…) を直接呼び出す必要がある
    • ライブラリのデフォルトの背後に隠さず、呼び出し側コードで retention ポリシーが見えるようにする方式である

クラッシュリカバリ

  • rollback は SQLite の ACID 特性に従い、業務書き込みとともに jobs、events、notifications をすべて削除する
  • トランザクション中に SIGKILL が発生しても安全で、次回 open 時に SQLite の atomic commit rollback が stale state を残さない
    • WAL または rollback journal の使用有無は journal mode に従う
    • 検証は tests/test_crash_recovery.py で行われており、subprocess を COMMIT 前に終了させた後、PRAGMA integrity_check == 'ok' と新しい notify フローを確認する
  • worker が処理中に停止した場合、visibility_timeout_s が過ぎると別の worker が再度 claim する
    • デフォルト値は 300 秒
    • attempts が増加する
    • max_attempts のデフォルト値 3 回を超えると行は _honker_dead に移動する
  • prune 中にオフラインだった listener は削除されたイベントを取り逃し、durable replay が必要な場合はコンシューマーごとのオフセットを保存する db.stream() を使う必要がある

Web フレームワーク連携

  • フレームワークプラグインは提供せず、API が小さいため数行の glue code で接続する方式を採用している
  • FastAPI では startup 時に worker loop を起動し、request 処理中のトランザクション内で business write と queue enqueue を一緒に実行する例を示している
  • SSE endpoint は db.listen(channel) または db.stream(name).subscribe(...) の上に async def stream(...): yield f"data: ...\n\n" という形で、およそ 30 行ほどで構成できる
  • Django と Flask では、Celery や RQ と同様のパターンで worker を別個の CLI プロセスとして実行する構成を推奨している

ORM の利用

  • ORM 接続で libhonker_ext を load し、ORM 自身のトランザクション内で SQL 関数を呼び出すと、enqueue は business write と原子的に commit される
  • SQLAlchemy の例では、connect イベントで extension をロードして SELECT honker_bootstrap() を実行した後、s.begin() トランザクション内でモデル INSERT と SELECT honker_enqueue(...) を一緒に呼び出す
  • worker は honker.open("app.db") を使う別プロセスで動作し、commit watcher は同じファイルに対するどの接続の commit に対しても起動する
  • Using with an ORM ガイドには、Django、SQLModel、Drizzle、Kysely、sqlx、GORM、ActiveRecord、Ecto との連携、および SQLModel/Pydantic 向けの TypedQueue[T] wrapper パターン、Prisma に関する caveat が含まれている

パフォーマンス

  • 現代的なノート PC では毎秒数千件のメッセージを処理できるとしている
  • プロセス間の wake 遅延は 1ms の poll cadence によって制限され、M-series 基準の中央値は約 1〜2ms としている
  • 実機での測定は bench/wake_latency_bench.pybench/real_bench.py で実行できる

開発構成

  • リポジトリレイアウト

    • honker-core/: すべてのバインディングが共有する Rust rlib で、in-tree で含まれ、crates.io にも配布される
    • honker-extension/: SQLite loadable extension 用の cdylib で、in-tree で含まれ、crates.io にも配布される
    • packages/honker/: Python パッケージで、PyO3 cdylib と Queue、Stream、Outbox、Scheduler を含む
    • packages/honker-node/: Node.js バインディングで、git submodule
    • packages/honker-rs/: Rust 向けの ergonomic wrapper で、git submodule
    • packages/honker-go/: Go バインディングで、git submodule
    • packages/honker-ruby/: Ruby バインディングで、git submodule
    • packages/honker-bun/: Bun バインディングで、git submodule
    • packages/honker-ex/: Elixir バインディングで、git submodule
    • packages/honker-cpp/: C++ バインディングで、git submodule
    • tests/: cross-package integration test ディレクトリ
    • bench/: ベンチマークディレクトリ
    • site/: honker.dev サイトで、Astro ベースの git submodule
    • 各バインディングのリポジトリは PyPI、npm、crates.io、Hex、RubyGems などに個別配布され、共通基盤の honker-corehonker-extension はこのリポジトリ内に直接含まれている
    • clone 時には git clone --recursive または git submodule update --init --recursive が必要

テストとカバレッジ

  • make test はデフォルトで Rust、Python、Node のテストを実行し、高速パスでは約 10 秒かかる
  • make test-python-slow は soak と real-time cron テストを含み、約 2 分かかる
  • make test-all は slow マークを含む全テストを実行する
  • make build は PyO3 maturin develop と loadable extension のビルドを行う
  • ベンチマークは python bench/wake_latency_bench.py --samples 500python bench/real_bench.py --workers 4 --enqueuers 2 --seconds 15python bench/ext_bench.py で実行できる
  • カバレッジツールのインストールには make install-coverage-deps を使い、coverage.pycargo-llvm-cov をインストールする
  • make coverage は 2 つの HTML レポートを coverage/ に生成し、make coverage-python は Python パス、make coverage-rusthonker-core Rust unit test 基準のレポートを作成する
  • Python カバレッジは packages/honker/ 基準で約 92% と記載されている
  • Rust カバレッジは cargo test のみを反映しており、honker_ops.rs の複数の経路は Python テストスイートでしか実行されないため Rust レポートには現れない
  • PyO3 境界をまたぐ LLVM プロファイルデータのマージによる cross-language coverage 結合 は難しく、まだ保留されている

ライセンス

  • Apache 2.0 ライセンスを使用している
  • 詳細は LICENSE にある

1件のコメント

 
GN⁺ 6 일 전
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 メカニズムとしてかなりうまく合いそう
    • 1ms ごとに stat() しても、思ったよりずっと安いと今回知った
      自分のハードウェアでは呼び出し 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 のようにポーリングでそれらしく再現はできるだろうが、言う通り最適ではなさそう
    • subscriber の状態 も一緒に保存すると、さらに良くなるかもしれない
      読み取り位置、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、そしてエラー時の再接続処理だ

    1. 1ms ごとに 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 を明示的に受け入れている
    2. data_version クエリが失敗したら、ディスクの一時的な障害、NFS の hiccup、connection の破損などを想定して再接続を試み、予防的に subscriber も起こす
    3. 100ms ごとに 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 の変更を監視できないだろうか

    • クロスプラットフォーム性 が壊れる。特に Mac では黙って取りこぼすことがあるので、信頼しにくい
      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 されていない」系のバグは、たいてい静かでタイミング依存なので、本当に追跡がつらい
    • WAL ファイルは残っていて truncate されるだけなので、それ自体は update として検出されるはず
      ただ、この部分のテストはまだないので、さらに確認は必要。いい指摘なので見ておく
  • ありがとう
    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 なのか気になる

    • ボトルネックは書き込みと claim/ack のフロー側だ
      journal mode と synchronous mode によってもかなり変わる
      notification は以前の stat(2) 方式でも新しい PRAGMA ベースでも非常に安価だ。ほかのコメントでも stat(2) はおよそ 1µs レベルだと言っていた
  • いいプロジェクトだ。自分も SQLite を普通の用途よりずっと押し広げるものを作っている
    SQLite が実際にどこまでできるのか、もっと多くの人が探っているのを見ると励まされる

  • SQLAlchemy を使う場合でも統合できるのか気になる
    今の見た目だと DB connection を自前で作ろうとしているように見える