Skip to content

CAMEL-24781: Support Kafka exactly-once processing - #28730

Open
ramu11 wants to merge 8 commits into
apache:mainfrom
ramu11:CAMEL-24781
Open

ramu11 wants to merge 8 commits into
apache:mainfrom
ramu11:CAMEL-24781

Conversation

@ramu11

@ramu11 ramu11 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Description

This PR adds opt-in read-process-write exactly-once processing support to the Apache Camel Kafka component.

When enabled, Kafka output records and the corresponding source consumer offsets are committed atomically in a single Kafka transaction. This prevents output records from becoming visible when processing fails before the source offsets are committed, and allows failed input records to be replayed safely.

Implementation

  • Adds the exactlyOnce option to the Kafka component configuration and exposes it through generated component and endpoint DSLs.
  • Captures source consumer group metadata and offsets while polling, preserving partition offset information, including leader epochs.
  • Uses the transactional producer's sendOffsetsToTransaction() API to include source offsets in the output transaction.
  • Commits the transaction only after successful route processing; processing failures abort the transaction.
  • Supports batch processing by aggregating offsets per partition, including when the batch body is transformed.
  • Adds configuration validation to ensure that exactly-once mode is used with compatible consumer and producer settings.
  • Avoids independent consumer offset commits while exactly-once processing is active.
  • Handles consumer group rebalance and stale consumer generation scenarios.

Configuration requirements and limitations

For the Kafka source endpoint, configure:

  • exactlyOnce=true
  • allowManualCommit=true
  • autoCommitEnable=false
  • breakOnFirstError=true
  • An explicit groupId

For the destination Kafka producer, configure exactlyOnce=true and enable transactions using transacted=true or a transactionalId.

Consumers reading the transactional output should use isolation.level=read_committed.

Kafka brokers version 2.5 or later are required. The current implementation supports one Kafka source and one exactly-once transactional producer per exchange/unit of work. Using an offsetRepository or a custom KafkaManualCommitFactory is not supported. Split/multicast child exchanges are not supported in exactly-once mode.

Tests

Adds unit and integration coverage for:

  • Exactly-once configuration validation.
  • Source offset and consumer group metadata handling.
  • Transaction commit and abort behavior.
  • End-to-end read-process-write exactly-once processing.
  • Batch processing across partitions.
  • Consumer rebalance and stale generation handling.

Existing Kafka routes remain unchanged unless exactlyOnce is explicitly enabled.

@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@oscerd oscerd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the exactly-once protocol, the failure handling and the configuration guarding closely — this is a serious, well-engineered feature, and the parts that are easy to get subtly wrong are handled correctly.

The transaction protocol is right. KafkaTransactionSynchronization sends the consumed offsets into the producer transaction with sendOffsetsToTransaction(offsets, ConsumerGroupMetadata) and only then commitTransaction() — the correct read-process-write ordering, and the test pins it with order.verify(...). On a failure of sendOffsetsToTransaction the test verifies abortTransaction() runs and commitTransaction() never does, so a mid-transaction failure aborts rather than partially commits. Tying this to a UoW Synchronization (commit on success, abort on failure), with care that a beginTransaction() failure doesn't leave a dangling synchronization, is the right structure, and using the ConsumerGroupMetadata overload (with the documented broker 2.5+ requirement) gets the zombie-fencing variant.

The prerequisite validation is what makes this safe in practice. validateExactlyOnceConfiguration() fails fast, before the consumer starts, unless every EOS precondition holds, and crucially it checks the effective consumer properties rather than just the typed option — so additionalProperties[enable.auto.commit]=true can't silently re-enable Kafka's background commits and break exactly-once behind the user's back. It also requires allowManualCommit=true, breakOnFirstError=true, an explicit groupId, and rejects a conflicting offsetRepository (offsets live in the transaction). That's the full set of foot-guns closed with clear messages, which is exactly what you want for a feature where a quiet misconfiguration silently degrades to at-least-once.

Config/compat. exactlyOnce and transacted default false and isolationLevel defaults read_uncommitted, so existing Kafka routes are unchanged — this is purely opt-in, which is why no upgrade-guide entry is needed. The new options are documented and the catalog/component/endpoint-DSL metadata is regenerated to match.

Scope note, not a blocker: this is a large change (~3k lines) and I focused on the EOS correctness core — the transaction lifecycle, the failure/abort path, and the validation. I did not line-by-line the commit-manager wiring (CommitManagers/AbstractCommitManager/NoopCommitManager), the per-partition highest-offset retention for batches, or the fencing/maxBlockMs edge behaviour; those are where a committer with Kafka depth and the EOS integration tests (CI was still running) should give it a final pass. But the protocol, the abort-on-failure guarantee and the configuration guardrails all check out.

Nice work.

Claude Code on behalf of oscerd

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation is solid. Good use of the three-flag pattern (transactionStarted, unitOfWorkMarked, synchronizationRegistered) for atomic transaction initialization with correct exception suppression in all cleanup paths. The KafkaExactlyOnceMetadata immutability design and generation-consistency validation are correct. Integration tests cover abort-and-replay, batch offset survival across body transformation, and rebalance fencing — the right failure modes.

One minor duplication to clean up (see inline).

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

✅ Generated files are up to date

An earlier CI run reported uncommitted generated changes; the latest run no longer does.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review — the dead-code finding from the previous SHA is still present.

The new commits correctly add the AtomicReference<String> activeExactlyOnceExchange concurrency guard and the matching releaseExactlyOnceTransaction cleanup. The three-flag commit protocol and exception-suppression paths look right.

One item not addressed (see inline).

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-kafka-common
  • components/camel-kafka
  • dsl/camel-componentdsl
  • dsl/camel-endpointdsl

🔁 2 tests passed only after a retry on JDK 21 (2 retried attempts)

Recovered flaky tests on JDK 21 (2)
Module Test Failed attempts First failure
dsl/camel-jbang/camel-jbang-plugin-tui AiPanelAcpTest.laterPromptsSkipThePreamble 1 Assertion condition defined as a Lambda expression in org.apache.camel.dsl.jbang.core.commands.tui.AiPanelAcpTest expected: <2> but was: <1> within 5 seconds.
dsl/camel-jbang/camel-jbang-plugin-tui MonitorStartupTest.browserOnlyServesUntilTheServerStops 1 Connection refused (connect failed)

ℹ️ These tests did not fail the build. Surefire retried them and they passed.
Retries are enabled project-wide by surefire.rerunFailingTestsCount in parent/pom.xml.


🔬 Scalpel shadow comparison — Scalpel: 11 of 713 tested, 24 compile-only — current: 11 all tested

Maveniverse Scalpel detected 11 affected modules (current approach: 11).

Skip-tests mode would test 11 modules (5 direct + 9 downstream), skip tests for 24 (generated code, meta-modules)

Modules Scalpel would test (11)
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-jbang-run ← downstream of org.apache.camel:camel-catalog
  • camel-kafka ← components/camel-kafka/src/generated/java/org/apache/camel/component/kafka/KafkaComponentConfigurer.java, components/camel-kafka/src/generated/java/org/apache/camel/component/kafka/KafkaEndpointConfigurer.java, components/camel-kafka/src/generated/java/org/apache/camel/component/kafka/KafkaEndpointUriFactory.java, components/camel-kafka/src/generated/resources/META-INF/org/apache/camel/component/kafka/kafka.json, components/camel-kafka/src/main/docs/kafka-component.adoc, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaConfiguration.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaConsumer.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaProducer.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaTransactionSynchronization.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/AbstractCommitManager.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/CommitManagers.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/DefaultKafkaManualCommit.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/KafkaExactlyOnceMetadata.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/KafkaManualCommitFactory.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/NoopCommitManager.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/support/batching/KafkaRecordBatchingProcessor.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/support/streaming/KafkaRecordStreamingProcessor.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/producer/support/KafkaProducerCallBack.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerStopTest.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerSuspendResumeTest.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaExactlyOnceConfigurationTest.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaProducerTest.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaTransactionSynchronizationTest.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/consumer/KafkaExactlyOnceMetadataTest.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/consumer/support/streaming/KafkaRecordStreamingProcessorTest.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/integration/KafkaExactlyOnceIT.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/integration/KafkaExactlyOnceRebalanceIT.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/integration/KafkaExactlyOnceRegressionIT.java
  • camel-kafka-common ← components/camel-kafka-common/src/main/java/org/apache/camel/component/kafka/KafkaConstants.java
  • camel-kafka-share ← downstream of org.apache.camel:camel-kafka-common
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (24)
  • apache-camel
  • camel-allcomponents
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • docs
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

⚠️ Some tests are disabled on GitHub Actions (@DisabledIfSystemProperty(named = "ci.env.name")) and require manual verification:

  • components/camel-kafka: 2 test(s) disabled on GitHub Actions
All tested modules (38 modules, 5m 24s total)

Total reactor time: 5m 24s

Module Duration Status
Camel :: JBang :: MCP 42.4s SUCCESS
Camel :: JBang :: Plugin :: Validate 28.6s SUCCESS
Camel :: YAML DSL :: Validator 28.4s SUCCESS
Camel :: Catalog :: Camel Catalog 25.1s SUCCESS
Camel :: YAML DSL 24.8s SUCCESS
Camel :: Component DSL 22.9s SUCCESS
Camel :: JBang :: Run 22.1s SUCCESS
Camel :: Kamelet Main 19.4s SUCCESS
Camel :: Docs 16.2s SUCCESS
Camel :: JBang :: Plugin :: Testing 14.5s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 13.8s SUCCESS
Camel :: Catalog :: Camel Route Parser 8.6s SUCCESS
Camel :: YAML DSL :: Deserializers 7.9s SUCCESS
Camel :: Kafka :: Common 7.2s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 6.9s SUCCESS
Camel :: All Components Sync point 6.5s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 4.8s SUCCESS
Camel :: Assembly 4.5s SUCCESS
Camel :: Catalog :: Maven 3.1s SUCCESS
Camel :: YAML DSL :: Maven Plugins 3.1s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 2.0s SUCCESS
Camel :: Catalog :: Dummy Component 1.9s SUCCESS
Camel :: JBang :: Integration tests 1.6s SUCCESS
Camel :: Coverage 1.4s SUCCESS
Camel :: JBang :: Plugin :: MCP 1.1s SUCCESS
Camel :: JBang :: Plugin :: Generate 1.1s SUCCESS
Camel :: Catalog :: Console 1.0s SUCCESS
Camel :: JBang :: Plugin :: Edit 0.8s SUCCESS
Camel :: Endpoint DSL :: Support 0.8s SUCCESS
Camel :: Launcher :: Container 0.7s SUCCESS
Camel :: JBang :: Main 0.7s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: JBang :: Plugin :: TUI n/a
Camel :: Kafka n/a
Camel :: Kafka :: Share n/a
Camel :: Launcher n/a

Top 20 slowest modules:

  • Camel :: JBang :: MCP (42.4s)
  • Camel :: JBang :: Plugin :: Validate (28.6s)
  • Camel :: YAML DSL :: Validator (28.4s)
  • Camel :: Catalog :: Camel Catalog (25.1s)
  • Camel :: YAML DSL (24.8s)
  • Camel :: Component DSL (22.9s)
  • Camel :: JBang :: Run (22.1s)
  • Camel :: Kamelet Main (19.4s)
  • Camel :: Docs (16.2s)
  • Camel :: JBang :: Plugin :: Testing (14.5s)
  • Camel :: JBang :: Plugin :: Kubernetes (13.8s)
  • Camel :: Catalog :: Camel Route Parser (8.6s)
  • Camel :: YAML DSL :: Deserializers (7.9s)
  • Camel :: Kafka :: Common (7.2s)
  • Camel :: Catalog :: Camel Report Maven Plugin (6.9s)
  • Camel :: All Components Sync point (6.5s)
  • Camel :: YAML DSL :: Validator Maven Plugin (4.8s)
  • Camel :: Assembly (4.5s)
  • Camel :: Catalog :: Maven (3.1s)
  • Camel :: YAML DSL :: Maven Plugins (3.1s)

