Honker - SQLiteにPostgresのNOTIFY/LISTENセマンティクスを追加する拡張機能
(github.com/russellromney)- 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_notify、pg-boss、Obanの利用がより適していることを明確にしている
主な機能
- プロセス間の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 でも起床したあとチャネルでフィルタする
- Node バインディングでも
-
SQLite extension
.load ./libhonker_extの後、SELECT honker_bootstrap();で初期化し、SQL 関数だけでキュー、ロック、rate limit、scheduler、stream、result 保存機能を使えるhonker_claim_batch、honker_ack_batch、honker_sweep_expired、honker_lock_acquire、honker_rate_limit_try、honker_scheduler_tick、honker_stream_publish、honker_stream_read_since、honker_result_saveのような関数が提供される- Python バインディングと extension は
_honker_live、_honker_dead、_honker_notificationsを共有するため、別の言語が extension 経由で投入したジョブを Python worker が取得できる - スキーマ互換性は
tests/test_extension_interop.pyに固定されている
設計
- このリポジトリには
honkerSQLite 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()
- ephemeral pub/sub である
- これら 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)で見る方式や、FSEvents・inotify・kqueueのような 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-coreのSharedWalWatcherが poll thread を所有し、subscriber id ごとの boundedSyncSender<()>チャネルへ 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
SELECT1 回でマイクロ秒レベルにとどまる - 逆に 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 ポリシーが見えるようにする方式である
- キューのジョブは ack されるまで残り、リトライ上限を超えると
クラッシュリカバリ
- 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.pyとbench/real_bench.pyで実行できる
開発構成
-
リポジトリレイアウト
honker-core/: すべてのバインディングが共有する Rustrlibで、in-tree で含まれ、crates.io にも配布されるhonker-extension/: SQLite loadable extension 用のcdylibで、in-tree で含まれ、crates.io にも配布されるpackages/honker/: Python パッケージで、PyO3cdylibと Queue、Stream、Outbox、Scheduler を含むpackages/honker-node/: Node.js バインディングで、git submodulepackages/honker-rs/: Rust 向けの ergonomic wrapper で、git submodulepackages/honker-go/: Go バインディングで、git submodulepackages/honker-ruby/: Ruby バインディングで、git submodulepackages/honker-bun/: Bun バインディングで、git submodulepackages/honker-ex/: Elixir バインディングで、git submodulepackages/honker-cpp/: C++ バインディングで、git submoduletests/: cross-package integration test ディレクトリbench/: ベンチマークディレクトリsite/:honker.devサイトで、Astro ベースの git submodule- 各バインディングのリポジトリは PyPI、npm、crates.io、Hex、RubyGems などに個別配布され、共通基盤の
honker-coreとhonker-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は PyO3maturin developと loadable extension のビルドを行う- ベンチマークは
python bench/wake_latency_bench.py --samples 500、python bench/real_bench.py --workers 4 --enqueuers 2 --seconds 15、python bench/ext_bench.pyで実行できる - カバレッジツールのインストールには
make install-coverage-depsを使い、coverage.pyとcargo-llvm-covをインストールする make coverageは 2 つの HTML レポートをcoverage/に生成し、make coverage-pythonは Python パス、make coverage-rustはhonker-coreRust unit test 基準のレポートを作成する- Python カバレッジは
packages/honker/基準で約 92% と記載されている - Rust カバレッジは
cargo testのみを反映しており、honker_ops.rsの複数の経路は Python テストスイートでしか実行されないため Rust レポートには現れない - PyO3 境界をまたぐ LLVM プロファイルデータのマージによる cross-language coverage 結合 は難しく、まだ保留されている
ライセンス
- Apache 2.0 ライセンスを使用している
- 詳細は LICENSE にある
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 を自前で作ろうとしているように見える