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 dd1b3a96f807 CAMEL-26743: camel-sql -
JdbcOrphanLockAwareIdempotentRepository: the keep-alive deadlocks with add and
remove on a small connection pool (#29437)
dd1b3a96f807 is described below
commit dd1b3a96f8073cd641f27aece61257020c506ea3
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Nov 9 15:39:25 2026 +0530
CAMEL-26743: camel-sql - JdbcOrphanLockAwareIdempotentRepository: the
keep-alive deadlocks with add and remove on a small connection pool (#29437)
* CAMEL-26743: camel-sql - JdbcOrphanLockAwareIdempotentRepository: the
keep-alive deadlocks with add and remove on a small connection pool
* CAMEL-26743: camel-sql - JdbcOrphanLockAwareIdempotentRepository: keep a
key alive only after its row is committed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../JdbcOrphanLockAwareIdempotentRepository.java | 143 ++++++----
...LockAwareIdempotentRepositoryKeepAliveTest.java | 304 +++++++++++++++++++++
...ckAwareIdempotentRepositoryTransactionTest.java | 154 +++++++++++
3 files changed, 549 insertions(+), 52 deletions(-)
diff --git
a/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepository.java
b/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepository.java
index 10bcaee14429..c7a9d1b741d3 100644
---
a/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepository.java
+++
b/components/camel-sql/src/main/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepository.java
@@ -17,13 +17,12 @@
package org.apache.camel.processor.idempotent.jdbc;
import java.sql.Timestamp;
-import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.locks.StampedLock;
import java.util.stream.Collectors;
import javax.sql.DataSource;
@@ -31,13 +30,15 @@ import javax.sql.DataSource;
import org.apache.camel.CamelContext;
import org.apache.camel.spi.ExecutorServiceManager;
import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.support.TransactionSynchronization;
+import
org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Implementation of {@link AbstractJdbcMessageIdRepository} which handles
orphan locks resulting from jvm crash.
*
* When an instance of the application acquires a lock on the idempotent
repository, the lock attributes are added to a
- * HashSet. While the lock is help by the instance, the instance keeps
updating the createdAt column with the current
+ * set. While the lock is held by the instance, the instance keeps updating
the createdAt column with the current
* timestamp indicating the instance holding the lock is active.
*
* A lock is granted to an instance if either the entry for the lock
attributes do not exists in the
@@ -49,9 +50,9 @@ public class JdbcOrphanLockAwareIdempotentRepository extends
JdbcMessageIdReposi
protected static final String DEFAULT_UPDATE_TIMESTAMP_QUERY
= "UPDATE CAMEL_MESSAGEPROCESSED SET createdAt =? WHERE
processorName =? AND messageId = ?";
- private final StampedLock sl = new StampedLock();
-
- private final Set<ProcessorNameAndMessageId> processorNameMessageIdSet =
new HashSet<>();
+ // a concurrent set, so no lock is held while SQL runs: add and remove
hold a connection of the pool while they
+ // run, and the keep-alive needs one too, so a lock held across SQL could
deadlock with a small pool
+ private final Set<ProcessorNameAndMessageId> processorNameMessageIdSet =
ConcurrentHashMap.newKeySet();
private ExecutorServiceManager executorServiceManager;
@@ -104,42 +105,77 @@ public class JdbcOrphanLockAwareIdempotentRepository
extends JdbcMessageIdReposi
@Override
protected int delete(String key) {
- long stamp = sl.writeLock();
- try {
- int result = super.delete(key);
- processorNameMessageIdSet.remove(new
ProcessorNameAndMessageId(processorName, key));
- return result;
- } finally {
- sl.unlockWrite(stamp);
+ int result = super.delete(key);
+ ProcessorNameAndMessageId id = new
ProcessorNameAndMessageId(processorName, key);
+ processorNameMessageIdSet.remove(id);
+ // an insert of the same key earlier in this transaction keeps it
alive only after the commit: cancel that
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ for (TransactionSynchronization synchronization :
TransactionSynchronizationManager.getSynchronizations()) {
+ if (synchronization instanceof KeepAliveAfterCommit pending &&
pending.repository == this
+ && pending.id.equals(id)) {
+ pending.cancelled = true;
+ }
+ }
}
-
+ return result;
}
@Override
protected int insert(String key) {
Timestamp currentTimestamp = new Timestamp(System.currentTimeMillis());
- long stamp = sl.writeLock();
- try {
- if (jdbcTemplate.queryForObject(getQueryString(), Integer.class,
processorName, key) == 0) {
- int result = jdbcTemplate.update(getInsertString(),
processorName, key, currentTimestamp);
- processorNameMessageIdSet.add(new
ProcessorNameAndMessageId(processorName, key));
- return result;
- } else {
- // Row exists — try to claim orphan lock with conditional
UPDATE.
- // Only succeeds if createdAt is older than lockMaxAge (lock
is truly orphaned).
- // Returns 0 if the lock is held by an active instance,
preventing the race in CAMEL-23294.
- String orphanLockRecoverQueryString =
getUpdateTimestampQuery() + " AND createdAt < ?";
- Timestamp xMillisAgo = new
Timestamp(System.currentTimeMillis() - lockMaxAgeMillis);
- int result
- = jdbcTemplate.update(orphanLockRecoverQueryString,
currentTimestamp, processorName, key, xMillisAgo);
- if (result > 0) {
- log.debug("Orphan lock seized for key: {}", key);
- processorNameMessageIdSet.add(new
ProcessorNameAndMessageId(processorName, key));
- }
- return result;
+ if (jdbcTemplate.queryForObject(getQueryString(), Integer.class,
processorName, key) == 0) {
+ int result = jdbcTemplate.update(getInsertString(), processorName,
key, currentTimestamp);
+ keepAliveAfterCommit(key);
+ return result;
+ } else {
+ // Row exists — try to claim orphan lock with conditional UPDATE.
+ // Only succeeds if createdAt is older than lockMaxAge (lock is
truly orphaned).
+ // Returns 0 if the lock is held by an active instance, preventing
the race in CAMEL-23294.
+ String orphanLockRecoverQueryString = getUpdateTimestampQuery() +
" AND createdAt < ?";
+ Timestamp xMillisAgo = new Timestamp(System.currentTimeMillis() -
lockMaxAgeMillis);
+ int result = jdbcTemplate.update(orphanLockRecoverQueryString,
currentTimestamp, processorName, key, xMillisAgo);
+ if (result > 0) {
+ log.debug("Orphan lock seized for key: {}", key);
+ keepAliveAfterCommit(key);
+ }
+ return result;
+ }
+ }
+
+ /**
+ * Keeps the lease of the key alive once the transaction that inserted or
seized its row commits. When that
+ * transaction rolls back (for example in a transacted route with
removeOnFailure=false), the row is not there, so
+ * the key must not be kept alive: another node may insert it later, and
this node would keep its lease alive.
+ */
+ private void keepAliveAfterCommit(String key) {
+ ProcessorNameAndMessageId id = new
ProcessorNameAndMessageId(processorName, key);
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new
KeepAliveAfterCommit(this, id));
+ } else {
+ processorNameMessageIdSet.add(id);
+ }
+ }
+
+ /**
+ * Adds a key to the keys kept alive after the commit of the transaction
that inserted or seized its row, unless the
+ * key was deleted later in that transaction.
+ */
+ private static final class KeepAliveAfterCommit implements
TransactionSynchronization {
+ private final JdbcOrphanLockAwareIdempotentRepository repository;
+ private final ProcessorNameAndMessageId id;
+ // set and read by the thread of the transaction
+ private boolean cancelled;
+
+ private KeepAliveAfterCommit(JdbcOrphanLockAwareIdempotentRepository
repository, ProcessorNameAndMessageId id) {
+ this.repository = repository;
+ this.id = id;
+ }
+
+ @Override
+ public void afterCommit() {
+ if (!cancelled) {
+ repository.processorNameMessageIdSet.add(id);
}
- } finally {
- sl.unlockWrite(stamp);
}
}
@@ -181,30 +217,33 @@ public class JdbcOrphanLockAwareIdempotentRepository
extends JdbcMessageIdReposi
@Override
protected int delete() {
- long stamp = sl.writeLock();
- try {
- int result = super.delete();
- processorNameMessageIdSet.clear();
- return result;
- } finally {
- sl.unlockWrite(stamp);
+ int result = super.delete();
+ processorNameMessageIdSet.clear();
+ // and the inserts earlier in this transaction are not kept alive
after the commit either
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ for (TransactionSynchronization synchronization :
TransactionSynchronizationManager.getSynchronizations()) {
+ if (synchronization instanceof KeepAliveAfterCommit pending &&
pending.repository == this) {
+ pending.cancelled = true;
+ }
+ }
}
-
+ return result;
}
void keepAlive() {
- Timestamp currentTimestamp = new Timestamp(System.currentTimeMillis());
- long stamp = sl.readLock();
try {
- List<Object[]> args = processorNameMessageIdSet.stream()
- .map(processorNameMessageId -> new Object[] {
- currentTimestamp,
processorNameMessageId.processorName, processorNameMessageId.messageId })
- .collect(Collectors.toList());
- transactionTemplate.execute(status ->
jdbcTemplate.batchUpdate(getUpdateTimestampQuery(), args));
+ transactionTemplate.execute(status -> {
+ // take the timestamp and the keys inside the transaction,
after the connection was obtained (with the
+ // DataSourceTransactionManager), as waiting for it can take a
while
+ Timestamp currentTimestamp = new
Timestamp(System.currentTimeMillis());
+ List<Object[]> args = processorNameMessageIdSet.stream()
+ .map(processorNameMessageId -> new Object[] {
+ currentTimestamp,
processorNameMessageId.processorName, processorNameMessageId.messageId })
+ .collect(Collectors.toList());
+ return jdbcTemplate.batchUpdate(getUpdateTimestampQuery(),
args);
+ });
} catch (Exception e) {
log.error("failed updating createdAt in keepAlive due to ", e);
- } finally {
- sl.unlockRead(stamp);
}
}
diff --git
a/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepositoryKeepAliveTest.java
b/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepositoryKeepAliveTest.java
new file mode 100644
index 000000000000..81bab6affcee
--- /dev/null
+++
b/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepositoryKeepAliveTest.java
@@ -0,0 +1,304 @@
+/*
+ * 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 java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import org.apache.camel.impl.DefaultCamelContext;
+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.DelegatingDataSource;
+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;
+
+/**
+ * The keep-alive must not wait for the add or remove of a key, nor make them
wait, while either holds a connection of
+ * the pool. With a lock held across the SQL, a keep-alive that waits for the
only connection while add or remove,
+ * holding that connection, waits for the lock deadlocks the node; and a
keep-alive that waits while add runs a slow
+ * statement (such as one waiting for a row lock of another node) lets the
leases of the node expire.
+ */
+class JdbcOrphanLockAwareIdempotentRepositoryKeepAliveTest {
+
+ private static final String PROCESSOR_NAME = "APP_1";
+ private static final String HELD = "HELD";
+ private static final String KEY = "KEY";
+ private static final long LOCK_MAX_AGE_MILLIS = 300_000L;
+ private static final long WAIT_SECONDS = 10;
+
+ private EmbeddedDatabase database;
+ private JdbcTemplate jdbcTemplate;
+ private JdbcOrphanLockAwareIdempotentRepository repository;
+ private Pool pool;
+ private Timestamp staleCreatedAt;
+
+ @BeforeEach
+ void setup() {
+ database = new EmbeddedDatabaseBuilder()
+ .setType(EmbeddedDatabaseType.H2)
+ .generateUniqueName(true)
+ .build();
+ jdbcTemplate = new JdbcTemplate(database);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (pool != null) {
+ // a thread held by the pool is let go, also when the test failed
before it did so
+ pool.release.countDown();
+ }
+ if (repository != null) {
+ repository.shutdown();
+ }
+ database.shutdown();
+ }
+
+ private void start(int connections) {
+ pool = new Pool(database, connections);
+ repository = new JdbcOrphanLockAwareIdempotentRepository(pool,
PROCESSOR_NAME, new DefaultCamelContext());
+ repository.setLockMaxAgeMillis(LOCK_MAX_AGE_MILLIS);
+ // the test calls keepAlive() itself
+ repository.setLockKeepAliveIntervalMillis(LOCK_MAX_AGE_MILLIS / 2);
+ repository.start();
+ assertTrue(repository.add(HELD));
+ // the lease of the held key is old, so a keep-alive that runs is seen
+ staleCreatedAt = new Timestamp(System.currentTimeMillis() -
LOCK_MAX_AGE_MILLIS / 2);
+ jdbcTemplate.update("UPDATE CAMEL_MESSAGEPROCESSED SET createdAt = ?
WHERE messageId = ?", staleCreatedAt, HELD);
+ }
+
+ @Test
+ void testKeepAliveWaitingForTheOnlyConnectionDoesNotBlockAdd() throws
Exception {
+ start(1);
+ AtomicReference<Object> added = new AtomicReference<>();
+ Thread adder = worker("adder", added, () -> repository.add(KEY));
+
+ keepAliveWhileHoldingTheOnlyConnection(adder);
+
+ assertEquals(Boolean.TRUE, added.get());
+ assertEquals(2, repository.getProcessorNameMessageIdSet().size());
+ }
+
+ @Test
+ void testKeepAliveWaitingForTheOnlyConnectionDoesNotBlockRemove() throws
Exception {
+ start(1);
+ assertTrue(repository.add(KEY));
+ AtomicReference<Object> removed = new AtomicReference<>();
+ Thread remover = worker("remover", removed, () ->
repository.remove(KEY));
+
+ keepAliveWhileHoldingTheOnlyConnection(remover);
+
+ assertEquals(Boolean.TRUE, removed.get());
+ assertEquals(1, repository.getProcessorNameMessageIdSet().size());
+ }
+
+ /**
+ * The worker borrows the only connection and is held there; the
keep-alive then waits for the connection, and the
+ * worker goes on. Both must finish, and the keep-alive must refresh the
held key.
+ */
+ private void keepAliveWhileHoldingTheOnlyConnection(Thread worker) throws
Exception {
+ CountDownLatch keepAliveWaits = new CountDownLatch(1);
+ pool.holdAfterBorrow(worker);
+ pool.onWait = keepAliveWaits::countDown;
+ worker.start();
+ assertTrue(pool.reached.await(WAIT_SECONDS, TimeUnit.SECONDS));
+
+ Thread keeper = new Thread(repository::keepAlive, "keep-alive");
+ keeper.start();
+ assertTrue(keepAliveWaits.await(WAIT_SECONDS, TimeUnit.SECONDS));
+ pool.release.countDown();
+
+ try {
+ worker.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ keeper.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ assertFalse(worker.isAlive(), () -> worker.getName() + " is
blocked: " + stackOf(worker));
+ assertFalse(keeper.isAlive(), () -> "the keep-alive is blocked: "
+ stackOf(keeper));
+ } finally {
+ // on a deadlock, end the wait of the keep-alive for the
connection, so both threads end
+ keeper.interrupt();
+ keeper.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ worker.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ }
+ assertTrue(createdAt(HELD).after(staleCreatedAt), "the keep-alive did
not refresh the held key");
+ }
+
+ @Test
+ void testKeepAliveDoesNotWaitForASlowInsert() throws Exception {
+ start(2);
+ AtomicReference<Object> added = new AtomicReference<>();
+ Thread adder = worker("adder", added, () -> repository.add(KEY));
+ // the INSERT of the add is held, as a statement that waits for a row
lock of another node would be
+ pool.holdAtStatement(adder, "INSERT");
+ adder.start();
+ assertTrue(pool.reached.await(WAIT_SECONDS, TimeUnit.SECONDS));
+
+ Thread keeper = new Thread(repository::keepAlive, "keep-alive");
+ try {
+ keeper.start();
+ keeper.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ assertFalse(keeper.isAlive(), () -> "the keep-alive waits for the
INSERT: " + stackOf(keeper));
+ assertTrue(createdAt(HELD).after(staleCreatedAt), "the keep-alive
did not refresh the held key");
+ } finally {
+ pool.release.countDown();
+ adder.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ keeper.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ }
+ assertFalse(adder.isAlive());
+ assertEquals(Boolean.TRUE, added.get());
+ }
+
+ /**
+ * A thread that calls the repository and keeps what it returned, or what
it threw.
+ */
+ private static Thread worker(String name, AtomicReference<Object> result,
Supplier<Boolean> call) {
+ return new Thread(() -> {
+ try {
+ result.set(call.get());
+ } catch (Throwable e) {
+ result.set(e);
+ }
+ }, name);
+ }
+
+ private Timestamp createdAt(String key) {
+ return jdbcTemplate.queryForObject("SELECT createdAt FROM
CAMEL_MESSAGEPROCESSED WHERE messageId = ?",
+ Timestamp.class, key);
+ }
+
+ private static String stackOf(Thread thread) {
+ StringBuilder sb = new StringBuilder(thread.getState().toString());
+ for (StackTraceElement element : thread.getStackTrace()) {
+ sb.append("\n\tat ").append(element);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * A pool of a fixed number of connections to the embedded database, which
waits for a connection as long as it
+ * takes, with a point where it holds one thread: after it borrowed a
connection, or before it runs a statement.
+ */
+ private static final class Pool extends DelegatingDataSource {
+
+ private final Semaphore free;
+ private final CountDownLatch reached = new CountDownLatch(1);
+ private final CountDownLatch release = new CountDownLatch(1);
+ private final AtomicReference<Thread> held = new AtomicReference<>();
+ private volatile String heldAtStatement;
+ private volatile Runnable onWait = () -> {
+ };
+
+ Pool(EmbeddedDatabase database, int connections) {
+ super(database);
+ this.free = new Semaphore(connections);
+ }
+
+ void holdAfterBorrow(Thread thread) {
+ held.set(thread);
+ }
+
+ void holdAtStatement(Thread thread, String sqlPrefix) {
+ heldAtStatement = sqlPrefix;
+ held.set(thread);
+ }
+
+ @Override
+ public Connection getConnection() throws SQLException {
+ if (!free.tryAcquire()) {
+ onWait.run();
+ try {
+ free.acquire();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new SQLException("Interrupted while waiting for a
connection", e);
+ }
+ }
+ Connection connection;
+ try {
+ connection = super.getConnection();
+ } catch (SQLException | RuntimeException e) {
+ free.release();
+ throw e;
+ }
+ if (heldAtStatement == null) {
+ hold();
+ }
+ return pooled(connection);
+ }
+
+ private void hold() {
+ if (held.compareAndSet(Thread.currentThread(), null)) {
+ reached.countDown();
+ try {
+ // bounded, so a failed test cannot leave the thread
waiting forever
+ release.await(WAIT_SECONDS * 3, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /**
+ * The connection, which goes back to the pool when it is closed.
+ */
+ private Connection pooled(Connection connection) {
+ AtomicBoolean closed = new AtomicBoolean();
+ return (Connection)
Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] {
Connection.class },
+ (proxy, method, args) -> {
+ if ("close".equals(method.getName())) {
+ if (closed.compareAndSet(false, true)) {
+ try {
+ connection.close();
+ } finally {
+ free.release();
+ }
+ }
+ return null;
+ }
+ if ("equals".equals(method.getName())) {
+ return proxy == args[0];
+ }
+ if ("hashCode".equals(method.getName())) {
+ return System.identityHashCode(proxy);
+ }
+ if ("prepareStatement".equals(method.getName()) &&
heldAtStatement != null
+ && ((String)
args[0]).startsWith(heldAtStatement)) {
+ hold();
+ }
+ try {
+ return method.invoke(connection, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ });
+ }
+ }
+}
diff --git
a/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepositoryTransactionTest.java
b/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepositoryTransactionTest.java
new file mode 100644
index 000000000000..03529d1dd2e0
--- /dev/null
+++
b/components/camel-sql/src/test/java/org/apache/camel/processor/idempotent/jdbc/JdbcOrphanLockAwareIdempotentRepositoryTransactionTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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 java.sql.Timestamp;
+
+import org.apache.camel.impl.DefaultCamelContext;
+import
org.apache.camel.processor.idempotent.jdbc.JdbcOrphanLockAwareIdempotentRepository.ProcessorNameAndMessageId;
+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 kept alive only once the transaction that inserted or seized its
row commits. In a transaction of the caller
+ * that rolls back (for example a transacted route with
removeOnFailure=false), the row is not there, and the node must
+ * not keep the key alive: another node may insert it later, and this node
would keep that node's lease alive.
+ */
+class JdbcOrphanLockAwareIdempotentRepositoryTransactionTest {
+
+ private static final String PROCESSOR_NAME = "APP_1";
+ private static final String KEY = "KEY";
+ private static final long LOCK_MAX_AGE_MILLIS = 300_000L;
+ private static final ProcessorNameAndMessageId ID = new
ProcessorNameAndMessageId(PROCESSOR_NAME, KEY);
+
+ private EmbeddedDatabase database;
+ private JdbcTemplate jdbcTemplate;
+ private JdbcOrphanLockAwareIdempotentRepository repository;
+
+ @BeforeEach
+ void setup() {
+ database = new EmbeddedDatabaseBuilder()
+ .setType(EmbeddedDatabaseType.H2)
+ .generateUniqueName(true)
+ .build();
+ jdbcTemplate = new JdbcTemplate(database);
+ repository = new JdbcOrphanLockAwareIdempotentRepository(database,
PROCESSOR_NAME, new DefaultCamelContext());
+ repository.setLockMaxAgeMillis(LOCK_MAX_AGE_MILLIS);
+ // the keep-alive task does not run during the test
+ repository.setLockKeepAliveIntervalMillis(LOCK_MAX_AGE_MILLIS / 2);
+ repository.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ repository.shutdown();
+ database.shutdown();
+ }
+
+ @Test
+ void testAddInACallerTransactionThatRollsBackIsNotKeptAlive() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ status.setRollbackOnly();
+ });
+
+ assertEquals(0, rows());
+ assertFalse(repository.getProcessorNameMessageIdSet().contains(ID));
+ }
+
+ @Test
+ void testAddInACallerTransactionIsKeptAliveAfterTheCommit() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+
assertFalse(repository.getProcessorNameMessageIdSet().contains(ID));
+ });
+
+ assertEquals(1, rows());
+ assertTrue(repository.getProcessorNameMessageIdSet().contains(ID));
+ }
+
+ @Test
+ void testAddIsKeptAlive() {
+ assertTrue(repository.add(KEY));
+
+ assertTrue(repository.getProcessorNameMessageIdSet().contains(ID));
+ }
+
+ @Test
+ void testAddAndRemoveInOneCallerTransactionIsNotKeptAlive() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ assertTrue(repository.remove(KEY));
+ });
+
+ assertEquals(0, rows());
+ assertFalse(repository.getProcessorNameMessageIdSet().contains(ID));
+ }
+
+ @Test
+ void testAddAndClearInOneCallerTransactionIsNotKeptAlive() {
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ repository.clear();
+ });
+
+ assertEquals(0, rows());
+ assertFalse(repository.getProcessorNameMessageIdSet().contains(ID));
+ }
+
+ @Test
+ void testOrphanLockSeizedInACallerTransactionThatRollsBackIsNotKeptAlive()
{
+ Timestamp orphaned = new Timestamp(System.currentTimeMillis() -
LOCK_MAX_AGE_MILLIS * 2);
+ jdbcTemplate.update("INSERT INTO CAMEL_MESSAGEPROCESSED
(processorName, messageId, createdAt) VALUES (?, ?, ?)",
+ PROCESSOR_NAME, KEY, orphaned);
+
+ repository.transactionTemplate.executeWithoutResult(status -> {
+ assertTrue(repository.add(KEY));
+ status.setRollbackOnly();
+ });
+
+ assertEquals(orphaned, jdbcTemplate.queryForObject(
+ "SELECT createdAt FROM CAMEL_MESSAGEPROCESSED WHERE
processorName = ? AND messageId = ?",
+ Timestamp.class, PROCESSOR_NAME, KEY));
+ assertFalse(repository.getProcessorNameMessageIdSet().contains(ID));
+ }
+
+ @Test
+ void testOrphanLockSeizedInACallerTransactionIsKeptAliveAfterTheCommit() {
+ Timestamp orphaned = new Timestamp(System.currentTimeMillis() -
LOCK_MAX_AGE_MILLIS * 2);
+ jdbcTemplate.update("INSERT INTO CAMEL_MESSAGEPROCESSED
(processorName, messageId, createdAt) VALUES (?, ?, ?)",
+ PROCESSOR_NAME, KEY, orphaned);
+
+ repository.transactionTemplate.executeWithoutResult(status ->
assertTrue(repository.add(KEY)));
+
+ assertTrue(repository.getProcessorNameMessageIdSet().contains(ID));
+ }
+
+ private int rows() {
+ return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM
CAMEL_MESSAGEPROCESSED WHERE processorName = ?",
+ Integer.class, PROCESSOR_NAME);
+ }
+}