Getting started
Install
Section titled “Install”pip install honkerRequires Python 3.11+. The wheel bundles the Rust cdylib so you don’t need a separate extension download. macOS (arm64, x86_64) and Linux (x86_64, aarch64) wheels are published.
npm install @russellthehippo/honker-nodeRequires Node 20+. The .node native binary is built per-platform by the release workflow. No separate extension download needed on supported targets (macOS arm64/x86_64, Linux x86_64/aarch64).
[dependencies]honker = "0.3"serde_json = "1"The honker crate pulls in honker-core and honker-extension. SQLite is vendored by default via rusqlite; opt out with default-features = false if you want to link against a system SQLite.
go get github.com/russellromney/honker-goRequires Go 1.25+. The Go binding calls into the extension via mattn/go-sqlite3, so you also need the compiled libhonker_ext.{dylib,so,dll} on disk. Grab it from the release page or build it yourself (see the SQLite extension tab).
# Gemfilegem "honker"bundle installRequires Ruby 3.0+. Uses the sqlite3 gem under the hood. As with Go, Ruby points at a prebuilt libhonker_ext.{dylib,so} for the extension.
bun add @russellthehippo/honker-bunRequires Bun 1.3+. Uses bun:sqlite and loads the extension from ./libhonker_ext.{dylib,so}. Prebuilt extension binaries are attached to each GitHub release.
def deps do [{:honker, "~> 0.1"}]endmix deps.getRequires Elixir 1.17+ and Erlang/OTP 26+. Uses exqlite for the SQLite FFI and loads the extension from a configured path.
dotnet add package HonkerTargets net8.0 and net10.0. The NuGet package bundles the native extension for linux-x64, osx-x64, osx-arm64, and win-x64. Override native loading with OpenOptions.ExtensionPath or HONKER_EXTENSION_PATH if you need an explicit path.
<dependency> <groupId>dev.honker</groupId> <artifactId>honker</artifactId> <version>0.1.0</version></dependency>Requires Java 17+. The JVM binding uses org.xerial:sqlite-jdbc and bundles the native Honker extension in the Maven artifact. Override native loading with OpenOptions.extensionPath(...) or HONKER_EXTENSION_PATH when you want to point at a local build.
<dependency> <groupId>dev.honker</groupId> <artifactId>honker-kotlin</artifactId> <version>0.1.0</version></dependency>Requires Kotlin 2.2+ and Java 17+. The Kotlin artifact layers coroutine Flow helpers, builder DSLs, and typed convenience extensions on top of the Java runtime.
git clone https://github.com/russellromney/honker-cpp.gitcd honker-cppzig buildRequires Zig 0.16+, a C++17 compiler, and SQLite 3.9+ with extension loading. On macOS use Homebrew’s sqlite3 (Apple’s system build omits load_extension). Link against the built libhonker_cpp.a static library and include include/honker.hpp.
Build from source. It’s one crate:
git clone https://github.com/russellromney/honkercd honkercargo build --release -p honker-extension# → target/release/libhonker_ext.{dylib,so}Works with any SQLite 3.9+ client: sqlite3 CLI, better-sqlite3, rusqlite, Go’s mattn/go-sqlite3, Ruby’s sqlite3 gem, Elixir’s exqlite, and so on. Prebuilt release artifacts for macOS and Linux are attached to each GitHub release.
Your first queue
Section titled “Your first queue”Open a database, create a queue, enqueue a job, and claim it, all in one script.
import honker
db = honker.open("app.db") # opens or creates the fileq = db.queue("greetings")
q.enqueue({"name": "world"})
job = q.claim_one("worker-1")print(f"hello, {job.payload['name']}!")job.ack()const { open } = require('@russellthehippo/honker-node');const db = open('app.db');const q = db.queue('greetings');
q.enqueue({ name: 'world' });
const job = q.claimOne('worker-1');console.log(`hello, ${job.payload.name}!`);job.ack();use honker::{Database, QueueOpts, EnqueueOpts};use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> { let db = Database::open("app.db")?; let q = db.queue("greetings", QueueOpts::default());
q.enqueue(&json!({"name": "world"}), EnqueueOpts::default())?;
if let Some(job) = q.claim_one("worker-1")? { let payload: serde_json::Value = serde_json::from_slice(&job.payload)?; println!("hello, {}!", payload["name"]); job.ack()?; }
Ok(())}package main
import ( "fmt" honker "github.com/russellromney/honker-go")
func main() { db, _ := honker.Open("app.db", "./libhonker_ext.dylib") defer db.Close()
q := db.Queue("greetings", honker.QueueOptions{}) q.Enqueue(map[string]any{"name": "world"}, honker.EnqueueOptions{})
job, _ := q.ClaimOne("worker-1") var p map[string]any job.UnmarshalPayload(&p) fmt.Printf("hello, %s!\n", p["name"]) job.Ack()}require "honker"
db = Honker::Database.new("app.db", extension_path: "./libhonker_ext.dylib")q = db.queue("greetings")
q.enqueue({name: "world"})
job = q.claim_one("worker-1")puts "hello, #{job.payload[:name]}!"job.ackimport { open } from "@russellthehippo/honker-bun";
const db = open("app.db", "./libhonker_ext.dylib");const q = db.queue("greetings");
q.enqueue({ name: "world" });
const job = q.claimOne("worker-1")!;console.log(`hello, ${(job.payload as { name: string }).name}!`);job.ack();{:ok, db} = Honker.open("app.db", extension_path: "./libhonker_ext.dylib")
{:ok, _id} = Honker.Queue.enqueue(db, "greetings", %{name: "world"})
{:ok, job} = Honker.Queue.claim_one(db, "greetings", "worker-1")IO.puts("hello, #{job.payload["name"]}!")Honker.Job.ack(db, job)using Honker;
using var db = Database.Open("app.db");var q = db.Queue("greetings");
q.Enqueue(new { name = "world" });
var job = q.ClaimOne("worker-1");Console.WriteLine($"hello, {job!.Payload.GetProperty("name").GetString()}!");job.Ack();import dev.honker.Honker;
try (var db = Honker.open("app.db")) { var q = db.queue("greetings");
q.enqueue("{\"name\":\"world\"}");
var job = q.claimOne("worker-1").orElseThrow(); System.out.println("claimed " + job.payloadJson()); job.ack();}import dev.honker.kotlin.honker
honker("app.db").use { db -> val q = db.queue("greetings")
q.enqueueJson("""{"name":"world"}""")
val job = q.claimOne("worker-1").orElseThrow() println("claimed ${job.payloadJson()}") job.ack()}#include "honker.hpp"
#include <iostream>
int main() { honker::Database db{"app.db", "./libhonker_ext.dylib"}; auto q = db.queue("greetings");
q.enqueue(R"({"name":"world"})");
if (auto job = q.claim_one("worker-1")) { auto payload = nlohmann::json::parse(job->payload()); std::cout << "hello, " << payload["name"].get<std::string>() << "!\n"; job->ack(); }}.load ./libhonker_extSELECT honker_bootstrap();
SELECT honker_enqueue('greetings', '{"name":"world"}', NULL, NULL, 0, 3, NULL);
SELECT honker_claim_batch('greetings', 'worker-1', 1, 300);-- Then ack the claimed job id from the JSON result above.SELECT honker_ack(1, 'worker-1');A proper worker
Section titled “A proper worker”The worker loop wakes within 1-2 ms when a new job lands, via a shared 1 ms PRAGMA data_version poll on the database. No application-level polling. (Two experimental alternatives — kernel filesystem events and mmap’d WAL-index reads — are available behind opt-in flags; see Watcher backends.)
import asyncioimport honker
async def main(): db = honker.open("app.db") q = db.queue("emails")
async for job in q.claim("worker-1"): try: await send_email(job.payload) job.ack() except Exception as e: # Scheduled retry with backoff. After max_attempts (default 3), # the job moves to _honker_dead with last_error set. job.retry(delay_s=60, error=str(e))
asyncio.run(main())loop { if let Some(job) = q.claim_one("worker-1")? { match send_email(&job.payload) { Ok(_) => { job.ack()?; } Err(e) => { job.retry(60, &e.to_string())?; } } } else { std::thread::sleep(std::time::Duration::from_millis(100)); }}for { job, _ := q.ClaimOne("worker-1") if job == nil { time.Sleep(100 * time.Millisecond) continue } var p map[string]any job.UnmarshalPayload(&p) if err := sendEmail(p); err != nil { job.Retry(60, err.Error()) } else { job.Ack() }}loop do job = q.claim_one("worker-1") if job.nil? sleep 0.1 next end begin send_email(job.payload) job.ack rescue => e job.retry(delay_s: 60, error: e.message) endendwhile (running) { const job = q.claimOne("worker-1"); if (!job) { await Bun.sleep(100); continue; } try { await sendEmail(job.payload as { to: string }); job.ack(); } catch (e) { job.retry(60, String(e)); }}loop = fn loop -> case Honker.Queue.claim_one(db, "emails", "worker-1") do {:ok, nil} -> :timer.sleep(100); loop.(loop) {:ok, job} -> try do send_email(job.payload) Honker.Job.ack(db, job) rescue e -> Honker.Job.retry(db, job, 60, Exception.message(e)) end loop.(loop) endendloop.(loop)using Honker;
using var db = Database.Open("app.db");var q = db.Queue("emails");
await foreach (var job in q.ClaimAsync("worker-1")){ try { await SendEmail(job.Payload); job.Ack(); } catch (Exception e) { job.Retry(60, e.Message); }}import dev.honker.Honker;import dev.honker.RetryableException;
import java.time.Duration;
try (var db = Honker.open("app.db")) { var q = db.queue("emails");
try (var worker = q.worker("worker-1", job -> { try { sendEmail(job.payloadJson()); } catch (Exception e) { throw new RetryableException(e.getMessage(), Duration.ofSeconds(60)); } })) { Thread.currentThread().join(); }}import dev.honker.kotlin.asFlowimport dev.honker.kotlin.honkerimport kotlinx.coroutines.flow.collectimport kotlinx.coroutines.runBlockingimport java.time.Duration
runBlocking { honker("app.db").use { db -> val q = db.queue("emails")
q.asFlow("worker-1").collect { job -> try { sendEmail(job.payloadJson()) job.ack() } catch (e: Exception) { job.retry(Duration.ofSeconds(60), e.message) } } }}#include "honker.hpp"
#include <chrono>#include <thread>
int main() { honker::Database db{"app.db", "./libhonker_ext.dylib"}; auto q = db.queue("emails");
for (;;) { if (auto job = q.claim_one("worker-1")) { try { send_email(job->payload()); job->ack(); } catch (const std::exception& e) { job->retry(60, e.what()); } } else { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } }}.load ./libhonker_extSELECT honker_bootstrap();
-- Claim one ready job.SELECT honker_claim_batch('emails', 'worker-1', 1, 300);
-- On success:SELECT honker_ack(42, 'worker-1');
-- Or on failure, schedule a retry 60 seconds out:SELECT honker_retry(42, 'worker-1', 60, 'smtp timeout');Atomic enqueue with business writes
Section titled “Atomic enqueue with business writes”The killer feature of a SQLite-native queue: the enqueue lands in the same transaction as your business write. No dual-write problem, no outbox pattern to bolt on (though Honker has one for external systems).
with db.transaction() as tx: tx.execute( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", [42, 9900, "alice"], ) q.enqueue({"order_id": 42, "email_type": "confirmation"}, tx=tx) # One COMMIT. Either both land or neither does.const tx = db.transaction();tx.execute( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", [42, 9900, 'alice'],);q.enqueueTx(tx, { order_id: 42, email_type: 'confirmation' });tx.commit();let tx = db.transaction()?;tx.execute( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", rusqlite::params![42, 9900, "alice"],)?;q.enqueue_tx( &tx, &json!({"order_id": 42, "email_type": "confirmation"}), EnqueueOpts::default(),)?;tx.commit()?;tx, _ := db.Begin()tx.Exec( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", 42, 9900, "alice",)q.EnqueueTx(tx, map[string]any{ "order_id": 42, "email_type": "confirmation",}, honker.EnqueueOptions{})tx.Commit()db.transaction do |tx| tx.execute( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", [42, 9900, "alice"], ) q.enqueue({order_id: 42, email_type: "confirmation"}, tx: tx)endconst tx = db.transaction();tx.execute( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", [42, 9900, "alice"],);q.enqueue({ order_id: 42, email_type: "confirmation" }, { tx });tx.commit();Honker.Transaction.transaction(db, fn tx -> Honker.Transaction.execute(tx, "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", [42, 9900, "alice"]) Honker.Queue.enqueue_tx(tx, "emails", %{order_id: 42, email_type: "confirmation"}, [], [])end)using (var tx = db.BeginTransaction()){ tx.Execute( "INSERT INTO orders (id, total, customer_id) VALUES (@p0, @p1, @p2)", 42, 9900, "alice"); q.Enqueue(new { order_id = 42, email_type = "confirmation" }, transaction: tx); tx.Commit();}db.transactionVoid(tx -> { tx.execute( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", java.util.List.of(42, 9900, "alice") ); q.enqueue(tx, "{\"order_id\":42,\"email_type\":\"confirmation\"}");});db.transactionVoid { tx -> tx.execute( "INSERT INTO orders (id, total, customer_id) VALUES (?, ?, ?)", listOf(42, 9900, "alice"), ) q.enqueue(tx, """{"order_id":42,"email_type":"confirmation"}""")}#include "honker.hpp"
int main() { honker::Database db{"app.db", "./libhonker_ext.dylib"}; auto q = db.queue("emails");
honker::Transaction tx{db.raw()}; sqlite3_exec( tx.raw(), "INSERT INTO orders (id, total, customer_id) VALUES (42, 9900, 'alice')", nullptr, nullptr, nullptr ); q.enqueue_tx(tx, R"({"order_id":42,"email_type":"confirmation"})"); tx.commit();}.load ./libhonker_extSELECT honker_bootstrap();
BEGIN;INSERT INTO orders (id, total, customer_id) VALUES (42, 9900, 'alice');SELECT honker_enqueue( 'emails', '{"order_id":42,"email_type":"confirmation"}', NULL, NULL, 0, 3, NULL);COMMIT;What’s next
Section titled “What’s next”- Queues for priority, delays, retries, dead-letter
- Tasks for Huey-style
@taskdecorators (Python) - Streams for durable pub/sub with per-consumer offsets
- Pub/Sub for
pg_notify-style ephemeral signals - Scheduler for cron and
@everytime-trigger tasks - Using with an ORM for the integration philosophy and per-language recipes for every Honker binding
- Extension reference for the full
honker_*SQL function family