DATAREDIS-73 - Add support for spring managed transactions.

RedisTemplate now supports enabling of transaction support, which is disabled by default.

In case of enabled transaction support the RedisConnections will be bound to the current thread during ongoing outer transactions. We call MULTI at the beginning and depending on transaction state EXEC or DISCARD at its end.

To support read operations during the transaction we wrap a proxy around the bound connection piping read operations to a new (non thread bound) connection obtained by the underlying RedisConnectionFactory.

Transaction support is available for jedis, lettuce and srp, while had to be skipped for jredis due to the lack of support for MULTI.

Original pull request: #64.
This commit is contained in:
Christoph Strobl
2014-03-20 14:55:24 +01:00
committed by Thomas Darimont
parent 4e4a2961da
commit bc1bbd1d39
11 changed files with 1080 additions and 16 deletions

View File

@@ -0,0 +1,158 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed 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.springframework.data.redis.connection;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;
import javax.sql.DataSource;
import org.hamcrest.core.Is;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.test.context.transaction.TransactionConfiguration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
@RunWith(SpringJUnit4ClassRunner.class)
@Transactional
@TransactionConfiguration(transactionManager = "transactionManager")
public abstract class AbstractTransactionalTestBase {
@Configuration
public abstract static class RedisContextConfiguration {
@Bean
public StringRedisTemplate redisTemplate() {
StringRedisTemplate template = new StringRedisTemplate(redisConnectionFactory());
// explicitly enable transaction support
template.setEnableTransactionSupport(true);
return template;
}
@Bean
public abstract RedisConnectionFactory redisConnectionFactory();
@Bean
public PlatformTransactionManager transactionManager() throws SQLException {
return new DataSourceTransactionManager(dataSource());
}
@Bean
public DataSource dataSource() throws SQLException {
DataSource ds = Mockito.mock(DataSource.class);
Mockito.when(ds.getConnection()).thenReturn(Mockito.mock(Connection.class));
return ds;
}
}
private @Autowired StringRedisTemplate template;
private @Autowired RedisConnectionFactory factory;
private List<String> KEYS = Arrays.asList("spring", "data", "redis");
private boolean valuesShouldHaveBeenPersisted = false;
@Before
public void setUp() {
valuesShouldHaveBeenPersisted = false;
cleanDataStore();
}
private void cleanDataStore() {
RedisConnection connection = factory.getConnection();
connection.flushDb();
connection.close();
}
@AfterTransaction
public void verifyTransactionResult() {
RedisConnection connection = factory.getConnection();
for (String key : KEYS) {
Assert.assertThat("Values for " + key + " should " + (valuesShouldHaveBeenPersisted ? "" : "NOT ")
+ "have been found.", connection.exists(key.getBytes()), Is.is(valuesShouldHaveBeenPersisted));
}
connection.close();
}
/**
* @see DATAREDIS-73
*/
@Rollback(true)
@Test
public void valueOperationSetShouldBeRolledBackCorrectly() {
for (String key : KEYS) {
template.opsForValue().set(key, key + "-value");
}
}
/**
* @see DATAREDIS-73
*/
@Rollback(false)
@Test
public void valueOperationSetShouldBeCommittedCorrectly() {
this.valuesShouldHaveBeenPersisted = true;
for (String key : KEYS) {
template.opsForValue().set(key, key + "-value");
}
}
/**
* @see DATAREDIS-73
*/
@Rollback(true)
@Test
public void listOperationLPushShoudBeRolledBackCorrectly() {
for (String key : KEYS) {
template.opsForList().leftPushAll(key, (String[]) KEYS.toArray());
}
}
/**
* @see DATAREDIS-73
*/
@Rollback(false)
@Test
public void listOperationLPushShoudBeCommittedCorrectly() {
this.valuesShouldHaveBeenPersisted = true;
for (String key : KEYS) {
template.opsForList().leftPushAll(key, (String[]) KEYS.toArray());
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed 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.springframework.data.redis.connection.jedis;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.AbstractTransactionalTestBase;
import org.springframework.data.redis.connection.jedis.TransactionalJedisItegrationTests.JedisContextConfiguration;
import org.springframework.test.context.ContextConfiguration;
/**
* @author Christoph Strobl
*/
@ContextConfiguration(classes = { JedisContextConfiguration.class })
public class TransactionalJedisItegrationTests extends AbstractTransactionalTestBase {
@Configuration
public static class JedisContextConfiguration extends RedisContextConfiguration {
@Override
@Bean
public JedisConnectionFactory redisConnectionFactory() {
JedisConnectionFactory factory = new JedisConnectionFactory();
factory.setHostName("localhost");
factory.setPort(6379);
return factory;
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed 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.springframework.data.redis.connection.lettuce;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.AbstractTransactionalTestBase;
import org.springframework.data.redis.connection.lettuce.TransactionalLettuceItegrationTests.LettuceContextConfiguration;
import org.springframework.test.context.ContextConfiguration;
/**
* @author Christoph Strobl
*/
@ContextConfiguration(classes = { LettuceContextConfiguration.class })
public class TransactionalLettuceItegrationTests extends AbstractTransactionalTestBase {
@Configuration
public static class LettuceContextConfiguration extends RedisContextConfiguration {
@Override
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory factory = new LettuceConnectionFactory();
factory.setHostName("localhost");
factory.setPort(6379);
return factory;
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed 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.springframework.data.redis.connection.srp;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.AbstractTransactionalTestBase;
import org.springframework.data.redis.connection.srp.TransactionalSrpItegrationTests.SrpContextConfiguration;
import org.springframework.test.context.ContextConfiguration;
/**
* @author Christoph Strobl
*/
@ContextConfiguration(classes = { SrpContextConfiguration.class })
public class TransactionalSrpItegrationTests extends AbstractTransactionalTestBase {
@Configuration
public static class SrpContextConfiguration extends RedisContextConfiguration {
@Override
@Bean
public SrpConnectionFactory redisConnectionFactory() {
SrpConnectionFactory factory = new SrpConnectionFactory();
factory.setHostName("localhost");
factory.setPort(6379);
return factory;
}
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed 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.springframework.data.redis.core;
import java.lang.reflect.Method;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Matchers;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisConnectionUtils.ConnectionSplittingInterceptor;
import org.springframework.util.ClassUtils;
/**
* @author Christoph Strobl
*/
@RunWith(MockitoJUnitRunner.class)
public class ConnectionSplittingInterceptorUnitTests {
private static final Method WRITE_METHOD, READONLY_METHOD;
private ConnectionSplittingInterceptor interceptor;
private @Mock RedisConnectionFactory connectionFactoryMock;
private @Mock RedisConnection freshConnectionMock;
private @Mock RedisConnection boundConnectionMock;
static {
try {
WRITE_METHOD = ClassUtils.getMethod(RedisConnection.class, "expire", byte[].class, long.class);
READONLY_METHOD = ClassUtils.getMethod(RedisConnection.class, "keys", byte[].class);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Before
public void setUp() {
interceptor = new ConnectionSplittingInterceptor(connectionFactoryMock);
Mockito.when(connectionFactoryMock.getConnection()).thenReturn(freshConnectionMock);
}
/**
* @see DATAREDIS-73
*/
@Test
public void interceptorShouldRequestFreshConnectionForReadonlyCommand() throws Throwable {
interceptor.intercept(boundConnectionMock, READONLY_METHOD, new Object[] { new byte[] {} }, null);
Mockito.verify(connectionFactoryMock, Mockito.times(1)).getConnection();
Mockito.verifyZeroInteractions(boundConnectionMock);
}
/**
* @see DATAREDIS-73
*/
@Test
public void interceptorShouldUseBoundConnectionForWriteOperations() throws Throwable {
interceptor.intercept(boundConnectionMock, WRITE_METHOD, new Object[] { new byte[] {}, 0L }, null);
Mockito.verify(boundConnectionMock, Mockito.times(1)).expire(Matchers.any(byte[].class), Matchers.anyLong());
Mockito.verifyZeroInteractions(connectionFactoryMock);
}
/**
* @see DATAREDIS-73
*/
@SuppressWarnings("unchecked")
@Test(expected = InvalidDataAccessApiUsageException.class)
public void interceptorShouldNotWrapException() throws Throwable {
Mockito.when(freshConnectionMock.keys(Mockito.any(byte[].class))).thenThrow(
InvalidDataAccessApiUsageException.class);
interceptor.intercept(boundConnectionMock, READONLY_METHOD, new Object[] { new byte[] {} }, null);
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed 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.springframework.data.redis.core;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
/**
* @author Christoph Strobl
* @author Thomas Darimont
*/
public class RedisCommandUnitTests {
public @Rule ExpectedException expectedException = ExpectedException.none();
/**
* @see DATAREDIS-73
*/
@Test
public void shouldIdentifyAliasCorrectly() {
assertThat(RedisCommand.CONFIG_SET.isRepresentedBy("setconfig"), equalTo(true));
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldIdentifyAliasCorrectlyWhenNamePassedInMixedCase() {
assertThat(RedisCommand.CONFIG_SET.isRepresentedBy("SetConfig"), equalTo(true));
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldNotThrowExceptionWhenUsingNullKeyForRepresentationCheck() {
assertThat(RedisCommand.CONFIG_SET.isRepresentedBy(null), equalTo(false));
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldIdentifyAliasCorrectlyViaLookup() {
assertThat(RedisCommand.failsafeCommandLookup("setconfig"), is(RedisCommand.CONFIG_SET));
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldIdentifyAliasCorrectlyWhenNamePassedInMixedCaseViaLookup() {
assertThat(RedisCommand.failsafeCommandLookup("SetConfig"), is(RedisCommand.CONFIG_SET));
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldReturnUnknownCommandForUnknownCommandString() {
assertThat(RedisCommand.failsafeCommandLookup("strangecommand"), is(RedisCommand.UNKNOWN));
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldNotThrowExceptionOnValidArgumentCount() {
RedisCommand.AUTH.validateArgumentCount(1);
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldThrowExceptionOnInvalidArgumentCountWhenExpectedExcatMatch() {
expectedException.expect(IllegalArgumentException.class);
expectedException.expectMessage("AUTH command requires 1 arguments");
RedisCommand.AUTH.validateArgumentCount(2);
}
/**
* @see DATAREDIS-73
*/
@Test
public void shouldThrowExceptionOnInvalidArgumentCountWhenExpectedMinimalMatch() {
expectedException.expect(IllegalArgumentException.class);
expectedException.expectMessage("DEL command requires at least 1 arguments");
RedisCommand.DEL.validateArgumentCount(0);
}
}