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 3c2e37175a54 CAMEL-26736: camel-jpa - JpaKeyValueRepository
putIfAbsent has one winner and expiry keeps a live entry (#29440)
3c2e37175a54 is described below
commit 3c2e37175a549ee4af58199a0d301911b515fced
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Nov 9 17:45:55 2026 +0530
CAMEL-26736: camel-jpa - JpaKeyValueRepository putIfAbsent has one winner
and expiry keeps a live entry (#29440)
* CAMEL-26736: camel-jpa - JpaKeyValueRepository putIfAbsent has one wi…
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/catalog/docs/keyValueRepository.adoc | 9 +-
.../keyvalue/jpa/JpaKeyValueRepository.java | 98 +++---
.../jpa/JpaKeyValueRepositoryConcurrencyTest.java | 355 +++++++++++++++++++++
.../modules/eips/pages/keyValueRepository.adoc | 9 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 +
5 files changed, 440 insertions(+), 42 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
index 750a45c396ba..b7e26662cd75 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
@@ -80,7 +80,7 @@ Choosing the right backend is typically a matter of:
| `JpaKeyValueRepository`
| `camel-jpa`
| JPA entity-backed; works with any JPA 2 provider (Hibernate, EclipseLink…)
-| ✗ (non-atomic)
+| ✓ (primary key)
| ✗ (client-side)
| `CassandraKeyValueRepository`
@@ -675,8 +675,11 @@ YAML DSL::
----
====
-NOTE: The JPA backend does not provide atomic `putIfAbsent`. Idempotency under
concurrent
-load relies on unique-constraint violations being caught at the database level.
+NOTE: `JpaKeyValueRepository.putIfAbsent` relies on the primary key of the
table: when concurrent
+inserts of the same key fail with a constraint violation, the transaction is
rolled back and the key
+is read again in a new transaction, which returns the value stored by the
other caller. After three
+failed inserts the `PersistenceException` is thrown. When `putIfAbsent` runs
in a transaction of the
+caller, the failed insert marks that transaction for rollback, so the caller's
transaction fails.
=== CassandraKeyValueRepository
diff --git
a/components/camel-jpa/src/main/java/org/apache/camel/processor/keyvalue/jpa/JpaKeyValueRepository.java
b/components/camel-jpa/src/main/java/org/apache/camel/processor/keyvalue/jpa/JpaKeyValueRepository.java
index 581f57101d3c..882321343ee2 100644
---
a/components/camel-jpa/src/main/java/org/apache/camel/processor/keyvalue/jpa/JpaKeyValueRepository.java
+++
b/components/camel-jpa/src/main/java/org/apache/camel/processor/keyvalue/jpa/JpaKeyValueRepository.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.processor.keyvalue.jpa;
+import java.sql.SQLException;
import java.sql.SQLIntegrityConstraintViolationException;
import java.time.Duration;
import java.util.Iterator;
@@ -70,6 +71,12 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
= "select e from " + KeyValueEntry.class.getName() + " e";
private static final String QUERY_COUNT_VALID
= "select count(e) from " + KeyValueEntry.class.getName() + " e
where e.expiresAt = 0 or e.expiresAt > ?1";
+ private static final String DELETE_IF_EXPIRED
+ = "delete from " + KeyValueEntry.class.getName()
+ + " e where e.itemKey = ?1 and e.expiresAt > 0 and e.expiresAt
<= ?2";
+
+ // how many transactions putIfAbsent may use when concurrent inserts of
the same key keep failing
+ private static final int PUT_IF_ABSENT_ATTEMPTS = 3;
private static final String SOMETHING_WENT_WRONG
= "Something went wrong in JpaKeyValueRepository: %s";
@@ -126,8 +133,7 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
if (entry == null) {
rc[0] = null;
} else if (entry.isExpired()) {
- entityManager.remove(entry);
- entityManager.flush();
+ deleteIfExpired(entityManager, key);
rc[0] = null;
} else {
rc[0] =
KeyValueRepositoryHelper.deserialize(entry.getItemValue(),
deserializationFilter);
@@ -200,8 +206,7 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
if (entry == null) {
rc[0] = null;
} else if (entry.isExpired()) {
- entityManager.remove(entry);
- entityManager.flush();
+ deleteIfExpired(entityManager, key);
rc[0] = null;
} else {
rc[0] =
KeyValueRepositoryHelper.deserialize(entry.getItemValue(),
deserializationFilter);
@@ -236,8 +241,7 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
if (entry == null) {
rc[0] = Boolean.FALSE;
} else if (entry.isExpired()) {
- entityManager.remove(entry);
- entityManager.flush();
+ deleteIfExpired(entityManager, key);
rc[0] = Boolean.FALSE;
} else {
rc[0] = Boolean.TRUE;
@@ -315,8 +319,35 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
LOG.debug("clear the store {}", KeyValueEntry.class.getName());
}
+ /**
+ * Stores the value only if no non-expired entry exists for the key. The
primary key of the table decides between
+ * concurrent callers: when the insert fails with a constraint violation,
the transaction is rolled back and the key
+ * is read again in a new transaction, which returns the value stored by
the other caller, or inserts again if that
+ * entry has been deleted meanwhile. In a transaction of the caller, the
failed insert marks that transaction for
+ * rollback.
+ *
+ * @throws PersistenceException if the insert failed in all attempts
+ */
@Override
public Object putIfAbsent(String key, Object value, Duration ttl) {
+ final byte[] serializedValue =
KeyValueRepositoryHelper.serialize(value);
+ for (int attempt = 1;; attempt++) {
+ try {
+ Object answer = doPutIfAbsent(key, serializedValue, ttl);
+ LOG.debug("putIfAbsent {} -> {}", key, answer != null ?
"existing" : "inserted");
+ return answer;
+ } catch (PersistenceException e) {
+ // another thread/node inserted the key: read it again in a
new transaction, as the failed one may
+ // not see that entry (REPEATABLE READ) or the entry may be
deleted meanwhile
+ if (attempt >= PUT_IF_ABSENT_ATTEMPTS ||
!isConstraintViolation(e)) {
+ throw e;
+ }
+ LOG.debug("Concurrent insert of key '{}' -- reading it again
in a new transaction", key);
+ }
+ }
+ }
+
+ private Object doPutIfAbsent(String key, byte[] serializedValue, Duration
ttl) {
final Object[] rc = new Object[1];
final EntityManager entityManager
= getTargetEntityManager(null, entityManagerFactory, false,
sharedEntityManager, true);
@@ -326,50 +357,29 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
entityManager.joinTransaction();
}
try {
- long expiresAt = toExpiresAt(ttl);
- byte[] serializedValue =
KeyValueRepositoryHelper.serialize(value);
-
KeyValueEntry entry = findByKey(entityManager, key);
if (entry != null && !entry.isExpired()) {
// key exists and is valid -- return existing value
rc[0] =
KeyValueRepositoryHelper.deserialize(entry.getItemValue(),
deserializationFilter);
- } else if (entry != null) {
- // key exists but expired -- update in place
- entry.setItemValue(serializedValue);
- entry.setExpiresAt(expiresAt);
- entityManager.merge(entry);
- entityManager.flush();
- rc[0] = null;
} else {
- // no entry -- persist new one
- entry = new KeyValueEntry(key, serializedValue, expiresAt);
- entityManager.persist(entry);
+ if (entry != null) {
+ // key exists but expired -- delete it, unless it has
been stored again meanwhile, in which
+ // case the insert below fails
+ deleteIfExpired(entityManager, key);
+ entityManager.detach(entry);
+ }
+ entityManager.persist(new KeyValueEntry(key,
serializedValue, toExpiresAt(ttl)));
entityManager.flush();
rc[0] = null;
}
} catch (Exception ex) {
- if (isConstraintViolation(ex)) {
- // concurrent insert of the same key -- treat as "already
present"
- LOG.debug("Concurrent insert detected for key: {}", key);
- // re-read to return the existing value
- try {
- KeyValueEntry existing = findByKey(entityManager, key);
- rc[0] = existing != null
- ?
KeyValueRepositoryHelper.deserialize(existing.getItemValue(),
deserializationFilter) : null;
- } catch (Exception inner) {
- // fall through with null
- rc[0] = null;
- }
- } else {
- String contextInfo = String.format(SOMETHING_WENT_WRONG,
ex.getMessage());
- throw new PersistenceException(contextInfo, ex);
- }
+ String contextInfo = String.format(SOMETHING_WENT_WRONG,
ex.getMessage());
+ throw new PersistenceException(contextInfo, ex);
} finally {
closeEntityManager(entityManager);
}
});
- LOG.debug("putIfAbsent {} -> {}", key, rc[0] != null ? "existing" :
"inserted");
return rc[0];
}
@@ -503,6 +513,17 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
return (KeyValueEntry) list.get(0);
}
+ /**
+ * Deletes the entry of the key only if it is still expired: another
transaction may have stored the key again since
+ * it was read, and that entry must be kept.
+ */
+ private static void deleteIfExpired(EntityManager entityManager, String
key) {
+ Query query = entityManager.createQuery(DELETE_IF_EXPIRED);
+ query.setParameter(1, key);
+ query.setParameter(2, System.currentTimeMillis());
+ query.executeUpdate();
+ }
+
private static long toExpiresAt(Duration ttl) {
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
return 0;
@@ -529,6 +550,11 @@ public class JpaKeyValueRepository extends ServiceSupport
implements KeyValueRep
if (cause instanceof EntityExistsException) {
return true;
}
+ // drivers such as the PostgreSQL and SQL Server ones report it
only with the SQL state class 23
+ if (cause instanceof SQLException sqlException &&
sqlException.getSQLState() != null
+ && sqlException.getSQLState().startsWith("23")) {
+ return true;
+ }
cause = cause.getCause();
}
return false;
diff --git
a/components/camel-jpa/src/test/java/org/apache/camel/processor/keyvalue/jpa/JpaKeyValueRepositoryConcurrencyTest.java
b/components/camel-jpa/src/test/java/org/apache/camel/processor/keyvalue/jpa/JpaKeyValueRepositoryConcurrencyTest.java
new file mode 100644
index 000000000000..64401004e29c
--- /dev/null
+++
b/components/camel-jpa/src/test/java/org/apache/camel/processor/keyvalue/jpa/JpaKeyValueRepositoryConcurrencyTest.java
@@ -0,0 +1,355 @@
+/*
+ * 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.keyvalue.jpa;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.sql.SQLException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.Persistence;
+import jakarta.persistence.PersistenceException;
+import jakarta.persistence.Query;
+
+import org.apache.camel.component.jpa.DefaultTransactionStrategy;
+import org.apache.camel.support.KeyValueRepositoryHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Two repositories (two nodes) on one database. The entity manager operations
of node A can be paused, so that the
+ * calls of both nodes interleave in a given order.
+ */
+class JpaKeyValueRepositoryConcurrencyTest {
+
+ private EntityManagerFactory entityManagerFactory;
+ private PausingEntityManagerFactory pausingA;
+ private JpaKeyValueRepository nodeA;
+ private JpaKeyValueRepository nodeB;
+ private final ExecutorService executor =
Executors.newSingleThreadExecutor();
+
+ @BeforeEach
+ void startNodes() throws Exception {
+ entityManagerFactory =
Persistence.createEntityManagerFactory("keyvalueDb",
+ Map.of("openjpa.ConnectionURL", "jdbc:h2:mem:kv-" +
UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"));
+ pausingA = new PausingEntityManagerFactory(entityManagerFactory);
+ nodeA = createNode(pausingA.proxy());
+ nodeB = createNode(entityManagerFactory);
+ }
+
+ private static JpaKeyValueRepository createNode(EntityManagerFactory emf)
throws Exception {
+ JpaKeyValueRepository node = new JpaKeyValueRepository(emf);
+ node.setTransactionStrategy(new DefaultTransactionStrategy(null, emf));
+ node.setJoinTransaction(false);
+ node.setSharedEntityManager(true);
+ node.init();
+ node.start();
+ return node;
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (pausingA != null) {
+ pausingA.releaseAll();
+ }
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
+ if (nodeA != null) {
+ nodeA.stop();
+ nodeB.stop();
+ }
+ if (entityManagerFactory != null) {
+ entityManagerFactory.close();
+ }
+ }
+
+ @Test
+ void putIfAbsentReturnsTheValueOfTheWinner() throws Exception {
+ Gate beforeInsert = pausingA.pause(2);
+
+ Future<Object> a = executor.submit(() -> nodeA.putIfAbsent("order-1",
"A", null));
+ // A has read the key (absent) and is about to insert it
+ beforeInsert.awaitReached();
+ assertNull(nodeB.putIfAbsent("order-1", "B", null), "B stores the key
first");
+ // the insert of A fails with a constraint violation
+ beforeInsert.release();
+
+ assertEquals("B", a.get(10, TimeUnit.SECONDS), "A lost the race and
must get the value of B");
+ assertEquals("B", nodeA.get("order-1"));
+ }
+
+ @Test
+ void
putIfAbsentReturnsTheValueOfTheWinnerWhenTheDriverReportsOnlyTheSqlState()
throws Exception {
+ // as the PostgreSQL and SQL Server drivers do: a plain SQLException
with the SQL state of a unique violation
+ pausingA.reportConstraintViolationsBySqlState();
+ Gate beforeInsert = pausingA.pause(2);
+
+ Future<Object> a = executor.submit(() -> nodeA.putIfAbsent("order-2",
"A", null));
+ beforeInsert.awaitReached();
+ assertNull(nodeB.putIfAbsent("order-2", "B", null), "B stores the key
first");
+ beforeInsert.release();
+
+ assertEquals("B", a.get(10, TimeUnit.SECONDS), "A lost the race and
must get the value of B");
+ }
+
+ @Test
+ void putIfAbsentInsertsAgainWhenTheOtherEntryIsDeletedMeanwhile() throws
Exception {
+ Gate beforeInsert = pausingA.pause(2);
+ Gate beforeSecondRead = pausingA.pause(4);
+
+ Future<Object> a = executor.submit(() -> nodeA.putIfAbsent("lease-1",
"A", null));
+ beforeInsert.awaitReached();
+ assertNull(nodeB.putIfAbsent("lease-1", "B", null), "B stores the key
first");
+ // the insert of A fails with a constraint violation, then A reads the
key again
+ beforeInsert.release();
+ beforeSecondRead.awaitReached();
+ assertEquals("B", nodeB.delete("lease-1"), "B releases the key");
+ beforeSecondRead.release();
+
+ // the key was free when A read it again, so A may get it, but then
its value must be stored
+ assertNull(a.get(10, TimeUnit.SECONDS));
+ assertEquals("A", nodeB.get("lease-1"), "A was told that its put
succeeded");
+ }
+
+ @Test
+ void putIfAbsentOnAnExpiredEntryHasOneWinner() throws Exception {
+ storeExpiredEntry("job-7");
+ Gate afterRead = pausingA.pause(2);
+
+ Future<Object> a = executor.submit(() -> nodeA.putIfAbsent("job-7",
"A", null));
+ // A has read the entry as expired
+ afterRead.awaitReached();
+ assertNull(nodeB.putIfAbsent("job-7", "B", null), "the expired entry
is absent, so B gets the key");
+ afterRead.release();
+
+ assertEquals("B", a.get(10, TimeUnit.SECONDS), "A lost the race and
must get the value of B");
+ assertEquals("B", nodeB.get("job-7"));
+ }
+
+ @Test
+ void readingAnExpiredEntryDoesNotDeleteTheEntryStoredMeanwhile() throws
Exception {
+ storeExpiredEntry("job-42");
+ Gate afterRead = pausingA.pause(2);
+
+ Future<Object> a = executor.submit(() -> nodeA.get("job-42"));
+ // A has read the entry as expired and is about to delete it
+ afterRead.awaitReached();
+ assertNull(nodeB.putIfAbsent("job-42", "B", null), "the expired entry
is absent, so B gets the key");
+ afterRead.release();
+
+ assertNull(a.get(10, TimeUnit.SECONDS), "A read the entry while it was
expired");
+ assertEquals("B", nodeB.get("job-42"), "the entry stored by B must be
kept");
+ assertEquals("B", nodeA.putIfAbsent("job-42", "A", null), "B still
holds the key");
+ }
+
+ @Test
+ void putIfAbsentGivesUpAfterThreeConstraintViolations() {
+ assertNull(nodeB.putIfAbsent("job-1", "B", null));
+ // node A does not find the stored entry, so each of its inserts fails
with a constraint violation
+ pausingA.hideEntries();
+
+ assertThrows(PersistenceException.class, () ->
nodeA.putIfAbsent("job-1", "A", null));
+ assertEquals(3, pausingA.operations("persist"));
+ assertEquals("B", nodeB.get("job-1"));
+ }
+
+ private void storeExpiredEntry(String key) {
+ EntityManager em = entityManagerFactory.createEntityManager();
+ try {
+ em.getTransaction().begin();
+ // an entry that expired long ago
+ em.persist(new KeyValueEntry(key,
KeyValueRepositoryHelper.serialize("old"), 1L));
+ em.getTransaction().commit();
+ } finally {
+ em.close();
+ }
+ }
+
+ /**
+ * Pauses before the n-th entity manager operation of a {@link
PausingEntityManagerFactory}, until released.
+ */
+ private static final class Gate {
+ private final int occurrence;
+ private final CountDownLatch reached = new CountDownLatch(1);
+ private final CountDownLatch released = new CountDownLatch(1);
+
+ private Gate(int occurrence) {
+ this.occurrence = occurrence;
+ }
+
+ private void beforeOperation(int number) {
+ if (number == occurrence) {
+ reached.countDown();
+ await(released);
+ }
+ }
+
+ void awaitReached() {
+ await(reached);
+ }
+
+ void release() {
+ released.countDown();
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ if (!latch.await(10, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Timeout waiting for the
other node");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+ }
+
+ /**
+ * Wraps the entity managers of a factory, counts their operations that
read or write the database (query
+ * executions, persist, merge, remove, flush) and pauses them at the given
gates.
+ */
+ private static final class PausingEntityManagerFactory {
+ private final EntityManagerFactory delegate;
+ private final List<Gate> gates = new CopyOnWriteArrayList<>();
+ private final List<String> executed = new CopyOnWriteArrayList<>();
+ private final AtomicInteger count = new AtomicInteger();
+ private volatile boolean hideEntries;
+ private volatile boolean bySqlState;
+
+ private PausingEntityManagerFactory(EntityManagerFactory delegate) {
+ this.delegate = delegate;
+ }
+
+ Gate pause(int occurrence) {
+ Gate gate = new Gate(occurrence);
+ gates.add(gate);
+ return gate;
+ }
+
+ void hideEntries() {
+ hideEntries = true;
+ }
+
+ void reportConstraintViolationsBySqlState() {
+ bySqlState = true;
+ }
+
+ long operations(String name) {
+ return executed.stream().filter(name::equals).count();
+ }
+
+ void releaseAll() {
+ gates.forEach(Gate::release);
+ }
+
+ private void beforeOperation(String name) {
+ executed.add(name);
+ int number = count.incrementAndGet();
+ gates.forEach(gate -> gate.beforeOperation(number));
+ }
+
+ EntityManagerFactory proxy() {
+ return proxy(EntityManagerFactory.class, delegate, (method, args)
-> {
+ Object result = invoke(delegate, method, args);
+ if (result instanceof EntityManager em) {
+ return proxy(EntityManager.class, em, (m, a) ->
onEntityManager(em, m, a));
+ }
+ return result;
+ });
+ }
+
+ private Object onEntityManager(EntityManager em, Method method,
Object[] args) throws Throwable {
+ String name = method.getName();
+ if ("persist".equals(name) || "merge".equals(name) ||
"remove".equals(name) || "flush".equals(name)) {
+ beforeOperation(name);
+ }
+ Object result;
+ try {
+ result = invoke(em, method, args);
+ } catch (PersistenceException e) {
+ if (bySqlState && "flush".equals(name)) {
+ throw new PersistenceException(
+ "Simulated driver error",
+ new SQLException("duplicate key value violates
unique constraint", "23505"));
+ }
+ throw e;
+ }
+ if (result instanceof Query query && "createQuery".equals(name) &&
args[0] instanceof String jpql) {
+ return proxy(Query.class, query, (m, a) -> onQuery(query,
jpql, m, a));
+ }
+ return result;
+ }
+
+ private Object onQuery(Query query, String jpql, Method method,
Object[] args) throws Throwable {
+ String name = method.getName();
+ if ("getResultList".equals(name) || "getSingleResult".equals(name)
|| "executeUpdate".equals(name)) {
+ beforeOperation(jpql.substring(0, jpql.indexOf(' ')));
+ if (hideEntries && "getResultList".equals(name) &&
jpql.startsWith("select e from")) {
+ return Collections.emptyList();
+ }
+ }
+ return invoke(query, method, args);
+ }
+
+ private static Object invoke(Object target, Method method, Object[]
args) throws Throwable {
+ try {
+ return method.invoke(target, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ }
+
+ @FunctionalInterface
+ private interface Handler {
+ Object handle(Method method, Object[] args) throws Throwable;
+ }
+
+ private static <T> T proxy(Class<T> type, Object target, Handler
handler) {
+ InvocationHandler invocationHandler = (p, method, args) -> {
+ if (method.getDeclaringClass() == Object.class) {
+ // equals, hashCode and toString of the proxy are those of
the target
+ if ("equals".equals(method.getName())) {
+ return p == args[0];
+ }
+ return invoke(target, method, args);
+ }
+ return handler.handle(method, args);
+ };
+ return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new
Class<?>[] { type }, invocationHandler));
+ }
+ }
+}
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
index 750a45c396ba..b7e26662cd75 100644
---
a/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
+++
b/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
@@ -80,7 +80,7 @@ Choosing the right backend is typically a matter of:
| `JpaKeyValueRepository`
| `camel-jpa`
| JPA entity-backed; works with any JPA 2 provider (Hibernate, EclipseLink…)
-| ✗ (non-atomic)
+| ✓ (primary key)
| ✗ (client-side)
| `CassandraKeyValueRepository`
@@ -675,8 +675,11 @@ YAML DSL::
----
====
-NOTE: The JPA backend does not provide atomic `putIfAbsent`. Idempotency under
concurrent
-load relies on unique-constraint violations being caught at the database level.
+NOTE: `JpaKeyValueRepository.putIfAbsent` relies on the primary key of the
table: when concurrent
+inserts of the same key fail with a constraint violation, the transaction is
rolled back and the key
+is read again in a new transaction, which returns the value stored by the
other caller. After three
+failed inserts the `PersistenceException` is thrown. When `putIfAbsent` runs
in a transaction of the
+caller, the failed insert marks that transaction for rollback, so the caller's
transaction fails.
=== CassandraKeyValueRepository
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 14c0c98d828d..7d1a8214d90f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -7434,6 +7434,17 @@ The return type of the protected method
`JpaEndpoint.createTransactionStrategy()
of `DefaultTransactionStrategy`. A subclass that overrides it must be
recompiled: compiled against the old return type,
its method is no longer called.
+=== camel-jpa - JpaKeyValueRepository: putIfAbsent after a constraint violation
+
+When the insert of `JpaKeyValueRepository.putIfAbsent` fails with a constraint
violation because another caller stored
+the key, the key is read again in a new transaction, which returns the value
stored by the other caller. Before, the key
+was read again in the failed transaction, which the constraint violation had
marked for rollback, so `putIfAbsent`
+failed when that transaction was committed (with a
`DataIntegrityViolationException` when the transaction strategy is
+Spring based). After a constraint violation, `putIfAbsent` now throws a
`PersistenceException` only when the insert has
+failed three times. A constraint violation is also recognised by its SQL state
(class `23`), as the PostgreSQL and SQL
+Server drivers report it. When `putIfAbsent` runs in a transaction of the
caller, the failed insert marks that
+transaction for rollback, so the caller's transaction fails, as before.
+
=== camel-jbang - the export to Quarkus keeps camel-quarkus-microprofile-health
`camel export --runtime=quarkus`, and `camel run --runtime=quarkus`, which
exports the same way, now write