gnodet-bot commented on code in PR #29564:
URL: https://github.com/apache/camel/pull/29564#discussion_r4549007726


##########
components/camel-hazelcast/src/main/java/org/apache/camel/processor/aggregate/hazelcast/HazelcastAggregationRepository.java:
##########
@@ -358,27 +362,65 @@ public void setHazelcastInstance(HazelcastInstance 
hazelcastInstance) {
     public void remove(CamelContext camelContext, String key, Exchange 
exchange) {
         DefaultExchangeHolder holder = DefaultExchangeHolder.marshal(exchange, 
true, allowSerializedHeaders);
         if (optimistic) {
-            // put the exchange into the recoverable storage before removing 
it from the operational storage: the two
-            // maps cannot be updated atomically, and if the process stops in 
between, the group is sent twice
-            // instead of being lost
-            if (useRecovery) {
-                LOG.trace("Putting an exchange with ID {} for key {} into a 
recoverable storage in an optimistic manner.",
-                        exchange.getExchangeId(), key);
-                persistedCache.put(exchange.getExchangeId(), holder);
-                LOG.trace("Put an exchange with ID {} for key {} into a 
recoverable storage in an optimistic manner.",
-                        exchange.getExchangeId(), key);
-            }
             LOG.trace("Removing an exchange with ID {} for key {} in an 
optimistic manner.", exchange.getExchangeId(), key);
-            if (!cache.remove(key, holder)) {
-                LOG.error(
-                        "Optimistic locking failed for exchange with key {}: 
IMap#remove removed no Exchanges, while it's expected to remove one.",
-                        key);
-                if (useRecovery) {
-                    removeFromRecoverableStorage(exchange.getExchangeId(), 
key);
+            if (useRecovery) {
+                // Use a Hazelcast transaction to atomically apply the 
conditional remove and the recovery write.
+                // A plain cache.remove(key, holder) followed by 
persistedCache.put(...) has two failure modes:
+                //   1. The two steps cannot be fenced: a concurrent thread 
holds the same exchangeId (it read the
+                //      same stored entry), so it would delete the winning 
thread's recovery entry during its own
+                //      cleanup on the compare-and-remove failure 
(CAMEL-26796).
+                //   2. If the JVM stops or persistedCache.put() throws after 
a successful cache.remove(), the group
+                //      is in neither map (CAMEL-26663).
+                // TransactionalMap.remove(key, value) is a compare-and-delete 
that enrolls the entry in the
+                // transaction: the loser sees the entry still present (or 
already gone), so its remove returns
+                // false, and it never reaches the recovery write.
+                TransactionOptions tOpts = new TransactionOptions();
+                
tOpts.setTransactionType(TransactionOptions.TransactionType.ONE_PHASE);
+                TransactionContext tCtx = 
hazelcastInstance.newTransactionContext(tOpts);
+                try {
+                    tCtx.beginTransaction();
+
+                    TransactionalMap<String, DefaultExchangeHolder> tCache = 
tCtx.getMap(cache.getName());
+                    TransactionalMap<String, DefaultExchangeHolder> 
tPersistentCache = tCtx.getMap(persistedCache.getName());
+
+                    if (!tCache.remove(key, holder)) {
+                        tCtx.rollbackTransaction();
+                        LOG.error(
+                                "Optimistic locking failed for exchange with 
key {}: IMap#remove removed no Exchanges, while it's expected to remove one.",

Review Comment:
   🔧 **Nit:** The error message says `IMap#remove` but this code path calls 
`tCache.remove(key, holder)` on a `TransactionalMap`, not an `IMap`. The 
message in the `useRecovery=false` branch (line ~418) is correct since 
`cache.remove` is an `IMap` there.
   
   ```suggestion
                           LOG.error(
                                   "Optimistic locking failed for exchange with 
key {}: TransactionalMap#remove removed no Exchanges, while it's expected to 
remove one.",
                                   key);
   ```



##########
components/camel-hazelcast/src/test/java/org/apache/camel/processor/aggregate/hazelcast/HazelcastAggregationRepositoryOperationsTest.java:
##########
@@ -193,78 +192,156 @@ public void checkOptimisticPersistentRemove() throws 
Exception {
     }
 
     @Test
-    public void 
checkOptimisticRemoveKeepsTheGroupWhenTheRecoverableStorageFails() throws 
Exception {
+    public void checkOptimisticRemoveOfAStaleExchangeLeavesNothingToRecover() 
throws Exception {
         HazelcastAggregationRepository repo
-                = new 
HazelcastAggregationRepository("optimisticRemovePutFailsRepo", true, 
getFirstInstance());
+                = new 
HazelcastAggregationRepository("optimisticRemoveStaleRepo", true, 
getFirstInstance());
 
         try {
             repo.doStart();
 
-            final String key = "optimisticRemovePutFailsKey";
-            Exchange ex = createExchangeWithBody("This group must not be 
lost.");
+            final String key = "optimisticRemoveStaleKey";
+            Exchange ex = createExchangeWithBody("The current group.");
             repo.add(context(), key, null, ex);
+            Exchange staleEx = createExchangeWithBody("A stale group.");
 
-            IMap<String, DefaultExchangeHolder> failingPersistedCache = 
mockMap();
-            when(failingPersistedCache.put(anyString(), any())).thenThrow(new 
HazelcastException("Simulated failure"));
-            repo.persistedCache = failingPersistedCache;
-
-            assertThrows(HazelcastException.class, () -> 
repo.remove(context(), key, ex));
+            assertThrows(OptimisticLockingException.class, () -> 
repo.remove(context(), key, staleEx));
 
-            Exchange getBackEx = repo.get(context(), key);
-            assertNotNull(getBackEx, "the group must still be in the 
repository after a failed remove");
-            assertEquals(ex.getIn().getBody(), getBackEx.getIn().getBody());
+            assertTrue(repo.scan(context()).isEmpty(), "a failed remove must 
not leave an exchange to recover");
+            assertEquals(ex.getIn().getBody(), repo.get(context(), 
key).getIn().getBody());
         } finally {
             repo.doStop();
         }
     }
 
+    /**
+     * Regression test for CAMEL-26395 / CAMEL-26663: when the transaction 
that atomically removes the entry from the
+     * operational cache and writes the recovery entry is rolled back (due to 
any failure), the group must still be
+     * present in the operational map so it can be retried — it must not be 
silently lost.
+     * <p>
+     * The original test for this guarantee (from CAMEL-26663) used Mockito to 
inject a mock {@code persistedCache} IMap
+     * that throws on {@code put}. With the transaction-based fix for 
CAMEL-26796, the code path goes through a
+     * {@link TransactionalMap} obtained from the {@code TransactionContext}, 
which bypasses a mock on the plain
+     * {@code persistedCache} field. This test therefore uses a subclass that 
simulates a failure mid-transaction by
+     * rolling back explicitly after the conditional remove succeeds, 
confirming that the transaction rollback restores
+     * the entry to the operational map.
+     */
     @Test
-    public void checkOptimisticRemoveOfAStaleExchangeLeavesNothingToRecover() 
throws Exception {
+    public void checkOptimisticRemoveKeepsTheGroupWhenTheTransactionFails() 
throws Exception {
+        final String persistentRepoName = 
"optimisticRemoveTxFailsRepo-completed";
         HazelcastAggregationRepository repo
-                = new 
HazelcastAggregationRepository("optimisticRemoveStaleRepo", true, 
getFirstInstance());
+                = new 
RollbackAfterRemoveRepository("optimisticRemoveTxFailsRepo", 
persistentRepoName, getFirstInstance());
 
         try {
             repo.doStart();
 
-            final String key = "optimisticRemoveStaleKey";
-            Exchange ex = createExchangeWithBody("The current group.");
+            final String key = "optimisticRemoveTxFailsKey";
+            Exchange ex = createExchangeWithBody("This group must not be 
lost.");
             repo.add(context(), key, null, ex);
-            Exchange staleEx = createExchangeWithBody("A stale group.");
 
-            assertThrows(OptimisticLockingException.class, () -> 
repo.remove(context(), key, staleEx));
+            // The subclass rolls the transaction back after the conditional 
remove, simulating a commit failure.
+            assertThrows(RuntimeCamelException.class, () -> 
repo.remove(context(), key, ex));
 
-            assertTrue(repo.scan(context()).isEmpty(), "a failed remove must 
not leave an exchange to recover");
-            assertEquals(ex.getIn().getBody(), repo.get(context(), 
key).getIn().getBody());
+            // The group must still be in the operational map — the rollback 
restored it.
+            Exchange getBackEx = repo.get(context(), key);
+            assertNotNull(getBackEx, "the group must still be in the 
repository after a failed transaction");
+            assertEquals(ex.getIn().getBody(), getBackEx.getIn().getBody());
+
+            // No recovery entry must have been written — the transaction was 
rolled back before the put.
+            assertTrue(repo.scan(context()).isEmpty(),
+                    "no recovery entry must have been written after a 
rolled-back transaction");
         } finally {
             repo.doStop();
         }
     }
 
+    /**
+     * Regression test for CAMEL-26796: in the optimistic remove() path, two 
threads racing on the same group both hold
+     * the same exchange (same exchangeId). The losing thread must not corrupt 
the winning thread's recovery entry.
+     * <p>
+     * Before the fix, the cleanup call on the losing thread would delete the 
winning thread's recovery entry (they both
+     * had the same exchangeId), making the group irrecoverable on a 
downstream failure. The fix uses a Hazelcast
+     * transaction to atomically apply the conditional remove and the recovery 
write, so the loser's transaction sees
+     * the entry already removed and rolls back without touching the 
persistent map.
+     * <p>
+     * Scenario: add a group, get it twice as {@code a} and {@code b} (same 
content, same exchangeId), remove(a)
+     * succeeds, remove(b) throws {@link OptimisticLockingException}. After 
the failed remove, recover(exchangeId) must
+     * still return the group so that downstream failures can be recovered.
+     * <p>
+     * Note: the transaction also closes the crash-between-two-steps window 
that CAMEL-26663 originally fixed: if the
+     * JVM stops after the conditional remove but before the recovery write, 
the transaction is rolled back on restart
+     * and the group re-appears in the operational repository.

Review Comment:
   🔧 **Nit:** With `ONE_PHASE`, the uncommitted transaction is not rolled back 
on JVM restart — it is rolled back on the cluster side when the transaction 
owner leaves or the transaction times out. `"on restart"` is misleading.
   
   ```suggestion
        * Note: the transaction also closes the crash-between-two-steps window 
that CAMEL-26663 originally fixed: if the
        * JVM stops after the conditional remove but before commit, the 
uncommitted transaction is rolled back
        * automatically (via transaction timeout or owner departure) and the 
group re-appears in the operational repository.
   ```



-- 
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