Skip to content

CAMEL-25011: camel-core - Saga EIP: keep the saga of an exchange in its copies - #26869

Open
allthingssecurity wants to merge 2 commits into
apache:mainfrom
allthingssecurity:camel-saga-exchange-copy
Open

allthingssecurity wants to merge 2 commits into
apache:mainfrom
allthingssecurity:camel-saga-exchange-copy

Conversation

@allthingssecurity

Copy link
Copy Markdown
Contributor

Description

CAMEL-25011

Since CAMEL-23469, the saga id travels in the exchange's internal state (getExchangeExtension().getSagaLongRunningAction()). But the copy constructor of AbstractExchange, used by Exchange.copy(), did not copy it. Only ExchangeHelper.copyResults does. The sub-exchanges of split, multicast, recipient list and wire tap are copies, so they kept the saga only through the Long-Running-Action header. Since CAMEL-24449, SagaProcessor reads that header only when the saga service supports it, and InMemorySagaService does not.

Together, a saga step reached through split, multicast, recipient list or wire tap no longer saw the saga of its exchange:

  • 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;
  • SUPPORTS ran outside of any saga.
from("direct:order").saga().compensation("direct:cancelOrder")
    .split(body()).to("direct:reserveItem").end()
    .process(e -> { throw new IllegalStateException("payment declined"); });
from("direct:reserveItem").saga()      // REQUIRED
    .compensation("direct:releaseItem").completion("direct:confirmItem")
    .to("direct:reserve");

Here the order was compensated, but the three reservations were confirmed.

The original exchange is affected too: ExchangeHelper.copyResults copies 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 following MANDATORY step of the same route failed, and a REQUIRED step silently started a saga of its own. The MANUAL completion example in the Saga EIP docs (a seda:operationCompleted route with MANDATORY, then saga: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 SagaExchangeCopyTest under InMemorySagaService: split into MANDATORY steps, multicast into REQUIRED steps, and a MANDATORY step 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:

testSplitMandatoryStepsJoinSaga       AssertionError: mock://compensate-item Received message count. Expected: <3> but was: <0>
testMulticastRequiredStepsJoinSaga    AssertionError: mock://compensate-item Received message count. Expected: <2> but was: <0>
testStepAfterMulticastJoinsSaga       AssertionError: mock://compensate-item Received message count. Expected: <1> but was: <0>

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/SUPPORTS step 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 failed beginStep. 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=0 for both MANDATORY and REQUIRED. That is the same result as a service that still reads the header.

Target

  • I checked that the commit is targeting the correct branch (Camel 4 uses the main branch)

Tracking

  • If this is a large change, bug fix, or code improvement, I checked there is a JIRA issue filed for the change (usually before you start working on it).

Apache Camel coding standards and style

  • I checked that each commit in the pull request has a meaningful subject line and body.
  • I have run mvn clean install -DskipTests locally 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

  • If this PR includes AI-generated code, commits have proper co-authorship attribution (e.g., Co-authored-by trailers) 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-By trailer.

Claude Code on behalf of allthingssecurity

🤖 Generated with Claude Code

allthingssecurity and others added 2 commits September 24, 2026 22:11
…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 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.

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants