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 4f2a45a68ab9 CAMEL-26868: camel-sql - JdbcCachedMessageIdRepository: a
key removed in a transaction of the caller is removed from the cache again when
it commits (#29669)
4f2a45a68ab9 is described below
commit 4f2a45a68ab92367973196f60d6bc7fb017330b4
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Nov 12 17:33:03 2026 +0530
CAMEL-26868: camel-sql - JdbcCachedMessageIdRepository: a key removed in a
transaction of the caller is removed from the cache again when it commits
(#29669)
* CAMEL-26868: camel-sql - JdbcCachedMessageIdRepository: a key removed in
a transaction of the caller is removed from the cache again when it commits
JdbcCachedMessageIdRepository.remove (and clear) removes the key from the
cache before and after the delete. In a transaction of the caller on the
same DataSource (or JTA), for example a direct remove or clear in a
transaction, or the idempotent consumer removing the key of a failure that
a dead letter channel handled inside an enclosing transaction that
commits, the delete is only committed later. Until then another thread
still sees the row: its add of the key is a duplicate and caches the key.
When the transaction commits, the row is gone but the key stays in the
cache, so every later add of that key is rejected as a duplicate without a
row, until the cache is reloaded.
remove (when it deleted a row) and clear now also remove the key (or
every key) from the cache when the transaction of the caller commits (a
transaction synchronization, as the add uses since CAMEL-26770). Without
such a transaction the delete has committed when remove returns, as
before. A remove and an add of the key in one transaction still leave the
key cached: the synchronizations run in the order they were registered.
This narrows the window, it does not close it: an add whose SELECT saw the
row before the commit and that caches the key after the eviction (without
a transaction of its own, or with one that commits later) still caches it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-26868: camel-sql - JdbcCachedMessageIdRepository: an add that read
the row before a remove or clear deleted it does not keep the key cached
Review of #29669: close the remaining window instead of a follow-up. An add
on
another thread whose SELECT saw the row before the delete committed, and
that
cached the key after the eviction ran, left the key cached without a row.
- A counter of evictions: every eviction after a delete (in remove when it
deleted a row, in clear, and when a transaction of the caller that deleted
commits) bumps it before the key leaves the cache. An add reads it before
its
SELECT, merges the key, and removes it again if the counter moved: either
the
eviction runs after the merge, or the add sees the change. An add that
runs
alongside a remove or clear of any key is not cached (one more SELECT
later).
- The cache hit of add is one computeIfPresent, so a key evicted between the
read and the increment is not put back.
- An add of a key later in the same transaction as its remove cancels the
eviction of that remove at the commit (the row is there again).
- reload() starts with an empty cache if a remove or clear ran while it read
the keys.
- sql-component.adoc: one sentence on remove and clear in a transaction.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/catalog/docs/sql-component.adoc | 4 +
.../camel-sql/src/main/docs/sql-component.adoc | 4 +
.../jdbc/JdbcCachedMessageIdRepository.java | 124 +++++++++++++--
...bcCachedMessageIdRepositoryTransactionTest.java | 166 +++++++++++++++++++++
4 files changed, 283 insertions(+), 15 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 9ba79a81b105..8bfd8c543df7 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
@@ -784,6 +784,10 @@ 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.
+A `remove` or `clear` in a transaction takes the key (or the keys) out of
+the cache again when that transaction commits, and an add that read the
+row before it was deleted does not keep the key in the cache.
+
=== 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 9ba79a81b105..8bfd8c543df7 100644
--- a/components/camel-sql/src/main/docs/sql-component.adoc
+++ b/components/camel-sql/src/main/docs/sql-component.adoc
@@ -784,6 +784,10 @@ 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.
+A `remove` or `clear` in a transaction takes the key (or the keys) out of
+the cache again when that transaction commits, and an add that read the
+row before it was deleted does not keep the key in the cache.
+
=== 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 546d446bbfc7..0ae7cc6fb82b 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
@@ -19,6 +19,7 @@ package org.apache.camel.processor.idempotent.jdbc;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
import javax.sql.DataSource;
@@ -39,6 +40,9 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
private volatile Map<String, Integer> cache = new ConcurrentHashMap<>();
private final AtomicInteger hitCount = new AtomicInteger();
private final AtomicInteger missCount = new AtomicInteger();
+ // bumped by every eviction after a delete, before the key leaves the
cache: an add that read the row before the
+ // delete committed sees the change after caching the key, and removes it
again
+ private final AtomicLong evictions = new AtomicLong();
private String queryAllString = DEFAULT_QUERY_ALL_STRING;
public JdbcCachedMessageIdRepository() {
@@ -73,17 +77,18 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
@Override
public boolean add(final String key) {
- Integer previousValue = cache.getOrDefault(key, 0);
- if (previousValue != 0) {
- cache.merge(key, 1, Integer::sum);
+ // one atomic step: a key evicted meanwhile by a remove on another
thread is not put back
+ if (cache.computeIfPresent(key, (k, count) -> count + 1) != null) {
hitCount.incrementAndGet();
return false;
}
missCount.incrementAndGet();
+ // read before the row is: a remove or clear that deletes it after
this read changes the counter
+ long seen = evictions.get();
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
- cacheAfterCommit(key);
+ cacheAfterCommit(key, seen);
return added;
}
@@ -103,10 +108,15 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
cache.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);
+ if (removed) {
+ // and again after the delete: an add of the key on another thread
that read the row before the delete
+ // committed may have cached it meanwhile, or caches it later and
then sees the changed counter (see cacheKey)
+ evict(key);
+ // in a transaction of the caller the row is deleted when it
commits: until then another thread still sees
+ // the row and caches the key (its add is a duplicate), so the key
is removed from the cache again after the
+ // commit
+ evictAfterCommit(key);
+ }
return removed;
}
@@ -117,8 +127,9 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
hitCount.set(0);
missCount.set(0);
super.clear();
- // and again after the delete, as in remove
- cache.clear();
+ // and again after the delete, and after the commit of a transaction
of the caller, as in remove
+ evict(null);
+ evictAfterCommit(null);
}
/**
@@ -126,11 +137,41 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
* (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) {
+ private void cacheAfterCommit(String key, long seen) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
- TransactionSynchronizationManager.registerSynchronization(new
CacheAfterCommit(this, key));
+ // a remove of the key earlier in the same transaction does not
evict it at the commit: the row is there again
+ cancelEvictAfterCommit(key);
+ TransactionSynchronizationManager.registerSynchronization(new
CacheAfterCommit(this, key, seen));
} else {
- cache.merge(key, 1, Integer::sum);
+ cacheKey(key, seen);
+ }
+ }
+
+ /**
+ * Caches the key, unless a remove or clear evicted keys since the add
read the row: that delete may have removed
+ * the row, and its eviction may have run before this merge. Either the
eviction runs after the merge and removes
+ * the key, or it has changed the counter before the check below, so a
read of the row before a delete committed
+ * does not leave its key in the cache (with read committed: a snapshot of
a transaction of the caller older than
+ * the counter is not covered). An add that runs alongside a remove or
clear of any key is therefore not cached,
+ * which costs one SELECT later.
+ */
+ private void cacheKey(String key, long seen) {
+ cache.merge(key, 1, Integer::sum);
+ if (evictions.get() != seen) {
+ cache.remove(key);
+ }
+ }
+
+ /**
+ * Removes the key (or every key when the key is null) from the cache
after its row was deleted. The counter changes
+ * first, so an add that cached the key meanwhile either sees the change
or is removed here.
+ */
+ private void evict(String key) {
+ evictions.incrementAndGet();
+ if (key == null) {
+ cache.clear();
+ } else {
+ cache.remove(key);
}
}
@@ -149,21 +190,68 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
}
}
+ /**
+ * Removes the key (or every key when the key is null) from the cache
again once the transaction of the caller that
+ * deleted its row commits. Without a transaction with synchronization the
delete has committed already. When the
+ * transaction of the caller is of another transaction manager (another
DataSource, JMS), the delete committed in a
+ * transaction of its own already, and this only costs a cache miss.
+ */
+ private void evictAfterCommit(String key) {
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new
EvictAfterCommit(this, key));
+ }
+ }
+
+ /**
+ * An add of the key later in the same transaction cancels its eviction
after the commit, as the row is there again
+ * when the transaction commits. The eviction of a clear is kept: the add
is then not cached, which costs one
+ * SELECT.
+ */
+ private void cancelEvictAfterCommit(String key) {
+ for (TransactionSynchronization synchronization :
TransactionSynchronizationManager.getSynchronizations()) {
+ if (synchronization instanceof EvictAfterCommit pending &&
pending.repository == this
+ && key.equals(pending.key)) {
+ pending.cancelled = true;
+ }
+ }
+ }
+
+ private static final class EvictAfterCommit implements
TransactionSynchronization {
+ private final JdbcCachedMessageIdRepository repository;
+ private final String key;
+ // set and read by the thread of the transaction
+ private boolean cancelled;
+
+ private EvictAfterCommit(JdbcCachedMessageIdRepository repository,
String key) {
+ this.repository = repository;
+ this.key = key;
+ }
+
+ @Override
+ public void afterCommit() {
+ if (!cancelled) {
+ repository.evict(key);
+ }
+ }
+ }
+
private static final class CacheAfterCommit implements
TransactionSynchronization {
private final JdbcCachedMessageIdRepository repository;
private final String key;
+ private final long seen;
// set and read by the thread of the transaction
private boolean cancelled;
- private CacheAfterCommit(JdbcCachedMessageIdRepository repository,
String key) {
+ private CacheAfterCommit(JdbcCachedMessageIdRepository repository,
String key, long seen) {
this.repository = repository;
this.key = key;
+ this.seen = seen;
}
@Override
public void afterCommit() {
if (!cancelled) {
- repository.cache.merge(key, 1, Integer::sum);
+ repository.cacheKey(key, seen);
}
}
}
@@ -185,6 +273,8 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
}
public void reload() {
+ // a remove or clear while the keys are read may delete a row the
query saw (as for an add, see cacheKey)
+ long seen = evictions.get();
transactionTemplate.execute(status -> {
try {
cache = jdbcTemplate.query(getQueryAllString(), resultSet -> {
@@ -204,6 +294,10 @@ public class JdbcCachedMessageIdRepository extends
JdbcMessageIdRepository {
return Boolean.TRUE;
});
+ if (evictions.get() != seen) {
+ // the loaded keys may include a deleted row: start empty instead
(only misses until they are cached again)
+ cache.clear();
+ }
}
}
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
index 656df26634b8..99914d274e53 100644
---
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
@@ -16,6 +16,12 @@
*/
package org.apache.camel.processor.idempotent.jdbc;
+import java.sql.Timestamp;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -126,6 +132,166 @@ public class JdbcCachedMessageIdRepositoryTransactionTest
{
assertTrue(repository.add(KEY));
}
+ @Test
+ public void
removeInACallerTransactionEvictsTheKeyThatAnotherThreadCachedBeforeTheCommit()
throws Exception {
+ assertTrue(repository.add(KEY));
+ // the other thread adds the key again while the transaction that
deletes its row is open: it still sees the
+ // row, so its add is a duplicate, and it caches the key
+ assertTrue(removeInAnOpenTransactionWhile(() ->
assertFalse(repository.add(KEY)), false));
+
+ assertEquals(0, rows());
+ assertFalse(repository.contains(KEY), "the key whose row was deleted
must not stay a duplicate in the cache");
+ assertTrue(repository.add(KEY));
+ assertEquals(1, rows());
+ }
+
+ @Test
+ public void
clearInACallerTransactionEvictsTheKeyThatAnotherThreadCachedBeforeTheCommit()
throws Exception {
+ assertTrue(repository.add(KEY));
+ assertTrue(removeInAnOpenTransactionWhile(() ->
assertFalse(repository.add(KEY)), true));
+
+ assertEquals(0, rows());
+ assertFalse(repository.contains(KEY), "the key whose row was deleted
must not stay a duplicate in the cache");
+ assertTrue(repository.add(KEY));
+ }
+
+ @Test
+ public void removeInACallerTransactionThatRollsBackKeepsTheRow() {
+ assertTrue(repository.add(KEY));
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.remove(KEY));
+ status.setRollbackOnly();
+ });
+
+ assertEquals(1, rows());
+ assertTrue(repository.contains(KEY));
+ assertFalse(repository.add(KEY));
+ }
+
+ @Test
+ public void removeAndAddInOneCallerTransactionIsCached() {
+ assertTrue(repository.add(KEY));
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.remove(KEY));
+ assertTrue(repository.add(KEY));
+ });
+ int hits = repository.getHitCount();
+
+ assertEquals(1, rows());
+ assertTrue(repository.contains(KEY));
+ assertEquals(hits + 1, repository.getHitCount(), "the key added after
the remove must be cached at the commit");
+ }
+
+ @Test
+ public void addOnAnotherThreadThatReadTheRowBeforeARemoveIsNotCached()
throws Exception {
+ // the row is there, but not in the cache (such as a row of another
node), so the add reads it
+ insertRowNotCached();
+ int misses = repository.getMissCount();
+ // the other thread reads the row (its add is a duplicate), the remove
deletes the row and evicts the key, and
+ // only then does the other thread cache the key, when its transaction
commits
+ assertFalse(inAnOpenTransactionWhile(() -> repository.add(KEY), () ->
assertTrue(repository.remove(KEY))));
+ assertEquals(misses + 1, repository.getMissCount(), "the add must have
read the row");
+
+ assertEquals(0, rows());
+ assertFalse(repository.contains(KEY), "the key whose row was deleted
must not stay a duplicate in the cache");
+ assertTrue(repository.add(KEY));
+ assertEquals(1, rows());
+ }
+
+ @Test
+ public void addOnAnotherThreadThatReadTheRowBeforeAClearIsNotCached()
throws Exception {
+ insertRowNotCached();
+ assertFalse(inAnOpenTransactionWhile(() -> repository.add(KEY),
repository::clear));
+
+ assertEquals(0, rows());
+ assertFalse(repository.contains(KEY), "the key whose row was deleted
must not stay a duplicate in the cache");
+ assertTrue(repository.add(KEY));
+ }
+
+ @Test
+ public void addOnAnotherThreadIsCachedWhenNoRowIsRemovedMeanwhile() throws
Exception {
+ assertTrue(inAnOpenTransactionWhile(() -> repository.add(KEY), () ->
assertFalse(repository.remove("OTHER"))));
+ int hits = repository.getHitCount();
+
+ assertTrue(repository.contains(KEY));
+ assertEquals(hits + 1, repository.getHitCount(), "the committed key
must be answered from the cache");
+ }
+
+ @Test
+ public void clearAndAddInOneCallerTransactionKeepsTheRow() {
+ assertTrue(repository.add(KEY));
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ repository.clear();
+ assertTrue(repository.add(KEY));
+ });
+
+ assertEquals(1, rows());
+ assertTrue(repository.contains(KEY));
+ assertFalse(repository.add(KEY));
+ }
+
+ /**
+ * Removes the key (or clears) in a transaction of the caller on another
thread, runs the action on this thread
+ * while that transaction is open, and then lets it commit. Returns
whether the remove returned true.
+ */
+ private boolean removeInAnOpenTransactionWhile(Runnable action, boolean
clear) throws Exception {
+ return inAnOpenTransactionWhile(() -> {
+ if (clear) {
+ repository.clear();
+ return true;
+ }
+ return repository.remove(KEY);
+ }, action);
+ }
+
+ /**
+ * Runs the step in a transaction of the caller on another thread, runs
the action on this thread while that
+ * transaction is open, and then lets it commit. Returns the result of the
step.
+ */
+ private boolean inAnOpenTransactionWhile(BooleanSupplier step, Runnable
action) throws Exception {
+ CountDownLatch stepped = new CountDownLatch(1);
+ CountDownLatch commit = new CountDownLatch(1);
+ AtomicReference<Boolean> result = new AtomicReference<>();
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ Thread caller = new Thread(() -> {
+ try {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ result.set(step.getAsBoolean());
+ stepped.countDown();
+ try {
+ assertTrue(commit.await(20, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ });
+ } catch (Throwable t) {
+ failure.set(t);
+ } finally {
+ stepped.countDown();
+ }
+ }, "caller-transaction");
+ caller.start();
+ try {
+ assertTrue(stepped.await(20, TimeUnit.SECONDS));
+ action.run();
+ } finally {
+ commit.countDown();
+ caller.join(TimeUnit.SECONDS.toMillis(20));
+ }
+ assertFalse(caller.isAlive());
+ if (failure.get() != null) {
+ throw new AssertionError("the transaction of the caller failed",
failure.get());
+ }
+ return result.get();
+ }
+
+ private void insertRowNotCached() {
+ jdbcTemplate.update("INSERT INTO CAMEL_MESSAGEPROCESSED
(processorName, messageId, createdAt) VALUES (?, ?, ?)",
+ PROCESSOR_NAME, KEY, new
Timestamp(System.currentTimeMillis()));
+ assertEquals(1, rows());
+ }
+
private int rows() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM
CAMEL_MESSAGEPROCESSED WHERE processorName = ?",
Integer.class, PROCESSOR_NAME);