Honker - SQLite में Postgres NOTIFY/LISTEN semantics जोड़ने वाला extension
(github.com/russellromney)- SQLite extension और कई language bindings के ज़रिए उसी
.dbफ़ाइल के अंदर durable pub/sub, job queue, और event stream को client polling या अलग daemon·broker के बिना साथ में संभालना संभव बनाता है notify(),stream(),queue()सभी caller के transaction के अंदर रिकॉर्ड होते हैं, इसलिए business writes के साथ commit होते हैं या साथ में rollback होते हैं, जिससे dual-write problem कम होती है- processes के बीच wake-up
PRAGMA **data_version**को हर 1ms पर जाँचने के तरीके से काम करता है, और single-digit millisecond-level latency तथा बहुत कम query cost को लक्ष्य बनाकर ट्यून किया गया है - job queue में retries, priority, delayed execution, dead-letter, scheduler, named lock, rate limiting शामिल हैं, और stream में प्रत्येक consumer के offset को स्टोर करने वाली at-least-once delivery को support करता है
- SQLite को primary datastore के रूप में इस्तेमाल करने वाले environments में application और async processing को एक database file में समेटकर operational complexity कम करने वाली संरचना है, और API अभी Experimental स्थिति में है
अवलोकन
- SQLite extension और कई language bindings के साथ Postgres-style
NOTIFY/LISTENbehavior को SQLite में जोड़ता है, और durable pub/sub, job queue, event stream को client polling या अलग daemon·broker के बिना उसी.dbफ़ाइल के अंदर चलाने देता है - Rust में एक बार परिभाषित on-disk layout के आधार पर Python, Node, Bun, Ruby, Go, Elixir, C++ bindings सभी एक ही loadable extension को पतले wrapper की तरह लपेटते हैं
- database को हर 1ms पर पढ़ने की विधि application-level polling की जगह लेती है, और
PRAGMA data_versionquery cost single-digit microseconds स्तर पर तथा process-to-process notification delivery single-digit milliseconds स्तर पर ट्यून की गई है - SQLite को primary datastore की तरह इस्तेमाल करने पर business writes और queue enqueueing को उसी transaction में commit या rollback किया जा सकता है, जिससे अलग datastore चलाने और dual-write problem को कम किया जा सकता है
- API अभी Experimental स्थिति में है और बदल सकता है
- अगर आप पहले से Postgres चला रहे हैं, तो
pg_notify, pg-boss, Oban का इस्तेमाल अधिक उपयुक्त होने की बात स्पष्ट रूप से कही गई है
मुख्य विशेषताएँ
- process-to-process notify/listen, retries और priority·delayed execution·dead-letter table वाली job queue, तथा consumer-wise offset वाली durable stream को एक ही
.dbफ़ाइल में साथ में उपलब्ध कराता है - सभी send operations को business writes के साथ atomically combine किया जा सकता है, इसलिए वे साथ में commit या साथ में rollback होते हैं
- cross-process response time single-digit milliseconds स्तर का है, और handler timeout, exponential backoff-आधारित retries, delayed jobs, task expiration, named lock, rate limiting भी शामिल हैं
- leader election-आधारित scheduler और crontab-style periodic task, साथ ही opt-in तरीके से task result storage भी support करता है
enqueueid लौटाता है, worker return value स्टोर करता है, और callerqueue.wait_result(id)से परिणाम का इंतज़ार कर सकता है
- SQLite loadable extension के रूप में उपलब्ध है, इसलिए कोई भी SQLite client वही tables पढ़ सकता है
- ORM द्वारा owned SQLite connection के अंदर भी काम करता है, और ORM guide में SQLAlchemy, SQLModel, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto integration को कवर किया गया है
- इसके विपरीत, जानबूझकर शामिल न किए गए दायरे भी स्पष्ट किए गए हैं
- task pipeline, chain, group, chord support नहीं किए जाते
- multi-writer replication support नहीं किया जाता
- DAG-आधारित workflow orchestration support नहीं किया जाता
त्वरित शुरुआत
-
Python queue
honker.open("app.db")से डेटाबेस खोलें औरdb.queue("emails")की तरह queue प्राप्त करें, ताकि jobs को enqueue और consume किया जा सकेwith db.transaction() as tx:ब्लॉक के अंदर order INSERT औरemails.enqueue(..., tx=tx)को साथ में चलाने पर order write और mail job enqueue एक ही transaction में बंध जाते हैं- worker
async for job in emails.claim("worker-1"):के रूप में jobs एक-एक करके लाता है, और सफलता परjob.ack(), विफलता परjob.retry(delay_s=60, error=str(e))से handle करता है claim()एक async iterator है और अंदरूनी तौर पर हर iteration मेंclaim_batch(worker_id, 1)को कॉल करता है- यह डेटाबेस के किसी भी commit पर जाग जाता है, और केवल तब 5-सेकंड वाले paranoia poll पर वापस जाता है जब commit watcher काम नहीं कर पाता
- batch jobs के लिए
claim_batch(worker_id, n)औरqueue.ack_batch(ids, worker_id)को सीधे इस्तेमाल करने के लिए अलग रखा गया है; default visibility 300 सेकंड है
-
Python tasks
@emails.task(retries=3, timeout_s=30)decorator इस्तेमाल करने पर function call सीधे queue enqueue में बदल जाती है औरTaskResultलौटाती है- caller इसे
send_email("alice@example.com", "Hi")की तरह उपयोग कर सकता है, औरr.get(timeout=10)से worker execution result का इंतज़ार कर सकता है - worker को
python -m honker worker myapp.tasks:db --queue=emails --concurrency=4की तरह अलग process या in-process में चलाया जा सकता है - automatic name
{module}.{qualname}होता है, और production environment में pending jobs के orphan होने से बचने के लिए, name बदलने की स्थिति में@emails.task(name="...")जैसे explicit name की सिफारिश की जाती है - periodic task के लिए
@emails.periodic_task(crontab("0 3 * * *"))फ़ॉर्म इस्तेमाल होता है - विस्तृत उदाहरण
packages/honker/examples/tasks.pyमें हैं
-
Python streams
db.stream("user-events")durable pub/sub देता है, और business UPDATE तथाstream.publish(..., tx=tx)को एक ही transaction में चलाया जा सकता हैasync for event in stream.subscribe(consumer="dashboard"):से subscribe करने पर saved offset के बाद की rows को replay किया जाता है, और उसके बाद commit-based real-time delivery पर स्विच हो जाता है- हर named consumer का offset
_honker_stream_consumersटेबल में store होता है - offset auto-save डिफ़ॉल्ट रूप से केवल हर 1000 events पर या हर 1 सेकंड में एक बार होता है, ताकि high throughput पर भी single-writer slot पर ज़रूरत से ज़्यादा दबाव न पड़े
save_every_n=औरsave_every_s=से इसे adjust किया जा सकता है, और दोनों को 0 रखने पर auto-save बंद हो जाता है, फिरstream.save_offset(consumer, offset, tx=tx)को सीधे कॉल किया जा सकता है- crash होने पर आख़िरी flush किए गए offset के बाद के in-flight events फिर से deliver होते हैं, इसलिए यह at-least-once मॉडल का पालन करता है
-
Python notify
async for n in db.listen("orders"):से ephemeral pub/sub subscribe किया जा सकता है, और transaction के अंदरtx.notify("orders", {"id": 42})से notification भेजी जा सकती है- listener अभी के
MAX(id)बिंदु से attach होता है, इसलिए पुरानी history replay नहीं होती - अगर durable replay चाहिए तो
db.stream()का उपयोग करना चाहिए - notifications table अपने-आप साफ़ नहीं होती, इसलिए scheduled job में
db.prune_notifications(older_than_s=…, max_keep=…)को कॉल करना चाहिए - task payload JSON के रूप में valid होना चाहिए, और Python writer तथा Node reader एक ही channel साझा कर सकते हैं
-
Node.js
- Node binding में भी
open('app.db'),db.transaction(),tx.notify(...),db.listen('orders')पैटर्न से वही functionality इस्तेमाल होती है - business write और notify एक ही commit में बंधे होते हैं, और listen डेटाबेस के किसी भी commit पर जागने के बाद channel के आधार पर filter करता है
- Node binding में भी
-
SQLite extension
.load ./libhonker_extके बादSELECT honker_bootstrap();से initialize करें, और सिर्फ SQL functions के ज़रिए queue, lock, rate limit, scheduler, stream, result storage सुविधाएँ इस्तेमाल की जा सकती हैं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जैसी functions उपलब्ध हैं- Python binding और extension
_honker_live,_honker_dead,_honker_notificationsको साझा करते हैं, इसलिए किसी दूसरी language द्वारा extension से डाली गई jobs को Python worker ले सकता है - schema compatibility को
tests/test_extension_interop.pyमें स्थिर रखा गया है
डिज़ाइन
- इस repository में
honkerSQLite loadable extension के साथ Python, Node, Rust, Go, Ruby, Bun, Elixir bindings भी शामिल हैं - यह उन applications को लक्षित करता है जो SQLite को primary store के रूप में इस्तेमाल करते हैं, और इसका फ़ोकस package logic को SQLite extension में ले जाकर कई languages और frameworks में समान तरीके से उपयोग योग्य बनाना है
- core primitives तीन हैं
- ephemeral pub/sub
notify() - consumer-specific offset वाला durable pub/sub
stream() - at-least-once job queue
queue()
- ephemeral pub/sub
- ये तीनों primitives caller के transaction के अंदर INSERT के रूप में रिकॉर्ड होते हैं, इसलिए job delivery और business write या तो साथ commit होते हैं या साथ rollback होते हैं
- लक्ष्य application-level polling के बिना
NOTIFY/LISTENजैसा व्यवहार लागू करना है, ताकि तेज़ response time मिल सके - अगर मौजूदा SQLite file को वैसे ही इस्तेमाल किया जाए, तो डेटाबेस का हर commit workers को जगा देगा, और अधिकांश triggers बिना वास्तविक processing के केवल message या queue पढ़कर empty result के साथ समाप्त हो सकते हैं
- इस तरह का overtriggering एक जानबूझकर चुना गया tradeoff है, जिसे push-जैसे व्यवहार और तेज़ response time के लिए अपनाया गया है
WAL अनुशंसित डिफ़ॉल्ट
- भाषा bindings डिफ़ॉल्ट रूप से
journal_mode = WALका उपयोग करती हैं, जो concurrent readers और single writer संरचना, कुशलfsyncbatching, औरwal_autocheckpoint = 10000सेटिंग प्रदान करता है - DELETE, TRUNCATE, MEMORY जैसे अन्य mode भी काम करते हैं, और commit detection सभी journal mode में बढ़ते
PRAGMA data_versionके आधार पर होता है - non-WAL mode में जो एकमात्र चीज़ खोती है वह पढ़ते समय साथ में लिखने की विशेषता है; correctness और processes के बीच wake अपने-आप में WAL पर निर्भर नहीं करते
- पूरा सिस्टम एक
.dbफ़ाइल से बना होता है, और WAL चालू होने पर.db-wal,.db-shmsidecar जुड़ सकते हैं - claim को partial index के ज़रिए एक बार के
UPDATE … RETURNINGसे संभाला जाता है, और ack को एक बार केDELETEसे - किसी भी journal mode में किसी भी समय केवल एक writer होता है, जबकि concurrent reader का लाभ WAL देता है
PRAGMA data_versionहर commit और checkpoint पर बढ़ता है, इसलिए WAL truncation, journal फ़ाइल का बनना और हटना, समान आकार के reuse जैसी स्थितियों को भी सही तरह से संभालता है- SQLite में wire protocol नहीं है, इसलिए server push संभव नहीं है; consumer को खुद पढ़ना शुरू करना पड़ता है
- wake signal counter की वृद्धि है
- उसके बाद वास्तविक lookup
SELECTहोता है
- transaction सस्ते होते हैं, इसलिए jobs, events, notifications को caller के खुले
with db.transaction()block के अंदर outbox pattern की तरह लिखा जाता है - WAL फ़ाइल के आकार·mtime को
stat(2)से देखने के तरीके याFSEvents·inotify·kqueueजैसे kernel watcher की जगहPRAGMA data_versionका उपयोग किया जाता हैdata_versionएक monotonic counter है जिसे SQLite किसी भी connection के commit पर बढ़ाता है- यह WAL truncation, clock skew, rollback हुए transaction को सही तरीके से संभालता है
- macOS के kernel watcher उसी process की writes को miss कर सकते हैं, और
(size, mtime)आधारितstat(2)WAL truncate होने के बाद फिर उसी आकार तक बढ़ने पर commit miss कर सकता है - यह Linux, macOS, Windows पर एक जैसा काम करता है और 1ms स्तर की resolution पर CPU लागत बहुत कम रहती है
- प्रति query लागत लगभग 3.5µs है, और 1kHz के आधार पर कुल लगभग 3.5ms/sec बताई गई है
- SQLite का lock model single machine, single writer को मानकर चलता है; अगर दो server NFS पर एक ही
.dbमें लिखें तो corruption होगा- ऐसे मामलों में file-level sharding या Postgres पर स्विच करना ज़रूरी है
आर्किटेक्चर
-
Wake path
- हर
Databaseके लिए एक PRAGMA poll thread होता है जो हर 1ms परdata_versionको query करता है - counter बदलने पर यह हर subscriber के bounded channel में tick fan-out करता है
- हर subscriber partial index का उपयोग करके
SELECT … WHERE id > last_seenचलाता है, नई rows लौटाता है, फिर दोबारा wait करता है - subscriber 100 हों तब भी सिर्फ़ 1 poll thread काफ़ी है
- idle listener कोई SQL query बिल्कुल नहीं चलाता
- idle लागत प्रति database हर 1ms पर सिर्फ़ एक
PRAGMA data_versionquery की है, और listener की संख्या SQLite counter read आधारित संरचना के कारण लगभग मुफ़्त की तरह बढ़ती है honker-coreकाSharedWalWatcherpoll thread का मालिक होता है और subscriber id के हिसाब से boundedSyncSender<()>channel में fan-out करता है- हर
db.wal_events()call subscriber को register करता है, और लौटाया गया handleDropहोने पर अपने-आप unsubscribe हो जाता है - listener drop होने पर bridge thread में
rx.recv() -> Errहोता है और cleanup के बाद वह बंद हो जाता है
- हर
-
Queue schema
_honker_liveमें pending और processing state वाली rows रहती हैं- partial index का रूप
(queue, priority DESC, run_at, id) WHERE state IN ('pending','processing')है - claim इस index के ज़रिए एक बार के
UPDATE … RETURNINGसे किया जाता है - ack एक बार का
DELETEहै - retry सीमा पार करने वाली rows
_honker_deadमें चली जाती हैं और claim path में दोबारा scan नहीं की जातीं - state पर partial index की वजह से claim hot path पूरे history size से नहीं बल्कि working set size से सीमित होता है
- dead row 100k होने पर भी claim की गति dead row न होने वाली queue जैसी ही बनी रहती है
-
Claim iterator
async for job in q.claim(id)बार-बारclaim_batch(id, 1)बुलाकर एक-एक काम देता हैJob.ack()अपने transaction के अंदर एक singleDELETEहै, और return value तबTrueहोती है जब claim अभी भी valid हो, जबकि visibility window निकल जाने पर और किसी दूसरे worker द्वारा फिर से हासिल कर लेने परFalseहोती है- किसी भी process के database commit पर यह जाग जाता है, और 5 सेकंड का paranoia poll ही एकमात्र fallback है
- batch work के लिए सीधे
claim_batch(worker_id, n)औरqueue.ack_batch(ids, worker_id)का उपयोग करना चाहिए - library iterator के पीछे batch को नहीं छिपाती, ताकि transaction लागत और at-most-once visibility behavior को ज़्यादा स्पष्ट तरीके से संभाला जा सके
-
Transaction coupling
notify()एक SQL scalar function है जो writer connection पर register होती है- यह caller के खुले transaction के तहत
_honker_notificationsमें INSERT करती है queue.enqueue(…, tx=tx)औरstream.publish(…, tx=tx)भी इसी तरह काम करते हैं- rollback होने पर job, event, notification भी साथ में गायब हो जाते हैं
- यह built-in transactional outbox pattern है, जो अलग library install किए बिना business write और side-effect enqueue को साथ में संभालता है
- अलग dispatch table या dispatcher process नहीं है; side-effect row खुद committed row बन जाती है, और database को देखने वाला कोई भी process इसे लगभग 1ms के भीतर उठा सकता है
-
Polling से तेज़ over-triggering
data_versionमें बदलाव उसDatabaseके सभी subscriber को जगा देता है, केवल committed channel को चुनकर नहीं जगाता- ग़लत तरीके से जागने की लागत सिर्फ़ एक indexed
SELECTहै, जो microsecond स्तर की है - इसके उलट, जिसे जगाना चाहिए उसे miss करना एक चुपचाप correctness bug बन जाता है
- channel filtering trigger notification चरण में नहीं बल्कि
SELECTpath में संभाली जाती है - SQLite बहुत सारे छोटे query चलाने वाले pattern को भी कुशलता से संभाल सकता है
-
Retention policy
- queue jobs ack होने तक बने रहते हैं, और retry सीमा पार करने पर
_honker_deadमें चले जाते हैं - stream events बने रहते हैं और हर named consumer अपना offset track करता है
- notify fire-and-forget है और इसकी कोई automatic cleanup नहीं है
- retention policy को caller primitive के हिसाब से चुनता है, और
db.prune_notifications(older_than_s=…, max_keep=…)को सीधे call करना पड़ता है - इसे library default के पीछे छिपाया नहीं जाता, बल्कि caller code में retention policy को स्पष्ट रखा जाता है
- queue jobs ack होने तक बने रहते हैं, और retry सीमा पार करने पर
क्रैश रिकवरी
- rollback, SQLite की ACID विशेषताओं के अनुसार, business write के साथ jobs, events और notifications को भी पूरी तरह हटा देता है
- ट्रांज़ैक्शन के दौरान
SIGKILLआने पर भी यह सुरक्षित रहता है, और अगली बार open करते समय SQLite का atomic commit rollback stale state नहीं छोड़ता- WAL या rollback journal का उपयोग journal mode पर निर्भर करता है
- सत्यापन
tests/test_crash_recovery.pyमें किया गया है, जहाँ COMMIT से पहले subprocess को समाप्त करने के बादPRAGMA integrity_check == 'ok'और नए notify flow की जाँच की जाती है
- अगर worker काम के दौरान मर जाए, तो
visibility_timeout_sबीतने पर दूसरा worker उसे फिर से claim कर लेता है- डिफ़ॉल्ट मान 300 सेकंड है
attemptsबढ़ता हैmax_attemptsका डिफ़ॉल्ट मान 3 बार से अधिक होने पर row को_honker_deadमें स्थानांतरित कर दिया जाता है
- prune के दौरान offline listener हटाए गए events को मिस कर सकता है; अगर durable replay चाहिए, तो consumer-प्रति offsets स्टोर करने वाला
db.stream()इस्तेमाल करना चाहिए
वेब फ्रेमवर्क इंटीग्रेशन
- framework plugins उपलब्ध नहीं कराए जाते; API छोटा है, इसलिए कुछ पंक्तियों के glue code से जोड़ने का तरीका चुना गया है
- FastAPI में startup के समय worker loop शुरू करने और request processing के दौरान transaction के भीतर 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 जैसे pattern के अनुसार worker को अलग CLI process के रूप में चलाने वाली संरचना की सिफारिश की जाती है
ORM का उपयोग
- ORM connection में
libhonker_extload करके, ORM के अपने transaction के भीतर SQL functions कॉल करने पर enqueue, business write के साथ atomically commit होता है - SQLAlchemy उदाहरण में connect event पर extension load किया जाता है और
SELECT honker_bootstrap()चलाया जाता है, फिरs.begin()transaction के भीतर model INSERT औरSELECT honker_enqueue(...)को साथ में कॉल किया जाता है - worker,
honker.open("app.db")का उपयोग करने वाली अलग process में चलता है, और commit watcher उसी file पर किसी भी connection के commit पर जाग जाता है - Using with an ORM गाइड में Django, SQLModel, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto इंटीग्रेशन, SQLModel/Pydantic के लिए
TypedQueue[T]wrapper pattern, और Prisma से जुड़ी caveat शामिल हैं
प्रदर्शन
- यह बताया गया है कि आधुनिक laptop पर प्रति सेकंड हज़ारों messages प्रोसेस किए जा सकते हैं
- process-के-बेच wake latency 1ms poll cadence से सीमित होती है, और M-series के आधार पर median लगभग 1~2ms बताया गया है
- वास्तविक hardware measurement
bench/wake_latency_bench.pyऔरbench/real_bench.pyसे किए जा सकते हैं
डेवलपमेंट संरचना
-
repository layout
honker-core/: सभी bindings द्वारा साझा किया जाने वाला Rustrlib; in-tree शामिल है और crates.io पर भी प्रकाशित होता हैhonker-extension/: SQLite loadable extension के लिएcdylib; in-tree शामिल है और crates.io पर भी प्रकाशित होता हैpackages/honker/: Python package, जिसमें PyO3cdylibऔर Queue, Stream, Outbox, Scheduler शामिल हैंpackages/honker-node/: Node.js binding, और git submodule हैpackages/honker-rs/: Rust के लिए ergonomic wrapper, और git submodule हैpackages/honker-go/: Go binding, और git submodule हैpackages/honker-ruby/: Ruby binding, और git submodule हैpackages/honker-bun/: Bun binding, और git submodule हैpackages/honker-ex/: Elixir binding, और git submodule हैpackages/honker-cpp/: C++ binding, और git submodule हैtests/: cross-package integration test directorybench/: benchmark directorysite/:honker.devsite, Astro आधारित, और git submodule है- प्रत्येक binding repository अलग-अलग PyPI, npm, crates.io, Hex, RubyGems आदि पर प्रकाशित होती है, जबकि साझा आधार
honker-coreऔरhonker-extensionइसी repository में सीधे शामिल हैं - clone करते समय
git clone --recursiveयाgit submodule update --init --recursiveआवश्यक है
टेस्ट और कवरेज
make testडिफ़ॉल्ट रूप से Rust, Python, Node tests चलाता है और fast path पर लगभग 10 सेकंड लेता हैmake test-python-slowमें soak और real-time cron tests शामिल हैं और इसमें लगभग 2 मिनट लगते हैंmake test-allslow marks सहित पूरे tests चलाता हैmake buildPyO3maturin developऔर loadable extension build करता है- benchmarks को
python bench/wake_latency_bench.py --samples 500,python bench/real_bench.py --workers 4 --enqueuers 2 --seconds 15,python bench/ext_bench.pyसे चलाया जा सकता है - coverage tools की installation के लिए
make install-coverage-depsका उपयोग किया जाता है, जोcoverage.pyऔरcargo-llvm-covइंस्टॉल करता है make coverageदो HTML reportscoverage/में बनाता है, औरmake coverage-pythonPython path के लिए,make coverage-rusthonker-coreRust unit tests के आधार पर report बनाता है- Python coverage
packages/honker/के आधार पर लगभग 92% बताई गई है - Rust coverage में केवल
cargo testपरिलक्षित होता है;honker_ops.rsके कई paths सिर्फ Python test suite से चलते हैं, इसलिए वे Rust report में नहीं आते - LLVM profile data को PyO3 boundary के पार merge करके cross-language coverage संयोजन करना कठिन है और इसे अभी टाल दिया गया है
लाइसेंस
- Apache 2.0 लाइसेंस का उपयोग किया जाता है
- अधिक जानकारी LICENSE में है
1 टिप्पणियां
Hacker News टिप्पणियाँ
मैंने यह खुद बनाया है। Honker SQLite में cross-process NOTIFY/LISTEN जोड़ता है, ताकि daemon या broker के बिना सिर्फ मौजूदा SQLite फ़ाइल के सहारे single-digit ms latency के साथ push-style event delivery मिल सके।
SQLite में Postgres जैसा server नहीं होता, इसलिए तय अंतराल पर query करने के बजाय polling source को WAL फ़ाइल पर हल्के
stat(2)पर शिफ्ट करना इसका मुख्य विचार है। SQLite बहुत सारी छोटी queries चलाने में भी efficient है (https://www.sqlite.org/np1queryprob.html), इसलिए इसे बहुत बड़ा upgrade कहना मुश्किल है, लेकिन WAL को मॉनिटर करके और सिर्फ SQLite function कॉल करके यह language-agnostic बन जाता है, यही दिलचस्प बात है।इसके ऊपर ephemeral pub/sub, retry और dead-letter के साथ durable work queue, और per-consumer offset वाले event stream भी जोड़े हैं। ये तीनों मौजूदा app की
.dbफ़ाइल के अंदर rows के रूप में हैं, इसलिए इन्हें business writes के साथ atomically commit किया जा सकता है, और rollback होने पर दोनों साथ में गायब हो जाते हैं।पहले इसका नाम litenotify/joblite था, लेकिन मैंने मज़ाक में
honker.devखरीद लिया था, फिर देखा कि Oban, pg-boss, Huey, RabbitMQ, Celery, Sidekiq जैसे नाम भी वैसे ही मज़ेदार हैं, तो यही नाम रख दिया। उम्मीद है कि यह उपयोगी होगा, या कम से कम मज़ेदार तो होगा, और alpha software वाली चेतावनी अभी भी लागू है।Java/Go/Clojure/C# जैसी भाषाओं में SQLite वैसे भी single writer है, इसलिए application खुद उस writer को मैनेज करे और language-level concurrent queue के जरिए यह जाने कि कौन-सी write हुई है और सिर्फ संबंधित threads को जगाए, यह ज़्यादा simple और clean लगता है।
फिर भी WAL का इस तरह creative इस्तेमाल काफ़ी मज़ेदार है, और Python/JS/TS/Ruby जैसी भाषाओं में जहाँ process-based concurrency आम है, वहाँ notify mechanism के रूप में यह काफ़ी अच्छा fit लगता है।
मेरे hardware पर इसमें प्रति call 1μs से भी कम लगता है, इसलिए इस स्तर की polling से CPU usage 0.1% भी नहीं होता।
stat(2)सेPRAGMA data_versionबेहतर नहीं होगा?https://sqlite.org/pragma.html#pragma_data_version
अगर C API इस्तेमाल कर रहे हैं तो और भी direct
SQLITE_FCNTL_DATA_VERSIONभी है।https://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
जानना चाहता हूँ कि क्या इसे हल्के Kafka की तरह persistent message stream के रूप में भी इस्तेमाल किया जा सकता है। क्या किसी खास topic के लिए किसी timestamp से past+live messages पूरे replay करने जैसी semantics भी संभव हैं?
pub/sub की तरह polling से इसका approximation किया जा सकता है, लेकिन जैसा आपने कहा, शायद वह optimal नहीं होगा।
read position, queue name, filter जैसी चीज़ें स्टोर कर दें, तो
stat(2)बदलने पर हर subscription thread को जगाकर उनसे N=1 SELECT करवाने के बजाय polling threadEvents INNER JOIN Subscribersचला सकता है और सिर्फ वास्तव में match होने वाले subscribers को जगा सकता है।फ़ीडबैक के लिए धन्यवाद। सुझावों को शामिल करते हुए मैंने PR भेज दिया है।
https://github.com/russellromney/honker/pulls/1
अब यह 3-layer polling structure में बदल गया है: हर 1ms पर
PRAGMA data_version, हर 100ms परstat, और error होने पर reconnect handling।PRAGMA data_versionइस्तेमाल करके पुरानेstat-आधारित size/mtime change detection को replace किया है। यह खुद SQLite का commit counter है, इसलिए monotonic है, clock skew से प्रभावित नहीं होता, और WAL truncation या rollback को भी सही तरह संभालता है। यह लगभग 3µs का nonblocking query है, और मैंने इसे performance के लिए नहीं बल्कि correctness के लिए बदला है। उल्टा, यह थोड़ा धीमा है। truncation का risk भी उम्मीद से ज़्यादा real निकला।टेस्ट करने पर C API का
SQLITE_FCNTL_DATA_VERSIONconnections के बीच काम नहीं कर रहा था। इसलिए अभी भी VFS layer से होकर जाने की cost देनी पड़ रही है, और इस tradeoff को मैंने स्पष्ट रूप से स्वीकार किया है।data_versionquery fail होती है, तो disk temporary error, NFS hiccup, connection corruption जैसी स्थितियाँ मानकर reconnect की कोशिश की जाती है, और precaution के तौर पर subscriber को भी जगा दिया जाता है।statसे(dev, ino)की तुलना startup के समय के मान से करके file replacement पकड़ा जाता है। जैसे atomic rename, litestream restore, volume remount जैसी स्थितियाँ।data_versionखुली हुई fd को follow करता है, इसलिए फ़ाइल बदल जाने पर भी वह original inode देखता रहता है और इसे पकड़ नहीं पाता।इससे Honker बेहतर हुआ है और मैंने भी बहुत कुछ सीखा है।
हल्का-सा प्रचार करूँ तो, आने वाले PostgreSQL 19 में LISTEN/NOTIFY को selective signaling में कहीं बेहतर scale करने के लिए optimize किया गया है।
यह patch उन स्थितियों के लिए है जहाँ बहुत सारे backend अलग-अलग channels को listen कर रहे होते हैं।
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9
polling के बिना inotify या cross-platform wrapper से WAL changes को monitor क्यों न किया जाए?
statबस हर जगह काम करता है।अलग IPC की तुलना में इसकी सबसे आकर्षक बात यह है कि यह business data के साथ atomic commit होता है।
बाहरी message delivery में हमेशा "notification तो चला गया लेकिन transaction rollback हो गया" जैसी समस्या रहती है, और यह जल्दी messy हो जाता है।
एक सवाल WAL checkpoint को लेकर है। जब SQLite WAL को वापस 0 पर truncate करता है, तो क्या
stat()polling इसे सही तरीके से संभालती है? लगता है जैसे events miss होने की कोई window हो सकती है।पहले Postgres+SQS के साथ मुझे बहुत परेशानी हुई थी, क्योंकि trigger enqueue को दूसरी connection में commit visible होने से पहले भेज देता था। फिर retry logic जोड़ा, worker side polling भी जोड़ी, और अंत में enqueue को transaction के भीतर लाना पड़ा। उसके बाद असल में वही चीज़ ज़्यादा moving parts के साथ दोबारा बन रही थी जो Honker कर रहा है।
"notification चला गया लेकिन row अभी commit नहीं हुई" जैसे bugs आमतौर पर चुपचाप और timing-dependent होते हैं, इसलिए इन्हें trace करना बेहद दर्दनाक होता है।
हालाँकि इस हिस्से के लिए अभी test नहीं है, इसलिए और verify करना होगा। अच्छा point है, मैं इसे देखूँगा।
धन्यवाद।
SQLite-आधारित छोटे apps अब बहुत बढ़ गए हैं, और उनमें से ज़्यादातर को queue और scheduler की ज़रूरत होती है।
मैंने खुद कुछ solutions चलाए हैं, लेकिन हमेशा Postgres-आधारित solutions की elegance की कमी महसूस हुई।
इसे मैं तुरंत आज़माने वाला हूँ।
अगर कोई समस्या मिले तो repo में PR या issue छोड़ना अच्छा रहेगा।
यहाँ kqueue/FSEvents इस्तेमाल करने का मन तो करता है, लेकिन मेरी समझ से Darwin उसी process की notifications drop कर देता है।
अगर publisher और listener एक ही process में हों, तो listener कभी-कभी जागता ही नहीं, और इसे trace करना काफ़ी messy हो जाता है।
statpolling भले ही बदसूरत लगे, लेकिन आख़िर में वही तरीका है जो हर जगह सचमुच काम करता है।WAL checkpoint के समय जब फ़ाइल फिर छोटी हो जाती है, तब wakeup होता है या poller size decrease को filter कर देता है, यह भी जानना चाहूँगा।
kqueue के VNODE events तब deliver होते हैं जब process के पास फ़ाइल तक पहुँच की अनुमति हो, same-process होने के आधार पर कोई filtering नहीं होती।
मैं जाँचकर फिर बताऊँगा।
बहुत शानदार। load आने पर bottleneck मुख्य रूप से SQLite write throughput होता है या WAL notification layer, यह जानना चाहूँगा।
यह journal mode और synchronous mode पर भी काफी निर्भर करता है।
notification चाहे पुराना
stat(2)तरीका हो या नयाPRAGMA-आधारित, बहुत सस्ता है। दूसरी टिप्पणियों में भी कहा गया किstat(2)लगभग 1µs के स्तर का है।अच्छा project है। मैं भी कुछ ऐसा बना रहा हूँ जो SQLite को उसके सामान्य उपयोग से कहीं आगे तक push करता है।
यह देखना उत्साहजनक है कि ज़्यादा लोग खोज रहे हैं कि SQLite वास्तव में कहाँ तक जा सकता है।
जानना चाहूँगा कि क्या SQLAlchemy इस्तेमाल करने पर भी इसका integration संभव है।
अभी के रूप से तो लगता है कि यह अपना DB connection खुद बनाना चाहता है।