Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/content/modules/ROOT/pages/overview.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
----
Expand All @@ -488,6 +489,30 @@ JSONOperations<String> 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<Object> results = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> 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<String> searchOps = modulesOperations.opsForSearch("myIndex");
SearchResult result = searchOps.search(new Query("@name:redis"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ public record RedisModulesOperations<K>(RedisModulesClient client, StringRedisTe
* Creates and returns operations for interacting with RedisJSON module.
* <p>
* 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.
* </p>
*
* @return a {@link JSONOperations} instance for JSON document operations
*/
public JSONOperations<K> opsForJSON() {
return new JSONOperationsImpl<>(client, gsonBuilder);
// Pass the template to enable transaction support
return new JSONOperationsImpl<>(client, gsonBuilder, template);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,17 +30,33 @@ public class JSONOperationsImpl<K> implements JSONOperations<K> {

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;
}

/**
Expand Down Expand Up @@ -163,24 +183,58 @@ public final <T> List<T> mget(Path2 path, Class<T> 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
* @param path the JSON path to set the data at
*/
@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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> redisModulesOperations;

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Autowired
private RedisTemplate<String, String> 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<Object> result1 = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> 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<Object> result = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> 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<Object> result = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> 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<Object> result = template.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> 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;
}
}