CAMEL-24144: InMemorySagaService - Remove coordinators from map after completion or compensation#24827
Conversation
… completion or compensation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Claus Ibsen <claus.ibsen@gmail.com>
|
🌟 Thank you for your contribution to the Apache Camel project! 🌟 🐫 Apache Camel Committers, please review the following items:
|
gnodet
left a comment
There was a problem hiding this comment.
Claude Code on behalf of gnodet
LGTM — straightforward memory leak fix.
The coordinators ConcurrentHashMap grows unboundedly because entries are never removed after reaching terminal state. The fix is minimal and correct:
-
Removal placement —
removeSaga()is called in.thenApply()aftercurrentStatus.set(COMPENSATED/COMPLETED), ensuring all step callbacks indoFinalize()have completed before the coordinator is evicted from the map. This ordering is important — any code that references the coordinator during finalization still has a valid reference. -
Package-private visibility —
removeSaga()is appropriately scoped since onlyInMemorySagaCoordinator(same package) needs to call it. -
Timeout path covered — timeout-triggered compensation flows through
doCompensate(), so the removal happens there too. -
SagaTimeoutTestupdate — correctly accounts for the race where a late-joining participant callsbegin()after the coordinator has already been removed from the map, resulting in "Exchange is not part of a saga" (null coordinator) instead of "Cannot begin: status is COMPENSATING/COMPENSATED". -
Test coverage — both terminal paths (completion and compensation) are tested with Awaitility to handle the async nature of the
.thenApply()chain.
Clean fix for a real production concern in long-running applications.
oscerd
left a comment
There was a problem hiding this comment.
LGTM — real memory-leak fix. InMemorySagaService kept every coordinator in its coordinators map for the CamelContext lifetime, so a long-running app accumulated one entry per saga forever. Adding removeSaga(sagaId) (coordinators.remove(...)) and calling it after both the completion and compensation terminal transitions bounds the map to in-flight sagas. remove is idempotent so the two call sites are harmless, and since it only fires after a terminal state there's no risk of dropping a still-active coordinator.
Good test coverage: SagaCoordinatorCleanupTest asserts the coordinator is gone after both completion and compensation using await().atMost(5s).untilAsserted(...) (no Thread.sleep), and the SagaTimeoutTest assertion is correctly widened to also accept "Exchange is not part of a saga" — the expected outcome now that a completed saga's coordinator is removed rather than lingering in a COMPENSATED state. CI is green.
Reviewed with Claude Code on behalf of Andrea Cosentino. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.
|
🧪 CI tested the following changed modules:
🔬 Scalpel shadow comparison — Scalpel: 550 tested, 29 compile-only — current: 550 all testedMaveniverse Scalpel detected 579 affected modules (current approach: 550).
|
Summary
Claude Code on behalf of davsclaus
InMemorySagaService.newSaga()puts every new coordinator into thecoordinatorsConcurrentHashMap, but nothing ever removes entries — not oncomplete(), not oncompensate(), not on timeout. This causes unbounded memory growth in long-running applications.Fix:
removeSaga(String)method toInMemorySagaServiceInMemorySagaCoordinatornow callsremoveSaga()after reaching terminal state (COMPENSATED or COMPLETED) indoCompensate()anddoComplete().thenApply()block after the finalization chain completes, so all step callbacks have finished before the coordinator is removedTest plan
SagaCoordinatorCleanupTestverifying coordinators are removed from the map after both completion and compensationSagaTimeoutTestto accept the additional valid outcome when a coordinator is removed before a late joiner tries to access it🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com