2 पॉइंट द्वारा GN⁺ 5 일 전 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • 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/LISTEN behavior को 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_version query 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 करता है
    • enqueue id लौटाता है, worker return value स्टोर करता है, और caller queue.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 करता है
  • 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 में honker SQLite 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()
  • ये तीनों 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 संरचना, कुशल fsync batching, और 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-shm sidecar जुड़ सकते हैं
  • 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_version query की है, और listener की संख्या SQLite counter read आधारित संरचना के कारण लगभग मुफ़्त की तरह बढ़ती है
    • honker-core का SharedWalWatcher poll thread का मालिक होता है और subscriber id के हिसाब से bounded SyncSender<()> channel में fan-out करता है
    • हर db.wal_events() call subscriber को register करता है, और लौटाया गया handle Drop होने पर अपने-आप 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 के अंदर एक single DELETE है, और 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 चरण में नहीं बल्कि SELECT path में संभाली जाती है
    • 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 को स्पष्ट रखा जाता है

क्रैश रिकवरी

  • 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_ext load करके, 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 द्वारा साझा किया जाने वाला Rust rlib; in-tree शामिल है और crates.io पर भी प्रकाशित होता है
    • honker-extension/: SQLite loadable extension के लिए cdylib; in-tree शामिल है और crates.io पर भी प्रकाशित होता है
    • packages/honker/: Python package, जिसमें PyO3 cdylib और 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 directory
    • bench/: benchmark directory
    • site/: honker.dev site, 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-all slow marks सहित पूरे tests चलाता है
  • make build PyO3 maturin 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 reports coverage/ में बनाता है, और make coverage-python Python path के लिए, make coverage-rust honker-core Rust 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 टिप्पणियां

 