⚙️ View full build and test results

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous findings are all addressed. LGTM.

The dead-code duplicate split/multicast guard in startKafkaTransaction (which was raised in my previous two reviews) has been removed in the latest commit. The canonical check in createExactlyOnceMetadata is the right place for this validation — close to the data it reads — and it now stands alone without the unreachable outer copy.

No new issues introduced by the removal.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@Croway Croway left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this feature. Before it ships I ran the exactly-once mode against real routes: a Kafka 4.4.0 broker (the test-infra container), kafka-clients 4.3.1 and PR head db8a96ff. I wrote one integration test per scenario. Each test asserts the faulty outcome, so a passing test means the problem reproduced. The numbers below are what the tests observed.

Several scenarios lose records silently, and several leave the route replaying the same record forever. Most of the cases come from three root causes:

  1. The consumer decides whether to move on from exchange.getException() alone, but the transaction outcome is decided later, in onDone. A transaction can be aborted while the exchange has no exception (rollbackOnly), or fail after the error handler has handled the exception. In the first case the consumer moves on and the record is lost. In the second, it replays forever.
  2. The source offsets and the "who commits them" decision travel with the exchange in fragile ways. They use a message header, a split/multicast property heuristic, and a synchronization that can be handed over to a copy with another exchange ID.
  3. Anything that repositions the consumer breaks the "replay from the committed offset" contract. That includes seekTo, a resume strategy, and a batch dropped on error.

Most likely in production, in order

# Trigger Effect Reproduced
1 batching=true and a rebalance while a batch is filling (rolling deploy, scale-out, pod restart) Silent loss of every record in the batch yes
2 filter/choice branch that does not send to Kafka, or exactlyOnce forgotten on the producer Offsets never committed; records reprocessed on every restart or rebalance; the whole topic if the producer flag is missing yes
3 Poison record handled by deadLetterChannel / handled(true) Endless replay, one more DLQ copy per replay yes
4 removeHeaders("*") (or anything that replaces the message) before the producer Endless replay with a misleading error yes
5 .to("seda:...") after the producer (asynchronous hand-over) Producer slot leaked: route stuck until restart, nothing committed yes
6 seekTo=END / seekTo=BEGINNING on the source END: silent loss. BEGINNING: committed duplicates yes
7 split/multicast with an aggregation strategy before the producer Parent exchange rejected, endless replay yes
8 markRollbackOnly() Silent loss of the record yes

1. Batch spanning a rebalance loses records (KafkaRecordBatchingProcessor.processBatch)

from("kafka:in?groupId=g&exactlyOnce=true&allowManualCommit=true&autoCommitEnable=false&breakOnFirstError=true"
     + "&batching=true&maxPollRecords=6&pollTimeoutMs=10000")
    .to("kafka:out?transacted=true&exactlyOnce=true");
  1. b0..b2 are polled and wait in the batch.
  2. A second member joins the group and the group rebalances.
  3. The consumer re-fetches b0..b2 under the new generation, which fills the batch.
  4. KafkaExactlyOnceMetadata.fromExchanges throws "exactlyOnce cannot combine records from different Kafka consumer group generations" before the route runs.
  5. processAndClearBatch clears the queue and the exception reaches the poll-exception strategy. SeekUtil logs "not advancing any partition offsets", but the position is already past b2.
  6. b3 and b4 are produced and processed.

Result: polls [3, 3, 2], the route never saw b0..b2, committed output [b3, b4], committed offset 5. b0..b2 are lost.

A batch that fills over several polls spans any rebalance. That is normal under moderate traffic, and a rebalance happens on every rolling deploy. The cooperative assignor also bumps the generation, so it does not avoid this. Suggestion: never drop a batch on error in this mode. Return a break-on-error result so the consumer reconnects and rewinds to the committed offsets, and flush or discard the pending batch from the rebalance listener so a batch never spans two generations.

2. Offsets are only committed by an exactly-once producer transaction

from(eosSource).filter(header("kafka.KEY").startsWith("m")).to(eosSink);   // m0, skip1, skip2, skip3

All 4 records were processed, but the committed offset stayed at 1. After a route restart, skip1..skip3 were consumed again. With .to("kafka:out?transacted=true") (the exactlyOnce flag forgotten on the producer), no offset was ever committed and nothing warned about it. After a restart the output was [m0, m1, m2, m0, m1, m2].

Content-based routing is everywhere. The side effects of the non-Kafka branch (DB writes, HTTP calls) repeat after each restart or rebalance until a later Kafka-bound record covers the offset, and consumer lag stays high. Suggestion: when an exchange completes and no exactly-once transaction claimed its offsets, commit them from the consumer, which is safe because nothing was produced. Warn when a record from an exactly-once source goes through a Kafka producer without exactlyOnce.

3. Dead letter channel cannot skip a poison record (KafkaTransactionSynchronization.onDone)

errorHandler(deadLetterChannel("direct:dlq"));
from(eosSource).to("kafka:out?transacted=true&exactlyOnce=true&maxRequestSize=1024");  // small-0, big-1 (4 KB), small-2

Within about 2 s, big-1 was attempted 4 times and reached the DLQ 3 times. Each commit failed with KafkaException: Cannot execute transactional method because we are in an error state, and small-2 was never reached. The loop does not end.

The cause: the send error puts the transaction into an abortable state, and the DLC then marks the exchange handled. onDone sees no exception and commits. The commit fails, the exception is set back on the exchange, and breakOnFirstError replays the record. A DLC is how users get past poison records (RecordTooLargeException, authorization on the output topic, invalid records). Suggestion: record the send failure in the synchronization. After a handled failure, abort, then commit the source offset alone in a new transaction, which skips the record atomically. At the very least, do not attempt a commit, and document the behaviour.

4. removeHeaders("*") breaks single-record mode (KafkaProducer.createExactlyOnceMetadata)

from(eosSource).removeHeaders("*").to(eosSink);

Every attempt failed with "exactlyOnce requires a CamelKafkaManualCommit header; configure allowManualCommit=true on the Kafka source endpoint", although that option is set. m0 was replayed forever and nothing was committed. removeHeaders("*") is a common way to stop source headers leaking into the output record. pollEnrich with the default strategy, or a processor that sets a new Message, has the same effect. Suggestion: carry KafkaExactlyOnceMetadata in an exchange property in single-record mode too, as the batch path already does.

5. Asynchronous hand-over leaks the producer slot (KafkaTransactionSynchronization, KafkaProducer.releaseExactlyOnceTransaction)

from(eosSource).to(eosSink).to("seda:audit");

SynchronizationAdapter.allowHandover() returns true, so SEDA moves the transaction synchronization to its copy. What happens next:

  1. The original exchange completes before the copy commits.
  2. The next record fails with "A Kafka exactlyOnce transaction is already active for exchange …-0000", which forces a reconnect.
  3. Because of the reconnect, the copy's delayed commit fails with "consumer group metadata mismatch: The coordinator is not aware of this member".
  4. The slot is then released with the copy's ID: "Could not release exactly-once producer slot for exchange …-0003; active exchange is …-0000".

Result: 51 "already active" failures in 28 s, nothing committed (not even m0), until the application restarts. Suggestion: override allowHandover() to return false, and release with the exchange ID stored when the slot was acquired.

6. seekTo is applied again on the reconnect that breakOnFirstError forces

SeekPolicyPartitionAssignmentAdapter seeks on every assignment. One failure on m2 gave:

  • seekTo=BEGINNING: committed output [m0, m1, m0, m1, m2, m3], so m0 and m1 were committed twice.
  • seekTo=END: committed output [warmup, m0, m1, m4]. m2 (the failed record) and m3 were never consumed.

Suggestion: reject seekTo and a ResumeStrategy in validateExactlyOnceConfiguration, as is already done for offsetRepository.

7. Split/multicast check rejects the parent after an aggregation strategy

from(eosSource).split(body().tokenize(","), AggregationStrategies.useLatest()).transform(body().append("!")).end().to(eosSink);

"exactlyOnce does not support split/multicast child exchanges" on the parent, replayed forever. MulticastProcessor.doDone → ExchangeHelper.copyResults copies the last sub-exchange's internal properties, including CamelSplitIndex, onto the parent. Checking the properties also misses copies that are not split children (see the wireTap case below). Suggestion: let the first transaction that includes the metadata mark it as claimed (compare-and-set on the metadata object kept in the exchange property), and reject any later claim.

8. markRollbackOnly() loses the record

from(eosSource).to(eosSink).filter(header("kafka.KEY").isEqualTo("m1")).markRollbackOnly().end();

Committed output [m0, m2], committed offset 3, m1 attempted once. onDone aborts the transaction, including m1's offset, but the consumer only checks getException() and moves on. Suggestion: in exactly-once mode, treat a rolled-back transaction as a failure in the consumer. For example, the synchronization could record the outcome on the exchange, and the consumer would break and replay when it was aborted.

Lower probability

  • wireTap to a second exactly-once producer. The copy commits the source offset in its own transaction. With a step after the main send that fails 1 s later, the output was [m0], the audit output [m0, m1, m2] and the committed offset 3, with m1 attempted once: m1's main output is lost. Without the delay, the second tap was rejected with "already active", because the previous tap still held the audit producer's slot. The claim in point 7 would cover this.
  • consumersCount > 1. For 30 records on 3 partitions there were 3 "already active" rejections and 33 attempts. The output stayed exactly-once and drained in 6.8 s, so the cost is extra replays and rebalances. Rejecting it at startup, or documenting it, would avoid surprises.
  • Producer on another cluster. Every commit fails with "Specified group generation id is not valid" and the record is replayed forever (24 attempts in 12 s). When the input topic does not exist on the producer's cluster, each commit blocks for about 60 s first. Kafka does not support exactly-once across clusters, so a clear startup error would be friendlier.

Tests and smaller points

  • to.assertIsSatisfied(30000) / (60000) in the three new ITs do not extend the wait. That argument is only used when the mock expects no messages. A mock expecting one message that arrives after 15 s fails assertIsSatisfied(60000) after 10 003 ms. Use MockEndpoint.assertIsSatisfied(context, 60, TimeUnit.SECONDS).
  • In this mode NoopCommitManager.commit() still logs at INFO "Auto commit … is enabled via Kafka consumer (NO-OP)", which is wrong here. It ran on every failure and stop: 182 lines in my run.
  • Fully qualified class names in KafkaConsumer.validateExactlyOnceConfiguration (DefaultKafkaManualCommitFactory, DefaultKafkaManualAsyncCommitFactory) and KafkaProducer.createExactlyOnceMetadata (java.util.Collections, which is already imported).

I can share the reproduction test class (KafkaExactlyOnceReproIT, one test per scenario above) if it helps.

Claude Code on behalf of Croway

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking on exactly-once support, it is a big and welcome piece of work.

I agree with @Croway's review: I traced #1 (a generation mismatch drops the batch: KafkaRecordBatchingProcessor throws in fromExchanges and processAndClearBatch clears the queue in finally), #3 (an exchange marked handled by the error handler/DLC still commits the transaction that is in error state, so the record replays forever) and #8 (markRollbackOnly() aborts the transaction, but the consumer only checks getException() and moves on, so the next transaction commits past the record) in the code, and they behave as described. Losing records is the one thing this feature must not do, so these need a fix (or a refusal at startup) before merge.

