ramu11 commented on PR #28730:
URL: https://github.com/apache/camel/pull/28730#issuecomment-6539141373

   > 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`)
   > ```java
   > 
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
   > ```java
   > 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`)
   > ```java
   > 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`)
   > ```java
   > 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`)
   > ```java
   > 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
   > ```java
   > 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
   > ```java
   > 
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.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to