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 d70d1b67fa7c CAMEL-26708: camel-sql - JdbcKeyValueRepository
putIfAbsent has one winner and expiry keeps a live entry (#29405)
d70d1b67fa7c is described below
commit d70d1b67fa7cbbc7dbf6752553168acd154b0331
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 8 16:31:10 2026 +0530
CAMEL-26708: camel-sql - JdbcKeyValueRepository putIfAbsent has one winner
and expiry keeps a live entry (#29405)
* CAMEL-26708: camel-sql - JdbcKeyValueRepository putIfAbsent has one
winner and expiry keeps a live entry
* CAMEL-26708: camel-sql - JdbcKeyValueRepository docs: class name, atomic
putIfAbsent, synchronised clocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../catalog/beans/JdbcKeyValueRepository.json | 2 +-
.../camel/catalog/docs/keyValueRepository.adoc | 21 +-
.../jdbc/JdbcKeyValueRepositoryConfigurer.java | 6 +
.../apache/camel/bean/JdbcKeyValueRepository.json | 2 +-
.../keyvalue/jdbc/JdbcKeyValueRepository.java | 74 ++++--
.../JdbcKeyValueRepositoryConcurrencyTest.java | 255 +++++++++++++++++++++
.../modules/eips/pages/keyValueRepository.adoc | 21 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 15 ++
.../dsl/jbang/core/commands/ai/eip-samples.json | 2 +-
9 files changed, 356 insertions(+), 42 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
index 655fa16a61ca..7e84b41ecbe3 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
@@ -10,7 +10,7 @@
"groupId": "org.apache.camel",
"artifactId": "camel-sql",
"version": "4.24.0-SNAPSHOT",
- "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
+ "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
}
}
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 119785e05086..139f35c2945a 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
@@ -74,7 +74,7 @@ Choosing the right backend is typically a matter of:
| `JdbcKeyValueRepository`
| `camel-sql`
| Any JDBC DataSource; portable; optional auto-DDL; ACID via DataSource
transactions
-| ✗ (non-atomic)
+| ✓ (primary key)
| ✗ (client-side)
| `JpaKeyValueRepository`
@@ -588,7 +588,7 @@ YAML DSL::
----
- beans:
- name: jdbcKvr
- type: org.apache.camel.component.sql.JdbcKeyValueRepository
+ type: org.apache.camel.processor.keyvalue.jdbc.JdbcKeyValueRepository
properties:
dataSource: "#bean:myDataSource"
tableName: product_cache
@@ -596,10 +596,14 @@ YAML DSL::
----
====
-NOTE: `JdbcKeyValueRepository` does not implement atomic `putIfAbsent` —
concurrent inserts
-may produce duplicate-key exceptions that are caught and treated as a "key
already exists"
-condition. This is safe for idempotency at low concurrency, but for
high-throughput
-exactly-once semantics, prefer a backend with native CAS.
+NOTE: `JdbcKeyValueRepository.putIfAbsent` relies on the primary key of the
table: when concurrent
+inserts of the same key fail with a duplicate key, the transaction is rolled
back and the key is read
+again in a new transaction, which returns the value stored by the other
caller. When `putIfAbsent`
+runs in a transaction of the caller, the `DuplicateKeyException` is thrown
instead.
+
+An entry with a TTL expires lazily: the node that reads it compares its own
clock with the `EXPIRES_AT`
+value written by the node that stored it, so all nodes need synchronised
clocks. A node whose clock runs
+ahead treats the entry as expired, and deletes it, before its TTL has passed.
=== JpaKeyValueRepository
@@ -657,9 +661,8 @@ YAML DSL::
----
====
-NOTE: Like `JdbcKeyValueRepository`, the JPA backend does not provide atomic
-`putIfAbsent`. Idempotency under concurrent load relies on unique-constraint
violations
-being caught at the database level.
+NOTE: The JPA backend does not provide atomic `putIfAbsent`. Idempotency under
concurrent
+load relies on unique-constraint violations being caught at the database level.
=== CassandraKeyValueRepository
diff --git
a/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
b/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
index 35e4baa2c619..9f0fe963cbe3 100644
---
a/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
+++
b/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
@@ -32,6 +32,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "createTableIfNotExists":
target.setCreateTableIfNotExists(property(camelContext, boolean.class, value));
return true;
case "deleteexpiredstring":
case "deleteExpiredString":
target.setDeleteExpiredString(property(camelContext, java.lang.String.class,
value)); return true;
+ case "deleteifexpiredstring":
+ case "deleteIfExpiredString":
target.setDeleteIfExpiredString(property(camelContext, java.lang.String.class,
value)); return true;
case "deleteifvaluestring":
case "deleteIfValueString":
target.setDeleteIfValueString(property(camelContext, java.lang.String.class,
value)); return true;
case "deletestring":
@@ -69,6 +71,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "createTableIfNotExists": return boolean.class;
case "deleteexpiredstring":
case "deleteExpiredString": return java.lang.String.class;
+ case "deleteifexpiredstring":
+ case "deleteIfExpiredString": return java.lang.String.class;
case "deleteifvaluestring":
case "deleteIfValueString": return java.lang.String.class;
case "deletestring":
@@ -107,6 +111,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "createTableIfNotExists": return
target.isCreateTableIfNotExists();
case "deleteexpiredstring":
case "deleteExpiredString": return target.getDeleteExpiredString();
+ case "deleteifexpiredstring":
+ case "deleteIfExpiredString": return target.getDeleteIfExpiredString();
case "deleteifvaluestring":
case "deleteIfValueString": return target.getDeleteIfValueString();
case "deletestring":
diff --git
a/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
b/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
index 655fa16a61ca..7e84b41ecbe3 100644
---
a/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
+++
b/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
@@ -10,7 +10,7 @@
"groupId": "org.apache.camel",
"artifactId": "camel-sql",
"version": "4.24.0-SNAPSHOT",
- "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
+ "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
}
}
diff --git
a/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
b/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
index f05b43b16a2d..383a1c161b91 100644
---
a/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
+++
b/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
@@ -79,6 +79,11 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
= "UPDATE CAMEL_KEYVALUE SET ITEM_VALUE = ?, EXPIRES_AT = ? WHERE
ITEM_KEY = ? AND ITEM_VALUE = ?";
protected static final String DEFAULT_DELETE_IF_VALUE_STRING
= "DELETE FROM CAMEL_KEYVALUE WHERE ITEM_KEY = ? AND ITEM_VALUE =
?";
+ protected static final String DEFAULT_DELETE_IF_EXPIRED_STRING
+ = "DELETE FROM CAMEL_KEYVALUE WHERE ITEM_KEY = ? AND EXPIRES_AT >
0 AND EXPIRES_AT <= ?";
+
+ // 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 Logger LOG =
LoggerFactory.getLogger(JdbcKeyValueRepository.class);
@@ -113,6 +118,8 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
private String updateIfValueString = DEFAULT_UPDATE_IF_VALUE_STRING;
@Metadata(label = "advanced", description = "SQL query to use for
conditional delete (CAS delete)")
private String deleteIfValueString = DEFAULT_DELETE_IF_VALUE_STRING;
+ @Metadata(label = "advanced", description = "SQL query to use for deleting
an entry by key only if it has expired")
+ private String deleteIfExpiredString = DEFAULT_DELETE_IF_EXPIRED_STRING;
@Metadata(label = "advanced,security",
description = "Sets an ObjectInputFilter pattern
(jdk.serialFilter syntax) applied when deserializing"
+ " values read back from the repository. When not
set, the JVM-wide jdk.serialFilter is"
@@ -186,6 +193,7 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
deleteExpiredString =
DEFAULT_DELETE_EXPIRED_STRING.replace(DEFAULT_TABLENAME, tableName);
updateIfValueString =
DEFAULT_UPDATE_IF_VALUE_STRING.replace(DEFAULT_TABLENAME, tableName);
deleteIfValueString =
DEFAULT_DELETE_IF_VALUE_STRING.replace(DEFAULT_TABLENAME, tableName);
+ deleteIfExpiredString =
DEFAULT_DELETE_IF_EXPIRED_STRING.replace(DEFAULT_TABLENAME, tableName);
}
}
@@ -282,28 +290,41 @@ public class JdbcKeyValueRepository extends
ServiceSupport implements KeyValueRe
transactionTemplate.executeWithoutResult(status ->
jdbcTemplate.update(getClearString()));
}
+ /**
+ * 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 duplicate key, 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.
+ *
+ * @throws DuplicateKeyException if the INSERT failed in all attempts, or
at once in a transaction of the caller
+ */
@Override
public Object putIfAbsent(String key, Object value, Duration ttl) {
- return transactionTemplate.execute(status -> {
- // check if a non-expired entry already exists
- Object existing = doGet(key);
- if (existing != null) {
- return existing;
- }
- // attempt to insert
- long expiresAt = toExpiresAt(ttl);
+ byte[] bytes = KeyValueRepositoryHelper.serialize(value);
+ boolean[] joined = { false };
+ for (int attempt = 1;; attempt++) {
try {
- jdbcTemplate.update(getInsertString(), key,
KeyValueRepositoryHelper.serialize(value), expiresAt);
- return null;
+ return transactionTemplate.execute(status -> {
+ joined[0] = status.hasTransaction() &&
!status.isNewTransaction();
+ // check if a non-expired entry already exists
+ Object existing = doGet(key);
+ if (existing != null) {
+ return existing;
+ }
+ // attempt to insert
+ long expiresAt = toExpiresAt(ttl);
+ jdbcTemplate.update(getInsertString(), key, bytes,
expiresAt);
+ return null;
+ });
} catch (DuplicateKeyException e) {
- // concurrent insert race -- another thread/node won
- LOG.debug("Concurrent insert race for key '{}' -- another
thread won, treating as existing", key);
- status.setRollbackOnly();
- // re-read the value that the other thread inserted
- Object concurrentValue = doGet(key);
- return concurrentValue;
+ // 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 (joined[0] || attempt >= PUT_IF_ABSENT_ATTEMPTS) {
+ throw e;
+ }
+ LOG.debug("Concurrent insert race for key '{}' -- reading it
again in a new transaction", key);
}
- });
+ }
}
/**
@@ -361,29 +382,32 @@ public class JdbcKeyValueRepository extends
ServiceSupport implements KeyValueRe
/**
* Internal get that reads the value for the given key within the current
transaction. If the entry has expired, it
- * is deleted and {@code null} is returned.
+ * is deleted, unless it has been stored again meanwhile, and {@code null}
is returned.
*
* @param key the key to look up
* @return the deserialized value, or {@code null} if not found or
expired
*/
private Object doGet(String key) {
try {
- // Use a two-element array as a container: [0] = deserialized
value, [1] = expired flag.
+ // Use a single-element array as the expired flag.
// The DELETE for expired rows is performed after queryForObject
returns so that we
// do not execute a statement while the ResultSet for the SELECT
is still open (some
// JDBC drivers dislike that).
final boolean[] expired = { false };
+ final long now = System.currentTimeMillis();
Object value = jdbcTemplate.queryForObject(getSelectString(), (rs,
rowNum) -> {
byte[] bytes = rs.getBytes(1);
long expiresAt = rs.getLong(2);
- if (expiresAt > 0 && System.currentTimeMillis() >= expiresAt) {
+ if (expiresAt > 0 && now >= expiresAt) {
expired[0] = true;
return null;
}
return KeyValueRepositoryHelper.deserialize(bytes,
deserializationFilter);
}, key);
if (expired[0]) {
- jdbcTemplate.update(getDeleteString(), key);
+ // delete the row only if it is still expired: another
transaction may have stored the key again
+ // since it was read, and that entry must be kept
+ jdbcTemplate.update(getDeleteIfExpiredString(), key, now);
return null;
}
return value;
@@ -526,6 +550,14 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
this.deleteIfValueString = deleteIfValueString;
}
+ public String getDeleteIfExpiredString() {
+ return deleteIfExpiredString;
+ }
+
+ public void setDeleteIfExpiredString(String deleteIfExpiredString) {
+ this.deleteIfExpiredString = deleteIfExpiredString;
+ }
+
public String getDeserializationFilter() {
return deserializationFilter;
}
diff --git
a/components/camel-sql/src/test/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConcurrencyTest.java
b/components/camel-sql/src/test/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConcurrencyTest.java
new file mode 100644
index 000000000000..3486981997fb
--- /dev/null
+++
b/components/camel-sql/src/test/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConcurrencyTest.java
@@ -0,0 +1,255 @@
+/*
+ * 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.jdbc;
+
+import java.util.List;
+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 javax.sql.DataSource;
+
+import org.apache.camel.support.KeyValueRepositoryHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.TransactionTemplate;
+
+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 statements of node A can
be paused, so that the calls of both nodes
+ * interleave in a given order.
+ */
+class JdbcKeyValueRepositoryConcurrencyTest {
+
+ private EmbeddedDatabase dataSource;
+ private PausingJdbcTemplate jdbcA;
+ private JdbcKeyValueRepository nodeA;
+ private JdbcKeyValueRepository nodeB;
+ private final ExecutorService executor =
Executors.newSingleThreadExecutor();
+
+ private void startNodes(int isolationLevel) throws Exception {
+ dataSource = new EmbeddedDatabaseBuilder()
+ .setType(EmbeddedDatabaseType.H2)
+ .generateUniqueName(true)
+ .build();
+ jdbcA = new PausingJdbcTemplate(dataSource);
+ nodeA = new JdbcKeyValueRepository(jdbcA,
transactionTemplate(isolationLevel));
+ nodeB = new JdbcKeyValueRepository(new JdbcTemplate(dataSource),
transactionTemplate(isolationLevel));
+ nodeA.init();
+ nodeA.start();
+ nodeB.init();
+ nodeB.start();
+ }
+
+ private TransactionTemplate transactionTemplate(int isolationLevel) {
+ TransactionTemplate template = new TransactionTemplate(new
DataSourceTransactionManager(dataSource));
+ template.setIsolationLevel(isolationLevel);
+ return template;
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (jdbcA != null) {
+ jdbcA.releaseAll();
+ }
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
+ if (nodeA != null) {
+ nodeA.stop();
+ nodeB.stop();
+ }
+ if (dataSource != null) {
+ dataSource.shutdown();
+ }
+ }
+
+ @Test
+ void putIfAbsentHasOneWinnerUnderRepeatableRead() throws Exception {
+ startNodes(TransactionDefinition.ISOLATION_REPEATABLE_READ);
+ Gate beforeInsert = jdbcA.pause("INSERT", 1);
+
+ Future<Object> a = executor.submit(() -> nodeA.putIfAbsent("order-1",
"A", null));
+ // A has read the key (absent), so its snapshot does not show what B
stores now
+ beforeInsert.awaitReached();
+ assertNull(nodeB.putIfAbsent("order-1", "B", null), "B stores the key
first");
+ // the INSERT of A fails with a duplicate key
+ 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 putIfAbsentDoesNotSucceedWithoutStoringTheValue() throws Exception {
+ startNodes(TransactionDefinition.ISOLATION_READ_COMMITTED);
+ Gate beforeInsert = jdbcA.pause("INSERT", 1);
+ Gate beforeSecondRead = jdbcA.pause("SELECT", 2);
+
+ 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 duplicate key, 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 readingAnExpiredEntryDoesNotDeleteTheEntryStoredMeanwhile() throws
Exception {
+ startNodes(TransactionDefinition.ISOLATION_READ_COMMITTED);
+ // an entry that expired long ago
+ nodeB.getJdbcTemplate().update(nodeB.getInsertString(), "job-42",
KeyValueRepositoryHelper.serialize("old"), 1L);
+ Gate beforeDelete = jdbcA.pause("DELETE", 1);
+
+ Future<Object> a = executor.submit(() -> nodeA.get("job-42"));
+ // A has read the entry as expired and is about to delete it
+ beforeDelete.awaitReached();
+ assertNull(nodeB.putIfAbsent("job-42", "B", null), "the expired entry
is absent, so B gets the key");
+ beforeDelete.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 putIfAbsentGivesUpAfterThreeDuplicateKeys() throws Exception {
+ startNodes(TransactionDefinition.ISOLATION_READ_COMMITTED);
+ assertNull(nodeB.putIfAbsent("job-1", "B", null));
+ // node A does not find the stored entry, so each of its INSERTs fails
with a duplicate key
+ nodeA.setSelectString(nodeA.getSelectString() + " AND 1 = 0");
+
+ assertThrows(DuplicateKeyException.class, () ->
nodeA.putIfAbsent("job-1", "A", null));
+ assertEquals(3, jdbcA.statements("INSERT"));
+ }
+
+ @Test
+ void putIfAbsentInTransactionOfCallerDoesNotTryAgain() throws Exception {
+ startNodes(TransactionDefinition.ISOLATION_READ_COMMITTED);
+ assertNull(nodeB.putIfAbsent("job-1", "B", null));
+ nodeA.setSelectString(nodeA.getSelectString() + " AND 1 = 0");
+ TransactionTemplate caller =
transactionTemplate(TransactionDefinition.ISOLATION_READ_COMMITTED);
+
+ // the transaction of the caller cannot go on after the duplicate key,
so the key is not read again in it
+ assertThrows(DuplicateKeyException.class,
+ () -> caller.executeWithoutResult(status ->
nodeA.putIfAbsent("job-1", "A", null)));
+ assertEquals(1, jdbcA.statements("INSERT"));
+ }
+
+ /**
+ * Pauses the n-th statement of a {@link PausingJdbcTemplate} that starts
with the given SQL verb, until released.
+ */
+ private static final class Gate {
+ private final String verb;
+ private final int occurrence;
+ private final AtomicInteger seen = new AtomicInteger();
+ private final CountDownLatch reached = new CountDownLatch(1);
+ private final CountDownLatch released = new CountDownLatch(1);
+
+ private Gate(String verb, int occurrence) {
+ this.verb = verb;
+ this.occurrence = occurrence;
+ }
+
+ private void beforeStatement(String sql) {
+ if (sql.startsWith(verb) && seen.incrementAndGet() == 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);
+ }
+ }
+ }
+
+ private static final class PausingJdbcTemplate extends JdbcTemplate {
+ private final List<Gate> gates = new CopyOnWriteArrayList<>();
+ private final List<String> executed = new CopyOnWriteArrayList<>();
+
+ private PausingJdbcTemplate(DataSource dataSource) {
+ super(dataSource);
+ }
+
+ Gate pause(String verb, int occurrence) {
+ Gate gate = new Gate(verb, occurrence);
+ gates.add(gate);
+ return gate;
+ }
+
+ long statements(String verb) {
+ return executed.stream().filter(sql ->
sql.startsWith(verb)).count();
+ }
+
+ void releaseAll() {
+ gates.forEach(Gate::release);
+ }
+
+ private void beforeStatement(String sql) {
+ executed.add(sql);
+ gates.forEach(gate -> gate.beforeStatement(sql));
+ }
+
+ @Override
+ public <T> T queryForObject(String sql, RowMapper<T> rowMapper,
Object... args) {
+ beforeStatement(sql);
+ return super.queryForObject(sql, rowMapper, args);
+ }
+
+ @Override
+ public int update(String sql, Object... args) {
+ beforeStatement(sql);
+ return super.update(sql, args);
+ }
+ }
+}
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 119785e05086..139f35c2945a 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
@@ -74,7 +74,7 @@ Choosing the right backend is typically a matter of:
| `JdbcKeyValueRepository`
| `camel-sql`
| Any JDBC DataSource; portable; optional auto-DDL; ACID via DataSource
transactions
-| ✗ (non-atomic)
+| ✓ (primary key)
| ✗ (client-side)
| `JpaKeyValueRepository`
@@ -588,7 +588,7 @@ YAML DSL::
----
- beans:
- name: jdbcKvr
- type: org.apache.camel.component.sql.JdbcKeyValueRepository
+ type: org.apache.camel.processor.keyvalue.jdbc.JdbcKeyValueRepository
properties:
dataSource: "#bean:myDataSource"
tableName: product_cache
@@ -596,10 +596,14 @@ YAML DSL::
----
====
-NOTE: `JdbcKeyValueRepository` does not implement atomic `putIfAbsent` —
concurrent inserts
-may produce duplicate-key exceptions that are caught and treated as a "key
already exists"
-condition. This is safe for idempotency at low concurrency, but for
high-throughput
-exactly-once semantics, prefer a backend with native CAS.
+NOTE: `JdbcKeyValueRepository.putIfAbsent` relies on the primary key of the
table: when concurrent
+inserts of the same key fail with a duplicate key, the transaction is rolled
back and the key is read
+again in a new transaction, which returns the value stored by the other
caller. When `putIfAbsent`
+runs in a transaction of the caller, the `DuplicateKeyException` is thrown
instead.
+
+An entry with a TTL expires lazily: the node that reads it compares its own
clock with the `EXPIRES_AT`
+value written by the node that stored it, so all nodes need synchronised
clocks. A node whose clock runs
+ahead treats the entry as expired, and deletes it, before its TTL has passed.
=== JpaKeyValueRepository
@@ -657,9 +661,8 @@ YAML DSL::
----
====
-NOTE: Like `JdbcKeyValueRepository`, the JPA backend does not provide atomic
-`putIfAbsent`. Idempotency under concurrent load relies on unique-constraint
violations
-being caught at the database level.
+NOTE: The JPA backend does not provide atomic `putIfAbsent`. Idempotency under
concurrent
+load relies on unique-constraint violations being caught at the database level.
=== 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 10ab4517e865..790e3865ee8b 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
@@ -2865,6 +2865,21 @@ A single `Map` body is now one batch item, and so is a
single `Map` in the `Came
(sql-stored). Before, its entries were used as the batch items and the call
failed (sql: no value found for the named
parameters, sql-stored: an `SQLException`).
+=== camel-sql - JdbcKeyValueRepository: deleteIfExpiredString, and putIfAbsent
after a duplicate key
+
+When `JdbcKeyValueRepository` reads an entry that has expired, it deletes the
row with the new `deleteIfExpiredString`
+statement (`DELETE FROM CAMEL_KEYVALUE WHERE ITEM_KEY = ? AND EXPIRES_AT > 0
AND EXPIRES_AT <= ?`) instead of
+`deleteString`, so that an entry another node stored for the key in the
meantime is kept. If you set `selectString`,
+`insertString` and `deleteString` for a table with other column names, set
`deleteIfExpiredString` as well; otherwise
+reading an expired entry fails with an SQL error. With `tableName` it is
derived from the default statement, as the
+other statements are.
+
+When the INSERT of `putIfAbsent` fails with a duplicate 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 could return
+`null` although nothing was stored). `putIfAbsent` now throws the
`DuplicateKeyException` when the INSERT fails three
+times, or at once when it runs in a transaction of the caller, as that
transaction cannot be used to read the key
+again.
+
=== camel-jta - an exchange marked rollback only last rolls back its
transaction
In a `transacted()` route that uses a JTA transaction manager,
`markRollbackOnlyLast()` now rolls back the
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
index a4449af9330c..58c73ca7a719 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
@@ -590,7 +590,7 @@
},
{
"source": "keyValueRepository.adoc",
- "yaml": "- beans:\n - name: jdbcKvr\n type:
org.apache.camel.component.sql.JdbcKeyValueRepository\n properties:\n
dataSource: \"#bean:myDataSource\"\n tableName: product_cache\n
createTableIfNotExists: true\n"
+ "yaml": "- beans:\n - name: jdbcKvr\n type:
org.apache.camel.processor.keyvalue.jdbc.JdbcKeyValueRepository\n
properties:\n dataSource: \"#bean:myDataSource\"\n tableName:
product_cache\n createTableIfNotExists: true\n"
},
{
"source": "keyValueRepository.adoc",