Two more points that are not raised yet:

  1. Default transactional.id is not unique per instance (KafkaProducer.java:155-159). With transacted=true it is getEndpoint().getId() + "-" + routeId, and the endpoint id is "endpoint" + counter (EndpointHelper.createEndpointId). Two replicas of the same application (the normal way to scale a consumer group) get the same id and keep fencing each other (ProducerFencedException, recreate, fence the other one ...). Two .to(sameUri) in one route have the same problem, as each SendProcessor creates its own producer. This predates the PR, but the new doc recommends transacted=true for exactly-once. Please document that the transactionalId must be unique per instance (e.g. from the pod/host name), or better, let exactlyOnce=true require an explicit transactionalId or add a per-instance suffix.
  2. No producer-side unit tests: KafkaProducerTest is unchanged. The doStart check, the reject branches of createExactlyOnceMetadata, the cleanup in startKafkaTransaction when beginTransaction/addSynchronization throws, and releaseExactlyOnceTransaction are not covered. On the IT side, a failing batch (abort, then the whole batch replays) is not covered either, and that is where #1 lives.

Smaller:

  • Doc: streaming mode is one transaction per record (begin, sendOffsetsToTransaction, commit per record). Worth recommending batching=true for throughput, and pointing to the CamelKafkaOverrideTopic header on one producer endpoint to write several topics in one transaction, as the "one exactlyOnce producer per exchange" rule rejects fan-out to several endpoints.
  • The exactlyOnce option description (KafkaConfiguration setter javadoc) is what tooling shows; it should mention the required allowManualCommit, autoCommitEnable=false, breakOnFirstError and groupId.
  • Nit: KafkaExactlyOnceMetadata.EXCHANGE_PROPERTY is the class FQCN, and EXACTLY_ONCE_PRODUCER_PROPERTY is private in KafkaProducer; a Camel... constant in KafkaConstants would match the module style.
  • KafkaExactlyOnceBatchIT expects exactly one batch for 8 records, which may be flaky if they are polled in two batches.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@ramu11

ramu11 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking on exactly-once support, it is a big and welcome piece of work.

I agree with @Croway's review: I traced #1 (a generation mismatch drops the batch: KafkaRecordBatchingProcessor throws in fromExchanges and processAndClearBatch clears the queue in finally), #3 (an exchange marked handled by the error handler/DLC still commits the transaction that is in error state, so the record replays forever) and #8 (markRollbackOnly() aborts the transaction, but the consumer only checks getException() and moves on, so the next transaction commits past the record) in the code, and they behave as described. Losing records is the one thing this feature must not do, so these need a fix (or a refusal at startup) before merge.

Two more points that are not raised yet:

  1. Default transactional.id is not unique per instance (KafkaProducer.java:155-159). With transacted=true it is getEndpoint().getId() + "-" + routeId, and the endpoint id is "endpoint" + counter (EndpointHelper.createEndpointId). Two replicas of the same application (the normal way to scale a consumer group) get the same id and keep fencing each other (ProducerFencedException, recreate, fence the other one ...). Two .to(sameUri) in one route have the same problem, as each SendProcessor creates its own producer. This predates the PR, but the new doc recommends transacted=true for exactly-once. Please document that the transactionalId must be unique per instance (e.g. from the pod/host name), or better, let exactlyOnce=true require an explicit transactionalId or add a per-instance suffix.
  2. No producer-side unit tests: KafkaProducerTest is unchanged. The doStart check, the reject branches of createExactlyOnceMetadata, the cleanup in startKafkaTransaction when beginTransaction/addSynchronization throws, and releaseExactlyOnceTransaction are not covered. On the IT side, a failing batch (abort, then the whole batch replays) is not covered either, and that is where Handle Jetty's removal of method ServletContextHandler.addFilter(String,String,int) #1 lives.

Smaller:

  • Doc: streaming mode is one transaction per record (begin, sendOffsetsToTransaction, commit per record). Worth recommending batching=true for throughput, and pointing to the CamelKafkaOverrideTopic header on one producer endpoint to write several topics in one transaction, as the "one exactlyOnce producer per exchange" rule rejects fan-out to several endpoints.
  • The exactlyOnce option description (KafkaConfiguration setter javadoc) is what tooling shows; it should mention the required allowManualCommit, autoCommitEnable=false, breakOnFirstError and groupId.
  • Nit: KafkaExactlyOnceMetadata.EXCHANGE_PROPERTY is the class FQCN, and EXACTLY_ONCE_PRODUCER_PROPERTY is private in KafkaProducer; a Camel... constant in KafkaConstants would match the module style.
  • KafkaExactlyOnceBatchIT expects exactly one batch for 8 records, which may be flaky if they are polled in two batches.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

Thanks @davsclaus for the thorough review and for tracing the failure cases.

I have pushed the changes in commit e7671c84f2d7.

  • Transactional ID: exactlyOnce=true now requires an explicit transactionalId. The documentation explains that it must be unique per producer instance and stable across restarts. transacted=true alone is not sufficient for exactly-once mode.
  • Producer-side tests: KafkaProducerTest now covers the exactly-once startup requirement, invalid destination/source configurations, missing or mismatched source metadata, transaction-start failure cleanup, synchronization-registration failure cleanup, and release of the producer slot using the original exchange ID.
  • Batch handling: Rather than supporting batch transactions with unsafe rebalance behavior, exactly-once mode now rejects batching=true at source startup. I removed KafkaExactlyOnceBatchIT, which assumed one batch for a fixed number of records. A broker-backed regression test verifies that batching is rejected.
  • Throughput and multiple topics: The documentation now recommends tuning producer batch.size and linger.ms while keeping consumer batching disabled. It also documents using one producer with CamelKafkaOverrideTopic to write to multiple output topics in one transaction.
  • Option documentation: The exactlyOnce setter javadoc now lists the required source options and explicit destination transactional ID.
  • Exchange properties: The exactly-once metadata and transaction markers now use shared CamelKafka... constants in KafkaConstants.
  • Other review points: The integration-test timeout assertions use the static MockEndpoint helper, and the duplicate split/multicast guard was removed.

The updated tests and documentation are included in the pushed commit. The full camel-kafka module test and integration-test build passed locally.

@ramu11

ramu11 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for this feature. Before it ships I ran the exactly-once mode against real routes: a Kafka 4.4.0 broker (the test-infra container), kafka-clients 4.3.1 and PR head db8a96ff. I wrote one integration test per scenario. Each test asserts the faulty outcome, so a passing test means the problem reproduced. The numbers below are what the tests observed.

Several scenarios lose records silently, and several leave the route replaying the same record forever. Most of the cases come from three root causes:

  1. The consumer decides whether to move on from exchange.getException() alone, but the transaction outcome is decided later, in onDone. A transaction can be aborted while the exchange has no exception (rollbackOnly), or fail after the error handler has handled the exception. In the first case the consumer moves on and the record is lost. In the second, it replays forever.
  2. The source offsets and the "who commits them" decision travel with the exchange in fragile ways. They use a message header, a split/multicast property heuristic, and a synchronization that can be handed over to a copy with another exchange ID.
  3. Anything that repositions the consumer breaks the "replay from the committed offset" contract. That includes seekTo, a resume strategy, and a batch dropped on error.

Most likely in production, in order

Trigger Effect Reproduced

1 batching=true and a rebalance while a batch is filling (rolling deploy, scale-out, pod restart) Silent loss of every record in the batch yes
2 filter/choice branch that does not send to Kafka, or exactlyOnce forgotten on the producer Offsets never committed; records reprocessed on every restart or rebalance; the whole topic if the producer flag is missing yes
3 Poison record handled by deadLetterChannel / handled(true) Endless replay, one more DLQ copy per replay yes
4 removeHeaders("*") (or anything that replaces the message) before the producer Endless replay with a misleading error yes
5 .to("seda:...") after the producer (asynchronous hand-over) Producer slot leaked: route stuck until restart, nothing committed yes
6 seekTo=END / seekTo=BEGINNING on the source END: silent loss. BEGINNING: committed duplicates yes
7 split/multicast with an aggregation strategy before the producer Parent exchange rejected, endless replay yes
8 markRollbackOnly() Silent loss of the record yes

1. Batch spanning a rebalance loses records (KafkaRecordBatchingProcessor.processBatch)

from("kafka:in?groupId=g&exactlyOnce=true&allowManualCommit=true&autoCommitEnable=false&breakOnFirstError=true"
     + "&batching=true&maxPollRecords=6&pollTimeoutMs=10000")
    .to("kafka:out?transacted=true&exactlyOnce=true");
  1. b0..b2 are polled and wait in the batch.
  2. A second member joins the group and the group rebalances.
  3. The consumer re-fetches b0..b2 under the new generation, which fills the batch.
  4. KafkaExactlyOnceMetadata.fromExchanges throws "exactlyOnce cannot combine records from different Kafka consumer group generations" before the route runs.
  5. processAndClearBatch clears the queue and the exception reaches the poll-exception strategy. SeekUtil logs "not advancing any partition offsets", but the position is already past b2.
  6. b3 and b4 are produced and processed.

Result: polls [3, 3, 2], the route never saw b0..b2, committed output [b3, b4], committed offset 5. b0..b2 are lost.

A batch that fills over several polls spans any rebalance. That is normal under moderate traffic, and a rebalance happens on every rolling deploy. The cooperative assignor also bumps the generation, so it does not avoid this. Suggestion: never drop a batch on error in this mode. Return a break-on-error result so the consumer reconnects and rewinds to the committed offsets, and flush or discard the pending batch from the rebalance listener so a batch never spans two generations.

2. Offsets are only committed by an exactly-once producer transaction

from(eosSource).filter(header("kafka.KEY").startsWith("m")).to(eosSink);   // m0, skip1, skip2, skip3

All 4 records were processed, but the committed offset stayed at 1. After a route restart, skip1..skip3 were consumed again. With .to("kafka:out?transacted=true") (the exactlyOnce flag forgotten on the producer), no offset was ever committed and nothing warned about it. After a restart the output was [m0, m1, m2, m0, m1, m2].

Content-based routing is everywhere. The side effects of the non-Kafka branch (DB writes, HTTP calls) repeat after each restart or rebalance until a later Kafka-bound record covers the offset, and consumer lag stays high. Suggestion: when an exchange completes and no exactly-once transaction claimed its offsets, commit them from the consumer, which is safe because nothing was produced. Warn when a record from an exactly-once source goes through a Kafka producer without exactlyOnce.

3. Dead letter channel cannot skip a poison record (KafkaTransactionSynchronization.onDone)

errorHandler(deadLetterChannel("direct:dlq"));
from(eosSource).to("kafka:out?transacted=true&exactlyOnce=true&maxRequestSize=1024");  // small-0, big-1 (4 KB), small-2

Within about 2 s, big-1 was attempted 4 times and reached the DLQ 3 times. Each commit failed with KafkaException: Cannot execute transactional method because we are in an error state, and small-2 was never reached. The loop does not end.

The cause: the send error puts the transaction into an abortable state, and the DLC then marks the exchange handled. onDone sees no exception and commits. The commit fails, the exception is set back on the exchange, and breakOnFirstError replays the record. A DLC is how users get past poison records (RecordTooLargeException, authorization on the output topic, invalid records). Suggestion: record the send failure in the synchronization. After a handled failure, abort, then commit the source offset alone in a new transaction, which skips the record atomically. At the very least, do not attempt a commit, and document the behaviour.

4. removeHeaders("*") breaks single-record mode (KafkaProducer.createExactlyOnceMetadata)

from(eosSource).removeHeaders("*").to(eosSink);

Every attempt failed with "exactlyOnce requires a CamelKafkaManualCommit header; configure allowManualCommit=true on the Kafka source endpoint", although that option is set. m0 was replayed forever and nothing was committed. removeHeaders("*") is a common way to stop source headers leaking into the output record. pollEnrich with the default strategy, or a processor that sets a new Message, has the same effect. Suggestion: carry KafkaExactlyOnceMetadata in an exchange property in single-record mode too, as the batch path already does.

5. Asynchronous hand-over leaks the producer slot (KafkaTransactionSynchronization, KafkaProducer.releaseExactlyOnceTransaction)

from(eosSource).to(eosSink).to("seda:audit");

SynchronizationAdapter.allowHandover() returns true, so SEDA moves the transaction synchronization to its copy. What happens next:

  1. The original exchange completes before the copy commits.
  2. The next record fails with "A Kafka exactlyOnce transaction is already active for exchange …-0000", which forces a reconnect.
  3. Because of the reconnect, the copy's delayed commit fails with "consumer group metadata mismatch: The coordinator is not aware of this member".
  4. The slot is then released with the copy's ID: "Could not release exactly-once producer slot for exchange …-0003; active exchange is …-0000".

Result: 51 "already active" failures in 28 s, nothing committed (not even m0), until the application restarts. Suggestion: override allowHandover() to return false, and release with the exchange ID stored when the slot was acquired.

6. seekTo is applied again on the reconnect that breakOnFirstError forces

SeekPolicyPartitionAssignmentAdapter seeks on every assignment. One failure on m2 gave:

  • seekTo=BEGINNING: committed output [m0, m1, m0, m1, m2, m3], so m0 and m1 were committed twice.
  • seekTo=END: committed output [warmup, m0, m1, m4]. m2 (the failed record) and m3 were never consumed.

Suggestion: reject seekTo and a ResumeStrategy in validateExactlyOnceConfiguration, as is already done for offsetRepository.

7. Split/multicast check rejects the parent after an aggregation strategy

from(eosSource).split(body().tokenize(","), AggregationStrategies.useLatest()).transform(body().append("!")).end().to(eosSink);

"exactlyOnce does not support split/multicast child exchanges" on the parent, replayed forever. MulticastProcessor.doDone → ExchangeHelper.copyResults copies the last sub-exchange's internal properties, including CamelSplitIndex, onto the parent. Checking the properties also misses copies that are not split children (see the wireTap case below). Suggestion: let the first transaction that includes the metadata mark it as claimed (compare-and-set on the metadata object kept in the exchange property), and reject any later claim.

8. markRollbackOnly() loses the record

from(eosSource).to(eosSink).filter(header("kafka.KEY").isEqualTo("m1")).markRollbackOnly().end();

Committed output [m0, m2], committed offset 3, m1 attempted once. onDone aborts the transaction, including m1's offset, but the consumer only checks getException() and moves on. Suggestion: in exactly-once mode, treat a rolled-back transaction as a failure in the consumer. For example, the synchronization could record the outcome on the exchange, and the consumer would break and replay when it was aborted.

Lower probability

  • wireTap to a second exactly-once producer. The copy commits the source offset in its own transaction. With a step after the main send that fails 1 s later, the output was [m0], the audit output [m0, m1, m2] and the committed offset 3, with m1 attempted once: m1's main output is lost. Without the delay, the second tap was rejected with "already active", because the previous tap still held the audit producer's slot. The claim in point 7 would cover this.
  • consumersCount > 1. For 30 records on 3 partitions there were 3 "already active" rejections and 33 attempts. The output stayed exactly-once and drained in 6.8 s, so the cost is extra replays and rebalances. Rejecting it at startup, or documenting it, would avoid surprises.
  • Producer on another cluster. Every commit fails with "Specified group generation id is not valid" and the record is replayed forever (24 attempts in 12 s). When the input topic does not exist on the producer's cluster, each commit blocks for about 60 s first. Kafka does not support exactly-once across clusters, so a clear startup error would be friendlier.

Tests and smaller points

  • to.assertIsSatisfied(30000) / (60000) in the three new ITs do not extend the wait. That argument is only used when the mock expects no messages. A mock expecting one message that arrives after 15 s fails assertIsSatisfied(60000) after 10 003 ms. Use MockEndpoint.assertIsSatisfied(context, 60, TimeUnit.SECONDS).
  • In this mode NoopCommitManager.commit() still logs at INFO "Auto commit … is enabled via Kafka consumer (NO-OP)", which is wrong here. It ran on every failure and stop: 182 lines in my run.
  • Fully qualified class names in KafkaConsumer.validateExactlyOnceConfiguration (DefaultKafkaManualCommitFactory, DefaultKafkaManualAsyncCommitFactory) and KafkaProducer.createExactlyOnceMetadata (java.util.Collections, which is already imported).

I can share the reproduction test class (KafkaExactlyOnceReproIT, one test per scenario above) if it helps.

Claude Code on behalf of Croway

Thanks for the detailed reproductions. I have pushed fixes in commit e7671c84f2d7 and added broker-backed regression coverage for the reported scenarios.

Here is how each finding is addressed:

  1. Batch/rebalance data loss: Exactly-once mode now rejects batching=true during source consumer startup. Rather than risk losing a batch across consumer group generations, this implementation currently supports streaming mode only. A real-route startup regression test verifies the rejection.

  2. Offsets for filtered records and non-EOS destinations: When a successful route does not use an exactly-once Kafka producer, the source offsets are committed directly on the consumer polling thread. A Kafka destination without exactlyOnce=true is rejected, leaving the source record available for replay. Regression tests cover both filtered records and a non-EOS Kafka destination.

  3. Handled send failures/DLC: Kafka send failures are retained even when the Camel error handler handles the exception. The failed output transaction is aborted, and the source offsets are committed in a new offset-only transaction. A broker-backed poison-record/DLC test verifies that the record is not replayed indefinitely. External DLQ side effects are still outside the Kafka transaction and are subject to the usual crash-boundary caveat.

  4. Message header removal: Source offsets and consumer group metadata are retained in an exchange property, so removeHeaders("*") or message replacement does not lose them. There is a regression test for header removal.

  5. SEDA handover: Exactly-once synchronization does not allow handover to a copied exchange. The producer slot is released using the original exchange ID captured when the transaction started. A SEDA regression test covers this case.

  6. Seek/reconnect behavior: seekTo and ResumeStrategy are rejected for exactly-once sources so replay always follows committed offsets.

  7. Split/multicast and wireTap: Source metadata can only be claimed by the original source exchange. Split/multicast aggregation back to the parent is supported when the Kafka producer is invoked after aggregation, but child and wireTap copies cannot claim the source offsets. Regression tests cover both cases.

  8. Rollback-only: An aborted transaction is now explicitly marked on the exchange and propagated to the streaming consumer, which treats it as a failure and replays the source record. There is a broker-backed markRollbackOnly() regression test.

I also addressed the additional observations:

  • consumersCount greater than one is rejected at startup.
  • Integration tests use the static MockEndpoint.assertIsSatisfied(context, timeout, unit) overload.
  • The exactly-once NoopCommitManager messages are logged at DEBUG.
  • The fully qualified class references were cleaned up.

The source and destination must use the same Kafka cluster. This is documented, and an offset-commit failure emits a warning to verify the cluster and consumer group generation. I have not added a proactive cluster-identity check during startup.

I ran the full camel-kafka module test and integration-test build successfully after these changes.

@ramu11
ramu11 requested review from Croway and davsclaus October 31, 2026 04:47

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review — all prior gnodet-bot findings are fully addressed. The dead-code duplicate guard from the previous two reviews has been removed.

This commit addresses all 10 issues raised by @Croway and @davsclaus. I traced the critical paths:

  • Two-transaction DLC skip (commitOffsetsAfterHandledSendFailure): abort old tx → recreate producer if needed → commit source-only tx. All failure modes (abort failure, offset-commit failure) set an exception on the exchange, causing completeExactlyOnceProcessing to detect the abort flag and force consumer replay. No silent data-loss path found.
  • Claim mechanism for split+aggregation: Camel's Splitter preserves the parent exchange ID on sub-exchanges, so the aggregated parent correctly passes the sourceExchangeId check. WireTap copies get new IDs and are correctly rejected.
  • Filter-branch offset commit: completeExactlyOnceProcessing() calling metadata.commitOffsets() when unclaimed is correct — a non-Kafka destination (log, direct, mock) intentionally takes this path, advancing the source offset on the polling thread.
  • SEDA handover: allowHandover() = false integrates with DefaultUnitOfWork.handoverCompletions() which skips non-handoverable synchronizations. The synchronization stays on the original exchange and fires correctly.

One minor nit (see inline).

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ramu11, this is a thorough round. I went through the delta against my review and @Croway's:

  • exactlyOnce=true now needs an explicit transactionalId.
  • Batching, seekTo, ResumeStrategy and consumersCount > 1 are rejected at startup.
  • Filtered records commit their offsets from the consumer.
  • The metadata lives in an exchange property, and a claim binds it to the source exchange.
  • Exactly-once synchronizations no longer allow handover, and the slot is released with the id captured at start.
  • A rollback-only or aborted transaction now replays the record.
  • The producer-side unit tests and the regression IT cover the scenarios.

All of my earlier points and Croway's #1-#8 look addressed in the code.

One remaining way to lose a record:

  • The send-failure marker survives a redelivery. KafkaExactlyOnceMetadata.recordSendFailure keeps the first failure in CamelKafkaExactlyOnceSendFailure, and nothing ever removes it. Suppose attempt 1 fails inside startKafkaTransaction (for example initTransactions() times out while the producer is recreated, KafkaProducer.java:627) and the error handler redelivers. Attempt 2 claims, sends and succeeds. In KafkaTransactionSynchronization.onDone (lines 89-97) the exchange has no exception but still has the marker, so commitOffsetsAfterHandledSendFailure aborts the good output and commits the source offset: the record is lost. Could you clear the marker when a new synchronization is registered (a failure before that point does not belong to the transaction), or better, keep the send-failure flag on the KafkaTransactionSynchronization instance instead of the exchange? A unit test with a failed first start and a successful redelivery would cover it.

Smaller:

  • recordSendFailure also runs for producers that are not exactly-once (KafkaProducer.java:431 and :522, KafkaProducerCallBack.java:89), so every failed Kafka send now leaves a Throwable exchange property. Please only record it when the exchange carries the exactly-once metadata.
  • Doc: a Kafka destination that is not exactly-once is rejected at runtime, not at startup, so the record replays (and a DLC runs again) until the configuration is fixed. Worth saying.
  • The batch-path exactly-once code (KafkaRecordBatchingProcessor.java:271-273, KafkaExactlyOnceMetadata.fromExchanges) is unreachable now that batching is rejected. It can go.
  • The doc lists the rejected options twice (kafka-component.adoc around lines 598 and 601).

Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@ramu11

ramu11 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ramu11, this is a thorough round. I went through the delta against my review and @Croway's:

  • exactlyOnce=true now needs an explicit transactionalId.
  • Batching, seekTo, ResumeStrategy and consumersCount > 1 are rejected at startup.
  • Filtered records commit their offsets from the consumer.
  • The metadata lives in an exchange property, and a claim binds it to the source exchange.
  • Exactly-once synchronizations no longer allow handover, and the slot is released with the id captured at start.
  • A rollback-only or aborted transaction now replays the record.
  • The producer-side unit tests and the regression IT cover the scenarios.

All of my earlier points and Croway's #1-#8 look addressed in the code.

One remaining way to lose a record:

  • The send-failure marker survives a redelivery. KafkaExactlyOnceMetadata.recordSendFailure keeps the first failure in CamelKafkaExactlyOnceSendFailure, and nothing ever removes it. Suppose attempt 1 fails inside startKafkaTransaction (for example initTransactions() times out while the producer is recreated, KafkaProducer.java:627) and the error handler redelivers. Attempt 2 claims, sends and succeeds. In KafkaTransactionSynchronization.onDone (lines 89-97) the exchange has no exception but still has the marker, so commitOffsetsAfterHandledSendFailure aborts the good output and commits the source offset: the record is lost. Could you clear the marker when a new synchronization is registered (a failure before that point does not belong to the transaction), or better, keep the send-failure flag on the KafkaTransactionSynchronization instance instead of the exchange? A unit test with a failed first start and a successful redelivery would cover it.

Smaller:

  • recordSendFailure also runs for producers that are not exactly-once (KafkaProducer.java:431 and :522, KafkaProducerCallBack.java:89), so every failed Kafka send now leaves a Throwable exchange property. Please only record it when the exchange carries the exactly-once metadata.
  • Doc: a Kafka destination that is not exactly-once is rejected at runtime, not at startup, so the record replays (and a DLC runs again) until the configuration is fixed. Worth saying.
  • The batch-path exactly-once code (KafkaRecordBatchingProcessor.java:271-273, KafkaExactlyOnceMetadata.fromExchanges) is unreachable now that batching is rejected. It can go.
  • The doc lists the rejected options twice (kafka-component.adoc around lines 598 and 601).

Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

Thanks for the review. I've addressed the comments in commit 6d46b1d5b2fb.

  • Stale failure marker: Clear the previous send-failure marker when a new exactly-once synchronization is successfully registered. Extended the producer test to cover a failed first transaction start followed by a successful retry.
  • Non-EOS producer paths: recordSendFailure now records failures only for exchanges carrying exactly-once metadata. Added a unit test for a non-exactly-once exchange.
  • Runtime destination validation: Updated the component documentation to clarify that a destination without exactly-once support is rejected when the exchange is processed, not at route startup, and explain the replay/error-handler implications.
  • Batching path: Removed the unreachable batch exactly-once metadata handling and the now-unused fromExchanges() method, since batch processing is rejected for this mode.
  • Documentation cleanup: Removed the duplicate rejected-options explanation from both documentation copies.
  • Also removed the redundant exchange.setException(null) call and its corresponding test assertion.

The changes are pushed to the PR branch. Thanks!

@ramu11
ramu11 requested a review from davsclaus October 31, 2026 07:42

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review — all five of davsclaus's remaining points are addressed in commit 6d46b1d5b2fb.

  • Stale send-failure marker ✅ — SEND_FAILURE_PROPERTY is now cleared inside startKafkaTransaction immediately after synchronizationRegistered = true, before any sends. On a successful retry, onDone reads a clean state and takes the commit path. Lifecycle ordering is correct: register sync → clear old marker → send → onDone commits.
  • Non-EOS producer paths ✅ — recordSendFailure now checks EXCHANGE_PROPERTY before recording the failure, so ordinary Kafka send failures no longer pollute non-exactly-once exchanges with a stale Throwable property.
  • Runtime destination validation ✅ — Documentation updated to clarify that a destination without exactlyOnce=true is rejected when the exchange is processed (not at startup), and that the source record replays until the configuration is fixed.
  • Unreachable batching path ✅ — KafkaExactlyOnceMetadata.fromExchanges() removed, the dead if (configuration.isExactlyOnce()) block in KafkaRecordBatchingProcessor removed. Clean.
  • Duplicate rejected-options listing ✅ — Both doc copies trimmed; the redundant second copy of the startup-rejection list is gone.
  • exchange.setException(null) no-op ✅ — Removed; replaced with a comment documenting why the invariant holds (the onDone guard ensures the exception is already null on entry to commitOffsetsAfterHandledSendFailure).

Test coverage is sound: failedExactlyOnceBeginTransactionReleasesTheMetadataClaimAndProducerSlot now verifies both the failure path (marker set via setProperty) and the retry path (marker cleared via removeProperty). sendFailureIsNotRecordedForANonExactlyOnceExchange covers the new EOS-only guard. LGTM.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: Thanks @ramu11, every point from the last round is in, but one way to lose a record is left: a redelivery inside the already-open transaction (KafkaProducer.java:689 + KafkaTransactionSynchronization.java:93). 2 minor points.

All of these look good:

  • The marker is cleared when a new synchronization registers.
  • recordSendFailure now only records for exchanges with exactly-once metadata.
  • The runtime-rejection doc is in.
  • The batch dead code is removed.
  • The doc duplicate is removed.
  • The setException(null) nit is fixed.

Remaining record loss: redelivery in the same transaction. Clearing the marker at KafkaProducer.java:642 covers a failure before the synchronization registers. Here is the case it misses:

  1. Attempt 1 begins the transaction and registers the synchronization, then the send fails and the marker is set.
  2. The error handler redelivers. uow.isTransactedBy(transactionId) is true, so we take the "Using existing kafka transaction" branch, and the marker stays.
  3. Attempt 2 succeeds.
  4. onDone sees no exception plus the marker. It aborts the good output and commits only the source offset, so the record is lost.

A retry in the same transaction can succeed: in kafka-clients 4.3.1 KafkaProducer.doSend, only an ApiException moves the transaction into the error state. A SerializationException (for example a schema-registry hiccup in the serializer) or a Camel-side createRecord failure leaves it usable.

Could we, in the existing-transaction branch, when the marker is present, abortTransaction() + beginTransaction() and clear the marker? That would also make redelivery work after an ApiException. Alternatively we could reject redelivery inside an exactly-once transaction. A unit test with a failed send, a successful redelivery (isTransactedBy=true) and then onDone expecting commitTransaction (no abort) would cover it.

Smaller:

  • Doc (adoc ~612-614): with .to(eosSink).to(nonEosKafka) and a DLC that handles the error, the marker set by the second producer sends onDone down the skip path. The record is skipped rather than replayed. Worth stating, or replaying in that case.
  • The new retry test only verifies removeProperty on a mock. Asserting the commit-vs-skip outcome through the synchronization would make it stronger.

Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@ramu11

ramu11 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

TL;DR: Thanks @ramu11, every point from the last round is in, but one way to lose a record is left: a redelivery inside the already-open transaction (KafkaProducer.java:689 + KafkaTransactionSynchronization.java:93). 2 minor points.

All of these look good:

  • The marker is cleared when a new synchronization registers.
  • recordSendFailure now only records for exchanges with exactly-once metadata.
  • The runtime-rejection doc is in.
  • The batch dead code is removed.
  • The doc duplicate is removed.
  • The setException(null) nit is fixed.

Remaining record loss: redelivery in the same transaction. Clearing the marker at KafkaProducer.java:642 covers a failure before the synchronization registers. Here is the case it misses:

  1. Attempt 1 begins the transaction and registers the synchronization, then the send fails and the marker is set.
  2. The error handler redelivers. uow.isTransactedBy(transactionId) is true, so we take the "Using existing kafka transaction" branch, and the marker stays.
  3. Attempt 2 succeeds.
  4. onDone sees no exception plus the marker. It aborts the good output and commits only the source offset, so the record is lost.

A retry in the same transaction can succeed: in kafka-clients 4.3.1 KafkaProducer.doSend, only an ApiException moves the transaction into the error state. A SerializationException (for example a schema-registry hiccup in the serializer) or a Camel-side createRecord failure leaves it usable.

Could we, in the existing-transaction branch, when the marker is present, abortTransaction() + beginTransaction() and clear the marker? That would also make redelivery work after an ApiException. Alternatively we could reject redelivery inside an exactly-once transaction. A unit test with a failed send, a successful redelivery (isTransactedBy=true) and then onDone expecting commitTransaction (no abort) would cover it.

Smaller:

  • Doc (adoc ~612-614): with .to(eosSink).to(nonEosKafka) and a DLC that handles the error, the marker set by the second producer sends onDone down the skip path. The record is skipped rather than replayed. Worth stating, or replaying in that case.
  • The new retry test only verifies removeProperty on a mock. Asserting the commit-vs-skip outcome through the synchronization would make it stronger.

Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

Thanks for the follow-up review. Addressed the remaining points in commit f9d92812c818.

  • Same-UoW redelivery: Rejects retries after a send failure within an active transaction and marks the exchange rollback-only. This aborts the entire transaction and allows source-record replay in a fresh transaction.
  • Non-EOS destination: Clarified the handled-failure and source-offset skip behavior in both documentation copies.
  • Regression coverage: Added a test verifying retry rejection, transaction abort, and no source-offset commit.

Validation passed: Kafka producer tests and all reported Maven builds completed successfully.

Changes are committed and pushed to the PR branch. Thanks for the review!

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review — all prior findings are addressed. The new commit (f9d92812) is clean.

The redelivery guard added in startKafkaTransaction is correct:

  • Placement — correctly sits in the else branch (transaction already active, uow.isTransactedBy(transactionId) == true), guarded by configuration.isExactlyOnce() to avoid touching non-EOS paths.
  • Rollback semantics — exchange.setRollbackOnly(true) is set before the IllegalStateException is thrown, so KafkaTransactionSynchronization.onDone() enters the rollback() path even if an outer error handler swallows the exception. The test at lines 318–328 verifies this explicitly: with isRollbackOnly() = true and getException() = null, onDone calls abortTransaction() and never reaches sendOffsetsToTransaction or commitTransaction.
  • SEND_FAILURE_PROPERTY idempotency — recordSendFailure() already guards against overwriting an existing marker (exchange.getProperty(SEND_FAILURE_PROPERTY) == null), so the catch block in the async process() path cannot clobber the original failure with the redelivery IllegalStateException.
  • Test coverage — the new test (rejectsRedeliveryAfterExactlyOnceSendFailureWithinActiveTransaction) correctly stubs uow.isTransactedBy() to return false, true (matching the first- and second-call sequence), stubs SEND_FAILURE_PROPERTY for the second process() invocation, and validates the full onDone abort path end-to-end.
  • Documentation — the doc update accurately describes the new constraint and aligns with the implementation.

LGTM.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@ramu11
ramu11 requested a review from davsclaus October 31, 2026 10:46

@Croway Croway left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ramu11 for the thorough rework. I re-ran my scenarios against f9d92812 on the same setup (Kafka 4.4.0 broker, kafka-clients 4.3.1). This time every test asserts the correct outcome.

All eight points from my first review are fixed, and so are the lower-probability ones:

Scenario Result at f9d92812
markRollbackOnly() m1 replayed, output [m0, m1, m2]
removeHeaders("*") output [m0, m1]
split with an aggregation strategy, then send output [c!], one attempt
.to("seda:...") after the producer output [m0, m1, m2], no "already active"
DLC without redelivery, poison record output [small-0, small-2], DLQ received it once
filter branch offsets committed, nothing consumed again after a restart
producer without exactlyOnce rejected, the record replays, nothing committed
seekTo=BEGINNING/END, batching=true, consumersCount=2 rejected at startup
wireTap next to a main send that then fails m1 replayed, output complete

The new skip, claim and direct-commit paths open three cases that I think matter in production.

1. A Kafka DLQ topic silently loses the poison record

errorHandler(deadLetterChannel("kafka:orders-dlq"));   // also with ?exactlyOnce=true&transactionalId=dlq-1
from(eosSource).to(eosSink + "&maxRequestSize=1024");  // small-0, big-1 (4 KB), small-2

For both DLQ endpoints, the committed offset was 3, the output [small-0, small-2], and the DLQ topic was empty:

  1. The DLQ producer is rejected. A plain endpoint fails validateExactlyOnceDestination. An exactly-once endpoint fails with "Only one exactlyOnce Kafka producer is supported per exchange/unit of work".
  2. The DLC swallows its own failure: deadLetterHandleNewException defaults to true, and it logs "New exception occurred during processing by the DeadLetterChannel".
  3. The exchange ends up handled, so onDone takes the skip path and logs "Skipped source offset(s) … after a handled Kafka send failure".

The record is gone, and three WARN lines are the only trace. A DLQ topic is the usual dead letter of a Kafka route, and the doc's advice ("configure a dead-letter route when the rejected payload must be retained") leads straight here.

Suggestion: write the DLQ record inside the offset-only transaction (abort, begin, send the DLQ record, sendOffsetsToTransaction, commit). That makes the skip and the DLQ write atomic. Failing that, skip only when the dead letter delivery succeeded and replay otherwise, and document that the DLQ must not be a Kafka endpoint.

2. An asynchronous hop to the exactly-once producer commits the offset without the output

from(eosSource).to("seda:out");                          from("seda:out").to(eosSink);
from(eosSource).wireTap("direct:out").to("log:main");    from("direct:out").to(eosSink);

In both cases the committed offset was 3 and the output was empty. The copy cannot claim the metadata, and the error stays in the secondary route:

  • SEDA: "exactlyOnce requires a Kafka consumer as the route source"
  • wireTap: "exactlyOnce source metadata cannot be used by a split, wireTap or copied exchange"

