CAMEL-25011: camel-core - Saga EIP: keep the saga of an exchange in its copies - #26869
allthingssecurity wants to merge 2 commits into
Conversation
…ts copies Since CAMEL-23469 the saga id travels in the exchange's internal state, but the copy constructor of AbstractExchange, used by Exchange.copy(), did not copy it. The sub-exchanges of split, multicast, recipient list and wire tap are copies, so they only kept the saga through the Long-Running-Action header. Since CAMEL-24449 SagaProcessor reads that header only for saga services that support it, which the default InMemorySagaService does not. So a saga step reached through split, multicast, recipient list or wire tap no longer saw the saga: MANDATORY failed with "Exchange is not part of a saga", REQUIRED started and completed a saga per sub-exchange even when the parent saga was compensated, and SUPPORTS ran outside of any saga. The copy constructor now copies the saga id, so a copy belongs to the same saga as the exchange it was copied from, as it did through the header before. The internal state is only set by Camel, so a message still cannot choose its saga (CAMEL-24449). Regression from the combination of CAMEL-23469 and CAMEL-24449. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts copies Since CAMEL-23469 the saga id travels in the exchange's internal state, but the copy constructor of AbstractExchange, used by Exchange.copy(), did not copy it. The sub-exchanges of split, multicast, recipient list and wire tap are copies, so they only kept the saga through the Long-Running-Action header. Since CAMEL-24449 SagaProcessor reads that header only for saga services that support it, which the default InMemorySagaService does not. So a saga step reached through split, multicast, recipient list or wire tap no longer saw the saga: MANDATORY failed with "Exchange is not part of a saga", REQUIRED started and completed a saga per sub-exchange even when the parent saga was compensated, and SUPPORTS ran outside of any saga. ExchangeHelper.copyResults also copies the saga id from a copy back to the original exchange, so after a multicast, recipient list, routing slip, failover or loop with copy, the original exchange lost its saga as well, and so did the seda consumer of the documented MANUAL completion example. The copy constructor now copies the saga id, so a copy belongs to the same saga as the exchange it was copied from, as it did through the header before. The internal state is only set by Camel, so a message still cannot choose its saga (CAMEL-24449). Regression from the combination of CAMEL-23469 and CAMEL-24449. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oscerd
left a comment
There was a problem hiding this comment.
Correct and minimal. The copy constructor now propagates sagaLongRunningAction from the parent, which is exactly the field CAMEL-23469 moved the saga id into, so Exchange.copy() finally matches what ExchangeHelper.copyResults already did — and split/multicast/recipient-list/wire-tap sub-exchanges once again carry their saga. That restores the three broken behaviours (MANDATORY failing, REQUIRED starting a per-sub-exchange saga, SUPPORTS running outside), and since the value is a String id, copying the reference is the right thing — a copy genuinely belongs to the same saga.
One coordination note: #26882 also touches AbstractExchange, but in the copy() method (the claim-check SafeCopyProperty handling) rather than this copy constructor, so the two don't overlap textually and can land together without reconciliation — just flagging since both are exchange-copy fixes from the same batch.
SagaExchangeCopyTest covers the split-into-saga path. LGTM.
(CI has not been triggered yet — fork PR awaiting a maintainer to approve the workflow run; I'll confirm green before it merges.)
This review was generated with AI assistance and reviewed/issued by the human operator. Claude Code on behalf of oscerd
Description
CAMEL-25011
Since CAMEL-23469, the saga id travels in the exchange's internal state (
getExchangeExtension().getSagaLongRunningAction()). But the copy constructor ofAbstractExchange, used byExchange.copy(), did not copy it. OnlyExchangeHelper.copyResultsdoes. The sub-exchanges of split, multicast, recipient list and wire tap are copies, so they kept the saga only through theLong-Running-Actionheader. Since CAMEL-24449,SagaProcessorreads that header only when the saga service supports it, andInMemorySagaServicedoes not.Together, a saga step reached through split, multicast, recipient list or wire tap no longer saw the saga of its exchange:
MANDATORYfailed withExchange is not part of a saga;REQUIREDstarted and completed a saga per sub-exchange, even when the parent saga was compensated;SUPPORTSran outside of any saga.Here the order was compensated, but the three reservations were confirmed.
The original exchange is affected too:
ExchangeHelper.copyResultscopies the saga id from a copy back to the original exchange, and the copy had none, so it cleared it. After a multicast (default aggregation), recipient list, routing slip, failover load balancer or loop with copy, a followingMANDATORYstep of the same route failed, and aREQUIREDstep silently started a saga of its own. The MANUAL completion example in the Saga EIP docs (aseda:operationCompletedroute withMANDATORY, thensaga:complete) is affected as well, because the seda consumer gets a copy.This change: the copy constructor copies the saga id, so a copy belongs to the same saga as the exchange it was copied from. This is how the header behaved before 4.22.1. The internal state is only set by Camel, so a message still cannot pick its saga through the header (CAMEL-24449 is kept).
Regression in 4.22.1 and 4.18.5, from the combination of CAMEL-23469 and CAMEL-24449.
Tests: new
SagaExchangeCopyTestunderInMemorySagaService: split intoMANDATORYsteps, multicast intoREQUIREDsteps, and aMANDATORYstep of the owner route after a multicast. The parent saga then fails, and every item must be compensated and none completed. Without the fix all three fail:With the fix they pass.
*Saga*,*Exchange*Test,*Multicast*,*Split*,*WireTap*,*RecipientList*in camel-core: 832 tests, 0 failures.Related: #26866 (CAMEL-25006) makes a
REQUIRED/SUPPORTSstep fail when its exchange carries the id of a saga that has ended. With this change, copies carry the saga id again, so a copy that reaches such a step after its saga ended (for example through an asynchronous wire tap or seda) fails as well, instead of starting a new saga. That is the behaviour before 4.22 too, when the ended coordinator failedbeginStep. The two PRs merge without conflicts.Found while modelling the in-memory saga in TLA+ (a participant that cannot see the saga behaves like one arriving after the saga ended, and breaks "no mixed outcome"), then reproduced against the real classes. The reproduction now gives
itemAction=3 compOwner=1 compItem=3 complItem=0for both MANDATORY and REQUIRED. That is the same result as a service that still reads the header.Target
mainbranch)Tracking
Apache Camel coding standards and style
mvn clean install -DskipTestslocally from root folder and I have committed all auto-generated changes.(I built and tested the affected modules, including the formatter and import-sort plugins. I did not run the full root build.)
AI-assisted contributions
Co-authored-bytrailers) and the PR description identifies the AI tool used.This PR was prepared with Claude Code (Claude Opus 5.5). The commit carries a
Co-Authored-Bytrailer.Claude Code on behalf of allthingssecurity
🤖 Generated with Claude Code