Skip to content

Kafka pubsub: transactions and exactly-once consume-transform-produce - #4508

Draft
javier-aliaga wants to merge 3 commits into
dapr:mainfrom
javier-aliaga:feat/kafka-exactly-once-semantics
Draft

Kafka pubsub: transactions and exactly-once consume-transform-produce#4508
javier-aliaga wants to merge 3 commits into
dapr:mainfrom
javier-aliaga:feat/kafka-exactly-once-semantics

Conversation

@javier-aliaga

@javier-aliaga javier-aliaga commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Description

Adds Kafka transactions support to the Kafka pubsub component (in the shared common/component/kafka code), enabling exactly-once processing between Kafka topics through three independently configurable capabilities:

  • Transactional publishing (producerTransactionsEnabled): every publish — single or bulk — is wrapped in a Kafka transaction on an idempotent producer. Bulk publishes become atomic (all entries commit or none do), and aborted publishes are never visible to consumers reading with read_committed.
  • Committed-only consumption (consumerIsolationLevel: "read_committed"): subscriptions never receive records from open or aborted transactions.
  • Exactly-once consume-transform-produce (consumerTransactionsEnabled): every delivery is processed inside a Kafka transaction. The component injects a __txnToken metadata entry (reaching the app as a header via the runtime's existing metadata pass-through — no dapr/dapr changes needed). Publishes from the handler that echo the token join the delivery's open transaction, and the consumer offset commits atomically with them (KIP-447, AddMessageToTxnWithGroupMetadata). Handler failure aborts the whole transaction and the delivery is redelivered.

Design notes

  • Producer-per-claim with stable transactional.id = <prefix>-<consumerGroup>-<topic>-<partition>: zombie fencing across rebalances and crashes, and no lock held across the app handler (deliveries are serial within a claim).
  • Fail-loud token contract: a publish carrying a stale or unknown token errors instead of silently publishing outside the transaction. The token is stripped from produced headers. A publish without a token is an ordinary publish (never an error).
  • Consumer transactions imply read_committed: leaving consumerIsolationLevel unset selects it automatically; an explicit read_uncommitted fails validation. Autocommit is disabled in transactional consume mode, and record-less deliveries commit their offset synchronously (sarama silently skips offset commits for record-less transactions).
  • Bulk subscribe is all-or-nothing: any entry failure aborts and redelivers the whole batch.
  • Bindings: producerTransactionsEnabled is supported for output; consumerTransactionsEnabled is rejected at init — the bindings model (separate input and output instances) cannot echo the token through the same component instance. Consume-transform-produce is pubsub-only.
  • Two flags, not one: producer and consumer transactions are configured separately, mirroring Kafka's own config split (transactional.id/idempotence are producer configs, isolation.level is a consumer config). A merged flag would force per-delivery transaction overhead onto consume-and-publish apps that don't need correlation, and serialized transactional publishing onto apps that only need transactional consume.
  • Shutdown: Close() preserves the prompt consumer-group teardown contract from fix: Fix kafka consumer shutdown #3907 — the group closes immediately and never waits on an in-flight transactional publish (the producer is abandoned to process exit in that case).

Scope note: the guarantee covers Kafka records and consumer offsets. The app handler itself still runs at-least-once — non-Kafka side effects are outside the transaction.

Issue reference

Closes #4301

Checklist

Please make sure you've completed the relevant tasks for this PR, out of the following list:

Adds opt-in Kafka transactions support to the shared Kafka component:

- producerTransactionsEnabled wraps every Publish/BulkPublish in a Kafka
  transaction on an idempotent producer. An aborted publish is never
  visible to read_committed consumers, and bulk publishes become atomic
  (all entries commit or none do, so per-entry error mapping is bypassed
  in favor of whole-batch failure).
- transactionalIdPrefix controls the producer transactional.id; a random
  per-instance suffix is always appended so scaled replicas never fence
  each other. Abandoned transactions are aborted by the broker on
  transaction timeout.
- consumerIsolationLevel (read_uncommitted/read_committed) is exposed as
  an independent consumer option.

Transaction cleanup follows sarama's state machine: abortable states are
aborted so the producer stays usable; a fatal transaction state closes
the producer and the next publish lazily recreates it with the same
transactional.id (epoch bump aborts the stale transaction broker-side).
Client creation/invalidation is now guarded by a lock, and the AWS IAM
client path shares the producer config application with the static path.

Init validates the sarama preconditions: producerRequiredAcks=all,
producerRetryMax >= 1 and Kafka >= 0.11.

Part of dapr#4301 (transactional consume-transform-produce follows in a
separate commit).