The original exchange completes without a claim, so completeExactlyOnceProcessing handles it like a filtered record and commits the offset with commitSync. The consumer cannot tell a filtered record from a record whose only output went to a rejected copy.

Suggestion: when a claim is rejected (copied exchange, or fromEndpoint not the Kafka source), mark the shared KafkaExactlyOnceMetadata as rejected, and have the consumer replay instead of commit when it sees the mark. That covers copies that run before the original completes. For slower asynchronous hops only the doc can help: say that the exactly-once producer must be reached synchronously by the source exchange, and that a SEDA, wireTap or threads() hop loses the record.

3. Split fan-out replays forever

from(eosSource).split(body().tokenize(",")).to(eosSink);

Each child is rejected with "cannot be used by a split, wireTap or copied exchange". The children have new exchange IDs (…0001 to …0003 in the log), the parent fails and the record replays: 54 attempts in 30 s, nothing committed. The restriction is documented, but this is the most common way to write a one-to-many route, and it only fails at runtime. This pattern works (output [a!, b!, c!] in one transaction, one attempt), so it would be worth putting next to the restriction in the doc:

from(eosSource)
    .split(body().tokenize(","), AggregationStrategies.groupedBody()).transform(body().append("!")).end()
    .to(eosSink);   // the List body is sent as three records in one transaction

Smaller

  • Two routes with the same sink URI share one transactionalId. Their two producers fenced each other once (InvalidProducerEpochException), although all 20 records were delivered exactly once. The doc says the ID must be unique per producer instance, but reusing one endpoint URI in two routes is the natural way to write it. A duplicate-transactionalId check per CamelContext, or a per-route suffix, would avoid the fencing.
  • DLC with maximumRedeliveries(2). The redelivery is rejected, the DLC handles the exchange, and the record is skipped (DLQ once, offset 3). The doc says instead that a rejected redelivery replays the source record. The behaviour is reasonable, but the doc should say that with a DLC the record goes to the DLQ after the first send failure, and that maximumRedeliveries never retries an exactly-once send.

I can share the test class (KafkaExactlyOnceReReviewIT, 17 tests) if it helps.

Claude Code on behalf of Croway

@ramu11

ramu11 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

" I can share the test class (KafkaExactlyOnceReReviewIT, 17 tests) if it helps."
@Croway please share

@Croway

Croway commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

@ramu11 here it is. Drop it into components/camel-kafka/src/test/java/org/apache/camel/component/kafka/integration/ and run:

mvn -pl components/camel-kafka-common,components/camel-kafka verify -Dit.test=KafkaExactlyOnceReReviewIT -Dtest=skip -Dsurefire.failIfNoSpecifiedTests=false

It uses the test-infra Kafka container. Each test creates its own CamelContext, topics, group and transactionalId, and asserts the correct exactly-once outcome, so a failing test is a remaining problem. Lines starting with REPRO in target/camel-kafka-test.log hold what each test observed (attempts per record key, committed output, committed source offset, failures).

Test Scenario At f9d92812
o1..o9 first review round (rollbackOnly, removeHeaders, split + aggregation, seda after the producer, DLC without redelivery, filter, producer without exactlyOnce, startup rejections, wireTap next to a failing send) pass
n1, n2 Kafka DLQ topic, plain or exactly-once endpoint (point 1) fail: offset committed, DLQ topic empty
n3 DLC with maximumRedeliveries(2) pass (record skipped, DLQ once)
n4, n5 seda or wireTap as the only path to Kafka (point 2) fail: offset committed, output empty
n6 split fan-out, each child sends (point 3) fail: replays forever
n7 two routes sharing one sink URI / transactionalId pass, logs the fencing failure
n8 split + groupedBody(), then one send (the fan-out pattern that works) pass

n1, n2, n4, n5 and n6 should pass once the three points are addressed. Feel free to take whatever is useful into the PR's own ITs.

KafkaExactlyOnceReReviewIT.java
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.camel.component.kafka.integration;

import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.Processor;
import org.apache.camel.builder.AggregationStrategies;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.kafka.KafkaComponent;
import org.apache.camel.component.kafka.KafkaConstants;
import org.apache.camel.impl.DefaultCamelContext;
import org.apache.camel.spi.CamelEvent;
import org.apache.camel.support.EventNotifierSupport;
import org.apache.camel.test.infra.kafka.services.KafkaService;
import org.apache.camel.test.infra.kafka.services.KafkaServiceFactory;
import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

/**
 * Exactly-once scenarios from the review of PR #28730. Every test asserts the correct exactly-once outcome, so a
 * failing test is a remaining problem. Lines starting with "REPRO" in target/camel-kafka-test.log hold the observed
 * values (attempts per record key, committed output, committed source offset, failures).
 * <p>
 * o1..o9 cover the first review round and pass at f9d92812. n1..n8 are the second round: at f9d92812 n1, n2 (Kafka DLQ
 * topic), n4, n5 (seda/wireTap as the only path to Kafka) and n6 (split fan-out) fail; n3, n7 and n8 pass, and n7 logs
 * the fencing between two producers sharing one transactionalId.
 * <p>
 * Run with: mvn -pl components/camel-kafka-common,components/camel-kafka verify -Dit.test=KafkaExactlyOnceReReviewIT
 * -Dtest=skip -Dsurefire.failIfNoSpecifiedTests=false
 */
class KafkaExactlyOnceReReviewIT {

    private static final Logger LOG = LoggerFactory.getLogger(KafkaExactlyOnceReReviewIT.class);

    @RegisterExtension
    static KafkaService service = KafkaServiceFactory.createSingletonService();

    private final Map<String, AtomicInteger> attempts = new ConcurrentHashMap<>();
    private final List<String> failures = new CopyOnWriteArrayList<>();
    private final List<CamelContext> contexts = new ArrayList<>();
    private AdminClient admin;
    private String id;

