Exactly-once delivery
ArkFlow delivers at-least-once by default. On recovery, in-flight messages are replayed and MAY be redelivered to outputs. For duplicate-intolerant sinks, ArkFlow can deliver exactly-once (more precisely, effectively-once) opt-in, via a transactional Kafka output. This page describes what that guarantees, how to configure it, and where its boundary lies.
How it works
A Kafka output configured for exactly-once uses a transactional producer:
init_transactionsis called once atconnect();- before each acknowledged batch, the output begins a transaction, sends every message in the batch, then commits;
- the WAL cursor advances (and the source is committed) only after the transaction commits successfully.
The unit of work is one ack range = one write_batch call = one Kafka
transaction. If a buffer (memory, a tumbling/sliding/session window, or a
join) aggregates several input messages into one output batch, that whole batch
is one atomic transaction unit. Downstream consumers reading with
isolation.level=read_committed observe each batch atomically — all messages
or none.
On any failure (a commit_transaction error that requires abort, or a crash),
the batch is not acknowledged, the WAL cursor does not advance, and the
range is replayed on recovery — which begins a fresh transaction.
Configuration
Enable exactly-once on the Kafka output with two keys:
output:
type: kafka
brokers:
- localhost:9092
topic:
type: value
value: orders-copy
exactly_once: true
transactional_id: arkflow-orders-copy-0 # stable across restarts; unique per producer
exactly_once: trueturns on transactional production.transactional_idis required whenexactly_onceis enabled. It MUST be stable across restarts (you own this) and unique per stream producer. On restart the broker uses the same id to fence the prior producer epoch and abort its in-flight (zombie) transaction, so zombie writes are never visible toread_committedconsumers.
Exactly-once is layered on top of at-least-once ingestion, so the input side needs durability enabled so that a crash between read and output does not lose data:
durability:
enabled: true
path: "./data/wal-eos"
sync: group_commit
A complete runnable example is in
examples/eos-kafka.yaml
(Kafka → Kafka, consume-transform-produce).
The honest boundary (read this)
The Kafka transactional output eliminates two specific sources of duplicates:
- in-transaction partial writes — the transaction commits atomically, so a
read_committedconsumer never sees a partial batch; - zombie-producer duplicates — the stable
transactional_idfences stale producer epochs across restarts.
It does not guarantee the absence of duplicates when a crash occurs after
the producer transaction is committed and before the source offset is
committed. The source offset is committed asynchronously (bounded by the
source's auto-commit interval, e.g. Kafka's default 5s). If the process crashes
in that window, on recovery the source redelivers the range, a new producer
writes it again, and a read_committed downstream consumer observes duplicate
rows.
Such residual duplicates MUST be absorbed downstream — by a dedup key, business-level idempotency, or an idempotent sink (e.g. UPSERT). Design your downstream consumers accordingly.
True end-to-end exactly-once that closes this window — committing the source
offset inside the producer transaction via send_offsets_to_transaction
(Kafka → Kafka only) — is future work (L3) and not provided today.
Requirements summary
exactly_once: truerequires a non-emptytransactional_id; validation fails with a clear error otherwise.- The WAL's object-store
node_idand the Kafkatransactional_idare independent configuration values — neither is derived from the other. - Outputs other than the transactional Kafka output keep today's default at-least-once behavior. Idempotent adapters for other sinks (SQL UPSERT, etc.) can be added in later changes.