[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-755":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":11,"languages":10,"totalLinesOfCode":10,"stars":12,"forks":13,"watchers":14,"openIssues":15,"contributorsCount":16,"subscribersCount":16,"size":16,"stars1d":17,"stars7d":18,"stars30d":19,"stars90d":16,"forks30d":16,"starsTrendScore":20,"compositeScore":21,"rankGlobal":10,"rankLanguage":10,"license":22,"archived":23,"fork":23,"defaultBranch":24,"hasWiki":25,"hasPages":23,"topics":26,"createdAt":10,"pushedAt":10,"updatedAt":27,"readmeContent":28,"aiSummary":29,"trendingCount":16,"starSnapshotCount":16,"syncStatus":30,"lastSyncTime":31,"discoverSource":32},755,"honker","russellromney\u002Fhonker","russellromney","SQLite extension + bindings for Postgres NOTIFY\u002FLISTEN semantics with durable queues, streams, pub\u002Fsub, and scheduler","https:\u002F\u002Fhonker.dev",null,"Python",2809,66,12,3,0,44,150,1677,132,27.48,"Other",false,"main",true,[],"2026-06-12 02:00:18","\u003Ch1 align=\"center\">\n  \u003Cimg src=\"assets\u002Fhonker-logo.png\" width=\"120\" alt=\"\" \u002F>\u003Cbr\u002F>honker\n\u003C\u002Fh1>\n\n`honker` is a SQLite extension + language bindings that add Postgres-style `NOTIFY`\u002F`LISTEN` semantics to SQLite, with built-in durable pub\u002Fsub, task queue, and event streams, without client polling or a daemon\u002Fbroker. Any language that can `SELECT load_extension('honker')` gets the same features.\n\n`honker` works by replacing application-level polling with a single-digit-µs `PRAGMA data_version` read on the database every 1ms, achieving push-like semantics and cross-process notifications with single-digit-millisecond delivery.\n\nSome bindings expose experimental watcher backends, such as mmap of\n`\u003Cdb>-shm` in WAL mode or kernel file events. AUTO backend modes stay\nconservative and use `PRAGMA data_version`. The stable semantics stay\nthe same: wake on committed updates, ignore rolled-back work, and\nre-read SQLite state after every wake.\n\n> Experimental. API may change.\n\nSQLite is increasingly the database for shipped projects. Those inevitably require pubsub and a task queue. The usual answer is \"add Redis + Celery.\" That works, but it introduces a second datastore with its own backup story, a dual-write problem between your business table and the queue, and the operational overhead of running a broker.\n\nhonker takes the approach that if SQLite is the primary datastore, the queue should live in the same file. That means `INSERT INTO orders` and `queue.enqueue(...)` commit in the same transaction. Rollback drops both. The queue is just rows in a table with a partial index.\n\nPrior art:  [`pg_notify`](https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-notify.html) (fast triggers, no retry\u002Fvisibility), [Huey](https:\u002F\u002Fgithub.com\u002Fcoleifer\u002Fhuey) (SQLite-backed Python), [pg-boss](https:\u002F\u002Fgithub.com\u002Ftimgit\u002Fpg-boss) and [Oban](https:\u002F\u002Fgithub.com\u002Fsorentwo\u002Foban) (the Postgres-side gold standards we're chasing on SQLite). If you already run Postgres, use those, as they are excellent.\n\nhonker ships as a [Rust crate](https:\u002F\u002Fcrates.io\u002Fcrates\u002Fhonker) (`honker`, plus `honker-core`\u002F`honker-extension`), a [SQLite loadable extension](#sqlite-extension-any-sqlite-39-client), and language packages: Python (`honker`), Node (`@russellthehippo\u002Fhonker-node`), Bun (`@russellthehippo\u002Fhonker-bun`), Ruby (`honker`), Go, Elixir, C++, .NET \u002F C#, and JVM \u002F Kotlin packages. The on-disk layout is defined once in Rust; every binding is a thin wrapper around the loadable extension.\n\nSee [Binding support](BINDINGS.md) for the current truth table: which\nbindings have typed queue\u002Fstream\u002Flisten\u002Fscheduler APIs, which ones have\npackaged-install proof, and what CI actually proves.\n\n## At a glance\n\n```python\nimport honker\n\ndb = honker.open(\"app.db\")\nemails = db.queue(\"emails\")\n\n# Enqueue\nemails.enqueue({\"to\": \"alice@example.com\"})\n\n# Consume (worker process)\nasync for job in emails.claim(\"worker-1\"):\n    send(job.payload)\n    job.ack()\n```\n\nAny enqueue can be atomic with a business write. Rollback drops both.\n\n```python\nwith db.transaction() as tx:\n    tx.execute(\"INSERT INTO orders (user_id) VALUES (?)\", [42])\n    emails.enqueue({\"to\": \"alice@example.com\"}, tx=tx)\n```\n\n## Features\n\nToday:\n\n- Notify\u002Flisten across processes on one `.db` file\n- Work queues with retries, priority, delayed jobs, and a dead-letter table\n- Any send can be atomic with your business write (commit together or roll back together)\n- Single-digit millisecond cross-process reaction time, no polling\n- Handler timeouts, declarative retries with exponential backoff\n- Delayed jobs, task expiration, named locks, rate-limiting\n- Time-trigger scheduling with a leader-elected scheduler:\n  5-field cron, 6-field cron, and `@every \u003Cn>\u003Cunit>`. Schedules are\n  addressable rows you can `pause`, `resume`, `update`, `list`, and\n  `unschedule` from any process or binding.\n- Cancel a pending or in-flight job by id (`queue.cancel(id)`); read\n  any job's row via `queue.get_job(id)`.\n- Opt-in task result storage (`enqueue` returns an id, worker persists the\n  return value, caller awaits `queue.wait_result(id)`)\n- Durable streams with per-consumer offsets and configurable flush interval\n- SQLite loadable extension so any SQLite client can read the same tables\n- Bindings: Python, Node.js, Rust, Go, Ruby, Bun, Elixir, .NET \u002F C#,\n  Java\u002FJVM, and Kotlin\n- Works inside an ORM-owned SQLite connection. SQLAlchemy, SQLModel,\n  Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto, Hibernate,\n  jOOQ, MyBatis, Exposed ([guide](https:\u002F\u002Fhonker.dev\u002Fguides\u002Form\u002F))\n\nDeliberately not built: task pipelines\u002Fchains\u002Fgroups\u002Fchords, multi-writer replication, workflow orchestration with DAGs.\n\n## Quick start \n\n### Python: queue (durable at-least-once work)\n\n```bash\npip install honker\n```\n\n```python\nimport honker\ndb = honker.open(\"app.db\")\nemails = db.queue(\"emails\")\n\nwith db.transaction() as tx:\n    tx.execute(\"INSERT INTO orders (user_id) VALUES (?)\", [42])\n    emails.enqueue({\"to\": \"alice@example.com\"}, tx=tx)   # atomic with order\n\n# Then in a worker, do: \nasync for job in emails.claim(\"worker-1\"):               # wakes on updates or due deadlines\n    try:\n        send(job.payload); job.ack()\n    except Exception as e:\n        job.retry(delay_s=60, error=str(e))\n```\n\n`claim()` is an async iterator. Each iteration is one `claim_batch(worker_id, 1)`. Wakes on any database update, or when the next claim-relevant deadline arrives (`run_at` for delayed jobs, or `claim_expires_at` for reclaims). Falls back to a 5 s paranoia poll only if the update watcher can't fire. For batched work, call `claim_batch(worker_id, n)` explicitly and ack with `queue.ack_batch(ids, worker_id)`. Defaults: visibility 300 s.\n\n### Python: tasks (Huey-style decorators)\n\nIf you want a function call to turn into an enqueued job without wrapping `queue.enqueue` by hand:\n\n```python\n@emails.task(retries=3, timeout_s=30)\ndef send_email(to: str, subject: str) -> dict:\n    ...\n    return {\"sent_at\": time.time()}\n\n# Caller\nr = send_email(\"alice@example.com\", \"Hi\")   # enqueues, returns a TaskResult\nprint(r.get(timeout=10))                    # blocks until worker runs it\n```\n\nWorker side, either in-process or as its own process:\n\n```bash\npython -m honker worker myapp.tasks:db --queue=emails --concurrency=4\n```\n\nAuto-name is `{module}.{qualname}` (Huey\u002FCelery convention). Explicit names with `@emails.task(name=\"...\")` are recommended in prod so renames don't orphan pending jobs. Periodic tasks use `@emails.periodic_task(crontab(\"0 3 * * *\"))`. Full details in [`packages\u002Fhonker\u002Fexamples\u002Ftasks.py`](packages\u002Fhonker\u002Fexamples\u002Ftasks.py).\n\n### Python: stream (durable pub\u002Fsub)\n\n```python\nstream = db.stream(\"user-events\")\n\nwith db.transaction() as tx:\n    tx.execute(\"UPDATE users SET name=? WHERE id=?\", [name, uid])\n    stream.publish({\"user_id\": uid, \"change\": \"name\"}, tx=tx)\n\nasync for event in stream.subscribe(consumer=\"dashboard\"):\n    await push_to_browser(event)\n```\n\nEach named consumer tracks its own offset in the `_honker_stream_consumers` table. `subscribe` replays rows past the saved offset, then transitions to live delivery on commit wake. The iterator auto-saves offset at most every 1000 events or every 1 second (whichever first) so a high-throughput stream doesn't hammer the single-writer slot. Override with `save_every_n=` \u002F `save_every_s=`, or set both to 0 to disable auto-save and call `stream.save_offset(consumer, offset, tx=tx)` yourself (atomic with whatever you just did in that tx). At-least-once: a crash re-delivers in-flight events up to the last flushed offset.\n\n### Python: notify (ephemeral pub\u002Fsub)\n\n```python\nasync for n in db.listen(\"orders\"):\n    print(n.channel, n.payload)\n\nwith db.transaction() as tx:\n    tx.execute(\"INSERT INTO orders (id, total) VALUES (?, ?)\", [42, 99.99])\n    tx.notify(\"orders\", {\"id\": 42})\n```\n\nListeners attach at current `MAX(id)`; history is not replayed. Use `db.stream()` if you need durable replay. The notifications table is not auto-pruned. Call `db.prune_notifications(older_than_s=…, max_keep=…)` from a scheduled task. Task payloads have to be valid JSON so a Python writer and Node reader can share a channel.\n\n### Node.js\n\n```js\nconst { open } = require('@russellthehippo\u002Fhonker-node');\nconst db = open('app.db');\n\n\u002F\u002F Atomic: business write + notify commit together\nconst tx = db.transaction();\ntx.execute('INSERT INTO orders (id) VALUES (?)', [42]);\ntx.notify('orders', { id: 42 });\ntx.commit();\n\n\u002F\u002F updateEvents wakes on any commit to the db.\nconst ev = db.updateEvents();\nlet lastSeen = 0;\nwhile (running) {\n  await ev.next();\n  const rows = db.query(\n    'SELECT id, payload FROM _honker_notifications WHERE id > ? ORDER BY id',\n    [lastSeen],\n  );\n  for (const row of rows) {\n    handle(JSON.parse(row.payload));\n    lastSeen = row.id;\n  }\n}\n```\n\nCurrent cross-language direct proof runs on every platform:\nPython -> Node wake through Node `updateEvents()`, and Node -> Python\nlistener wake through Python `listen()`.\n\n### Java \u002F JVM\n\n```java\nimport dev.honker.*;\n\ntry (Database db = Honker.open(\"app.db\")) {\n    Queue emails = db.queue(\"emails\");\n    long id = emails.enqueue(\"{\\\"to\\\":\\\"alice@example.com\\\"}\");\n\n    Job job = emails.claimOne(\"worker-1\").orElseThrow();\n    sendEmail(job.payloadJson());\n    job.ack();\n\n    emails.saveResult(id, \"{\\\"ok\\\":true}\");\n    emails.waitResultAsync(id, WaitOptions.timeout(Duration.ofSeconds(10)))\n        .thenAccept(this::handleResult);\n}\n```\n\nThe JVM binding keeps JSON-library choice out of the core jar. Bring\nJackson, Gson, Moshi, JSON-B, or a hand-written mapper by implementing\n`JsonCodec\u003CT>`:\n\n```java\nJsonCodec\u003CEmail> emailJson = new JsonCodec\u003C>() {\n    public String encode(Email value) {\n        try {\n            return mapper.writeValueAsString(value);\n        } catch (Exception e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public Email decode(String json) {\n        try {\n            return mapper.readValue(json, Email.class);\n        } catch (Exception e) {\n            throw new RuntimeException(e);\n        }\n    }\n};\n\ntry (Database db = Honker.open(\"app.db\")) {\n    TypedQueue\u003CEmail> emails = db.queue(\"emails\").typed(emailJson);\n    emails.enqueue(new Email(\"alice@example.com\"));\n\n    TypedJob\u003CEmail> job = emails.claimOne(\"worker-1\").orElseThrow();\n    sendEmail(job.payload());\n    job.ack();\n}\n```\n\nJava handles are `AutoCloseable`, and worker\u002Flistener\u002Fsubscriber\noptions accept executors so application frameworks can own thread\npools and shutdown.\n\n### Kotlin\n\n```kotlin\nhonker(\"app.db\").use { db ->\n    val emails = db.queue(\"emails\")\n    emails.enqueueJson(\"\"\"{\"to\":\"alice@example.com\"}\"\"\")\n\n    emails.asFlow(\"worker-1\").collect { job ->\n        sendEmail(job.payloadJson())\n        job.ack()\n    }\n}\n```\n\nKotlin adds coroutine-friendly wrappers without duplicating the JVM\nruntime:\n\n```kotlin\ndb.listen(\"orders\")\n    .asFlow()\n    .collect { notification -> println(notification.payloadJson) }\n\ndb.stream(\"user-events\")\n    .asFlow()\n    .collect { event -> push(event.payloadJson) }\n\nval result = task.enqueueJson().await(Duration.ofSeconds(10))\n```\n\nTyped helpers use the same `JsonCodec\u003CT>` seam:\n\n```kotlin\nval emailJson = object : JsonCodec\u003CEmail> {\n    override fun encode(value: Email) = mapper.writeValueAsString(value)\n    override fun decode(json: String) = mapper.readValue(json, Email::class.java)\n}\n\ndb.queue(\"emails\").enqueue(Email(\"alice@example.com\"), emailJson)\nval job = db.queue(\"emails\").asFlow(\"worker-1\").first()\nsendEmail(job.decode(emailJson))\njob.ack()\n```\n\n### SQLite extension (any SQLite 3.9+ client)\n\n```sql\n.load .\u002Flibhonker_ext\nSELECT honker_bootstrap();\nINSERT INTO _honker_live (queue, payload) VALUES ('emails', '{\"to\":\"alice\"}');\nSELECT honker_claim_batch('emails', 'worker-1', 32, 300);    -- JSON array\nSELECT honker_ack_batch('[1,2,3]', 'worker-1');              -- DELETEs; returns count\nSELECT honker_sweep_expired('emails');                       -- count moved to dead\nSELECT honker_lock_acquire('backup', 'me', 60);              -- 1 = got it, 0 = held\nSELECT honker_lock_release('backup', 'me');                  -- 1 = released\nSELECT honker_rate_limit_try('api', 10, 60);                 -- 1 = under, 0 = at limit\nSELECT honker_rate_limit_sweep(3600);                        -- drop windows >1h old\nSELECT honker_cron_next_after('0 3 * * *', unixepoch());     -- 5-field cron\nSELECT honker_cron_next_after('*\u002F2 * * * * *', unixepoch()); -- 6-field cron\nSELECT honker_cron_next_after('@every 5s', unixepoch());     -- interval schedule\nSELECT honker_scheduler_register('nightly', 'backups',\n  '0 3 * * *', '\"go\"', 0, NULL);                         -- register periodic task\nSELECT honker_scheduler_register('fast', 'backups',\n  '@every 5s', '\"go\"', 0, NULL);                         -- interval schedule\nSELECT honker_scheduler_tick(unixepoch());                   -- JSON: fires due\nSELECT honker_scheduler_soonest();                           -- min next_fire_at\nSELECT honker_scheduler_unregister('nightly');               -- 1 = deleted\nSELECT honker_queue_next_claim_at('emails');                 -- next run_at \u002F reclaim deadline\nSELECT honker_stream_publish('orders', 'k', '{\"id\":42}');    -- returns offset\nSELECT honker_stream_read_since('orders', 0, 1000);          -- JSON array\nSELECT honker_stream_save_offset('worker', 'orders', 42);    -- monotonic upsert\nSELECT honker_stream_get_offset('worker', 'orders');         -- offset or 0\nSELECT honker_result_save(42, '{\"ok\":true}', 3600);          -- save w\u002F 1h TTL\nSELECT honker_result_get(42);                                -- value or NULL\nSELECT honker_result_sweep();                                -- prune expired\nSELECT notify('orders', '{\"id\":42}');\n```\n\nThe extension shares `_honker_live`, `_honker_dead`, and `_honker_notifications` with the Python binding, so a Python worker can claim jobs any other language pushed via the extension. Schema compatibility is pinned by `tests\u002Ftest_extension_interop.py`.\n\n## Design\n\nThis repo includes the `honker` SQLite loadable extension and bindings\nfor Python, Node, Rust, Go, Ruby, Bun, Elixir, C++, .NET \u002F C#,\nJava\u002FJVM, and Kotlin.\n\nFor most applications, [SQLite alone is sufficient](https:\u002F\u002Fwww.epicweb.dev\u002Fwhy-you-should-probably-be-using-sqlite). There are already great libraries that leverage SQLite for durable messaging. [Huey](https:\u002F\u002Fgithub.com\u002Fcoleifer\u002Fhuey) is the one honker draws the most from. This project is inspired by it and seeks to do something similar across languages and frameworks by moving package logic into a SQLite extension.\n\nFor Postgres-backed apps, [`pg_notify`](https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-notify.html) + [pg-boss](https:\u002F\u002Fgithub.com\u002Ftimgit\u002Fpg-boss) or [Oban](https:\u002F\u002Fhexdocs.pm\u002Foban\u002F) is the equivalent. This library is for apps where SQLite is the primary datastore.\n\nThe extension has three primitives that tie it together: ephemeral pub\u002Fsub (`notify()`), durable pub\u002Fsub with per-consumer offsets (`stream()`), at-least-once work queue (`queue()`). All three are INSERTs inside your transaction, which lets a task \"send\" be atomic with your business write, and rollback drops everything.\n\nThe explicit goal is to do `NOTIFY`\u002F`LISTEN` semantics without application-level polling, to achieve single-digit ms reaction time. If you use your app's existing SQLite file containing business logic, it will notify workers on every commit to the database. This means that most triggers will not result in anything happening: instead, workers just read the message\u002Fqueue with no result. This \"overtriggering\" is on purpose and is the tradeoff for push-like semantics and fast reaction time.\n\n### WAL is the recommended default\n\nThe language bindings default to `journal_mode = WAL` because it gives concurrent readers with one writer and efficient fsync batching (`wal_autocheckpoint = 10000`). Other journal modes (DELETE, TRUNCATE, MEMORY) still work. The wake path is `PRAGMA data_version`, which increments on every commit in every journal mode and is visible across processes. What you lose in non-WAL modes is WAL's concurrent-read-while-writing property; correctness and cross-process wake do not depend on WAL.\n\n- One `.db` is the entire system (plus `.db-wal` \u002F `.db-shm` sidecars if you've opted into WAL). You get every benefit of SQLite (embedded, local, durable, snapshot-able) that your app already uses.\n- Claim is one `UPDATE … RETURNING` via a partial index; ack is one `DELETE`. One writer at a time no matter the journal mode; concurrent readers come with WAL.\n- We poll `PRAGMA data_version` every 1 ms to detect commits from any connection in any journal mode. The counter increments on every commit and on checkpoint, so WAL truncation, journal-file comings-and-goings, and exact-size collisions are all handled correctly.\n- SQLite has no wire protocol. Consumers must initiate reads; server-push is impossible. Wake signal = counter increment → `SELECT`.\n- Transactions are cheap, so jobs, events, and notifications are rows in the caller's open `with db.transaction()` block in an \"outbox\"-type pattern.\n- We use `PRAGMA data_version` instead of `stat(2)` on the WAL file or kernel watchers (`FSEvents`\u002F`inotify`\u002F`kqueue`). `data_version` is a monotonic counter incremented by SQLite on every commit by any connection: it handles WAL truncation, clock skew, and rolled-back transactions correctly. Kernel watchers drop same-process writes on macOS, and `stat(2)` on `(size, mtime)` misses commits when the WAL is truncated then grows back to the same size. `PRAGMA data_version` works identically on Linux\u002FmacOS\u002FWindows at ~1 ms granularity for negligible CPU. Cost: ~3.5 µs per query, ~3.5 ms\u002Fsec total at 1 kHz.\n- Single machine, single writer. SQLite's locking is designed for a single host. Two servers writing one `.db` over NFS will corrupt it. Shard by file, or switch to Postgres.\n\n### In-memory databases are not supported\n\nHonker does not support SQLite in-memory database filenames such as `:memory:`, `file::memory:?cache=shared`, or `file:\u003Cname>?mode=memory&cache=shared`. Bare `:memory:` creates a separate database per connection, which would split Honker's writer, readers, and update watcher across different databases. SQLite's shared-memory URI forms can share state across multiple connections, but only inside one process, so they do not exercise Honker's cross-process worker\u002Flistener contract.\n\nFor tests and feature environments, use a temporary file-backed `.db`. It is still cheap and disposable, but it preserves the same SQLite locking, wake, crash\u002Freopen, and multi-process semantics that Honker relies on in production.\n\n## Architecture\n\n### Wake path\n\n- One PRAGMA-poll thread per `Database`, queries `data_version` every 1 ms\n- Counter change → fan out a tick to each subscriber's bounded channel\n- Each subscriber runs `SELECT … WHERE id > last_seen` against a partial index, yields rows, returns to wait\n- 100 subscribers = 1 poll thread\n- Idle listeners run zero SQL queries\n\nIdle cost is a single `PRAGMA data_version` query per millisecond per database. Listener count scales for free because the wake signal is a SQLite counter read instead of a polling query.\n\n`SharedUpdateWatcher` (in `honker-core`) owns the poll thread and fans out to N subscribers via bounded `SyncSender\u003C()>` channels keyed by subscriber id. Each `db.update_events()` call registers a subscriber and returns a handle whose `Drop` auto-unsubscribes, so a dropped listener causes the bridge thread's `rx.recv() -> Err` and exits cleanly.\n\n### Wake backend (advanced)\n\nPolling is the default. It's the only backend shipped in published wheels. Two opt-in alternatives exist behind Cargo features for source builds: kernel filesystem events, and an mmap read of SQLite's WAL index. Builds without the requested feature reject `kernel` \u002F `shm` explicitly instead of silently substituting polling. Both experimental backends can give lower idle CPU or faster wakes, but they can also miss wakes or fire wakes you didn't ask for. All three watch for the database file being swapped under them; if that happens they shut down loudly — every subscriber sees an error from `update_events()` instead of hanging.\n\nBinding support is tracked in [BINDINGS.md](BINDINGS.md). All maintained\nbindings route blocking wake waits through the same `honker-core`\nwatcher, either in-process or through the shared extension ABI.\n\nOne thing changed for everyone, no opt-in needed: the polling backend now keeps its connection through transient `SQLITE_BUSY` \u002F `SQLITE_LOCKED` errors during commits. Before, it would drop and reconnect, which could miss wakes on non-WAL journal modes (DELETE \u002F TRUNCATE \u002F PERSIST). Now it just retries the next tick.\n\nFull reference — when to pick which, source-build flags, recovery patterns, what we haven't tested yet — at [docs › Watcher backends](https:\u002F\u002Fhonker.dev\u002Freference\u002Fwatcher-backends\u002F).\n\n### Queue schema\n\n- `_honker_live`: pending + processing rows\n- Partial index: `(queue, priority DESC, run_at, id) WHERE state IN ('pending','processing')`\n- Claim = one `UPDATE … RETURNING` via that index\n- Ack = one `DELETE`\n- Retry-exhausted → `_honker_dead` (never scanned by claim path)\n\nPartial-index on state means the claim hot path is bounded by the *working-set* size rather than the *history* size. A queue with 100k dead rows claims as fast as a queue with zero.\n\n### Claim iterator\n\n- `async for job in q.claim(id)` yields one job at a time via `claim_batch(id, 1)`\n- `Job.ack()` is one `DELETE` in its own transaction. Return is an honest bool: `True` iff the claim was still valid, `False` if the visibility window elapsed and another worker reclaimed.\n- Wakes on database update from any process, or when the next `run_at` \u002F\n  reclaim deadline arrives; a 5 s paranoia poll is the only fallback.\n\nFor batched work, call `claim_batch(worker_id, n)` directly and ack with `queue.ack_batch(ids, worker_id)`. The library doesn't hide batching behind the iterator. The per-tx cost and the at-most-once visibility semantics are easier to reason about when the API doesn't try to be clever.\n\n### Transactional coupling\n\n- `notify()` is a SQL scalar function registered on the writer connection\n- INSERTs into `_honker_notifications` under the caller's open tx\n- `queue.enqueue(…, tx=tx)` and `stream.publish(…, tx=tx)` do the same\n- Rollback drops the job\u002Fevent\u002Fnotification with the rest of the tx\n\nThis is the transactional outbox pattern, by default, without a library to install. Business write and side-effect enqueue commit or roll back together. There is no separate dispatch table and no separate dispatcher process: the side-effect row *is* the committed row, and any process watching the db picks it up within ~1 ms.\n\n### Over-triggering quickly is better than over-triggering from polling\n\n- A `data_version` change wakes *every* subscriber on that `Database`, not just the ones whose channel committed\n- Each wasted wake = one indexed SELECT (microseconds)\n- A missed wake = a silent correctness bug\n\nThe library prefers waking ten listeners that don't care over missing one that does. Channel filtering happens in the `SELECT` path instead of the trigger notification. [Many small queries are efficient in SQLite](https:\u002F\u002Fwww.sqlite.org\u002Fnp1queryprob.html).\n\n### Retention\n\n- Queue jobs persist until ack; retry-exhausted rows move to `_honker_dead`\n- Stream events persist; each named consumer tracks its own offset\n- Notify is fire-and-forget and not auto-pruned\n\nThe caller chooses retention per primitive. `db.prune_notifications(older_than_s=…, max_keep=…)` is a tool you invoke. This keeps retention policy visible in the caller's code instead of inherited from a library default.\n\n## Crash recovery\n\n- Rollback drops jobs\u002Fevents\u002Fnotifications with your business write (SQLite ACID).\n- SIGKILL mid-tx is safe. SQLite's atomic-commit rollback on next open leaves no stale state (WAL or rollback journal, depending on mode). Verified in `tests\u002Ftest_crash_recovery.py` (subprocess killed pre-COMMIT, `PRAGMA integrity_check == 'ok'`, fresh notifies still flow).\n- If a worker crashes mid-job, the claim expires after `visibility_timeout_s` (default 300 s) and another worker reclaims. `attempts` increments. After `max_attempts` (default 3), the row moves to `_honker_dead`.\n- Listeners offline during a prune miss the pruned events. For durable replay, use `db.stream()`, which tracks per-consumer offsets.\n\n## Wiring into your web framework\n\nHonker ships no framework plugins. API is small and the integration is a few lines of glue:\n\n```python\n# FastAPI: enqueue in a request, run workers via lifespan.\n@app.on_event(\"startup\")\nasync def _start_workers():\n    async def worker_loop():\n        async for job in db.queue(\"emails\").claim(\"worker\"):\n            await honker._worker.run_task(\n                job, send_email, timeout=30, retries=3, backoff=2.0\n            )\n    app.state._worker = asyncio.create_task(worker_loop())\n\n@app.post(\"\u002Forders\")\nasync def create_order(order: dict):\n    with db.transaction() as tx:\n        tx.execute(\"INSERT INTO orders (user_id) VALUES (?)\", [order[\"user_id\"]])\n        db.queue(\"emails\").enqueue({\"to\": order[\"email\"]}, tx=tx)\n    return {\"ok\": True}\n```\n\nSSE endpoints are ~30 lines of `async def stream(...): yield f\"data: ...\\n\\n\"` over `db.listen(channel)` or `db.stream(name).subscribe(...)`. For Django\u002FFlask, run the worker as a dedicated CLI process (same pattern as Celery\u002FRQ).\n\n### Using an ORM (SQLAlchemy, Django, Drizzle, Hibernate, jOOQ, Exposed, …)\n\nLoad `libhonker_ext` on your ORM's connection and call the SQL functions inside the ORM's own transaction. The enqueue commits atomically with your business write.\n\n```python\n# SQLAlchemy\n@event.listens_for(engine, \"connect\")\ndef _load_honker(conn, _):\n    conn.enable_load_extension(True)\n    conn.load_extension(\"\u002Fpath\u002Fto\u002Flibhonker_ext\")\n    conn.execute(\"SELECT honker_bootstrap()\")\n\nwith Session(engine) as s, s.begin():\n    s.add(Order(user_id=42))\n    s.execute(text(\"SELECT honker_enqueue(:q, :p, NULL, NULL, 0, 3, NULL)\"),\n              {\"q\": \"emails\", \"p\": '{\"to\":\"alice@example.com\"}'})\n```\n\nFor JVM ORMs, use the ORM-owned JDBC connection inside the ORM\ntransaction. Configure sqlite-jdbc with extension loading enabled, load\nthe extension once per connection, then enqueue with SQL where the\nbusiness write happens.\n\n```java\n\u002F\u002F Hibernate \u002F JPA\nentityManager.unwrap(Session.class).doWork(conn -> {\n    try (PreparedStatement stmt = conn.prepareStatement(\n        \"SELECT honker_enqueue(?, ?, NULL, NULL, 0, 3, NULL)\"\n    )) {\n        stmt.setString(1, \"emails\");\n        stmt.setString(2, \"{\\\"to\\\":\\\"alice@example.com\\\"}\");\n        stmt.executeQuery().close();\n    }\n});\n```\n\n```kotlin\n\u002F\u002F jOOQ\nctx.transaction { cfg ->\n    val tx = DSL.using(cfg)\n    tx.insertInto(ORDERS).set(ORDERS.USER_ID, 42).execute()\n    tx.fetchValue(\n        \"SELECT honker_enqueue(?, ?, NULL, NULL, 0, 3, NULL)\",\n        \"emails\",\n        \"\"\"{\"to\":\"alice@example.com\"}\"\"\",\n    )\n}\n```\n\n```kotlin\n\u002F\u002F Exposed\ntransaction {\n    Orders.insert { it[userId] = 42 }\n    exec(\n        \"SELECT honker_enqueue('emails', '{\\\"to\\\":\\\"alice@example.com\\\"}', NULL, NULL, 0, 3, NULL)\"\n    )\n}\n```\n\nWorkers run as a separate process using `honker.open(\"app.db\")` or\n`Honker.open(\"app.db\")`. The commit watcher wakes on commits from any\nconnection to the file. See [Using with an ORM](https:\u002F\u002Fhonker.dev\u002Fguides\u002Form\u002F)\nfor Django, SQLModel, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto,\na typed-payload `TypedQueue[T]` wrapper pattern for SQLModel\u002FPydantic,\nand the Prisma caveat.\n\n## Performance\n\nHandles thousands of messages per second on a modern laptop, with cross-process wake latency bounded by the 1 ms poll cadence (~1–2 ms median on M-series). Run `bench\u002Fwake_latency_bench.py` and `bench\u002Freal_bench.py` to measure on your hardware.\n\n## Development\n\nLayout:\n\n```\nhonker-core\u002F              # Rust rlib shared across all bindings (in-tree, published on crates.io)\nhonker-extension\u002F         # SQLite loadable extension (cdylib, published on crates.io)\npackages\u002F\n  honker\u002F                 # Python package (PyO3 cdylib + Queue\u002FStream\u002FOutbox\u002FScheduler)\n  honker-node\u002F            # napi-rs Node.js binding\n  honker-rs\u002F              # ergonomic Rust wrapper\n  honker-go\u002F              # Go binding\n  honker-ruby\u002F            # Ruby binding\n  honker-bun\u002F             # Bun binding\n  honker-ex\u002F              # Elixir binding\n  honker-cpp\u002F             # C++ binding\n  honker-dotnet\u002F          # .NET \u002F C# binding\n  honker-jvm\u002F             # JVM \u002F Java-compatible binding\n  honker-kotlin\u002F          # Kotlin convenience wrapper\ntests\u002F                    # integration tests (cross-package)\nbench\u002F                    # benches\nsite\u002F                     # honker.dev (Astro)                [git submodule]\n```\n\nThe maintained language bindings live in-tree so one feature can update\ncore behavior, docs, tests, and binding surfaces together. The core\nengine is still Rust (`honker-core` + `honker-extension`); the language\npackages are first-class wrappers published independently to their\necosystems. `site\u002F` remains a separate docs-site submodule.\n\n```bash\nmake test                   # default: rust + python + node (fast, ~10s)\nmake test-python-slow       # soak + real-time cron tests (~2 min)\nmake test-jvm               # JVM binding tests\nmake test-kotlin            # JVM + Kotlin wrapper tests\nmake test-jvm-consumer       # clean Maven consumer + packaged native proof\nmake test-all               # everything including slow marks\n\nmake build                  # PyO3 maturin develop + loadable extension\n\npython bench\u002Fwake_latency_bench.py --samples 500\npython bench\u002Freal_bench.py --workers 4 --enqueuers 2 --seconds 15\npython bench\u002Fext_bench.py\n```\n\n### Coverage\n\nOne-time: `make install-coverage-deps` (installs `coverage.py` + `cargo-llvm-cov`).\n\n```bash\nmake coverage               # both HTML reports into coverage\u002F\nmake coverage-python        # honker python paths\nmake coverage-rust          # honker-core Rust unit tests\n```\n\nPython coverage reflects the full honker test suite (~92% of `packages\u002Fhonker\u002F`). Rust coverage reflects only `cargo test`. Many `honker_ops.rs` paths (`honker_enqueue`, `honker_claim_batch`, etc.) are only exercised via the Python test suite and won't show up in the Rust report. Combined cross-language coverage is non-trivial (LLVM profile-data merging across PyO3 boundaries) and is deferred.\n\n## License\n\nApache 2.0. See [LICENSE](LICENSE).\n","honker 是一个 SQLite 扩展及语言绑定项目，它为 SQLite 添加了类似 PostgreSQL 的 NOTIFY\u002FLISTEN 语义，并支持持久化队列、流、发布\u002F订阅和调度功能。该项目通过在数据库中每毫秒读取一次 `PRAGMA data_version` 来替代应用层轮询，从而实现推送式语义和跨进程通知，延迟低至个位数毫秒。此外，honker 提供了多种语言的绑定包，包括 Python、Node.js、Bun、Ruby、Go、Elixir、C++、.NET\u002FC# 和 JVM\u002FKotlin 等，使得开发者可以轻松地在 SQLite 中集成消息队列和任务调度功能，特别适用于那些希望减少外部依赖（如 Redis 或 Celery）并简化运维复杂度的应用场景。",2,"2026-06-11 02:39:08","CREATED_QUERY"]