This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new abbf701df316 CAMEL-26770: camel-sql - JdbcCachedMessageIdRepository:
cache a key only after its row is committed (#29533)
abbf701df316 is described below
commit abbf701df316e7de8467e2b79760ad4942dc3856
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Nov 10 18:22:33 2026 +0530
CAMEL-26770: camel-sql - JdbcCachedMessageIdRepository: cache a key only
after its row is committed (#29533)
JdbcCachedMessageIdRepository.add put the key in its in-memory cache as soon
as super.add returned, before the transaction that inserted the row
committed. When the add joined a caller's transaction that later rolled
back,
the cache kept a key whose row was never stored, and later duplicates of
that
message were rejected. Cache the key only after the row is committed, and
document the extra rollback of a second uncommitted add.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/catalog/docs/sql-component.adoc | 9 ++
.../camel-sql/src/main/docs/sql-component.adoc | 9 ++
.../jdbc/JdbcCachedMessageIdRepository.java | 62 +++++++++-
...bcCachedMessageIdRepositoryTransactionTest.java | 133 +++++++++++++++++++++
4 files changed, 211 insertions(+), 2 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sql-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sql-component.adoc
index b1823c53b43c..9ba79a81b105 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sql-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sql-component.adoc
@@ -775,6 +775,15 @@ checked first before passing through to the original
implementation.
As with all cache implementations, there are considerations that should
be made with regard to stale data and your specific usage.
+In a transaction, a key is added to the cache only once the transaction
+that inserted its row commits, so a rolled-back add is never cached. A
+second add of the same key on the same node, made while the first is still
+uncommitted, therefore does not hit the cache: it reaches the primary key,
+gets a duplicate key error, and its transaction is marked rollback-only, so
+that exchange is rolled back once before its redelivery is filtered as a
+duplicate. The `JdbcMessageIdRepository`, and several nodes sharing the
+table, behave the same way.
+
=== Using the JDBC based aggregation repository
`JdbcAggregationRepository` is an `AggregationRepository` which on the
diff --git a/components/camel-sql/src/main/docs/sql-component.adoc
b/components/camel-sql/src/main/docs/sql-component.adoc
index b1823c53b43c..9ba79a81b105 100644
--- a/components/camel-sql/src/main/docs/sql-component.adoc
+++ b/components/camel-sql/src/main/docs/sql-component.adoc
@@ -775,6 +775,15 @@ checked first before passing through to the original
implementation.
As with all cache implementations, there are considerations that should
be made with regard to stale data and your specific usage.
+In a transaction, a key is added to the cache only once the transaction
+that inserted its row commits, so a rolled-back add is never cached. A
+second add of the same key on the same node, made while the first is still
+uncommitted, therefore does not hit the cache: it reaches the primary key,
+gets a duplicate key error, and its transaction is marked rollback-only, so
+that exchange is rolled back once before its redelivery is filtered as a
+duplicate. The `JdbcMessageIdRepository`, and several nodes sharing the
+table, behave the same way.
+
=== Using the JDBC based aggregation repository
`JdbcAggregationRepository` is an `AggregationRepository` which on the
diff --git
a/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcCachedMessageIdRepository.java
b/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcCachedMessageIdRepository.java
index 07c2bbb91f42..546d446bbfc7 100644
---
a/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcCachedMessageIdRepository.java
+++
b/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcCachedMessageIdRepository.java
@@ -24,6 +24,8 @@ import javax.sql.DataSource;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.support.TransactionSynchronization;
+import
org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
/**
@@ -81,7 +83,7 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
boolean added = super.add(key);
// only remember the key after the database insert succeeded,
otherwise a failed insert
// would leave the key cached and every redelivery would be rejected
as a duplicate
- cache.merge(key, 1, Integer::sum);
+ cacheAfterCommit(key);
return added;
}
@@ -99,15 +101,71 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
@Override
public boolean remove(String key) {
cache.remove(key);
- return super.remove(key);
+ cancelCacheAfterCommit(key);
+ boolean removed = super.remove(key);
+ // and again after the delete: an add of the key on another thread
whose transaction committed while this delete
+ // waited for its row lock may have cached it meanwhile. This narrows
the window in which the cache keeps a
+ // deleted key; it does not close it, as that add's afterCommit can
still run after this line.
+ cache.remove(key);
+ return removed;
}
@Override
public void clear() {
cache.clear();
+ cancelCacheAfterCommit(null);
hitCount.set(0);
missCount.set(0);
super.clear();
+ // and again after the delete, as in remove
+ cache.clear();
+ }
+
+ /**
+ * Caches the key once the transaction that inserted its row commits. In a
transaction of the caller that rolls back
+ * (for example a transacted route), the row is gone, and a cached key
would make the redelivery of the message a
+ * duplicate. Without a transaction with synchronization the key is cached
at once.
+ */
+ private void cacheAfterCommit(String key) {
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new
CacheAfterCommit(this, key));
+ } else {
+ cache.merge(key, 1, Integer::sum);
+ }
+ }
+
+ /**
+ * A remove of the key (or a clear when the key is null) later in the same
transaction cancels its caching after the
+ * commit.
+ */
+ private void cancelCacheAfterCommit(String key) {
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ for (TransactionSynchronization synchronization :
TransactionSynchronizationManager.getSynchronizations()) {
+ if (synchronization instanceof CacheAfterCommit pending &&
pending.repository == this
+ && (key == null || pending.key.equals(key))) {
+ pending.cancelled = true;
+ }
+ }
+ }
+ }
+
+ private static final class CacheAfterCommit implements
TransactionSynchronization {
+ private final JdbcCachedMessageIdRepository repository;
+ private final String key;
+ // set and read by the thread of the transaction
+ private boolean cancelled;
+
+ private CacheAfterCommit(JdbcCachedMessageIdRepository repository,
String key) {
+ this.repository = repository;
+ this.key = key;
+ }
+
+ @Override
+ public void afterCommit() {
+ if (!cancelled) {
+ repository.cache.merge(key, 1, Integer::sum);
+ }
+ }
}
public String getQueryAllString() {
diff --git
a/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcCachedMessageIdRepositoryTransactionTest.java
b/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcCachedMessageIdRepositoryTransactionTest.java
new file mode 100644
index 000000000000..656df26634b8
--- /dev/null
+++
b/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcCachedMessageIdRepositoryTransactionTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.processor.idempotent.jdbc;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A key is cached only once the transaction that inserted its row commits. In
a transaction of the caller that rolls
+ * back (for example a transacted route), the row is gone, so the redelivery
of the message must not be rejected as a
+ * duplicate from the cache.
+ */
+public class JdbcCachedMessageIdRepositoryTransactionTest {
+
+ private static final String PROCESSOR_NAME = "myProcessorName";
+ private static final String KEY = "KEY";
+
+ private EmbeddedDatabase dataSource;
+ private JdbcTemplate jdbcTemplate;
+ private JdbcCachedMessageIdRepository repository;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ dataSource = new EmbeddedDatabaseBuilder()
+ .setType(EmbeddedDatabaseType.H2)
+ .generateUniqueName(true)
+ .build();
+ jdbcTemplate = new JdbcTemplate(dataSource);
+ repository = new JdbcCachedMessageIdRepository(dataSource,
PROCESSOR_NAME);
+ repository.start();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ if (repository != null) {
+ repository.stop();
+ }
+ if (dataSource != null) {
+ dataSource.shutdown();
+ }
+ }
+
+ @Test
+ public void addInACallerTransactionThatRollsBackIsNotCached() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ status.setRollbackOnly();
+ });
+
+ assertEquals(0, rows());
+ assertFalse(repository.contains(KEY), "the key of a rolled back add
must not be a duplicate");
+ // the redelivery of the message is processed
+ assertTrue(repository.add(KEY));
+ assertEquals(1, rows());
+ }
+
+ @Test
+ public void addInACallerTransactionIsCachedAfterTheCommit() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ // not cached before the commit: contains reads the row
+ int misses = repository.getMissCount();
+ assertTrue(repository.contains(KEY));
+ assertEquals(misses + 1, repository.getMissCount());
+ });
+ int hits = repository.getHitCount();
+
+ assertEquals(1, rows());
+ assertTrue(repository.contains(KEY));
+ assertEquals(hits + 1, repository.getHitCount(), "the committed key
must be answered from the cache");
+ }
+
+ @Test
+ public void addIsCached() {
+ assertTrue(repository.add(KEY));
+ int hits = repository.getHitCount();
+
+ assertFalse(repository.add(KEY));
+ assertEquals(hits + 1, repository.getHitCount());
+ }
+
+ @Test
+ public void addAndRemoveInOneCallerTransactionIsNotCached() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ assertTrue(repository.remove(KEY));
+ });
+
+ assertEquals(0, rows());
+ assertFalse(repository.contains(KEY));
+ assertTrue(repository.add(KEY));
+ }
+
+ @Test
+ public void addAndClearInOneCallerTransactionIsNotCached() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ repository.clear();
+ });
+
+ assertEquals(0, rows());
+ assertFalse(repository.contains(KEY));
+ assertTrue(repository.add(KEY));
+ }
+
+ private int rows() {
+ return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM
CAMEL_MESSAGEPROCESSED WHERE processorName = ?",
+ Integer.class, PROCESSOR_NAME);
+ }
+}