Signed-off-by: Javier Aliaga <javier@diagrid.io>
…shes

Adds consumerTransactionsEnabled: every delivery is processed inside a
Kafka transaction, giving consume-transform-produce pipelines the Kafka
Streams guarantee boundary through the Dapr sidecar.

Mechanism:
- Each consumer-group claim (topic-partition) lazily creates its own
  transactional producer. Deliveries within a claim are serial, so the
  producer has at most one open transaction by construction and no lock
  is ever held across the app handler (the deadlock that sank the
  previous attempt in dapr#4300).
- The claim producer's transactional.id is stable
  (<prefix>-<group>-<topic>-<partition>): after a rebalance the new
  owner's producer epoch-fences a zombie's, and a producer recreated
  after a fatal error aborts its own stale transaction the same way.
- A per-delivery transaction token (__txnToken) is injected into the
  event metadata, which the runtime already forwards to the app as HTTP
  headers / gRPC metadata. Publishes carrying metadata.__txnToken are
  routed into the correlated open transaction; the token itself is
  never forwarded as a record header. A token that does not match an
  open transaction fails loudly rather than silently downgrading to a
  non-transactional publish.
- On handler success the consumer offset joins the transaction with the
  group member metadata (KIP-447) before commit; deliberately no
  MarkMessage afterwards, so a stale non-transactional autocommit can
  never regress a transactional commit. On handler error the
  transaction aborts and the existing retry path takes over with a
  fresh transaction per attempt.
- Bulk subscribe is all-or-nothing: one transaction per batch, any
  entry failure aborts and redelivers the whole batch (partial success
  cannot coexist with atomicity).

Delivery to the app itself remains at-least-once and non-Kafka side
effects stay outside the transaction, as with Kafka Streams.

- Record-less transactions: sarama silently skips offset commits for
  transactions that produced no records, so a delivery whose handler
  published nothing ends its (empty) transaction and commits the offset
  via a synchronous MarkMessage+Commit instead — with no records there
  is nothing for the offset to be atomic with. Autocommit is disabled
  in transactional mode so a stale background commit can never regress
  a transactional offset commit.

Part of dapr#4301 (certification tests follow in a separate commit).

Signed-off-by: Javier Aliaga <javier@diagrid.io>
Four deterministic scenarios against the real 3-broker cluster,
exercising the component API directly (they certify the component's
transactional semantics; runtime metadata pass-through is dapr/dapr's
contract):

1. Transactional bulk publish is atomic: a batch with an oversized
   entry aborts as a whole and nothing becomes visible to
   read_committed consumers; a valid batch is fully visible.
2. consumerIsolationLevel: a record from an aborted transaction is
   delivered by a read_uncommitted component and never by a
   read_committed one.
3. Consume-transform-produce: the handler publishes an output carrying
   the delivery's __txnToken and fails twice before succeeding —
   exactly one output is visible to read_committed consumers, the
   aborted attempts' outputs stay hidden, and the input offset commits
   atomically with the output.
4. Bulk subscribe all-or-nothing: a per-entry failure aborts and
   redelivers the whole batch; offsets commit only after full success
   (record-less synchronous path).

Closes dapr#4301

Signed-off-by: Javier Aliaga <javier@diagrid.io>
@javier-aliaga
javier-aliaga force-pushed the feat/kafka-exactly-once-semantics branch from 97610f8 to 1c254b4 Compare August 4, 2026 10:31
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.09179% with 70 lines in your changes missing coverage. Please review.
✅ Project coverage is 32.92%. Comparing base (cc03682) to head (1c254b4).
⚠️ Report is 29 commits behind head on main.

Files with missing lines Patch % Lines
common/component/kafka/clients.go 43.39% 29 Missing and 1 partial ⚠️
common/component/kafka/producer.go 83.92% 14 Missing and 4 partials ⚠️
common/component/kafka/consumer.go 87.06% 8 Missing and 7 partials ⚠️
common/component/kafka/subscriber.go 70.00% 3 Missing ⚠️
common/component/kafka/aws.go 0.00% 2 Missing ⚠️
common/component/kafka/transactions.go 96.66% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4508      +/-   ##
==========================================
+ Coverage   31.94%   32.92%   +0.98%     
==========================================
  Files         353      354       +1     
  Lines       47723    38277    -9446     
==========================================
- Hits        15243    12602    -2641     
+ Misses      31277    24453    -6824     
- Partials     1203     1222      +19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support kafka EOS (exactly only once semantics)

1 participant