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.
That residual window — a crash between producer commit and source offset
commit — is what L3 closes. Configure the paired Kafka input and output to
commit the source offset inside the producer transaction via
send_offsets_to_transaction:
input:
type: kafka
brokers:
- localhost:9092
topics:
- orders
consumer_group: orders-copy-group
start_from_latest: false
transactional_offsets: true
output:
type: kafka
brokers:
- localhost:9092
topic:
type: value
value: orders-copy
exactly_once: true
transactional_id: arkflow-orders-copy-0
offset_commit_group: orders-copy-group # must name the input's consumer_group
- Input
transactional_offsets: trueregisters the consumer group's metadata in a process-internal registry and changesack()to advance only the in-memory frontier — the input no longer commits offsets itself (itsundo()compensation rewinds only that frontier); broker group offsets advance only with output transactions. - Output
offset_commit_group(requiresexactly_once: true) derives the covered offsets from each batch's__meta_partition/__meta_offsetcolumns and folds them into the same transaction as the writes. Each partition's commit is clamped to the paired input's contiguous acknowledged frontier —min(batch max offset + 1, frontier next)per partition, never regressing below what this producer already sent — so offsets still settling elsewhere in the graph (content routing, fan-out, filtered branches) can never be transactionally skipped: any crash replays at least the unsettled part. The clamp makes the commit lag one batch behind the writes (a transaction may only commit what previous acknowledgements settled), so the bound is at-least-once with at most one batch of tail duplication: the last written batch before a shutdown may replay. Aread_committedconsumer of the source group still observes writes and offset advances atomically, and a rolled-back transaction leaves the group offsets unchanged.
L3 boundaries, enforced by explicit errors rather than silent degradation:
- Pairing is validated at startup (fail-closed) — a Kafka input declaring
transactional_offsets: truewhose consumer group is never named by any output'soffset_commit_groupwould silently never commit its group offsets (a crash then restarts the group purely perauto.offset.reset, andlatestskips everything produced meanwhile). Once all components of a stream/job are built, startup validation rejects such a configuration with a configuration error naming the group and both keys. - Process-internal pairing — the output resolves the named group through
the in-process registry; the input and output must run in the same ArkFlow
process. Distributed jobs that split input and output across nodes are not
covered (independent change). An
offset_commit_groupnaming a group with no livetransactional_offsetsinput fails the write. - Single input topic — batch metadata carries partitions without topics, so the registry routes by the input's topic list; a multi-topic subscription is rejected.
- Kafka → Kafka only — a batch without Kafka source metadata columns
contributes no offsets (the writes still proceed); non-Kafka sources cannot
pair with the transactional output. Such batches sit outside the L3
guarantee: with
transactional_offsets: truethe input no longer commits offsets itself, so if no later metadata-carrying transaction advances the group offset, a restart replays those records and duplicates the output — keep the pipeline free of metadata-dropping processors (batch-rebuilding transformations such asjson_to_arrow) between the Kafka input and the output, and absorb duplicates downstream otherwise.
A complete runnable L3 example is in
examples/eos-kafka-l3.yaml
(examples/eos-kafka.yaml shows the L2-only variant).
Requirements summary
exactly_once: truerequires a non-emptytransactional_id; validation fails with a clear error otherwise.offset_commit_grouprequiresexactly_once: true; the builder rejects the configuration otherwise. The named group must have a same-process Kafka input withtransactional_offsets: true: a configuration whose transactional input is never claimed by any output fails startup with a configuration error, and a write against a group with no live input fails the write.- 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.