diff --git a/docs/src/docs/asciidoc/index.adoc b/docs/src/docs/asciidoc/index.adoc index 25e9efc..2e5f01b 100644 --- a/docs/src/docs/asciidoc/index.adoc +++ b/docs/src/docs/asciidoc/index.adoc @@ -34,6 +34,7 @@ Below are the highlights of what is new in Spring Session 1.1. You can find a co * https://github.com/spring-projects/spring-session/issues/277[#277] - Added link:guides/hazelcast-spring.html[@EnableHazelcastHttpSession] * https://github.com/spring-projects/spring-session/issues/271[#271] - Performance improvements * https://github.com/spring-projects/spring-session/pull/218[#218] - Allow scoping the session in Redis using <> +* https://github.com/spring-projects/spring-session/issues/273[#273] - Allow writing to Redis immediately (instead of lazily) using <> * https://github.com/spring-projects/spring-session/issues/272[#272] - Add `ExpiringSession.setLastAccessedTime(long)` [[samples]] @@ -565,6 +566,8 @@ You can use the following attributes to customize the configuration: * **maxInactiveIntervalInSeconds** - the amount of time before the session will expire in seconds * **redisNamespace** - allows configuring an application specific namespace for the sessions. Redis keys and channel ids will start with the prefix of `spring:session::`. +* **redisFlushMode** - allows specifying when data will be written to Redis. The default is only when `save` is invoked on `SessionRepository`. +A value of `RedisFlushMode.IMMEDIATE` will write to Redis as soon as possible. ===== Custom RedisSerializer diff --git a/spring-session/src/integration-test/java/org/springframework/session/data/redis/flushimmediately/RedisHttpSessionConfig.java b/spring-session/src/integration-test/java/org/springframework/session/data/redis/flushimmediately/RedisHttpSessionConfig.java new file mode 100644 index 0000000..37b1eb0 --- /dev/null +++ b/spring-session/src/integration-test/java/org/springframework/session/data/redis/flushimmediately/RedisHttpSessionConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2016 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.session.data.redis.flushimmediately; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.session.data.redis.RedisFlushMode; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; + +/** + * @author Rob Winch + * + */ +@Configuration +@EnableRedisHttpSession(redisFlushMode = RedisFlushMode.IMMEDIATE) +public class RedisHttpSessionConfig { + @Bean + public JedisConnectionFactory connectionFactory() throws Exception { + JedisConnectionFactory factory = new JedisConnectionFactory(); + factory.setUsePool(false); + return factory; + } +} \ No newline at end of file diff --git a/spring-session/src/integration-test/java/org/springframework/session/data/redis/flushimmediately/RedisOperationsSessionRepositoryFlushImmediatelyITests.java b/spring-session/src/integration-test/java/org/springframework/session/data/redis/flushimmediately/RedisOperationsSessionRepositoryFlushImmediatelyITests.java new file mode 100644 index 0000000..594446e --- /dev/null +++ b/spring-session/src/integration-test/java/org/springframework/session/data/redis/flushimmediately/RedisOperationsSessionRepositoryFlushImmediatelyITests.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2015 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.session.data.redis.flushimmediately; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.session.ExpiringSession; +import org.springframework.session.SessionRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes=RedisHttpSessionConfig.class) +@WebAppConfiguration +public class RedisOperationsSessionRepositoryFlushImmediatelyITests { + + @Autowired + private SessionRepository sessionRepository; + + @Test + public void savesOnCreate() throws InterruptedException { + S created = sessionRepository.createSession(); + + S getSession = sessionRepository.getSession(created.getId()); + + assertThat(getSession).isNotNull(); + } +} \ No newline at end of file diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/RedisFlushMode.java b/spring-session/src/main/java/org/springframework/session/data/redis/RedisFlushMode.java new file mode 100644 index 0000000..089d2e9 --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/data/redis/RedisFlushMode.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2016 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.session.data.redis; + +import org.springframework.session.SessionRepository; + +/** + * Specifies when to write to the backing Redis instance. + * + * @author Rob Winch + * @since 1.1 + */ +public enum RedisFlushMode { + /** + * Only writes to Redis when + * {@link SessionRepository#save(org.springframework.session.Session)} is + * invoked. In a web environment this is typically done as soon as the HTTP + * response is committed. + */ + ON_SAVE, + + /** + * Writes to Redis as soon as possible. For example + * {@link SessionRepository#createSession()} will write the session to + * Redis. Another example is that setting an attribute on the session will + * also write to Redis immediately. + */ + IMMEDIATE +} diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java b/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java index 7f31f37..e1b1b26 100644 --- a/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java +++ b/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java @@ -304,6 +304,8 @@ public class RedisOperationsSessionRepository implements FindByIndexNameSessionR private RedisSerializer defaultSerializer = new JdkSerializationRedisSerializer(); + private RedisFlushMode redisFlushMode = RedisFlushMode.ON_SAVE; + /** * Allows creating an instance and uses a default {@link RedisOperations} for both managing the session and the expirations. * @@ -360,6 +362,14 @@ public class RedisOperationsSessionRepository implements FindByIndexNameSessionR this.defaultSerializer = defaultSerializer; } + /** + * @param redisFlushMode + */ + public void setRedisFlushMode(RedisFlushMode redisFlushMode) { + Assert.notNull(redisFlushMode, "redisFlushMode cannot be null"); + this.redisFlushMode = redisFlushMode; + } + public void save(RedisSession session) { session.saveDelta(); if(session.isNew()) { @@ -636,6 +646,7 @@ public class RedisOperationsSessionRepository implements FindByIndexNameSessionR delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds()); delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime()); this.isNew = true; + flushImmediateIfNecessary(); } /** @@ -656,6 +667,7 @@ public class RedisOperationsSessionRepository implements FindByIndexNameSessionR public void setLastAccessedTime(long lastAccessedTime) { cached.setLastAccessedTime(lastAccessedTime); delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime()); + flushImmediateIfNecessary(); } public boolean isExpired() { @@ -681,6 +693,7 @@ public class RedisOperationsSessionRepository implements FindByIndexNameSessionR public void setMaxInactiveIntervalInSeconds(int interval) { cached.setMaxInactiveIntervalInSeconds(interval); delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds()); + flushImmediateIfNecessary(); } public int getMaxInactiveIntervalInSeconds() { @@ -699,11 +712,19 @@ public class RedisOperationsSessionRepository implements FindByIndexNameSessionR public void setAttribute(String attributeName, Object attributeValue) { cached.setAttribute(attributeName, attributeValue); delta.put(getSessionAttrNameKey(attributeName), attributeValue); + flushImmediateIfNecessary(); } public void removeAttribute(String attributeName) { cached.removeAttribute(attributeName); delta.put(getSessionAttrNameKey(attributeName), null); + flushImmediateIfNecessary(); + } + + private void flushImmediateIfNecessary() { + if(redisFlushMode == RedisFlushMode.IMMEDIATE) { + saveDelta(); + } } /** diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/EnableRedisHttpSession.java b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/EnableRedisHttpSession.java index 72e3548..1f89b2f 100644 --- a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/EnableRedisHttpSession.java +++ b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/EnableRedisHttpSession.java @@ -22,7 +22,9 @@ import java.lang.annotation.Target; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.session.SessionRepository; import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; +import org.springframework.session.data.redis.RedisFlushMode; /** * Add this annotation to an {@code @Configuration} class to expose the @@ -76,4 +78,22 @@ public @interface EnableRedisHttpSession { * @return the unique namespace for keys */ String redisNamespace() default ""; + + /** + *

