Queue (Kafka interop)
Consume and produce against an existing Kafka — Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for cdc-out.
@voltro/plugin-queue is the door to queues somebody else owns: a Voltro
backend consuming and producing against an adopter's existing Kafka. The
boundary with the built-ins, in one line each: the outbox
is your own durable side-effects, a workflow is
your own orchestration — this plugin is interop with foreign infrastructure.
Kafka first; the provider contract is cut so SQS/RabbitMQ can be later
implementations.
// app.config.ts
import { queuePlugin } from '@voltro/plugin-queue'
export default {
// …
plugins: [
queuePlugin({ brokers: ['kafka-1:9092', 'kafka-2:9092'] }),
],
}Consuming: *.consumer.ts
// src/consumers/orders.consumer.ts
import { Schema } from 'effect'
import { defineQueueConsumer } from '@voltro/plugin-queue'
export const orders = defineQueueConsumer({
topic: 'orders',
schema: Schema.Struct({ orderId: Schema.String, total: Schema.Number }),
handler: async (order, ctx) => {
// MUST be idempotent — delivery is at-least-once. A unique-column
// upsert is the standard shape:
await ctx.store.upsert('orders_mirror',
{ orderId: order.orderId, total: order.total },
{ conflictColumns: ['orderId'] })
},
})Both boot paths discover *.consumer.ts; the plugin starts every registered
consumer at activation and stops them at shutdown. The semantics, precisely:
- Ordering: serial per partition. Parallelism exists only ACROSS partitions — concurrency inside one would destroy ordering and commit semantics both. Retry backoff deliberately BLOCKS the partition.
- Commit after the handler, per message. A process killed mid-batch redelivers exactly the unhandled tail — never the whole batch, never a skipped message.
- Decode failures dead-letter IMMEDIATELY (to
<topic>.dlq, withx-voltro-dlq-*reason headers) — a deterministic failure retried forever is an infinite loop with extra steps, and a poison message must release its partition. - Handler failures retry with backoff, then dead-letter after
maxAttempts(default 3). - A rebalance is not a failure. A partition revoked mid-batch or mid-retry stops processing without a retry-counter increment or a DLQ publish — the new owner redelivers.
- Handlers are NOT wrapped in a transaction (the same documented
boundary as HTTP route handlers). A handler needing atomic multi-writes
opens
ctx.store.transactionalitself — and stays idempotent either way. - Replica coordination is Kafka's own. Every replica joins the same consumer group and the broker assigns partitions — no advisory lock, unlike schedules, which coordinate through the claim table because no broker exists to do it for them.
Producing
Two paths, one rule: transactional-with-a-write goes through the outbox.
// Inside a mutation — commits or rolls back WITH the domain write:
await ctx.outbox.enqueue('queue.produce', {
topic: 'orders',
messages: [{ key: order.id, value: JSON.stringify(order) }],
})// src/queue.outbox.ts — the bridge (once per app):
import { queueOutboxHandler } from '@voltro/plugin-queue'
export default queueOutboxHandler()The outbox runner delivers after commit — batched (messages is an array →
one transport round-trip), at-least-once, retried, dead-lettered. One
durability path: the existing outbox, not a second one. For fire-and-forget
producing without a surrounding write, yield* QueueService in a handler and
call produce(topic, messages) directly.
Topic creation is EXPLICIT (provider.ensureTopics([...])) — your Kafka is
foreign infrastructure, and whether a client may create topics is your
policy. A consumer started against a topic that does not exist yet warns and
retries in the background (it connects once the topic appears), never
aborting the boot.
cdc-out to Kafka
kafkaSink plugs table-change mirroring (plugin-cdc-out)
into the SAME provider: message key = the row id (one row's changes stay
ordered in one partition), value = the change record, and the
x-voltro-delivery-key header carries cdc-out's at-least-once dedupe handle.
import { cdcOutPlugin } from '@voltro/plugin-cdc-out'
import { kafkaSink } from '@voltro/plugin-queue'
cdcOutPlugin({ sinks: [{ table: 'orders', sink: kafkaSink({ topic: 'orders.cdc' }) }] })Observability
Metrics. Every counter is exported to the metrics
registry, so it is scrapeable via
@voltro/plugin-prometheus and readable at
GET /_voltro/inspect/metrics:
| Series | Type | What it answers |
|---|---|---|
voltro_queue_consumed_total{topic,outcome} |
counter | Throughput, and — with outcome = ok | dead-lettered — the dead-letter rate as a plain division. |
voltro_queue_retries_total{topic} |
counter | In-process handler retries. A retry BLOCKS its partition, so a rising rate is head-of-line latency, not just noise. |
voltro_queue_produced_total{topic} |
counter | Messages produced through the outbox bridge. |
voltro_queue_lag_messages{topic,partition} |
gauge | Backlog behind the message just picked up — "are the consumers keeping up", which no counter can answer. |
outcome has two values on purpose: ok + dead-lettered is every message
the runner finished with. A message abandoned by a rebalance is in neither —
it was not consumed here, its new owner redelivers it and counts it there, and
counting it twice would make the dead-letter ratio wrong in the direction of
looking healthy.
Lag is a sample at pickup and costs nothing to collect (highWatermark
rides along in the fetch response — no admin round trip per message). Read it
together with the consume rate: nothing arrives to move the gauge on an idle or
revoked partition, so it holds its last value, and a frozen high lag and a
frozen low lag look identical on their own.
The per-topic counters plus the last error also stay on
GET /_voltro/inspect/plugins/queue/consumers and in the dashboards' Queue
panel — that view is this replica, right now, and carries an error string,
which is not a time series. Both are moved by one recorder each, so they cannot
drift.
Tracing. Each consumed message is processed inside a queue.consume span
that ADOPTS the producer's traceparent header as its parent, so a Kafka hop no
longer ends the trace. The span covers the whole message — decode, every retry,
and the dead-letter publish — and carries messaging.system,
messaging.destination.name, messaging.consumer.group.name,
messaging.destination.partition.id, messaging.message.offset and
voltro.queue.outcome (ok | dead-lettered | stale). A missing or
malformed traceparent starts a fresh root span rather than failing the
message. ctx.traceparent is still handed to your handler for hops the
framework does not make for you.
Consumer spans are emitted from detached work — a broker callback, outside the
server's Effect scope — and reach the server's tracer because the server
publishes its tracer instance for exactly that case. There is still only ONE
tracer: a second provider would mean a second exporter nothing flushes at
shutdown. The same applies to cdcOut.deliver and
plugin.<name>.schedule-fire, which run detached for the same reason.