    @BeforeEach
    void setUp(TestInfo info) {
        id = info.getTestMethod().orElseThrow().getName() + "-" + Long.toHexString(System.nanoTime());
        admin = AdminClient.create(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, boot()));
    }

    @AfterEach
    void tearDown() {
        for (CamelContext context : contexts) {
            context.stop();
        }
        admin.close();
    }

    // =================================================================================================================
    // Original findings: expected to be fixed
    // =================================================================================================================

    @Test
    void o1MarkRollbackOnlyReplaysTheRecord() throws Exception {
        String in = topics("in", "out")[0];
        AtomicBoolean rolledBack = new AtomicBoolean();
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt()).to(sink(id + "-out", "tx"))
                        .filter(e -> "m1".equals(key(e)) && rolledBack.compareAndSet(false, true))
                        .markRollbackOnly().end();
            }
        });
        produce(in, "m0", "m1", "m2");
        awaitCommitted(in, 3);
        List<String> out = readCommittedKeys(id + "-out");
        LOG.info("REPRO o1 rollbackOnly: attempts={} out={} committed={}", attempts, out, committed(id, in));
        assertEquals(List.of("m0", "m1", "m2"), out);
        assertEquals(2, attempts("m1"));
    }

    @Test
    void o2RemoveHeadersKeepsTheOffsets() throws Exception {
        String in = topics("in", "out")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt()).removeHeaders("*").to(sink(id + "-out", "tx"));
            }
        });
        produce(in, "m0", "m1");
        awaitCommitted(in, 2);
        List<String> out = readCommittedValues(id + "-out");
        LOG.info("REPRO o2 removeHeaders: attempts={} out={}", attempts, out);
        assertEquals(List.of("m0", "m1"), out);
    }

    @Test
    void o3SplitWithAggregationStrategySendsTheParent() throws Exception {
        String in = topics("in", "out")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt())
                        .split(body().tokenize(","), AggregationStrategies.useLatest()).transform(body().append("!")).end()
                        .to(sink(id + "-out", "tx"));
            }
        });
        produce(in, Map.of("m0", "a,b,c"));
        awaitCommitted(in, 1);
        List<String> out = readCommittedValues(id + "-out");
        LOG.info("REPRO o3 split+aggregation: attempts={} out={}", attempts, out);
        assertEquals(List.of("c!"), out);
        assertEquals(1, attempts("m0"));
    }

    @Test
    void o4SedaAfterTheProducerDoesNotWedgeTheRoute() throws Exception {
        String in = topics("in", "out")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt()).to(sink(id + "-out", "tx")).to("seda:audit");
                from("seda:audit").to("log:audit?level=DEBUG");
            }
        });
        produce(in, "m0", "m1", "m2");
        awaitCommitted(in, 3);
        List<String> out = readCommittedKeys(id + "-out");
        LOG.info("REPRO o4 seda after producer: attempts={} out={} failures={}", attempts, out, sample());
        assertEquals(List.of("m0", "m1", "m2"), out);
    }

    @Test
    void o5DeadLetterChannelWithoutRedeliverySkipsThePoisonRecord() throws Exception {
        String in = topics("in", "out")[0];
        AtomicInteger dlq = new AtomicInteger();
        start(new RouteBuilder() {
            @Override
            public void configure() {
                errorHandler(deadLetterChannel("direct:dlq"));
                from("direct:dlq").process(e -> dlq.incrementAndGet());
                from(source(in)).routeId(id).process(countAttempt())
                        .to(sink(id + "-out", "tx") + "&maxRequestSize=1024");
            }
        });
        produce(in, poisonRecords());
        awaitCommitted(in, 3);
        List<String> out = readCommittedKeys(id + "-out");
        LOG.info("REPRO o5 DLC no redelivery: attempts={} dlq={} out={}", attempts, dlq.get(), out);
        assertEquals(List.of("small-0", "small-2"), out);
        assertEquals(1, dlq.get());
    }

    @Test
    void o6FilteredRecordsCommitTheirOffsets() throws Exception {
        String in = topics("in", "out")[0];
        CamelContext context = start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt())
                        .filter(header(KafkaConstants.KEY).startsWith("m")).to(sink(id + "-out", "tx"));
            }
        });
        produce(in, "m0", "skip1", "skip2", "skip3");
        awaitCommitted(in, 4);
        context.getRouteController().stopRoute(id);
        context.getRouteController().startRoute(id);
        produce(in, "m4");
        awaitCommitted(in, 5);
        LOG.info("REPRO o6 filter: attempts={}", attempts);
        assertEquals(1, attempts("skip1"), "not consumed again after the restart");
    }

    @Test
    void o7ProducerWithoutExactlyOnceIsRejectedAndTheRecordReplays() throws Exception {
        String in = topics("in", "out")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt()).to("kafka:" + id + "-out?transacted=true");
            }
        });
        produce(in, "m0");
        await().atMost(60, TimeUnit.SECONDS).until(() -> attempts("m0") >= 2);
        LOG.info("REPRO o7 producer without exactlyOnce: attempts={} committed={} out={} failures={}", attempts,
                committed(id, in), readCommittedKeys(id + "-out"), sample());
        assertEquals(null, committed(id, in));
        assertTrue(readCommittedKeys(id + "-out").isEmpty());
    }

    @Test
    void o8RepositioningOptionsAreRejectedAtStartup() throws Exception {
        String in = topics("in")[0];
        for (String extra : List.of("&seekTo=BEGINNING", "&seekTo=END", "&batching=true", "&consumersCount=2")) {
            CamelContext context = newContext();
            context.addRoutes(new RouteBuilder() {
                @Override
                public void configure() {
                    from(source(in) + extra).to(sink(id + "-out", "tx"));
                }
            });
            Exception e = assertThrows(Exception.class, context::start, extra);
            LOG.info("REPRO o8 {} rejected: {}", extra, rootMessage(e));
            context.stop();
        }
    }

    @Test
    void o9WireTapNextToTheMainSendCannotSkipAFailedRecord() throws Exception {
        String in = topics("in", "out", "audit")[0];
        AtomicBoolean failed = new AtomicBoolean();
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt())
                        .wireTap("direct:audit")
                        .to(sink(id + "-out", "tx"))
                        .delay(1000)
                        .process(failOnce("m1", failed));
                from("direct:audit").to(sink(id + "-audit", "audit"));
            }
        });
        produce(in, "m0", "m1", "m2");
        awaitCommitted(in, 3);
        List<String> out = readCommittedKeys(id + "-out");
        LOG.info("REPRO o9 wireTap next to main send: attempts={} out={} audit={} failures={}", attempts, out,
                readCommittedKeys(id + "-audit"), sample());
        assertEquals(List.of("m0", "m1", "m2"), out);
    }

    // =================================================================================================================
    // New probes
    // =================================================================================================================

    /** A Kafka DLQ topic is the usual dead letter channel of a Kafka route. */
    @Test
    void n1DeadLetterChannelToAKafkaTopic() throws Exception {
        deadLetterToKafka("kafka:" + id + "-dlq");
    }

    @Test
    void n2DeadLetterChannelToAnExactlyOnceKafkaTopic() throws Exception {
        deadLetterToKafka("kafka:" + id + "-dlq?exactlyOnce=true&transactionalId=" + id + "-dlq-tx");
    }

    private void deadLetterToKafka(String dlqUri) throws Exception {
        String in = topics("in", "out", "dlq")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                errorHandler(deadLetterChannel(dlqUri));
                from(source(in)).routeId(id).process(countAttempt())
                        .to(sink(id + "-out", "tx") + "&maxRequestSize=1024");
            }
        });
        produce(in, poisonRecords());
        boolean committed3 = awaitQuietly(() -> Long.valueOf(3).equals(committed(id, in)), 45);
        List<String> out = readCommittedKeys(id + "-out");
        List<String> dlq = readCommittedKeys(id + "-dlq");
        LOG.info("REPRO {}: dlqUri={} committedOffset={} out={} dlqTopic={} attempts={} failures={}", id, dlqUri,
                committed(id, in), out, dlq, attempts, sample());
        if (committed3) {
            assertTrue(dlq.contains("big-1"), "the offset moved past big-1, so it must be in the DLQ topic: " + dlq);
        } else {
            fail("the route never moved past big-1: attempts=" + attempts);
        }
    }

    /** A dead letter channel usually redelivers a few times first. */
    @Test
    void n3DeadLetterChannelWithRedeliveries() throws Exception {
        String in = topics("in", "out")[0];
        AtomicInteger dlq = new AtomicInteger();
        start(new RouteBuilder() {
            @Override
            public void configure() {
                errorHandler(deadLetterChannel("direct:dlq").maximumRedeliveries(2).redeliveryDelay(0));
                from("direct:dlq").process(e -> dlq.incrementAndGet());
                from(source(in)).routeId(id).process(countAttempt())
                        .to(sink(id + "-out", "tx") + "&maxRequestSize=1024");
            }
        });
        produce(in, poisonRecords());
        boolean committed3 = awaitQuietly(() -> Long.valueOf(3).equals(committed(id, in)), 45);
        LOG.info("REPRO n3 DLC maximumRedeliveries(2): committed3={} committedOffset={} attempts={} dlq={} out={}"
                 + " failures={}",
                committed3, committed(id, in), attempts, dlq.get(), readCommittedKeys(id + "-out"), sample());
        assertTrue(committed3, "the poison record is never skipped: attempts=" + attempts + " dlq=" + dlq.get());
        assertEquals(1, dlq.get());
    }

    /** The only Kafka send is in a SEDA route. */
    @Test
    void n4SedaIsTheOnlyPathToKafka() throws Exception {
        onlyKafkaPathIsACopy(true);
    }

    /** The only Kafka send is behind a wireTap. */
    @Test
    void n5WireTapIsTheOnlyPathToKafka() throws Exception {
        onlyKafkaPathIsACopy(false);
    }

    private void onlyKafkaPathIsACopy(boolean seda) throws Exception {
        String in = topics("in", "out")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                if (seda) {
                    from(source(in)).routeId(id).process(countAttempt()).to("seda:out");
                    from("seda:out").to(sink(id + "-out", "tx"));
                } else {
                    from(source(in)).routeId(id).process(countAttempt()).wireTap("direct:out").to("log:main?level=DEBUG");
                    from("direct:out").to(sink(id + "-out", "tx"));
                }
            }
        });
        produce(in, "m0", "m1", "m2");
        boolean committed3 = awaitQuietly(() -> Long.valueOf(3).equals(committed(id, in)), 30);
        List<String> out = readCommittedKeys(id + "-out");
        LOG.info("REPRO {}: committedOffset={} out={} attempts={} failures={}", id, committed(id, in), out, attempts,
                sample());
        if (committed3) {
            assertEquals(List.of("m0", "m1", "m2"), out, "offsets committed, so the outputs must exist");
        }
    }

    /** One input record fans out into several output records. */
    @Test
    void n6SplitFanOutToKafka() throws Exception {
        String in = topics("in", "out")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt())
                        .split(body().tokenize(",")).to(sink(id + "-out", "tx")).end();
            }
        });
        produce(in, Map.of("m0", "a,b,c"));
        boolean committed1 = awaitQuietly(() -> Long.valueOf(1).equals(committed(id, in)), 30);
        List<String> out = readCommittedValues(id + "-out");
        LOG.info("REPRO n6 split fan-out: committed1={} committedOffset={} out={} attempts={} failures={}", committed1,
                committed(id, in), out, attempts, sample());
        if (committed1) {
            assertEquals(List.of("a", "b", "c"), out, "offset committed, so every split output must exist once");
        }
    }

    /** Workaround for the fan-out: one send of an iterable body, so every record is in one transaction. */
    @Test
    void n8FanOutAsOneIterableSend() throws Exception {
        String in = topics("in", "out")[0];
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in)).routeId(id).process(countAttempt())
                        .split(body().tokenize(","), AggregationStrategies.groupedBody()).transform(body().append("!")).end()
                        .to(sink(id + "-out", "tx"));
            }
        });
        produce(in, Map.of("m0", "a,b,c"));
        boolean committed1 = awaitQuietly(() -> Long.valueOf(1).equals(committed(id, in)), 30);
        List<String> out = readCommittedValues(id + "-out");
        LOG.info("REPRO n8 fan-out as one iterable send: committed1={} out={} attempts={} failures={}", committed1, out,
                attempts, sample());
        assertTrue(committed1);
        assertEquals(List.of("a!", "b!", "c!"), out);
    }

    /** Two routes write to the same sink endpoint URI, so to the same transactionalId. */
    @Test
    void n7TwoRoutesShareOneSinkUri() throws Exception {
        String in1 = topics("in1", "in2", "out")[0];
        String in2 = id + "-in2";
        String sink = sink(id + "-out", "shared");
        start(new RouteBuilder() {
            @Override
            public void configure() {
                from(source(in1, id + "-g1")).routeId(id + "-r1").process(countAttempt()).to(sink);
                from(source(in2, id + "-g2")).routeId(id + "-r2").process(countAttempt()).to(sink);
            }
        });
        String[] a = new String[10];
        String[] b = new String[10];
        for (int i = 0; i < 10; i++) {
            a[i] = "a" + i;
            b[i] = "b" + i;
        }
        long start = System.currentTimeMillis();
        produce(in1, a);
        produce(in2, b);
        boolean drained = awaitQuietly(() -> readCommittedKeys(id + "-out").size() >= 20, 90);
        List<String> out = readCommittedKeys(id + "-out");
        long fenced = failures.stream().filter(f -> f.contains("Fenced") || f.contains("fenced")).count();
        LOG.info("REPRO n7 shared transactionalId: drained={} in {} ms out={} (distinct {}) attempts={} fencedFailures={}"
                 + " failures={}",
                drained, System.currentTimeMillis() - start, out.size(), out.stream().distinct().count(),
                attempts.values().stream().mapToInt(AtomicInteger::get).sum(), fenced, sample());
        assertTrue(drained, "not all records delivered");
        assertEquals(20, out.size(), "exactly once");
    }

    // =================================================================================================================
    // helpers
    // =================================================================================================================

    private static String boot() {
        return service.getBootstrapServers();
    }

    private String source(String topic) {
        return source(topic, id);
    }

    private static String source(String topic, String group) {
        return "kafka:" + topic + "?groupId=" + group + "&exactlyOnce=true&autoOffsetReset=earliest"
               + "&autoCommitEnable=false&allowManualCommit=true&breakOnFirstError=true&pollTimeoutMs=1000";
    }

    private String sink(String topic, String txSuffix) {
        return "kafka:" + topic + "?exactlyOnce=true&transactionalId=" + id + "-" + txSuffix + "&requestRequiredAcks=-1";
    }

    private static Map<String, String> poisonRecords() {
        Map<String, String> records = new LinkedHashMap<>();
        records.put("small-0", "small");
        records.put("big-1", "x".repeat(4000));
        records.put("small-2", "small");
        return records;
    }

    private CamelContext newContext() {
        CamelContext context = new DefaultCamelContext();
        KafkaComponent kafka = new KafkaComponent(context);
        kafka.getConfiguration().setBrokers(boot());
        context.addComponent("kafka", kafka);
        context.getManagementStrategy().addEventNotifier(new EventNotifierSupport() {
            @Override
            public void notify(CamelEvent event) {
                if (event instanceof CamelEvent.ExchangeFailedEvent failed) {
                    failures.add(String.valueOf(failed.getExchange().getException()));
                }
            }
        });
        return context;
    }

    private CamelContext start(RouteBuilder routes) throws Exception {
        CamelContext context = newContext();
        context.addRoutes(routes);
        context.start();
        contexts.add(context);
        return context;
    }

    private static String key(Exchange e) {
        return e.getMessage().getHeader(KafkaConstants.KEY, String.class);
    }

    private Processor countAttempt() {
        return e -> attempts.computeIfAbsent(key(e), k -> new AtomicInteger()).incrementAndGet();
    }

    private static Processor failOnce(String key, AtomicBoolean failed) {
        return e -> {
            if (key.equals(key(e)) && failed.compareAndSet(false, true)) {
                throw new IllegalStateException("failure injected by the test after the send");
            }
        };
    }

    private int attempts(String key) {
        AtomicInteger count = attempts.get(key);
        return count == null ? 0 : count.get();
    }

    private List<String> sample() {
        return failures.stream().distinct().limit(3).toList();
    }

    private static String rootMessage(Throwable t) {
        while (t.getCause() != null) {
            t = t.getCause();
        }
        return t.getMessage();
    }

    private static boolean awaitQuietly(Callable<Boolean> condition, int seconds) {
        try {
            await().atMost(seconds, TimeUnit.SECONDS).until(condition);
            return true;
        } catch (ConditionTimeoutException e) {
            return false;
        }
    }

    private void awaitCommitted(String topic, long offset) {
        await().atMost(60, TimeUnit.SECONDS).until(() -> Long.valueOf(offset).equals(committed(id, topic)));
    }

    /** Creates the topics id-name and returns their names. */
    private String[] topics(String... names) throws Exception {
        String[] topics = new String[names.length];
        try (AdminClient client = AdminClient.create(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, boot()))) {
            List<NewTopic> newTopics = new ArrayList<>();
            for (int i = 0; i < names.length; i++) {
                topics[i] = id + "-" + names[i];
                newTopics.add(new NewTopic(topics[i], 1, (short) 1));
            }
            client.createTopics(newTopics).all().get(30, TimeUnit.SECONDS);
        }
        return topics;
    }

    private static void produce(String topic, String... keys) {
        Map<String, String> records = new LinkedHashMap<>();
        for (String key : keys) {
            records.put(key, key);
        }
        produce(topic, records);
    }

    private static void produce(String topic, Map<String, String> records) {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, boot());
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
            records.forEach((k, v) -> producer.send(new ProducerRecord<>(topic, k, v)));
            producer.flush();
        }
    }

    private static List<String> readCommittedKeys(String topic) {
        return readCommitted(topic, true);
    }

    private static List<String> readCommittedValues(String topic) {
        return readCommitted(topic, false);
    }

    private static List<String> readCommitted(String topic, boolean keys) {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, boot());
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        props.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        List<String> result = new ArrayList<>();
        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
            List<TopicPartition> partitions = consumer.partitionsFor(topic, Duration.ofSeconds(10)).stream()
                    .map(p -> new TopicPartition(topic, p.partition())).toList();
            consumer.assign(partitions);
            consumer.seekToBeginning(partitions);
            Map<TopicPartition, Long> end = consumer.endOffsets(partitions);
            long deadline = System.currentTimeMillis() + 15000;
            while (System.currentTimeMillis() < deadline
                    && partitions.stream().anyMatch(tp -> consumer.position(tp) < end.get(tp))) {
                consumer.poll(Duration.ofMillis(200)).forEach(r -> result.add(keys ? r.key() : r.value()));
            }
        }
        return result;
    }

    private Long committed(String group, String topic) throws Exception {
        OffsetAndMetadata offset = admin.listConsumerGroupOffsets(group).partitionsToOffsetAndMetadata()
                .get(30, TimeUnit.SECONDS).get(new TopicPartition(topic, 0));
        return offset == null ? null : offset.offset();
    }
}