GN⁺ 5 일 전
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 वाली चेतावनी अभी भी लागू है।

    • यह ज़्यादातर उन भाषाओं के लिए उपयोगी लगता है जहाँ process-based concurrency संभालना आसान होता है।
      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 लगता है।
    • अब जाकर पता चला कि हर 1ms में stat() करना सोच से भी ज़्यादा सस्ता है।
      मेरे 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 नहीं होगा।
    • अगर subscriber state भी साथ स्टोर की जाए तो यह और बेहतर हो सकता है।
      read position, queue name, filter जैसी चीज़ें स्टोर कर दें, तो stat(2) बदलने पर हर subscription thread को जगाकर उनसे N=1 SELECT करवाने के बजाय polling thread Events 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।

    1. हर 1ms पर 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_VERSION connections के बीच काम नहीं कर रहा था। इसलिए अभी भी VFS layer से होकर जाने की cost देनी पड़ रही है, और इस tradeoff को मैंने स्पष्ट रूप से स्वीकार किया है।
    2. अगर data_version query fail होती है, तो disk temporary error, NFS hiccup, connection corruption जैसी स्थितियाँ मानकर reconnect की कोशिश की जाती है, और precaution के तौर पर subscriber को भी जगा दिया जाता है।
    3. हर 100ms पर 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 क्यों न किया जाए?

    • cross-platform हिस्सा टूट जाता है। खासकर Mac पर यह कभी-कभी चुपचाप swallow हो जाता है, इसलिए इस पर भरोसा करना मुश्किल है।
      stat बस हर जगह काम करता है।
  • अलग IPC की तुलना में इसकी सबसे आकर्षक बात यह है कि यह business data के साथ atomic commit होता है।
    बाहरी message delivery में हमेशा "notification तो चला गया लेकिन transaction rollback हो गया" जैसी समस्या रहती है, और यह जल्दी messy हो जाता है।
    एक सवाल WAL checkpoint को लेकर है। जब SQLite WAL को वापस 0 पर truncate करता है, तो क्या stat() polling इसे सही तरीके से संभालती है? लगता है जैसे events miss होने की कोई window हो सकती है।

    • मेरे हिसाब से atomicity ही लगभग सब कुछ है।
      पहले Postgres+SQS के साथ मुझे बहुत परेशानी हुई थी, क्योंकि trigger enqueue को दूसरी connection में commit visible होने से पहले भेज देता था। फिर retry logic जोड़ा, worker side polling भी जोड़ी, और अंत में enqueue को transaction के भीतर लाना पड़ा। उसके बाद असल में वही चीज़ ज़्यादा moving parts के साथ दोबारा बन रही थी जो Honker कर रहा है।
      "notification चला गया लेकिन row अभी commit नहीं हुई" जैसे bugs आमतौर पर चुपचाप और timing-dependent होते हैं, इसलिए इन्हें trace करना बेहद दर्दनाक होता है।
    • WAL फ़ाइल बनी रहती है और सिर्फ truncate होती है, इसलिए उसे update के रूप में पकड़ा जाना चाहिए।
      हालाँकि इस हिस्से के लिए अभी test नहीं है, इसलिए और verify करना होगा। अच्छा point है, मैं इसे देखूँगा।
  • धन्यवाद।
    SQLite-आधारित छोटे apps अब बहुत बढ़ गए हैं, और उनमें से ज़्यादातर को queue और scheduler की ज़रूरत होती है।
    मैंने खुद कुछ solutions चलाए हैं, लेकिन हमेशा Postgres-आधारित solutions की elegance की कमी महसूस हुई।
    इसे मैं तुरंत आज़माने वाला हूँ।

    • छोटी proliferation वाली अभिव्यक्ति मेरे side projects की आदत से बने इस झुंड को बताने के लिए बिलकुल सही है।
      अगर कोई समस्या मिले तो repo में PR या issue छोड़ना अच्छा रहेगा।
  • यहाँ kqueue/FSEvents इस्तेमाल करने का मन तो करता है, लेकिन मेरी समझ से Darwin उसी process की notifications drop कर देता है।
    अगर publisher और listener एक ही process में हों, तो listener कभी-कभी जागता ही नहीं, और इसे trace करना काफ़ी messy हो जाता है। stat polling भले ही बदसूरत लगे, लेकिन आख़िर में वही तरीका है जो हर जगह सचमुच काम करता है।
    WAL checkpoint के समय जब फ़ाइल फिर छोटी हो जाती है, तब wakeup होता है या poller size decrease को filter कर देता है, यह भी जानना चाहूँगा।

    • यह टिप्पणी पूरी तरह गलत है।
      kqueue के VNODE events तब deliver होते हैं जब process के पास फ़ाइल तक पहुँच की अनुमति हो, same-process होने के आधार पर कोई filtering नहीं होती।
    • इसके लिए वास्तव में test की ज़रूरत है।
      मैं जाँचकर फिर बताऊँगा।
  • बहुत शानदार। load आने पर bottleneck मुख्य रूप से SQLite write throughput होता है या WAL notification layer, यह जानना चाहूँगा।

    • bottleneck writes और claim/ack flow में है।
      यह journal mode और synchronous mode पर भी काफी निर्भर करता है।
      notification चाहे पुराना stat(2) तरीका हो या नया PRAGMA-आधारित, बहुत सस्ता है। दूसरी टिप्पणियों में भी कहा गया कि stat(2) लगभग 1µs के स्तर का है।
  • अच्छा project है। मैं भी कुछ ऐसा बना रहा हूँ जो SQLite को उसके सामान्य उपयोग से कहीं आगे तक push करता है।
    यह देखना उत्साहजनक है कि ज़्यादा लोग खोज रहे हैं कि SQLite वास्तव में कहाँ तक जा सकता है।

  • जानना चाहूँगा कि क्या SQLAlchemy इस्तेमाल करने पर भी इसका integration संभव है।
    अभी के रूप से तो लगता है कि यह अपना DB connection खुद बनाना चाहता है।