You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@camel.apache.org by "Claus Ibsen (Jira)" <ji...@apache.org> on 2026/10/06 19:39:00 UTC

[jira] [Resolved] (CAMEL-24953) Idempotent Consumer with skipDuplicate=false: a duplicate that fails removes the message id that the original exchange added

     [ https://issues.apache.org/jira/browse/CAMEL-24953?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel ]

Claus Ibsen resolved CAMEL-24953.
---------------------------------
    Fix Version/s: 4.23.0
       Resolution: Fixed

The fix is merged on main, so it is in Camel 4.23.0:
* 64e17e003b0c CAMEL-24953: camel-core - Idempotent Consumer: do not let a routed duplicate remove or confirm the key
* c0291dc08f03 CAMEL-24953: camel-core - Make the new test class package-private
* f80b68052fce CAMEL-24953: camel-core - Use KeyValueIdempotentRepository in the new test and always release the blocked exchange

Resolving, as the ticket was not updated when the PR was merged.

_Claude Code on behalf of Claus Ibsen_

> Idempotent Consumer with skipDuplicate=false: a duplicate that fails removes the message id that the original exchange added
> ----------------------------------------------------------------------------------------------------------------------------
>
>                 Key: CAMEL-24953
>                 URL: https://issues.apache.org/jira/browse/CAMEL-24953
>             Project: Camel
>          Issue Type: Bug
>          Components: camel-core
>            Reporter: shashank
>            Priority: Minor
>             Fix For: 4.23.0
>
>
> With {{skipDuplicate(false)}}, a duplicate is not dropped. It is flagged with {{CamelDuplicateMessage=true}} and continues through the route, typically into a {{filter}} or {{choice}} that sends duplicates somewhere else. {{IdempotentConsumer.process}} registers the same {{IdempotentOnCompletion}} for a duplicate as for a new message, and the completion does not know that the exchange is a duplicate. If the duplicate fails, {{onFailedMessage}} calls {{idempotentRepository.remove(messageId)}} (with the default {{removeOnFailure=true}}). That deletes the entry the *original* exchange added, whether the original has already completed successfully or is still being processed.
> Result:
> * The next copy of the message is processed again, although the original was processed successfully.
> * In eager mode (the default), that next copy can be processed at the same time as the original, which is the case eager mode is meant to prevent.
> Reproducer (the same result with {{MemoryIdempotentRepository}}, {{KeyValueIdempotentRepository}} and {{FileIdempotentRepository}}):
> {code:java}
> from("direct:in")
>     .idempotentConsumer(header("id")).idempotentRepository(repo).skipDuplicate(false)
>     .choice()
>         .when(exchangeProperty(Exchange.DUPLICATE_MESSAGE).isEqualTo(true))
>             .process(e -> { throw new IllegalStateException("duplicate audit endpoint down"); })
>         .otherwise()
>             .process(e -> processed.incrementAndGet())
>     .end();
> {code}
> Sequential sends with header {{id=1}}:
> # m1 is processed. {{repo.contains("1")}} is true.
> # m2 is flagged as a duplicate, and the duplicate branch throws. After this, {{repo.contains("1")}} is *false*.
> # m3 is *processed as a new message* (processed=2, expected 1).
> Concurrent variant: m1 is held inside the route by a latch, m2 is a duplicate that fails, and m3 is processed as new while m1 is still in flight. Two exchanges with the same id are processed concurrently in eager mode.
> The javadoc describes the removal as a rollback of the failing exchange's own add. The {{IdempotentRepository}} javadoc says "On failure, remove(String) rolls back so the message can be redelivered", and the EIP page says the id is removed "if the Exchange failed, otherwise it stays there". A duplicate did not add the id, so there is nothing for it to roll back. For the same reason, a successful duplicate should not {{confirm}} (or, in non-eager mode, {{add}}) the original's key. That is a no-op in the core repositories, but not necessarily in others.
> Proposed fix: in {{IdempotentConsumer.process}}, only register the {{IdempotentOnCompletion}} for a new message. A duplicate that is not skipped ({{skipDuplicate=false}}) still gets the {{CamelDuplicateMessage=true}} property, still increments the duplicate counter and still calls {{onDuplicateMessage}}, and is then routed on with the original callback and no idempotent completion. So a duplicate never calls {{add}}, {{confirm}} or {{remove}} on the key. New messages behave as before, for every combination of {{eager}}, {{completionEager}} and {{removeOnFailure}}. A PR with the fix follows, with regression test {{IdempotentConsumerFailedDuplicateTest}} (sequential case with eager, completionEager and non-eager, plus the concurrent eager case); all four tests fail without the change.
> Found with a TLA+ model of the Idempotent Consumer EIP, then reproduced against 4.23.0-SNAPSHOT. The model with this change keeps the invariants "an id that was processed successfully is not processed again", "a remove only rolls back the exchange's own add" and "eager mode never processes two exchanges with the same id concurrently".
> Related question, non-eager mode: with {{eager(false)}}, two exchanges with the same id can both pass {{contains()}}, which is documented. If one succeeds (and adds the id) and the other then fails, the failing one removes the id, and a later copy is processed again. The {{IdempotentRepository}} javadoc does say that {{remove}} "is still called on failure" in non-eager mode. But a non-eager exchange that fails never added the id, so the call can only remove an entry that another exchange committed. Should {{removeOnFailure}} apply in non-eager mode at all? The fix leaves this as it is (it is documented behaviour).



--
This message was sent by Atlassian Jira
(v8.20.10#820010)