+ * Sets the flush mode for the Redis sessions. The default is IMMEDIATE + * which only updates the backing Redis when + * {@link SessionRepository#save(org.springframework.session.Session)} is + * invoked. In a web environment this happens just before the HTTP resposne + * is committed. + *

+ *

+ * Setting the value to IMMEDIATE will ensure that the any updates to the + * Session are immediately written to the Redis instance. + *

+ * + * @return the {@link RedisFlushMode} to use + * @since 1.1 + */ + RedisFlushMode redisFlushMode() default RedisFlushMode.ON_SAVE; } \ No newline at end of file diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java index 6b53eb0..a73f6f5 100644 --- a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java +++ b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java @@ -37,10 +37,12 @@ import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration; +import org.springframework.session.data.redis.RedisFlushMode; import org.springframework.session.data.redis.RedisOperationsSessionRepository; import org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction; import org.springframework.session.data.redis.config.ConfigureRedisAction; import org.springframework.session.web.http.SessionRepositoryFilter; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** @@ -63,6 +65,8 @@ public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguratio private String redisNamespace = ""; + private RedisFlushMode redisFlushMode = RedisFlushMode.ON_SAVE; + private RedisSerializer defaultRedisSerializer; @Bean @@ -102,6 +106,8 @@ public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguratio if(StringUtils.hasText(redisNamespace)) { sessionRepository.setRedisKeyNamespace(redisNamespace); } + + sessionRepository.setRedisFlushMode(redisFlushMode); return sessionRepository; } @@ -113,6 +119,11 @@ public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguratio this.redisNamespace = namespace; } + public void setRedisFlushMode(RedisFlushMode redisFlushMode) { + Assert.notNull(redisFlushMode, "redisFlushMode cannot be null"); + this.redisFlushMode = redisFlushMode; + } + private String getRedisNamespace() { if(StringUtils.hasText(this.redisNamespace)) { return this.redisNamespace; @@ -126,6 +137,7 @@ public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguratio AnnotationAttributes enableAttrs = AnnotationAttributes.fromMap(enableAttrMap); maxInactiveIntervalInSeconds = enableAttrs.getNumber("maxInactiveIntervalInSeconds"); this.redisNamespace = enableAttrs.getString("redisNamespace"); + this.redisFlushMode = enableAttrs.getEnum("redisFlushMode"); } @Bean diff --git a/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java b/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java index d8edb35..2a2df36 100644 --- a/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java +++ b/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java @@ -15,12 +15,16 @@ */ package org.springframework.session.data.redis; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyLong; import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; import static org.springframework.session.data.redis.RedisOperationsSessionRepository.CREATION_TIME_ATTR; import static org.springframework.session.data.redis.RedisOperationsSessionRepository.LAST_ACCESSED_ATTR; @@ -466,6 +470,132 @@ public class RedisOperationsSessionRepositoryTests { assertThat(resolver.resolvePrincipal(session)).isEqualTo(principal); } + @Test + public void flushModeOnSaveCreate() { + redisRepository.createSession(); + + verifyZeroInteractions(boundHashOperations); + } + + @Test + public void flushModeOnSaveSetAttribute() { + RedisSession session = redisRepository.createSession(); + session.setAttribute("something", "here"); + + verifyZeroInteractions(boundHashOperations); + } + + @Test + public void flushModeOnSaveRemoveAttribute() { + RedisSession session = redisRepository.createSession(); + session.removeAttribute("remove"); + + verifyZeroInteractions(boundHashOperations); + } + + @Test + public void flushModeOnSaveSetLastAccessedTime() { + RedisSession session = redisRepository.createSession(); + session.setLastAccessedTime(1L); + + verifyZeroInteractions(boundHashOperations); + } + + @Test + public void flushModeOnSaveSetMaxInactiveIntervalInSeconds() { + RedisSession session = redisRepository.createSession(); + session.setMaxInactiveIntervalInSeconds(1); + + verifyZeroInteractions(boundHashOperations); + } + + @Test + public void flushModeImmediateCreate() { + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); + + redisRepository.setRedisFlushMode(RedisFlushMode.IMMEDIATE); + RedisSession session = redisRepository.createSession(); + + Map delta = getDelta(); + assertThat(delta.size()).isEqualTo(3); + Object creationTime = delta.get(CREATION_TIME_ATTR); + assertThat(creationTime).isEqualTo(session.getCreationTime()); + assertThat(delta.get(MAX_INACTIVE_ATTR)).isEqualTo(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); + assertThat(delta.get(LAST_ACCESSED_ATTR)).isEqualTo(session.getCreationTime()); + } + + @Test + public void flushModeImmediateSetAttribute() { + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); + + redisRepository.setRedisFlushMode(RedisFlushMode.IMMEDIATE); + RedisSession session = redisRepository.createSession(); + String attrName = "someAttribute"; + session.setAttribute(attrName, "someValue"); + + Map delta = getDelta(2); + assertThat(delta.size()).isEqualTo(1); + assertThat(delta).isEqualTo(map(getSessionAttrNameKey(attrName), session.getAttribute(attrName))); + } + + @Test + public void flushModeImmediateRemoveAttribute() { + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); + + redisRepository.setRedisFlushMode(RedisFlushMode.IMMEDIATE); + RedisSession session = redisRepository.createSession(); + String attrName = "someAttribute"; + session.removeAttribute(attrName); + + Map delta = getDelta(2); + assertThat(delta.size()).isEqualTo(1); + assertThat(delta).isEqualTo(map(getSessionAttrNameKey(attrName), session.getAttribute(attrName))); + } + + @Test + public void flushModeSetMaxInactiveIntervalInSeconds() { + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); + + redisRepository.setRedisFlushMode(RedisFlushMode.IMMEDIATE); + RedisSession session = redisRepository.createSession(); + + reset(boundHashOperations); + + session.setMaxInactiveIntervalInSeconds(1); + + verify(boundHashOperations).expire(anyLong(), any(TimeUnit.class)); + } + + @Test + public void flushModeSetLastAccessedTime() { + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); + + redisRepository.setRedisFlushMode(RedisFlushMode.IMMEDIATE); + RedisSession session = redisRepository.createSession(); + + long now = System.currentTimeMillis(); + session.setLastAccessedTime(now); + + Map delta = getDelta(2); + assertThat(delta.size()).isEqualTo(1); + assertThat(delta).isEqualTo(map(LAST_ACCESSED_ATTR, session.getLastAccessedTime())); + } + + @Test(expected = IllegalArgumentException.class) + public void setRedisFlushModeNull() { + redisRepository.setRedisFlushMode(null); + } + private String getKey(String id) { return "spring:session:sessions:" + id; } @@ -482,7 +612,11 @@ public class RedisOperationsSessionRepositoryTests { } private Map getDelta() { - verify(boundHashOperations).putAll(delta.capture()); - return delta.getValue(); + return getDelta(1); + } + + private Map getDelta(int times) { + verify(boundHashOperations,times(times)).putAll(delta.capture()); + return delta.getAllValues().get(times - 1); } } \ No newline at end of file