This is an automated email from the ASF dual-hosted git repository.
gnodet 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 aa7bf6a6820b CAMEL-24595: CassandraKeyValueRepository: implement
atomic replace() and delete(key, expected) using LWT
aa7bf6a6820b is described below
commit aa7bf6a6820b9cdc89dab3b0271abd4f79a8875a
Author: Guillaume Nodet <[email protected]>
AuthorDate: Thu Sep 3 11:25:56 2026 +0200
CAMEL-24595: CassandraKeyValueRepository: implement atomic replace() and
delete(key, expected) using LWT
* CAMEL-24595: CassandraKeyValueRepository: implement atomic replace() and
delete(key, expected) using lightweight transactions
* Regen
---
.../cassandra/CassandraKeyValueRepository.java | 107 ++++++++++++++++++++-
.../cassandra/CassandraKeyValueRepositoryIT.java | 20 ++++
2 files changed, 125 insertions(+), 2 deletions(-)
diff --git
a/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
b/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
index 156c47b5ce90..b365dd6f2fa2 100644
---
a/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
+++
b/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
@@ -28,9 +28,11 @@ import
com.datastax.oss.driver.api.core.cql.PreparedStatement;
import com.datastax.oss.driver.api.core.cql.ResultSet;
import com.datastax.oss.driver.api.core.cql.Row;
import com.datastax.oss.driver.api.core.cql.SimpleStatement;
+import com.datastax.oss.driver.api.querybuilder.QueryBuilder;
import com.datastax.oss.driver.api.querybuilder.delete.Delete;
import com.datastax.oss.driver.api.querybuilder.select.Select;
import com.datastax.oss.driver.api.querybuilder.truncate.Truncate;
+import com.datastax.oss.driver.api.querybuilder.update.Update;
import org.apache.camel.api.management.ManagedAttribute;
import org.apache.camel.api.management.ManagedOperation;
import org.apache.camel.api.management.ManagedResource;
@@ -58,8 +60,13 @@ import static
org.apache.camel.utils.cassandra.CassandraUtils.generateTruncate;
* stored as {@code TEXT}. Time-to-live is handled natively by Cassandra's
{@code USING TTL} clause on {@code INSERT}
* statements, so expired entries are removed automatically by Cassandra
without any client-side eviction logic.
* <p/>
- * The {@link #putIfAbsent(String, Object, long)} method is implemented
atomically using Cassandra's lightweight
- * transactions ({@code INSERT ... IF NOT EXISTS}).
+ * The CAS (compare-and-swap) methods are implemented atomically using
Cassandra's lightweight transactions (LWT):
+ * <ul>
+ * <li>{@link #putIfAbsent(String, Object, Duration)} uses {@code INSERT ...
IF NOT EXISTS}</li>
+ * <li>{@link #replace(String, Object, Object, Duration)} uses
+ * {@code UPDATE ... SET value = ? WHERE key = ? IF value = ?}</li>
+ * <li>{@link #delete(String, Object)} uses {@code DELETE FROM ... WHERE key =
? IF value = ?}</li>
+ * </ul>
* <p/>
* Advice: use LeveledCompaction for the backing table and tune read/write
consistency levels for your use case.
*
@@ -97,6 +104,9 @@ public class CassandraKeyValueRepository extends
ServiceSupport implements KeyVa
private PreparedStatement truncateStatement;
private PreparedStatement insertIfNotExistsStatement;
private PreparedStatement insertIfNotExistsWithTtlStatement;
+ private PreparedStatement updateIfValueStatement;
+ private PreparedStatement updateIfValueWithTtlStatement;
+ private PreparedStatement deleteIfValueStatement;
public CassandraKeyValueRepository() {
}
@@ -134,6 +144,9 @@ public class CassandraKeyValueRepository extends
ServiceSupport implements KeyVa
initClearStatement();
initInsertIfNotExistsStatement();
initInsertIfNotExistsWithTtlStatement();
+ initUpdateIfValueStatement();
+ initUpdateIfValueWithTtlStatement();
+ initDeleteIfValueStatement();
}
@Override
@@ -222,6 +235,38 @@ public class CassandraKeyValueRepository extends
ServiceSupport implements KeyVa
insertIfNotExistsWithTtlStatement = getSession().prepare(statement);
}
+ protected void initUpdateIfValueStatement() {
+ // UPDATE table SET value = ? WHERE key = ? IF value = ?
+ Update update = QueryBuilder.update(table)
+ .setColumn(VALUE_COLUMN, bindMarker())
+ .whereColumn(KEY_COLUMN).isEqualTo(bindMarker())
+ .ifColumn(VALUE_COLUMN).isEqualTo(bindMarker());
+ SimpleStatement statement = applyConsistencyLevel(update.build(),
writeConsistencyLevel);
+ LOGGER.debug("Generated Update if value {}", statement);
+ updateIfValueStatement = getSession().prepare(statement);
+ }
+
+ protected void initUpdateIfValueWithTtlStatement() {
+ // UPDATE table USING TTL ? SET value = ? WHERE key = ? IF value = ?
+ Update update = QueryBuilder.update(table)
+ .usingTtl(bindMarker())
+ .setColumn(VALUE_COLUMN, bindMarker())
+ .whereColumn(KEY_COLUMN).isEqualTo(bindMarker())
+ .ifColumn(VALUE_COLUMN).isEqualTo(bindMarker());
+ SimpleStatement statement = applyConsistencyLevel(update.build(),
writeConsistencyLevel);
+ LOGGER.debug("Generated Update if value with TTL {}", statement);
+ updateIfValueWithTtlStatement = getSession().prepare(statement);
+ }
+
+ protected void initDeleteIfValueStatement() {
+ // DELETE FROM table WHERE key = ? IF value = ?
+ Delete delete = generateDelete(table, new String[] { KEY_COLUMN },
false)
+ .ifColumn(VALUE_COLUMN).isEqualTo(bindMarker());
+ SimpleStatement statement = applyConsistencyLevel(delete.build(),
writeConsistencyLevel);
+ LOGGER.debug("Generated Delete if value {}", statement);
+ deleteIfValueStatement = getSession().prepare(statement);
+ }
+
//
-------------------------------------------------------------------------
// KeyValueRepository operations
@@ -319,6 +364,64 @@ public class CassandraKeyValueRepository extends
ServiceSupport implements KeyVa
return existingBuffer != null ?
KeyValueRepositoryHelper.deserialize(existingBuffer) : null;
}
+ /**
+ * Atomically replaces the value for the given key only if the current
value equals the expected old value, using
+ * Cassandra's lightweight transaction ({@code UPDATE ... SET value = ?
WHERE key = ? IF value = ?}).
+ * <p/>
+ * <b>Note:</b> the comparison is performed server-side on the
<em>serialized</em> byte representation of the value,
+ * not via {@link java.util.Objects#equals(Object, Object)} on the
deserialized objects (as the default SPI
+ * implementation does). Two objects that are {@code .equals()} but
serialize to different bytes (e.g. maps/sets
+ * with non-deterministic iteration order) would cause this method to
return {@code false} where the default
+ * implementation would return {@code true}.
+ *
+ * @param expectedOldValue the value that must currently be associated
with the key
+ * @param newValue the new value to store
+ * @param ttl the time-to-live for the new entry; {@code
null}, zero, or negative means no expiration
+ * @return {@code true} if the value was replaced, {@code
false} if the current value did not match
+ */
+ @Override
+ public boolean replace(String key, Object expectedOldValue, Object
newValue, Duration ttl) {
+ LOGGER.debug("Replacing key {} if value matches, TTL {}", key, ttl);
+ ByteBuffer serializedNewValue =
KeyValueRepositoryHelper.serializeToByteBuffer(newValue);
+ ByteBuffer serializedExpectedValue =
KeyValueRepositoryHelper.serializeToByteBuffer(expectedOldValue);
+ ResultSet rs;
+ int ttlSeconds = toTtlSeconds(ttl);
+ if (ttlSeconds > 0) {
+ // bind order: TTL, newValue (SET), key (WHERE), expectedValue (IF)
+ rs = getSession().execute(
+ updateIfValueWithTtlStatement.bind(ttlSeconds,
serializedNewValue, key, serializedExpectedValue));
+ } else {
+ // bind order: newValue (SET), key (WHERE), expectedValue (IF)
+ rs = getSession().execute(
+ updateIfValueStatement.bind(serializedNewValue, key,
serializedExpectedValue));
+ }
+ return isApplied(rs);
+ }
+
+ /**
+ * Atomically removes the entry for the given key only if the current
value equals the expected value, using
+ * Cassandra's lightweight transaction ({@code DELETE FROM ... WHERE key =
? IF value = ?}).
+ * <p/>
+ * <b>Note:</b> the comparison is performed server-side on the
<em>serialized</em> byte representation of the value,
+ * not via {@link java.util.Objects#equals(Object, Object)} on the
deserialized objects (as the default SPI
+ * implementation does). Two objects that are {@code .equals()} but
serialize to different bytes (e.g. maps/sets
+ * with non-deterministic iteration order) would cause this method to
return {@code false} where the default
+ * implementation would return {@code true}.
+ *
+ * @param key the key to remove
+ * @param expectedValue the value that must currently be associated with
the key
+ * @return {@code true} if the entry was removed, {@code
false} if the current value did not match or
+ * the key was not present
+ */
+ @Override
+ public boolean delete(String key, Object expectedValue) {
+ LOGGER.debug("Deleting key {} if value matches", key);
+ ByteBuffer serializedExpectedValue =
KeyValueRepositoryHelper.serializeToByteBuffer(expectedValue);
+ // bind order: key (WHERE), expectedValue (IF)
+ ResultSet rs = getSession().execute(deleteIfValueStatement.bind(key,
serializedExpectedValue));
+ return isApplied(rs);
+ }
+
@Override
@ManagedAttribute(description = "The number of entries in the repository")
public int size() {
diff --git
a/components/camel-cassandraql/src/test/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepositoryIT.java
b/components/camel-cassandraql/src/test/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepositoryIT.java
index 2855ff20f62d..221adb218d49 100644
---
a/components/camel-cassandraql/src/test/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepositoryIT.java
+++
b/components/camel-cassandraql/src/test/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepositoryIT.java
@@ -170,6 +170,26 @@ public class CassandraKeyValueRepositoryIT extends
BaseCassandra {
assertFalse(replaced);
}
+ @Test
+ public void testReplaceWithTtl() {
+ repository.put("key1", "value1", null);
+ boolean replaced = repository.replace("key1", "value1", "value2",
Duration.ofSeconds(2));
+ assertTrue(replaced);
+ assertEquals("value2", repository.get("key1"));
+
+ // The replaced value should expire after the TTL
+ await().atMost(10, TimeUnit.SECONDS)
+ .untilAsserted(() -> assertNull(repository.get("key1")));
+ }
+
+ @Test
+ public void testReplaceWithTtlNonMatchingOldValue() {
+ repository.put("key1", "value1", null);
+ boolean replaced = repository.replace("key1", "wrong", "value2",
Duration.ofSeconds(2));
+ assertFalse(replaced);
+ assertEquals("value1", repository.get("key1"));
+ }
+
@Test
public void testDeleteWithExpectedValueMatching() {
repository.put("key1", "value1", null);