Claude Code on behalf of Croway

@ramu11

ramu11 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Croway for the review. I've pushed the updates in commit 833647702ac6 on branch CAMEL-24781.

Changes made:

  • Scoped the effective Kafka transactional ID by the owning Camel route ID in exactly-once mode. This prevents separate producer instances belonging to different routes but sharing the same Kafka endpoint from fencing each other.
  • Clarified the transactional ID configuration requirements in the component documentation, including stable route IDs and unique base IDs across runtime instances.
  • Updated the transaction synchronization, exactly-once metadata handling, and streaming processor logic, with additional unit and integration test coverage.
  • Corrected the DLQ regression-test setup so both DLQ URI branches use maxRequestSize=1024, ensuring the test exercises the intended failed-DLQ-delivery and source-record replay scenario.

Validation:

Ran:

mvn -pl components/camel-kafka install \
  -DskipTests=false \
  -DskipITs=false

Result: BUILD SUCCESS

Relevant test results:

  • KafkaExactlyOnceRegressionIT: 15 tests, 0 failures, 0 errors.
  • KafkaExactlyOnceReReviewIT: 17 tests, 0 failures, 0 errors.
  • KafkaExactlyOnceIT: 1 test, passed.
  • KafkaExactlyOnceRebalanceIT: 1 test, passed.
  • Unit test run: 42 tests, 0 failures, 0 errors.

The supplied review integration test was used for local validation and is not included in the commit.

All changes are committed and pushed. Could you please take another look?

@ramu11
ramu11 requested a review from Croway October 31, 2026 15:42

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR introduces a large and well-structured Kafka exactly-once feature. Two issues found.


Finding 1 — groupId mismatch throws wrong exception type

In createExactlyOnceMetadata(), when the source endpoint's groupId doesn't match the captured consumer group metadata, the code throws IllegalStateException instead of ClaimRejectedException. Every other rejection path in this method throws ClaimRejectedException, which is the correct type to signal a claim refusal.

The impact is behavioral: recordSendFailure() explicitly filters out ClaimRejectedException instances, so it does not record them as send failures. A plain IllegalStateException falls through that filter and gets recorded as a Kafka send failure instead. This causes KafkaTransactionSynchronization.onDone() to enter the offset-only commit path rather than the rollback-and-replay path — the wrong recovery behavior for a claim rejection.

The fix is one line: replace throw new IllegalStateException(reason) with throw new KafkaExactlyOnceMetadata.ClaimRejectedException(reason), consistent with every other rejection path in the same method.


Finding 2 — hasAsynchronousHandoff() doesn't detect dynamic routing constructs

containsAsynchronousNode() checks for wireTap, threads, and static to[seda:...] patterns, but misses dynamic routing constructs that can also hand off to an async endpoint: recipientList, routingSlip, and toD("seda:..."). The toD shortName is "toD", not "to", so the label check on line 216 won't match it. A route using toD("seda:my-queue") would pass the async-handoff check undetected, and the exactly-once claim could be incorrectly attempted on an async copy.

This is a lower-severity gap — the common cases are covered — but worth documenting at least (a @implNote or TODO in the method), or adding detection for the missing cases if they are expected to be used with exactly-once routes.


This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: Thanks @ramu11, the redelivery fix is right and well tested: a retry inside the open transaction is rejected, so no good output is aborted. One way to lose a record is still there: a non-Kafka failure handled by a DLC whose DLQ is a plain Kafka endpoint (KafkaProducer.java:733 + KafkaTransactionSynchronization.java:129). The new async-handoff check also makes filtered records replay forever in some routes. 4 smaller points.

Addressed:

  • Redelivery in the open transaction: rejected and marked rollback-only. The new test drives onDone and asserts abort, no offsets, no commit. (Small thing: its comment says rollback-only wins "even if the error handler handles"; in Camel the handled path clears rollback-only, which is why the DLC skip described in the doc works.)
  • The doc for a handled non-exactly-once destination.
  • The shallow retry test.

1. A non-Kafka failure plus a Kafka DLQ loses the record.

errorHandler(deadLetterChannel("kafka:dlq"));
from(eosSource).to(eosSink).process(e -> { throw new IllegalStateException("boom"); });
  1. No Kafka send failed, so isSecondaryKafkaDeadLetterSend is false.
  2. The DLQ producer is rejected by validateExactlyOnceDestination, which records the rejection as the first send failure (:733).
  3. The DLC handles its own new exception (deadLetterHandleNewException defaults to true).
  4. onDone takes the offset-only skip path: eosSink's output is aborted, the offset is committed, and the DLQ topic is empty.

With an exactly-once Kafka DLQ the same route hits rejectClaim and replays forever, with the DLQ attempted on each pass.

Could the validation rejection stop counting as a send failure (mark the claim rejected instead), and could a Kafka DLQ be allowed for any handled failure, not only after a Kafka send failure? An IT with this route would cover it.

2. hasAsynchronousHandoff scans the whole route model (KafkaRecordStreamingProcessor.java:183-213). getChildren() includes branches the exchange never took. So any route with a wireTap, threads() or to("seda:...") anywhere fails every record that no producer claimed. Example:

from(eosSource).filter(p).to(eosSink).end().wireTap("direct:audit");

Here the filtered records replay forever and the consumer is stuck, while the doc says filtered records commit their offset. The check also misses toD/recipientList/routingSlip, and it walks the route tree for every filtered record. Could we rely on rejectCopyClaim and the TO_ENDPOINT check plus the doc, or only look at the path the exchange actually took? A filter + wireTap test would pin it.

Smaller:

  • org.apache.camel.Route is fully qualified at KafkaRecordStreamingProcessor.java:192; please import it.
  • SECONDARY_SEND_FAILURE_PROPERTY / SUPPRESS_NEXT_SEND_FAILURE_PROPERTY could live in KafkaConstants like the other exactly-once properties.
  • The ITs cover a failed Kafka DLQ but not a successful one (record in the DLQ, offset skipped). Worth adding, since the doc recommends it.
  • groupId mismatch (KafkaProducer.java:802): throwing ClaimRejectedException would match the other rejection paths. Today onDone still rolls back because it checks isClaimRejected() first.

Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review — both prior gnodet-bot findings are addressed in commit a5700ed2. Two of davsclaus's open points remain unresolved.

Prior findings — status:

  • ✅ groupId mismatch now throws ClaimRejectedException (line 802) instead of IllegalStateException — consistent with every other rejection path. Test updated with assertNull(metadata.getSendFailure(), ...) verifying the rejection is not recorded as a Kafka send failure.
  • ✅ containsAsynchronousNode now detects recipientList, routingSlip, and toD[seda:...]. Unit tests added for each.

Remaining issues:

See inline comments for details on the two open correctness bugs from davsclaus's last round.


This review was generated by an AI agent, Hermès on behalf of @gnodet.

IllegalStateException failure = new IllegalStateException(
"A Kafka exactlyOnce source requires every Kafka destination producer to set exactlyOnce=true "
+ "and configure an explicit transactionalId");
KafkaTransactionSynchronization.recordSendFailure(exchange, failure);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Record loss: DLQ validation rejection counted as a Kafka send failure

validateExactlyOnceDestination records its own rejection as a Kafka send failure via recordSendFailure. This enables a silent record-loss path:

  1. Processing fails (non-Kafka exception, e.g. an IllegalStateException from a process() step).
  2. DLC routes the exchange to kafka:dlq (a plain Kafka endpoint, no exactlyOnce=true).
  3. validateExactlyOnceDestination fires: isSecondaryKafkaDeadLetterSend() returns false (no prior Kafka send failure), so it calls recordSendFailure and throws IllegalStateException.
  4. DLC handles its own new exception (deadLetterHandleNewException=true) — exchange.getException() is cleared.
  5. KafkaTransactionSynchronization.onDone(): sendFailure != null, no exception → takes commitOffsetsAfterHandledSendFailure → aborts the (successfully sent) eosSink output and commits only source offsets. DLQ topic is empty.

The record is silently lost. The offset-skip path was designed for a genuine Kafka send failure handled by the error handler. A DLQ configuration rejection is not a Kafka send failure. Fix: remove the recordSendFailure call from validateExactlyOnceDestination and instead mark the exactlyOnceMetadata claim as rejected (analogous to the existing rejectClaim pattern) so onDone takes the rollback path rather than the offset-skip path.

if (routeId == null) {
return false;
}
org.apache.camel.Route route = exchange.getContext().getRoute(routeId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Filtered records replay forever when any route branch contains a wireTap, recipientList, or routingSlip

containsAsynchronousNode(route.getRoute()) walks the entire route tree, including branches the exchange never took. For example:

from(eosSource).filter(predicate).to(eosSink).end().wireTap("direct:audit");

A filtered record (bypasses .to(eosSink)) reaches completeExactlyOnceProcessing with !metadata.isClaimed(). hasAsynchronousHandoff walks the whole route → finds wireTap → returns true → sets an exception → consumer replays the record forever.

With this commit, recipientList and routingSlip are now also unconditionally flagged as async. Any route containing those EIPs — regardless of whether the filtered record ever reached them — causes filtered records to replay forever. This is a regression for the filter + recipientList pattern.

The TO_ENDPOINT check at lines 184–186 is already the right approach (it reflects what the exchange actually did). The static route-tree walk is not. Fix options:

  1. Remove the tree walk entirely — rely on TO_ENDPOINT for seda: detection and add a flag that async dispatchers (wireTap, threads) set on the exchange when they actually fire.
  2. Only walk the route tree when TO_ENDPOINT is not set and limit detection to direct children of the route root (not deep children in branches).

Also: org.apache.camel.Route is fully qualified here; please add import org.apache.camel.Route; and use the simple name.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: Thanks @ramu11. a5700ed lands 4 minutes after my previous comment and applies gnodet-bot's suggestions; the groupId mismatch now throwing ClaimRejectedException is good. My previous comment still stands in full, and one change here makes its point 2 wider.

containsAsynchronousNode (KafkaRecordStreamingProcessor.java:196-219) now also flags recipientList, routingSlip and toD[seda:. The scan still walks the whole route model (getChildren() includes branches the exchange never took), so with this change any exactly-once route that has a recipientList or routingSlip anywhere fails, and replays forever, every record that no producer claimed (for example the records a filter drops). The new tests mock NamedNode, so they check the detection but not this false positive. Basing the decision on what the exchange actually did (TO_ENDPOINT, or a marker set when an async EIP actually runs) would fix both the old and the new cases; a filter + wireTap or filter + recipientList route test would pin it.

The other points of my previous comment are still open: the record loss with a Kafka DLQ (KafkaProducer.java:733), the FQCN at :192, the constant placement and the happy-path DLQ IT.


Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants