diff --git a/docs/content/modules/ROOT/pages/overview.adoc b/docs/content/modules/ROOT/pages/overview.adoc index 6f7d13266..7675d83ed 100644 --- a/docs/content/modules/ROOT/pages/overview.adoc +++ b/docs/content/modules/ROOT/pages/overview.adoc @@ -477,6 +477,7 @@ These features are designed for power users, customization, and advanced integra * JSON operations via `JSONOperations` for low-level JSON document manipulation * Search operations via `SearchOperations` for direct RediSearch functionality * Probabilistic data structure operations: `BloomOperations`, `CuckooFilterOperations`, `CountMinSketchOperations`, `TopKOperations`, `TDigestOperations` +* **Transaction Support**: JSON operations automatically participate in Redis transactions when executed within a transaction context [source,java] ---- @@ -488,6 +489,30 @@ JSONOperations jsonOps = modulesOperations.opsForJSON(); jsonOps.set("obj", myObject); MyObject retrieved = jsonOps.get("obj", MyObject.class); +// JSON operations with transactions (automatically participates) +@Autowired +private StringRedisTemplate stringRedisTemplate; + +List results = stringRedisTemplate.execute(new SessionCallback>() { + @Override + public List execute(RedisOperations operations) throws DataAccessException { + operations.watch("obj"); // Watch for concurrent modifications + + // Read current value + MyObject current = jsonOps.get("obj", MyObject.class); + + // Start transaction + operations.multi(); + + // JSON operations are automatically queued in the transaction + jsonOps.set("obj", updatedObject); + jsonOps.set("backup", current); + + // Execute transaction (returns empty list if watched key was modified) + return operations.exec(); + } +}); + // Direct search operations SearchOperations searchOps = modulesOperations.opsForSearch("myIndex"); SearchResult result = searchOps.search(new Query("@name:redis")); diff --git a/redis-om-spring/src/main/java/com/redis/om/spring/ops/RedisModulesOperations.java b/redis-om-spring/src/main/java/com/redis/om/spring/ops/RedisModulesOperations.java index bd4e79fce..3539058a1 100644 --- a/redis-om-spring/src/main/java/com/redis/om/spring/ops/RedisModulesOperations.java +++ b/redis-om-spring/src/main/java/com/redis/om/spring/ops/RedisModulesOperations.java @@ -43,13 +43,15 @@ public record RedisModulesOperations(RedisModulesClient client, StringRedisTe * Creates and returns operations for interacting with RedisJSON module. *

* RedisJSON operations allow storing, retrieving, and manipulating JSON documents - * directly in Redis with path-based access to JSON elements. + * directly in Redis with path-based access to JSON elements. The operations + * automatically participate in Redis transactions when executed within a transaction context. *

* * @return a {@link JSONOperations} instance for JSON document operations */ public JSONOperations opsForJSON() { - return new JSONOperationsImpl<>(client, gsonBuilder); + // Pass the template to enable transaction support + return new JSONOperationsImpl<>(client, gsonBuilder, template); } /** diff --git a/redis-om-spring/src/main/java/com/redis/om/spring/ops/json/JSONOperationsImpl.java b/redis-om-spring/src/main/java/com/redis/om/spring/ops/json/JSONOperationsImpl.java index d1d5f3432..af795ff1e 100644 --- a/redis-om-spring/src/main/java/com/redis/om/spring/ops/json/JSONOperationsImpl.java +++ b/redis-om-spring/src/main/java/com/redis/om/spring/ops/json/JSONOperationsImpl.java @@ -5,6 +5,10 @@ import java.util.Objects; import org.json.JSONArray; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisConnectionUtils; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.lang.Nullable; import com.google.gson.Gson; @@ -26,17 +30,33 @@ public class JSONOperationsImpl implements JSONOperations { private final GsonBuilder builder; private final RedisModulesClient client; + private final StringRedisTemplate template; + private final RedisConnectionFactory connectionFactory; private Gson gson; /** * Constructs a new JSONOperationsImpl with the specified client and JSON builder. + * This constructor is for backward compatibility when no template is provided. * * @param client the Redis modules client for JSON operations * @param builder the Gson builder for JSON serialization/deserialization */ public JSONOperationsImpl(RedisModulesClient client, GsonBuilder builder) { + this(client, builder, null); + } + + /** + * Constructs a new JSONOperationsImpl with full transaction support. + * + * @param client the Redis modules client for JSON operations + * @param builder the Gson builder for JSON serialization/deserialization + * @param template the Spring Redis template for transaction management (optional) + */ + public JSONOperationsImpl(RedisModulesClient client, GsonBuilder builder, StringRedisTemplate template) { this.client = client; this.builder = builder; + this.template = template; + this.connectionFactory = template != null ? template.getConnectionFactory() : null; } /** @@ -163,17 +183,35 @@ public final List mget(Path2 path, Class clazz, K... keys) { /** * Sets a JSON document for the given key. + * Automatically participates in Redis transactions when executed within a transaction context. * * @param key the key to store the JSON document under * @param object the object to serialize and store as JSON */ @Override public void set(K key, Object object) { + // Check for transaction context if template is available + if (connectionFactory != null) { + RedisConnection connection = RedisConnectionUtils.getConnection(connectionFactory); + try { + if (connection.isQueueing()) { + // We're in a transaction - use execute to properly queue the command + connection.execute("JSON.SET", key.toString().getBytes(), ".".getBytes(), getGson().toJson(object) + .getBytes()); + return; + } + } finally { + RedisConnectionUtils.releaseConnection(connection, connectionFactory); + } + } + + // Not in a transaction or no template available - execute normally client.clientForJSON().jsonSet(key.toString(), Path2.ROOT_PATH, getGson().toJson(object)); } /** * Sets JSON data at the specified path for the given key. + * Automatically participates in Redis transactions when executed within a transaction context. * * @param key the key identifying the JSON document * @param object the object to serialize and store as JSON @@ -181,6 +219,22 @@ public void set(K key, Object object) { */ @Override public void set(K key, Object object, Path2 path) { + // Check for transaction context if template is available + if (connectionFactory != null) { + RedisConnection connection = RedisConnectionUtils.getConnection(connectionFactory); + try { + if (connection.isQueueing()) { + // We're in a transaction - use execute to properly queue the command + connection.execute("JSON.SET", key.toString().getBytes(), path.toString().getBytes(), getGson().toJson(object) + .getBytes()); + return; + } + } finally { + RedisConnectionUtils.releaseConnection(connection, connectionFactory); + } + } + + // Not in a transaction or no template available - execute normally client.clientForJSON().jsonSet(key.toString(), path, getGson().toJson(object)); } diff --git a/tests/src/test/java/com/redis/om/spring/ops/json/RedisModulesOperationsTransactionTest.java b/tests/src/test/java/com/redis/om/spring/ops/json/RedisModulesOperationsTransactionTest.java new file mode 100644 index 000000000..dc781041f --- /dev/null +++ b/tests/src/test/java/com/redis/om/spring/ops/json/RedisModulesOperationsTransactionTest.java @@ -0,0 +1,220 @@ +package com.redis.om.spring.ops.json; + +import com.redis.om.spring.AbstractBaseDocumentTest; +import com.redis.om.spring.ops.RedisModulesOperations; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test to reproduce issue #377: JSON operations not participating in Redis transactions + * + * The issue is that when using JSON.SET within a Redis transaction (WATCH/MULTI/EXEC), + * the JSON operation is executed outside the transaction on a different connection, + * defeating the purpose of using transactions for atomicity. + */ +public class RedisModulesOperationsTransactionTest extends AbstractBaseDocumentTest { + + @Autowired + private RedisModulesOperations redisModulesOperations; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private RedisTemplate template; + + private static final String TEST_KEY = "issue377:testkey"; + private static final String COUNTER_KEY = "issue377:counter"; + + @BeforeEach + void setup() { + // Clean up test keys + template.delete(TEST_KEY); + template.delete(COUNTER_KEY); + } + + @Test + void testTransactionWithWatchProtectsAgainstConcurrentModification() { + // This test verifies that WATCH properly protects against concurrent modifications + // when JSON operations are correctly participating in transactions + + // Set initial value + TestObject initialObject = new TestObject("initial", 1); + redisModulesOperations.opsForJSON().set(TEST_KEY, initialObject); + + // First transaction: Read value, then modify it + List result1 = stringRedisTemplate.execute(new SessionCallback>() { + @Override + public List execute(RedisOperations operations) throws DataAccessException { + // Watch the key + operations.watch(TEST_KEY); + + // Read current value (simulating decision based on current state) + TestObject current = redisModulesOperations.opsForJSON().get(TEST_KEY, TestObject.class); + assertThat(current.getName()).isEqualTo("initial"); + + // Simulate another client modifying the key between WATCH and EXEC + // This would happen in a real concurrent scenario + redisModulesOperations.opsForJSON().set(TEST_KEY, new TestObject("modified-by-other", 99)); + + // Now try to execute our transaction + operations.multi(); + redisModulesOperations.opsForJSON().set(TEST_KEY, new TestObject("our-change", 2)); + return operations.exec(); + } + }); + + // Transaction should fail because the watched key was modified + // Failed transactions return an empty list + assertThat(result1).isEmpty(); + + // The value should be from the concurrent modification, not our transaction + TestObject finalObject = redisModulesOperations.opsForJSON().get(TEST_KEY, TestObject.class); + assertThat(finalObject.getName()).isEqualTo("modified-by-other"); + assertThat(finalObject.getVersion()).isEqualTo(99); + } + + @Test + void testTransactionAtomicityForMultipleOperations() { + // This test verifies that JSON and regular Redis operations + // are atomic when executed in a transaction + + // Set initial values + TestObject initialObject = new TestObject("initial", 1); + redisModulesOperations.opsForJSON().set(TEST_KEY, initialObject); + stringRedisTemplate.opsForValue().set(COUNTER_KEY, "0"); + + // Execute a transaction with multiple operations + List result = stringRedisTemplate.execute(new SessionCallback>() { + @Override + public List execute(RedisOperations operations) throws DataAccessException { + operations.multi(); + + // Queue multiple operations + redisModulesOperations.opsForJSON().set(TEST_KEY, new TestObject("updated", 2)); + operations.opsForValue().increment(COUNTER_KEY); + operations.opsForValue().increment(COUNTER_KEY); + operations.opsForValue().increment(COUNTER_KEY); + + return operations.exec(); + } + }); + + // All operations should have succeeded atomically + assertThat(result).isNotNull(); + assertThat(result).hasSize(4); // 1 JSON.SET + 3 INCREMENTs + + // Verify final state - all operations executed + String counterValue = stringRedisTemplate.opsForValue().get(COUNTER_KEY); + assertThat(counterValue).isEqualTo("3"); + + TestObject currentObject = redisModulesOperations.opsForJSON().get(TEST_KEY, TestObject.class); + assertThat(currentObject.getName()).isEqualTo("updated"); + assertThat(currentObject.getVersion()).isEqualTo(2); + } + + @Test + void testTransactionRollbackOnWatchedKeyChange() { + // This test verifies proper rollback behavior when a watched key changes + + // Set initial values + redisModulesOperations.opsForJSON().set(TEST_KEY, new TestObject("initial", 1)); + stringRedisTemplate.opsForValue().set(COUNTER_KEY, "10"); + + // Execute transaction that will be aborted due to watched key change + List result = stringRedisTemplate.execute(new SessionCallback>() { + @Override + public List execute(RedisOperations operations) throws DataAccessException { + // Watch the counter key + operations.watch(COUNTER_KEY); + + // Read initial value + String initialCounter = (String) operations.opsForValue().get(COUNTER_KEY); + assertThat(initialCounter).isEqualTo("10"); + + // Modify watched key outside transaction (simulating concurrent modification) + stringRedisTemplate.opsForValue().set(COUNTER_KEY, "99"); + + // Try to execute transaction - should fail + operations.multi(); + redisModulesOperations.opsForJSON().set(TEST_KEY, new TestObject("should-not-be-set", 999)); + operations.opsForValue().set(COUNTER_KEY, "100"); + return operations.exec(); + } + }); + + // Transaction should have been aborted + // Aborted transactions return an empty list + assertThat(result).isEmpty(); + + // Neither operation should have been applied + TestObject obj = redisModulesOperations.opsForJSON().get(TEST_KEY, TestObject.class); + assertThat(obj.getName()).isEqualTo("initial"); // JSON unchanged + assertThat(obj.getVersion()).isEqualTo(1); + + String counter = stringRedisTemplate.opsForValue().get(COUNTER_KEY); + assertThat(counter).isEqualTo("99"); // Counter has concurrent modification value + } + + @Test + void testJsonOperationExecutesOutsideTransaction_ShowsIssue() { + // This test clearly demonstrates the issue: JSON operations execute immediately, + // not within the transaction boundary + + TestObject initialObject = new TestObject("initial", 1); + redisModulesOperations.opsForJSON().set(TEST_KEY, initialObject); + + // Create a flag to track if JSON operation executed before transaction completes + final boolean[] jsonExecutedBeforeExec = {false}; + + List result = template.execute(new SessionCallback>() { + @Override + public List execute(RedisOperations operations) throws DataAccessException { + operations.multi(); + + // This JSON.SET should be queued but executes immediately (bug) + redisModulesOperations.opsForJSON().set(TEST_KEY, new TestObject("insideTransaction", 2)); + + // Read the value - if transaction is working, should still be "initial" + // But due to bug, it will already be "insideTransaction" + TestObject valueBeforeExec = redisModulesOperations.opsForJSON().get(TEST_KEY, TestObject.class); + + if ("insideTransaction".equals(valueBeforeExec.getName())) { + jsonExecutedBeforeExec[0] = true; + } + + // Regular Redis operations are properly queued + operations.opsForValue().set(COUNTER_KEY, "1"); + + return operations.exec(); + } + }); + + // This assertion now passes with our fix - JSON operations are properly queued in transactions + assertThat(jsonExecutedBeforeExec[0]) + .as("JSON.SET should be queued in transaction, not executed immediately") + .isFalse(); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + static class TestObject { + private String name; + private int version; + } +} \ No newline at end of file