Skip to content

Streams

Streams are the durable cousin of pub/sub. Every event is a row in _honker_stream, consumers track offsets in _honker_stream_consumers, and replay-on-reconnect is built in. Use streams when you need catch-up after a disconnect. At-least-once processing depends on when the binding saves its offset; see Processing guarantees below. Use pub/sub when you only care about live signals and don’t need history.

The on-disk stream event format is shared across bindings: a Go publisher and a Python consumer can run against the same .db file.

import honker
db = honker.open("app.db")
s = db.stream("orders")
s.publish({"id": 42, "amount": 9900})
# Atomic with a business write
with db.transaction() as tx:
tx.execute("INSERT INTO orders VALUES (?, ?)", [42, 9900])
s.publish({"id": 42, "amount": 9900}, tx=tx)

A named consumer resumes where it left off. Most bindings provide a subscription that checkpoints on a cadence; Ruby and Elixir expose the same behavior as an explicit read/process/save loop.

async for event in s.subscribe(consumer="email-worker"):
await send_order_email(event.payload)
# Offset auto-saves on a cadence (default: every 1000 events or 1 second).

For exactly-once-within-a-business-transaction semantics, save the offset in the same transaction as the downstream write. If the transaction rolls back, the offset doesn’t advance and the event replays on the next read.

# Python
async for event in s.subscribe(
consumer="invariant-writer",
save_every_n=0, # disable auto-save
save_every_s=0,
):
with db.transaction() as tx:
apply_to_read_model(event.payload, tx=tx)
# Stream.save_offset has no tx parameter; use the extension
# function directly so the offset lands in the same transaction.
tx.query(
"SELECT honker_stream_save_offset(?, ?, ?)",
["invariant-writer", s.name, event.offset],
)

(Calling s.save_offset("invariant-writer", event.offset) without tx= also works, but it commits outside your transaction and the atomicity is lost.)

BindingTransaction-aware offset save
Node, Rust, Go, Ruby, Bun, Elixir, .NET, C++Typed transaction-aware method
Python, Java, KotlinRun honker_stream_save_offset through the open transaction

The extension-level SQL participates in whichever transaction owns that connection.

BindingSaved consumer offsetExplicit starting offsetConsumer optional
Pythonyesfrom_offsetyes
.NETyesfromOffsetyes
Java, KotlinyesSubscribeOptions.fromOffsetyes
Node, Rust, Go, Bun, C++yesnono
Ruby, Elixirmanual read/save APIspass offset to read APIn/a

For Python, .NET, Java, and Kotlin, an explicit starting offset takes precedence over a saved consumer offset. Omitting both starts after offset 0, which replays the whole topic. None of these APIs provides a special live-tail-from-now mode; query the current maximum offset first if that is the behavior you need.

Durable storage does not by itself mean that every high-level subscriber waits for your application work before saving its offset.

  • Python and .NET save a previous event only when the consumer asks for the next event. Java advances after its callback returns. These shapes replay an event when its handler fails before that point.
  • Node, Bun, Rust, Go, and C++ may persist an offset on a cadence or when the subscription closes without knowing whether downstream application work committed. Kotlin Flow uses the Java callback to hand an event into a coroutine channel, so the Java runtime can consider delivery complete before the collector finishes processing it.
  • Ruby and Elixir expose manual read/save loops, so the application chooses when to advance.

For a strict at-least-once processing boundary, the Python/.NET iterator shape and Java callback save only after successful processing as described above. In the other bindings, use the manual read/save APIs and advance only after successful work; disabling the periodic cadence alone is not sufficient when a subscription final-flushes its current offset. For exactly-once-within-one SQLite transaction, save the offset alongside the downstream write as shown above.

  • _honker_stream is the append-only event log. A (topic, offset) index keeps reads O(log n) even with millions of historical events.
  • _honker_stream_consumers stores (name, topic) -> offset. One row per consumer per topic.